diff --git a/default.py b/default.py index e4e549f8..82179057 100644 --- a/default.py +++ b/default.py @@ -141,6 +141,10 @@ class Main(): elif mode == 'hub': entrypoint.hub(params.get('type')) + elif mode == 'select-libraries': + LOG.info('User requested to select Plex libraries') + transfer.plex_command('select-libraries') + else: entrypoint.show_main_menu(content_type=params.get('content_type')) diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 2ca300cf..ba4650ea 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -558,6 +558,12 @@ msgctxt "#30523" msgid "Also show sync progress for playstate and user data" msgstr "" +# PKC Settings - Sync Options +msgctxt "#30524" +msgid "Select Plex libraries to sync" +msgstr "" + + # PKC Settings - Playback msgctxt "#30527" msgid "Ignore specials in next episodes" diff --git a/resources/lib/library_sync/__init__.py b/resources/lib/library_sync/__init__.py index 0c4f23ea..58b6bd6c 100644 --- a/resources/lib/library_sync/__init__.py +++ b/resources/lib/library_sync/__init__.py @@ -7,3 +7,5 @@ from .websocket import store_websocket_message, process_websocket_messages, \ WEBSOCKET_MESSAGES, PLAYSTATE_SESSIONS from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED from .fanart import FanartThread, FanartTask +from .videonodes import VideoNodes +from .sections import force_full_sync diff --git a/resources/lib/library_sync/full_sync.py b/resources/lib/library_sync/full_sync.py index d85fc291..2ea072d5 100644 --- a/resources/lib/library_sync/full_sync.py +++ b/resources/lib/library_sync/full_sync.py @@ -68,6 +68,7 @@ class FullSync(common.fullsync_mixin): self.context = None self.get_children = None self.successful = None + self.section_success = None self.install_sync_done = utils.settings('SyncInstallRunDone') == 'true' self.threader = backgroundthread.ThreaderManager( worker=backgroundthread.NonstoppingBackgroundWorker, @@ -232,8 +233,8 @@ class FullSync(common.fullsync_mixin): if not itemtype.update_userdata(xml_item, section['plex_type']): # Somehow did not sync this item yet itemtype.add_update(xml_item, - section['section_name'], - section['section_id']) + section_name=section['section_name'], + section_id=section['section_id']) itemtype.plexdb.update_last_sync(int(xml_item.attrib['ratingKey']), section['plex_type'], self.current_sync) @@ -248,38 +249,39 @@ class FullSync(common.fullsync_mixin): LOG.error('Could not entirely process section %s', section) return False - def threaded_get_iterators(self, kinds, queue, updated_at=None, - last_viewed_at=None): + def threaded_get_iterators(self, kinds, queue, all_items=False): """ PF.SectionItems is costly, so let's do it asynchronous """ - if self.repair: - updated_at = None - last_viewed_at = None - else: - updated_at = updated_at - UPDATED_AT_SAFETY if updated_at else None - last_viewed_at = last_viewed_at - LAST_VIEWED_AT_SAFETY \ - if last_viewed_at else None try: for kind in kinds: for section in (x for x in sections.SECTIONS if x['plex_type'] == kind[1]): if self.isCanceled(): return + if not section['sync_to_kodi']: + LOG.info('User chose to not sync section %s', section) + continue element = copy.deepcopy(section) element['section_type'] = element['plex_type'] element['plex_type'] = kind[0] element['element_type'] = kind[1] element['context'] = kind[2] element['get_children'] = kind[3] + if self.repair or all_items: + updated_at = None + else: + updated_at = section['last_sync'] - UPDATED_AT_SAFETY \ + if section['last_sync'] else None try: element['iterator'] = PF.SectionItems(section['section_id'], plex_type=kind[0], updated_at=updated_at, - last_viewed_at=last_viewed_at) + last_viewed_at=None) except RuntimeError: LOG.warn('Sync at least partially unsuccessful') self.successful = False + self.section_success = False else: queue.put(element) finally: @@ -303,14 +305,13 @@ class FullSync(common.fullsync_mixin): # Already start setting up the iterators. We need to enforce # syncing e.g. show before season before episode iterator_queue = Queue.Queue() - updated_at = int(utils.settings('lastfullsync')) or None task = backgroundthread.FunctionAsTask(self.threaded_get_iterators, None, kinds, - iterator_queue, - updated_at=updated_at) + iterator_queue) backgroundthread.BGThreader.addTask(task) while True: + self.section_success = True section = iterator_queue.get() iterator_queue.task_done() if section is None: @@ -323,10 +324,14 @@ class FullSync(common.fullsync_mixin): # Now do the heavy lifting if self.isCanceled() or not self.addupdate_section(section): return False + if self.section_success: + # Need to check because a thread might have missed to get + # some items from the PMS + with PlexDB() as plexdb: + # Set the new time mark for the next delta sync + plexdb.update_section_last_sync(section['section_id'], + self.current_sync) common.update_kodi_library(video=True, music=True) - if self.successful: - # Set timestamp for next sync - neglecting playstates! - utils.settings('lastfullsync', value=str(int(self.current_sync))) # In order to not delete all your songs again if app.SYNC.enable_music: kinds.extend([ @@ -347,7 +352,8 @@ class FullSync(common.fullsync_mixin): task = backgroundthread.FunctionAsTask(self.threaded_get_iterators, None, kinds, - iterator_queue) + iterator_queue, + all_items=True) backgroundthread.BGThreader.addTask(task) while True: section = iterator_queue.get() @@ -364,7 +370,7 @@ class FullSync(common.fullsync_mixin): return False # Delete movies that are not on Plex anymore - LOG.info('Looking for items to delete') + LOG.debug('Looking for items to delete') kinds = [ (v.PLEX_TYPE_MOVIE, itemtypes.Movie), (v.PLEX_TYPE_SHOW, itemtypes.Show), diff --git a/resources/lib/library_sync/sections.py b/resources/lib/library_sync/sections.py index 77517cbf..3a615790 100644 --- a/resources/lib/library_sync/sections.py +++ b/resources/lib/library_sync/sections.py @@ -4,7 +4,7 @@ from __future__ import absolute_import, division, unicode_literals from logging import getLogger import copy -from . import common, videonodes +from . import videonodes from ..utils import cast from ..plex_db import PlexDB from .. import kodi_db @@ -22,6 +22,16 @@ SECTIONS = [] IS_CANCELED = None +def force_full_sync(): + """ + Resets the sync timestamp for all sections to 0, thus forcing a subsequent + full sync (not delta) + """ + LOG.info('Telling PKC to do a full sync instead of a delta sync') + with PlexDB() as plexdb: + plexdb.force_full_sync() + + def sync_from_pms(parent_self): """ Sync the Plex library sections @@ -35,6 +45,7 @@ def sync_from_pms(parent_self): def _sync_from_pms(): + global PLAYLISTS, NODES, SECTIONS sections = PF.get_plex_sections() try: sections.attrib @@ -45,7 +56,7 @@ def _sync_from_pms(): # Will reboot Kodi is new library detected music.excludefromscan_music_folders(xml=sections) - global PLAYLISTS, NODES, SECTIONS + VNODES.clearProperties() SECTIONS = [] NODES = { v.PLEX_TYPE_MOVIE: [], @@ -54,64 +65,46 @@ def _sync_from_pms(): v.PLEX_TYPE_PHOTO: [] } PLAYLISTS = copy.deepcopy(NODES) - sorted_sections = [] - - for section in sections: - if (section.attrib['type'] in - (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW, v.PLEX_TYPE_PHOTO, - v.PLEX_TYPE_ARTIST)): - sorted_sections.append(section.attrib['title']) - LOG.debug('Sorted sections: %s', sorted_sections) - totalnodes = len(sorted_sections) - - VNODES.clearProperties() - with PlexDB() as plexdb: # Backup old sections to delete them later, if needed (at the end # of this method, only unused sections will be left in old_sections) - old_sections = list(plexdb.section_ids()) + old_sections = list(plexdb.all_sections()) with kodi_db.KodiVideoDB() as kodidb: - for section in sections: + for index, section in enumerate(sections): _process_section(section, kodidb, plexdb, - sorted_sections, - old_sections, - totalnodes) + index, + old_sections) if old_sections: # Section has been deleted on the PMS delete_sections(old_sections) # update sections for all: with PlexDB() as plexdb: - SECTIONS = list(plexdb.section_infos()) - utils.window('Plex.nodes.total', str(totalnodes)) - LOG.info("Finished processing library sections: %s", SECTIONS) + SECTIONS = list(plexdb.all_sections()) + utils.window('Plex.nodes.total', str(len(sections))) + LOG.info("Finished processing %s library sections: %s", len(sections), SECTIONS) return True -def _process_section(section_xml, kodidb, plexdb, sorted_sections, - old_sections, totalnodes): +def _process_section(section_xml, kodidb, plexdb, index, old_sections): + global PLAYLISTS, NODES folder = section_xml.attrib plex_type = folder['type'] # Only process supported formats if plex_type not in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW, v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_PHOTO): LOG.error('Unsupported Plex section type: %s', folder) - return totalnodes + return section_id = cast(int, folder['key']) section_name = folder['title'] - global PLAYLISTS, NODES # Prevent duplicate for nodes of the same type nodes = NODES[plex_type] # Prevent duplicate for playlists of the same type playlists = PLAYLISTS[plex_type] # Get current media folders from plex database section = plexdb.section(section_id) - try: - current_sectionname = section[1] - current_sectiontype = section[2] - current_tagid = section[3] - except TypeError: + if not section: LOG.info('Creating section id: %s in Plex database.', section_id) tagid = kodidb.create_tag(section_name) # Create playlist for the video library @@ -121,28 +114,30 @@ def _process_section(section_xml, kodidb, plexdb, sorted_sections, playlists.append(section_name) # Create the video node if section_name not in nodes: - VNODES.viewNode(sorted_sections.index(section_name), + VNODES.viewNode(index, section_name, plex_type, None, section_id) nodes.append(section_name) - totalnodes += 1 # Add view to plex database - plexdb.add_section(section_id, section_name, plex_type, tagid) + plexdb.add_section(section_id, + section_name, + plex_type, + tagid, + True, # Sync this new section for now + None) else: LOG.info('Found library section id %s, name %s, type %s, tagid %s', - section_id, current_sectionname, current_sectiontype, - current_tagid) + section_id, section['section_name'], section['plex_type'], + section['kodi_tagid']) # Remove views that are still valid to delete rest later - try: - old_sections.remove(section_id) - except ValueError: - # View was just created, nothing to remove - pass - + for section in old_sections: + if section['section_id'] == section_id: + old_sections.remove(section) + break # View was modified, update with latest info - if current_sectionname != section_name: + if section['section_name'] != section_name: LOG.info('section id: %s new sectionname: %s', section_id, section_name) tagid = kodidb.create_tag(section_name) @@ -151,22 +146,24 @@ def _process_section(section_xml, kodidb, plexdb, sorted_sections, plexdb.add_section(section_id, section_name, plex_type, - tagid) + tagid, + section['sync_to_kodi'], # Use "old" setting + section['last_sync']) - if plexdb.section_id_by_name(current_sectionname) is None: + if plexdb.section_id_by_name(section['section_name']) is None: # The tag could be a combined view. Ensure there's # no other tags with the same name before deleting # playlist. utils.playlist_xsp(plex_type, - current_sectionname, + section['section_name'], section_id, - current_sectiontype, + section['plex_type'], True) # Delete video node if plex_type != "musicvideos": VNODES.viewNode( - indexnumber=sorted_sections.index(section_name), - tagname=current_sectionname, + indexnumber=index, + tagname=section['section_name'], mediatype=plex_type, viewtype=None, viewid=section_id, @@ -179,17 +176,16 @@ def _process_section(section_xml, kodidb, plexdb, sorted_sections, playlists.append(section_name) # Add new video node if section_name not in nodes and plex_type != "musicvideos": - VNODES.viewNode(sorted_sections.index(section_name), + VNODES.viewNode(index, section_name, plex_type, None, section_id) nodes.append(section_name) - totalnodes += 1 # Update items with new tag for kodi_id in plexdb.kodiid_by_sectionid(section_id, plex_type): kodidb.update_tag( - current_tagid, tagid, kodi_id, current_sectiontype) + section['kodi_tagid'], tagid, kodi_id, section['plex_type']) else: # Validate the playlist exists or recreate it if (section_name not in playlists and plex_type in @@ -200,14 +196,12 @@ def _process_section(section_xml, kodidb, plexdb, sorted_sections, playlists.append(section_name) # Create the video node if not already exists if section_name not in nodes and plex_type != "musicvideos": - VNODES.viewNode(sorted_sections.index(section_name), + VNODES.viewNode(index, section_name, plex_type, None, section_id) nodes.append(section_name) - totalnodes += 1 - return totalnodes def _delete_kodi_db_items(section_id, section_type): @@ -246,25 +240,55 @@ def delete_sections(old_sections): Deletes all elements for a Plex section that has been deleted. (e.g. all TV shows, Seasons and Episodes of a Show section) """ - try: + LOG.info("Removing entire Plex library sections: %s", old_sections) + for section in old_sections: + # "Deleting " + utils.dialog('notification', + heading='{plex}', + message='%s %s' % (utils.lang(30052), section['section_name']), + icon='{plex}', + sound=False) + if section['plex_type'] == v.PLEX_TYPE_PHOTO: + # not synced - just remove the link in our Plex sections table + pass + else: + if not _delete_kodi_db_items(section['section_id'], section['plex_type']): + return + # Only remove Plex entry if we've removed all items first with PlexDB() as plexdb: - old_sections = [plexdb.section(x) for x in old_sections] - LOG.info("Removing entire Plex library sections: %s", old_sections) - for section in old_sections: - # "Deleting " - utils.dialog('notification', - heading='{plex}', - message='%s %s' % (utils.lang(30052), section[1]), - icon='{plex}', - sound=False) - if section[2] == v.PLEX_TYPE_PHOTO: - # not synced - just remove the link in our Plex sections table - pass - else: - if not _delete_kodi_db_items(section[0], section[2]): - return - # Only remove Plex entry if we've removed all items first - with PlexDB() as plexdb: - plexdb.remove_section(section[0]) - finally: - common.update_kodi_library() + plexdb.remove_section(section['section_id']) + + +def choose_libraries(): + """ + Displays a dialog for the user to select the libraries he wants synched + + Returns True if this was successful, False if not + """ + # xbmcgui.Dialog().multiselect(heading, options[, autoclose, preselect, useDetails]) + # "Select Plex libraries to sync" + import xbmcgui + sections = [] + preselect = [] + for i, section in enumerate(SECTIONS): + sections.append(section['section_name']) + if section['plex_type'] == v.PLEX_TYPE_ARTIST: + if section['sync_to_kodi'] and app.SYNC.enable_music: + preselect.append(i) + else: + if section['sync_to_kodi']: + preselect.append(i) + selected = xbmcgui.Dialog().multiselect(utils.lang(30524), + sections, + preselect=preselect, + useDetails=False) + if selected is None: + # User canceled + return False + with PlexDB() as plexdb: + for i, section in enumerate(SECTIONS): + sync = True if i in selected else False + plexdb.update_section_sync(section['section_id'], sync) + sections = list(plexdb.all_sections()) + LOG.info('Plex libraries to sync: %s', sections) + return True diff --git a/resources/lib/plex_db/common.py b/resources/lib/plex_db/common.py index a02e1ea0..f29ad99f 100644 --- a/resources/lib/plex_db/common.py +++ b/resources/lib/plex_db/common.py @@ -194,7 +194,8 @@ def initialize(): section_name TEXT, plex_type TEXT, kodi_tagid INTEGER, - sync_to_kodi INTEGER) + sync_to_kodi INTEGER, + last_sync INTEGER) ''') plexdb.cursor.execute(''' CREATE TABLE IF NOT EXISTS movie( diff --git a/resources/lib/plex_db/sections.py b/resources/lib/plex_db/sections.py index f214b955..a65e3cec 100644 --- a/resources/lib/plex_db/sections.py +++ b/resources/lib/plex_db/sections.py @@ -4,43 +4,39 @@ from __future__ import absolute_import, division, unicode_literals class Sections(object): - def section_ids(self): + def all_sections(self): """ - Returns an iterator for section Plex ids for all sections - """ - self.cursor.execute('SELECT section_id FROM sections') - return (x[0] for x in self.cursor) - - def section_infos(self): - """ - Returns an iterator for dicts for all Plex libraries: - { - 'section_id' - 'section_name' - 'plex_type' - 'kodi_tagid' - 'sync_to_kodi' - } + Returns an iterator for all sections """ self.cursor.execute('SELECT * FROM sections') - return ({'section_id': x[0], - 'section_name': x[1], - 'plex_type': x[2], - 'kodi_tagid': x[3], - 'sync_to_kodi': x[4]} for x in self.cursor) + return (self.entry_to_section(x) for x in self.cursor) def section(self, section_id): """ - For section_id, returns the tuple (or None) + For section_id, returns the dict section_id INTEGER PRIMARY KEY, section_name TEXT, plex_type TEXT, kodi_tagid INTEGER, - sync_to_kodi INTEGER + sync_to_kodi BOOL, + last_sync INTEGER """ self.cursor.execute('SELECT * FROM sections WHERE section_id = ? LIMIT 1', (section_id, )) - return self.cursor.fetchone() + return self.entry_to_section(self.cursor.fetchone()) + + @staticmethod + def entry_to_section(entry): + if not entry: + return + return { + 'section_id': entry[0], + 'section_name': entry[1], + 'plex_type': entry[2], + 'kodi_tagid': entry[3], + 'sync_to_kodi': entry[4] == 1, + 'last_sync': entry[5] + } def section_id_by_name(self, section_name): """ @@ -54,22 +50,35 @@ class Sections(object): pass def add_section(self, section_id, section_name, plex_type, kodi_tagid, - sync_to_kodi=True): + sync_to_kodi, last_sync): """ Appends a Plex section to the Plex sections table sync=False: Plex library won't be synced to Kodi """ query = ''' INSERT OR REPLACE INTO sections( - section_id, section_name, plex_type, kodi_tagid, sync_to_kodi) - VALUES (?, ?, ?, ?, ?) + section_id, + section_name, + plex_type, + kodi_tagid, + sync_to_kodi, + last_sync) + VALUES (?, ?, ?, ?, ?, ?) ''' self.cursor.execute(query, (section_id, section_name, plex_type, kodi_tagid, - sync_to_kodi)) + sync_to_kodi, + last_sync)) + + def update_section(self, section_id, section_name): + """ + Updates the section with section_id + """ + query = 'UPDATE sections SET section_name = ? WHERE section_id = ?' + self.cursor.execute(query, (section_name, section_id)) def remove_section(self, section_id): """ @@ -77,3 +86,35 @@ class Sections(object): """ self.cursor.execute('DELETE FROM sections WHERE section_id = ?', (section_id, )) + + def update_section_sync(self, section_id, sync_to_kodi): + """ + Updates whether we should sync sections_id (sync=True) or not + """ + if sync_to_kodi: + query = ''' + UPDATE sections + SET sync_to_kodi = ? + WHERE section_id = ? + ''' + else: + # Set last_sync = 0 in order to force a full sync if reactivated + query = ''' + UPDATE sections + SET sync_to_kodi = ?, last_sync = 0 + WHERE section_id = ? + ''' + self.cursor.execute(query, (sync_to_kodi, section_id)) + + def update_section_last_sync(self, section_id, last_sync): + """ + Updates the timestamp for the section + """ + self.cursor.execute('UPDATE sections SET last_sync = ? WHERE section_id = ?', + (last_sync, section_id)) + + def force_full_sync(self): + """ + Sets the last_sync flag to 0 for every section + """ + self.cursor.execute('UPDATE sections SET last_sync = 0') diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index b9c7e440..6f14704d 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -9,7 +9,7 @@ import xbmcgui from . import utils, clientinfo, timing from . import initialsetup from . import kodimonitor -from . import sync +from . import sync, library_sync from . import websocket_client from . import plex_companion from . import plex_functions as PF, playqueue as PQ @@ -97,8 +97,7 @@ class Service(): # Load/Reset PKC entirely - important for user/Kodi profile switch # Clear video nodes properties - from .library_sync import videonodes - videonodes.VideoNodes().clearProperties() + library_sync.VideoNodes().clearProperties() clientinfo.getDeviceId() # Init time-offset between Kodi and Plex timing.KODI_PLEX_TIME_OFFSET = float(utils.settings('kodiplextimeoffset') or 0.0) @@ -222,7 +221,7 @@ class Service(): utils.delete_nodes() app.ACCOUNT.set_unauthenticated() # Force full sync after login - utils.settings('lastfullsync', value='0') + library_sync.force_full_sync() app.SYNC.run_lib_scan = 'full' # Enable the main loop to display user selection dialog app.APP.suspend = False @@ -286,6 +285,33 @@ class Service(): LOG.info("Entering PMS address complete") return True + def choose_plex_libraries(self): + if not app.CONN.online: + LOG.error('PMS not online to choose libraries') + # "{0} offline" + utils.dialog('notification', + utils.lang(29999), + utils.lang(39213).format(app.CONN.server_name or ''), + icon='{plex}') + return + if not app.ACCOUNT.authenticated: + LOG.error('Not yet authenticated for PMS to choose libraries') + # "Unauthorized for PMS" + utils.dialog('notification', utils.lang(29999), utils.lang(30017)) + return + app.APP.suspend_threads() + from .library_sync import sections + try: + # Get newest sections from the PMS + if not sections.sync_from_pms(self): + return + if not sections.choose_libraries(): + return + # Force a full sync + app.SYNC.run_lib_scan = 'full' + finally: + app.APP.resume_threads() + def _do_auth(self): LOG.info('Authenticating user') if app.ACCOUNT.plex_username and not app.ACCOUNT.force_login: # Found a user in the settings, try to authenticate @@ -435,6 +461,8 @@ class Service(): app.SYNC.run_lib_scan = 'fanart' elif plex_command == 'textures-scan': app.SYNC.run_lib_scan = 'textures' + elif plex_command == 'select-libraries': + self.choose_plex_libraries() elif plex_command == 'RESET-PKC': utils.reset() if task: diff --git a/resources/lib/utils.py b/resources/lib/utils.py index a0571c1a..5b5f86f1 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -466,7 +466,6 @@ def wipe_database(): kodi_db.reset_cached_images() # reset the install run flag settings('SyncInstallRunDone', value="false") - settings('lastfullsync', value="0") init_dbs() LOG.info('Wiping done') if settings('kodi_db_has_been_wiped_clean') != 'true': diff --git a/resources/settings.xml b/resources/settings.xml index fae79370..642bd24b 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -52,6 +52,7 @@ + @@ -84,7 +85,6 @@ -