diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae7e068a..05038189 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,21 @@ Thanks a ton for contributing to PlexKodiConnect! +## Feature requests + +* Are you missing a certain functionality? Then [visit feathub.com](http://feathub.com/croneter/PlexKodiConnect) + +## Issues + +* Something not working like it's supposed to? Then [open a new issue report](https://github.com/croneter/PlexKodiConnect/wiki/How-to-Report-A-Bug) + +## Translations + +* Want to help translate PlexKodiConnect? Then go [visit crowdin.com](https://crowdin.com/project/plexkodiconnect/invite) + +## Programming + * Please make pull requests towards the **develop** branch, not the master branch. Hence please fork the **develop** branch and not the master branch * Thanks if you can follow the Python style guide [PEP8](https://www.python.org/dev/peps/pep-0008/) to keep things neat and clean * Thanks if you add some comments to make your code more readable ;-) + diff --git a/README.md b/README.md index 80ea4184..fdcf2ec3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +##Status + +[![GitHub issues](https://img.shields.io/github/issues/croneter/PlexKodiConnect.svg?maxAge=60&style=flat-square)](https://github.com/croneter/PlexKodiConnect/issues) +[![GitHub pull requests](https://img.shields.io/github/issues-pr/croneter/PlexKodiConnect.svg?maxAge=60&style=flat-square)](https://github.com/croneter/PlexKodiConnect/pulls) + + # PlexKodiConnect (PKC) **Combine the best frontend media player Kodi with the best multimedia backend server Plex** @@ -5,19 +11,23 @@ PKC combines the best of Kodi - ultra smooth navigation, beautiful and highly cu Have a look at [some screenshots](https://github.com/croneter/PlexKodiConnect/wiki/Some-PKC-Screenshots) to see what's possible. +### Call for Translations + +Please help translate PlexKodiConnect into your language: [visit crowdin.com](https://crowdin.com/project/plexkodiconnect/invite) + ### Content -* [**Warning**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#warning) -* [**What does PKC do and how is it different from the official 'Plex for Kodi'**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#what-does-pkc-do-and-how-is-it-different-from-the-official-plex-for-kod) -* [**Download and Installation**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#download-and-installation) -* [**Important notes**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#important-notes) -* [**Donations**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#donations) -* [**What is currently supported?**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#what-is-currently-supported) -* [**Known Larger Issues**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#known-larger-issues) -* [**Issues being worked on**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#issues-being-worked-on) -* [**Pipeline for future development**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#what-could-be-in-the-pipeline-for-future-development) -* [**Checkout the PKC Wiki**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#checkout-the-pkc-wiki) -* [**Credits**](https://github.com/croneter/PlexKodiConnect/tree/hotfixes#credits) +* [**Warning**](#warning) +* [**What does PKC do and how is it different from the official 'Plex for Kodi'**](#what-does-pkc-do-and-how-is-it-different-from-the-official-plex-for-kod) +* [**Download and Installation**](#download-and-installation) +* [**Important notes**](#important-notes) +* [**Donations**](#donations) +* [**What is currently supported?**](#what-is-currently-supported) +* [**Known Larger Issues**](#known-larger-issues) +* [**Issues being worked on**](#issues-being-worked-on) +* [**Requests for new features**](#requests-for-new-features) +* [**Checkout the PKC Wiki**](#checkout-the-pkc-wiki) +* [**Credits**](#credits) ### Warning Use at your own risk! This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases as this plugin directly changes them. Don't worry if you want Plex to manage all your media (like you should ;-)). @@ -71,8 +81,10 @@ PKC currently provides the following features: - Different PKC interface languages: + English + German - + Czech (thanks @Pavuucek) - + More coming up + + Czech, thanks @Pavuucek + + Spanish, thanks @bartolomesoriano + + Danish, thanks @FIGHT + + More coming up: [you can help!](https://crowdin.com/project/plexkodiconnect/invite) - [Plex Watch Later / Plex It!](https://support.plex.tv/hc/en-us/sections/200211783-Plex-It-) - [Plex Companion](https://support.plex.tv/hc/en-us/sections/200276908-Plex-Companion): fling Plex media (or anything else) from other Plex devices to PlexKodiConnect - [Plex Transcoding](https://support.plex.tv/hc/en-us/articles/200250377-Transcoding-Media) @@ -108,12 +120,9 @@ However, some changes to individual items are instantly detected, e.g. if you ma Have a look at the [Github Issues Page](https://github.com/croneter/PlexKodiConnect/issues). Before you open your own issue, please read [How to report a bug](https://github.com/croneter/PlexKodiConnect/wiki/How-to-Report-A-Bug). -### What could be in the pipeline for future development? +### Requests for new features -- Plex channels -- Movie extras (trailers already work) -- Playlists -- Music Videos +[![Feature Requests](http://feathub.com/croneter/PlexKodiConnect?format=svg)](http://feathub.com/croneter/PlexKodiConnect) ### Checkout the PKC Wiki The [Wiki can be found here](https://github.com/croneter/PlexKodiConnect/wiki) and will hopefully answer all your questions. You can even edit the wiki yourself! diff --git a/addon.xml b/addon.xml index f36f8b09..7c699048 100644 --- a/addon.xml +++ b/addon.xml @@ -1,7 +1,7 @@ @@ -16,18 +16,32 @@ - Settings for the Plex Server + 30416 [!IsEmpty(ListItem.DBID) + !StringCompare(ListItem.DBID,-1) | !IsEmpty(ListItem.Property(plexid))] + !IsEmpty(Window(10000).Property(plex_context)) + Native Integration of Plex into Kodi + Native Integration of Plex into Kodi + Native Integration of Plex into Kodi + Úplná integrace Plexu do Kodi + Komplette Integration von Plex in Kodi + Native Integration of Plex into Kodi + Indbygget Integration af Plex i Kodi + Directe integratie van Plex in Kodi + Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them). Use at your own risk! + Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them). Use at your own risk! + Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them). Use at your own risk! + Připojte Kodi ke svému Plex Media Serveru. Tento doplněk předpokládá, že spravujete veškerá svá videa pomocí Plexu (nikoliv pomocí Kodi). Můžete přijít o data uložená ve video a hudební databázi Kodi (tento doplněk je přímo mění). Používejte na vlastní nebezpečí! + Verbindet Kodi mit deinem Plex Media Server. Dieses Addon geht davon aus, dass du all deine Videos mit Plex verwaltest (und keine direkt mit Kodi). Du wirst möglicherweise Daten verlieren, die bereits in der Kodi Video- und/oder Musik-Datenbank gespeichert sind (da dieses Addon beide Datenbanken direkt verändert). Verwende auf eigene Gefahr! + Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them). Use at your own risk! + 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! + Verbind Kodi met je Plex Media Server. Deze plugin gaat ervan uit dat je al je video's met Plex (en niet met Kodi) beheerd. Je kunt gegevens reeds opgeslagen in de databases voor video en muziek van Kodi (deze plugin wijzigt deze gegevens direct) verliezen. Gebruik op eigen risico! all - en - GNU GENERAL PUBLIC LICENSE. Version 2, June 1991 + GPL v2.0 https://forums.plex.tv https://github.com/croneter/PlexKodiConnect + https://github.com/croneter/PlexKodiConnect - Native Integration of Plex into Kodi - Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them). Use at your own risk! \ No newline at end of file diff --git a/changelog.txt b/changelog.txt index e8cd4465..f9eb03a3 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,73 @@ +version 1.6.4 (beta only) +- Amazon Alexa support! Be mindful to check the Alexa forum thread first; there are still many issues completely unrelated to PKC +- Enable skipping for Plex Companion +- Set default companion name to PlexKodiConnect + +version 1.6.3 +- Fix UnicodeEncodeError for non ASCII filenames in playback_starter +- Cleanup playlist/playqueue string/unicode + +version 1.6.2 +- Fix Plex Web Issue, thanks @AllanMar +- Fix TypeError on manually entering PMS port +- Fix KeyError +- Update Danish translation +- Update readme + +version 1.6.1 +- New Danish translation, thanks @Osberg +- Fix UnicodeDecodeError for non-ASCII filenames +- Better error handling for Plex Companion +- Fix ValueError for Watch Later +- Try to skip new PMS items we've already processed +- Fix TypeError + +version 1.6.0 +A DATABASE RESET IS ABSOLUTELY NECESSARY if you're not using beta PKC +Make previous version available for everyone. The highlights: +- New Spanish translation, thanks @bartolomesoriano +- New Czech translation, thanks @Pavuucek +- Plex Companion is completely rewired and should now handly anything you throw at it +- Early compatibility with Kodi 18 Leia +- New playback startup mechanism for plugin paths +- Code rebranding from Emby to Plex, including a plex.db database :-) +- Fixes to Kodi ratings +- Fix playstate and PMS item changes not working/not propagating anymore (caused by a change Plex made with the websocket interface) +- Improvements to the way PKC behaves if the PMS goes offline +- New setting to always transcode if the video bitrate is above a certain threshold (will not work with direct paths) +- Be smarter when deciding when to transcode +- Only sign the user out if the PMS says so +- Cache missing artwork on PKC startup +- Lots of code refactoring and code optimizations +- Tons of fixes + +version 1.5.15 (beta only) +- Fix ratings for movies +- Fixes to Plex Companion +- Always run only one instance of PKC + +version 1.5.14 (beta only) +- Krypton: Fix ratings for episodes and TV shows +- Plex Companion: Fix KeyError for Plex Web +- Fix UnicodeDecodeError for non-ASCII filenames +- Hopefully fix items not marked as entirely watched after having seen >90% +- Code optimization + +version 1.5.13 (beta only) +- New Spanish translation, thanks @bartolomesoriano +- Fix some possible connection issues +- Fix screensaver not toggling back after full sync +- Update addon.xml +- Update readme +- Updated installation instructions on the Wiki +- Updated translations + +version 1.5.12 (beta only) +- Possible compatibility with Alexa - do report back! +- Setup crowdin translations. Please help out translating PlexKodiConnect: https://crowdin.com/project/plexkodiconnect/invite +- Fix OperationalError on update sync +- Apply item limit to On Deck if OnDeckTVextended not set, thanks @dettwild + version 1.5.11 (beta only) - Fix userratings for Kodi Krypton - Krypton: update userratings on startup and on user change diff --git a/default.py b/default.py index ec164dae..bfc9b517 100644 --- a/default.py +++ b/default.py @@ -133,7 +133,7 @@ class Main(): elif mode in ("nextup", "inprogressepisodes"): limit = int(params['limit']) - modes[mode](itemid, limit) + modes[mode](params['tagname'], limit) elif mode in ("channels","getsubfolders"): modes[mode](itemid) @@ -161,8 +161,7 @@ class Main(): modes[mode](itemid, params=argv[2]) elif mode == 'Plex_Node': modes[mode](params.get('id'), - params.get('viewOffset'), - params.get('plex_type')) + params.get('viewOffset')) else: modes[mode]() else: diff --git a/resources/language/English/strings.xml b/resources/language/English/strings.xml index 1960c628..f2de1455 100644 --- a/resources/language/English/strings.xml +++ b/resources/language/English/strings.xml @@ -515,5 +515,5 @@ Reset all PlexKodiConnect Addon settings? (this is usually NOT recommended and unnecessary!) Amazon Alexa (Voice Recognition) - Alexa aktivieren - \ No newline at end of file + Activate Alexa + diff --git a/resources/lib/PlexAPI.py b/resources/lib/PlexAPI.py index d1e26558..e78621d5 100644 --- a/resources/lib/PlexAPI.py +++ b/resources/lib/PlexAPI.py @@ -57,7 +57,7 @@ import variables as v log = logging.getLogger("PLEX."+__name__) REGEX_IMDB = re_compile(r'''/(tt\d+)''') -REGEX_TVDB = re_compile(r'''tvdb://(\d+)''') +REGEX_TVDB = re_compile(r'''thetvdb:\/\/(.+?)\?''') ############################################################################### @@ -474,9 +474,18 @@ class PlexAPI(): if PMS['uuid'] in self.g_PMS: log.debug('We already know of PMS %s from plex.tv' % PMS['serverName']) - continue - self.declarePMS(PMS['uuid'], PMS['serverName'], 'http', - PMS['ip'], PMS['port']) + # Update with GDM data - potentially more reliable than plex.tv + self.updatePMSProperty(PMS['uuid'], 'ip', PMS['ip']) + self.updatePMSProperty(PMS['uuid'], 'port', PMS['port']) + self.updatePMSProperty(PMS['uuid'], 'local', '1') + self.updatePMSProperty(PMS['uuid'], 'scheme', 'http') + self.updatePMSProperty(PMS['uuid'], + 'baseURL', + 'http://%s:%s' % (PMS['ip'], + PMS['port'])) + else: + self.declarePMS(PMS['uuid'], PMS['serverName'], 'http', + PMS['ip'], PMS['port']) # Ping to check whether we need HTTPs or HTTP https = PMSHttpsEnabled('%s:%s' % (PMS['ip'], PMS['port'])) if https is None: @@ -1247,26 +1256,26 @@ class API(): favorite = False try: playcount = int(item['viewCount']) - except KeyError: + except (KeyError, ValueError): playcount = None played = True if playcount else False try: lastPlayedDate = DateToKodi(int(item['lastViewedAt'])) - except KeyError: + except (KeyError, ValueError): lastPlayedDate = None try: userrating = int(float(item['userRating'])) - except KeyError: + except (KeyError, ValueError): userrating = 0 try: rating = float(item['audienceRating']) - except KeyError: + except (KeyError, ValueError): try: rating = float(item['rating']) - except KeyError: + except (KeyError, ValueError): rating = 0.0 resume, runtime = self.getRuntime() diff --git a/resources/lib/PlexCompanion.py b/resources/lib/PlexCompanion.py index 64814eef..fd79d8bf 100644 --- a/resources/lib/PlexCompanion.py +++ b/resources/lib/PlexCompanion.py @@ -6,13 +6,16 @@ from socket import SHUT_RDWR from xbmc import sleep -from utils import settings, ThreadMethodsAdditionalSuspend, ThreadMethods +from utils import settings, ThreadMethodsAdditionalSuspend, ThreadMethods, \ + window from plexbmchelper import listener, plexgdm, subscribers, functions, \ httppersist, plexsettings -from PlexFunctions import ParseContainerKey +from PlexFunctions import ParseContainerKey, GetPlexMetadata +from PlexAPI import API import player from entrypoint import Plex_Node -from variables import KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE +import variables as v + ############################################################################### @@ -34,7 +37,7 @@ class PlexCompanion(Thread): # Start GDM for server/client discovery self.client = plexgdm.plexgdm() self.client.clientDetails(self.settings) - log.debug("Registration string is: %s " + log.debug("Registration string is:\n%s" % self.client.getClientDetails()) # kodi player instance self.player = player.Player() @@ -76,27 +79,62 @@ class PlexCompanion(Thread): log.debug('Processing: %s' % task) data = task['data'] - if (task['action'] == 'playlist' and + if task['action'] == 'alexa': + # e.g. Alexa + xml = GetPlexMetadata(data['key']) + try: + xml[0].attrib + except (AttributeError, IndexError, TypeError): + log.error('Could not download Plex metadata') + return + api = API(xml[0]) + if api.getType() == v.PLEX_TYPE_ALBUM: + log.debug('Plex music album detected') + self.mgr.playqueue.init_playqueue_from_plex_children( + api.getRatingKey()) + else: + thread = Thread(target=Plex_Node, + args=('{server}%s' % data.get('key'), + data.get('offset'), + True, + False),) + thread.setDaemon(True) + thread.start() + + elif (task['action'] == 'playlist' and data.get('address') == 'node.plexapp.com'): # E.g. watch later initiated by Companion thread = Thread(target=Plex_Node, args=('{server}%s' % data.get('key'), data.get('offset'), - data.get('type'), True),) thread.setDaemon(True) thread.start() + elif task['action'] == 'playlist': # Get the playqueue ID try: - _, ID, query = ParseContainerKey(data['containerKey']) + typus, ID, query = ParseContainerKey(data['containerKey']) except Exception as e: log.error('Exception while processing: %s' % e) import traceback log.error("Traceback:\n%s" % traceback.format_exc()) return - playqueue = self.mgr.playqueue.get_playqueue_from_type( - KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']]) + try: + playqueue = self.mgr.playqueue.get_playqueue_from_type( + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']]) + except KeyError: + # E.g. Plex web does not supply the media type + # Still need to figure out the type (video vs. music vs. pix) + xml = GetPlexMetadata(data['key']) + try: + xml[0].attrib + except (AttributeError, IndexError, TypeError): + log.error('Could not download Plex metadata') + return + api = API(xml[0]) + playqueue = self.mgr.playqueue.get_playqueue_from_type( + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.getType()]) self.mgr.playqueue.update_playqueue_from_PMS( playqueue, ID, @@ -117,6 +155,7 @@ class PlexCompanion(Thread): jsonClass, requestMgr, self.player, self.mgr) queue = Queue.Queue(maxsize=100) + self.queue = queue if settings('plexCompanion') == 'true': # Start up httpd @@ -179,6 +218,7 @@ class PlexCompanion(Thread): log.debug("Client is no longer registered. " "Plex Companion still running on port %s" % self.settings['myport']) + client.register_as_client() # Get and set servers if message_count % 30 == 0: subscriptionManager.serverlist = client.getServerList() @@ -200,7 +240,7 @@ class PlexCompanion(Thread): queue.task_done() # Don't sleep continue - sleep(20) + sleep(50) client.stop_all() if httpd: diff --git a/resources/lib/PlexFunctions.py b/resources/lib/PlexFunctions.py index 904648d9..04e1a6ff 100644 --- a/resources/lib/PlexFunctions.py +++ b/resources/lib/PlexFunctions.py @@ -314,8 +314,8 @@ def GetPlexCollections(mediatype): return collections -def GetPlexPlaylist(itemid, librarySectionUUID, mediatype='movie', - trailers=False): +def init_plex_playqueue(itemid, librarySectionUUID, mediatype='movie', + trailers=False): """ Returns raw API metadata XML dump for a playlist with e.g. trailers. """ diff --git a/resources/lib/artwork.py b/resources/lib/artwork.py index 786a6d5b..1336687f 100644 --- a/resources/lib/artwork.py +++ b/resources/lib/artwork.py @@ -99,7 +99,7 @@ def setKodiWebServerDetails(): result = loads(result) try: xbmc_username = result['result']['value'] - except TypeError: + except (TypeError, KeyError): pass web_pass = { "jsonrpc": "2.0", diff --git a/resources/lib/clientinfo.py b/resources/lib/clientinfo.py index 8568930c..91cb6b91 100644 --- a/resources/lib/clientinfo.py +++ b/resources/lib/clientinfo.py @@ -39,7 +39,7 @@ def getXArgsDeviceInfo(options=None): 'X-Plex-Product': v.ADDON_NAME, 'X-Plex-Version': v.ADDON_VERSION, 'X-Plex-Client-Identifier': getDeviceId(), - 'X-Plex-Provides': 'client,controller,player', + 'X-Plex-Provides': 'client,controller,player,pubsub-player', } if window('pms_token'): xargs['X-Plex-Token'] = window('pms_token') diff --git a/resources/lib/companion.py b/resources/lib/companion.py new file mode 100644 index 00000000..652e5c48 --- /dev/null +++ b/resources/lib/companion.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +import logging +from re import compile as re_compile + +from xbmc import Player + +from utils import JSONRPC +from variables import ALEXA_TO_COMPANION +from playqueue import Playqueue +from PlexFunctions import GetPlexKeyNumber + +############################################################################### + +log = logging.getLogger("PLEX."+__name__) + +REGEX_PLAYQUEUES = re_compile(r'''/playQueues/(\d+)$''') + +############################################################################### + + +def getPlayers(): + info = JSONRPC("Player.GetActivePlayers").execute()['result'] or [] + ret = {} + for player in info: + player['playerid'] = int(player['playerid']) + ret[player['type']] = player + return ret + + +def getPlayerIds(): + ret = [] + for player in getPlayers().values(): + ret.append(player['playerid']) + return ret + + +def getPlaylistId(typus): + """ + typus: one of the Kodi types, e.g. audio or video + + Returns None if nothing was found + """ + for playlist in getPlaylists(): + if playlist.get('type') == typus: + return playlist.get('playlistid') + + +def getPlaylists(): + """ + Returns a list, e.g. + [ + {u'playlistid': 0, u'type': u'audio'}, + {u'playlistid': 1, u'type': u'video'}, + {u'playlistid': 2, u'type': u'picture'} + ] + """ + return JSONRPC('Playlist.GetPlaylists').execute() + + +def millisToTime(t): + millis = int(t) + seconds = millis / 1000 + minutes = seconds / 60 + hours = minutes / 60 + seconds = seconds % 60 + minutes = minutes % 60 + millis = millis % 1000 + return {'hours': hours, + 'minutes': minutes, + 'seconds': seconds, + 'milliseconds': millis} + + +def skipTo(params): + # Does not seem to be implemented yet + playQueueItemID = params.get('playQueueItemID', 'not available') + library, plex_id = GetPlexKeyNumber(params.get('key')) + log.debug('Skipping to playQueueItemID %s, plex_id %s' + % (playQueueItemID, plex_id)) + found = True + playqueues = Playqueue() + for (player, ID) in getPlayers().iteritems(): + playqueue = playqueues.get_playqueue_from_type(player) + for i, item in enumerate(playqueue.items): + if item.ID == playQueueItemID or item.plex_id == plex_id: + break + else: + log.debug('Item not found to skip to') + found = False + if found: + Player().play(playqueue.kodi_pl, None, False, i) + + +def convert_alexa_to_companion(dictionary): + for key in dictionary: + if key in ALEXA_TO_COMPANION: + dictionary[ALEXA_TO_COMPANION[key]] = dictionary[key] + del dictionary[key] + + +def process_command(request_path, params, queue=None): + """ + queue: Queue() of PlexCompanion.py + """ + if params.get('deviceName') == 'Alexa': + convert_alexa_to_companion(params) + log.debug('Received request_path: %s, params: %s' % (request_path, params)) + if "/playMedia" in request_path: + # We need to tell service.py + action = 'alexa' if params.get('deviceName') == 'Alexa' else 'playlist' + queue.put({ + 'action': action, + 'data': params + }) + + elif request_path == "player/playback/setParameters": + if 'volume' in params: + volume = int(params['volume']) + log.debug("Adjusting the volume to %s" % volume) + JSONRPC('Application.SetVolume').execute({"volume": volume}) + + elif request_path == "player/playback/play": + for playerid in getPlayerIds(): + JSONRPC("Player.PlayPause").execute({"playerid": playerid, + "play": True}) + + elif request_path == "player/playback/pause": + for playerid in getPlayerIds(): + JSONRPC("Player.PlayPause").execute({"playerid": playerid, + "play": False}) + + elif request_path == "player/playback/stop": + for playerid in getPlayerIds(): + JSONRPC("Player.Stop").execute({"playerid": playerid}) + + elif request_path == "player/playback/seekTo": + for playerid in getPlayerIds(): + JSONRPC("Player.Seek").execute( + {"playerid": playerid, + "value": millisToTime(params.get('offset', 0))}) + + elif request_path == "player/playback/stepForward": + for playerid in getPlayerIds(): + JSONRPC("Player.Seek").execute({"playerid": playerid, + "value": "smallforward"}) + + elif request_path == "player/playback/stepBack": + for playerid in getPlayerIds(): + JSONRPC("Player.Seek").execute({"playerid": playerid, + "value": "smallbackward"}) + + elif request_path == "player/playback/skipNext": + for playerid in getPlayerIds(): + JSONRPC("Player.GoTo").execute({"playerid": playerid, + "to": "next"}) + + elif request_path == "player/playback/skipPrevious": + for playerid in getPlayerIds(): + JSONRPC("Player.GoTo").execute({"playerid": playerid, + "to": "previous"}) + + elif request_path == "player/playback/skipTo": + skipTo(params) + + elif request_path == "player/navigation/moveUp": + JSONRPC("Input.Up").execute() + + elif request_path == "player/navigation/moveDown": + JSONRPC("Input.Down").execute() + + elif request_path == "player/navigation/moveLeft": + JSONRPC("Input.Left").execute() + + elif request_path == "player/navigation/moveRight": + JSONRPC("Input.Right").execute() + + elif request_path == "player/navigation/select": + JSONRPC("Input.Select").execute() + + elif request_path == "player/navigation/home": + JSONRPC("Input.Home").execute() + + elif request_path == "player/navigation/back": + JSONRPC("Input.Back").execute() + + else: + log.error('Unknown request path: %s' % request_path) diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index cd4f70dc..2aa33cd3 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -14,6 +14,7 @@ from utils import window, settings, language as lang, dialog, tryDecode,\ tryEncode, CatchExceptions, JSONRPC import downloadutils import playbackutils as pbutils +import plexdb_functions as plexdb from PlexFunctions import GetPlexMetadata, GetPlexSectionResults, \ GetMachineIdentifier @@ -96,7 +97,7 @@ def togglePlexTV(): sound=False) -def Plex_Node(url, viewOffset, plex_type, playdirectly=False): +def Plex_Node(url, viewOffset, playdirectly=False, node=True): """ Called only for a SINGLE element for Plex.tv watch later @@ -120,11 +121,25 @@ def Plex_Node(url, viewOffset, plex_type, playdirectly=False): else: window('plex_customplaylist.seektime', value=str(viewOffset)) log.info('Set resume point to %s' % str(viewOffset)) - typus = v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type] + api = API(xml[0]) + typus = v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.getType()] + if node is True: + plex_id = None + kodi_id = 'plexnode' + else: + plex_id = api.getRatingKey() + kodi_id = None + with plexdb.Get_Plex_DB() as plex_db: + plexdb_item = plex_db.getItem_byId(plex_id) + try: + kodi_id = plexdb_item[0] + except TypeError: + log.info('Couldnt find item %s in Kodi db' + % api.getRatingKey()) playqueue = Playqueue().get_playqueue_from_type(typus) result = pbutils.PlaybackUtils(xml, playqueue).play( - None, - kodi_id='plexnode', + plex_id, + kodi_id=kodi_id, plex_lib_UUID=xml.attrib.get('librarySectionUUID')) if result.listitem: listitem = convert_PKC_to_listitem(result.listitem) diff --git a/resources/lib/initialsetup.py b/resources/lib/initialsetup.py index 7bae1bde..4e9ad834 100644 --- a/resources/lib/initialsetup.py +++ b/resources/lib/initialsetup.py @@ -6,13 +6,12 @@ import logging import xbmc import xbmcgui -from utils import settings, window, language as lang +from utils import settings, window, language as lang, tryEncode import downloadutils from userclient import UserClient from PlexAPI import PlexAPI from PlexFunctions import GetMachineIdentifier, get_PMS_settings -import variables as v ############################################################################### @@ -257,7 +256,8 @@ class InitialSetup(): log.warn('Not authorized even though we are signed ' ' in to plex.tv correctly') self.dialog.ok(lang(29999), '%s %s' - % lang(39214) + server['name']) + % (lang(39214), + tryEncode(server['name']))) return else: return diff --git a/resources/lib/itemtypes.py b/resources/lib/itemtypes.py index cd0f6bad..5c419afd 100644 --- a/resources/lib/itemtypes.py +++ b/resources/lib/itemtypes.py @@ -6,6 +6,7 @@ import logging from urllib import urlencode from ntpath import dirname from datetime import datetime +from xbmc import sleep import artwork from utils import tryEncode, tryDecode, settings, window, kodiSQL, \ @@ -21,6 +22,7 @@ import variables as v log = logging.getLogger("PLEX."+__name__) +MARK_PLAYED_AT = 0.90 ############################################################################### @@ -159,13 +161,13 @@ class Items(object): # If the playback was stopped, check whether we need to increment the # playcount. PMS won't tell us the playcount via websockets if item['state'] in ('stopped', 'ended'): - markPlayed = 0.90 complete = float(item['viewOffset']) / float(item['duration']) log.info('Item %s stopped with completion rate %s percent.' 'Mark item played at %s percent.' - % (item['ratingKey'], str(complete), markPlayed), 1) - if complete >= markPlayed: + % (item['ratingKey'], str(complete), MARK_PLAYED_AT), 1) + if complete >= MARK_PLAYED_AT: log.info('Marking as completely watched in Kodi', 1) + sleep(500) try: item['viewCount'] += 1 except TypeError: @@ -314,15 +316,17 @@ class Movies(Items): # Update the movie entry if v.KODIVERSION >= 17: # update new ratings Kodi 17 - ratingid = self.kodi_db.get_ratingid(movieid) + rating_id = self.kodi_db.get_ratingid(movieid, + v.KODI_TYPE_MOVIE) self.kodi_db.update_ratings(movieid, v.KODI_TYPE_MOVIE, "default", rating, votecount, - ratingid) + rating_id) # update new uniqueid Kodi 17 - uniqueid = self.kodi_db.get_uniqueid(movieid) + uniqueid = self.kodi_db.get_uniqueid(movieid, + v.KODI_TYPE_MOVIE) self.kodi_db.update_uniqueid(movieid, v.KODI_TYPE_MOVIE, imdb, @@ -338,10 +342,10 @@ class Movies(Items): WHERE idMovie = ? ''' kodicursor.execute(query, (title, plot, shortplot, tagline, - votecount, rating, writer, year, imdb, sorttitle, runtime, - mpaa, genre, director, title, studio, trailer, country, - playurl, pathid, fileid, year, userdata['UserRating'], - movieid)) + votecount, rating_id, writer, year, imdb, sorttitle, + runtime, mpaa, genre, director, title, studio, trailer, + country, playurl, pathid, fileid, year, + userdata['UserRating'], movieid)) else: query = ''' UPDATE movie @@ -361,7 +365,8 @@ class Movies(Items): log.info("ADD movie itemid: %s - Title: %s" % (itemid, title)) if v.KODIVERSION >= 17: # add new ratings Kodi 17 - self.kodi_db.add_ratings(self.kodi_db.create_entry_rating(), + rating_id = self.kodi_db.create_entry_rating() + self.kodi_db.add_ratings(rating_id, movieid, v.KODI_TYPE_MOVIE, "default", @@ -381,9 +386,9 @@ class Movies(Items): ?, ?, ?, ?, ?, ?, ?) ''' kodicursor.execute(query, (movieid, fileid, title, plot, - shortplot, tagline, votecount, rating, writer, year, imdb, - sorttitle, runtime, mpaa, genre, director, title, studio, - trailer, country, playurl, pathid, year, + shortplot, tagline, votecount, rating_id, writer, year, + imdb, sorttitle, runtime, mpaa, genre, director, title, + studio, trailer, country, playurl, pathid, year, userdata['UserRating'])) else: query = ''' @@ -512,8 +517,6 @@ class TVShows(Items): if not itemid: log.error("Cannot parse XML data for TV show") return - # If the item already exist in the local Kodi DB we'll perform a full item update - # If the item doesn't exist, we'll add it to the database update_item = True force_episodes = False plex_dbitem = plex_db.getItem_byId(itemid) @@ -547,6 +550,7 @@ class TVShows(Items): title, sorttitle = API.getTitle() plot = API.getPlot() rating = API.getAudienceRating() + votecount = None premieredate = API.getPremiereDate() tvdb = API.getProvider('tvdb') mpaa = API.getMpaa() @@ -594,33 +598,6 @@ class TVShows(Items): if update_item: log.info("UPDATE tvshow itemid: %s - Title: %s" % (itemid, title)) - if v.KODIVERSION >= 17: - # update new ratings Kodi 17 - ratingid = self.kodi_db.get_ratingid(showid) - self.kodi_db.update_ratings(showid, - v.KODI_TYPE_SHOW, - "default", - rating, - None, # votecount - ratingid) - # update new uniqueid Kodi 17 - uniqueid = self.kodi_db.get_uniqueid(showid) - self.kodi_db.update_uniqueid(showid, - v.KODI_TYPE_SHOW, - tvdb, - "tvdb", - uniqueid) - # Update the tvshow entry - query = ' '.join(( - - "UPDATE tvshow", - "SET c00 = ?, c01 = ?, c04 = ?, c05 = ?, c08 = ?, c09 = ?,", - "c12 = ?, c13 = ?, c14 = ?, c15 = ?", - "WHERE idShow = ?" - )) - kodicursor.execute(query, (title, plot, rating, premieredate, genre, title, - tvdb, mpaa, studio, sorttitle, showid)) - # Add reference is idempotent; the call here updates also fileid # and pathid when item is moved or renamed plex_db.addReference(itemid, @@ -630,50 +607,60 @@ class TVShows(Items): kodi_pathid=pathid, checksum=checksum, view_id=viewid) - - ##### OR ADD THE TVSHOW ##### + if v.KODIVERSION >= 17: + # update new ratings Kodi 17 + rating_id = self.kodi_db.get_ratingid(showid, v.KODI_TYPE_SHOW) + self.kodi_db.update_ratings(showid, + v.KODI_TYPE_SHOW, + "default", + rating, + votecount, + rating_id) + # update new uniqueid Kodi 17 + uniqueid = self.kodi_db.get_uniqueid(showid, v.KODI_TYPE_SHOW) + self.kodi_db.update_uniqueid(showid, + v.KODI_TYPE_SHOW, + tvdb, + "tvdb", + uniqueid) + # Update the tvshow entry + query = ''' + UPDATE tvshow + SET c00 = ?, c01 = ?, c04 = ?, c05 = ?, c08 = ?, c09 = ?, + c12 = ?, c13 = ?, c14 = ?, c15 = ? + WHERE idShow = ? + ''' + kodicursor.execute(query, (title, plot, rating_id, + premieredate, genre, title, tvdb, + mpaa, studio, sorttitle, showid)) + else: + # Update the tvshow entry + query = ''' + UPDATE tvshow + SET c00 = ?, c01 = ?, c04 = ?, c05 = ?, c08 = ?, c09 = ?, + c12 = ?, c13 = ?, c14 = ?, c15 = ? + WHERE idShow = ? + ''' + kodicursor.execute(query, (title, plot, rating, premieredate, + genre, title, tvdb, mpaa, studio, + sorttitle, showid)) + + # OR ADD THE TVSHOW ##### else: log.info("ADD tvshow itemid: %s - Title: %s" % (itemid, title)) - if v.KODIVERSION >= 17: - # add new ratings Kodi 17 - ratingid = self.kodi_db.create_entry_rating() - self.kodi_db.add_ratings(ratingid, - showid, - v.KODI_TYPE_SHOW, - "default", - rating, - None) # votecount - # add new uniqueid Kodi 17 - uniqueid = self.kodi_db.create_entry_uniqueid() - self.kodi_db.add_uniqueid(uniqueid, - showid, - v.KODI_TYPE_SHOW, - tvdb, - "tvdb") - query = ' '.join(( - - "UPDATE path", - "SET strPath = ?, strContent = ?, strScraper = ?, noUpdate = ?", - "WHERE idPath = ?" - )) - kodicursor.execute(query, (toplevelpath, "tvshows", "metadata.local", 1, toppathid)) - - # Create the tvshow entry - query = ( - ''' - INSERT INTO tvshow( - idShow, c00, c01, c04, c05, c08, c09, c12, c13, c14, c15) - - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ''' - ) - kodicursor.execute(query, (showid, title, plot, rating, premieredate, genre, - title, tvdb, mpaa, studio, sorttitle)) - + query = ''' + UPDATE path + SET strPath = ?, strContent = ?, strScraper = ?, noUpdate = ? + WHERE idPath = ? + ''' + kodicursor.execute(query, (toplevelpath, + "tvshows", + "metadata.local", + 1, + toppathid)) # Link the path - query = "INSERT INTO tvshowlinkpath(idShow, idPath) values(?, ?)" + query = "INSERT INTO tvshowlinkpath(idShow, idPath) values (?, ?)" kodicursor.execute(query, (showid, pathid)) - # Create the reference in plex table plex_db.addReference(itemid, v.PLEX_TYPE_SHOW, @@ -682,16 +669,51 @@ class TVShows(Items): kodi_pathid=pathid, checksum=checksum, view_id=viewid) + if v.KODIVERSION >= 17: + # add new ratings Kodi 17 + rating_id = self.kodi_db.create_entry_rating() + self.kodi_db.add_ratings(rating_id, + showid, + v.KODI_TYPE_SHOW, + "default", + rating, + votecount) + # add new uniqueid Kodi 17 + self.kodi_db.add_uniqueid(self.kodi_db.create_entry_uniqueid(), + showid, + v.KODI_TYPE_SHOW, + tvdb, + "tvdb") + # Create the tvshow entry + query = ''' + INSERT INTO tvshow( + idShow, c00, c01, c04, c05, c08, c09, c12, c13, c14, + c15) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''' + kodicursor.execute(query, (showid, title, plot, rating_id, + premieredate, genre, title, tvdb, + mpaa, studio, sorttitle)) + else: + # Create the tvshow entry + query = ''' + INSERT INTO tvshow( + idShow, c00, c01, c04, c05, c08, c09, c12, c13, c14, + c15) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''' + kodicursor.execute(query, (showid, title, plot, rating, + premieredate, genre, title, tvdb, + mpaa, studio, sorttitle)) # Update the path - query = ' '.join(( - - "UPDATE path", - "SET strPath = ?, strContent = ?, strScraper = ?, noUpdate = ?, ", - "idParentPath = ?" - "WHERE idPath = ?" - )) + query = ''' + UPDATE path + SET strPath = ?, strContent = ?, strScraper = ?, noUpdate = ?, + idParentPath = ? + WHERE idPath = ? + ''' kodicursor.execute(query, (path, None, None, 1, toppathid, pathid)) - + # Process cast people = API.getPeopleList() self.kodi_db.addPeople(showid, people, "tvshow") @@ -707,12 +729,6 @@ class TVShows(Items): tags.extend(collections) self.kodi_db.addTags(showid, tags, "tvshow") - # if force_episodes: - # # We needed to recreate the show entry. Re-add episodes now. - # log.info("Repairing episodes for showid: %s %s" % (showid, title)) - # all_episodes = embyserver.getEpisodesbyShow(itemid) - # self.added_episode(all_episodes['Items'], None) - @CatchExceptions(warnuser=True) def add_updateSeason(self, item, viewtag=None, viewid=None): API = PlexAPI.API(item) @@ -809,13 +825,13 @@ class TVShows(Items): userdata = API.getUserData() playcount = userdata['PlayCount'] dateplayed = userdata['LastPlayedDate'] + tvdb = API.getProvider('tvdb') + votecount = None # item details peoples = API.getPeople() director = API.joinList(peoples['Director']) writer = API.joinList(peoples['Writer']) - cast = API.joinList(peoples['Cast']) - producer = API.joinList(peoples['Producer']) title, sorttitle = API.getTitle() plot = API.getPlot() rating = userdata['Rating'] @@ -916,7 +932,23 @@ class TVShows(Items): log.info("UPDATE episode itemid: %s" % (itemid)) # Update the movie entry if v.KODIVERSION >= 17: - # Kodi Krypton + # update new ratings Kodi 17 + ratingid = self.kodi_db.get_ratingid(episodeid, + v.KODI_TYPE_EPISODE) + self.kodi_db.update_ratings(episodeid, + v.KODI_TYPE_EPISODE, + "default", + rating, + votecount, + ratingid) + # update new uniqueid Kodi 17 + uniqueid = self.kodi_db.get_uniqueid(episodeid, + v.KODI_TYPE_EPISODE) + self.kodi_db.update_uniqueid(episodeid, + v.KODI_TYPE_EPISODE, + tvdb, + "tvdb", + uniqueid) query = ''' UPDATE episode SET c00 = ?, c01 = ?, c03 = ?, c04 = ?, c05 = ?, c09 = ?, @@ -962,7 +994,20 @@ class TVShows(Items): log.info("ADD episode itemid: %s - Title: %s" % (itemid, title)) # Create the episode entry if v.KODIVERSION >= 17: - # Kodi Krypton + # add new ratings Kodi 17 + rating_id = self.kodi_db.create_entry_rating() + self.kodi_db.add_ratings(rating_id, + episodeid, + v.KODI_TYPE_EPISODE, + "default", + rating, + votecount) + # add new uniqueid Kodi 17 + self.kodi_db.add_uniqueid(self.kodi_db.create_entry_uniqueid(), + episodeid, + v.KODI_TYPE_EPISODE, + tvdb, + "tvdb") query = ''' INSERT INTO episode( idEpisode, idFile, c00, c01, c03, c04, c05, c09, c10, c12, c13, c14, idShow, c15, c16, c18, @@ -971,7 +1016,7 @@ class TVShows(Items): ?, ?) ''' kodicursor.execute(query, (episodeid, fileid, title, plot, - rating, writer, premieredate, runtime, director, season, + rating_id, writer, premieredate, runtime, director, season, episode, title, showid, airsBeforeSeason, airsBeforeEpisode, playurl, pathid, seasonid, userdata['UserRating'])) @@ -1193,18 +1238,23 @@ class TVShows(Items): self.kodi_db.remove_ratings(kodi_id, v.KODI_TYPE_SHOW) log.info("Removed tvshow: %s." % kodi_id) - def removeSeason(self, kodiid): + def removeSeason(self, kodi_id): kodicursor = self.kodicursor - self.artwork.deleteArtwork(kodiid, "season", kodicursor) - kodicursor.execute("DELETE FROM seasons WHERE idSeason = ?", (kodiid,)) - log.info("Removed season: %s." % kodiid) + self.artwork.deleteArtwork(kodi_id, "season", kodicursor) + kodicursor.execute("DELETE FROM seasons WHERE idSeason = ?", + (kodi_id,)) + log.info("Removed season: %s." % kodi_id) - def removeEpisode(self, kodiid, fileid): + def removeEpisode(self, kodi_id, fileid): kodicursor = self.kodicursor - self.artwork.deleteArtwork(kodiid, "episode", kodicursor) - kodicursor.execute("DELETE FROM episode WHERE idEpisode = ?", (kodiid,)) + self.artwork.deleteArtwork(kodi_id, "episode", kodicursor) + kodicursor.execute("DELETE FROM episode WHERE idEpisode = ?", + (kodi_id,)) kodicursor.execute("DELETE FROM files WHERE idFile = ?", (fileid,)) - log.info("Removed episode: %s." % kodiid) + if v.KODIVERSION >= 17: + self.kodi_db.remove_uniqueid(kodi_id, v.KODI_TYPE_EPISODE) + self.kodi_db.remove_ratings(kodi_id, v.KODI_TYPE_EPISODE) + log.info("Removed episode: %s." % kodi_id) class Music(Items): diff --git a/resources/lib/kodidb_functions.py b/resources/lib/kodidb_functions.py index ac14853b..5148fb47 100644 --- a/resources/lib/kodidb_functions.py +++ b/resources/lib/kodidb_functions.py @@ -1423,9 +1423,11 @@ class Kodidb_Functions(): def add_uniqueid(self, *args): """ Feed with: - uniqueid_id, media_id, media_type, value, type - - type: e.g. 'imdb' + uniqueid_id: int + media_id: int + media_type: string + value: string + type: e.g. 'imdb' or 'tvdb' """ query = ''' INSERT INTO uniqueid( @@ -1434,9 +1436,12 @@ class Kodidb_Functions(): ''' self.cursor.execute(query, (args)) - def get_uniqueid(self, media_id): - query = "SELECT uniqueid_id FROM uniqueid WHERE media_id = ?" - self.cursor.execute(query, (media_id,)) + def get_uniqueid(self, kodi_id, kodi_type): + query = ''' + SELECT uniqueid_id FROM uniqueid + WHERE media_id = ? AND media_type = ? + ''' + self.cursor.execute(query, (kodi_id, kodi_type)) try: uniqueid = self.cursor.fetchone()[0] except TypeError: @@ -1465,9 +1470,12 @@ class Kodidb_Functions(): self.cursor.execute("select coalesce(max(rating_id),0) from rating") return self.cursor.fetchone()[0] + 1 - def get_ratingid(self, media_id): - query = "SELECT rating_id FROM rating WHERE media_id = ?" - self.cursor.execute(query, (media_id,)) + def get_ratingid(self, kodi_id, kodi_type): + query = ''' + SELECT rating_id FROM rating + WHERE media_id = ? AND media_type = ? + ''' + self.cursor.execute(query, (kodi_id, kodi_type)) try: ratingid = self.cursor.fetchone()[0] except TypeError: diff --git a/resources/lib/librarysync.py b/resources/lib/librarysync.py index 279f679f..8de3a96a 100644 --- a/resources/lib/librarysync.py +++ b/resources/lib/librarysync.py @@ -321,6 +321,11 @@ 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() @@ -532,6 +537,9 @@ 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) @@ -605,6 +613,7 @@ class LibrarySync(Thread): except Exception as e: # Empty movies, tv shows? log.error('Path hack failed with error message: %s' % str(e)) + setScreensaver(value=screensaver) return True def processView(self, folderItem, kodi_db, plex_db, totalnodes): @@ -883,6 +892,7 @@ 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: @@ -902,6 +912,7 @@ class LibrarySync(Thread): 'title': item.attrib.get('title', 'Missing Title'), 'mediaType': item.attrib.get('type') }) + self.just_processed[itemId] = now return if self.compare: @@ -927,6 +938,7 @@ class LibrarySync(Thread): 'title': item.attrib.get('title', 'Missing Title'), 'mediaType': item.attrib.get('type') }) + self.just_processed[itemId] = now else: # Initial or repair sync: get all Plex movies for item in xml: @@ -945,6 +957,7 @@ class LibrarySync(Thread): 'title': item.attrib.get('title', 'Missing Title'), 'mediaType': item.attrib.get('type') }) + self.just_processed[itemId] = now def GetAndProcessXMLs(self, itemType): """ @@ -1449,6 +1462,8 @@ class LibrarySync(Thread): 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): @@ -1533,6 +1548,7 @@ 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 @@ -1547,6 +1563,14 @@ class LibrarySync(Thread): 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 state != 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? for existingItem in self.itemsToProcess: if existingItem['ratingKey'] == plex_id: diff --git a/resources/lib/loghandler.py b/resources/lib/loghandler.py index 51c22aff..c4e34188 100644 --- a/resources/lib/loghandler.py +++ b/resources/lib/loghandler.py @@ -63,7 +63,7 @@ class MyFormatter(logging.Formatter): # 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' + self._fmt = '%(name)s -> %(levelname)s: %(message)s' # Call the original formatter class to do the grunt work result = logging.Formatter.format(self, record) diff --git a/resources/lib/playbackutils.py b/resources/lib/playbackutils.py index 37bb9db9..fabbda83 100644 --- a/resources/lib/playbackutils.py +++ b/resources/lib/playbackutils.py @@ -14,7 +14,7 @@ from utils import window, settings, tryEncode, tryDecode, language as lang import downloadutils from PlexAPI import API -from PlexFunctions import GetPlexPlaylist +from PlexFunctions import init_plex_playqueue from PKC_listitem import PKC_ListItem as ListItem, convert_PKC_to_listitem from playlist_func import add_item_to_kodi_playlist, \ get_playlist_details_from_xml, add_listitem_to_Kodi_playlist, \ @@ -134,12 +134,14 @@ class PlaybackUtils(): else: trailers = True # Post to the PMS. REUSE THE PLAYQUEUE! - xml = GetPlexPlaylist( - plex_id, - plex_lib_UUID, - mediatype=api.getType(), - trailers=trailers) - get_playlist_details_from_xml(playqueue, xml=xml) + xml = init_plex_playqueue(plex_id, + plex_lib_UUID, + mediatype=api.getType(), + trailers=trailers) + try: + get_playlist_details_from_xml(playqueue, xml=xml) + except KeyError: + return if (not homeScreen and not seektime and sizePlaylist < 2 and window('plex_customplaylist') != "true" and @@ -288,16 +290,19 @@ class PlaybackUtils(): self.currentPosition = 0 for item in self.xml: api = API(item) + successful = True if api.getType() == v.PLEX_TYPE_CLIP: self.add_trailer(item) else: with Get_Plex_DB() as plex_db: db_item = plex_db.getItem_byId(api.getRatingKey()) if db_item is not None: - if add_item_to_kodi_playlist(self.playqueue, - self.currentPosition, - kodi_id=db_item[0], - kodi_type=db_item[4]) is True: + successful = add_item_to_kodi_playlist( + self.playqueue, + self.currentPosition, + kodi_id=db_item[0], + kodi_type=db_item[4]) + if successful is True: self.currentPosition += 1 if len(item[0]) > 1: self.add_part(item, @@ -307,8 +312,9 @@ class PlaybackUtils(): else: # Item not in Kodi DB self.add_trailer(item) - self.playqueue.items[self.currentPosition - 1].ID = item.get( - '%sItemID' % self.playqueue.kind) + if successful is True: + self.playqueue.items[self.currentPosition - 1].ID = item.get( + '%sItemID' % self.playqueue.kind) def add_trailer(self, item): # Playurl needs to point back so we can get metadata! diff --git a/resources/lib/player.py b/resources/lib/player.py index 7e5d05ff..bba39909 100644 --- a/resources/lib/player.py +++ b/resources/lib/player.py @@ -8,7 +8,7 @@ import xbmc import xbmcgui from utils import window, settings, language as lang, DateToKodi, \ - getUnixTimestamp + getUnixTimestamp, tryDecode, tryEncode import downloadutils import plexdb_functions as plexdb import kodidb_functions as kodidb @@ -48,7 +48,7 @@ class Player(xbmc.Player): # Get current file (in utf-8!) try: - currentFile = self.getPlayingFile() + currentFile = tryDecode(self.getPlayingFile()) xbmc.sleep(300) except: currentFile = "" @@ -56,7 +56,7 @@ class Player(xbmc.Player): while not currentFile: xbmc.sleep(100) try: - currentFile = self.getPlayingFile() + currentFile = tryDecode(self.getPlayingFile()) except: pass if count == 20: @@ -71,11 +71,11 @@ class Player(xbmc.Player): self.currentFile = currentFile window('plex_lastPlayedFiled', value=currentFile) # We may need to wait for info to be set in kodi monitor - itemId = window("plex_%s.itemid" % currentFile) + itemId = window("plex_%s.itemid" % tryEncode(currentFile)) count = 0 while not itemId: xbmc.sleep(200) - itemId = window("plex_%s.itemid" % currentFile) + itemId = window("plex_%s.itemid" % tryEncode(currentFile)) if count == 5: log.warn("Could not find itemId, cancelling playback report!") return @@ -83,7 +83,7 @@ class Player(xbmc.Player): log.info("ONPLAYBACK_STARTED: %s itemid: %s" % (currentFile, itemId)) - plexitem = "plex_%s" % currentFile + plexitem = "plex_%s" % tryEncode(currentFile) runtime = window("%s.runtime" % plexitem) refresh_id = window("%s.refreshid" % plexitem) playMethod = window("%s.playmethod" % plexitem) @@ -146,8 +146,10 @@ class Player(xbmc.Player): # Get the current audio track and subtitles if playMethod == "Transcode": # property set in PlayUtils.py - postdata['AudioStreamIndex'] = window("%sAudioStreamIndex" % currentFile) - postdata['SubtitleStreamIndex'] = window("%sSubtitleStreamIndex" % currentFile) + postdata['AudioStreamIndex'] = window("%sAudioStreamIndex" + % tryEncode(currentFile)) + postdata['SubtitleStreamIndex'] = window("%sSubtitleStreamIndex" + % tryEncode(currentFile)) else: # Get the current kodi audio and subtitles and convert to plex equivalent tracks_query = { @@ -385,15 +387,16 @@ class Player(xbmc.Player): # Clean the WINDOW properties for filename in self.played_info: + plex_item = 'plex_%s' % tryEncode(filename) cleanup = ( - 'plex_%s.itemid' % filename, - 'plex_%s.runtime' % filename, - 'plex_%s.refreshid' % filename, - 'plex_%s.playmethod' % filename, - 'plex_%s.type' % filename, - 'plex_%s.runtime' % filename, - 'plex_%s.playcount' % filename, - 'plex_%s.playlistPosition' % filename + '%s.itemid' % plex_item, + '%s.runtime' % plex_item, + '%s.refreshid' % plex_item, + '%s.playmethod' % plex_item, + '%s.type' % plex_item, + '%s.runtime' % plex_item, + '%s.playcount' % plex_item, + '%s.playlistPosition' % plex_item ) for item in cleanup: window(item, clear=True) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index f5afa8b6..5ab80b6c 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -3,7 +3,7 @@ from urllib import quote import plexdb_functions as plexdb from downloadutils import DownloadUtils as DU -from utils import JSONRPC, tryEncode +from utils import JSONRPC, tryEncode, tryDecode from PlexAPI import API ############################################################################### @@ -36,7 +36,11 @@ class Playlist_Object_Baseclase(object): answ += "items: %s, " % self.items for key in self.__dict__: if key not in ("ID", 'items'): - answ += '%s: %s, ' % (key, getattr(self, key)) + if type(getattr(self, key)) in (str, unicode): + answ += '%s: %s, ' % (key, tryEncode(getattr(self, key))) + else: + # e.g. int + answ += '%s: %s, ' % (key, str(getattr(self, key))) return answ[:-2] + ">" def clear(self): @@ -73,14 +77,18 @@ class Playlist_Item(object): plex_UUID = None # Plex librarySectionUUID kodi_id = None # Kodi unique kodi id (unique only within type!) kodi_type = None # Kodi type: 'movie' - file = None # Path to the item's file - uri = None # Weird Plex uri path involving plex_UUID + file = None # Path to the item's file. STRING!! + uri = None # Weird Plex uri path involving plex_UUID. STRING! guid = None # Weird Plex guid def __repr__(self): answ = "<%s: " % (self.__class__.__name__) for key in self.__dict__: - answ += '%s: %s, ' % (key, getattr(self, key)) + if type(getattr(self, key)) in (str, unicode): + answ += '%s: %s, ' % (key, tryEncode(getattr(self, key))) + else: + # e.g. int + answ += '%s: %s, ' % (key, str(getattr(self, key))) return answ[:-2] + ">" @@ -110,6 +118,7 @@ def playlist_item_from_kodi(kodi_item): # TO BE VERIFIED - PLEX DOESN'T LIKE PLAYLIST ADDS IN THIS MANNER item.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' % (item.plex_UUID, item.plex_id)) + log.debug('Made playlist item from Kodi: %s' % item) return item @@ -128,6 +137,10 @@ def playlist_item_from_plex(plex_id): item.kodi_type = plex_dbitem[4] except: raise KeyError('Could not find plex_id %s in database' % plex_id) + item.plex_UUID = plex_id + item.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' % + (item.plex_UUID, plex_id)) + log.debug('Made playlist item from plex: %s' % item) return item @@ -209,15 +222,14 @@ def update_playlist_from_PMS(playlist, playlist_id=None, xml=None): """ if xml is None: xml = get_PMS_playlist(playlist, playlist_id) - try: - xml.attrib['%sVersion' % playlist.kind] - except: - log.error('Could not process Plex playlist') - return # Clear our existing playlist and the associated Kodi playlist playlist.clear() # Set new values - get_playlist_details_from_xml(playlist, xml) + try: + get_playlist_details_from_xml(playlist, xml) + except KeyError: + log.error('Could not update playlist from PMS') + return for plex_item in xml: playlist_item = add_to_Kodi_playlist(playlist, plex_item) if playlist_item is not None: @@ -231,19 +243,23 @@ def init_Plex_playlist(playlist, plex_id=None, kodi_item=None): WILL ALSO UPDATE OUR PLAYLISTS """ log.debug('Initializing the playlist %s on the Plex side' % playlist) - if plex_id: - item = playlist_item_from_plex(plex_id) - else: - item = playlist_item_from_kodi(kodi_item) - params = { - 'next': 0, - 'type': playlist.type, - 'uri': item.uri - } - xml = DU().downloadUrl(url="{server}/%ss" % playlist.kind, - action_type="POST", - parameters=params) - get_playlist_details_from_xml(playlist, xml) + try: + if plex_id: + item = playlist_item_from_plex(plex_id) + else: + item = playlist_item_from_kodi(kodi_item) + params = { + 'next': 0, + 'type': playlist.type, + 'uri': item.uri + } + xml = DU().downloadUrl(url="{server}/%ss" % playlist.kind, + action_type="POST", + parameters=params) + get_playlist_details_from_xml(playlist, xml) + except KeyError: + log.error('Could not init Plex playlist') + return item.ID = xml[-1].attrib['%sItemID' % playlist.kind] playlist.items.append(item) log.debug('Initialized the playlist on the Plex side: %s' % playlist) @@ -255,6 +271,8 @@ def add_listitem_to_playlist(playlist, pos, listitem, kodi_id=None, Adds a listitem to both the Kodi and Plex playlist at position pos [int]. If file is not None, file will overrule kodi_id! + + file: str!! """ log.debug('add_listitem_to_playlist at position %s. Playlist before add: ' '%s' % (pos, playlist)) @@ -282,6 +300,8 @@ def add_item_to_playlist(playlist, pos, kodi_id=None, kodi_type=None, plex_id=None, file=None): """ Adds an item to BOTH the Kodi and Plex playlist at position pos [int] + + file: str! """ log.debug('add_item_to_playlist. Playlist before adding: %s' % playlist) kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file} @@ -305,7 +325,11 @@ def add_item_to_PMS_playlist(playlist, pos, plex_id=None, kodi_item=None): log.debug('Adding new item plex_id: %s, kodi_item: %s on the Plex side at ' 'position %s for %s' % (plex_id, kodi_item, pos, playlist)) if plex_id: - item = playlist_item_from_plex(plex_id) + try: + item = playlist_item_from_plex(plex_id) + except KeyError: + log.error('Could not add new item to the PMS playlist') + return else: item = playlist_item_from_kodi(kodi_item) url = "{server}/%ss/%s?uri=%s" % (playlist.kind, playlist.ID, item.uri) @@ -342,6 +366,8 @@ def add_item_to_kodi_playlist(playlist, pos, kodi_id=None, kodi_type=None, Adds an item to the KODI playlist only. WILL ALSO UPDATE OUR PLAYLISTS Returns False if unsuccessful + + file: str! """ log.debug('Adding new item kodi_id: %s, kodi_type: %s, file: %s to Kodi ' 'only at position %s for %s' @@ -418,11 +444,9 @@ def refresh_playlist_from_PMS(playlist): """ xml = get_PMS_playlist(playlist) try: - xml.attrib['%sVersion' % playlist.kind] - except: - log.error('Could not download Plex playlist.') - return - get_playlist_details_from_xml(playlist, xml) + get_playlist_details_from_xml(playlist, xml) + except KeyError: + log.error('Could not refresh playlist from PMS') def delete_playlist_item_from_PMS(playlist, pos): @@ -469,8 +493,9 @@ def get_kodi_playqueues(): try: queues = queues['result'] except KeyError: - raise KeyError('Could not get Kodi playqueues. JSON Result was: %s' - % queues) + log.error('Could not get Kodi playqueues. JSON Result was: %s' + % queues) + queues = [] return queues @@ -490,7 +515,7 @@ def add_to_Kodi_playlist(playlist, xml_video_element): if item.kodi_id: params['item'] = {'%sid' % item.kodi_type: item.kodi_id} else: - params['item'] = {'file': tryEncode(item.file)} + params['item'] = {'file': item.file} reply = JSONRPC('Playlist.Add').execute(params) if reply.get('error') is not None: log.error('Could not add item %s to Kodi playlist. Error: %s' @@ -506,6 +531,8 @@ def add_listitem_to_Kodi_playlist(playlist, pos, listitem, file, Adds an xbmc listitem to the Kodi playlist.xml_video_element WILL NOT UPDATE THE PLEX SIDE, BUT WILL UPDATE OUR PLAYLISTS + + file: string! """ log.debug('Insert listitem at position %s for Kodi only for %s' % (pos, playlist)) diff --git a/resources/lib/playqueue.py b/resources/lib/playqueue.py index a07ec325..7adfca1a 100644 --- a/resources/lib/playqueue.py +++ b/resources/lib/playqueue.py @@ -7,8 +7,10 @@ from xbmc import sleep, Player, PlayList, PLAYLIST_MUSIC, PLAYLIST_VIDEO from utils import window, ThreadMethods, ThreadMethodsAdditionalSuspend import playlist_func as PL -from PlexFunctions import ConvertPlexToKodiTime +from PlexFunctions import ConvertPlexToKodiTime, GetAllPlexChildren +from PlexAPI import API from playbackutils import PlaybackUtils +import variables as v ############################################################################### log = logging.getLogger("PLEX."+__name__) @@ -31,6 +33,8 @@ class Playqueue(Thread): def __init__(self, callback=None): self.__dict__ = self.__shared_state if self.playqueues is not None: + log.debug('Playqueue thread has already been initialized') + Thread.__init__(self) return self.mgr = callback @@ -69,6 +73,25 @@ class Playqueue(Thread): raise ValueError('Wrong playlist type passed in: %s' % typus) return playqueue + def init_playqueue_from_plex_children(self, plex_id): + """ + Init a new playqueue e.g. from an album. Alexa does this + """ + xml = GetAllPlexChildren(plex_id) + try: + xml[0].attrib + except (TypeError, IndexError, AttributeError): + log.error('Could not download the PMS xml for %s' % plex_id) + return + playqueue = self.get_playqueue_from_type( + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']]) + playqueue.clear() + for i, child in enumerate(xml): + api = API(child) + PL.add_item_to_playlist(playqueue, i, plex_id=api.getRatingKey()) + log.debug('Firing up Kodi player') + Player().play(playqueue.kodi_pl, None, False, 0) + def update_playqueue_from_PMS(self, playqueue, playqueue_id=None, @@ -85,11 +108,12 @@ class Playqueue(Thread): '%s, repeat %s' % (playqueue_id, offset, repeat)) with lock: xml = PL.get_PMS_playlist(playqueue, playqueue_id) - if xml is None: + playqueue.clear() + try: + PL.get_playlist_details_from_xml(playqueue, xml) + except KeyError: log.error('Could not get playqueue ID %s' % playqueue_id) return - playqueue.clear() - PL.get_playlist_details_from_xml(playqueue, xml) PlaybackUtils(xml, playqueue).play_all() playqueue.repeat = 0 if not repeat else int(repeat) window('plex_customplaylist', value="true") diff --git a/resources/lib/playutils.py b/resources/lib/playutils.py index af6d6b8e..7c4e1af4 100644 --- a/resources/lib/playutils.py +++ b/resources/lib/playutils.py @@ -9,6 +9,7 @@ import xbmcgui import xbmcvfs from utils import window, settings, tryEncode, language as lang +import variables as v import PlexAPI @@ -160,11 +161,11 @@ class PlayUtils(): - video bitrate above specified settings bitrate if the corresponding file settings are set to 'true' """ - videoCodec = self.API.getVideoCodec() - log.info("videoCodec: %s" % videoCodec) - if self.API.getType() in ('clip', 'track'): + if self.API.getType() in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SONG): log.info('Plex clip or music track, not transcoding') return False + videoCodec = self.API.getVideoCodec() + log.info("videoCodec: %s" % videoCodec) if window('plex_forcetranscode') == 'true': log.info('User chose to force-transcode') return True diff --git a/resources/lib/plexbmchelper/functions.py b/resources/lib/plexbmchelper/functions.py index 5885253d..cdda52fe 100644 --- a/resources/lib/plexbmchelper/functions.py +++ b/resources/lib/plexbmchelper/functions.py @@ -57,7 +57,7 @@ def plex_type(xbmc_type): def getXMLHeader(): - return '\r\n' + return '\n' def getOKMsg(): diff --git a/resources/lib/plexbmchelper/listener.py b/resources/lib/plexbmchelper/listener.py index 422a9232..a3294705 100644 --- a/resources/lib/plexbmchelper/listener.py +++ b/resources/lib/plexbmchelper/listener.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- import logging -import re +from re import sub from SocketServer import ThreadingMixIn from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler from urlparse import urlparse, parse_qs from xbmc import sleep +from companion import process_command +from utils import window from functions import * @@ -19,7 +21,6 @@ log = logging.getLogger("PLEX."+__name__) class MyHandler(BaseHTTPRequestHandler): protocol_version = 'HTTP/1.1' - regex = re.compile(r'''/playQueues/(\d+)$''') def __init__(self, *args, **kwargs): BaseHTTPRequestHandler.__init__(self, *args, **kwargs) @@ -58,7 +59,7 @@ class MyHandler(BaseHTTPRequestHandler): 'x-plex-version, x-plex-platform-version, x-plex-username, ' 'x-plex-client-identifier, x-plex-target-client-identifier, ' 'x-plex-device-name, x-plex-platform, x-plex-product, accept, ' - 'x-plex-device') + 'x-plex-device, x-plex-device-screen-resolution') self.end_headers() self.wfile.close() @@ -83,11 +84,10 @@ class MyHandler(BaseHTTPRequestHandler): subMgr = self.server.subscriptionManager js = self.server.jsonClass settings = self.server.settings - queue = self.server.queue try: request_path = self.path[1:] - request_path = re.sub(r"\?.*", "", request_path) + request_path = sub(r"\?.*", "", request_path) url = urlparse(self.path) paramarrays = parse_qs(url.query) params = {} @@ -101,10 +101,10 @@ class MyHandler(BaseHTTPRequestHandler): params.get('commandID', False)) if request_path == "version": self.response( - "PlexKodiConnect Plex Companion: Running\r\nVersion: %s" + "PlexKodiConnect Plex Companion: Running\nVersion: %s" % settings['version']) elif request_path == "verify": - self.response("XBMC JSON connection test:\r\n" + + self.response("XBMC JSON connection test:\n" + js.jsonrpc("ping")) elif "resources" == request_path: resp = ('%s' @@ -113,7 +113,7 @@ class MyHandler(BaseHTTPRequestHandler): ' title="%s"' ' protocol="plex"' ' protocolVersion="1"' - ' protocolCapabilities="navigation,playback,timeline"' + ' protocolCapabilities="timeline,playback,navigation,playqueues"' ' machineIdentifier="%s"' ' product="PlexKodiConnect"' ' platform="%s"' @@ -145,9 +145,9 @@ class MyHandler(BaseHTTPRequestHandler): sleep(950) commandID = params.get('commandID', 0) self.response( - re.sub(r"INSERTCOMMANDID", - str(commandID), - subMgr.msg(js.getPlayers())), + sub(r"INSERTCOMMANDID", + str(commandID), + subMgr.msg(js.getPlayers())), { 'X-Plex-Client-Identifier': settings['uuid'], 'Access-Control-Expose-Headers': @@ -160,121 +160,11 @@ class MyHandler(BaseHTTPRequestHandler): uuid = self.headers.get('X-Plex-Client-Identifier', False) \ or self.client_address[0] subMgr.removeSubscriber(uuid) - elif request_path == "player/playback/setParameters": - self.response(getOKMsg(), js.getPlexHeaders()) - if 'volume' in params: - volume = int(params['volume']) - log.debug("adjusting the volume to %s%%" % volume) - js.jsonrpc("Application.SetVolume", - {"volume": volume}) - elif "/playMedia" in request_path: - self.response(getOKMsg(), js.getPlexHeaders()) - offset = params.get('viewOffset', params.get('offset', "0")) - protocol = params.get('protocol', "http") - address = params.get('address', self.client_address[0]) - server = self.getServerByHost(address) - port = params.get('port', server.get('port', '32400')) - try: - containerKey = urlparse(params.get('containerKey')).path - except: - containerKey = '' - try: - playQueueID = self.regex.findall(containerKey)[0] - except IndexError: - playQueueID = '' - # We need to tell service.py - queue.put({ - 'action': 'playlist', - 'data': params - }) - subMgr.lastkey = params['key'] - subMgr.containerKey = containerKey - subMgr.playQueueID = playQueueID - subMgr.server = server.get('server', 'localhost') - subMgr.port = port - subMgr.protocol = protocol - subMgr.notify() - elif request_path == "player/playback/play": - self.response(getOKMsg(), js.getPlexHeaders()) - for playerid in js.getPlayerIds(): - js.jsonrpc("Player.PlayPause", - {"playerid": playerid, "play": True}) - subMgr.notify() - elif request_path == "player/playback/pause": - self.response(getOKMsg(), js.getPlexHeaders()) - for playerid in js.getPlayerIds(): - js.jsonrpc("Player.PlayPause", - {"playerid": playerid, "play": False}) - subMgr.notify() - elif request_path == "player/playback/stop": - self.response(getOKMsg(), js.getPlexHeaders()) - for playerid in js.getPlayerIds(): - js.jsonrpc("Player.Stop", {"playerid": playerid}) - subMgr.notify() - elif request_path == "player/playback/seekTo": - self.response(getOKMsg(), js.getPlexHeaders()) - for playerid in js.getPlayerIds(): - js.jsonrpc("Player.Seek", - {"playerid": playerid, - "value": millisToTime( - params.get('offset', 0))}) - subMgr.notify() - elif request_path == "player/playback/stepForward": - self.response(getOKMsg(), js.getPlexHeaders()) - for playerid in js.getPlayerIds(): - js.jsonrpc("Player.Seek", - {"playerid": playerid, - "value": "smallforward"}) - subMgr.notify() - elif request_path == "player/playback/stepBack": - self.response(getOKMsg(), js.getPlexHeaders()) - for playerid in js.getPlayerIds(): - js.jsonrpc("Player.Seek", - {"playerid": playerid, - "value": "smallbackward"}) - subMgr.notify() - elif request_path == "player/playback/skipNext": - self.response(getOKMsg(), js.getPlexHeaders()) - for playerid in js.getPlayerIds(): - js.jsonrpc("Player.GoTo", - {"playerid": playerid, - "to": "next"}) - subMgr.notify() - elif request_path == "player/playback/skipPrevious": - self.response(getOKMsg(), js.getPlexHeaders()) - for playerid in js.getPlayerIds(): - js.jsonrpc("Player.GoTo", - {"playerid": playerid, - "to": "previous"}) - subMgr.notify() - elif request_path == "player/playback/skipTo": - js.skipTo(params.get('key').rsplit('/', 1)[1], - params.get('type')) - subMgr.notify() - elif request_path == "player/navigation/moveUp": - self.response(getOKMsg(), js.getPlexHeaders()) - js.jsonrpc("Input.Up") - elif request_path == "player/navigation/moveDown": - self.response(getOKMsg(), js.getPlexHeaders()) - js.jsonrpc("Input.Down") - elif request_path == "player/navigation/moveLeft": - self.response(getOKMsg(), js.getPlexHeaders()) - js.jsonrpc("Input.Left") - elif request_path == "player/navigation/moveRight": - self.response(getOKMsg(), js.getPlexHeaders()) - js.jsonrpc("Input.Right") - elif request_path == "player/navigation/select": - self.response(getOKMsg(), js.getPlexHeaders()) - js.jsonrpc("Input.Select") - elif request_path == "player/navigation/home": - self.response(getOKMsg(), js.getPlexHeaders()) - js.jsonrpc("Input.Home") - elif request_path == "player/navigation/back": - self.response(getOKMsg(), js.getPlexHeaders()) - js.jsonrpc("Input.Back") else: - log.error('Unknown request path: %s' % request_path) - + # Throw it to companion.py + process_command(request_path, params, self.server.queue) + self.response(getOKMsg(), js.getPlexHeaders()) + subMgr.notify() except: log.error('Error encountered. Traceback:') import traceback diff --git a/resources/lib/plexbmchelper/plexgdm.py b/resources/lib/plexbmchelper/plexgdm.py index 94fc492e..488dbf54 100644 --- a/resources/lib/plexbmchelper/plexgdm.py +++ b/resources/lib/plexbmchelper/plexgdm.py @@ -57,23 +57,22 @@ class plexgdm: self._discovery_is_running = False self._registration_is_running = False - self.discovery_complete = False self.client_registered = False self.download = downloadutils.DownloadUtils().downloadUrl def clientDetails(self, options): self.client_data = ( - "Content-Type: plex/media-player\r\n" - "Resource-Identifier: %s\r\n" - "Name: %s\r\n" - "Port: %s\r\n" - "Product: %s\r\n" - "Version: %s\r\n" - "Protocol: plex\r\n" - "Protocol-Version: 1\r\n" + "Content-Type: plex/media-player\n" + "Resource-Identifier: %s\n" + "Name: %s\n" + "Port: %s\n" + "Product: %s\n" + "Version: %s\n" + "Protocol: plex\n" + "Protocol-Version: 1\n" "Protocol-Capabilities: timeline,playback,navigation," - "playqueues\r\n" - "Device-Class: HTPC" + "playqueues\n" + "Device-Class: HTPC\n" ) % ( options['uuid'], options['client_name'], @@ -86,10 +85,25 @@ class plexgdm: def getClientDetails(self): return self.client_data + def register_as_client(self): + """ + Registers PKC's Plex Companion to the PMS + """ + try: + log.debug("Sending registration data: HELLO %s\n%s" + % (self.client_header, self.client_data)) + self.update_sock.sendto("HELLO %s\n%s" + % (self.client_header, self.client_data), + self.client_register_group) + log.debug('(Re-)registering PKC Plex Companion successful') + except: + log.error("Unable to send registration message") + def client_update(self): - update_sock = socket.socket(socket.AF_INET, - socket.SOCK_DGRAM, - socket.IPPROTO_UDP) + self.update_sock = socket.socket(socket.AF_INET, + socket.SOCK_DGRAM, + socket.IPPROTO_UDP) + update_sock = self.update_sock # Set socket reuse, may not work on all OSs. try: @@ -129,16 +143,9 @@ class plexgdm: self._multicast_address) + socket.inet_aton('0.0.0.0')) update_sock.setblocking(0) - log.debug("Sending registration data: HELLO %s\r\n%s" - % (self.client_header, self.client_data)) # Send initial client registration - try: - update_sock.sendto("HELLO %s\r\n%s" - % (self.client_header, self.client_data), - self.client_register_group) - except: - log.error("Unable to send registration message") + self.register_as_client() # Now, listen format client discovery reguests and respond. while self._registration_is_running: @@ -153,7 +160,7 @@ class plexgdm: log.debug("Detected client discovery request from %s. " " Replying" % str(addr)) try: - update_sock.sendto("HTTP/1.0 200 OK\r\n%s" + update_sock.sendto("HTTP/1.0 200 OK\n%s" % self.client_data, addr) except: @@ -165,10 +172,10 @@ class plexgdm: log.info("Client Update loop stopped") # When we are finished, then send a final goodbye message to # deregister cleanly. - log.debug("Sending registration data: BYE %s\r\n%s" + log.debug("Sending registration data: BYE %s\n%s" % (self.client_header, self.client_data)) try: - update_sock.sendto("BYE %s\r\n%s" + update_sock.sendto("BYE %s\n%s" % (self.client_header, self.client_data), self.client_register_group) except: @@ -176,41 +183,41 @@ class plexgdm: self.client_registered = False def check_client_registration(self): + if not self.client_registered: + log.debug('Client has not been marked as registered') + return False + if not self.server_list: + log.info("Server list is empty. Unable to check") + return False + for server in self.server_list: + if server['uuid'] == window('plex_machineIdentifier'): + media_server = server['server'] + media_port = server['port'] + scheme = server['protocol'] + break + else: + log.info("Did not find our server!") + return False - if self.client_registered and self.discovery_complete: - if not self.server_list: - log.info("Server list is empty. Unable to check") - return False - try: - for server in self.server_list: - if server['uuid'] == window('plex_machineIdentifier'): - media_server = server['server'] - media_port = server['port'] - scheme = server['protocol'] - break - else: - log.info("Did not find our server!") - return False - - log.debug("Checking server [%s] on port [%s]" - % (media_server, media_port)) - client_result = self.download( - '%s://%s:%s/clients' % (scheme, media_server, media_port)) - registered = False - for client in client_result: - if (client.attrib.get('machineIdentifier') == - self.client_id): - registered = True - if registered: - log.debug("Client registration successful. " - "Client data is: %s" % client_result) - return True - else: - log.info("Client registration not found. " - "Client data is: %s" % client_result) - except: - log.error("Unable to check status") - pass + log.debug("Checking server [%s] on port [%s]" + % (media_server, media_port)) + xml = self.download( + '%s://%s:%s/clients' % (scheme, media_server, media_port)) + try: + xml[0].attrib + except (TypeError, IndexError, AttributeError): + log.error('Could not download clients for %s' % media_server) + return False + registered = False + for client in xml: + if (client.attrib.get('machineIdentifier') == + self.client_id): + registered = True + if registered: + return True + else: + log.info("Client registration not found. " + "Client data is: %s" % xml) return False def getServerList(self): diff --git a/resources/lib/plexbmchelper/plexsettings.py b/resources/lib/plexbmchelper/plexsettings.py index cd289f03..3e93b01a 100644 --- a/resources/lib/plexbmchelper/plexsettings.py +++ b/resources/lib/plexbmchelper/plexsettings.py @@ -26,7 +26,7 @@ def getSettings(): options['gdm_debug'] = settings('companionGDMDebugging') options['gdm_debug'] = True if options['gdm_debug'] == 'true' else False - options['client_name'] = settings('deviceName') + options['client_name'] = v.DEVICENAME # XBMC web server options options['webserver_enabled'] = (getGUI('webserver') == "true") diff --git a/resources/lib/plexbmchelper/subscribers.py b/resources/lib/plexbmchelper/subscribers.py index 41cb52da..463807fe 100644 --- a/resources/lib/plexbmchelper/subscribers.py +++ b/resources/lib/plexbmchelper/subscribers.py @@ -71,7 +71,7 @@ class SubscriptionManager: msg += self.getTimelineXML(self.js.getAudioPlayerId(players), plex_audio()) msg += self.getTimelineXML(self.js.getPhotoPlayerId(players), plex_photo()) msg += self.getTimelineXML(self.js.getVideoPlayerId(players), plex_video()) - msg += "\r\n" + msg += "\n" return msg def getTimelineXML(self, playerid, ptype): @@ -84,7 +84,7 @@ class SubscriptionManager: else: state = "stopped" time = 0 - ret = "\r\n"+' ", "") +DEVICENAME = DEVICENAME.replace("*", "") +DEVICENAME = DEVICENAME.replace("?", "") +DEVICENAME = DEVICENAME.replace('|', "") +DEVICENAME = DEVICENAME.replace('(', "") +DEVICENAME = DEVICENAME.replace(')', "") +DEVICENAME = DEVICENAME.strip() # Database paths _DB_VIDEO_VERSION = { @@ -248,3 +253,16 @@ KODI_SUPPORTED_IMAGES = ( '.pcx', '.tga' ) + + +# Translation table from Alexa websocket commands to Plex Companion +ALEXA_TO_COMPANION = { + 'queryKey': 'key', + 'queryOffset': 'offset', + 'queryMachineIdentifier': 'machineIdentifier', + 'queryProtocol': 'protocol', + 'queryAddress': 'address', + 'queryPort': 'port', + 'queryContainerKey': 'containerKey', + 'queryToken': 'token', +} diff --git a/resources/lib/websocket_client.py b/resources/lib/websocket_client.py index 312080b5..7ded4456 100644 --- a/resources/lib/websocket_client.py +++ b/resources/lib/websocket_client.py @@ -4,6 +4,7 @@ import logging import websocket from json import loads +import xml.etree.ElementTree as etree from threading import Thread from Queue import Queue from ssl import CERT_NONE @@ -12,6 +13,7 @@ from xbmc import sleep from utils import window, settings, ThreadMethodsAdditionalSuspend, \ ThreadMethods +from companion import process_command ############################################################################### @@ -29,10 +31,151 @@ class WebSocket(Thread): if callback is not None: self.mgr = callback self.ws = None - # Communication with librarysync - self.queue = Queue() Thread.__init__(self) + def process(self, opcode, message): + raise NotImplementedError + + def receive(self, ws): + # Not connected yet + if ws is None: + raise websocket.WebSocketConnectionClosedException + + frame = ws.recv_frame() + + if not frame: + raise websocket.WebSocketException("Not a valid frame %s" % frame) + elif frame.opcode in self.opcode_data: + return frame.opcode, frame.data + elif frame.opcode == websocket.ABNF.OPCODE_CLOSE: + ws.send_close() + return frame.opcode, None + elif frame.opcode == websocket.ABNF.OPCODE_PING: + ws.pong("Hi!") + return None, None + + def getUri(self): + raise NotImplementedError + + def run(self): + log.info("----===## Starting %s ##===----" % self.__class__.__name__) + + counter = 0 + handshake_counter = 0 + threadStopped = self.threadStopped + threadSuspended = self.threadSuspended + while not threadStopped(): + # In the event the server goes offline + while threadSuspended(): + # Set in service.py + if self.ws is not None: + try: + self.ws.shutdown() + except: + pass + self.ws = None + if threadStopped(): + # Abort was requested while waiting. We should exit + log.info("##===---- %s Stopped ----===##" + % self.__class__.__name__) + return + sleep(1000) + try: + self.process(*self.receive(self.ws)) + except websocket.WebSocketTimeoutException: + # No worries if read timed out + pass + except websocket.WebSocketConnectionClosedException: + log.info("Connection closed, (re)connecting") + uri, sslopt = self.getUri() + try: + # Low timeout - let's us shut this thread down! + self.ws = websocket.create_connection( + uri, + timeout=1, + sslopt=sslopt, + enable_multithread=True) + except IOError: + # Server is probably offline + log.info("Error connecting") + self.ws = None + counter += 1 + if counter > 3: + counter = 0 + self.IOError_response() + sleep(1000) + except websocket.WebSocketTimeoutException: + log.info("timeout while connecting, trying again") + self.ws = None + sleep(1000) + except websocket.WebSocketException as e: + log.info('WebSocketException: %s' % 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__) + break + self.ws = None + sleep(1000) + except Exception as e: + log.error("Unknown exception encountered in connecting: %s" + % e) + import traceback + log.error("Traceback:\n%s" % 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) + import traceback + log.error("Traceback:\n%s" % traceback.format_exc()) + try: + self.ws.shutdown() + except: + pass + self.ws = None + log.info("##===---- %s Stopped ----===##" % self.__class__.__name__) + + def stopThread(self): + """ + Overwrite this method from ThreadMethods to close websockets + """ + log.info("Stopping %s thread." % self.__class__.__name__) + self._threadStopped = True + try: + self.ws.shutdown() + except: + pass + + +class PMS_Websocket(WebSocket): + """ + Websocket connection with the PMS for Plex Companion + """ + # Communication with librarysync + queue = Queue() + + def getUri(self): + server = window('pms_server') + # Need to use plex.tv token, if any. NOT user token + token = window('plex_token') + # Get the appropriate prefix for the websocket + if server.startswith('https'): + server = "wss%s" % server[5:] + else: + server = "ws%s" % server[4:] + uri = "%s/:/websockets/notifications" % server + if token: + uri += '?X-Plex-Token=%s' % token + sslopt = {} + if settings('sslverify') == "false": + sslopt["cert_reqs"] = CERT_NONE + log.debug("Uri: %s, sslopt: %s" % (uri, sslopt)) + return uri, sslopt + def process(self, opcode, message): if opcode not in self.opcode_data: return False @@ -62,131 +205,58 @@ class WebSocket(Thread): self.queue.put(message) return True - def receive(self, ws): - # Not connected yet - if ws is None: - raise websocket.WebSocketConnectionClosedException + def IOError_response(self): + log.warn("Repeatedly could not connect to PMS, " + "declaring the connection dead") + window('plex_online', value='false') - frame = ws.recv_frame() - - if not frame: - raise websocket.WebSocketException("Not a valid frame %s" % frame) - elif frame.opcode in self.opcode_data: - return frame.opcode, frame.data - elif frame.opcode == websocket.ABNF.OPCODE_CLOSE: - ws.send_close() - return frame.opcode, None - elif frame.opcode == websocket.ABNF.OPCODE_PING: - ws.pong("Hi!") - return None, None +class Alexa_Websocket(WebSocket): + """ + Websocket connection to talk to Amazon Alexa + """ def getUri(self): - server = window('pms_server') - # Need to use plex.tv token, if any. NOT user token - token = window('plex_token') - # Get the appropriate prefix for the websocket - if server.startswith('https'): - server = "wss%s" % server[5:] - else: - server = "ws%s" % server[4:] - uri = "%s/:/websockets/notifications" % server - if token: - uri += '?X-Plex-Token=%s' % token + self.plex_client_Id = window('plex_client_Id') + uri = ('wss://pubsub.plex.tv/sub/websockets/%s/%s?X-Plex-Token=%s' + % (window('currUserId'), + self.plex_client_Id, + window('plex_token'))) sslopt = {} - if settings('sslverify') == "false": - sslopt["cert_reqs"] = CERT_NONE log.debug("Uri: %s, sslopt: %s" % (uri, sslopt)) return uri, sslopt - def run(self): - log.info("----===## Starting WebSocketClient ##===----") - - counter = 0 - handshake_counter = 0 - threadStopped = self.threadStopped - threadSuspended = self.threadSuspended - while not threadStopped(): - # In the event the server goes offline - while threadSuspended(): - # Set in service.py - if self.ws is not None: - try: - self.ws.shutdown() - except: - pass - self.ws = None - if threadStopped(): - # Abort was requested while waiting. We should exit - log.info("##===---- WebSocketClient Stopped ----===##") - return - sleep(1000) - try: - self.process(*self.receive(self.ws)) - except websocket.WebSocketTimeoutException: - # No worries if read timed out - pass - except websocket.WebSocketConnectionClosedException: - log.info("Connection closed, (re)connecting") - uri, sslopt = self.getUri() - try: - # Low timeout - let's us shut this thread down! - self.ws = websocket.create_connection( - uri, - timeout=1, - sslopt=sslopt, - enable_multithread=True) - except IOError: - # Server is probably offline - log.info("Error connecting") - self.ws = None - counter += 1 - if counter > 3: - log.warn("Repeatedly could not connect to PMS, " - "declaring the connection dead") - window('plex_online', value='false') - counter = 0 - sleep(1000) - except websocket.WebSocketTimeoutException: - log.info("timeout while connecting, trying again") - self.ws = None - sleep(1000) - except websocket.WebSocketException as e: - log.info('WebSocketException: %s' % e) - if 'Handshake Status 401' in e.args: - handshake_counter += 1 - if handshake_counter >= 5: - log.info('Error in handshake detected. Stopping ' - 'WebSocketClient now') - break - self.ws = None - sleep(1000) - except Exception as e: - log.error("Unknown exception encountered in connecting: %s" - % e) - import traceback - log.error("Traceback:\n%s" % 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) - try: - self.ws.shutdown() - except: - pass - self.ws = None - - log.info("##===---- WebSocketClient Stopped ----===##") - - def stopThread(self): - """ - Overwrite this method from ThreadMethods to close websockets - """ - log.info("Stopping websocket client thread.") - self._threadStopped = True + 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) try: - self.ws.shutdown() + message = etree.fromstring(message) + except Exception as ex: + log.error('Error decoding message from Alexa: %s' % ex) + return False + try: + if message.attrib['command'] == 'processRemoteControlCommand': + message = message[0] + else: + log.error('Unknown Alexa message received') + return False except: - pass + log.error('Could not parse Alexa message') + return False + process_command(message.attrib['path'][1:], + message.attrib, + queue=self.mgr.plexCompanion.queue) + return True + + def IOError_response(self): + pass + + def threadSuspended(self): + """ + Overwrite to ignore library sync stuff and allow to check for + plex_restricteduser + """ + return (self._threadSuspended or + window('plex_restricteduser') == 'true' or + not window('plex_token')) diff --git a/resources/settings.xml b/resources/settings.xml index fa8348c7..fd50830b 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -21,13 +21,9 @@ - - - - @@ -36,11 +32,14 @@ - - - - - + + + + + + + + diff --git a/service.py b/service.py index 13057a2c..696a87b9 100644 --- a/service.py +++ b/service.py @@ -4,7 +4,7 @@ import logging from os import path as os_path -from sys import path as sys_path +from sys import path as sys_path, argv from xbmc import translatePath, Monitor, sleep from xbmcaddon import Addon @@ -36,7 +36,7 @@ import initialsetup from kodimonitor import KodiMonitor from librarysync import LibrarySync import videonodes -from websocket_client import WebSocket +from websocket_client import PMS_Websocket, Alexa_Websocket import downloadutils from playqueue import Playqueue @@ -70,6 +70,7 @@ class Service(): user_running = False ws_running = False + alexa_running = False library_running = False plexCompanion_running = False playqueue_running = False @@ -100,6 +101,7 @@ class Service(): log.warn("Number of sync threads: %s" % settings('syncThreadNumber')) log.warn("Log Level: %s" % logLevel) + log.warn("Full sys.argv received: %s" % argv) # Reset window props for profile switch properties = [ @@ -147,7 +149,8 @@ class Service(): # Initialize important threads, handing over self for callback purposes self.user = UserClient(self) - self.ws = WebSocket(self) + self.ws = PMS_Websocket(self) + self.alexa = Alexa_Websocket(self) self.library = LibrarySync(self) self.plexCompanion = PlexCompanion(self) self.playqueue = Playqueue(self) @@ -200,6 +203,11 @@ class Service(): if not self.ws_running: self.ws_running = True self.ws.start() + # Start the Alexa thread + if (not self.alexa_running and + settings('enable_alexa') == 'true'): + self.alexa_running = True + self.alexa.start() # Start the syncing thread if not self.library_running: self.library_running = True @@ -325,6 +333,10 @@ class Service(): self.ws.stopThread() except: log.warn('Websocket client already shut down') + try: + self.alexa.stopThread() + except: + log.warn('Websocket client already shut down') try: self.user.stopThread() except: @@ -333,14 +345,23 @@ class Service(): downloadutils.DownloadUtils().stopSession() except: pass - + window('plex_service_started', clear=True) log.warn("======== STOP %s ========" % v.ADDON_NAME) +# Safety net - Kody starts PKC twice upon first installation! +if window('plex_service_started') == 'true': + exit = True +else: + window('plex_service_started', value='true') + exit = False + # Delay option delay = int(settings('startupDelay')) log.warn("Delaying Plex startup by: %s sec..." % delay) -if delay and Monitor().waitForAbort(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.") else: