diff --git a/README.md b/README.md index 6fdf4e29..c2cbdc51 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![stable version](https://img.shields.io/badge/stable_version-2.1.5-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip) -[![beta version](https://img.shields.io/badge/beta_version-2.2.11-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) +[![stable version](https://img.shields.io/badge/stable_version-2.1.6-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip) +[![beta version](https://img.shields.io/badge/beta_version-2.2.12-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) [![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation) [![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq) diff --git a/addon.xml b/addon.xml index 23bbfade..0e7983f6 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -73,7 +73,12 @@ Нативна інтеграція Plex в Kodi Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик! Використовуйте на свій ризик - version 2.2.11 (beta only): + version 2.2.12 (beta only): +- Fix slow sync. Fix endless sync of corrupted PMS elements +- Refactor playlist code +- Fix FutureWarning + +version 2.2.11 (beta only): - Fix OnDeck widget for Direct Paths - Fix Plex Companion crashing when connected to Plex Web - Fix Plex Companion crash when connected to Plex Web playing playlist music diff --git a/changelog.txt b/changelog.txt index 3216abc9..fab04cff 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,8 @@ +version 2.2.12 (beta only): +- Fix slow sync. Fix endless sync of corrupted PMS elements +- Refactor playlist code +- Fix FutureWarning + version 2.2.11 (beta only): - Fix OnDeck widget for Direct Paths - Fix Plex Companion crashing when connected to Plex Web diff --git a/resources/lib/library_sync/process_metadata.py b/resources/lib/library_sync/process_metadata.py index e7930a95..ac65aac6 100644 --- a/resources/lib/library_sync/process_metadata.py +++ b/resources/lib/library_sync/process_metadata.py @@ -69,7 +69,7 @@ class ThreadedProcessMetadata(Thread): continue # Do the work item_method = getattr(item_class, item['method']) - if item.get('children'): + if item.get('children') is not None: item_method(item['xml'][0], viewtag=item['view_name'], viewid=item['view_id'], diff --git a/resources/lib/librarysync.py b/resources/lib/librarysync.py index 64e44764..24b2589f 100644 --- a/resources/lib/librarysync.py +++ b/resources/lib/librarysync.py @@ -1189,8 +1189,8 @@ class LibrarySync(Thread): if typus == 'playlist': if not state.SYNC_PLAYLISTS: continue - playlists.process_websocket(plex_id=unicode(item['itemID']), - status=status) + playlists.websocket(plex_id=unicode(item['itemID']), + status=status) elif status == 9: # Immediately and always process deletions (as the PMS will # send additional message with other codes) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index 876e87f6..fe53a1fa 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # -*- coding: utf-8 -*- """ Collection of functions associated with Kodi and Plex playlists and playqueues @@ -60,47 +61,6 @@ class PlaylistObjectBaseclase(object): return answ + '}}' -class Playlist_Object(PlaylistObjectBaseclase): - """ - To be done for synching Plex playlists to Kodi - """ - kind = 'playList' - - def __init__(self): - self.plex_name = None - self.plex_updatedat = None - self._kodi_path = None - self.kodi_filename = None - self.kodi_extension = None - self.kodi_hash = None - PlaylistObjectBaseclase.__init__(self) - - @property - def kodi_path(self): - return self._kodi_path - - @kodi_path.setter - def kodi_path(self, path): - if not isinstance(path, unicode): - raise RuntimeError('Path is %s, not unicode!' % type(path)) - file = path_ops.path.basename(path) - try: - self.kodi_filename, self.kodi_extension = file.split('.', 1) - except ValueError: - LOG.error('Trying to set invalid path: %s', path) - raise PlaylistError('Invalid path: %s' % path) - if path.startswith(v.PLAYLIST_PATH_VIDEO): - self.type = v.KODI_TYPE_VIDEO_PLAYLIST - elif path.startswith(v.PLAYLIST_PATH_MUSIC): - self.type = v.KODI_TYPE_AUDIO_PLAYLIST - else: - LOG.error('Playlist type not supported for %s', path) - raise PlaylistError('Playlist type not supported: %s' % path) - if not self.plex_name: - self.plex_name = self.kodi_filename - self._kodi_path = path - - class Playqueue_Object(PlaylistObjectBaseclase): """ PKC object to represent PMS playQueues and Kodi playlist for queueing @@ -478,34 +438,6 @@ def update_playlist_from_PMS(playlist, playlist_id=None, xml=None): playlist.items.append(playlist_item) -def init_plex_playlist(playlist, plex_id): - """ - Initializes a new playlist on the PMS side. Will set playlist.id and - playlist.plex_updatedat. Will raise PlaylistError if something went wrong. - """ - LOG.debug('Initializing the playlist with Plex id %s on the Plex side: %s', - plex_id, playlist) - params = { - 'type': v.PLEX_PLAYLIST_TYPE_FROM_KODI[playlist.type], - 'title': playlist.plex_name, - 'smart': 0, - 'uri': ('library://None/item/%s' % (urllib.quote('/library/metadata/%s' - % plex_id, safe=''))) - } - xml = DU().downloadUrl(url='{server}/playlists', - action_type='POST', - parameters=params) - try: - xml[0].attrib - except (TypeError, IndexError, AttributeError): - LOG.error('Could not initialize playlist on Plex side with plex id %s', - plex_id) - raise PlaylistError('Could not initialize Plex playlist %s', plex_id) - api = API(xml[0]) - playlist.id = api.plex_id() - playlist.plex_updatedat = api.updated_at() - - def init_plex_playqueue(playlist, plex_id=None, kodi_item=None): """ Initializes the Plex side without changing the Kodi playlists @@ -601,30 +533,6 @@ def add_item_to_playlist(playlist, pos, kodi_id=None, kodi_type=None, return item -def add_item_to_plex_playlist(playlist, plex_id): - """ - Adds the item with plex_id to the existing Plex playlist (at the end). - Will set playlist.plex_updatedat - Raises PlaylistError if that did not work out. - """ - params = { - 'uri': ('library://None/item/%s' % (urllib.quote('/library/metadata/%s' - % plex_id, safe=''))) - } - xml = DU().downloadUrl(url='{server}/playlists/%s/items' % playlist.id, - action_type='PUT', - parameters=params) - try: - xml[0].attrib - except (TypeError, IndexError, AttributeError): - LOG.error('Could not initialize playlist on Plex side with plex id %s', - plex_id) - raise PlaylistError('Could not item %s to Plex playlist %s', - plex_id, playlist) - api = API(xml[0]) - playlist.plex_updatedat = api.updated_at() - - def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None): """ Adds a new item to the playlist at position pos [int] only on the Plex @@ -731,32 +639,6 @@ def move_playlist_item(playlist, before_pos, after_pos): LOG.debug('Done moving for %s', playlist) -def get_all_playlists(): - """ - Returns an XML with all Plex playlists or None - """ - xml = DU().downloadUrl("{server}/playlists", - headerOptions={'Accept': 'application/xml'}) - try: - xml.attrib - except (AttributeError, TypeError): - LOG.error('Could not download a list of all playlists') - xml = None - return xml - - -def get_pms_playlist_metadata(plex_id): - """ - Returns an xml with the entire metadata like updatedAt. - """ - xml = DU().downloadUrl('{server}/playlists/%s' % plex_id) - try: - xml.attrib - except AttributeError: - xml = None - return xml - - def get_PMS_playlist(playlist, playlist_id=None): """ Fetches the PMS playlist/playqueue as an XML. Pass in playlist_id if we @@ -898,11 +780,3 @@ def get_plextype_from_xml(xml): LOG.error('Could not get plex metadata for plex id %s', plex_id) return return new_xml[0].attrib.get('type') - - -def delete_playlist_from_pms(playlist): - """ - Deletes the playlist from the PMS - """ - DU().downloadUrl("{server}/%ss/%s" % (playlist.kind.lower(), playlist.id), - action_type="DELETE") diff --git a/resources/lib/playlists.py b/resources/lib/playlists.py deleted file mode 100644 index 5be1f82a..00000000 --- a/resources/lib/playlists.py +++ /dev/null @@ -1,674 +0,0 @@ -# -*- coding: utf-8 -*- -from logging import getLogger -import Queue -import xbmc - -from .watchdog import events -from .watchdog.observers import Observer -from .watchdog.utils.bricks import OrderedSetQueue -from . import playlist_func as PL -from .plex_api import API -from . import kodidb_functions as kodidb -from . import plexdb_functions as plexdb -from . import utils -from . import path_ops -from . import variables as v -from . import state - -############################################################################### - -LOG = getLogger('PLEX.playlists') - -# Safety margin for playlist filesystem operations -FILESYSTEM_TIMEOUT = 1 -# These filesystem events are considered similar -SIMILAR_EVENTS = (events.EVENT_TYPE_CREATED, events.EVENT_TYPE_MODIFIED) - -# Which playlist formates are supported by PKC? -SUPPORTED_FILETYPES = ( - 'm3u', - # 'm3u8' - # 'pls', - # 'cue', -) - - -def create_plex_playlist(playlist): - """ - Adds the playlist [Playlist_Object] to the PMS. If playlist.id is - not None the existing Plex playlist will be overwritten; otherwise a new - playlist will be generated and stored accordingly in the playlist object. - Will also add (or modify an existing) Plex playlist table entry. - Make sure that playlist.kodi_hash is set! - Returns None or raises PL.PlaylistError - """ - LOG.debug('Creating Plex playlist from Kodi file: %s', playlist) - plex_ids = _playlist_file_to_plex_ids(playlist) - if not plex_ids: - LOG.info('No Plex ids found for playlist %s', playlist) - raise PL.PlaylistError - for pos, plex_id in enumerate(plex_ids): - try: - if pos == 0 or not playlist.id: - PL.init_plex_playlist(playlist, plex_id) - else: - PL.add_item_to_plex_playlist(playlist, plex_id=plex_id) - except PL.PlaylistError: - continue - update_plex_table(playlist) - LOG.debug('Done creating Plex playlist %s', playlist) - - -def delete_plex_playlist(playlist): - """ - Removes the playlist [Playlist_Object] from the PMS. Will also delete the - entry in the Plex playlist table. - Returns None or raises PL.PlaylistError - """ - if (state.SYNC_SPECIFIC_KODI_PLAYLISTS and - not sync_kodi_playlist(playlist.kodi_path)): - # We might have already synced this playlist BEFORE user chose to only - # sync specific playlists. Let's NOT delete all those playlists. - # However, delete it from our database of synced playlists. - LOG.debug('Not deleting playlist since user chose not to sync: %s', - playlist) - else: - LOG.debug('Deleting playlist from PMS: %s', playlist) - PL.delete_playlist_from_pms(playlist) - update_plex_table(playlist, delete=True) - - -def create_kodi_playlist(plex_id): - """ - Creates a new Kodi playlist file. Will also add (or modify an existing) - Plex playlist table entry. - Assumes that the Plex playlist is indeed new. A NEW Kodi playlist will be - created in any case (not replaced). Thus make sure that the "same" playlist - is deleted from both disk and the Plex database. - Returns the playlist or raises PL.PlaylistError - - Be aware that user settings will be checked whether this Plex playlist - should actually indeed be synced - """ - xml_metadata = PL.get_pms_playlist_metadata(plex_id) - if xml_metadata is None: - LOG.error('Could not get Plex playlist metadata %s', plex_id) - raise PL.PlaylistError('Could not get Plex playlist %s' % plex_id) - api = API(xml_metadata[0]) - if state.SYNC_SPECIFIC_PLEX_PLAYLISTS: - prefix = utils.settings('syncSpecificPlexPlaylistsPrefix').lower() - if api.title() and not api.title().lower().startswith(prefix): - LOG.debug('User chose to not sync Plex playlist %s', api.title()) - return - playlist = PL.Playlist_Object() - playlist.id = api.plex_id() - playlist.type = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()] - if not state.ENABLE_MUSIC and playlist.type == v.KODI_PLAYLIST_TYPE_AUDIO: - return - playlist.plex_name = api.title() - playlist.plex_updatedat = api.updated_at() - LOG.debug('Creating new Kodi playlist from Plex playlist: %s', playlist) - # Derive filename close to Plex playlist name - name = utils.valid_filename(playlist.plex_name) - path = path_ops.path.join(v.PLAYLIST_PATH, playlist.type, '%s.m3u' % name) - while path_ops.exists(path) or playlist_object_from_db(path=path): - # In case the Plex playlist names are not unique - occurance = utils.REGEX_FILE_NUMBERING.search(path) - if not occurance: - path = path_ops.path.join(v.PLAYLIST_PATH, - playlist.type, - '%s_01.m3u' % name[:min(len(name), 248)]) - else: - occurance = int(occurance.group(1)) + 1 - path = path_ops.path.join(v.PLAYLIST_PATH, - playlist.type, - '%s_%02d.m3u' % (name[:min(len(name), - 248)], - occurance)) - LOG.debug('Kodi playlist path: %s', path) - playlist.kodi_path = path - xml_playlist = PL.get_PMS_playlist(playlist, playlist_id=plex_id) - if xml_playlist is None: - LOG.error('Could not get Plex playlist %s', plex_id) - raise PL.PlaylistError('Could not get Plex playlist %s' % plex_id) - _write_playlist_to_file(playlist, xml_playlist) - playlist.kodi_hash = utils.generate_file_md5(path) - update_plex_table(playlist) - LOG.debug('Created Kodi playlist based on Plex playlist: %s', playlist) - - -def delete_kodi_playlist(playlist): - """ - Removes the corresponding Kodi file for playlist [Playlist_Object] from - disk. Be sure that playlist.kodi_path is set. Will also delete the entry in - the Plex playlist table. - Returns None or raises PL.PlaylistError - """ - if not sync_kodi_playlist(playlist.kodi_path): - LOG.debug('Do not delete since we should not sync playlist %s', - playlist) - else: - try: - path_ops.remove(playlist.kodi_path) - except (OSError, IOError) as err: - LOG.error('Could not delete Kodi playlist file %s. Error:\n%s: %s', - playlist, err.errno, err.strerror) - raise PL.PlaylistError('Could not delete %s' % playlist.kodi_path) - update_plex_table(playlist, delete=True) - - -def update_plex_table(playlist, delete=False): - """ - Assumes that all sync operations are over. Takes playlist [Playlist_Object] - and creates/updates the corresponding Plex playlists table entry - - Pass delete=True to delete the playlist entry - """ - if delete: - with plexdb.Get_Plex_DB() as plex_db: - plex_db.delete_playlist_entry(playlist) - return - with plexdb.Get_Plex_DB() as plex_db: - plex_db.insert_playlist_entry(playlist) - - -def _playlist_file_to_plex_ids(playlist): - """ - Takes the playlist file located at path [unicode] and parses it. - Returns a list of plex_ids (str) or raises PL.PlaylistError if a single - item cannot be parsed from Kodi to Plex. - """ - if playlist.kodi_extension == 'm3u': - plex_ids = m3u_to_plex_ids(playlist) - else: - LOG.error('Unknown playlist extension: %s', playlist.kodi_extension) - raise PL.PlaylistError - return plex_ids - - -def _m3u_iterator(text): - """ - Yields e.g. plugin://plugin.video.plexkodiconnect.movies/?plex_id=xxx - """ - lines = iter(text.split('\n')) - for line in lines: - if line.startswith('#EXTINF:'): - yield next(lines).strip() - - -def m3u_to_plex_ids(playlist): - """ - Adapter to process *.m3u playlist files. Encoding is not uniform! - """ - plex_ids = list() - with open(path_ops.encode_path(playlist.kodi_path), 'rb') as f: - text = f.read() - try: - text = text.decode(v.M3U_ENCODING) - except UnicodeDecodeError: - LOG.warning('Fallback to ISO-8859-1 decoding for %s', playlist) - text = text.decode('ISO-8859-1') - for entry in _m3u_iterator(text): - plex_id = utils.REGEX_PLEX_ID.search(entry) - if plex_id: - plex_id = plex_id.group(1) - plex_ids.append(plex_id) - else: - # Add-on paths not working, try direct - kodi_id, kodi_type = kodidb.kodiid_from_filename( - entry, db_type=playlist.type) - if not kodi_id: - continue - with plexdb.Get_Plex_DB() as plex_db: - plex_id = plex_db.getItem_byKodiId(kodi_id, kodi_type) - if plex_id: - plex_ids.append(plex_id[0]) - return plex_ids - - -def _write_playlist_to_file(playlist, xml): - """ - Feed with playlist [Playlist_Object]. Will write the playlist to a m3u file - Returns None or raises PL.PlaylistError - """ - text = '#EXTCPlayListM3U::M3U\n' - for element in xml: - api = API(element) - append_season_episode = False - if api.plex_type() == v.PLEX_TYPE_EPISODE: - _, show, season_id, episode_id = api.episode_data() - try: - season_id = int(season_id) - episode_id = int(episode_id) - except ValueError: - pass - else: - append_season_episode = True - if append_season_episode: - text += ('#EXTINF:%s,%s S%.2dE%.2d - %s\n%s\n' - % (api.runtime(), show, season_id, episode_id, - api.title(), api.path())) - else: - # Only append the TV show name - text += ('#EXTINF:%s,%s - %s\n%s\n' - % (api.runtime(), show, api.title(), api.path())) - else: - text += ('#EXTINF:%s,%s\n%s\n' - % (api.runtime(), api.title(), api.path())) - text += '\n' - text = text.encode(v.M3U_ENCODING, 'strict') - try: - with open(path_ops.encode_path(playlist.kodi_path), 'wb') as f: - f.write(text) - except (OSError, IOError) as err: - LOG.error('Could not write Kodi playlist file: %s', playlist) - LOG.error('Error message %s: %s', err.errno, err.strerror) - raise PL.PlaylistError('Cannot write Kodi playlist to path for %s' - % playlist) - - -def change_plex_playlist_name(playlist, new_name): - """ - TODO - Renames the existing playlist with new_name [unicode] - """ - pass - - -def plex_id_from_playlist_path(path): - """ - Given the Kodi playlist path [unicode], this will return the Plex id [str] - or None - """ - with plexdb.Get_Plex_DB() as plex_db: - plex_id = plex_db.plex_id_from_playlist_path(path) - if not plex_id: - LOG.error('Could not find existing entry for playlist path %s', path) - return plex_id - - -def playlist_object_from_db(path=None, kodi_hash=None, plex_id=None): - """ - Returns the playlist as a Playlist_Object for either the plex_id, path or - kodi_hash. kodi_hash will be more reliable as it includes path and file - content. - """ - playlist = PL.Playlist_Object() - with plexdb.Get_Plex_DB() as plex_db: - playlist = plex_db.retrieve_playlist(playlist, plex_id, path, kodi_hash) - return playlist - - -def _kodi_playlist_identical(xml_element): - """ - Feed with one playlist xml element from the PMS. Will return True if PKC - already synced this playlist, False if not or if the Play playlist has - changed in the meantime - """ - pass - - -def process_websocket(plex_id, status): - """ - Hit by librarysync to process websocket messages concerning playlists - """ - - create = False - with state.LOCK_PLAYLISTS: - playlist = playlist_object_from_db(plex_id=plex_id) - try: - if playlist and status == 9: - LOG.debug('Plex deletion of playlist detected: %s', playlist) - delete_kodi_playlist(playlist) - elif playlist: - xml = PL.get_pms_playlist_metadata(plex_id) - if xml is None: - LOG.error('Could not download playlist %s', plex_id) - return - api = API(xml[0]) - if api.updated_at() == playlist.plex_updatedat: - LOG.debug('Playlist with id %s already synced: %s', - plex_id, playlist) - else: - LOG.debug('Change of Plex playlist detected: %s', - playlist) - delete_kodi_playlist(playlist) - create = True - elif not playlist and not status == 9: - LOG.debug('Creation of new Plex playlist detected: %s', - plex_id) - create = True - # To the actual work - if create: - create_kodi_playlist(plex_id) - except PL.PlaylistError: - pass - - -def full_sync(): - """ - Full sync of playlists between Kodi and Plex. Returns True is successful, - False otherwise - """ - if not state.SYNC_PLAYLISTS: - LOG.debug('Not syncing playlists') - return True - LOG.info('Starting playlist full sync') - with state.LOCK_PLAYLISTS: - return _full_sync() - - -def _full_sync(): - """ - Need to lock because we're messing with playlists - """ - # Get all Plex playlists - xml = PL.get_all_playlists() - if xml is None: - return False - # For each playlist, check Plex database to see whether we already synced - # before. If yes, make sure that hashes are identical. If not, sync it. - with plexdb.Get_Plex_DB() as plex_db: - old_plex_ids = plex_db.plex_ids_all_playlists() - for xml_playlist in xml: - api = API(xml_playlist) - if (not state.ENABLE_MUSIC and - api.playlist_type() == v.PLEX_TYPE_AUDIO_PLAYLIST): - continue - playlist = playlist_object_from_db(plex_id=api.plex_id()) - try: - if not playlist: - LOG.debug('New Plex playlist %s discovered: %s', - api.plex_id(), api.title()) - create_kodi_playlist(api.plex_id()) - continue - elif playlist.plex_updatedat != api.updated_at(): - LOG.debug('Detected changed Plex playlist %s: %s', - api.plex_id(), api.title()) - if path_ops.exists(playlist.kodi_path): - delete_kodi_playlist(playlist) - else: - update_plex_table(playlist, delete=True) - create_kodi_playlist(api.plex_id()) - except PL.PlaylistError: - LOG.info('Skipping playlist %s: %s', api.plex_id(), api.title()) - try: - old_plex_ids.remove(api.plex_id()) - except ValueError: - pass - # Get rid of old Plex playlists that were deleted on the Plex side - for plex_id in old_plex_ids: - playlist = playlist_object_from_db(plex_id=plex_id) - if playlist: - LOG.debug('Removing outdated Plex playlist %s from %s', - playlist.plex_name, playlist.kodi_path) - try: - delete_kodi_playlist(playlist) - except PL.PlaylistError: - pass - # Look at all supported Kodi playlists. Check whether they are in the DB. - with plexdb.Get_Plex_DB() as plex_db: - old_kodi_hashes = plex_db.kodi_hashes_all_playlists() - master_paths = [v.PLAYLIST_PATH_VIDEO] - if state.ENABLE_MUSIC: - master_paths.append(v.PLAYLIST_PATH_MUSIC) - for master_path in master_paths: - for root, _, files in path_ops.walk(master_path): - for file in files: - try: - extension = file.rsplit('.', 1)[1] - except IndexError: - continue - if extension not in SUPPORTED_FILETYPES: - continue - path = path_ops.path.join(root, file) - if not sync_kodi_playlist(path): - continue - kodi_hash = utils.generate_file_md5(path) - playlist = playlist_object_from_db(kodi_hash=kodi_hash) - playlist_2 = playlist_object_from_db(path=path) - if playlist: - # Nothing changed at all - neither path nor content - old_kodi_hashes.remove(kodi_hash) - continue - try: - playlist = PL.Playlist_Object() - playlist.kodi_path = path - playlist.kodi_hash = kodi_hash - if playlist_2: - LOG.debug('Changed Kodi playlist %s detected: %s', - playlist_2.plex_name, path) - playlist.id = playlist_2.id - playlist.plex_name = playlist_2.plex_name - delete_plex_playlist(playlist_2) - create_plex_playlist(playlist) - else: - LOG.debug('New Kodi playlist detected: %s', path) - # Make sure that we delete any playlist with other hash - create_plex_playlist(playlist) - except PL.PlaylistError: - LOG.info('Skipping Kodi playlist %s', path) - for kodi_hash in old_kodi_hashes: - playlist = playlist_object_from_db(kodi_hash=kodi_hash) - if playlist: - try: - delete_plex_playlist(playlist) - except PL.PlaylistError: - pass - LOG.info('Playlist full sync done') - return True - - -def sync_kodi_playlist(path): - """ - Returns True if we should sync this Kodi playlist with path [unicode] to - Plex based on the playlist file name and the user settings, False otherwise - """ - if not state.SYNC_SPECIFIC_KODI_PLAYLISTS: - return True - playlist = PL.Playlist_Object() - try: - playlist.kodi_path = path - except PL.PlaylistError: - pass - else: - prefix = utils.settings('syncSpecificKodiPlaylistsPrefix').lower() - if playlist.kodi_filename.lower().startswith(prefix): - return True - return False - - -class PlaylistEventhandler(events.FileSystemEventHandler): - """ - PKC eventhandler to monitor Kodi playlists safed to disk - """ - def dispatch(self, event): - """ - Dispatches events to the appropriate methods. - - :param event: - The event object representing the file system event. - :type event: - :class:`FileSystemEvent` - """ - if not state.SYNC_PLAYLISTS: - # Sync is deactivated - return - try: - _, extension = event.src_path.rsplit('.', 1) - except ValueError: - return - if extension.lower() not in SUPPORTED_FILETYPES: - return - if event.src_path.startswith(v.PLAYLIST_PATH_MIXED): - return - if (not state.ENABLE_MUSIC and - event.src_path.startswith(v.PLAYLIST_PATH_MUSIC)): - return - path = event.dest_path if event.event_type == events.EVENT_TYPE_MOVED \ - else event.src_path - if not sync_kodi_playlist(path): - return - _method_map = { - events.EVENT_TYPE_MODIFIED: self.on_modified, - events.EVENT_TYPE_MOVED: self.on_moved, - events.EVENT_TYPE_CREATED: self.on_created, - events.EVENT_TYPE_DELETED: self.on_deleted, - } - with state.LOCK_PLAYLISTS: - _method_map[event.event_type](event) - - def on_created(self, event): - LOG.debug('on_created: %s', event.src_path) - old_playlist = playlist_object_from_db(path=event.src_path) - if (old_playlist and old_playlist.kodi_hash == - utils.generate_file_md5(event.src_path)): - LOG.debug('Playlist already in DB - skipping') - return - elif old_playlist: - LOG.debug('Playlist already in DB but it has been changed') - self.on_modified(event) - return - playlist = PL.Playlist_Object() - playlist.kodi_path = event.src_path - playlist.kodi_hash = utils.generate_file_md5(event.src_path) - try: - create_plex_playlist(playlist) - except PL.PlaylistError: - pass - - def on_modified(self, event): - LOG.debug('on_modified: %s', event.src_path) - old_playlist = playlist_object_from_db(path=event.src_path) - new_playlist = PL.Playlist_Object() - if old_playlist: - # Retain the name! Might've come from Plex - # (rename should fire on_moved) - new_playlist.plex_name = old_playlist.plex_name - new_playlist.kodi_path = event.src_path - new_playlist.kodi_hash = utils.generate_file_md5(event.src_path) - try: - if not old_playlist: - LOG.debug('Old playlist not found, creating a new one') - try: - create_plex_playlist(new_playlist) - except PL.PlaylistError: - pass - elif old_playlist.kodi_hash == new_playlist.kodi_hash: - LOG.debug('Old and new playlist are identical - nothing to do') - else: - delete_plex_playlist(old_playlist) - create_plex_playlist(new_playlist) - except PL.PlaylistError: - pass - - def on_moved(self, event): - LOG.debug('on_moved: %s to %s', event.src_path, event.dest_path) - old_playlist = playlist_object_from_db(path=event.src_path) - if not old_playlist: - LOG.error('Did not have source path in the DB %s', event.src_path) - else: - delete_plex_playlist(old_playlist) - new_playlist = PL.Playlist_Object() - new_playlist.kodi_path = event.dest_path - new_playlist.kodi_hash = utils.generate_file_md5(event.dest_path) - try: - create_plex_playlist(new_playlist) - except PL.PlaylistError: - pass - - def on_deleted(self, event): - LOG.debug('on_deleted: %s', event.src_path) - playlist = playlist_object_from_db(path=event.src_path) - if not playlist: - LOG.error('Playlist not found in DB for path %s', event.src_path) - else: - delete_plex_playlist(playlist) - - -class PlaylistQueue(OrderedSetQueue): - """ - OrderedSetQueue that drops all directory events immediately - """ - def _put(self, item): - if item[0].is_directory: - self.unfinished_tasks -= 1 - else: - # Can't use super as OrderedSetQueue is old style class - OrderedSetQueue._put(self, item) - - -class PlaylistObserver(Observer): - """ - PKC implementation, overriding the dispatcher. PKC will wait for the - duration timeout (in seconds) AFTER receiving a filesystem event. A new - ("non-similar") event will reset the timer. - Creating and modifying will be regarded as equal. - """ - def __init__(self, *args, **kwargs): - super(PlaylistObserver, self).__init__(*args, **kwargs) - # Drop the same events that get into the queue even if there are other - # events in between these similar events. Ignore directory events - # completely - self._event_queue = PlaylistQueue() - - @staticmethod - def _pkc_similar_events(event1, event2): - if event1 == event2: - return True - elif (event1.src_path == event2.src_path and - event1.event_type in SIMILAR_EVENTS and - event2.event_type in SIMILAR_EVENTS): - # Set created and modified events to equal - return True - return False - - def _dispatch_iterator(self, event_queue, timeout): - """ - This iterator will block for timeout (seconds) until an event is - received or raise Queue.Empty. - """ - event, watch = event_queue.get(block=True, timeout=timeout) - event_queue.task_done() - start = utils.unix_timestamp() - while utils.unix_timestamp() - start < timeout: - if state.STOP_PKC: - raise Queue.Empty - try: - new_event, new_watch = event_queue.get(block=False) - except Queue.Empty: - xbmc.sleep(200) - else: - event_queue.task_done() - start = utils.unix_timestamp() - if self._pkc_similar_events(new_event, event): - continue - else: - yield event, watch - event, watch = new_event, new_watch - yield event, watch - - def dispatch_events(self, event_queue, timeout): - for event, watch in self._dispatch_iterator(event_queue, timeout): - # This is copy-paste of original code - with self._lock: - # To allow unschedule/stop and safe removal of event handlers - # within event handlers itself, check if the handler is still - # registered after every dispatch. - for handler in list(self._handlers.get(watch, [])): - if handler in self._handlers.get(watch, []): - handler.dispatch(event) - - -def kodi_playlist_monitor(): - """ - Monitors the Kodi playlist folder special://profile/playlist for the user. - Will thus catch all changes on the Kodi side of things. - - Returns an watchdog Observer instance. Be sure to use - observer.stop() (and maybe observer.join()) to shut down properly - """ - event_handler = PlaylistEventhandler() - observer = PlaylistObserver(timeout=FILESYSTEM_TIMEOUT) - observer.schedule(event_handler, v.PLAYLIST_PATH, recursive=True) - observer.start() - return observer diff --git a/resources/lib/playlists/__init__.py b/resources/lib/playlists/__init__.py new file mode 100644 index 00000000..7de68e86 --- /dev/null +++ b/resources/lib/playlists/__init__.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Syncs Plex playlists <=> Kodi playlists with 3 main components: + +kodi_playlist_monitor() +watchdog Observer checking whether Kodi playlists are changed + +websocket(plex_id, status) +Hit with websocket answers from our background sync + +full_sync() +Triggers a full re-sync of playlists + +PlaylistError is thrown if anything wierd happens +""" +from logging import getLogger + +from .common import Playlist, PlaylistError, PlaylistObserver +from . import pms, db, kodi_pl, plex_pl + +from ..watchdog import events +from ..plex_api import API +from .. import utils +from .. import path_ops +from .. import variables as v +from .. import state + +############################################################################### +LOG = getLogger('PLEX.playlists') + +# Safety margin for playlist filesystem operations +FILESYSTEM_TIMEOUT = 1 + +# Which playlist formates are supported by PKC? +SUPPORTED_FILETYPES = ( + 'm3u', + # 'm3u8' + # 'pls', + # 'cue', +) +############################################################################### + + +def kodi_playlist_monitor(): + """ + Monitors the Kodi playlist folder special://profile/playlist for the user. + Will thus catch all changes on the Kodi side of things. + + Returns an watchdog Observer instance. Be sure to use + observer.stop() (and maybe observer.join()) to shut down properly + """ + event_handler = PlaylistEventhandler() + observer = PlaylistObserver(timeout=FILESYSTEM_TIMEOUT) + observer.schedule(event_handler, v.PLAYLIST_PATH, recursive=True) + observer.start() + return observer + + +def websocket(plex_id, status): + """ + Hit by librarysync to process websocket messages concerning playlists + """ + create = False + with state.LOCK_PLAYLISTS: + playlist = db.get_playlist(plex_id=plex_id) + if playlist and status == 9: + # Won't be able to download metadata of the deleted playlist + if sync_plex_playlist(playlist=playlist): + LOG.debug('Plex deletion of playlist detected: %s', playlist) + try: + kodi_pl.delete(playlist) + except PlaylistError: + pass + return + xml = pms.metadata(plex_id) + if xml is None: + LOG.debug('Could not download playlist %s, probably deleted', + plex_id) + return + if not sync_plex_playlist(xml=xml[0]): + return + api = API(xml[0]) + try: + if playlist: + if api.updated_at() == playlist.plex_updatedat: + LOG.debug('Playlist with id %s already synced: %s', + plex_id, playlist) + else: + LOG.debug('Change of Plex playlist detected: %s', + playlist) + kodi_pl.delete(playlist) + create = True + elif not playlist and not status == 9: + LOG.debug('Creation of new Plex playlist detected: %s', + plex_id) + create = True + # To the actual work + if create: + kodi_pl.create(plex_id) + except PlaylistError: + pass + + +def full_sync(): + """ + Full sync of playlists between Kodi and Plex. Returns True is successful, + False otherwise + """ + if not state.SYNC_PLAYLISTS: + LOG.debug('Not syncing playlists') + return True + LOG.info('Starting playlist full sync') + with state.LOCK_PLAYLISTS: + return _full_sync() + + +def _full_sync(): + """ + Need to lock because we're messing with playlists + """ + # Get all Plex playlists + xml = pms.all_playlists() + if xml is None: + return False + # For each playlist, check Plex database to see whether we already synced + # before. If yes, make sure that hashes are identical. If not, sync it. + old_plex_ids = db.plex_playlist_ids() + for xml_playlist in xml: + api = API(xml_playlist) + try: + old_plex_ids.remove(api.plex_id()) + except ValueError: + pass + if not sync_plex_playlist(xml=xml_playlist): + continue + playlist = db.get_playlist(plex_id=api.plex_id()) + try: + if not playlist: + LOG.debug('New Plex playlist %s discovered: %s', + api.plex_id(), api.title()) + kodi_pl.create(api.plex_id()) + elif playlist.plex_updatedat != api.updated_at(): + LOG.debug('Detected changed Plex playlist %s: %s', + api.plex_id(), api.title()) + kodi_pl.delete(playlist) + kodi_pl.create(api.plex_id()) + except PlaylistError: + LOG.info('Skipping playlist %s: %s', api.plex_id(), api.title()) + # Get rid of old Plex playlists that were deleted on the Plex side + for plex_id in old_plex_ids: + playlist = db.get_playlist(plex_id=plex_id) + LOG.debug('Removing outdated Plex playlist: %s', playlist) + try: + kodi_pl.delete(playlist) + except PlaylistError: + LOG.debug('Skipping deletion of playlist: %s', playlist) + # Look at all supported Kodi playlists. Check whether they are in the DB. + old_kodi_paths = db.kodi_playlist_paths() + for root, _, files in path_ops.walk(v.PLAYLIST_PATH): + for f in files: + path = path_ops.path.join(root, f) + try: + old_kodi_paths.remove(path) + except ValueError: + pass + if not sync_kodi_playlist(path): + continue + kodi_hash = utils.generate_file_md5(path) + playlist = db.get_playlist(path=path) + if playlist and playlist.kodi_hash == kodi_hash: + continue + try: + if not playlist: + LOG.debug('New Kodi playlist detected: %s', path) + playlist = Playlist() + playlist.kodi_path = path + playlist.kodi_hash = kodi_hash + plex_pl.create(playlist) + else: + LOG.debug('Changed Kodi playlist detected: %s', path) + plex_pl.delete(playlist) + playlist.kodi_hash = kodi_hash + plex_pl.create(playlist) + except PlaylistError: + LOG.info('Skipping Kodi playlist %s', path) + for kodi_path in old_kodi_paths: + playlist = db.get_playlist(path=kodi_path) + try: + plex_pl.delete(playlist) + except PlaylistError: + LOG.debug('Skipping deletion of playlist: %s', playlist) + LOG.info('Playlist full sync done') + return True + + +def sync_kodi_playlist(path): + """ + Returns True if we should sync this Kodi playlist with path [unicode] to + Plex based on the playlist file name and the user settings, False otherwise + """ + if path.startswith(v.PLAYLIST_PATH_MIXED): + return False + try: + extension = path.rsplit('.', 1)[1].lower() + except IndexError: + return False + if extension not in SUPPORTED_FILETYPES: + return False + if not state.SYNC_SPECIFIC_KODI_PLAYLISTS: + return True + playlist = Playlist() + playlist.kodi_path = path + prefix = utils.settings('syncSpecificKodiPlaylistsPrefix').lower() + if playlist.kodi_filename.lower().startswith(prefix): + return True + LOG.debug('User chose to not sync Kodi playlist %s', path) + return False + + +def sync_plex_playlist(plex_id=None, xml=None, playlist=None): + """ + Returns True if we should sync this specific Plex playlist due to the + user settings (including a disabled music library), False if not. + + Pass in either the plex_id or an xml (where API(xml) will be used) + """ + if not state.SYNC_SPECIFIC_PLEX_PLAYLISTS: + return True + if playlist: + # Mainly once we DELETED a Plex playlist that we're NOT supposed + # to sync + name = playlist.plex_name + typus = playlist.kodi_type + else: + if xml is None: + xml = pms.metadata(plex_id) + if xml is None: + LOG.info('Could not get Plex metadata for playlist %s', + plex_id) + return False + api = API(xml[0]) + else: + api = API(xml) + name = api.title() + typus = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()] + if (not state.ENABLE_MUSIC and typus == v.PLEX_PLAYLIST_TYPE_AUDIO): + LOG.debug('Not synching Plex audio playlist') + return False + prefix = utils.settings('syncSpecificPlexPlaylistsPrefix').lower() + if name and name.lower().startswith(prefix): + return True + LOG.debug('User chose to not sync Plex playlist %s', name) + return False + + +class PlaylistEventhandler(events.FileSystemEventHandler): + """ + PKC eventhandler to monitor Kodi playlists safed to disk + """ + def dispatch(self, event): + """ + Dispatches events to the appropriate methods. + + :param event: + The event object representing the file system event. + :type event: + :class:`FileSystemEvent` + """ + if not state.SYNC_PLAYLISTS: + # Sync is deactivated + return + path = event.dest_path if event.event_type == events.EVENT_TYPE_MOVED \ + else event.src_path + if not sync_kodi_playlist(path): + return + _method_map = { + events.EVENT_TYPE_MODIFIED: self.on_modified, + events.EVENT_TYPE_MOVED: self.on_moved, + events.EVENT_TYPE_CREATED: self.on_created, + events.EVENT_TYPE_DELETED: self.on_deleted, + } + with state.LOCK_PLAYLISTS: + _method_map[event.event_type](event) + + def on_created(self, event): + LOG.debug('on_created: %s', event.src_path) + old_playlist = db.get_playlist(path=event.src_path) + kodi_hash = utils.generate_file_md5(event.src_path) + if old_playlist and old_playlist.kodi_hash == kodi_hash: + LOG.debug('Playlist already in DB - skipping') + return + elif old_playlist: + LOG.debug('Playlist already in DB but it has been changed') + self.on_modified(event) + return + playlist = Playlist() + playlist.kodi_path = event.src_path + playlist.kodi_hash = kodi_hash + try: + plex_pl.create(playlist) + except PlaylistError: + pass + + def on_modified(self, event): + LOG.debug('on_modified: %s', event.src_path) + old_playlist = db.get_playlist(path=event.src_path) + kodi_hash = utils.generate_file_md5(event.src_path) + if old_playlist and old_playlist.kodi_hash == kodi_hash: + LOG.debug('Nothing modified, playlist already in DB - skipping') + return + new_playlist = Playlist() + if old_playlist: + # Retain the name! Might've come from Plex + # (rename should fire on_moved) + new_playlist.plex_name = old_playlist.plex_name + plex_pl.delete(old_playlist) + new_playlist.kodi_path = event.src_path + new_playlist.kodi_hash = kodi_hash + try: + plex_pl.create(new_playlist) + except PlaylistError: + pass + + def on_moved(self, event): + LOG.debug('on_moved: %s to %s', event.src_path, event.dest_path) + kodi_hash = utils.generate_file_md5(event.dest_path) + # First check whether we don't already have destination playlist in + # our DB. Just in case.... + old_playlist = db.get_playlist(path=event.dest_path) + if old_playlist: + LOG.warning('Found target playlist already in our DB!') + new_event = events.FileModifiedEvent(event.dest_path) + self.on_modified(new_event) + return + # All good + old_playlist = db.get_playlist(path=event.src_path) + if not old_playlist: + LOG.debug('Did not have source path in the DB %s', event.src_path) + else: + plex_pl.delete(old_playlist) + new_playlist = Playlist() + new_playlist.kodi_path = event.dest_path + new_playlist.kodi_hash = kodi_hash + try: + plex_pl.create(new_playlist) + except PlaylistError: + pass + + def on_deleted(self, event): + LOG.debug('on_deleted: %s', event.src_path) + playlist = db.get_playlist(path=event.src_path) + if not playlist: + LOG.debug('Playlist not found in DB for path %s', event.src_path) + else: + plex_pl.delete(playlist) diff --git a/resources/lib/playlists/common.py b/resources/lib/playlists/common.py new file mode 100644 index 00000000..c389eda8 --- /dev/null +++ b/resources/lib/playlists/common.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from logging import getLogger +import Queue +import time + +from ..watchdog import events +from ..watchdog.observers import Observer +from ..watchdog.utils.bricks import OrderedSetQueue + +from .. import path_ops +from .. import variables as v +############################################################################### +LOG = getLogger('PLEX.playlists.common') + +# These filesystem events are considered similar +SIMILAR_EVENTS = (events.EVENT_TYPE_CREATED, events.EVENT_TYPE_MODIFIED) +############################################################################### + + +class PlaylistError(Exception): + """ + The one main exception thrown if anything goes awry + """ + pass + + +class Playlist(object): + """ + Class representing a synced Playlist with info for both Kodi and Plex. + Attributes: + Plex: + plex_id: unicode + plex_name: unicode + plex_updatedat: unicode + + Kodi: + kodi_path: unicode + kodi_filename: unicode + kodi_extension: unicode + kodi_type: unicode + kodi_hash: unicode + + Testing for a Playlist() returns ONLY True if all the following attributes + are set; 2 playlists are only equal if all attributes are equal: + plex_id + plex_name + plex_updatedat + kodi_path + kodi_filename + kodi_type + kodi_hash + """ + def __init__(self): + # Plex + self.plex_id = None + self.plex_name = None + self.plex_updatedat = None + # Kodi + self._kodi_path = None + self.kodi_filename = None + self.kodi_extension = None + self.kodi_type = None + self.kodi_hash = None + + def __repr__(self): + return ("{{" + "'plex_id': {self.plex_id}, " + "'plex_name': '{self.plex_name}', " + "'kodi_type': '{self.kodi_type}', " + "'kodi_filename': '{self.kodi_filename}', " + "'kodi_path': '{self._kodi_path}', " + "'plex_updatedat': {self.plex_updatedat}, " + "'kodi_hash': '{self.kodi_hash}'" + "}}").format(self=self) + + def __str__(self): + return self.__repr__() + + def __bool__(self): + return (self.plex_id and self.plex_updatedat and self.plex_name and + self._kodi_path and self.kodi_filename and self.kodi_type and + self.kodi_hash) + + # Used for comparison of playlists + @property + def key(self): + return (self.plex_id, self.plex_updatedat, self.plex_name, + self._kodi_path, self.kodi_filename, self.kodi_type, + self.kodi_hash) + + def __eq__(self, playlist): + return self.key == playlist.key + + def __ne__(self, playlist): + return self.key != playlist.key + + @property + def kodi_path(self): + return self._kodi_path + + @kodi_path.setter + def kodi_path(self, path): + if not isinstance(path, unicode): + raise RuntimeError('Path not in unicode: %s' % path) + f = path_ops.path.basename(path) + try: + self.kodi_filename, self.kodi_extension = f.split('.', 1) + except ValueError: + LOG.error('Trying to set invalid path: %s', path) + raise PlaylistError('Invalid path: %s' % path) + if path.startswith(v.PLAYLIST_PATH_VIDEO): + self.kodi_type = v.KODI_TYPE_VIDEO_PLAYLIST + elif path.startswith(v.PLAYLIST_PATH_MUSIC): + self.kodi_type = v.KODI_TYPE_AUDIO_PLAYLIST + else: + LOG.error('Playlist type not supported for %s', path) + raise PlaylistError('Playlist type not supported: %s' % path) + if not self.plex_name: + self.plex_name = self.kodi_filename + self._kodi_path = path + + +class PlaylistQueue(OrderedSetQueue): + """ + OrderedSetQueue that drops all directory events immediately + """ + def _put(self, item): + if item[0].is_directory: + self.unfinished_tasks -= 1 + else: + # Can't use super as OrderedSetQueue is old style class + OrderedSetQueue._put(self, item) + + +class PlaylistObserver(Observer): + """ + PKC implementation, overriding the dispatcher. PKC will wait for the + duration timeout (in seconds) AFTER receiving a filesystem event. A new + ("non-similar") event will reset the timer. + Creating and modifying will be regarded as equal. + """ + def __init__(self, *args, **kwargs): + super(PlaylistObserver, self).__init__(*args, **kwargs) + # Drop the same events that get into the queue even if there are other + # events in between these similar events. Ignore directory events + # completely + self._event_queue = PlaylistQueue() + + @staticmethod + def _pkc_similar_events(event1, event2): + if event1 == event2: + return True + elif (event1.src_path == event2.src_path and + event1.event_type in SIMILAR_EVENTS and + event2.event_type in SIMILAR_EVENTS): + # Set created and modified events to equal + return True + return False + + def _dispatch_iterator(self, event_queue, timeout): + """ + This iterator will block for timeout (seconds) until an event is + received or raise Queue.Empty. + """ + event, watch = event_queue.get(block=True, timeout=timeout) + event_queue.task_done() + start = time.time() + while time.time() - start < timeout: + try: + new_event, new_watch = event_queue.get(block=False) + except Queue.Empty: + time.sleep(0.2) + else: + event_queue.task_done() + start = time.time() + if self._pkc_similar_events(new_event, event): + continue + else: + yield event, watch + event, watch = new_event, new_watch + yield event, watch + + def dispatch_events(self, event_queue, timeout): + for event, watch in self._dispatch_iterator(event_queue, timeout): + # This is copy-paste of original code + with self._lock: + # To allow unschedule/stop and safe removal of event handlers + # within event handlers itself, check if the handler is still + # registered after every dispatch. + for handler in list(self._handlers.get(watch, [])): + if handler in self._handlers.get(watch, []): + handler.dispatch(event) diff --git a/resources/lib/playlists/db.py b/resources/lib/playlists/db.py new file mode 100644 index 00000000..c94ab0a0 --- /dev/null +++ b/resources/lib/playlists/db.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Synced playlists are stored in our plex.db. Interact with it through this +module +""" +from logging import getLogger + +from .common import Playlist, PlaylistError + +from .. import kodidb_functions as kodidb +from .. import plexdb_functions as plexdb +from .. import path_ops, utils, variables as v +############################################################################### +LOG = getLogger('PLEX.playlists.db') + +############################################################################### + + +def plex_playlist_ids(): + """ + Returns a list of all Plex ids of the playlists already in our DB + """ + with plexdb.Get_Plex_DB() as plex_db: + return plex_db.plex_ids_all_playlists() + + +def kodi_playlist_paths(): + """ + Returns a list of all Kodi playlist paths of the playlists already synced + """ + with plexdb.Get_Plex_DB() as plex_db: + return plex_db.all_kodi_playlist_paths() + + +def update_playlist(playlist, delete=False): + """ + Assumes that all sync operations are over. Takes playlist [Playlist] + and creates/updates the corresponding Plex playlists table entry + + Pass delete=True to delete the playlist entry + """ + with plexdb.Get_Plex_DB() as plex_db: + if delete: + plex_db.delete_playlist_entry(playlist) + else: + plex_db.insert_playlist_entry(playlist) + + +def get_playlist(path=None, kodi_hash=None, plex_id=None): + """ + Returns the playlist as a Playlist for either the plex_id, path or + kodi_hash. kodi_hash will be more reliable as it includes path and file + content. + """ + playlist = Playlist() + with plexdb.Get_Plex_DB() as plex_db: + playlist = plex_db.retrieve_playlist(playlist, + plex_id, + path, kodi_hash) + return playlist + + +def _m3u_iterator(text): + """ + Yields e.g. plugin://plugin.video.plexkodiconnect.movies/?plex_id=xxx + """ + lines = iter(text.split('\n')) + for line in lines: + if line.startswith('#EXTINF:'): + yield next(lines).strip() + + +def m3u_to_plex_ids(playlist): + """ + Adapter to process *.m3u playlist files. Encoding is not uniform! + """ + plex_ids = list() + with open(path_ops.encode_path(playlist.kodi_path), 'rb') as f: + text = f.read() + try: + text = text.decode(v.M3U_ENCODING) + except UnicodeDecodeError: + LOG.warning('Fallback to ISO-8859-1 decoding for %s', playlist) + text = text.decode('ISO-8859-1') + for entry in _m3u_iterator(text): + plex_id = utils.REGEX_PLEX_ID.search(entry) + if plex_id: + plex_id = plex_id.group(1) + plex_ids.append(plex_id) + else: + # Add-on paths not working, try direct + kodi_id, kodi_type = kodidb.kodiid_from_filename( + entry, db_type=playlist.kodi_type) + if not kodi_id: + continue + with plexdb.Get_Plex_DB() as plex_db: + plex_id = plex_db.getItem_byKodiId(kodi_id, kodi_type) + if plex_id: + plex_ids.append(plex_id[0]) + return plex_ids + + +def playlist_file_to_plex_ids(playlist): + """ + Takes the playlist file located at path [unicode] and parses it. + Returns a list of plex_ids (str) or raises PL.PlaylistError if a single + item cannot be parsed from Kodi to Plex. + """ + if playlist.kodi_extension == 'm3u': + plex_ids = m3u_to_plex_ids(playlist) + else: + LOG.error('Unsupported playlist extension: %s', + playlist.kodi_extension) + raise PlaylistError + return plex_ids diff --git a/resources/lib/playlists/kodi_pl.py b/resources/lib/playlists/kodi_pl.py new file mode 100644 index 00000000..573efd65 --- /dev/null +++ b/resources/lib/playlists/kodi_pl.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Create and delete playlists on the Kodi side of things +""" +from logging import getLogger + +from .common import Playlist, PlaylistError +from . import db, pms + +from ..plex_api import API +from .. import utils, path_ops, variables as v +############################################################################### +LOG = getLogger('PLEX.playlists.kodi_pl') + +############################################################################### + + +def create(plex_id): + """ + Creates a new Kodi playlist file. Will also add (or modify an existing) + Plex playlist table entry. + Assumes that the Plex playlist is indeed new. A NEW Kodi playlist will be + created in any case (not replaced). Thus make sure that the "same" playlist + is deleted from both disk and the Plex database. + Returns the playlist or raises PlaylistError + """ + xml_metadata = pms.metadata(plex_id) + if xml_metadata is None: + LOG.error('Could not get Plex playlist metadata %s', plex_id) + raise PlaylistError('Could not get Plex playlist %s' % plex_id) + api = API(xml_metadata[0]) + playlist = Playlist() + playlist.plex_id = api.plex_id() + playlist.kodi_type = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()] + playlist.plex_name = api.title() + playlist.plex_updatedat = api.updated_at() + LOG.debug('Creating new Kodi playlist from Plex playlist: %s', playlist) + # Derive filename close to Plex playlist name + name = utils.valid_filename(playlist.plex_name) + path = path_ops.path.join(v.PLAYLIST_PATH, playlist.kodi_type, + '%s.m3u' % name) + while path_ops.exists(path) or db.get_playlist(path=path): + # In case the Plex playlist names are not unique + occurance = utils.REGEX_FILE_NUMBERING.search(path) + if not occurance: + path = path_ops.path.join(v.PLAYLIST_PATH, + playlist.kodi_type, + '%s_01.m3u' % name[:min(len(name), 248)]) + else: + occurance = int(occurance.group(1)) + 1 + path = path_ops.path.join(v.PLAYLIST_PATH, + playlist.kodi_type, + '%s_%02d.m3u' % (name[:min(len(name), + 248)], + occurance)) + LOG.debug('Kodi playlist path: %s', path) + playlist.kodi_path = path + xml_playlist = pms.get_playlist(plex_id) + if xml_playlist is None: + LOG.error('Could not get Plex playlist %s', plex_id) + raise PlaylistError('Could not get Plex playlist %s' % plex_id) + _write_playlist_to_file(playlist, xml_playlist) + playlist.kodi_hash = utils.generate_file_md5(path) + db.update_playlist(playlist) + LOG.debug('Created Kodi playlist based on Plex playlist: %s', playlist) + + +def delete(playlist): + """ + Removes the corresponding Kodi file for playlist Playlist from + disk. Be sure that playlist.kodi_path is set. Will also delete the entry in + the Plex playlist table. + Returns None or raises PlaylistError + """ + if path_ops.exists(playlist.kodi_path): + try: + path_ops.remove(playlist.kodi_path) + except (OSError, IOError) as err: + LOG.error('Could not delete Kodi playlist file %s. Error:\n%s: %s', + playlist, err.errno, err.strerror) + raise PlaylistError('Could not delete %s' % playlist.kodi_path) + db.update_playlist(playlist, delete=True) + + +def _write_playlist_to_file(playlist, xml): + """ + Feed with playlist Playlist. Will write the playlist to a m3u file + Returns None or raises PlaylistError + """ + text = '#EXTCPlayListM3U::M3U\n' + for element in xml: + api = API(element) + append_season_episode = False + if api.plex_type() == v.PLEX_TYPE_EPISODE: + _, show, season_id, episode_id = api.episode_data() + try: + season_id = int(season_id) + episode_id = int(episode_id) + except ValueError: + pass + else: + append_season_episode = True + if append_season_episode: + text += ('#EXTINF:%s,%s S%.2dE%.2d - %s\n%s\n' + % (api.runtime(), show, season_id, episode_id, + api.title(), api.path())) + else: + # Only append the TV show name + text += ('#EXTINF:%s,%s - %s\n%s\n' + % (api.runtime(), show, api.title(), api.path())) + else: + text += ('#EXTINF:%s,%s\n%s\n' + % (api.runtime(), api.title(), api.path())) + text += '\n' + text = text.encode(v.M3U_ENCODING, 'strict') + try: + with open(path_ops.encode_path(playlist.kodi_path), 'wb') as f: + f.write(text) + except EnvironmentError as err: + LOG.error('Could not write Kodi playlist file: %s', playlist) + LOG.error('Error message %s: %s', err.errno, err.strerror) + raise PlaylistError('Cannot write Kodi playlist to path for %s' + % playlist) diff --git a/resources/lib/playlists/plex_pl.py b/resources/lib/playlists/plex_pl.py new file mode 100644 index 00000000..6699b4ed --- /dev/null +++ b/resources/lib/playlists/plex_pl.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Create and delete playlists on the Plex side of things +""" +from logging import getLogger + +from .common import PlaylistError +from . import pms, db +############################################################################### +LOG = getLogger('PLEX.playlists.plex_pl') + +############################################################################### + + +def create(playlist): + """ + Adds the playlist Playlist to the PMS. If playlist.id is + not None the existing Plex playlist will be overwritten; otherwise a new + playlist will be generated and stored accordingly in the playlist object. + Will also add (or modify an existing) Plex playlist table entry. + Make sure that playlist.kodi_hash is set! + Returns None or raises PlaylistError + """ + LOG.debug('Creating Plex playlist from Kodi file: %s', playlist) + plex_ids = db.playlist_file_to_plex_ids(playlist) + if not plex_ids: + LOG.warning('No Plex ids found for playlist %s', playlist) + raise PlaylistError + for pos, plex_id in enumerate(plex_ids): + try: + if pos == 0 or not playlist.plex_id: + pms.initialize(playlist, plex_id) + else: + pms.add_item(playlist, plex_id=plex_id) + except PlaylistError: + continue + db.update_playlist(playlist) + LOG.debug('Done creating Plex playlist %s', playlist) + + +def delete(playlist): + """ + Removes the playlist Playlist from the PMS. Will also delete the + entry in the Plex playlist table. + Returns None or raises PlaylistError + """ + LOG.debug('Deleting playlist from PMS: %s', playlist) + pms.delete(playlist) + db.update_playlist(playlist, delete=True) diff --git a/resources/lib/playlists/pms.py b/resources/lib/playlists/pms.py new file mode 100644 index 00000000..88d03f35 --- /dev/null +++ b/resources/lib/playlists/pms.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Functions to communicate with the currently connected PMS in order to +manipulate playlists +""" +from logging import getLogger +import urllib + +from .common import PlaylistError + +from ..plex_api import API +from ..downloadutils import DownloadUtils as DU +from .. import variables as v +############################################################################### +LOG = getLogger('PLEX.playlists.pms') + +############################################################################### + + +def all_playlists(): + """ + Returns an XML with all Plex playlists or None + """ + xml = DU().downloadUrl('{server}/playlists') + try: + xml.attrib + except (AttributeError, TypeError): + LOG.error('Could not download a list of all playlists') + xml = None + return xml + + +def get_playlist(plex_id): + """ + Fetches the PMS playlist/playqueue as an XML. Pass in playlist id + Returns None if something went wrong + """ + xml = DU().downloadUrl("{server}/playlists/%s/items" % plex_id) + try: + xml.attrib + except AttributeError: + xml = None + return xml + + +def initialize(playlist, plex_id): + """ + Initializes a new playlist on the PMS side. Will set playlist.plex_id and + playlist.plex_updatedat. Will raise PlaylistError if something went wrong. + """ + LOG.debug('Initializing the playlist with Plex id %s on the Plex side: %s', + plex_id, playlist) + params = { + 'type': v.PLEX_PLAYLIST_TYPE_FROM_KODI[playlist.kodi_type], + 'title': playlist.plex_name, + 'smart': 0, + 'uri': ('library://None/item/%s' % (urllib.quote('/library/metadata/%s' + % plex_id, safe=''))) + } + xml = DU().downloadUrl(url='{server}/playlists', + action_type='POST', + parameters=params) + try: + xml[0].attrib + except (TypeError, IndexError, AttributeError): + LOG.error('Could not initialize playlist on Plex side with plex id %s', + plex_id) + raise PlaylistError('Could not initialize Plex playlist %s', plex_id) + api = API(xml[0]) + playlist.plex_id = api.plex_id() + playlist.plex_updatedat = api.updated_at() + + +def add_item(playlist, plex_id): + """ + Adds the item with plex_id to the existing Plex playlist (at the end). + Will set playlist.plex_updatedat + Raises PlaylistError if that did not work out. + """ + params = { + 'uri': ('library://None/item/%s' % (urllib.quote('/library/metadata/%s' + % plex_id, safe=''))) + } + xml = DU().downloadUrl(url='{server}/playlists/%s/items' % playlist.plex_id, + action_type='PUT', + parameters=params) + try: + xml[0].attrib + except (TypeError, IndexError, AttributeError): + LOG.error('Could not initialize playlist on Plex side with plex id %s', + plex_id) + raise PlaylistError('Could not item %s to Plex playlist %s', + plex_id, playlist) + api = API(xml[0]) + playlist.plex_updatedat = api.updated_at() + + +def metadata(plex_id): + """ + Returns an xml with the entire metadata like updatedAt. + """ + xml = DU().downloadUrl('{server}/playlists/%s' % plex_id) + try: + xml.attrib + except AttributeError: + xml = None + return xml + + +def delete(playlist): + """ + Deletes the playlist from the PMS + """ + DU().downloadUrl('{server}/playlists/%s' % playlist.plex_id, + action_type="DELETE") diff --git a/resources/lib/playqueue.py b/resources/lib/playqueue.py index 1f08cc8b..fc6c6606 100644 --- a/resources/lib/playqueue.py +++ b/resources/lib/playqueue.py @@ -73,7 +73,7 @@ def init_playqueue_from_plex_children(plex_id, transient_token=None): """ Init a new playqueue e.g. from an album. Alexa does this - Returns the Playlist_Object + Returns the playqueue """ xml = PF.GetAllPlexChildren(plex_id) try: diff --git a/resources/lib/plex_functions.py b/resources/lib/plex_functions.py index e577bafa..fe64a8be 100644 --- a/resources/lib/plex_functions.py +++ b/resources/lib/plex_functions.py @@ -473,6 +473,7 @@ def GetPlexMetadata(key): 'includeExtras': 1, # Trailers and Extras => Extras 'includeReviews': 1, 'includeRelated': 0, # Similar movies => Video -> Related + 'skipRefresh': 1, # 'includeRelatedCount': 0, # 'includeOnDeck': 1, # 'includeChapters': 1, diff --git a/resources/lib/plexdb_functions.py b/resources/lib/plexdb_functions.py index 9a0e3866..ea3e445b 100644 --- a/resources/lib/plexdb_functions.py +++ b/resources/lib/plexdb_functions.py @@ -414,12 +414,12 @@ class Plex_DB_Functions(): answ.append(entry[0]) return answ - def kodi_hashes_all_playlists(self): + def all_kodi_playlist_paths(self): """ - Returns a list of all Kodi hashes of playlists. + Returns a list of all Kodi playlist paths. """ answ = [] - self.plexcursor.execute('SELECT kodi_hash FROM playlists') + self.plexcursor.execute('SELECT kodi_path FROM playlists') for entry in self.plexcursor.fetchall(): answ.append(entry[0]) return answ @@ -427,8 +427,9 @@ class Plex_DB_Functions(): def retrieve_playlist(self, playlist, plex_id=None, path=None, kodi_hash=None): """ - Returns a complete Playlist_Object (empty one passed in via playlist) - for the entry with plex_id. Or None if not found + Returns a complete Playlist (empty one passed in via playlist) + for the entry with plex_id OR kodi_hash OR kodi_path. + Returns None if not found """ query = ''' SELECT plex_id, plex_name, plex_updatedat, kodi_path, kodi_type, @@ -450,11 +451,11 @@ class Plex_DB_Functions(): answ = self.plexcursor.fetchone() if not answ: return - playlist.id = answ[0] + playlist.plex_id = answ[0] playlist.plex_name = answ[1] playlist.plex_updatedat = answ[2] playlist.kodi_path = answ[3] - playlist.type = answ[4] + playlist.kodi_type = answ[4] playlist.kodi_hash = answ[5] return playlist @@ -469,9 +470,9 @@ class Plex_DB_Functions(): VALUES (?, ?, ?, ?, ?, ?) ''' self.plexcursor.execute(query, - (playlist.id, playlist.plex_name, + (playlist.plex_id, playlist.plex_name, playlist.plex_updatedat, playlist.kodi_path, - playlist.type, playlist.kodi_hash)) + playlist.kodi_type, playlist.kodi_hash)) def delete_playlist_entry(self, playlist): """ @@ -479,12 +480,12 @@ class Plex_DB_Functions(): playlists table. Be sure to either set playlist.id or playlist.kodi_path """ - if playlist.id: + if playlist.plex_id: query = 'DELETE FROM playlists WHERE plex_id = ?' - var = playlist.id + var = playlist.plex_id elif playlist.kodi_path: query = 'DELETE FROM playlists WHERE kodi_path = ?' var = playlist.kodi_path else: - raise RuntimeError('Cannot delete playlist: %s', playlist) + raise RuntimeError('Cannot delete playlist: %s' % playlist) self.plexcursor.execute(query, (var, )) diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 45656b03..6ce21ba6 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -1011,19 +1011,18 @@ def delete_nodes(): def generate_file_md5(path): """ Generates the md5 hash value for the file located at path [unicode]. - The hash includes the path and is thus different for the same file for - different filenames. - Returns a unique string containing only hexadecimal digits + The hash does not include the path and filename and is thus identical for + a file that was moved/changed name. + Returns a unique unicode containing only hexadecimal digits """ m = hashlib.md5() - m.update(path.encode('utf-8')) with open(path_ops.encode_path(path), 'rb') as f: while True: piece = f.read(32768) if not piece: break m.update(piece) - return m.hexdigest() + return m.hexdigest().decode('utf-8') ###############################################################################