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.
This commit is contained in:
Marvin Steadfast 2015-11-05 09:46:01 +01:00
parent 48637f22e9
commit 4b2b9fe2ce
5 changed files with 385 additions and 1 deletions

133
beetsplug/embyupdate.py Normal file
View 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.')

View file

@ -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)
-------------------------

View 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.
Defailt: 8096
- **username**: A username of a Emby user that is allowed to refresh the library.
- **password**: That users password.

View file

@ -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

212
test/test_embyupdate.py Normal file
View 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')