From fa6d95aa61e5d3fe411c389bc9b6a4100aea5ab4 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 17 Sep 2017 13:36:59 +0200 Subject: [PATCH] Merge master --- README.md | 9 +- addon.xml | 52 +- contextmenu.py | 49 +- default.py | 29 +- resources/lib/PlexAPI.py | 65 +- resources/lib/PlexFunctions.py | 5 +- resources/lib/artwork.py | 5 +- resources/lib/clientinfo.py | 6 +- resources/lib/command_pipeline.py | 12 +- resources/lib/downloadutils.py | 26 +- resources/lib/entrypoint.py | 10 +- resources/lib/initialsetup.py | 4 +- resources/lib/itemtypes.py | 8 +- resources/lib/kodidb_functions.py | 9 +- resources/lib/kodimonitor.py | 87 ++- resources/lib/library_sync/fanart.py | 5 +- resources/lib/library_sync/get_metadata.py | 14 +- .../lib/library_sync/process_metadata.py | 2 +- resources/lib/library_sync/sync_info.py | 15 +- resources/lib/librarysync.py | 709 ++++++++++-------- resources/lib/loghandler.py | 83 +- resources/lib/pickler.py | 35 +- resources/lib/playback_starter.py | 4 + resources/lib/player.py | 6 +- resources/lib/state.py | 38 + resources/lib/utils.py | 41 +- resources/lib/websocket_client.py | 98 ++- resources/settings.xml | 1 - service.py | 41 +- 29 files changed, 816 insertions(+), 652 deletions(-) diff --git a/README.md b/README.md index dbae4777..fa978ee3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![stable version](https://img.shields.io/badge/stable_version-1.8.5-blue.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect/bin/repository.plexkodiconnect/repository.plexkodiconnect-1.0.0.zip) -[![beta version](https://img.shields.io/badge/beta_version-1.8.5-red.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect_BETA/bin-BETA/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.0.zip) +[![stable version](https://img.shields.io/badge/stable_version-1.8.12-blue.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect/bin/repository.plexkodiconnect/repository.plexkodiconnect-1.0.0.zip) +[![beta version](https://img.shields.io/badge/beta_version-1.8.14-red.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect_BETA/bin-BETA/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.0.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) @@ -105,14 +105,11 @@ I'm not in any way affiliated with Plex. Thank you very much for a small donatio Solutions are unlikely due to the nature of these issues - A Plex Media Server "bug" leads to frequent and slow syncs, see [here for more info](https://github.com/croneter/PlexKodiConnect/issues/135) -- *Plex Music when using Addon paths instead of Native Direct Paths:* Kodi tries to scan every(!) single Plex song on startup. This leads to errors in the Kodi log file and potentially even crashes. See the [Github issue](https://github.com/croneter/PlexKodiConnect/issues/14) for more details +- *Plex Music when using Addon paths instead of Native Direct Paths:* Kodi tries to scan every(!) single Plex song on startup. This leads to errors in the Kodi log file and potentially even crashes. See the [Github issues](https://github.com/croneter/PlexKodiConnect/issues/14) for more details. **Workaround**: use [PKC direct paths](https://github.com/croneter/PlexKodiConnect/wiki/Set-up-Direct-Paths) instead of addon paths. *Background Sync:* The Plex Server does not tell anyone of the following changes. Hence PKC cannot detect these changes instantly but will notice them only on full/delta syncs (standard settings is every 60 minutes) - Toggle the viewstate of an item to (un)watched outside of Kodi -- Changing details of an item, e.g. replacing a poster - -However, some changes to individual items are instantly detected, e.g. if you match a yet unrecognized movie. ### Issues being worked on diff --git a/addon.xml b/addon.xml index 04d6d22e..8c4a7abb 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -59,7 +59,55 @@ Indbygget Integration af Plex i Kodi Tilslut Kodi til din Plex Media Server. Dette plugin forudsætter, at du administrere alle dine videoer med Plex (og ikke med Kodi). Du kan miste data som allerede er gemt i Kodi video og musik-databaser (dette plugin ændrer direkte i dem). Brug på eget ansvar! Brug på eget ansvar - version 1.8.6: + version 1.8.14 (beta only): +- Greatly speed up displaying context menu +- Fix IndexError e.g. for channels if stream info missing +- Sleep a bit before marking item as fully watched +- Don't sleep before updating playstate to fully watched (if you watch on another Plex client) +- Fix KeyError for TV live channels for getGeople + +version 1.8.13 (beta only): +- Background sync now picks up more PMS changes +- Detect Plex item deletion more reliably +- Fix changed Plex metadata not synced repeatedly +- Detect (some, not all) changes to PKC settings and apply them on-the-fly +- Fix resuming interrupted sync +- PKC logging now uses Kodi log levels +- Further code optimizations + +version 1.8.12: +- Fix library sync crashing trying to display an error + +version 1.8.11: +- version 1.8.10 for everybody + +version 1.8.10 (beta only): +- Vastly improve sync speed for music +- Never show library sync dialog if media is playing +- Improvements to sync dialog +- Fix stop synching if path not found +- Resume aborted sync on PKC settings change +- Don't quit library sync if failed repeatedly +- Verify path for every Plex library on install sync +- More descriptive downloadable subtitles +- More code fixes and optimization + +version 1.8.9 +- Fix playback not starting in some circumstances +- Deactivate some annoying popups on install + +version 1.8.8 +- Fix playback not starting in some circumstances +- Fix first artist "missing" tag (Reset your DB!) +- Update Czech translation + +version 1.8.7 (beta only): +- Some fixes to playstate reporting, thanks @RickDB +- Add Kodi info screen for episodes in context menu +- Fix PKC asking for trailers not working +- Fix PKC not automatically updating + +version 1.8.6: - Portuguese translation, thanks @goncalo532 - Updated other translations diff --git a/contextmenu.py b/contextmenu.py index df6de3fd..1ab7f553 100644 --- a/contextmenu.py +++ b/contextmenu.py @@ -1,52 +1,41 @@ # -*- coding: utf-8 -*- ############################################################################### +from os import path as os_path +from sys import path as sys_path -import logging -import os -import sys +from xbmcaddon import Addon +from xbmc import translatePath, sleep, log, LOGERROR +from xbmcgui import Window -import xbmc -import xbmcaddon - -############################################################################### - -_addon = xbmcaddon.Addon(id='plugin.video.plexkodiconnect') +_addon = Addon(id='plugin.video.plexkodiconnect') try: _addon_path = _addon.getAddonInfo('path').decode('utf-8') except TypeError: _addon_path = _addon.getAddonInfo('path').decode() try: - _base_resource = xbmc.translatePath(os.path.join( + _base_resource = translatePath(os_path.join( _addon_path, 'resources', 'lib')).decode('utf-8') except TypeError: - _base_resource = xbmc.translatePath(os.path.join( + _base_resource = translatePath(os_path.join( _addon_path, 'resources', 'lib')).decode() -sys.path.append(_base_resource) +sys_path.append(_base_resource) -############################################################################### - -import loghandler -from context_entry import ContextMenu - -############################################################################### - -loghandler.config() -log = logging.getLogger("PLEX.contextmenu") +from pickler import unpickle_me, pickl_window ############################################################################### if __name__ == "__main__": - - try: - # Start the context menu - ContextMenu() - except Exception as error: - log.exception(error) - import traceback - log.exception("Traceback:\n%s" % traceback.format_exc()) - raise + win = Window(10000) + while win.getProperty('plex_command'): + sleep(20) + win.setProperty('plex_command', 'CONTEXT_menu') + while not pickl_window('plex_result'): + sleep(50) + result = unpickle_me() + if result is None: + log('PLEX.%s: Error encountered, aborting' % __name__, level=LOGERROR) diff --git a/default.py b/default.py index 96983316..ec25242f 100644 --- a/default.py +++ b/default.py @@ -32,9 +32,9 @@ sys_path.append(_base_resource) ############################################################################### import entrypoint -from utils import window, pickl_window, reset, passwordsXML, language as lang,\ - dialog -from pickler import unpickle_me +from utils import window, reset, passwordsXML, language as lang, dialog, \ + plex_command +from pickler import unpickle_me, pickl_window from PKC_listitem import convert_PKC_to_listitem import variables as v @@ -127,28 +127,29 @@ class Main(): log.error('Not connected to a PMS.') else: if mode == 'repair': - window('plex_runLibScan', value='repair') log.info('Requesting repair lib sync') + plex_command('RUN_LIB_SCAN', 'repair') elif mode == 'manualsync': log.info('Requesting full library scan') - window('plex_runLibScan', value='full') + plex_command('RUN_LIB_SCAN', 'full') elif mode == 'texturecache': - window('plex_runLibScan', value='del_textures') + log.info('Requesting texture caching of all textures') + plex_command('RUN_LIB_SCAN', 'textures') elif mode == 'chooseServer': entrypoint.chooseServer() elif mode == 'refreshplaylist': log.info('Requesting playlist/nodes refresh') - window('plex_runLibScan', value='views') + plex_command('RUN_LIB_SCAN', 'views') elif mode == 'deviceid': self.deviceid() elif mode == 'fanart': log.info('User requested fanarttv refresh') - window('plex_runLibScan', value='fanart') + plex_command('RUN_LIB_SCAN', 'fanart') elif '/extrafanart' in argv[0]: plexpath = argv[2][1:] @@ -165,15 +166,13 @@ class Main(): else: entrypoint.doMainListing(content_type=params.get('content_type')) - def play(self): + @staticmethod + def play(): """ Start up playback_starter in main Python thread """ # Put the request into the 'queue' - while window('plex_command'): - sleep(50) - window('plex_command', - value='play_%s' % argv[2]) + plex_command('PLAY', argv[2]) # Wait for the result while not pickl_window('plex_result'): sleep(50) @@ -190,7 +189,8 @@ class Main(): listitem = convert_PKC_to_listitem(result.listitem) setResolvedUrl(HANDLE, True, listitem) - def deviceid(self): + @staticmethod + def deviceid(): deviceId_old = window('plex_client_Id') from clientinfo import getDeviceId try: @@ -205,6 +205,7 @@ class Main(): dialog('ok', lang(29999), lang(33033)) executebuiltin('RestartApp') + if __name__ == '__main__': log.info('%s started' % v.ADDON_ID) Main() diff --git a/resources/lib/PlexAPI.py b/resources/lib/PlexAPI.py index 65db0967..8de6fbcf 100644 --- a/resources/lib/PlexAPI.py +++ b/resources/lib/PlexAPI.py @@ -49,7 +49,7 @@ from xbmcvfs import exists import clientinfo as client from downloadutils import DownloadUtils from utils import window, settings, language as lang, tryDecode, tryEncode, \ - DateToKodi, exists_dir + DateToKodi, exists_dir, slugify from PlexFunctions import PMSHttpsEnabled import plexdb_functions as plexdb import variables as v @@ -1346,14 +1346,18 @@ class API(): cast = [] producer = [] for child in self.item: - if child.tag == 'Director': - director.append(child.attrib['tag']) - elif child.tag == 'Writer': - writer.append(child.attrib['tag']) - elif child.tag == 'Role': - cast.append(child.attrib['tag']) - elif child.tag == 'Producer': - producer.append(child.attrib['tag']) + try: + if child.tag == 'Director': + director.append(child.attrib['tag']) + elif child.tag == 'Writer': + writer.append(child.attrib['tag']) + elif child.tag == 'Role': + cast.append(child.attrib['tag']) + elif child.tag == 'Producer': + producer.append(child.attrib['tag']) + except KeyError: + log.warn('Malformed PMS answer for getPeople: %s: %s' + % (child.tag, child.attrib)) return { 'Director': director, 'Writer': writer, @@ -1750,8 +1754,16 @@ class API(): videotracks = [] audiotracks = [] subtitlelanguages = [] - # Sometimes, aspectratio is on the "toplevel" - aspectratio = self.item[0].attrib.get('aspectRatio', None) + try: + # Sometimes, aspectratio is on the "toplevel" + aspectratio = self.item[0].attrib.get('aspectRatio', None) + except IndexError: + # There is no stream info at all, returning empty + return { + 'video': videotracks, + 'audio': audiotracks, + 'subtitle': subtitlelanguages + } # TODO: what if several Media tags exist?!? # Loop over parts for child in self.item[0]: @@ -2357,11 +2369,11 @@ class API(): # ext = stream.attrib.get('format') if key: # We do know the language - temporarily download - if stream.attrib.get('languageCode') is not None: + if stream.attrib.get('language') is not None: path = self.download_external_subtitles( "{server}%s" % key, "subtitle%02d.%s.%s" % (fileindex, - stream.attrib['languageCode'], + stream.attrib['language'], stream.attrib['codec'])) fileindex += 1 # We don't know the language - no need to download @@ -2395,9 +2407,14 @@ class API(): log.error('Could not temporarily download subtitle %s' % url) return else: - r.encoding = 'utf-8' - with open(path, 'wb') as f: - f.write(r.content) + log.debug('Writing temp subtitle to %s' % path) + try: + with open(path, 'wb') as f: + f.write(r.content) + except UnicodeEncodeError: + log.debug('Need to slugify the filename %s' % path) + with open(slugify(path), 'wb') as f: + f.write(r.content) return path def GetKodiPremierDate(self): @@ -2575,16 +2592,16 @@ class API(): if path is None: return None typus = v.REMAP_TYPE_FROM_PLEXTYPE[typus] - if window('remapSMB') == 'true': - path = path.replace(window('remapSMB%sOrg' % typus), - window('remapSMB%sNew' % typus), + if state.REMAP_PATH is True: + path = path.replace(getattr(state, 'remapSMB%sOrg' % typus), + getattr(state, 'remapSMB%sNew' % typus), 1) # There might be backslashes left over: path = path.replace('\\', '/') - elif window('replaceSMB') == 'true': + elif state.REPLACE_SMB_PATH is True: if path.startswith('\\\\'): path = 'smb:' + path.replace('\\', '/') - if ((window('plex_pathverified') == 'true' and forceCheck is False) or + if ((state.PATH_VERIFIED and forceCheck is False) or omitCheck is True): return path @@ -2612,12 +2629,12 @@ class API(): if self.askToValidate(path): state.STOP_SYNC = True path = None - window('plex_pathverified', value='true') + state.PATH_VERIFIED = True else: path = None elif forceCheck is False: - if window('plex_pathverified') != 'true': - window('plex_pathverified', value='true') + # Only set the flag if we were not force-checking the path + state.PATH_VERIFIED = True return path def askToValidate(self, url): diff --git a/resources/lib/PlexFunctions.py b/resources/lib/PlexFunctions.py index e6e9954e..94cfcda5 100644 --- a/resources/lib/PlexFunctions.py +++ b/resources/lib/PlexFunctions.py @@ -15,7 +15,7 @@ from variables import PLEX_TO_KODI_TIMEFACTOR log = getLogger("PLEX."+__name__) CONTAINERSIZE = int(settings('limitindex')) - +REGEX_PLEX_KEY = re.compile(r'''/(.+)/(\d+)$''') ############################################################################### @@ -36,9 +36,8 @@ def GetPlexKeyNumber(plexKey): Returns ('','') if nothing is found """ - regex = re.compile(r'''/(.+)/(\d+)$''') try: - result = regex.findall(plexKey)[0] + result = REGEX_PLEX_KEY.findall(plexKey)[0] except IndexError: result = ('', '') return result diff --git a/resources/lib/artwork.py b/resources/lib/artwork.py index 1a310921..ce2edc34 100644 --- a/resources/lib/artwork.py +++ b/resources/lib/artwork.py @@ -126,8 +126,9 @@ def double_urldecode(text): return unquote(unquote(text)) -@thread_methods(add_stops=['STOP_SYNC'], - add_suspends=['SUSPEND_LIBRARY_THREAD', 'DB_SCAN']) +@thread_methods(add_suspends=['SUSPEND_LIBRARY_THREAD', + 'DB_SCAN', + 'STOP_SYNC']) class Image_Cache_Thread(Thread): xbmc_host = 'localhost' xbmc_port, xbmc_username, xbmc_password = setKodiWebServerDetails() diff --git a/resources/lib/clientinfo.py b/resources/lib/clientinfo.py index 91cb6b91..dfddae5f 100644 --- a/resources/lib/clientinfo.py +++ b/resources/lib/clientinfo.py @@ -68,13 +68,13 @@ def getDeviceId(reset=False): # Because Kodi appears to cache file settings!! if clientId != "" and reset is False: window('plex_client_Id', value=clientId) - log.warn("Unique device Id plex_client_Id loaded: %s" % clientId) + log.info("Unique device Id plex_client_Id loaded: %s" % clientId) return clientId - log.warn("Generating a new deviceid.") + log.info("Generating a new deviceid.") from uuid import uuid4 clientId = str(uuid4()) settings('plex_client_Id', value=clientId) window('plex_client_Id', value=clientId) - log.warn("Unique device Id plex_client_Id loaded: %s" % clientId) + log.info("Unique device Id plex_client_Id loaded: %s" % clientId) return clientId diff --git a/resources/lib/command_pipeline.py b/resources/lib/command_pipeline.py index 64fc799c..92c6cc57 100644 --- a/resources/lib/command_pipeline.py +++ b/resources/lib/command_pipeline.py @@ -21,9 +21,6 @@ class Monitor_Window(Thread): Monitors window('plex_command') for new entries that we need to take care of, e.g. for new plays initiated on the Kodi side with addon paths. - Possible values of window('plex_command'): - 'play_....': to start playback using playback_starter - Adjusts state.py accordingly """ # Borg - multiple instances, shared state @@ -40,9 +37,8 @@ class Monitor_Window(Thread): if window('plex_command'): value = window('plex_command') window('plex_command', clear=True) - if value.startswith('play_'): - queue.put(value) - + if value.startswith('PLAY-'): + queue.put(value.replace('PLAY-', '')) elif value == 'SUSPEND_LIBRARY_THREAD-True': state.SUSPEND_LIBRARY_THREAD = True elif value == 'SUSPEND_LIBRARY_THREAD-False': @@ -64,6 +60,10 @@ class Monitor_Window(Thread): elif value.startswith('PLEX_USERNAME-'): state.PLEX_USERNAME = \ value.replace('PLEX_USERNAME-', '') or None + elif value.startswith('RUN_LIB_SCAN-'): + state.RUN_LIB_SCAN = value.replace('RUN_LIB_SCAN-', '') + elif value == 'CONTEXT_menu': + queue.put('dummy?mode=context_menu') else: raise NotImplementedError('%s not implemented' % value) else: diff --git a/resources/lib/downloadutils.py b/resources/lib/downloadutils.py index a30ab4d9..53eb8d84 100644 --- a/resources/lib/downloadutils.py +++ b/resources/lib/downloadutils.py @@ -198,12 +198,12 @@ class DownloadUtils(): # THE EXCEPTIONS except requests.exceptions.ConnectionError as e: # Connection error - log.debug("Server unreachable at: %s" % url) - log.debug(e) + log.warn("Server unreachable at: %s" % url) + log.warn(e) except requests.exceptions.Timeout as e: - log.debug("Server timeout at: %s" % url) - log.debug(e) + log.warn("Server timeout at: %s" % url) + log.warn(e) except requests.exceptions.HTTPError as e: log.warn('HTTP Error at %s' % url) @@ -300,21 +300,21 @@ class DownloadUtils(): # update pass else: - log.error("Unable to convert the response for: " - "%s" % url) - log.info("Received headers were: %s" % r.headers) - log.info('Received text:') - log.info(r.text) + log.warn("Unable to convert the response for: " + "%s" % url) + log.warn("Received headers were: %s" % r.headers) + log.warn('Received text:') + log.warn(r.text) return True elif r.status_code == 403: # E.g. deleting a PMS item - log.error('PMS sent 403: Forbidden error for url %s' % url) + log.warn('PMS sent 403: Forbidden error for url %s' % url) return None else: - log.error('Unknown answer from PMS %s with status code %s. ' - 'Message:' % (url, r.status_code)) + log.warn('Unknown answer from PMS %s with status code %s. ' + 'Message:' % (url, r.status_code)) r.encoding = 'utf-8' - log.info(r.text) + log.warn(r.text) return True # And now deal with the consequences of the exceptions diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index 5dfc93d2..de4b9f83 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -575,14 +575,6 @@ def getExtraFanArt(plexid, plexPath): xbmcplugin.endOfDirectory(HANDLE) -def RunLibScan(mode): - if window('plex_online') != "true": - # Server is not online, do not run the sync - dialog('ok', lang(29999), lang(39205)) - else: - window('plex_runLibScan', value='full') - - def getOnDeck(viewid, mediatype, tagname, limit): """ Retrieves Plex On Deck items, currently only for TV shows @@ -975,7 +967,7 @@ def __LogIn(): SUSPEND_LIBRARY_THREAD is set to False in service.py if user was signed out! """ - window('plex_runLibScan', value='full') + plex_command('RUN_LIB_SCAN', 'full') # Restart user client plex_command('SUSPEND_USER_CLIENT', 'False') diff --git a/resources/lib/initialsetup.py b/resources/lib/initialsetup.py index f29afa72..730517e9 100644 --- a/resources/lib/initialsetup.py +++ b/resources/lib/initialsetup.py @@ -496,10 +496,10 @@ class InitialSetup(): # If you use several Plex libraries of one kind, e.g. "Kids Movies" and # "Parents Movies", be sure to check https://goo.gl/JFtQV9 - dialog.ok(heading=lang(29999), line1=lang(39076)) + # dialog.ok(heading=lang(29999), line1=lang(39076)) # Need to tell about our image source for collections: themoviedb.org - dialog.ok(heading=lang(29999), line1=lang(39717)) + # dialog.ok(heading=lang(29999), line1=lang(39717)) # Make sure that we only ask these questions upon first installation settings('InstallQuestionsAnswered', value='true') diff --git a/resources/lib/itemtypes.py b/resources/lib/itemtypes.py index f244844b..7261861f 100644 --- a/resources/lib/itemtypes.py +++ b/resources/lib/itemtypes.py @@ -161,8 +161,9 @@ class Items(object): # If offset exceeds duration skip update if item['viewOffset'] > item['duration']: - log.error("Error while updating play state, viewOffset exceeded duration") - return + log.error("Error while updating play state, viewOffset " + "exceeded duration") + return complete = float(item['viewOffset']) / float(item['duration']) log.info('Item %s stopped with completion rate %s percent.' @@ -170,7 +171,6 @@ class Items(object): % (item['ratingKey'], str(complete), MARK_PLAYED_AT), 1) if complete >= MARK_PLAYED_AT: log.info('Marking as completely watched in Kodi') - sleep(500) try: item['viewCount'] += 1 except TypeError: @@ -1729,7 +1729,7 @@ class Music(Items): if album is None or album == 401: log.error('Could not download album, abort') return - self.add_updateAlbum(album[0]) + self.add_updateAlbum(album[0], children=[item]) plex_dbalbum = plex_db.getItem_byId(plex_albumId) try: albumid = plex_dbalbum[0] diff --git a/resources/lib/kodidb_functions.py b/resources/lib/kodidb_functions.py index 503dfcc2..5f289dac 100644 --- a/resources/lib/kodidb_functions.py +++ b/resources/lib/kodidb_functions.py @@ -1280,7 +1280,14 @@ class Kodidb_Functions(): try: artistid = self.cursor.fetchone()[0] except TypeError: - self.cursor.execute("select coalesce(max(idArtist),0) from artist") + # Krypton has a dummy first entry idArtist: 1 strArtist: + # [Missing Tag] strMusicBrainzArtistID: Artist Tag Missing + if v.KODIVERSION >= 17: + self.cursor.execute( + "select coalesce(max(idArtist),1) from artist") + else: + self.cursor.execute( + "select coalesce(max(idArtist),0) from artist") artistid = self.cursor.fetchone()[0] + 1 query = ( ''' diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index fde49a13..27db9a73 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -2,24 +2,48 @@ ############################################################################### -import logging +from logging import getLogger from json import loads from xbmc import Monitor, Player, sleep -import downloadutils +from downloadutils import DownloadUtils import plexdb_functions as plexdb -from utils import window, settings, CatchExceptions, tryDecode, tryEncode +from utils import window, settings, CatchExceptions, tryDecode, tryEncode, \ + plex_command from PlexFunctions import scrobble from kodidb_functions import get_kodiid_from_filename from PlexAPI import API -from variables import REMAP_TYPE_FROM_PLEXTYPE import state ############################################################################### -log = logging.getLogger("PLEX."+__name__) +log = getLogger("PLEX."+__name__) +# settings: window-variable +WINDOW_SETTINGS = { + 'enableContext': 'plex_context', + 'plex_restricteduser': 'plex_restricteduser', + 'force_transcode_pix': 'plex_force_transcode_pix', + 'fetch_pms_item_number': 'fetch_pms_item_number' +} + +# settings: state-variable (state.py) +# Need to use getattr and setattr! +STATE_SETTINGS = { + 'dbSyncIndicator': 'SYNC_DIALOG', + 'remapSMB': 'REMAP_PATH', + 'remapSMBmovieOrg': 'remapSMBmovieOrg', + 'remapSMBmovieNew': 'remapSMBmovieNew', + 'remapSMBtvOrg': 'remapSMBtvOrg', + 'remapSMBtvNew': 'remapSMBtvNew', + 'remapSMBmusicOrg': 'remapSMBmusicOrg', + 'remapSMBmusicNew': 'remapSMBmusicNew', + 'remapSMBphotoOrg': 'remapSMBphotoOrg', + 'remapSMBphotoNew': 'remapSMBphotoNew', + 'enableMusic': 'ENABLE_MUSIC', + 'enableBackgroundSync': 'BACKGROUND_SYNC' +} ############################################################################### @@ -27,7 +51,7 @@ class KodiMonitor(Monitor): def __init__(self, callback): self.mgr = callback - self.doUtils = downloadutils.DownloadUtils().downloadUrl + self.doUtils = DownloadUtils().downloadUrl self.xbmcplayer = Player() self.playqueue = self.mgr.playqueue Monitor.__init__(self) @@ -47,31 +71,42 @@ class KodiMonitor(Monitor): """ Monitor the PKC settings for changes made by the user """ - # settings: window-variable - items = { - 'logLevel': 'plex_logLevel', - 'enableContext': 'plex_context', - 'plex_restricteduser': 'plex_restricteduser', - 'dbSyncIndicator': 'dbSyncIndicator', - 'remapSMB': 'remapSMB', - 'replaceSMB': 'replaceSMB', - 'force_transcode_pix': 'plex_force_transcode_pix', - 'fetch_pms_item_number': 'fetch_pms_item_number' - } - # Path replacement - for typus in REMAP_TYPE_FROM_PLEXTYPE.values(): - for arg in ('Org', 'New'): - key = 'remapSMB%s%s' % (typus, arg) - items[key] = key + log.debug('PKC settings change detected') + changed = False # Reset the window variables from the settings variables - for settings_value, window_value in items.iteritems(): + for settings_value, window_value in WINDOW_SETTINGS.iteritems(): if window(window_value) != settings(settings_value): - log.debug('PKC settings changed: %s is now %s' + changed = True + log.debug('PKC window settings changed: %s is now %s' % (settings_value, settings(settings_value))) window(window_value, value=settings(settings_value)) if settings_value == 'fetch_pms_item_number': log.info('Requesting playlist/nodes refresh') - window('plex_runLibScan', value="views") + plex_command('RUN_LIB_SCAN', 'views') + # Reset the state variables in state.py + for settings_value, state_name in STATE_SETTINGS.iteritems(): + new = settings(settings_value) + if new == 'true': + new = True + elif new == 'false': + new = False + if getattr(state, state_name) != new: + changed = True + log.debug('PKC state settings %s changed from %s to %s' + % (settings_value, getattr(state, state_name), new)) + setattr(state, state_name, new) + # Special cases, overwrite all internal settings + state.FULL_SYNC_INTERVALL = int(settings('fullSyncInterval'))*60 + state.BACKGROUNDSYNC_SAFTYMARGIN = int( + settings('backgroundsync_saftyMargin')) + state.SYNC_THREAD_NUMBER = int(settings('syncThreadNumber')) + # Never set through the user + # state.KODI_PLEX_TIME_OFFSET = float(settings('kodiplextimeoffset')) + if changed is True: + # Assume that the user changed the settings so that we can now find + # the path to all media files + state.STOP_SYNC = False + state.PATH_VERIFIED = False @CatchExceptions(warnuser=False) def onNotification(self, sender, method, data): @@ -137,7 +172,7 @@ class KodiMonitor(Monitor): elif method == "GUI.OnScreensaverDeactivated": if settings('dbSyncScreensaver') == "true": sleep(5000) - window('plex_runLibScan', value="full") + plex_command('RUN_LIB_SCAN', 'full') elif method == "System.OnQuit": log.info('Kodi OnQuit detected - shutting down') diff --git a/resources/lib/library_sync/fanart.py b/resources/lib/library_sync/fanart.py index 1fdcb4e7..620f341d 100644 --- a/resources/lib/library_sync/fanart.py +++ b/resources/lib/library_sync/fanart.py @@ -17,8 +17,9 @@ log = getLogger("PLEX."+__name__) ############################################################################### -@thread_methods(add_suspends=['SUSPEND_LIBRARY_THREAD', 'DB_SCAN'], - add_stops=['STOP_SYNC']) +@thread_methods(add_suspends=['SUSPEND_LIBRARY_THREAD', + 'DB_SCAN', + 'STOP_SYNC']) class Process_Fanart_Thread(Thread): """ Threaded download of additional fanart in the background diff --git a/resources/lib/library_sync/get_metadata.py b/resources/lib/library_sync/get_metadata.py index ed3e187e..afe47b5b 100644 --- a/resources/lib/library_sync/get_metadata.py +++ b/resources/lib/library_sync/get_metadata.py @@ -16,7 +16,7 @@ log = getLogger("PLEX."+__name__) ############################################################################### -@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD']) +@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD', 'STOP_SYNC']) class Threaded_Get_Metadata(Thread): """ Threaded download of Plex XML metadata for a certain library item. @@ -115,17 +115,9 @@ class Threaded_Get_Metadata(Thread): except (TypeError, IndexError, AttributeError): log.error('Could not get children for Plex id %s' % item['itemId']) - else: item['children'] = [] - for child in children_xml: - child_xml = GetPlexMetadata(child.attrib['ratingKey']) - try: - child_xml[0].attrib - except (TypeError, IndexError, AttributeError): - log.error('Could not get child for Plex id %s' - % child.attrib['ratingKey']) - else: - item['children'].append(child_xml[0]) + else: + item['children'] = children_xml # place item into out queue out_queue.put(item) diff --git a/resources/lib/library_sync/process_metadata.py b/resources/lib/library_sync/process_metadata.py index c4c599a4..cdefa952 100644 --- a/resources/lib/library_sync/process_metadata.py +++ b/resources/lib/library_sync/process_metadata.py @@ -15,7 +15,7 @@ log = getLogger("PLEX."+__name__) ############################################################################### -@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD']) +@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD', 'STOP_SYNC']) class Threaded_Process_Metadata(Thread): """ Not yet implemented for more than 1 thread - if ever. Only to be called by diff --git a/resources/lib/library_sync/sync_info.py b/resources/lib/library_sync/sync_info.py index b2dd98d8..494b499a 100644 --- a/resources/lib/library_sync/sync_info.py +++ b/resources/lib/library_sync/sync_info.py @@ -2,7 +2,8 @@ from logging import getLogger from threading import Thread, Lock -from xbmc import sleep +from xbmc import sleep, Player +from xbmcgui import DialogProgressBG from utils import thread_methods, language as lang @@ -18,18 +19,17 @@ LOCK = Lock() ############################################################################### -@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD']) +@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD', 'STOP_SYNC']) class Threaded_Show_Sync_Info(Thread): """ Threaded class to show the Kodi statusbar of the metadata download. Input: - dialog xbmcgui.DialogProgressBG() object to show progress total: Total number of items to get + item_type: """ - def __init__(self, dialog, total, item_type): + def __init__(self, total, item_type): self.total = total - self.dialog = dialog self.item_type = item_type Thread.__init__(self) @@ -51,14 +51,15 @@ class Threaded_Show_Sync_Info(Thread): log.debug('Show sync info thread started') # cache local variables because it's faster total = self.total - dialog = self.dialog + dialog = DialogProgressBG('dialoglogProgressBG') thread_stopped = self.thread_stopped dialog.create("%s %s: %s %s" % (lang(39714), self.item_type, str(total), lang(39715))) + player = Player() total = 2 * total totalProgress = 0 - while thread_stopped() is False: + while thread_stopped() is False and not player.isPlaying(): with LOCK: get_progress = GET_METADATA_COUNT process_progress = PROCESS_METADATA_COUNT diff --git a/resources/lib/librarysync.py b/resources/lib/librarysync.py index 40078e45..6b3e8560 100644 --- a/resources/lib/librarysync.py +++ b/resources/lib/librarysync.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- ############################################################################### -import logging +from logging import getLogger from threading import Thread import Queue from random import shuffle import xbmc -import xbmcgui from xbmcvfs import exists from utils import window, settings, getUnixTimestamp, sourcesXML,\ @@ -22,7 +21,8 @@ import videonodes import variables as v from PlexFunctions import GetPlexMetadata, GetAllPlexLeaves, scrobble, \ - GetPlexSectionResults, GetAllPlexChildren, GetPMSStatus, get_plex_sections + GetPlexSectionResults, GetPlexKeyNumber, GetPMSStatus, get_plex_sections, \ + GetAllPlexChildren import PlexAPI from library_sync.get_metadata import Threaded_Get_Metadata from library_sync.process_metadata import Threaded_Process_Metadata @@ -33,84 +33,77 @@ import state ############################################################################### -log = logging.getLogger("PLEX."+__name__) +log = getLogger("PLEX."+__name__) ############################################################################### -@thread_methods(add_stops=['STOP_SYNC'], - add_suspends=['SUSPEND_LIBRARY_THREAD']) +@thread_methods(add_suspends=['SUSPEND_LIBRARY_THREAD', 'STOP_SYNC']) class LibrarySync(Thread): """ """ def __init__(self, callback=None): self.mgr = callback - # Dict of items we just processed in order to prevent a reprocessing - # caused by websocket - self.just_processed = {} - # How long do we wait until we start re-processing? (in seconds) - self.ignore_just_processed = 10*60 self.itemsToProcess = [] self.sessionKeys = [] self.fanartqueue = Queue.Queue() if settings('FanartTV') == 'true': self.fanartthread = Process_Fanart_Thread(self.fanartqueue) # How long should we wait at least to process new/changed PMS items? - self.saftyMargin = int(settings('backgroundsync_saftyMargin')) - - self.fullSyncInterval = int(settings('fullSyncInterval')) * 60 self.user = userclient.UserClient() self.vnodes = videonodes.VideoNodes() - self.dialog = xbmcgui.Dialog() + self.xbmcplayer = xbmc.Player() - self.syncThreadNumber = int(settings('syncThreadNumber')) self.installSyncDone = settings('SyncInstallRunDone') == 'true' - window('dbSyncIndicator', value=settings('dbSyncIndicator')) - self.enableMusic = settings('enableMusic') == "true" - self.enableBackgroundSync = settings( - 'enableBackgroundSync') == "true" + state.FULL_SYNC_INTERVALL = int(settings('fullSyncInterval')) * 60 + state.SYNC_THREAD_NUMBER = int(settings('syncThreadNumber')) + state.SYNC_DIALOG = settings('dbSyncIndicator') == 'true' + state.ENABLE_MUSIC = settings('enableMusic') == 'true' + state.BACKGROUND_SYNC = settings( + 'enableBackgroundSync') == 'true' + state.BACKGROUNDSYNC_SAFTYMARGIN = int( + settings('backgroundsync_saftyMargin')) + + # Show sync dialog even if user deactivated? + self.force_dialog = True # Init for replacing paths - window('remapSMB', value=settings('remapSMB')) - window('replaceSMB', value=settings('replaceSMB')) + state.REPLACE_SMB_PATH = settings('replaceSMB') == 'true' + state.REMAP_PATH = settings('remapSMB') == 'true' for typus in v.REMAP_TYPE_FROM_PLEXTYPE.values(): for arg in ('Org', 'New'): key = 'remapSMB%s%s' % (typus, arg) - window(key, value=settings(key)) + setattr(state, key, settings(key)) # Just in case a time sync goes wrong - self.timeoffset = int(settings('kodiplextimeoffset')) - window('kodiplextimeoffset', value=str(self.timeoffset)) + state.KODI_PLEX_TIME_OFFSET = float(settings('kodiplextimeoffset')) Thread.__init__(self) - def showKodiNote(self, message, forced=False, icon="plex"): + def showKodiNote(self, message, icon="plex"): """ Shows a Kodi popup, if user selected to do so. Pass message in unicode or string icon: "plex": shows Plex icon "error": shows Kodi error icon - - forced: always show popup, even if user setting to off """ - if settings('dbSyncIndicator') != 'true': - if not forced: - return + if self.xbmcplayer.isPlaying(): + # Don't show any dialog if media is playing + return + if state.SYNC_DIALOG is not True and self.force_dialog is not True: + return if icon == "plex": - self.dialog.notification( - lang(29999), - message, - "special://home/addons/plugin.video.plexkodiconnect/icon.png", - 5000, - False) + dialog('notification', + heading='{plex}', + message=message, + icon='{plex}', + sound=False) elif icon == "error": - self.dialog.notification( - lang(29999), - message, - xbmcgui.NOTIFICATION_ERROR, - 7000, - True) + dialog('notification', + heading='{plex}', + message=message, + icon='{error}') def syncPMStime(self): """ @@ -208,11 +201,10 @@ class LibrarySync(Thread): return False # Calculate time offset Kodi-PMS - self.timeoffset = int(koditime) - int(plextime) - window('kodiplextimeoffset', value=str(self.timeoffset)) - settings('kodiplextimeoffset', value=str(self.timeoffset)) + state.KODI_PLEX_TIME_OFFSET = float(koditime) - float(plextime) + settings('kodiplextimeoffset', value=str(state.KODI_PLEX_TIME_OFFSET)) log.info("Time offset Koditime - Plextime in seconds: %s" - % str(self.timeoffset)) + % str(state.KODI_PLEX_TIME_OFFSET)) return True def initializeDBs(self): @@ -257,9 +249,6 @@ class LibrarySync(Thread): # True: we're syncing only the delta, e.g. different checksum self.compare = not repair - # Empty our list of item's we've just processed in the past - self.just_processed = {} - self.new_items_only = True # This will also update playstates and userratings! log.info('Running fullsync for NEW PMS items with repair=%s' % repair) @@ -293,23 +282,21 @@ class LibrarySync(Thread): 'movies': self.PlexMovies, 'tvshows': self.PlexTVShows, } - if self.enableMusic: + if state.ENABLE_MUSIC: process['music'] = self.PlexMusic # Do the processing for itemtype in process: - if self.thread_stopped(): - xbmc.executebuiltin('InhibitIdleShutdown(false)') - setScreensaver(value=screensaver) - return False - if not process[itemtype](): + if (self.thread_stopped() or + self.thread_suspended() or + not process[itemtype]()): xbmc.executebuiltin('InhibitIdleShutdown(false)') setScreensaver(value=screensaver) return False # Let kodi update the views in any case, since we're doing a full sync xbmc.executebuiltin('UpdateLibrary(video)') - if self.enableMusic: + if state.ENABLE_MUSIC: xbmc.executebuiltin('UpdateLibrary(music)') window('plex_initialScan', clear=True) @@ -317,13 +304,13 @@ class LibrarySync(Thread): setScreensaver(value=screensaver) if window('plex_scancrashed') == 'true': # Show warning if itemtypes.py crashed at some point - self.dialog.ok(lang(29999), lang(39408)) + dialog('ok', heading='{plex}', line1=lang(39408)) window('plex_scancrashed', clear=True) elif window('plex_scancrashed') == '401': window('plex_scancrashed', clear=True) if state.PMS_STATUS not in ('401', 'Auth'): # Plex server had too much and returned ERROR - self.dialog.ok(lang(29999), lang(39409)) + dialog('ok', heading='{plex}', line1=lang(39409)) # Path hack, so Kodis Information screen works with kodidb.GetKodiDB('video') as kodi_db: @@ -472,12 +459,12 @@ class LibrarySync(Thread): """ Compare the views to Plex """ - if state.DIRECT_PATHS is True and self.enableMusic is True: + if state.DIRECT_PATHS is True and state.ENABLE_MUSIC is True: if music.set_excludefromscan_music_folders() is True: log.info('Detected new Music library - restarting now') # 'New Plex music library detected. Sorry, but we need to # restart Kodi now due to the changes made.' - dialog('ok', lang(29999), lang(39711)) + dialog('ok', heading='{plex}', line1=lang(39711)) from xbmc import executebuiltin executebuiltin('RestartApp') return False @@ -625,7 +612,6 @@ class LibrarySync(Thread): self.allPlexElementsId APPENDED(!!) dict = {itemid: checksum} """ - now = getUnixTimestamp() if self.new_items_only is True: # Only process Plex items that Kodi does not already have in lib for item in xml: @@ -633,8 +619,8 @@ class LibrarySync(Thread): if not itemId: # Skipping items 'title=All episodes' without a 'ratingKey' continue - self.allPlexElementsId[itemId] = ("K%s%s" % - (itemId, item.attrib.get('updatedAt', ''))) + self.allPlexElementsId[itemId] = "K%s%s" % \ + (itemId, item.attrib.get('updatedAt', '')) if itemId not in self.allKodiElementsId: self.updatelist.append({ 'itemId': itemId, @@ -646,10 +632,8 @@ class LibrarySync(Thread): 'mediaType': item.attrib.get('type'), 'get_children': get_children }) - self.just_processed[itemId] = now return - - if self.compare: + elif self.compare: # Only process the delta - new or changed items for item in xml: itemId = item.attrib.get('ratingKey') @@ -673,7 +657,6 @@ class LibrarySync(Thread): 'mediaType': item.attrib.get('type'), 'get_children': get_children }) - self.just_processed[itemId] = now else: # Initial or repair sync: get all Plex movies for item in xml: @@ -681,8 +664,8 @@ class LibrarySync(Thread): if not itemId: # Skipping items 'title=All episodes' without a 'ratingKey' continue - self.allPlexElementsId[itemId] = ("K%s%s" - % (itemId, item.attrib.get('updatedAt', ''))) + self.allPlexElementsId[itemId] = "K%s%s" \ + % (itemId, item.attrib.get('updatedAt', '')) self.updatelist.append({ 'itemId': itemId, 'itemType': itemType, @@ -693,7 +676,6 @@ class LibrarySync(Thread): 'mediaType': item.attrib.get('type'), 'get_children': get_children }) - self.just_processed[itemId] = now def GetAndProcessXMLs(self, itemType): """ @@ -725,7 +707,7 @@ class LibrarySync(Thread): getMetadataQueue.put(updateItem) # Spawn GetMetadata threads for downloading threads = [] - for i in range(min(self.syncThreadNumber, itemNumber)): + for i in range(min(state.SYNC_THREAD_NUMBER, itemNumber)): thread = Threaded_Get_Metadata(getMetadataQueue, processMetadataQueue) thread.setDaemon(True) @@ -739,12 +721,9 @@ class LibrarySync(Thread): thread.start() threads.append(thread) # Start one thread to show sync progress ONLY for new PMS items - if self.new_items_only is True and window('dbSyncIndicator') == 'true': - dialog = xbmcgui.DialogProgressBG() - thread = sync_info.Threaded_Show_Sync_Info( - dialog, - itemNumber, - itemType) + if self.new_items_only is True and (state.SYNC_DIALOG is True or + self.force_dialog is True): + thread = sync_info.Threaded_Show_Sync_Info(itemNumber, itemType) thread.setDaemon(True) thread.start() threads.append(thread) @@ -803,7 +782,9 @@ class LibrarySync(Thread): # PROCESS MOVIES ##### self.updatelist = [] for view in views: - if self.thread_stopped(): + if self.installSyncDone is not True: + state.PATH_VERIFIED = False + if self.thread_stopped() or self.thread_suspended(): return False # Get items per view viewId = view['id'] @@ -821,10 +802,9 @@ class LibrarySync(Thread): viewName, viewId) self.GetAndProcessXMLs(itemType) - log.info("Processed view") # Update viewstate for EVERY item for view in views: - if self.thread_stopped(): + if self.thread_stopped() or self.thread_suspended(): return False self.PlexUpdateWatched(view['id'], itemType) @@ -896,7 +876,9 @@ class LibrarySync(Thread): # PROCESS TV Shows ##### self.updatelist = [] for view in views: - if self.thread_stopped(): + if self.installSyncDone is not True: + state.PATH_VERIFIED = False + if self.thread_stopped() or self.thread_suspended(): return False # Get items per view viewId = view['id'] @@ -925,7 +907,7 @@ class LibrarySync(Thread): # PROCESS TV Seasons ##### # Cycle through tv shows for tvShowId in allPlexTvShowsId: - if self.thread_stopped(): + if self.thread_stopped() or self.thread_suspended(): return False # Grab all seasons to tvshow from PMS seasons = GetAllPlexChildren(tvShowId) @@ -950,7 +932,7 @@ class LibrarySync(Thread): # PROCESS TV Episodes ##### # Cycle through tv shows for view in views: - if self.thread_stopped(): + if self.thread_stopped() or self.thread_suspended(): return False # Grab all episodes to tvshow from PMS episodes = GetAllPlexLeaves(view['id']) @@ -985,7 +967,7 @@ class LibrarySync(Thread): # Update viewstate: for view in views: - if self.thread_stopped(): + if self.thread_stopped() or self.thread_suspended(): return False self.PlexUpdateWatched(view['id'], itemType) @@ -1022,7 +1004,7 @@ class LibrarySync(Thread): for kind in (v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_ALBUM, v.PLEX_TYPE_SONG): - if self.thread_stopped(): + if self.thread_stopped() or self.thread_suspended(): return False log.debug("Start processing music %s" % kind) self.allKodiElementsId = {} @@ -1039,7 +1021,7 @@ class LibrarySync(Thread): # Update viewstate for EVERY item for view in views: - if self.thread_stopped(): + if self.thread_stopped() or self.thread_suspended(): return False self.PlexUpdateWatched(view['id'], itemType) @@ -1064,7 +1046,9 @@ class LibrarySync(Thread): except ValueError: pass for view in views: - if self.thread_stopped(): + if self.installSyncDone is not True: + state.PATH_VERIFIED = False + if self.thread_stopped() or self.thread_suspended(): return False # Get items per view itemsXML = GetPlexSectionResults(view['id'], args=urlArgs) @@ -1092,11 +1076,24 @@ class LibrarySync(Thread): processes json.loads() messages from websocket. Triage what we need to do with "process_" methods """ - typus = message.get('type') - if typus == 'playing': - self.process_playing(message['PlaySessionStateNotification']) - elif typus == 'timeline': - self.process_timeline(message['TimelineEntry']) + if message['type'] == 'playing': + try: + self.process_playing(message['PlaySessionStateNotification']) + except KeyError: + log.error('Received invalid PMS message for playstate: %s' + % message) + elif message['type'] == 'timeline': + try: + self.process_timeline(message['TimelineEntry']) + except (KeyError, ValueError): + log.error('Received invalid PMS message for timeline: %s' + % message) + elif message['type'] == 'activity': + try: + self.process_activity(message['ActivityNotification']) + except KeyError: + log.error('Received invalid PMS message for activity: %s' + % message) def multi_delete(self, liste, deleteListe): """ @@ -1139,25 +1136,22 @@ class LibrarySync(Thread): now = getUnixTimestamp() deleteListe = [] for i, item in enumerate(self.itemsToProcess): - if self.thread_stopped(): + if self.thread_stopped() or self.thread_suspended(): # Chances are that Kodi gets shut down break if item['state'] == 9: successful = self.process_deleteditems(item) - elif now - item['timestamp'] < self.saftyMargin: + elif now - item['timestamp'] < state.BACKGROUNDSYNC_SAFTYMARGIN: # We haven't waited long enough for the PMS to finish # processing the item. Do it later (excepting deletions) continue else: successful = self.process_newitems(item) - if successful: - self.just_processed[str(item['ratingKey'])] = now if successful and settings('FanartTV') == 'true': - plex_type = v.PLEX_TYPE_FROM_WEBSOCKET[item['type']] - if plex_type in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW): + if item['type'] in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW): self.fanartqueue.put({ 'plex_id': item['ratingKey'], - 'plex_type': plex_type, + 'plex_type': item['type'], 'refresh': False }) if successful is True: @@ -1213,22 +1207,25 @@ class LibrarySync(Thread): return True def process_deleteditems(self, item): - if item.get('type') == 1: - log.debug("Removing movie %s" % item.get('ratingKey')) + if item['type'] == v.PLEX_TYPE_MOVIE: + log.debug("Removing movie %s" % item['ratingKey']) self.videoLibUpdate = True with itemtypes.Movies() as movie: - movie.remove(item.get('ratingKey')) - elif item.get('type') in (2, 3, 4): - log.debug("Removing episode/season/tv show %s" - % item.get('ratingKey')) + movie.remove(item['ratingKey']) + elif item['type'] in (v.PLEX_TYPE_SHOW, + v.PLEX_TYPE_SEASON, + v.PLEX_TYPE_EPISODE): + log.debug("Removing episode/season/tv show %s" % item['ratingKey']) self.videoLibUpdate = True with itemtypes.TVShows() as show: - show.remove(item.get('ratingKey')) - elif item.get('type') in (8, 9, 10): - log.debug("Removing song/album/artist %s" % item.get('ratingKey')) + show.remove(item['ratingKey']) + elif item['type'] in (v.PLEX_TYPE_ARTIST, + v.PLEX_TYPE_ALBUM, + v.PLEX_TYPE_SONG): + log.debug("Removing song/album/artist %s" % item['ratingKey']) self.musicLibUpdate = True with itemtypes.Music() as music: - music.remove(item.get('ratingKey')) + music.remove(item['ratingKey']) return True def process_timeline(self, data): @@ -1236,30 +1233,32 @@ class LibrarySync(Thread): PMS is messing with the library items, e.g. new or changed. Put in our "processing queue" for later """ - now = getUnixTimestamp() for item in data: if 'tv.plex' in item.get('identifier', ''): # Ommit Plex DVR messages - the Plex IDs are not corresponding # (DVR ratingKeys are not unique and might correspond to a # movie or episode) continue - typus = int(item.get('type', 0)) - status = int(item.get('state', 0)) - if status == 9 or (typus in (1, 4, 10) and status == 5): - # Only process deleted items OR movies, episodes, tracks/songs - plex_id = str(item.get('itemID', '0')) - if plex_id == '0': - log.error('Received malformed PMS message: %s' % item) - continue - try: - if (now - self.just_processed[plex_id] < - self.ignore_just_processed and status != 9): - log.debug('We just processed %s: ignoring' % plex_id) - continue - except KeyError: - # Item has NOT just been processed - pass - # Have we already added this element? + typus = v.PLEX_TYPE_FROM_WEBSOCKET[int(item['type'])] + if typus == v.PLEX_TYPE_CLIP: + # No need to process extras or trailers + continue + status = int(item['state']) + if status == 9: + # Immediately and always process deletions (as the PMS will + # send additional message with other codes) + self.itemsToProcess.append({ + 'state': status, + 'type': typus, + 'ratingKey': str(item['itemID']), + 'timestamp': getUnixTimestamp(), + 'attempt': 0 + }) + elif typus in (v.PLEX_TYPE_MOVIE, + v.PLEX_TYPE_EPISODE, + v.PLEX_TYPE_SONG) and status == 5: + plex_id = str(item['itemID']) + # Have we already added this element for processing? for existingItem in self.itemsToProcess: if existingItem['ratingKey'] == plex_id: break @@ -1273,101 +1272,140 @@ class LibrarySync(Thread): 'attempt': 0 }) + def process_activity(self, data): + """ + PMS is re-scanning an item, e.g. after having changed a movie poster. + WATCH OUT for this if it's triggered by our PKC library scan! + """ + for item in data: + if item['event'] != 'ended': + # Scan still going on, so skip for now + continue + elif item['Activity'].get('Context') is None: + # Not related to any Plex element, but entire library + continue + elif item['Activity']['type'] != 'library.refresh.items': + # Not the type of message relevant for us + continue + plex_id = GetPlexKeyNumber(item['Activity']['Context']['key'])[1] + if plex_id == '': + # Likely a Plex id like /library/metadata/3/children + continue + # We're only looking at existing elements - have we synced yet? + with plexdb.Get_Plex_DB() as plex_db: + kodi_info = plex_db.getItem_byId(plex_id) + if kodi_info is None: + log.debug('Plex id %s not synced yet - skipping' % plex_id) + continue + # Have we already added this element? + for existingItem in self.itemsToProcess: + if existingItem['ratingKey'] == plex_id: + break + else: + # Haven't added this element to the queue yet + self.itemsToProcess.append({ + 'state': None, # Don't need a state here + 'type': kodi_info[5], + 'ratingKey': plex_id, + 'timestamp': getUnixTimestamp(), + 'attempt': 0 + }) + def process_playing(self, data): """ Someone (not necessarily the user signed in) is playing something some- where """ items = [] - with plexdb.Get_Plex_DB() as plex_db: - for item in data: - # Drop buffering messages immediately - status = item.get('state') - if status == 'buffering': - continue - ratingKey = item.get('ratingKey') - kodiInfo = plex_db.getItem_byId(ratingKey) - if kodiInfo is None: - # Item not (yet) in Kodi library - continue - sessionKey = item.get('sessionKey') - # Do we already have a sessionKey stored? - if sessionKey not in self.sessionKeys: - if settings('plex_serverowned') == 'false': - # Not our PMS, we are not authorized to get the - # sessions - # On the bright side, it must be us playing :-) - self.sessionKeys = { - sessionKey: {} - } - else: - # PMS is ours - get all current sessions - self.sessionKeys = GetPMSStatus(state.PLEX_TOKEN) - log.debug('Updated current sessions. They are: %s' - % self.sessionKeys) - if sessionKey not in self.sessionKeys: - log.warn('Session key %s still unknown! Skip ' - 'item' % sessionKey) - continue - - currSess = self.sessionKeys[sessionKey] - if settings('plex_serverowned') != 'false': - # Identify the user - same one as signed on with PKC? Skip - # update if neither session's username nor userid match - # (Owner sometime's returns id '1', not always) - if (not state.PLEX_TOKEN and currSess['userId'] == '1'): - # PKC not signed in to plex.tv. Plus owner of PMS is - # playing (the '1'). - # Hence must be us (since several users require plex.tv - # token for PKC) - pass - elif not (currSess['userId'] == state.PLEX_USER_ID - or - currSess['username'] == state.PLEX_USERNAME): - log.debug('Our username %s, userid %s did not match ' - 'the session username %s with userid %s' - % (state.PLEX_USERNAME, - state.PLEX_USER_ID, - currSess['username'], - currSess['userId'])) - continue - - # Get an up-to-date XML from the PMS - # because PMS will NOT directly tell us: - # duration of item - # viewCount - if currSess.get('duration') is None: - xml = GetPlexMetadata(ratingKey) - if xml in (None, 401): - log.error('Could not get up-to-date xml for item %s' - % ratingKey) - continue - API = PlexAPI.API(xml[0]) - userdata = API.getUserData() - currSess['duration'] = userdata['Runtime'] - currSess['viewCount'] = userdata['PlayCount'] - # Sometimes, Plex tells us resume points in milliseconds and - # not in seconds - thank you very much! - if item.get('viewOffset') > currSess['duration']: - resume = item.get('viewOffset') / 1000 + for item in data: + # Drop buffering messages immediately + status = item['state'] + if status == 'buffering': + continue + ratingKey = str(item['ratingKey']) + with plexdb.Get_Plex_DB() as plex_db: + kodi_info = plex_db.getItem_byId(ratingKey) + if kodi_info is None: + # Item not (yet) in Kodi library + continue + sessionKey = item['sessionKey'] + # Do we already have a sessionKey stored? + if sessionKey not in self.sessionKeys: + if settings('plex_serverowned') == 'false': + # Not our PMS, we are not authorized to get the + # sessions + # On the bright side, it must be us playing :-) + self.sessionKeys = { + sessionKey: {} + } else: - resume = item.get('viewOffset') - # Append to list that we need to process - items.append({ - 'ratingKey': ratingKey, - 'kodi_id': kodiInfo[0], - 'file_id': kodiInfo[1], - 'kodi_type': kodiInfo[4], - 'viewOffset': resume, - 'state': status, - 'duration': currSess['duration'], - 'viewCount': currSess['viewCount'], - 'lastViewedAt': DateToKodi(getUnixTimestamp()) - }) - log.debug('Update playstate for user %s with id %s: %s' - % (state.PLEX_USERNAME, - state.PLEX_USER_ID, - items[-1])) + # PMS is ours - get all current sessions + self.sessionKeys = GetPMSStatus(state.PLEX_TOKEN) + log.debug('Updated current sessions. They are: %s' + % self.sessionKeys) + if sessionKey not in self.sessionKeys: + log.warn('Session key %s still unknown! Skip ' + 'item' % sessionKey) + continue + + currSess = self.sessionKeys[sessionKey] + if settings('plex_serverowned') != 'false': + # Identify the user - same one as signed on with PKC? Skip + # update if neither session's username nor userid match + # (Owner sometime's returns id '1', not always) + if (not state.PLEX_TOKEN and currSess['userId'] == '1'): + # PKC not signed in to plex.tv. Plus owner of PMS is + # playing (the '1'). + # Hence must be us (since several users require plex.tv + # token for PKC) + pass + elif not (currSess['userId'] == state.PLEX_USER_ID + or + currSess['username'] == state.PLEX_USERNAME): + log.debug('Our username %s, userid %s did not match ' + 'the session username %s with userid %s' + % (state.PLEX_USERNAME, + state.PLEX_USER_ID, + currSess['username'], + currSess['userId'])) + continue + + # Get an up-to-date XML from the PMS + # because PMS will NOT directly tell us: + # duration of item + # viewCount + if currSess.get('duration') is None: + xml = GetPlexMetadata(ratingKey) + if xml in (None, 401): + log.error('Could not get up-to-date xml for item %s' + % ratingKey) + continue + API = PlexAPI.API(xml[0]) + userdata = API.getUserData() + currSess['duration'] = userdata['Runtime'] + currSess['viewCount'] = userdata['PlayCount'] + # Sometimes, Plex tells us resume points in milliseconds and + # not in seconds - thank you very much! + if item.get('viewOffset') > currSess['duration']: + resume = item.get('viewOffset') / 1000 + else: + resume = item.get('viewOffset') + # Append to list that we need to process + items.append({ + 'ratingKey': ratingKey, + 'kodi_id': kodi_info[0], + 'file_id': kodi_info[1], + 'kodi_type': kodi_info[4], + 'viewOffset': resume, + 'state': status, + 'duration': currSess['duration'], + 'viewCount': currSess['viewCount'], + 'lastViewedAt': DateToKodi(getUnixTimestamp()) + }) + log.debug('Update playstate for user %s with id %s: %s' + % (state.PLEX_USERNAME, + state.PLEX_USER_ID, + items[-1])) # Now tell Kodi where we are for item in items: itemFkt = getattr(itemtypes, @@ -1394,6 +1432,68 @@ class LibrarySync(Thread): 'refresh': refresh }) + def triage_lib_scans(self): + """ + Decides what to do if state.RUN_LIB_SCAN has been set. E.g. manually + triggered full or repair syncs + """ + if state.RUN_LIB_SCAN in ("full", "repair"): + log.info('Full library scan requested, starting') + window('plex_dbScan', value="true") + state.DB_SCAN = True + if state.RUN_LIB_SCAN == "full": + self.fullSync() + else: + self.fullSync(repair=True) + window('plex_dbScan', clear=True) + state.DB_SCAN = False + # Full library sync finished + self.showKodiNote(lang(39407)) + # Reset views was requested from somewhere else + elif state.RUN_LIB_SCAN == "views": + log.info('Refresh playlist and nodes requested, starting') + window('plex_dbScan', value="true") + state.DB_SCAN = True + # First remove playlists + deletePlaylists() + # Remove video nodes + deleteNodes() + # Kick off refresh + if self.maintainViews() is True: + # Ran successfully + log.info("Refresh playlists/nodes completed") + # "Plex playlists/nodes refreshed" + self.showKodiNote(lang(39405)) + else: + # Failed + log.error("Refresh playlists/nodes failed") + # "Plex playlists/nodes refresh failed" + self.showKodiNote(lang(39406), + icon="error") + window('plex_dbScan', clear=True) + state.DB_SCAN = False + elif state.RUN_LIB_SCAN == 'fanart': + # Only look for missing fanart (No) + # or refresh all fanart (Yes) + self.fanartSync(refresh=dialog( + 'yesno', + heading='{plex}', + line1=lang(39223), + nolabel=lang(39224), + yeslabel=lang(39225))) + elif state.RUN_LIB_SCAN == 'textures': + state.DB_SCAN = True + window('plex_dbScan', value="true") + import artwork + artwork.Artwork().fullTextureCacheSync() + window('plex_dbScan', clear=True) + state.DB_SCAN = False + else: + raise NotImplementedError('Library scan not defined: %s' + % state.RUN_LIB_SCAN) + # Reset + state.RUN_LIB_SCAN = None + def run(self): try: self.run_internal() @@ -1404,7 +1504,7 @@ class LibrarySync(Thread): import traceback log.error("Traceback:\n%s" % traceback.format_exc()) # Library sync thread has crashed - self.dialog.ok(lang(29999), lang(39400)) + dialog('ok', heading='{plex}', line1=lang(39400)) raise def run_internal(self): @@ -1412,24 +1512,21 @@ class LibrarySync(Thread): thread_stopped = self.thread_stopped thread_suspended = self.thread_suspended installSyncDone = self.installSyncDone - enableBackgroundSync = self.enableBackgroundSync + background_sync = state.BACKGROUND_SYNC fullSync = self.fullSync processMessage = self.processMessage processItems = self.processItems - fullSyncInterval = self.fullSyncInterval + FULL_SYNC_INTERVALL = state.FULL_SYNC_INTERVALL lastSync = 0 lastTimeSync = 0 lastProcessing = 0 oneDay = 60*60*24 - xbmcplayer = xbmc.Player() - # Link to Websocket queue queue = self.mgr.ws.queue startupComplete = False self.views = [] - errorcount = 0 log.info("---===### Starting LibrarySync ###===---") @@ -1450,7 +1547,9 @@ class LibrarySync(Thread): return xbmc.sleep(1000) - if (window('plex_dbCheck') != "true" and installSyncDone): + if state.KODI_DB_CHECKED is False and installSyncDone: + # Install sync was already done, don't force-show dialogs + self.force_dialog = False # Verify the validity of the database currentVersion = settings('dbCreatedWithVersion') minVersion = window('plex_minDBVersion') @@ -1459,18 +1558,19 @@ class LibrarySync(Thread): log.warn("Db version out of date: %s minimum version " "required: %s" % (currentVersion, minVersion)) # DB out of date. Proceed to recreate? - resp = self.dialog.yesno(heading=lang(29999), - line1=lang(39401)) + resp = dialog('yesno', + heading=lang(29999), + line1=lang(39401)) if not resp: log.warn("Db version out of date! USER IGNORED!") # PKC may not work correctly until reset - self.dialog.ok(heading=lang(29999), - line1=(lang(29999) + lang(39402))) + dialog('ok', + heading='{plex}', + line1=lang(29999) + lang(39402)) else: reset() break - - window('plex_dbCheck', value="true") + state.KODI_DB_CHECKED = True if not startupComplete: # Also runs when first installed @@ -1483,7 +1583,7 @@ class LibrarySync(Thread): log.error('Current Kodi version: %s' % tryDecode( xbmc.getInfoLabel('System.BuildVersion'))) # "Current Kodi version is unsupported, cancel lib sync" - self.dialog.ok(heading=lang(29999), line1=lang(39403)) + dialog('ok', heading='{plex}', line1=lang(39403)) break # Run start up sync state.DB_SCAN = True @@ -1518,123 +1618,68 @@ class LibrarySync(Thread): settings('SyncInstallRunDone', value="true") settings("dbCreatedWithVersion", v.ADDON_VERSION) installSyncDone = True + self.force_dialog = False else: log.error("Initial start-up full sync unsuccessful") - errorcount += 1 - if errorcount > 2: - log.error("Startup full sync failed. Stopping sync") - # "Startup syncing process failed repeatedly" - # "Please restart" - self.dialog.ok(heading=lang(29999), - line1=lang(39404)) - break # Currently no db scan, so we can start a new scan elif state.DB_SCAN is False: # Full scan was requested from somewhere else, e.g. userclient - if window('plex_runLibScan') in ("full", "repair"): - log.info('Full library scan requested, starting') - window('plex_dbScan', value="true") + if state.RUN_LIB_SCAN is not None: + # Force-show dialogs since they are user-initiated + self.force_dialog = True + self.triage_lib_scans() + self.force_dialog = False + continue + now = getUnixTimestamp() + # Standard syncs - don't force-show dialogs + self.force_dialog = False + if (now - lastSync > FULL_SYNC_INTERVALL and + not self.xbmcplayer.isPlaying()): + lastSync = now + log.info('Doing scheduled full library scan') state.DB_SCAN = True - if window('plex_runLibScan') == "full": - fullSync() - elif window('plex_runLibScan') == "repair": - fullSync(repair=True) - window('plex_runLibScan', clear=True) + window('plex_dbScan', value="true") + if fullSync() is False and not thread_stopped(): + log.error('Could not finish scheduled full sync') + self.force_dialog = True + self.showKodiNote(lang(39410), + icon='error') + self.force_dialog = False window('plex_dbScan', clear=True) state.DB_SCAN = False # Full library sync finished - self.showKodiNote(lang(39407), forced=False) - # Reset views was requested from somewhere else - elif window('plex_runLibScan') == "views": - log.info('Refresh playlist and nodes requested, starting') - window('plex_dbScan', value="true") - state.DB_SCAN = True - window('plex_runLibScan', clear=True) - - # First remove playlists - deletePlaylists() - # Remove video nodes - deleteNodes() - # Kick off refresh - if self.maintainViews() is True: - # Ran successfully - log.info("Refresh playlists/nodes completed") - # "Plex playlists/nodes refreshed" - self.showKodiNote(lang(39405), forced=True) - else: - # Failed - log.error("Refresh playlists/nodes failed") - # "Plex playlists/nodes refresh failed" - self.showKodiNote(lang(39406), - forced=True, - icon="error") - window('plex_dbScan', clear=True) - state.DB_SCAN = False - elif window('plex_runLibScan') == 'fanart': - window('plex_runLibScan', clear=True) - # Only look for missing fanart (No) - # or refresh all fanart (Yes) - self.fanartSync(refresh=self.dialog.yesno( - heading=lang(29999), - line1=lang(39223), - nolabel=lang(39224), - yeslabel=lang(39225))) - elif window('plex_runLibScan') == 'del_textures': - window('plex_runLibScan', clear=True) + self.showKodiNote(lang(39407)) + elif now - lastTimeSync > oneDay: + lastTimeSync = now + log.info('Starting daily time sync') state.DB_SCAN = True window('plex_dbScan', value="true") - import artwork - artwork.Artwork().fullTextureCacheSync() + self.syncPMStime() window('plex_dbScan', clear=True) state.DB_SCAN = False - else: - now = getUnixTimestamp() - if (now - lastSync > fullSyncInterval and - not xbmcplayer.isPlaying()): - lastSync = now - log.info('Doing scheduled full library scan') - state.DB_SCAN = True - window('plex_dbScan', value="true") - if fullSync() is False and not thread_stopped(): - log.error('Could not finish scheduled full sync') - self.showKodiNote(lang(39410), - forced=True, - icon='error') - window('plex_dbScan', clear=True) - state.DB_SCAN = False - # Full library sync finished - self.showKodiNote(lang(39407), forced=False) - elif now - lastTimeSync > oneDay: - lastTimeSync = now - log.info('Starting daily time sync') - state.DB_SCAN = True - window('plex_dbScan', value="true") - self.syncPMStime() - window('plex_dbScan', clear=True) - state.DB_SCAN = False - elif enableBackgroundSync: - # Check back whether we should process something - # Only do this once every while (otherwise, potentially - # many screen refreshes lead to flickering) - if now - lastProcessing > 5: - lastProcessing = now - processItems() - # See if there is a PMS message we need to handle - try: - message = queue.get(block=False) - except Queue.Empty: - xbmc.sleep(100) - continue - # Got a message from PMS; process it - else: - processMessage(message) - queue.task_done() - # NO sleep! - continue - else: - # Still sleep if backgroundsync disabled + elif background_sync: + # Check back whether we should process something + # Only do this once every while (otherwise, potentially + # many screen refreshes lead to flickering) + if now - lastProcessing > 5: + lastProcessing = now + processItems() + # See if there is a PMS message we need to handle + try: + message = queue.get(block=False) + except Queue.Empty: xbmc.sleep(100) + continue + # Got a message from PMS; process it + else: + processMessage(message) + queue.task_done() + # NO sleep! + continue + else: + # Still sleep if backgroundsync disabled + xbmc.sleep(100) xbmc.sleep(100) diff --git a/resources/lib/loghandler.py b/resources/lib/loghandler.py index c4e34188..f81c962d 100644 --- a/resources/lib/loghandler.py +++ b/resources/lib/loghandler.py @@ -1,74 +1,47 @@ # -*- coding: utf-8 -*- - -################################################################################################## - +############################################################################### import logging import xbmc +############################################################################### +LEVELS = { + logging.ERROR: xbmc.LOGERROR, + logging.WARNING: xbmc.LOGWARNING, + logging.INFO: xbmc.LOGNOTICE, + logging.DEBUG: xbmc.LOGDEBUG +} +############################################################################### -from utils import window, tryEncode -################################################################################################## +def tryEncode(uniString, encoding='utf-8'): + """ + Will try to encode uniString (in unicode) to encoding. This possibly + fails with e.g. Android TV's Python, which does not accept arguments for + string.encode() + """ + if isinstance(uniString, str): + # already encoded + return uniString + try: + uniString = uniString.encode(encoding, "ignore") + except TypeError: + uniString = uniString.encode() + return uniString def config(): - logger = logging.getLogger('PLEX') logger.addHandler(LogHandler()) logger.setLevel(logging.DEBUG) class LogHandler(logging.StreamHandler): - def __init__(self): - logging.StreamHandler.__init__(self) - self.setFormatter(MyFormatter()) + self.setFormatter(logging.Formatter(fmt="%(name)s: %(message)s")) def emit(self, record): - - if self._get_log_level(record.levelno): - try: - xbmc.log(self.format(record), level=xbmc.LOGNOTICE) - except UnicodeEncodeError: - xbmc.log(tryEncode(self.format(record)), level=xbmc.LOGNOTICE) - - @classmethod - def _get_log_level(cls, level): - - levels = { - logging.ERROR: 0, - logging.WARNING: 0, - logging.INFO: 1, - logging.DEBUG: 2 - } try: - log_level = int(window('plex_logLevel')) - except ValueError: - log_level = 0 - - return log_level >= levels[level] - - -class MyFormatter(logging.Formatter): - - def __init__(self, fmt="%(name)s -> %(message)s"): - - logging.Formatter.__init__(self, fmt) - - def format(self, record): - - # Save the original format configured by the user - # when the logger formatter was instantiated - format_orig = self._fmt - - # Replace the original format with one customized by logging level - if record.levelno in (logging.DEBUG, logging.ERROR): - self._fmt = '%(name)s -> %(levelname)s: %(message)s' - - # Call the original formatter class to do the grunt work - result = logging.Formatter.format(self, record) - - # Restore the original format configured by the user - self._fmt = format_orig - - return result + xbmc.log(self.format(record), level=LEVELS[record.levelno]) + except UnicodeEncodeError: + xbmc.log(tryEncode(self.format(record)), + level=LEVELS[record.levelno]) diff --git a/resources/lib/pickler.py b/resources/lib/pickler.py index 9bd73bec..b5579cd4 100644 --- a/resources/lib/pickler.py +++ b/resources/lib/pickler.py @@ -1,13 +1,26 @@ # -*- coding: utf-8 -*- ############################################################################### -import logging -import cPickle as Pickle +from cPickle import dumps, loads -from utils import pickl_window +from xbmcgui import Window +from xbmc import log, LOGDEBUG +############################################################################### +WINDOW = Window(10000) +PREFIX = 'PLEX.%s: ' % __name__ ############################################################################### -log = logging.getLogger("PLEX."+__name__) -############################################################################### + +def pickl_window(property, value=None, clear=False): + """ + Get or set window property - thread safe! For use with Pickle + Property and value must be string + """ + if clear: + WINDOW.clearProperty(property) + elif value is not None: + WINDOW.setProperty(property, value) + else: + return WINDOW.getProperty(property) def pickle_me(obj, window_var='plex_result'): @@ -19,9 +32,9 @@ def pickle_me(obj, window_var='plex_result'): obj can be pretty much any Python object. However, classes and functions won't work. See the Pickle documentation """ - log.debug('Start pickling: %s' % obj) - pickl_window(window_var, value=Pickle.dumps(obj)) - log.debug('Successfully pickled') + log('%sStart pickling: %s' % (PREFIX, obj), level=LOGDEBUG) + pickl_window(window_var, value=dumps(obj)) + log('%sSuccessfully pickled' % PREFIX, level=LOGDEBUG) def unpickle_me(window_var='plex_result'): @@ -31,9 +44,9 @@ def unpickle_me(window_var='plex_result'): """ result = pickl_window(window_var) pickl_window(window_var, clear=True) - log.debug('Start unpickling') - obj = Pickle.loads(result) - log.debug('Successfully unpickled: %s' % obj) + log('%sStart unpickling' % PREFIX, level=LOGDEBUG) + obj = loads(result) + log('%sSuccessfully unpickled: %s' % (PREFIX, obj), level=LOGDEBUG) return obj diff --git a/resources/lib/playback_starter.py b/resources/lib/playback_starter.py index f0ac27f5..aabfc3ac 100644 --- a/resources/lib/playback_starter.py +++ b/resources/lib/playback_starter.py @@ -17,6 +17,7 @@ import variables as v from downloadutils import DownloadUtils from PKC_listitem import convert_PKC_to_listitem import plexdb_functions as plexdb +from context_entry import ContextMenu import state ############################################################################### @@ -142,6 +143,9 @@ class Playback_Starter(Thread): params.get('view_offset'), directplay=True if params.get('play_directly') else False, node=False if params.get('node') == 'false' else True) + elif mode == 'context_menu': + ContextMenu() + result = Playback_Successful() except: log.error('Error encountered for mode %s, params %s' % (mode, params)) diff --git a/resources/lib/player.py b/resources/lib/player.py index 1dafbe68..63596d88 100644 --- a/resources/lib/player.py +++ b/resources/lib/player.py @@ -5,10 +5,8 @@ import logging import json import xbmc -import xbmcgui -from utils import window, settings, language as lang, DateToKodi, \ - getUnixTimestamp, tryDecode, tryEncode +from utils import window, DateToKodi, getUnixTimestamp, tryDecode, tryEncode import downloadutils import plexdb_functions as plexdb import kodidb_functions as kodidb @@ -354,6 +352,8 @@ class Player(xbmc.Player): log.info("Percent complete: %s Mark played at: %s" % (percentComplete, markPlayed)) if percentComplete >= markPlayed: + # Kodi seems to sometimes overwrite our playstate, so wait + xbmc.sleep(500) # Tell Kodi that we've finished watching (Plex knows) if (data['fileid'] is not None and data['itemType'] in (v.KODI_TYPE_MOVIE, diff --git a/resources/lib/state.py b/resources/lib/state.py index b364f749..97da71c9 100644 --- a/resources/lib/state.py +++ b/resources/lib/state.py @@ -10,6 +10,8 @@ STOP_PKC = False SUSPEND_LIBRARY_THREAD = False # Set if user decided to cancel sync STOP_SYNC = False +# Could we access the paths? +PATH_VERIFIED = False # Set if a Plex-Kodi DB sync is being done - along with # window('plex_dbScan') set to 'true' DB_SCAN = False @@ -24,6 +26,42 @@ RESTRICTED_USER = False DIRECT_PATHS = False # Shall we replace custom user ratings with the number of versions available? INDICATE_MEDIA_VERSIONS = False +# Do we need to run a special library scan? +RUN_LIB_SCAN = None + +# Stemming from the PKC settings.xml +# Shall we show Kodi dialogs when synching? +SYNC_DIALOG = True +# Have we already checked the Kodi DB on consistency? +KODI_DB_CHECKED = False +# Is synching of Plex music enabled? +ENABLE_MUSIC = True +# How often shall we sync? +FULL_SYNC_INTERVALL = 0 +# Background Sync enabled at all? +BACKGROUND_SYNC = True +# How long shall we wait with synching a new item to make sure Plex got all +# metadata? +BACKGROUNDSYNC_SAFTYMARGIN = 0 +# How many threads to download Plex metadata on sync? +SYNC_THREAD_NUMBER = 0 +# What's the time offset between the PMS and Kodi? +KODI_PLEX_TIME_OFFSET = 0.0 + +# Path remapping mechanism (e.g. smb paths) +# Do we replace \\myserver\path to smb://myserver/path? +REPLACE_SMB_PATH = False +# Do we generally remap? +REMAP_PATH = False +# Mappings for REMAP_PATH: +remapSMBmovieOrg = None +remapSMBmovieNew = None +remapSMBtvOrg = None +remapSMBtvNew = None +remapSMBmusicOrg = None +remapSMBmusicNew = None +remapSMBphotoOrg = None +remapSMBphotoNew = None # Along with window('plex_authenticated') AUTHENTICATED = False diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 27354384..eddc8e7f 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -59,24 +59,6 @@ def window(property, value=None, clear=False, windowid=10000): return tryDecode(win.getProperty(property)) -def pickl_window(property, value=None, clear=False, windowid=10000): - """ - Get or set window property - thread safe! For use with Pickle - Property and value must be string - """ - if windowid != 10000: - win = xbmcgui.Window(windowid) - else: - win = WINDOW - - if clear: - win.clearProperty(property) - elif value is not None: - win.setProperty(property, value) - else: - return win.getProperty(property) - - def plex_command(key, value): """ Used to funnel states between different Python instances. NOT really thread @@ -86,7 +68,7 @@ def plex_command(key, value): value: either 'True' or 'False' """ while window('plex_command'): - xbmc.sleep(5) + xbmc.sleep(20) window('plex_command', value='%s-%s' % (key, value)) @@ -140,6 +122,15 @@ def dialog(typus, *args, **kwargs): Displays xbmcgui Dialog. Pass a string as typus: 'yesno', 'ok', 'notification', 'input', 'select', 'numeric' + kwargs: + heading='{plex}' title bar (here PlexKodiConnect) + message=lang(30128), Actual dialog content. Don't use with OK + line1=str(), For 'OK' and 'yesno' dialogs use line1...line3! + time=5000, + sound=True, + nolabel=str(), For 'yesno' dialogs + yeslabel=str(), For 'yesno' dialogs + Icons: icon='{plex}' Display Plex standard icon icon='{info}' xbmcgui.NOTIFICATION_INFO @@ -221,6 +212,16 @@ def tryDecode(string, encoding='utf-8'): return string +def slugify(text): + """ + Normalizes text (in unicode or string) to e.g. enable safe filenames. + Returns unicode + """ + if not isinstance(text, unicode): + text = unicode(text) + return unicode(normalize('NFKD', text).encode('ascii', 'ignore')) + + def escape_html(string): """ Escapes the following: @@ -248,7 +249,7 @@ def DateToKodi(stamp): None if an error was encountered """ try: - stamp = float(stamp) + float(window('kodiplextimeoffset')) + stamp = float(stamp) + state.KODI_PLEX_TIME_OFFSET date_time = localtime(stamp) localdate = strftime('%Y-%m-%d %H:%M:%S', date_time) except: diff --git a/resources/lib/websocket_client.py b/resources/lib/websocket_client.py index 0ae40da8..ba3f97e1 100644 --- a/resources/lib/websocket_client.py +++ b/resources/lib/websocket_client.py @@ -84,7 +84,8 @@ class WebSocket(Thread): # No worries if read timed out pass except websocket.WebSocketConnectionClosedException: - log.info("Connection closed, (re)connecting") + log.info("%s: connection closed, (re)connecting" + % self.__class__.__name__) uri, sslopt = self.getUri() try: # Low timeout - let's us shut this thread down! @@ -95,7 +96,7 @@ class WebSocket(Thread): enable_multithread=True) except IOError: # Server is probably offline - log.info("Error connecting") + log.info("%s: Error connecting" % self.__class__.__name__) self.ws = None counter += 1 if counter > 3: @@ -103,33 +104,41 @@ class WebSocket(Thread): self.IOError_response() sleep(1000) except websocket.WebSocketTimeoutException: - log.info("timeout while connecting, trying again") + log.info("%s: Timeout while connecting, trying again" + % self.__class__.__name__) self.ws = None sleep(1000) except websocket.WebSocketException as e: - log.info('WebSocketException: %s' % e) + log.info('%s: WebSocketException: %s' + % (self.__class__.__name__, e)) if 'Handshake Status 401' in e.args: handshake_counter += 1 if handshake_counter >= 5: - log.info('Error in handshake detected. Stopping ' - '%s now' % self.__class__.__name__) + log.info('%s: Error in handshake detected. ' + 'Stopping now' + % self.__class__.__name__) break self.ws = None sleep(1000) except Exception as e: - log.error("Unknown exception encountered in connecting: %s" - % e) + log.error('%s: Unknown exception encountered when ' + 'connecting: %s' % (self.__class__.__name__, e)) import traceback - log.error("Traceback:\n%s" % traceback.format_exc()) + log.error("%s: Traceback:\n%s" + % (self.__class__.__name__, + traceback.format_exc())) self.ws = None sleep(1000) else: counter = 0 handshake_counter = 0 except Exception as e: - log.error("Unknown exception encountered: %s" % e) + log.error("%s: Unknown exception encountered: %s" + % (self.__class__.__name__, e)) import traceback - log.error("Traceback:\n%s" % traceback.format_exc()) + log.error("%s: Traceback:\n%s" + % (self.__class__.__name__, + traceback.format_exc())) try: self.ws.shutdown() except: @@ -171,37 +180,46 @@ class PMS_Websocket(WebSocket): sslopt = {} if settings('sslverify') == "false": sslopt["cert_reqs"] = CERT_NONE - log.debug("Uri: %s, sslopt: %s" % (uri, sslopt)) + log.debug("%s: Uri: %s, sslopt: %s" + % (self.__class__.__name__, uri, sslopt)) return uri, sslopt def process(self, opcode, message): if opcode not in self.opcode_data: - return False + return try: message = loads(message) - except Exception as ex: - log.error('Error decoding message from websocket: %s' % ex) + except ValueError: + log.error('%s: Error decoding message from websocket' + % self.__class__.__name__) log.error(message) - return False + return try: message = message['NotificationContainer'] except KeyError: - log.error('Could not parse PMS message: %s' % message) - return False + log.error('%s: Could not parse PMS message: %s' + % (self.__class__.__name__, message)) + return # Triage typus = message.get('type') if typus is None: - log.error('No message type, dropping message: %s' % message) - return False - log.debug('Received message from PMS server: %s' % message) + log.error('%s: No message type, dropping message: %s' + % (self.__class__.__name__, message)) + return + log.debug('%s: Received message from PMS server: %s' + % (self.__class__.__name__, message)) # Drop everything we're not interested in - if typus not in ('playing', 'timeline'): - return True - - # Put PMS message on queue and let libsync take care of it - self.queue.put(message) - return True + if typus not in ('playing', 'timeline', 'activity'): + return + elif typus == 'activity' and state.DB_SCAN is True: + # Only add to processing if PKC is NOT doing a lib scan (and thus + # possibly causing these reprocessing messages en mass) + log.debug('%s: Dropping message as PKC is currently synching' + % self.__class__.__name__) + else: + # Put PMS message on queue and let libsync take care of it + self.queue.put(message) def IOError_response(self): log.warn("Repeatedly could not connect to PMS, " @@ -224,32 +242,36 @@ class Alexa_Websocket(WebSocket): % (state.PLEX_USER_ID, self.plex_client_Id, state.PLEX_TOKEN)) sslopt = {} - log.debug("Uri: %s, sslopt: %s" % (uri, sslopt)) + log.debug("%s: Uri: %s, sslopt: %s" + % (self.__class__.__name__, uri, sslopt)) return uri, sslopt def process(self, opcode, message): if opcode not in self.opcode_data: - return False - log.debug('Received the following message from Alexa:') - log.debug(message) + return + log.debug('%s: Received the following message from Alexa:' + % self.__class__.__name__) + log.debug('%s: %s' % (self.__class__.__name__, message)) try: message = etree.fromstring(message) except Exception as ex: - log.error('Error decoding message from Alexa: %s' % ex) - return False + log.error('%s: Error decoding message from Alexa: %s' + % (self.__class__.__name__, ex)) + return try: if message.attrib['command'] == 'processRemoteControlCommand': message = message[0] else: - log.error('Unknown Alexa message received') - return False + log.error('%s: Unknown Alexa message received' + % self.__class__.__name__) + return except: - log.error('Could not parse Alexa message') - return False + log.error('%s: Could not parse Alexa message' + % self.__class__.__name__) + return process_command(message.attrib['path'][1:], message.attrib, queue=self.mgr.plexCompanion.queue) - return True def IOError_response(self): pass diff --git a/resources/settings.xml b/resources/settings.xml index 52865be1..bfe2d62a 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -146,7 +146,6 @@ - diff --git a/service.py b/service.py index 601a2a59..6b8f464f 100644 --- a/service.py +++ b/service.py @@ -30,8 +30,7 @@ sys_path.append(_base_resource) ############################################################################### -from utils import settings, window, language as lang, dialog, tryEncode, \ - tryDecode +from utils import settings, window, language as lang, dialog, tryDecode from userclient import UserClient import initialsetup from kodimonitor import KodiMonitor @@ -82,10 +81,8 @@ class Service(): def __init__(self): - logLevel = self.getLogLevel() self.monitor = Monitor() - window('plex_logLevel', value=str(logLevel)) window('plex_kodiProfile', value=tryDecode(translatePath("special://profile"))) window('plex_context', @@ -94,27 +91,26 @@ class Service(): value=settings('fetch_pms_item_number')) # Initial logging - log.warn("======== START %s ========" % v.ADDON_NAME) - log.warn("Platform: %s" % v.PLATFORM) - log.warn("KODI Version: %s" % v.KODILONGVERSION) - log.warn("%s Version: %s" % (v.ADDON_NAME, v.ADDON_VERSION)) - log.warn("Using plugin paths: %s" + log.info("======== START %s ========" % v.ADDON_NAME) + log.info("Platform: %s" % v.PLATFORM) + log.info("KODI Version: %s" % v.KODILONGVERSION) + log.info("%s Version: %s" % (v.ADDON_NAME, v.ADDON_VERSION)) + log.info("Using plugin paths: %s" % (settings('useDirectPaths') != "true")) - log.warn("Number of sync threads: %s" + log.info("Number of sync threads: %s" % settings('syncThreadNumber')) - log.warn("Log Level: %s" % logLevel) - log.warn("Full sys.argv received: %s" % argv) + log.info("Full sys.argv received: %s" % argv) # Reset window props for profile switch properties = [ "plex_online", "plex_serverStatus", "plex_onWake", - "plex_dbCheck", "plex_kodiScan", + "plex_kodiScan", "plex_shouldStop", "plex_dbScan", "plex_initialScan", "plex_customplayqueue", "plex_playbackProps", - "plex_runLibScan", "pms_token", "plex_token", + "pms_token", "plex_token", "pms_server", "plex_machineIdentifier", "plex_servername", "plex_authenticated", "PlexUserImage", "useDirectPaths", - "kodiplextimeoffset", "countError", "countUnauthorized", + "countError", "countUnauthorized", "plex_restricteduser", "plex_allows_mediaDeletion", "plex_command", "plex_result", "plex_force_transcode_pix" ] @@ -127,13 +123,6 @@ class Service(): # Set the minimum database version window('plex_minDBVersion', value="1.5.10") - def getLogLevel(self): - try: - logLevel = int(settings('logLevel')) - except ValueError: - logLevel = 0 - return logLevel - def __stop_PKC(self): """ Kodi's abortRequested is really unreliable :-( @@ -173,7 +162,7 @@ class Service(): if window('plex_kodiProfile') != kodiProfile: # Profile change happened, terminate this thread and others - log.warn("Kodi profile was: %s and changed to: %s. " + log.info("Kodi profile was: %s and changed to: %s. " "Terminating old PlexKodiConnect thread." % (kodiProfile, window('plex_kodiProfile'))) @@ -332,7 +321,7 @@ class Service(): except: pass window('plex_service_started', clear=True) - log.warn("======== STOP %s ========" % v.ADDON_NAME) + log.info("======== STOP %s ========" % v.ADDON_NAME) # Safety net - Kody starts PKC twice upon first installation! @@ -345,11 +334,11 @@ else: # Delay option delay = int(settings('startupDelay')) -log.warn("Delaying Plex startup by: %s sec..." % delay) +log.info("Delaying Plex startup by: %s sec..." % delay) if exit: log.error('PKC service.py already started - exiting this instance') elif delay and Monitor().waitForAbort(delay): # Start the service - log.warn("Abort requested while waiting. PKC not started.") + log.info("Abort requested while waiting. PKC not started.") else: Service().ServiceEntryPoint()