mirror of
https://github.com/beetbox/beets.git
synced 2025-12-24 01:25:47 +01:00
Merge pull request #1714 from xsteadfastx/master
Added embyupdate plugin
This commit is contained in:
commit
2048aa660b
5 changed files with 385 additions and 1 deletions
133
beetsplug/embyupdate.py
Normal file
133
beetsplug/embyupdate.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""Updates the Emby Library whenever the beets library is changed.
|
||||||
|
|
||||||
|
emby:
|
||||||
|
host: localhost
|
||||||
|
port: 8096
|
||||||
|
username: user
|
||||||
|
password: password
|
||||||
|
"""
|
||||||
|
from __future__ import (division, absolute_import, print_function,
|
||||||
|
unicode_literals)
|
||||||
|
|
||||||
|
from beets import config
|
||||||
|
from beets.plugins import BeetsPlugin
|
||||||
|
from urllib import urlencode
|
||||||
|
from urlparse import urljoin, parse_qs, urlsplit, urlunsplit
|
||||||
|
import hashlib
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def api_url(host, port, endpoint):
|
||||||
|
"""Returns a joined url.
|
||||||
|
"""
|
||||||
|
joined = urljoin('http://{0}:{1}'.format(host, port), endpoint)
|
||||||
|
scheme, netloc, path, query_string, fragment = urlsplit(joined)
|
||||||
|
query_params = parse_qs(query_string)
|
||||||
|
|
||||||
|
query_params['format'] = ['json']
|
||||||
|
new_query_string = urlencode(query_params, doseq=True)
|
||||||
|
|
||||||
|
return urlunsplit((scheme, netloc, path, new_query_string, fragment))
|
||||||
|
|
||||||
|
|
||||||
|
def password_data(username, password):
|
||||||
|
"""Returns a dict with username and its encoded password.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'username': username,
|
||||||
|
'password': hashlib.sha1(password).hexdigest(),
|
||||||
|
'passwordMd5': hashlib.md5(password).hexdigest()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_headers(user_id, token=None):
|
||||||
|
"""Return header dict that is needed to talk to the Emby API.
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
'Authorization': 'MediaBrowser',
|
||||||
|
'UserId': user_id,
|
||||||
|
'Client': 'other',
|
||||||
|
'Device': 'empy',
|
||||||
|
'DeviceId': 'beets',
|
||||||
|
'Version': '0.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
if token:
|
||||||
|
headers['X-MediaBrowser-Token'] = token
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def get_token(host, port, headers, auth_data):
|
||||||
|
"""Return token for a user.
|
||||||
|
"""
|
||||||
|
url = api_url(host, port, '/Users/AuthenticateByName')
|
||||||
|
r = requests.post(url, headers=headers, data=auth_data)
|
||||||
|
|
||||||
|
return r.json().get('AccessToken')
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(host, port, username):
|
||||||
|
"""Return user dict from server or None if there is no user.
|
||||||
|
"""
|
||||||
|
url = api_url(host, port, '/Users/Public')
|
||||||
|
r = requests.get(url)
|
||||||
|
user = [i for i in r.json() if i['Name'] == username]
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class EmbyUpdate(BeetsPlugin):
|
||||||
|
def __init__(self):
|
||||||
|
super(EmbyUpdate, self).__init__()
|
||||||
|
|
||||||
|
# Adding defaults.
|
||||||
|
config['emby'].add({
|
||||||
|
u'host': u'localhost',
|
||||||
|
u'port': 8096
|
||||||
|
})
|
||||||
|
|
||||||
|
self.register_listener('database_change', self.listen_for_db_change)
|
||||||
|
|
||||||
|
def listen_for_db_change(self, lib, model):
|
||||||
|
"""Listens for beets db change and register the update for the end.
|
||||||
|
"""
|
||||||
|
self.register_listener('cli_exit', self.update)
|
||||||
|
|
||||||
|
def update(self, lib):
|
||||||
|
"""When the client exists try to send refresh request to Emby.
|
||||||
|
"""
|
||||||
|
self._log.info(u'Updating Emby library...')
|
||||||
|
|
||||||
|
host = config['emby']['host'].get()
|
||||||
|
port = config['emby']['port'].get()
|
||||||
|
username = config['emby']['username'].get()
|
||||||
|
password = config['emby']['password'].get()
|
||||||
|
|
||||||
|
# Get user information from the Emby API.
|
||||||
|
user = get_user(host, port, username)
|
||||||
|
if not user:
|
||||||
|
self._log.warning(u'User {0} could not be found.'.format(username))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create Authentication data and headers.
|
||||||
|
auth_data = password_data(username, password)
|
||||||
|
headers = create_headers(user[0]['Id'])
|
||||||
|
|
||||||
|
# Get authentication token.
|
||||||
|
token = get_token(host, port, headers, auth_data)
|
||||||
|
if not token:
|
||||||
|
self._log.warning(
|
||||||
|
u'Couldnt not get token for user {0}'.format(username))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Recreate headers with a token.
|
||||||
|
headers = create_headers(user[0]['Id'], token=token)
|
||||||
|
|
||||||
|
# Trigger the Update.
|
||||||
|
url = api_url(host, port, '/Library/Refresh')
|
||||||
|
r = requests.post(url, headers=headers)
|
||||||
|
if r.status_code != 204:
|
||||||
|
self._log.warning(u'Update could not be triggered')
|
||||||
|
else:
|
||||||
|
self._log.info(u'Update triggered.')
|
||||||
|
|
@ -18,7 +18,8 @@ New:
|
||||||
various-artists albums. The setting defaults to "Various Artists," the
|
various-artists albums. The setting defaults to "Various Artists," the
|
||||||
MusicBrainz standard. In order to match MusicBrainz, the
|
MusicBrainz standard. In order to match MusicBrainz, the
|
||||||
:doc:`/plugins/discogs` also adopts the same setting.
|
:doc:`/plugins/discogs` also adopts the same setting.
|
||||||
|
* :doc:`/plugins/embyupdate`: A plugin to trigger a library refresh on a
|
||||||
|
`Emby Server`_ if database changed.
|
||||||
|
|
||||||
For developers:
|
For developers:
|
||||||
|
|
||||||
|
|
@ -56,6 +57,8 @@ Fixes:
|
||||||
* :doc:`/plugins/smartplaylist`: More gracefully handle malformed queries and
|
* :doc:`/plugins/smartplaylist`: More gracefully handle malformed queries and
|
||||||
missing configuration.
|
missing configuration.
|
||||||
|
|
||||||
|
.. _Emby Server: http://emby.media
|
||||||
|
|
||||||
|
|
||||||
1.3.15 (October 17, 2015)
|
1.3.15 (October 17, 2015)
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
|
||||||
33
docs/plugins/embyupdate.rst
Normal file
33
docs/plugins/embyupdate.rst
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
EmbyUpdate Plugin
|
||||||
|
=================
|
||||||
|
|
||||||
|
``embyupdate`` is a plugin that lets you automatically update `Emby`_'s library whenever you change your beets library.
|
||||||
|
|
||||||
|
To use ``embyupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll probably want to configure the specifics of your Emby server. You can do that using an ``emby:`` section in your ``config.yaml``, which looks like this::
|
||||||
|
|
||||||
|
emby:
|
||||||
|
host: localhost
|
||||||
|
port: 8096
|
||||||
|
username: user
|
||||||
|
password: password
|
||||||
|
|
||||||
|
To use the ``embyupdate`` plugin you need to install the `requests`_ library with::
|
||||||
|
|
||||||
|
pip install requests
|
||||||
|
|
||||||
|
With that all in place, you'll see beets send the "update" command to your Emby server every time you change your beets library.
|
||||||
|
|
||||||
|
.. _Emby: http://emby.media/
|
||||||
|
.. _requests: http://docs.python-requests.org/en/latest/
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
-------------
|
||||||
|
|
||||||
|
The available options under the ``emby:`` section are:
|
||||||
|
|
||||||
|
- **host**: The Emby server name.
|
||||||
|
Default: ``localhost``
|
||||||
|
- **port**: The Emby server port.
|
||||||
|
Default: 8096
|
||||||
|
- **username**: A username of a Emby user that is allowed to refresh the library.
|
||||||
|
- **password**: That user's password.
|
||||||
|
|
@ -41,6 +41,7 @@ Each plugin has its own set of options that can be defined in a section bearing
|
||||||
duplicates
|
duplicates
|
||||||
echonest
|
echonest
|
||||||
embedart
|
embedart
|
||||||
|
embyupdate
|
||||||
fetchart
|
fetchart
|
||||||
fromfilename
|
fromfilename
|
||||||
ftintitle
|
ftintitle
|
||||||
|
|
@ -131,6 +132,7 @@ Path Formats
|
||||||
Interoperability
|
Interoperability
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
|
* :doc:`embyupdate`: Automatically notifies `Emby`_ whenever the beets library changes.
|
||||||
* :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks.
|
* :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks.
|
||||||
* :doc:`ipfs`: Import libraries from friends and get albums from them via ipfs.
|
* :doc:`ipfs`: Import libraries from friends and get albums from them via ipfs.
|
||||||
* :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library
|
* :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library
|
||||||
|
|
@ -143,6 +145,7 @@ Interoperability
|
||||||
* :doc:`badfiles`: Check audio file integrity.
|
* :doc:`badfiles`: Check audio file integrity.
|
||||||
|
|
||||||
|
|
||||||
|
.. _Emby: http://emby.media
|
||||||
.. _Plex: http://plex.tv
|
.. _Plex: http://plex.tv
|
||||||
|
|
||||||
Miscellaneous
|
Miscellaneous
|
||||||
|
|
|
||||||
212
test/test_embyupdate.py
Normal file
212
test/test_embyupdate.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
from __future__ import (division, absolute_import, print_function,
|
||||||
|
unicode_literals)
|
||||||
|
|
||||||
|
from test._common import unittest
|
||||||
|
from test.helper import TestHelper
|
||||||
|
from beetsplug import embyupdate
|
||||||
|
import responses
|
||||||
|
|
||||||
|
|
||||||
|
class EmbyUpdateTest(unittest.TestCase, TestHelper):
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_beets()
|
||||||
|
self.load_plugins('embyupdate')
|
||||||
|
|
||||||
|
self.config['emby'] = {
|
||||||
|
u'host': u'localhost',
|
||||||
|
u'port': 8096,
|
||||||
|
u'username': u'username',
|
||||||
|
u'password': u'password'
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.teardown_beets()
|
||||||
|
self.unload_plugins()
|
||||||
|
|
||||||
|
def test_api_url(self):
|
||||||
|
self.assertEqual(
|
||||||
|
embyupdate.api_url(self.config['emby']['host'].get(),
|
||||||
|
self.config['emby']['port'].get(),
|
||||||
|
'/Library/Refresh'),
|
||||||
|
'http://localhost:8096/Library/Refresh?format=json'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_password_data(self):
|
||||||
|
self.assertEqual(
|
||||||
|
embyupdate.password_data(self.config['emby']['username'].get(),
|
||||||
|
self.config['emby']['password'].get()),
|
||||||
|
{
|
||||||
|
'username': 'username',
|
||||||
|
'password': '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8',
|
||||||
|
'passwordMd5': '5f4dcc3b5aa765d61d8327deb882cf99'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_header_no_token(self):
|
||||||
|
self.assertEqual(
|
||||||
|
embyupdate.create_headers('e8837bc1-ad67-520e-8cd2-f629e3155721'),
|
||||||
|
{
|
||||||
|
'Authorization': 'MediaBrowser',
|
||||||
|
'UserId': 'e8837bc1-ad67-520e-8cd2-f629e3155721',
|
||||||
|
'Client': 'other',
|
||||||
|
'Device': 'empy',
|
||||||
|
'DeviceId': 'beets',
|
||||||
|
'Version': '0.0.0'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_header_with_token(self):
|
||||||
|
self.assertEqual(
|
||||||
|
embyupdate.create_headers('e8837bc1-ad67-520e-8cd2-f629e3155721',
|
||||||
|
token='abc123'),
|
||||||
|
{
|
||||||
|
'Authorization': 'MediaBrowser',
|
||||||
|
'UserId': 'e8837bc1-ad67-520e-8cd2-f629e3155721',
|
||||||
|
'Client': 'other',
|
||||||
|
'Device': 'empy',
|
||||||
|
'DeviceId': 'beets',
|
||||||
|
'Version': '0.0.0',
|
||||||
|
'X-MediaBrowser-Token': 'abc123'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_get_token(self):
|
||||||
|
body = ('{"User":{"Name":"username", '
|
||||||
|
'"ServerId":"1efa5077976bfa92bc71652404f646ec",'
|
||||||
|
'"Id":"2ec276a2642e54a19b612b9418a8bd3b","HasPassword":true,'
|
||||||
|
'"HasConfiguredPassword":true,'
|
||||||
|
'"HasConfiguredEasyPassword":false,'
|
||||||
|
'"LastLoginDate":"2015-11-09T08:35:03.6357440Z",'
|
||||||
|
'"LastActivityDate":"2015-11-09T08:35:03.6665060Z",'
|
||||||
|
'"Configuration":{"AudioLanguagePreference":"",'
|
||||||
|
'"PlayDefaultAudioTrack":true,"SubtitleLanguagePreference":"",'
|
||||||
|
'"DisplayMissingEpisodes":false,'
|
||||||
|
'"DisplayUnairedEpisodes":false,'
|
||||||
|
'"GroupMoviesIntoBoxSets":false,'
|
||||||
|
'"DisplayChannelsWithinViews":[],'
|
||||||
|
'"ExcludeFoldersFromGrouping":[],"GroupedFolders":[],'
|
||||||
|
'"SubtitleMode":"Default","DisplayCollectionsView":true,'
|
||||||
|
'"DisplayFoldersView":false,"EnableLocalPassword":false,'
|
||||||
|
'"OrderedViews":[],"IncludeTrailersInSuggestions":true,'
|
||||||
|
'"EnableCinemaMode":true,"LatestItemsExcludes":[],'
|
||||||
|
'"PlainFolderViews":[],"HidePlayedInLatest":true,'
|
||||||
|
'"DisplayChannelsInline":false},'
|
||||||
|
'"Policy":{"IsAdministrator":true,"IsHidden":false,'
|
||||||
|
'"IsDisabled":false,"BlockedTags":[],'
|
||||||
|
'"EnableUserPreferenceAccess":true,"AccessSchedules":[],'
|
||||||
|
'"BlockUnratedItems":[],'
|
||||||
|
'"EnableRemoteControlOfOtherUsers":false,'
|
||||||
|
'"EnableSharedDeviceControl":true,'
|
||||||
|
'"EnableLiveTvManagement":true,"EnableLiveTvAccess":true,'
|
||||||
|
'"EnableMediaPlayback":true,'
|
||||||
|
'"EnableAudioPlaybackTranscoding":true,'
|
||||||
|
'"EnableVideoPlaybackTranscoding":true,'
|
||||||
|
'"EnableContentDeletion":false,'
|
||||||
|
'"EnableContentDownloading":true,"EnableSync":true,'
|
||||||
|
'"EnableSyncTranscoding":true,"EnabledDevices":[],'
|
||||||
|
'"EnableAllDevices":true,"EnabledChannels":[],'
|
||||||
|
'"EnableAllChannels":true,"EnabledFolders":[],'
|
||||||
|
'"EnableAllFolders":true,"InvalidLoginAttemptCount":0,'
|
||||||
|
'"EnablePublicSharing":true}},'
|
||||||
|
'"SessionInfo":{"SupportedCommands":[],'
|
||||||
|
'"QueueableMediaTypes":[],"PlayableMediaTypes":[],'
|
||||||
|
'"Id":"89f3b33f8b3a56af22088733ad1d76b3",'
|
||||||
|
'"UserId":"2ec276a2642e54a19b612b9418a8bd3b",'
|
||||||
|
'"UserName":"username","AdditionalUsers":[],'
|
||||||
|
'"ApplicationVersion":"Unknown version",'
|
||||||
|
'"Client":"Unknown app",'
|
||||||
|
'"LastActivityDate":"2015-11-09T08:35:03.6665060Z",'
|
||||||
|
'"DeviceName":"Unknown device","DeviceId":"Unknown device id",'
|
||||||
|
'"SupportsRemoteControl":false,"PlayState":{"CanSeek":false,'
|
||||||
|
'"IsPaused":false,"IsMuted":false,"RepeatMode":"RepeatNone"}},'
|
||||||
|
'"AccessToken":"4b19180cf02748f7b95c7e8e76562fc8",'
|
||||||
|
'"ServerId":"1efa5077976bfa92bc71652404f646ec"}')
|
||||||
|
|
||||||
|
responses.add(responses.POST,
|
||||||
|
('http://localhost:8096'
|
||||||
|
'/Users/AuthenticateByName'),
|
||||||
|
body=body,
|
||||||
|
status=200,
|
||||||
|
content_type='application/json')
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': 'MediaBrowser',
|
||||||
|
'UserId': 'e8837bc1-ad67-520e-8cd2-f629e3155721',
|
||||||
|
'Client': 'other',
|
||||||
|
'Device': 'empy',
|
||||||
|
'DeviceId': 'beets',
|
||||||
|
'Version': '0.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_data = {
|
||||||
|
'username': 'username',
|
||||||
|
'password': '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8',
|
||||||
|
'passwordMd5': '5f4dcc3b5aa765d61d8327deb882cf99'
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
embyupdate.get_token('localhost', 8096, headers, auth_data),
|
||||||
|
'4b19180cf02748f7b95c7e8e76562fc8')
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_get_user(self):
|
||||||
|
body = ('[{"Name":"username",'
|
||||||
|
'"ServerId":"1efa5077976bfa92bc71652404f646ec",'
|
||||||
|
'"Id":"2ec276a2642e54a19b612b9418a8bd3b","HasPassword":true,'
|
||||||
|
'"HasConfiguredPassword":true,'
|
||||||
|
'"HasConfiguredEasyPassword":false,'
|
||||||
|
'"LastLoginDate":"2015-11-09T08:35:03.6357440Z",'
|
||||||
|
'"LastActivityDate":"2015-11-09T08:42:39.3693220Z",'
|
||||||
|
'"Configuration":{"AudioLanguagePreference":"",'
|
||||||
|
'"PlayDefaultAudioTrack":true,"SubtitleLanguagePreference":"",'
|
||||||
|
'"DisplayMissingEpisodes":false,'
|
||||||
|
'"DisplayUnairedEpisodes":false,'
|
||||||
|
'"GroupMoviesIntoBoxSets":false,'
|
||||||
|
'"DisplayChannelsWithinViews":[],'
|
||||||
|
'"ExcludeFoldersFromGrouping":[],"GroupedFolders":[],'
|
||||||
|
'"SubtitleMode":"Default","DisplayCollectionsView":true,'
|
||||||
|
'"DisplayFoldersView":false,"EnableLocalPassword":false,'
|
||||||
|
'"OrderedViews":[],"IncludeTrailersInSuggestions":true,'
|
||||||
|
'"EnableCinemaMode":true,"LatestItemsExcludes":[],'
|
||||||
|
'"PlainFolderViews":[],"HidePlayedInLatest":true,'
|
||||||
|
'"DisplayChannelsInline":false},'
|
||||||
|
'"Policy":{"IsAdministrator":true,"IsHidden":false,'
|
||||||
|
'"IsDisabled":false,"BlockedTags":[],'
|
||||||
|
'"EnableUserPreferenceAccess":true,"AccessSchedules":[],'
|
||||||
|
'"BlockUnratedItems":[],'
|
||||||
|
'"EnableRemoteControlOfOtherUsers":false,'
|
||||||
|
'"EnableSharedDeviceControl":true,'
|
||||||
|
'"EnableLiveTvManagement":true,"EnableLiveTvAccess":true,'
|
||||||
|
'"EnableMediaPlayback":true,'
|
||||||
|
'"EnableAudioPlaybackTranscoding":true,'
|
||||||
|
'"EnableVideoPlaybackTranscoding":true,'
|
||||||
|
'"EnableContentDeletion":false,'
|
||||||
|
'"EnableContentDownloading":true,'
|
||||||
|
'"EnableSync":true,"EnableSyncTranscoding":true,'
|
||||||
|
'"EnabledDevices":[],"EnableAllDevices":true,'
|
||||||
|
'"EnabledChannels":[],"EnableAllChannels":true,'
|
||||||
|
'"EnabledFolders":[],"EnableAllFolders":true,'
|
||||||
|
'"InvalidLoginAttemptCount":0,"EnablePublicSharing":true}}]')
|
||||||
|
|
||||||
|
responses.add(responses.GET,
|
||||||
|
'http://localhost:8096/Users/Public',
|
||||||
|
body=body,
|
||||||
|
status=200,
|
||||||
|
content_type='application/json')
|
||||||
|
|
||||||
|
response = embyupdate.get_user('localhost', 8096, 'username')
|
||||||
|
|
||||||
|
self.assertEqual(response[0]['Id'],
|
||||||
|
'2ec276a2642e54a19b612b9418a8bd3b')
|
||||||
|
|
||||||
|
self.assertEqual(response[0]['Name'],
|
||||||
|
'username')
|
||||||
|
|
||||||
|
|
||||||
|
def suite():
|
||||||
|
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == b'__main__':
|
||||||
|
unittest.main(defaultTest='suite')
|
||||||
Loading…
Reference in a new issue