#!/usr/bin/env python # -*- coding: utf-8 -*- from __future__ import absolute_import, division, unicode_literals from logging import getLogger import Queue import copy import xbmcgui from .get_metadata import GetMetadataTask, reset_collections from . import common, sections from .. import utils, timing, backgroundthread, variables as v, app from .. import plex_functions as PF, itemtypes from ..plex_db import PlexDB if common.PLAYLIST_SYNC_ENABLED: from .. import playlists LOG = getLogger('PLEX.sync.full_sync') # How many items will be put through the processing chain at once? BATCH_SIZE = 500 # Safety margin to filter PMS items - how many seconds to look into the past? UPDATED_AT_SAFETY = 60 * 5 LAST_VIEWED_AT_SAFETY = 60 * 5 class InitNewSection(object): """ Throw this into the queue used for ProcessMetadata to tell it which Plex library section we're looking at """ def __init__(self, context, total_number_of_items, section_name, section_id, plex_type): self.context = context self.total = total_number_of_items self.name = section_name self.id = section_id self.plex_type = plex_type class FullSync(common.fullsync_mixin): def __init__(self, repair, callback, show_dialog): """ repair=True: force sync EVERY item """ self.repair = repair self.callback = callback self.queue = None self.process_thread = None self.current_sync = None self.plexdb = None self.plex_type = None self.section_type = None self.worker_count = int(utils.settings('syncThreadNumber')) self.item_count = 0 # For progress dialog self.show_dialog = show_dialog self.show_dialog_userdata = utils.settings('playstate_sync_indicator') == 'true' self.dialog = None self.total = 0 self.current = 0 self.processed = 0 self.title = '' self.section = None self.section_name = None self.section_type_text = None 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, worker_count=self.worker_count) super(FullSync, self).__init__() def update_progressbar(self): if self.dialog: try: progress = int(float(self.current) / float(self.total) * 100.0) except ZeroDivisionError: progress = 0 self.dialog.update(progress, '%s (%s)' % (self.section_name, self.section_type_text), '%s %s/%s' % (self.title, self.current, self.total)) if app.APP.is_playing_video: self.dialog.close() self.dialog = None def process_item(self, xml_item): """ Processes a single library item """ plex_id = int(xml_item.get('ratingKey')) if not self.repair and self.plexdb.checksum(plex_id, self.plex_type) == \ int('%s%s' % (plex_id, xml_item.get('updatedAt', xml_item.get('addedAt', 1541572987)))): return self.threader.addTask(GetMetadataTask(self.queue, plex_id, self.plex_type, self.get_children, self.item_count)) self.item_count += 1 def update_library(self): LOG.debug('Writing changes to Kodi library now') i = 0 if not self.section: _, self.section = self.queue.get() self.queue.task_done() while not self.isCanceled() and self.item_count > 0: section = self.section if not section: break LOG.debug('Start or continue processing section %s (%ss)', section.name, section.plex_type) self.processed = 0 self.total = section.total self.section_name = section.name self.section_type_text = utils.lang( v.TRANSLATION_FROM_PLEXTYPE[section.plex_type]) with section.context(self.current_sync) as context: while not self.isCanceled() and self.item_count > 0: try: _, item = self.queue.get(block=False) except backgroundthread.Queue.Empty: if self.threader.threader.working(): app.APP.monitor.waitForAbort(0.02) continue else: # Try again, in case a thread just finished i += 1 if i == 3: break continue i = 0 self.queue.task_done() if isinstance(item, dict): context.add_update(item['xml'][0], section_name=section.name, section_id=section.id, children=item['children']) self.title = item['xml'][0].get('title') self.processed += 1 elif isinstance(item, InitNewSection) or item is None: self.section = item break else: raise ValueError('Unknown type %s' % type(item)) self.item_count -= 1 self.current += 1 self.update_progressbar() if self.processed == 500: self.processed = 0 context.commit() LOG.debug('Done writing changes to Kodi library') @utils.log_time def addupdate_section(self, section): LOG.debug('Processing library section for new or changed items %s', section) if not self.install_sync_done: app.SYNC.path_verified = False try: # Sync new, updated and deleted items iterator = section.iterator # Tell the processing thread about this new section queue_info = InitNewSection(section.context, iterator.total, iterator.get('librarySectionTitle', iterator.get('title1')), section.section_id, section.plex_type) self.queue.put((-1, queue_info)) last = True # To keep track of the item-number in order to kill while loops self.item_count = 0 self.current = 0 # Initialize only once to avoid loosing the last value before # we're breaking the for loop loop = common.tag_last(iterator) while True: # Check Plex DB to see what we need to add/update with PlexDB() as self.plexdb: for last, xml_item in loop: if self.isCanceled(): return False self.process_item(xml_item) if self.item_count == BATCH_SIZE: break # Make sure Plex DB above is closed before adding/updating if self.item_count == BATCH_SIZE: self.update_library() if last: break self.update_library() reset_collections() return True except RuntimeError: LOG.error('Could not entirely process section %s', section) return False @utils.log_time def playstate_per_section(self, section): LOG.debug('Processing %s playstates for library section %s', section.iterator.total, section) try: # Sync new, updated and deleted items iterator = section.iterator # Tell the processing thread about this new section queue_info = InitNewSection(section.context, iterator.total, section.name, section.section_id, section.plex_type) self.queue.put((-1, queue_info)) self.total = iterator.total self.section_name = section.name self.section_type_text = utils.lang( v.TRANSLATION_FROM_PLEXTYPE[section.plex_type]) self.current = 0 last = True loop = common.tag_last(iterator) while True: with section.context(self.current_sync) as itemtype: for i, (last, xml_item) in enumerate(loop): if self.isCanceled(): return False if not itemtype.update_userdata(xml_item, section.plex_type): # Somehow did not sync this item yet itemtype.add_update(xml_item, section_name=section.name, section_id=section.section_id) itemtype.plexdb.update_last_sync(int(xml_item.attrib['ratingKey']), section.plex_type, self.current_sync) self.current += 1 self.update_progressbar() if (i + 1) % (10 * BATCH_SIZE) == 0: break if last: break return True except RuntimeError: LOG.error('Could not entirely process section %s', section) return False def threaded_get_iterators(self, kinds, queue, all_items=False): """ PF.SectionItems is costly, so let's do it asynchronous """ try: for kind in kinds: for section in (x for x in app.SYNC.sections if x.section_type == kind[1]): if self.isCanceled(): LOG.debug('Need to exit now') return if not section.sync_to_kodi: LOG.info('User chose to not sync section %s', section) continue element = copy.deepcopy(section) element.plex_type = kind[0] element.section_type = element.plex_type element.context = kind[2] element.get_children = kind[3] element.Queue = kind[4] 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=element.plex_type, updated_at=updated_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) except Exception: utils.ERROR(notify=True) finally: queue.put(None) def full_library_sync(self): """ """ # structure: # (plex_type, # section_type, # context for itemtype, # download children items, e.g. songs for a specific album?, # Queue) kinds = [ (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_MOVIE, itemtypes.Movie, False, Queue.Queue), (v.PLEX_TYPE_SHOW, v.PLEX_TYPE_SHOW, itemtypes.Show, False, Queue.Queue), (v.PLEX_TYPE_SEASON, v.PLEX_TYPE_SHOW, itemtypes.Season, False, Queue.Queue), (v.PLEX_TYPE_EPISODE, v.PLEX_TYPE_SHOW, itemtypes.Episode, False, Queue.Queue) ] if app.SYNC.enable_music: kinds.extend([ (v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_ARTIST, itemtypes.Artist, False, Queue.Queue), (v.PLEX_TYPE_ALBUM, v.PLEX_TYPE_ARTIST, itemtypes.Album, True, backgroundthread.OrderedQueue), ]) # ADD NEW ITEMS # Already start setting up the iterators. We need to enforce # syncing e.g. show before season before episode iterator_queue = Queue.Queue() task = backgroundthread.FunctionAsTask(self.threaded_get_iterators, None, kinds, iterator_queue) backgroundthread.BGThreader.addTask(task) while True: self.section_success = True section = iterator_queue.get() iterator_queue.task_done() if section is None: break # Setup our variables self.plex_type = section.plex_type self.section_type = section.section_type self.context = section.context self.get_children = section.get_children self.queue = section.Queue() # 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) # Sync Plex playlists to Kodi and vice-versa if common.PLAYLIST_SYNC_ENABLED: if self.show_dialog: if self.dialog: self.dialog.close() self.dialog = xbmcgui.DialogProgressBG() # "Synching playlists" self.dialog.create(utils.lang(39715)) if not playlists.full_sync(): return False # SYNC PLAYSTATE of ALL items (otherwise we won't pick up on items that # were set to unwatched). Also mark all items on the PMS to be able # to delete the ones still in Kodi LOG.info('Start synching playstate and userdata for every item') # In order to not delete all your songs again if app.SYNC.enable_music: # We don't need to enforce the album order now kinds.pop(5) kinds.extend([ (v.PLEX_TYPE_ALBUM, v.PLEX_TYPE_ARTIST, itemtypes.Album, True, Queue.Queue), (v.PLEX_TYPE_SONG, v.PLEX_TYPE_ARTIST, itemtypes.Song, True, Queue.Queue), ]) # Make sure we're not showing an item's title in the sync dialog self.title = '' self.threader.shutdown() self.threader = None if not self.show_dialog_userdata and self.dialog: # Close the progress indicator dialog self.dialog.close() self.dialog = None task = backgroundthread.FunctionAsTask(self.threaded_get_iterators, None, kinds, iterator_queue, all_items=True) backgroundthread.BGThreader.addTask(task) while True: section = iterator_queue.get() iterator_queue.task_done() if section is None: break # Setup our variables self.plex_type = section.plex_type self.section_type = section.section_type self.context = section.context self.get_children = section.get_children # Now do the heavy lifting if self.isCanceled() or not self.playstate_per_section(section): return False # Delete movies that are not on Plex anymore LOG.debug('Looking for items to delete') kinds = [ (v.PLEX_TYPE_MOVIE, itemtypes.Movie), (v.PLEX_TYPE_SHOW, itemtypes.Show), (v.PLEX_TYPE_SEASON, itemtypes.Season), (v.PLEX_TYPE_EPISODE, itemtypes.Episode) ] if app.SYNC.enable_music: kinds.extend([ (v.PLEX_TYPE_ARTIST, itemtypes.Artist), (v.PLEX_TYPE_ALBUM, itemtypes.Album), (v.PLEX_TYPE_SONG, itemtypes.Song) ]) for plex_type, context in kinds: # Delete movies that are not on Plex anymore while True: with context(self.current_sync) as ctx: plex_ids = list(ctx.plexdb.plex_id_by_last_sync(plex_type, self.current_sync, BATCH_SIZE)) for plex_id in plex_ids: if self.isCanceled(): return False ctx.remove(plex_id, plex_type) if len(plex_ids) < BATCH_SIZE: break LOG.debug('Done deleting') return True def run(self): app.APP.register_thread(self) try: self._run() finally: app.APP.deregister_thread(self) LOG.info('Done full_sync') @utils.log_time def _run(self): self.current_sync = timing.plex_now() # Get latest Plex libraries and build playlist and video node files if self.isCanceled() or not sections.sync_from_pms(self): return self.successful = True try: if self.show_dialog: self.dialog = xbmcgui.DialogProgressBG() self.dialog.create(utils.lang(39714)) # Actual syncing - do only new items first LOG.info('Running full_library_sync with repair=%s', self.repair) if self.isCanceled() or not self.full_library_sync(): self.successful = False return finally: common.update_kodi_library(video=True, music=True) if self.dialog: self.dialog.close() if self.threader: self.threader.shutdown() self.threader = None if not self.successful and not self.isCanceled(): # "ERROR in library sync" utils.dialog('notification', heading='{plex}', message=utils.lang(39410), icon='{error}') if self.callback: self.callback(self.successful) def start(show_dialog, repair=False, callback=None): FullSync(repair, callback, show_dialog).run()