From 4b2b9fe2cee204e3eda0871764f2d4e090554aa8 Mon Sep 17 00:00:00 2001 From: Marvin Steadfast Date: Thu, 5 Nov 2015 09:46:01 +0100 Subject: [PATCH] Added embyupdate plugin Its a simple plugin that triggers a library refresh after the library got changed. It does the same thing like the plexupdate plugin. --- beetsplug/embyupdate.py | 133 ++++++++++++++++++++++ docs/changelog.rst | 5 +- docs/plugins/embyupdate.rst | 33 ++++++ docs/plugins/index.rst | 3 + test/test_embyupdate.py | 212 ++++++++++++++++++++++++++++++++++++ 5 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 beetsplug/embyupdate.py create mode 100644 docs/plugins/embyupdate.rst create mode 100644 test/test_embyupdate.py diff --git a/beetsplug/embyupdate.py b/beetsplug/embyupdate.py new file mode 100644 index 000000000..ffca7c30c --- /dev/null +++ b/beetsplug/embyupdate.py @@ -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.') diff --git a/docs/changelog.rst b/docs/changelog.rst index d86137b0e..27567f283 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,7 +18,8 @@ New: various-artists albums. The setting defaults to "Various Artists," the MusicBrainz standard. In order to match MusicBrainz, the :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: @@ -56,6 +57,8 @@ Fixes: * :doc:`/plugins/smartplaylist`: More gracefully handle malformed queries and missing configuration. +.. _Emby Server: http://emby.media + 1.3.15 (October 17, 2015) ------------------------- diff --git a/docs/plugins/embyupdate.rst b/docs/plugins/embyupdate.rst new file mode 100644 index 000000000..2a47826ae --- /dev/null +++ b/docs/plugins/embyupdate.rst @@ -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. + Defailt: 8096 +- **username**: A username of a Emby user that is allowed to refresh the library. +- **password**: That users password. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index a45e0be5f..d09139837 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -41,6 +41,7 @@ Each plugin has its own set of options that can be defined in a section bearing duplicates echonest embedart + embyupdate fetchart fromfilename ftintitle @@ -131,6 +132,7 @@ Path Formats 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:`ipfs`: Import libraries from friends and get albums from them via ipfs. * :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library @@ -143,6 +145,7 @@ Interoperability * :doc:`badfiles`: Check audio file integrity. +.. _Emby: http://emby.media .. _Plex: http://plex.tv Miscellaneous diff --git a/test/test_embyupdate.py b/test/test_embyupdate.py new file mode 100644 index 000000000..d21c19987 --- /dev/null +++ b/test/test_embyupdate.py @@ -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')