From c69176d4a1e70964c47113353aba4c2dd64bb910 Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 9 Mar 2019 16:19:29 +0100 Subject: [PATCH 1/8] Fix some KeyErrors when playing songs --- resources/lib/kodimonitor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index d27fb696..ae2e8ef3 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -454,6 +454,9 @@ def _record_playstate(status, ended): if not status['plex_id']: LOG.debug('No Plex id found to record playstate for status %s', status) return + if status['plex_type'] not in v.PLEX_VIDEOTYPES: + LOG.debug('Not messing with non-video entries') + return with PlexDB() as plexdb: db_item = plexdb.item_by_id(status['plex_id'], status['plex_type']) if not db_item: From 62ecefdcca3ae24054f450679acb291f0d5e3e99 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 10 Mar 2019 11:25:02 +0100 Subject: [PATCH 2/8] Less logging --- resources/lib/playlists/kodi_pl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/lib/playlists/kodi_pl.py b/resources/lib/playlists/kodi_pl.py index f131d79d..2e6a8ac8 100644 --- a/resources/lib/playlists/kodi_pl.py +++ b/resources/lib/playlists/kodi_pl.py @@ -54,8 +54,8 @@ def create(plex_id): else: number = int(occurance.group(1)) + 1 if number > 3: - LOG.error('Detected spanning tree issue, abort sync for %s', - playlist) + LOG.warn('Detected spanning tree issue, abort sync for %s', + playlist) raise PlaylistError('Spanning tree warning') basename = re.sub(REGEX_FILE_NUMBERING, '', path) path = '%s_%02d.m3u' % (basename, number) From 8c51ee5c7a448ce726eb7028fa11dd60d52e3afc Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 10 Mar 2019 12:28:10 +0100 Subject: [PATCH 3/8] Ignore all websocket playlist messages caused by PKC --- resources/lib/playlists/__init__.py | 8 ++------ resources/lib/playlists/plex_pl.py | 6 +++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/resources/lib/playlists/__init__.py b/resources/lib/playlists/__init__.py index 9d35125c..601879dc 100644 --- a/resources/lib/playlists/__init__.py +++ b/resources/lib/playlists/__init__.py @@ -36,9 +36,6 @@ SUPPORTED_FILETYPES = ( ) # Avoid endless loops. Store Plex IDs for creating, Kodi paths for deleting! IGNORE_KODI_PLAYLIST_CHANGE = list() -# Used for updating Plex playlists due to Kodi changes - Plex playlist -# will have to be deleted first. Add Plex ids! -IGNORE_PLEX_PLAYLIST_CHANGE = list() ############################################################################### @@ -99,10 +96,10 @@ def websocket(plex_id, status): plex_id = int(plex_id) with app.APP.lock_playlists: playlist = db.get_playlist(plex_id=plex_id) - if plex_id in IGNORE_PLEX_PLAYLIST_CHANGE: + if plex_id in plex_pl.IGNORE_PLEX_PLAYLIST_CHANGE: LOG.debug('Ignoring detected Plex playlist change for %s', playlist) - IGNORE_PLEX_PLAYLIST_CHANGE.remove(plex_id) + plex_pl.IGNORE_PLEX_PLAYLIST_CHANGE.remove(plex_id) return if playlist and status == 9: # Won't be able to download metadata of the deleted playlist @@ -250,7 +247,6 @@ def _full_sync(): LOG.info('Skipping Kodi playlist %s', path) else: LOG.debug('Changed Kodi playlist detected: %s', path) - IGNORE_PLEX_PLAYLIST_CHANGE.append(playlist.plex_id) plex_pl.delete(playlist) playlist.kodi_hash = kodi_hash try: diff --git a/resources/lib/playlists/plex_pl.py b/resources/lib/playlists/plex_pl.py index d7edc432..03c863a4 100644 --- a/resources/lib/playlists/plex_pl.py +++ b/resources/lib/playlists/plex_pl.py @@ -10,7 +10,9 @@ from .common import PlaylistError from . import pms, db ############################################################################### LOG = getLogger('PLEX.playlists.plex_pl') - +# Used for updating Plex playlists due to Kodi changes - Plex playlist +# will have to be deleted first. Add Plex ids! +IGNORE_PLEX_PLAYLIST_CHANGE = list() ############################################################################### @@ -28,6 +30,7 @@ def create(playlist): if not plex_ids: LOG.warning('No Plex ids found for playlist %s', playlist) raise PlaylistError + IGNORE_PLEX_PLAYLIST_CHANGE.append(playlist.plex_id) pms.add_items(playlist, plex_ids) db.update_playlist(playlist) LOG.debug('Done creating Plex playlist %s', playlist) @@ -40,5 +43,6 @@ def delete(playlist): Returns None or raises PlaylistError """ LOG.debug('Deleting playlist from PMS: %s', playlist) + IGNORE_PLEX_PLAYLIST_CHANGE.append(playlist.plex_id) pms.delete(playlist) db.update_playlist(playlist, delete=True) From c99cead6f5f9e4a78fb76fa50c114d352976d216 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 10 Mar 2019 12:59:47 +0100 Subject: [PATCH 4/8] Ignore all file events for playlists caused by PKC --- resources/lib/playlists/__init__.py | 29 +++++------------------------ resources/lib/playlists/kodi_pl.py | 15 +++++++++++---- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/resources/lib/playlists/__init__.py b/resources/lib/playlists/__init__.py index 601879dc..231b1865 100644 --- a/resources/lib/playlists/__init__.py +++ b/resources/lib/playlists/__init__.py @@ -34,8 +34,6 @@ SUPPORTED_FILETYPES = ( # 'pls', # 'cue', ) -# Avoid endless loops. Store Plex IDs for creating, Kodi paths for deleting! -IGNORE_KODI_PLAYLIST_CHANGE = list() ############################################################################### @@ -106,10 +104,9 @@ def websocket(plex_id, status): if sync_plex_playlist(playlist=playlist): LOG.debug('Plex deletion of playlist detected: %s', playlist) try: - IGNORE_KODI_PLAYLIST_CHANGE.append(plex_id) kodi_pl.delete(playlist) except PlaylistError: - IGNORE_KODI_PLAYLIST_CHANGE.remove(plex_id) + pass return xml = pms.metadata(plex_id) if xml is None: @@ -127,7 +124,6 @@ def websocket(plex_id, status): else: LOG.debug('Change of Plex playlist detected: %s', playlist) - IGNORE_KODI_PLAYLIST_CHANGE.append(plex_id) kodi_pl.delete(playlist) create = True elif not playlist and not status == 9: @@ -136,10 +132,9 @@ def websocket(plex_id, status): create = True # To the actual work if create: - IGNORE_KODI_PLAYLIST_CHANGE.append(plex_id) kodi_pl.create(plex_id) except PlaylistError: - IGNORE_KODI_PLAYLIST_CHANGE.remove(plex_id) + pass def full_sync(): @@ -184,41 +179,33 @@ def _full_sync(): if not playlist: LOG.debug('New Plex playlist %s discovered: %s', api.plex_id(), api.title()) - IGNORE_KODI_PLAYLIST_CHANGE.append(api.plex_id()) try: kodi_pl.create(api.plex_id()) except PlaylistError: LOG.info('Skipping creation of playlist %s', api.plex_id()) - IGNORE_KODI_PLAYLIST_CHANGE.remove(api.plex_id()) elif playlist.plex_updatedat != api.updated_at(): LOG.debug('Detected changed Plex playlist %s: %s', api.plex_id(), api.title()) # Since we are DELETING a playlist, we need to catch with path! - IGNORE_KODI_PLAYLIST_CHANGE.append(playlist.kodi_path) try: kodi_pl.delete(playlist) except PlaylistError: LOG.info('Skipping recreation of playlist %s', api.plex_id()) - IGNORE_KODI_PLAYLIST_CHANGE.remove(playlist.kodi_path) else: - IGNORE_KODI_PLAYLIST_CHANGE.append(api.plex_id()) try: kodi_pl.create(api.plex_id()) except PlaylistError: LOG.info('Could not recreate playlist %s', api.plex_id()) - IGNORE_KODI_PLAYLIST_CHANGE.remove(api.plex_id()) # Get rid of old Plex playlists that were deleted on the Plex side for plex_id in old_plex_ids: if isCanceled(): return False playlist = db.get_playlist(plex_id=plex_id) - IGNORE_KODI_PLAYLIST_CHANGE.append(playlist.kodi_path) LOG.debug('Removing outdated Plex playlist from Kodi: %s', playlist) try: kodi_pl.delete(playlist) except PlaylistError: LOG.debug('Skipping deletion of playlist: %s', playlist) - IGNORE_KODI_PLAYLIST_CHANGE.remove(playlist.kodi_path) # Look at all supported Kodi playlists. Check whether they are in the DB. old_kodi_paths = db.kodi_playlist_paths() for root, _, files in path_ops.walk(v.PLAYLIST_PATH): @@ -382,15 +369,9 @@ class PlaylistEventhandler(events.FileSystemEventHandler): else event.src_path if not sync_kodi_playlist(path): return - playlist = db.get_playlist(path=path) - if playlist and playlist.plex_id in IGNORE_KODI_PLAYLIST_CHANGE: - LOG.debug('Ignoring event %s for playlist %s', event, playlist) - IGNORE_KODI_PLAYLIST_CHANGE.remove(playlist.plex_id) - return - if not playlist and path in IGNORE_KODI_PLAYLIST_CHANGE: - LOG.debug('Ignoring deletion event %s for playlist %s', - event, playlist) - IGNORE_KODI_PLAYLIST_CHANGE.remove(path) + if path in kodi_pl.IGNORE_KODI_PLAYLIST_CHANGE: + LOG.debug('Ignoring event %s', event) + kodi_pl.IGNORE_KODI_PLAYLIST_CHANGE.remove(path) return _method_map = { events.EVENT_TYPE_MODIFIED: self.on_modified, diff --git a/resources/lib/playlists/kodi_pl.py b/resources/lib/playlists/kodi_pl.py index 2e6a8ac8..f9413144 100644 --- a/resources/lib/playlists/kodi_pl.py +++ b/resources/lib/playlists/kodi_pl.py @@ -14,10 +14,10 @@ from ..plex_api import API from .. import utils, path_ops, variables as v ############################################################################### LOG = getLogger('PLEX.playlists.kodi_pl') - -############################################################################### - REGEX_FILE_NUMBERING = re.compile(r'''_(\d\d)\.\w+$''') +# Avoid endless loops. Store the Kodi paths +IGNORE_KODI_PLAYLIST_CHANGE = list() +############################################################################### def create(plex_id): @@ -65,7 +65,12 @@ def create(plex_id): if xml_playlist is None: LOG.error('Could not get Plex playlist %s', plex_id) raise PlaylistError('Could not get Plex playlist %s' % plex_id) - _write_playlist_to_file(playlist, xml_playlist) + IGNORE_KODI_PLAYLIST_CHANGE.append(playlist.kodi_path) + try: + _write_playlist_to_file(playlist, xml_playlist) + except Exception: + IGNORE_KODI_PLAYLIST_CHANGE.remove(playlist.kodi_path) + raise playlist.kodi_hash = utils.generate_file_md5(path) db.update_playlist(playlist) LOG.debug('Created Kodi playlist based on Plex playlist: %s', playlist) @@ -79,12 +84,14 @@ def delete(playlist): Returns None or raises PlaylistError """ if path_ops.exists(playlist.kodi_path): + IGNORE_KODI_PLAYLIST_CHANGE.append(playlist.kodi_path) try: path_ops.remove(playlist.kodi_path) LOG.debug('Deleted Kodi playlist: %s', playlist) except (OSError, IOError) as err: LOG.error('Could not delete Kodi playlist file %s. Error:\n%s: %s', playlist, err.errno, err.strerror) + IGNORE_KODI_PLAYLIST_CHANGE.remove(playlist.kodi_path) raise PlaylistError('Could not delete %s' % playlist.kodi_path) db.update_playlist(playlist, delete=True) From 4ea77370e871e51c6349648e5412931dcc859cdc Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 9 Mar 2019 15:18:59 +0100 Subject: [PATCH 5/8] Beta version bump 2.7.5 - Includes a migration! --- README.md | 2 +- addon.xml | 9 +++++++-- changelog.txt | 5 +++++ resources/lib/migration.py | 5 +++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dd947056..fd3e9242 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![stable version](https://img.shields.io/badge/stable_version-2.7.4-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip) -[![beta version](https://img.shields.io/badge/beta_version-2.7.4-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) +[![beta version](https://img.shields.io/badge/beta_version-2.7.5-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) [![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation) [![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq) diff --git a/addon.xml b/addon.xml index 3a5c4175..1aa6734d 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -77,7 +77,12 @@ Нативна інтеграція Plex в Kodi Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик! Використовуйте на свій ризик - version 2.7.4: + version 2.7.5: +- Giant overhaul of widgets +- Fix some KeyErrors when playing songs +- Fix rare cases where playlists were being created + +version 2.7.4: - Fix PKC not synching new items if an older Kodi db is present version 2.7.3: diff --git a/changelog.txt b/changelog.txt index 7ff50ce3..bf7668b9 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,8 @@ +version 2.7.5: +- Giant overhaul of widgets +- Fix some KeyErrors when playing songs +- Fix rare cases where playlists were being created + version 2.7.4: - Fix PKC not synching new items if an older Kodi db is present diff --git a/resources/lib/migration.py b/resources/lib/migration.py index 87efd19a..cc9c0f69 100644 --- a/resources/lib/migration.py +++ b/resources/lib/migration.py @@ -31,4 +31,9 @@ def check_migration(): utils.settings('ipaddress', value='') utils.settings('port', value='') + if not utils.compare_version(last_migration, '2.7.6'): + LOG.info('Migrating to version 2.7.5') + from .library_sync.sections import delete_files + delete_files() + utils.settings('last_migrated_PKC_version', value=v.ADDON_VERSION) From 5b7595c68126b55d66ba63df5f565c0cefd85a8e Mon Sep 17 00:00:00 2001 From: croneter Date: Tue, 8 Jan 2019 18:00:54 +0100 Subject: [PATCH 6/8] Rework widgets and listitems --- default.py | 39 +- resources/lib/entrypoint.py | 861 +++++------------------ resources/lib/json_rpc.py | 34 +- resources/lib/kodi_constants.py | 92 +++ resources/lib/library_sync/__init__.py | 3 +- resources/lib/library_sync/full_sync.py | 80 +-- resources/lib/library_sync/nodes.py | 373 ++++++++++ resources/lib/library_sync/sections.py | 809 ++++++++++++++------- resources/lib/library_sync/time.py | 4 +- resources/lib/library_sync/videonodes.py | 488 ------------- resources/lib/music.py | 23 +- resources/lib/path_ops.py | 5 +- resources/lib/plex_api.py | 233 ++++-- resources/lib/plex_db/common.py | 5 +- resources/lib/plex_functions.py | 7 +- resources/lib/service_entry.py | 16 +- resources/lib/utils.py | 78 +- resources/lib/variables.py | 56 +- resources/lib/widgets.py | 624 ++++++++++++++++ 19 files changed, 2206 insertions(+), 1624 deletions(-) create mode 100644 resources/lib/kodi_constants.py create mode 100644 resources/lib/library_sync/nodes.py delete mode 100644 resources/lib/library_sync/videonodes.py create mode 100644 resources/lib/widgets.py diff --git a/default.py b/default.py index 82179057..bd2f4a7d 100644 --- a/default.py +++ b/default.py @@ -41,33 +41,18 @@ class Main(): elif mode == 'plex_node': self.play() - elif mode == 'ondeck': - entrypoint.on_deck_episodes(itemid, - params.get('tagname'), - int(params.get('limit'))) - - elif mode == 'recentepisodes': - entrypoint.recent_episodes(params.get('type'), - params.get('tagname'), - int(params.get('limit'))) - - elif mode == 'nextup': - entrypoint.next_up_episodes(params['tagname'], - int(params['limit'])) - - elif mode == 'inprogressepisodes': - entrypoint.in_progress_episodes(params['tagname'], - int(params['limit'])) - elif mode == 'browseplex': entrypoint.browse_plex(key=params.get('key'), - plex_section_id=params.get('id')) + plex_type=params.get('plex_type'), + section_id=params.get('section_id'), + synched=params.get('synched') != 'false', + prompt=params.get('prompt')) elif mode == 'watchlater': entrypoint.watchlater() elif mode == 'channels': - entrypoint.channels() + entrypoint.browse_plex(key='/channels/all') elif mode == 'route_to_extras': # Hack so we can store this path in the Kodi DB @@ -86,20 +71,23 @@ class Main(): xbmc.executebuiltin('Addon.OpenSettings(%s)' % v.ADDON_ID) elif mode == 'enterPMS': - entrypoint.create_new_pms() + LOG.info('Request to manually enter new PMS address') + transfer.plex_command('enter_new_pms_address') elif mode == 'reset': transfer.plex_command('RESET-PKC') elif mode == 'togglePlexTV': - entrypoint.toggle_plex_tv_sign_in() + LOG.info('Toggle of Plex.tv sign-in requested') + transfer.plex_command('toggle_plex_tv_sign_in') elif mode == 'passwords': from resources.lib.windows import direct_path_sources direct_path_sources.start() elif mode == 'switchuser': - entrypoint.switch_plex_user() + LOG.info('Plex home user switch requested') + transfer.plex_command('switch_plex_user') elif mode in ('manualsync', 'repair'): if mode == 'repair': @@ -114,7 +102,8 @@ class Main(): transfer.plex_command('textures-scan') elif mode == 'chooseServer': - entrypoint.choose_pms_server() + LOG.info("Choosing PMS server requested, starting") + transfer.plex_command('choose_pms_server') elif mode == 'deviceid': self.deviceid() @@ -139,7 +128,7 @@ class Main(): entrypoint.playlists(params.get('content_type')) elif mode == 'hub': - entrypoint.hub(params.get('type')) + entrypoint.hub(params.get('content_type')) elif mode == 'select-libraries': LOG.info('User requested to select Plex libraries') diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index 9a332354..4c6a31f5 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -6,44 +6,55 @@ e.g. plugin://... calls. Hence be careful to only rely on window variables. """ from __future__ import absolute_import, division, unicode_literals from logging import getLogger -from sys import argv -from urllib import urlencode +import sys + +import xbmc import xbmcplugin -from xbmc import sleep from xbmcgui import ListItem from . import utils from . import path_ops -from . import transfer from .downloadutils import DownloadUtils as DU from .plex_api import API from . import plex_functions as PF -from . import json_rpc as js from . import variables as v # Be careful - your using app in another Python instance! -from . import app +from . import app, widgets -############################################################################### LOG = getLogger('PLEX.entrypoint') -############################################################################### - -def choose_pms_server(): +def guess_content_type(): """ - Lets user choose from list of PMS + Returns either 'video', 'audio' or 'image', based how the user navigated to + the current view. + Returns None if this failed, e.g. when the user picks widgets """ - LOG.info("Choosing PMS server requested, starting") - transfer.plex_command('choose_pms_server') - - -def toggle_plex_tv_sign_in(): - """ - Signs out of Plex.tv if there was a token saved and thus deletes the token. - Or signs in to plex.tv if the user was not logged in before. - """ - LOG.info('Toggle of Plex.tv sign-in requested') - transfer.plex_command('toggle_plex_tv_sign_in') + content_type = None + if xbmc.getCondVisibility('Window.IsActive(Videos)'): + content_type = 'video' + elif xbmc.getCondVisibility('Window.IsActive(Music)'): + content_type = 'audio' + elif xbmc.getCondVisibility('Window.IsActive(Pictures)'): + content_type = 'image' + elif xbmc.getCondVisibility('Container.Content(movies)'): + content_type = 'video' + elif xbmc.getCondVisibility('Container.Content(episodes)'): + content_type = 'video' + elif xbmc.getCondVisibility('Container.Content(seasons)'): + content_type = 'video' + elif xbmc.getCondVisibility('Container.Content(tvshows)'): + content_type = 'video' + elif xbmc.getCondVisibility('Container.Content(albums)'): + content_type = 'audio' + elif xbmc.getCondVisibility('Container.Content(artists)'): + content_type = 'audio' + elif xbmc.getCondVisibility('Container.Content(songs)'): + content_type = 'audio' + elif xbmc.getCondVisibility('Container.Content(pictures)'): + content_type = 'image' + LOG.debug('Guessed content type: %s', content_type) + return content_type def directory_item(label, path, folder=True): @@ -57,7 +68,7 @@ def directory_item(label, path, folder=True): {"fanart": "special://home/addons/plugin.video.plexkodiconnect/fanart.jpg"}) listitem.setArt( {"landscape":"special://home/addons/plugin.video.plexkodiconnect/fanart.jpg"}) - xbmcplugin.addDirectoryItem(handle=int(argv[1]), + xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=path, listitem=listitem, isFolder=folder) @@ -67,41 +78,56 @@ def show_main_menu(content_type=None): """ Shows the main PKC menu listing with all libraries, Channel, settings, etc. """ - LOG.debug('Do main listing with content_type: %s', content_type) - xbmcplugin.setContent(int(argv[1]), 'files') - # Get emby nodes from the window props - plexprops = utils.window('Plex.nodes.total') - if plexprops: - totalnodes = int(plexprops) - for i in range(totalnodes): - path = utils.window('Plex.nodes.%s.index' % i) - if not path: - path = utils.window('Plex.nodes.%s.content' % i) - if not path: - continue - label = utils.window('Plex.nodes.%s.title' % i) - node_type = utils.window('Plex.nodes.%s.type' % i) - # because we do not use seperate entrypoints for each content type, - # we need to figure out which items to show in each listing. for - # now we just only show picture nodes in the picture library video - # nodes in the video library and all nodes in any other window - if node_type == 'photos' and content_type == 'image': - directory_item(label, path) - elif node_type == 'albums' and content_type == 'audio': - directory_item(label, path) - elif node_type in ('movies', - 'tvshows', - 'homevideos', - 'musicvideos') and content_type == 'video': - directory_item(label, path) + content_type = content_type or guess_content_type() + LOG.debug('Do main listing for content_type: %s', content_type) + xbmcplugin.setContent(int(sys.argv[1]), 'files') + # Get nodes from the window props + totalnodes = int(utils.window('Plex.nodes.total') or 0) + for i in range(totalnodes): + path = utils.window('Plex.nodes.%s.content' % i) + if not path: + continue + label = utils.window('Plex.nodes.%s.title' % i) + node_type = utils.window('Plex.nodes.%s.type' % i) + # because we do not use seperate entrypoints for each content type, + # we need to figure out which items to show in each listing. for + # now we just only show picture nodes in the picture library video + # nodes in the video library and all nodes in any other window + if node_type == 'photos' and content_type == 'image': + directory_item(label, path) + elif node_type in ('artists', + 'albums', + 'songs') and content_type == 'audio': + directory_item(label, path) + elif node_type in ('movies', + 'tvshows', + 'homevideos', + 'musicvideos') and content_type == 'video': + directory_item(label, path) + elif content_type is None: + # To let the user pick this node as a WIDGET (content_type is None) + # Should only be called if the user selects widgets + LOG.info('Detected user selecting widgets') + directory_item(label, path) + if not path.startswith('library://'): + # Already using add-on paths (e.g. section not synched) + continue + # Add ANOTHER menu item that uses add-on paths instead of direct + # paths in order to let the user navigate into all submenus + addon_path = utils.window('Plex.nodes.%s.addon_path' % i) + # Append "(More...)" to the label + directory_item('%s (%s)' % (label, utils.lang(22082)), addon_path) # Playlists if content_type != 'image': - directory_item(utils.lang(136), - ('plugin://%s?mode=playlists&content_type=%s' - % (v.ADDON_ID, content_type))) + path = 'plugin://%s?mode=playlists' % v.ADDON_ID + if content_type: + path += '&content_type=%s' % content_type + directory_item(utils.lang(136), path) # Plex Hub - directory_item('Plex Hub', - 'plugin://%s?mode=hub&type=%s' % (v.ADDON_ID, content_type)) + path = 'plugin://%s?mode=hub' % v.ADDON_ID + if content_type: + path += '&content_type=%s' % content_type + directory_item('Plex Hub', path) # Plex Watch later if content_type not in ('image', 'audio'): directory_item(utils.lang(39211), @@ -116,242 +142,62 @@ def show_main_menu(content_type=None): directory_item(utils.lang(39201), "plugin://%s?mode=settings" % v.ADDON_ID) directory_item(utils.lang(39204), "plugin://%s?mode=manualsync" % v.ADDON_ID) - xbmcplugin.endOfDirectory(int(argv[1])) + xbmcplugin.endOfDirectory(int(sys.argv[1])) -def switch_plex_user(): +def show_listing(xml, plex_type=None, section_id=None, synched=True, key=None, + content_type=None): """ - Signs out currently logged in user (if applicable). Triggers sign-in of a - new user + Pass synched=False if the items have not been synched to the Kodi DB """ - # Guess these user avatars are a future feature. Skipping for now - # Delete any userimages. Since there's always only 1 user: position = 0 - # position = 0 - # utils.window('EmbyAdditionalUserImage.%s' % position, clear=True) - LOG.info("Plex home user switch requested") - transfer.plex_command('switch_plex_user') - - -def create_listitem(item, append_show_title=False, append_sxxexx=False): - """ - Feed with a Kodi json item response to get a xbmcgui.ListItem() with - everything set and ready. - """ - title = item['title'] - listitem = ListItem(title) - listitem.setProperty('IsPlayable', 'true') - metadata = { - 'duration': str(item['runtime'] / 60), - 'Plot': item['plot'], - 'Playcount': item['playcount'] - } - if 'episode' in item: - episode = item['episode'] - metadata['Episode'] = episode - if 'season' in item: - season = item['season'] - metadata['Season'] = season - if season and episode: - listitem.setProperty('episodeno', 's%.2de%.2d' % (season, episode)) - if append_sxxexx is True: - title = 'S%.2dE%.2d - %s' % (season, episode, title) - if 'firstaired' in item: - metadata['Premiered'] = item['firstaired'] - if 'showtitle' in item: - metadata['TVshowTitle'] = item['showtitle'] - if append_show_title is True: - title = item['showtitle'] + ' - ' + title - if 'rating' in item: - metadata['Rating'] = str(round(float(item['rating']), 1)) - if 'director' in item: - metadata['Director'] = item['director'] - if 'writer' in item: - metadata['Writer'] = item['writer'] - if 'cast' in item: - cast = [] - castandrole = [] - for person in item['cast']: - name = person['name'] - cast.append(name) - castandrole.append((name, person['role'])) - metadata['Cast'] = cast - metadata['CastAndRole'] = castandrole - - metadata['Title'] = title - metadata['mediatype'] = 'episode' - metadata['dbid'] = str(item['episodeid']) - listitem.setLabel(title) - listitem.setInfo(type='Video', infoLabels=metadata) - - listitem.setProperty('resumetime', str(item['resume']['position'])) - listitem.setProperty('totaltime', str(item['resume']['total'])) - listitem.setArt(item['art']) - listitem.setThumbnailImage(item['art'].get('thumb', '')) - listitem.setArt({'icon': 'DefaultTVShows.png'}) - listitem.setProperty('fanart_image', item['art'].get('tvshow.fanart', '')) + content_type = content_type or guess_content_type() + LOG.debug('show_listing: content_type %s, section_id %s, synched %s, ' + 'key %s, plex_type %s', content_type, section_id, synched, key, + plex_type) try: - listitem.addContextMenuItems([(utils.lang(30032), - 'XBMC.Action(Info)',)]) - except TypeError: - # Kodi fuck-up - pass - for key, value in item['streamdetails'].iteritems(): - for stream in value: - listitem.addStreamInfo(key, stream) - return listitem - - -def next_up_episodes(tagname, limit): - """ - List the next up episodes for tagname. - """ - count = 0 - # if the addon is called with nextup parameter, - # we return the nextepisodes list of the given tagname - xbmcplugin.setContent(int(argv[1]), 'episodes') - # First we get a list of all the TV shows - filtered by tag - params = { - 'sort': {'order': "descending", 'method': "lastplayed"}, - 'filter': { - 'and': [ - {'operator': "true", 'field': "inprogress", 'value': ""}, - {'operator': "is", 'field': "tag", 'value': "%s" % tagname} - ]}, - 'properties': ['title', 'studio', 'mpaa', 'file', 'art'] - } - for item in js.get_tv_shows(params): - if utils.settings('ignoreSpecialsNextEpisodes') == "true": - params = { - 'tvshowid': item['tvshowid'], - 'sort': {'method': "episode"}, - 'filter': { - 'and': [ - {'operator': "lessthan", - 'field': "playcount", - 'value': "1"}, - {'operator': "greaterthan", - 'field': "season", - 'value': "0"}]}, - 'properties': [ - "title", "playcount", "season", "episode", "showtitle", - "plot", "file", "rating", "resume", "tvshowid", "art", - "streamdetails", "firstaired", "runtime", "writer", - "dateadded", "lastplayed" - ], - 'limits': {"end": 1} - } - else: - params = { - 'tvshowid': item['tvshowid'], - 'sort': {'method': "episode"}, - 'filter': { - 'operator': "lessthan", - 'field': "playcount", - 'value': "1"}, - 'properties': [ - "title", "playcount", "season", "episode", "showtitle", - "plot", "file", "rating", "resume", "tvshowid", "art", - "streamdetails", "firstaired", "runtime", "writer", - "dateadded", "lastplayed" - ], - 'limits': {"end": 1} - } - for episode in js.get_episodes(params): - xbmcplugin.addDirectoryItem(handle=int(argv[1]), - url=episode['file'], - listitem=create_listitem(episode)) - count += 1 - if count == limit: - break - xbmcplugin.endOfDirectory(handle=int(argv[1])) - - -def in_progress_episodes(tagname, limit): - """ - List the episodes that are in progress for tagname - """ - count = 0 - # if the addon is called with inprogressepisodes parameter, - # we return the inprogressepisodes list of the given tagname - xbmcplugin.setContent(int(argv[1]), 'episodes') - # First we get a list of all the in-progress TV shows - filtered by tag - params = { - 'sort': {'order': "descending", 'method': "lastplayed"}, - 'filter': { - 'and': [ - {'operator': "true", 'field': "inprogress", 'value': ""}, - {'operator': "is", 'field': "tag", 'value': "%s" % tagname} - ]}, - 'properties': ['title', 'studio', 'mpaa', 'file', 'art'] - } - for item in js.get_tv_shows(params): - params = { - 'tvshowid': item['tvshowid'], - 'sort': {'method': "episode"}, - 'filter': { - 'operator': "true", - 'field': "inprogress", - 'value': ""}, - 'properties': ["title", "playcount", "season", "episode", - "showtitle", "plot", "file", "rating", "resume", - "tvshowid", "art", "cast", "streamdetails", - "firstaired", "runtime", "writer", "dateadded", - "lastplayed"] - } - for episode in js.get_episodes(params): - xbmcplugin.addDirectoryItem(handle=int(argv[1]), - url=episode['file'], - listitem=create_listitem(episode)) - count += 1 - if count == limit: - break - xbmcplugin.endOfDirectory(handle=int(argv[1])) - - -def recent_episodes(mediatype, tagname, limit): - """ - List the recently added episodes for tagname - """ - count = 0 - # if the addon is called with recentepisodes parameter, - # we return the recentepisodes list of the given tagname - xbmcplugin.setContent(int(argv[1]), 'episodes') - append_show_title = utils.settings('RecentTvAppendShow') == 'true' - append_sxxexx = utils.settings('RecentTvAppendSeason') == 'true' - # First we get a list of all the TV shows - filtered by tag - show_ids = set() - params = { - 'sort': {'order': "descending", 'method': "dateadded"}, - 'filter': {'operator': "is", 'field': "tag", 'value': "%s" % tagname}, - } - for tv_show in js.get_tv_shows(params): - show_ids.add(tv_show['tvshowid']) - params = { - 'sort': {'order': "descending", 'method': "dateadded"}, - 'properties': ["title", "playcount", "season", "episode", "showtitle", - "plot", "file", "rating", "resume", "tvshowid", "art", - "streamdetails", "firstaired", "runtime", "cast", "writer", - "dateadded", "lastplayed"], - "limits": {"end": limit} - } - if utils.settings('TVShowWatched') == 'false': - params['filter'] = { - 'operator': "lessthan", - 'field': "playcount", - 'value': "1" - } - for episode in js.get_episodes(params): - if episode['tvshowid'] in show_ids: - listitem = create_listitem(episode, - append_show_title=append_show_title, - append_sxxexx=append_sxxexx) - xbmcplugin.addDirectoryItem(handle=int(argv[1]), - url=episode['file'], - listitem=listitem) - count += 1 - if count == limit: - break - xbmcplugin.endOfDirectory(handle=int(argv[1])) + xml[0] + except IndexError: + LOG.info('xml received from the PMS is empty: %s', xml.attrib) + xbmcplugin.endOfDirectory(handle=int(sys.argv[1])) + return + if content_type == 'video': + xbmcplugin.setContent(int(sys.argv[1]), 'videos') + elif content_type == 'audio': + xbmcplugin.setContent(int(sys.argv[1]), 'artists') + elif plex_type in (v.PLEX_TYPE_PLAYLIST, v.PLEX_TYPE_CHANNEL): + xbmcplugin.setContent(int(sys.argv[1]), 'videos') + elif plex_type: + xbmcplugin.setContent(int(sys.argv[1]), + v.MEDIATYPE_FROM_PLEX_TYPE[plex_type]) + else: + xbmcplugin.setContent(int(sys.argv[1]), 'files') + # Initialization + widgets.PLEX_TYPE = plex_type + widgets.SYNCHED = synched + if content_type and xml[0].tag == 'Playlist': + # Certain views mix playlist types audio and video + for entry in reversed(xml): + if entry.get('playlistType') != content_type: + xml.remove(entry) + if xml.get('librarySectionID'): + widgets.SECTION_ID = utils.cast(int, xml.get('librarySectionID')) + elif section_id: + widgets.SECTION_ID = utils.cast(int, section_id) + if xml.get('viewGroup') == 'secondary': + # Need to chain keys for navigation + widgets.KEY = key + # Process all items to show + widgets.attach_kodi_ids(xml) + all_items = widgets.process_method_on_list(widgets.generate_item, xml) + all_items = widgets.process_method_on_list(widgets.prepare_listitem, + all_items) + # fill that listing... + all_items = widgets.process_method_on_list(widgets.create_listitem, + all_items) + xbmcplugin.addDirectoryItems(int(sys.argv[1]), all_items, len(all_items)) + # end directory listing + xbmcplugin.addSortMethod(int(sys.argv[1]), xbmcplugin.SORT_METHOD_UNSORTED) + xbmcplugin.endOfDirectory(handle=int(sys.argv[1])) def get_video_files(plex_id, params): @@ -375,14 +221,14 @@ def get_video_files(plex_id, params): if plex_id is None: LOG.info('No Plex ID found, abort getting Extras') - return xbmcplugin.endOfDirectory(int(argv[1])) + return xbmcplugin.endOfDirectory(int(sys.argv[1])) app.init(entrypoint=True) item = PF.GetPlexMetadata(plex_id) try: path = utils.try_decode(item[0][0][0].attrib['file']) except (TypeError, IndexError, AttributeError, KeyError): LOG.error('Could not get file path for item %s', plex_id) - return xbmcplugin.endOfDirectory(int(argv[1])) + return xbmcplugin.endOfDirectory(int(sys.argv[1])) # Assign network protocol if path.startswith('\\\\'): path = path.replace('\\\\', 'smb://') @@ -398,20 +244,20 @@ def get_video_files(plex_id, params): item_path = utils.try_encode(path_ops.path.join(root, directory)) listitem = ListItem(item_path, path=item_path) - xbmcplugin.addDirectoryItem(handle=int(argv[1]), + xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=item_path, listitem=listitem, isFolder=True) for file in files: item_path = utils.try_encode(path_ops.path.join(root, file)) listitem = ListItem(item_path, path=item_path) - xbmcplugin.addDirectoryItem(handle=int(argv[1]), + xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=file, listitem=listitem) break else: LOG.error('Kodi cannot access folder %s', path) - xbmcplugin.endOfDirectory(int(argv[1])) + xbmcplugin.endOfDirectory(int(sys.argv[1])) @utils.catch_exceptions(warnuser=False) @@ -427,7 +273,7 @@ def extra_fanart(plex_id, plex_path): plex_id = plex_path.split("/")[-2] if not plex_id: LOG.error('Could not get a plex_id, aborting') - return xbmcplugin.endOfDirectory(int(argv[1])) + return xbmcplugin.endOfDirectory(int(sys.argv[1])) # We need to store the images locally for this to work # because of the caching system in xbmc @@ -440,7 +286,7 @@ def extra_fanart(plex_id, plex_path): xml = PF.GetPlexMetadata(plex_id) if xml is None: LOG.error('Could not download metadata for %s', plex_id) - return xbmcplugin.endOfDirectory(int(argv[1])) + return xbmcplugin.endOfDirectory(int(sys.argv[1])) api = API(xml[0]) backdrops = api.artwork()['Backdrop'] @@ -450,7 +296,7 @@ def extra_fanart(plex_id, plex_path): fanart_dir, "fanart%.3d.jpg" % count)) listitem = ListItem("%.3d" % count, path=art_file) xbmcplugin.addDirectoryItem( - handle=int(argv[1]), + handle=int(sys.argv[1]), url=art_file, listitem=listitem) path_ops.copyfile(backdrop, utils.try_decode(art_file)) @@ -464,149 +310,10 @@ def extra_fanart(plex_id, plex_path): file = utils.decode_path(file) art_file = utils.try_encode(path_ops.path.join(root, file)) listitem = ListItem(file, path=art_file) - xbmcplugin.addDirectoryItem(handle=int(argv[1]), + xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=art_file, listitem=listitem) - xbmcplugin.endOfDirectory(int(argv[1])) - - -def _wait_for_auth(): - """ - Call to be sure that PKC is authenticated, e.g. for widgets on Kodi - startup. Will wait for at most 30s, then fail if not authenticated. - - Will set xbmcplugin.endOfDirectory(int(argv[1]), False) if failed - """ - counter = 0 - while utils.window('plex_authenticated') != 'true': - counter += 1 - if counter == 300: - LOG.error('Aborting view, we were not authenticated for PMS') - xbmcplugin.endOfDirectory(int(argv[1]), False) - return False - sleep(100) - return True - - -def on_deck_episodes(viewid, tagname, limit): - """ - Retrieves Plex On Deck items, currently only for TV shows - - Input: - viewid: Plex id of the library section, e.g. '1' - tagname: Name of the Plex library, e.g. "My Movies" - limit: Max. number of items to retrieve, e.g. 50 - """ - xbmcplugin.setContent(int(argv[1]), 'episodes') - append_show_title = utils.settings('OnDeckTvAppendShow') == 'true' - append_sxxexx = utils.settings('OnDeckTvAppendSeason') == 'true' - if utils.settings('OnDeckTVextended') == 'false': - # Chances are that this view is used on Kodi startup - # Wait till we've connected to a PMS. At most 30s - if not _wait_for_auth(): - return - # We're using another python instance - need to load some vars - app.init(entrypoint=True) - xml = DU().downloadUrl('{server}/library/sections/%s/onDeck' % viewid) - if xml in (None, 401): - LOG.error('Could not download PMS xml for view %s', viewid) - xbmcplugin.endOfDirectory(int(argv[1]), False) - return - counter = 0 - for item in xml: - api = API(item) - listitem = api.create_listitem( - append_show_title=append_show_title, - append_sxxexx=append_sxxexx) - if api.resume_point(): - listitem.setProperty('resumetime', str(api.resume_point())) - path = api.path(force_first_media=True) - xbmcplugin.addDirectoryItem( - handle=int(argv[1]), - url=path, - listitem=listitem) - counter += 1 - if counter == limit: - break - xbmcplugin.endOfDirectory( - handle=int(argv[1]), - cacheToDisc=utils.settings('enableTextureCache') == 'true') - return - - # if the addon is called with nextup parameter, - # we return the nextepisodes list of the given tagname - # First we get a list of all the TV shows - filtered by tag - params = { - 'sort': {'order': "descending", 'method': "lastplayed"}, - 'filter': { - 'and': [ - {'operator': "true", 'field': "inprogress", 'value': ""}, - {'operator': "is", 'field': "tag", 'value': "%s" % tagname} - ]} - } - items = js.get_tv_shows(params) - if not items: - # Now items retrieved - empty directory - xbmcplugin.endOfDirectory(handle=int(argv[1])) - return - - params = { - 'sort': {'method': "episode"}, - 'limits': {"end": 1}, - 'properties': [ - "title", "playcount", "season", "episode", "showtitle", - "plot", "file", "rating", "resume", "tvshowid", "art", - "streamdetails", "firstaired", "runtime", "cast", "writer", - "dateadded", "lastplayed" - ], - } - if utils.settings('ignoreSpecialsNextEpisodes') == "true": - params['filter'] = { - 'and': [ - {'operator': "lessthan", 'field': "playcount", 'value': "1"}, - {'operator': "greaterthan", 'field': "season", 'value': "0"} - ] - } - else: - params['filter'] = { - 'or': [ - {'operator': "lessthan", 'field': "playcount", 'value': "1"}, - {'operator': "true", 'field': "inprogress", 'value': ""} - ] - } - - # Are there any episodes still in progress/not yet finished watching?!? - # Then we should show this episode, NOT the "next up" - inprog_params = { - 'sort': {'method': "episode"}, - 'filter': {'operator': "true", 'field': "inprogress", 'value': ""}, - 'properties': params['properties'] - } - - count = 0 - for item in items: - inprog_params['tvshowid'] = item['tvshowid'] - episodes = js.get_episodes(inprog_params) - if not episodes: - # No, there are no episodes not yet finished. Get "next up" - params['tvshowid'] = item['tvshowid'] - episodes = js.get_episodes(params) - if not episodes: - # Also no episodes currently coming up - continue - for episode in episodes: - # There will always be only 1 episode ('limit=1') - listitem = create_listitem(episode, - append_show_title=append_show_title, - append_sxxexx=append_sxxexx) - xbmcplugin.addDirectoryItem(handle=int(argv[1]), - url=episode['file'], - listitem=listitem, - isFolder=False) - count += 1 - if count >= limit: - break - xbmcplugin.endOfDirectory(handle=int(argv[1])) + xbmcplugin.endOfDirectory(int(sys.argv[1])) def playlists(content_type): @@ -614,34 +321,21 @@ def playlists(content_type): Lists all Plex playlists of the media type plex_playlist_type content_type: 'audio', 'video' """ + content_type = content_type or guess_content_type() LOG.debug('Listing Plex %s playlists', content_type) - if not _wait_for_auth(): - return - xbmcplugin.setContent(int(argv[1]), 'files') app.init(entrypoint=True) from .playlists.pms import all_playlists xml = all_playlists() if xml is None: return - for item in xml: - api = API(item) - if not api.playlist_type() == content_type: - continue - listitem = ListItem(api.title()) - listitem.setArt({'thumb': api.one_artwork('composite')}) - url = "plugin://%s/" % v.ADDON_ID - key = api.path_and_plex_id() - params = { - 'mode': "browseplex", - 'key': key, - } - xbmcplugin.addDirectoryItem(handle=int(argv[1]), - url="%s?%s" % (url, urlencode(params)), - isFolder=True, - listitem=listitem) - xbmcplugin.endOfDirectory( - handle=int(argv[1]), - cacheToDisc=utils.settings('enableTextureCache') == 'true') + if content_type is not None: + # This will be skipped if user selects a widget + # Buggy xml.remove(child) requires reversed() + for entry in reversed(xml): + api = API(entry) + if not api.playlist_type() == content_type: + xml.remove(entry) + show_listing(xml, content_type=content_type) def hub(content_type): @@ -650,27 +344,35 @@ def hub(content_type): content_type: audio, video, image """ + content_type = content_type or guess_content_type() + LOG.debug('Showing Plex Hub entries for %s', content_type) app.init(entrypoint=True) xml = PF.get_plex_hub() try: xml.attrib except AttributeError: LOG.error('Could not get Plex hub listing') - return xbmcplugin.endOfDirectory(int(argv[1]), False) - for entry in xml: + return xbmcplugin.endOfDirectory(int(sys.argv[1]), False) + # We need to make sure that only entries that WORK are displayed + # WARNING: using xml.remove(child) in for-loop requires traversing from + # the end! + for entry in reversed(xml): api = API(entry) + append = False if content_type == 'video' and api.plex_type() in v.PLEX_VIDEOTYPES: - __build_folder(entry) + append = True elif content_type == 'audio' and api.plex_type() in v.PLEX_AUDIOTYPES: - __build_folder(entry) + append = True elif content_type == 'image' and api.plex_type() == v.PLEX_TYPE_PHOTO: - __build_folder(entry) - else: + append = True + elif content_type != 'image' and api.plex_type() == v.PLEX_TYPE_PLAYLIST: + append = True + elif content_type is None: # Needed for widgets, where no content_type is provided - __build_folder(entry) - xbmcplugin.endOfDirectory( - handle=int(argv[1]), - cacheToDisc=utils.settings('enableTextureCache') == 'true') + append = True + if not append: + xml.remove(entry) + show_listing(xml, content_type=content_type) def watchlater(): @@ -679,10 +381,10 @@ def watchlater(): """ if utils.window('plex_token') == '': LOG.error('No watch later - not signed in to plex.tv') - return xbmcplugin.endOfDirectory(int(argv[1]), False) + return xbmcplugin.endOfDirectory(int(sys.argv[1]), False) if utils.window('plex_restricteduser') == 'true': LOG.error('No watch later - restricted user') - return xbmcplugin.endOfDirectory(int(argv[1]), False) + return xbmcplugin.endOfDirectory(int(sys.argv[1]), False) app.init(entrypoint=True) xml = DU().downloadUrl('https://plex.tv/pms/playlists/queue/all', @@ -690,225 +392,46 @@ def watchlater(): headerOptions={'X-Plex-Token': utils.window('plex_token')}) if xml in (None, 401): LOG.error('Could not download watch later list from plex.tv') - return xbmcplugin.endOfDirectory(int(argv[1]), False) - - LOG.info('Displaying watch later plex.tv items') - xbmcplugin.setContent(int(argv[1]), 'movies') - direct_paths = utils.settings('useDirectPaths') == '1' - for item in xml: - __build_item(item, direct_paths) - xbmcplugin.endOfDirectory( - handle=int(argv[1]), - cacheToDisc=utils.settings('enableTextureCache') == 'true') + return xbmcplugin.endOfDirectory(int(sys.argv[1]), False) + show_listing(xml) -def channels(): - """ - Listing for Plex Channels - """ - app.init(entrypoint=True) - xml = DU().downloadUrl('{server}/channels/all') - try: - xml[0].attrib - except (ValueError, AttributeError, IndexError, TypeError): - LOG.error('Could not download Plex Channels') - return xbmcplugin.endOfDirectory(int(argv[1]), False) - - LOG.info('Displaying Plex Channels') - xbmcplugin.setContent(int(argv[1]), 'files') - for method in v.SORT_METHODS_DIRECTORY: - xbmcplugin.addSortMethod(int(argv[1]), getattr(xbmcplugin, method)) - for item in xml: - __build_folder(item) - xbmcplugin.endOfDirectory( - handle=int(argv[1]), - cacheToDisc=utils.settings('enableTextureCache') == 'true') - - -def browse_plex(key=None, plex_section_id=None): +def browse_plex(key=None, plex_type=None, section_id=None, synched=True, + prompt=None): """ Lists the content of a Plex folder, e.g. channels. Either pass in key (to - be used directly for PMS url {server}) or the plex_section_id + be used directly for PMS url {server}) or the section_id + + Pass synched=False if the items have NOT been synched to the Kodi DB """ - LOG.debug('Browsing to key %s, section %s', key, plex_section_id) + LOG.debug('Browsing to key %s, section %s, plex_type: %s, synched: %s', + key, section_id, plex_type, synched) app.init(entrypoint=True) - if key: - xml = DU().downloadUrl('{server}%s' % key) - else: - xml = PF.GetPlexSectionResults(plex_section_id) + xml = DU().downloadUrl('{server}%s' % key) try: xml.attrib except AttributeError: LOG.error('Could not browse to key %s, section %s', - key, plex_section_id) - return xbmcplugin.endOfDirectory(int(argv[1]), False) - - photos = False - movies = False - clips = False - tvshows = False - episodes = False - songs = False - artists = False - albums = False - musicvideos = False - direct_paths = utils.settings('useDirectPaths') == '1' - for item in xml: - if item.tag == 'Directory': - __build_folder(item, plex_section_id=plex_section_id) - else: - typus = item.attrib.get('type') - __build_item(item, direct_paths) - if typus == v.PLEX_TYPE_PHOTO: - photos = True - elif typus == v.PLEX_TYPE_MOVIE: - movies = True - elif typus == v.PLEX_TYPE_CLIP: - clips = True - elif typus in (v.PLEX_TYPE_SHOW, v.PLEX_TYPE_SEASON): - tvshows = True - elif typus == v.PLEX_TYPE_EPISODE: - episodes = True - elif typus == v.PLEX_TYPE_SONG: - songs = True - elif typus == v.PLEX_TYPE_ARTIST: - artists = True - elif typus == v.PLEX_TYPE_ALBUM: - albums = True - elif typus == v.PLEX_TYPE_MUSICVIDEO: - musicvideos = True - - # Set the correct content type - if movies is True: - LOG.debug('Setting view to movies') - xbmcplugin.setContent(int(argv[1]), 'movies') - sort_methods = v.SORT_METHODS_MOVIES - elif clips is True: - LOG.debug('Clips -> Setting view to movies') - xbmcplugin.setContent(int(argv[1]), 'movies') - sort_methods = v.SORT_METHODS_CLIPS - elif photos is True: - LOG.debug('Setting view to images') - xbmcplugin.setContent(int(argv[1]), 'images') - sort_methods = v.SORT_METHODS_PHOTOS - elif tvshows is True: - LOG.debug('Setting view to tvshows') - xbmcplugin.setContent(int(argv[1]), 'tvshows') - sort_methods = v.SORT_METHOD_TVSHOWS - elif episodes is True: - LOG.debug('Setting view to episodes') - xbmcplugin.setContent(int(argv[1]), 'episodes') - sort_methods = v.SORT_METHODS_EPISODES - elif songs is True: - LOG.debug('Setting view to songs') - xbmcplugin.setContent(int(argv[1]), 'songs') - sort_methods = v.SORT_METHODS_SONGS - elif artists is True: - LOG.debug('Setting view to artists') - xbmcplugin.setContent(int(argv[1]), 'artists') - sort_methods = v.SORT_METHODS_ARTISTS - elif albums is True: - LOG.debug('Setting view to albums') - xbmcplugin.setContent(int(argv[1]), 'albums') - sort_methods = v.SORT_METHODS_ALBUMS - elif musicvideos is True: - LOG.debug('Setting view to musicvideos') - xbmcplugin.setContent(int(argv[1]), 'musicvideos') - sort_methods = v.SORT_METHODS_MOVIES - else: - LOG.debug('Setting view to files') - xbmcplugin.setContent(int(argv[1]), 'files') - sort_methods = v.SORT_METHODS_DIRECTORY - - for method in sort_methods: - LOG.debug('Adding Kodi sort method %s', method) - xbmcplugin.addSortMethod(int(argv[1]), getattr(xbmcplugin, method)) - - # Set the Kodi title for this view - title = xml.attrib.get('librarySectionTitle', xml.attrib.get('title1')) - xbmcplugin.setPluginCategory(int(argv[1]), title) - - xbmcplugin.endOfDirectory( - handle=int(argv[1]), - cacheToDisc=utils.settings('enableTextureCache') == 'true') - - -def __build_folder(xml_element, plex_section_id=None): - url = "plugin://%s/" % v.ADDON_ID - key = xml_element.get('fastKey', xml_element.get('key')) - if not key.startswith('/'): - key = '/library/sections/%s/%s' % (plex_section_id, key) - params = { - 'mode': "browseplex", - 'key': key, - } - if plex_section_id: - params['id'] = plex_section_id - listitem = ListItem(xml_element.get('title')) - thumb = xml_element.get('thumb') or \ - 'special://home/addons/%s/icon.png' % v.ADDON_ID - art = xml_element.get('art') or \ - 'special://home/addons/%s/fanart.jpg' % v.ADDON_ID - listitem.setThumbnailImage(thumb) - listitem.setArt({'fanart': art, 'landscape': art}) - xbmcplugin.addDirectoryItem(handle=int(argv[1]), - url="%s?%s" % (url, urlencode(params)), - isFolder=True, - listitem=listitem) - - -def __build_item(xml_element, direct_paths): - api = API(xml_element) - listitem = api.create_listitem() - resume = api.resume_point() - if resume: - listitem.setProperty('resumetime', str(resume)) - if (api.path_and_plex_id().startswith('/system/services') or - api.path_and_plex_id().startswith('http')): - params = { - 'mode': 'plex_node', - 'key': xml_element.attrib.get('key'), - 'offset': xml_element.attrib.get('viewOffset', '0'), - } - url = "plugin://%s?%s" % (v.ADDON_ID, urlencode(params)) - elif api.plex_type() == v.PLEX_TYPE_PHOTO: - url = api.get_picture_path() - else: - url = api.path(direct_paths=direct_paths) - xbmcplugin.addDirectoryItem(handle=int(argv[1]), - url=url, - listitem=listitem) + key, section_id) + show_listing(xml, plex_type, section_id, synched, key) def extras(plex_id): """ Lists all extras for plex_id """ - xbmcplugin.setContent(int(argv[1]), 'movies') app.init(entrypoint=True) xml = PF.GetPlexMetadata(plex_id) try: xml[0].attrib except (TypeError, IndexError, KeyError): - xbmcplugin.endOfDirectory(int(argv[1])) + xbmcplugin.endOfDirectory(int(sys.argv[1])) return - for item in API(xml[0]).extras(): - api = API(item) - path = api.path() - # For some reason, the combination of metadata used by - # api.create_listitem() breaks the listitem - listitem = ListItem(api.title()) - listitem.setPath(path) - listitem.setArt(api.artwork()) - xbmcplugin.addDirectoryItem(handle=int(argv[1]), - url=path, - listitem=listitem) - xbmcplugin.endOfDirectory(int(argv[1])) - - -def create_new_pms(): - """ - Opens dialogs for the user the plug in the PMS details - """ - LOG.info('Request to manually enter new PMS address') - transfer.plex_command('enter_new_pms_address') + extras = API(xml[0]).extras() + if not extras: + return + for child in xml: + xml.remove(child) + for i, child in enumerate(extras): + xml.insert(i, child) + show_listing(xml, synched=False, plex_type=v.PLEX_TYPE_MOVIE) diff --git a/resources/lib/json_rpc.py b/resources/lib/json_rpc.py index d5646379..9b2edffd 100644 --- a/resources/lib/json_rpc.py +++ b/resources/lib/json_rpc.py @@ -8,7 +8,26 @@ from __future__ import absolute_import, division, unicode_literals from json import loads, dumps from xbmc import executeJSONRPC -from . import timing +from . import kodi_constants, timing, variables as v + +JSON_FROM_KODITYPE = { + v.KODI_TYPE_MOVIE: ('VideoLibrary.GetMovieDetails', + kodi_constants.FIELDS_MOVIES), + v.KODI_TYPE_SHOW: ('VideoLibrary.GetTVShowDetails', + kodi_constants.FIELDS_TVSHOWS), + v.KODI_TYPE_SEASON: ('VideoLibrary.GetSeasonDetails', + kodi_constants.FIELDS_SEASON), + v.KODI_TYPE_EPISODE: ('VideoLibrary.GetEpisodeDetails', + kodi_constants.FIELDS_EPISODES), + v.KODI_TYPE_ARTIST: ('AudioLibrary.GetArtistDetails', + kodi_constants.FIELDS_ARTISTS), + v.KODI_TYPE_ALBUM: ('AudioLibrary.GetAlbumDetails', + kodi_constants.FIELDS_ALBUMS), + v.KODI_TYPE_SONG: ('AudioLibrary.GetSongDetails', + kodi_constants.FIELDS_SONGS), + v.KODI_TYPE_SET: ('VideoLibrary.GetMovieSetDetails', + []), +} class JsonRPC(object): @@ -557,3 +576,16 @@ def settings_setsettingvalue(setting, value): 'setting': setting, 'value': value }) + + +def item_details(kodi_id, kodi_type): + ''' + Returns the Kodi item dict for this item + ''' + json, fields = JSON_FROM_KODITYPE[kodi_type] + ret = JsonRPC(json).execute({'%sid' % kodi_type: kodi_id, + 'properties': fields}) + try: + return ret['result']['%sdetails' % kodi_type] + except (KeyError, TypeError): + return {} diff --git a/resources/lib/kodi_constants.py b/resources/lib/kodi_constants.py new file mode 100644 index 00000000..61f88cd9 --- /dev/null +++ b/resources/lib/kodi_constants.py @@ -0,0 +1,92 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +''' +script.module.metadatautils +kodi_constants.py +Several common constants for use with Kodi json api +''' +FIELDS_BASE = ['dateadded', 'file', 'lastplayed', 'plot', 'title', 'art', + 'playcount'] +FIELDS_FILE = FIELDS_BASE + ['streamdetails', 'director', 'resume', 'runtime'] +FIELDS_MOVIES = FIELDS_FILE + ['plotoutline', 'sorttitle', 'cast', 'votes', + 'showlink', 'top250', 'trailer', 'year', 'country', 'studio', 'set', + 'genre', 'mpaa', 'setid', 'rating', 'tag', 'tagline', 'writer', + 'originaltitle', 'imdbnumber', 'uniqueid'] +FIELDS_TVSHOWS = FIELDS_BASE + ['sorttitle', 'mpaa', 'premiered', 'year', + 'episode', 'watchedepisodes', 'votes', 'rating', 'studio', 'season', + 'genre', 'cast', 'episodeguide', 'tag', 'originaltitle', 'imdbnumber'] +FIELDS_SEASON = ['art', 'playcount', 'season', 'showtitle', 'episode', + 'tvshowid', 'watchedepisodes', 'userrating', 'fanart', 'thumbnail'] +FIELDS_EPISODES = FIELDS_FILE + ['cast', 'productioncode', 'rating', 'votes', + 'episode', 'showtitle', 'tvshowid', 'season', 'firstaired', 'writer', + 'originaltitle'] +FIELDS_MUSICVIDEOS = FIELDS_FILE + ['genre', 'artist', 'tag', 'album', 'track', + 'studio', 'year'] +FIELDS_FILES = FIELDS_FILE + ['plotoutline', 'sorttitle', 'cast', 'votes', + 'trailer', 'year', 'country', 'studio', 'genre', 'mpaa', 'rating', + 'tagline', 'writer', 'originaltitle', 'imdbnumber', 'premiered', 'episode', + 'showtitle', 'firstaired', 'watchedepisodes', 'duration', 'season'] +FIELDS_SONGS = ['artist', 'displayartist', 'title', 'rating', 'fanart', + 'thumbnail', 'duration', 'disc', 'playcount', 'comment', 'file', 'album', + 'lastplayed', 'genre', 'musicbrainzartistid', 'track', 'dateadded'] +FIELDS_ALBUMS = ['title', 'fanart', 'thumbnail', 'genre', 'displayartist', + 'artist', 'musicbrainzalbumartistid', 'year', 'rating', 'artistid', + 'musicbrainzalbumid', 'theme', 'description', 'type', 'style', 'playcount', + 'albumlabel', 'mood', 'dateadded'] +FIELDS_ARTISTS = ['born', 'formed', 'died', 'style', 'yearsactive', 'mood', + 'fanart', 'thumbnail', 'musicbrainzartistid', 'disbanded', 'description', + 'instrument'] +FIELDS_RECORDINGS = ['art', 'channel', 'directory', 'endtime', 'file', 'genre', + 'icon', 'playcount', 'plot', 'plotoutline', 'resume', 'runtime', + 'starttime', 'streamurl', 'title'] +FIELDS_CHANNELS = ['broadcastnow', 'channeltype', 'hidden', 'locked', + 'lastplayed', 'thumbnail', 'channel'] + +FILTER_UNWATCHED = { + 'operator': 'lessthan', + 'field': 'playcount', + 'value': '1' +} +FILTER_WATCHED = { + 'operator': 'isnot', + 'field': 'playcount', + 'value': '0' +} +FILTER_RATING = { + 'operator': 'greaterthan', + 'field': 'rating', + 'value': '7' +} +FILTER_RATING_MUSIC = { + 'operator': 'greaterthan', + 'field': 'rating', + 'value': '3' +} +FILTER_INPROGRESS = { + 'operator': 'true', + 'field': 'inprogress', + 'value': '' +} +SORT_RATING = { + 'method': 'rating', + 'order': 'descending' +} +SORT_RANDOM = { + 'method': 'random', + 'order': 'descending' +} +SORT_TITLE = { + 'method': 'title', + 'order': 'ascending' +} +SORT_DATEADDED = { + 'method': 'dateadded', + 'order': 'descending' +} +SORT_LASTPLAYED = { + 'method': 'lastplayed', + 'order': 'descending' +} +SORT_EPISODE = { + 'method': 'episode' +} diff --git a/resources/lib/library_sync/__init__.py b/resources/lib/library_sync/__init__.py index 58b6bd6c..ca1a088a 100644 --- a/resources/lib/library_sync/__init__.py +++ b/resources/lib/library_sync/__init__.py @@ -7,5 +7,4 @@ from .websocket import store_websocket_message, process_websocket_messages, \ WEBSOCKET_MESSAGES, PLAYSTATE_SESSIONS from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED from .fanart import FanartThread, FanartTask -from .videonodes import VideoNodes -from .sections import force_full_sync +from .sections import force_full_sync, delete_files, clear_window_vars diff --git a/resources/lib/library_sync/full_sync.py b/resources/lib/library_sync/full_sync.py index 2ea072d5..80973562 100644 --- a/resources/lib/library_sync/full_sync.py +++ b/resources/lib/library_sync/full_sync.py @@ -166,14 +166,14 @@ class FullSync(common.fullsync_mixin): app.SYNC.path_verified = False try: # Sync new, updated and deleted items - iterator = section['iterator'] + iterator = section.iterator # Tell the processing thread about this new section - queue_info = InitNewSection(section['context'], + queue_info = InitNewSection(section.context, iterator.total, iterator.get('librarySectionTitle', iterator.get('title1')), - section['section_id'], - section['plex_type']) + section.section_id, + section.plex_type) self.queue.put(queue_info) last = True # To keep track of the item-number in order to kill while loops @@ -206,37 +206,37 @@ class FullSync(common.fullsync_mixin): @utils.log_time def playstate_per_section(self, section): LOG.debug('Processing %s playstates for library section %s', - section['iterator'].total, section) + section.iterator.total, section) try: # Sync new, updated and deleted items - iterator = section['iterator'] + iterator = section.iterator # Tell the processing thread about this new section - queue_info = InitNewSection(section['context'], + queue_info = InitNewSection(section.context, iterator.total, - section['section_name'], - section['section_id'], - section['plex_type']) + section.name, + section.section_id, + section.plex_type) self.queue.put(queue_info) self.total = iterator.total - self.section_name = section['section_name'] + self.section_name = section.name self.section_type_text = utils.lang( - v.TRANSLATION_FROM_PLEXTYPE[section['plex_type']]) + v.TRANSLATION_FROM_PLEXTYPE[section.plex_type]) self.current = 0 last = True loop = common.tag_last(iterator) while True: - with section['context'](self.current_sync) as itemtype: + with section.context(self.current_sync) as itemtype: for i, (last, xml_item) in enumerate(loop): if self.isCanceled(): return False - if not itemtype.update_userdata(xml_item, section['plex_type']): + if not itemtype.update_userdata(xml_item, section.plex_type): # Somehow did not sync this item yet itemtype.add_update(xml_item, - section_name=section['section_name'], - section_id=section['section_id']) + section_name=section.name, + section_id=section.section_id) itemtype.plexdb.update_last_sync(int(xml_item.attrib['ratingKey']), - section['plex_type'], + section.plex_type, self.current_sync) self.current += 1 self.update_progressbar() @@ -256,28 +256,27 @@ class FullSync(common.fullsync_mixin): try: for kind in kinds: for section in (x for x in sections.SECTIONS - if x['plex_type'] == kind[1]): + if x.section_type == kind[1]): if self.isCanceled(): return - if not section['sync_to_kodi']: + if not section.sync_to_kodi: LOG.info('User chose to not sync section %s', section) continue element = copy.deepcopy(section) - element['section_type'] = element['plex_type'] - element['plex_type'] = kind[0] - element['element_type'] = kind[1] - element['context'] = kind[2] - element['get_children'] = kind[3] + element.plex_type = kind[0] + element.section_type = element.plex_type + element.context = kind[2] + element.get_children = kind[3] if self.repair or all_items: updated_at = None else: - updated_at = section['last_sync'] - UPDATED_AT_SAFETY \ - if section['last_sync'] else None + updated_at = section.last_sync - UPDATED_AT_SAFETY \ + if section.last_sync else None try: - element['iterator'] = PF.SectionItems(section['section_id'], - plex_type=kind[0], - updated_at=updated_at, - last_viewed_at=None) + element.iterator = PF.SectionItems(section.section_id, + plex_type=element.plex_type, + updated_at=updated_at, + last_viewed_at=None) except RuntimeError: LOG.warn('Sync at least partially unsuccessful') self.successful = False @@ -317,10 +316,10 @@ class FullSync(common.fullsync_mixin): if section is None: break # Setup our variables - self.plex_type = section['plex_type'] - self.section_type = section['section_type'] - self.context = section['context'] - self.get_children = section['get_children'] + self.plex_type = section.plex_type + self.section_type = section.section_type + self.context = section.context + self.get_children = section.get_children # Now do the heavy lifting if self.isCanceled() or not self.addupdate_section(section): return False @@ -329,7 +328,7 @@ class FullSync(common.fullsync_mixin): # some items from the PMS with PlexDB() as plexdb: # Set the new time mark for the next delta sync - plexdb.update_section_last_sync(section['section_id'], + plexdb.update_section_last_sync(section.section_id, self.current_sync) common.update_kodi_library(video=True, music=True) # In order to not delete all your songs again @@ -361,10 +360,10 @@ class FullSync(common.fullsync_mixin): if section is None: break # Setup our variables - self.plex_type = section['plex_type'] - self.section_type = section['section_type'] - self.context = section['context'] - self.get_children = section['get_children'] + self.plex_type = section.plex_type + self.section_type = section.section_type + self.context = section.context + self.get_children = section.get_children # Now do the heavy lifting if self.isCanceled() or not self.playstate_per_section(section): return False @@ -410,9 +409,6 @@ class FullSync(common.fullsync_mixin): @utils.log_time def _run(self): self.current_sync = timing.plex_now() - # Delete playlist and video node files from Kodi - utils.delete_playlists() - utils.delete_nodes() # Get latest Plex libraries and build playlist and video node files if not sections.sync_from_pms(self): return diff --git a/resources/lib/library_sync/nodes.py b/resources/lib/library_sync/nodes.py new file mode 100644 index 00000000..0203e964 --- /dev/null +++ b/resources/lib/library_sync/nodes.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +import urllib + +from ..utils import etree +from .. import variables as v, utils + +ICON_PATH = 'special://home/addons/plugin.video.plexkodiconnect/icon.png' +RECOMMENDED_SCORE_LOWER_BOUND = 7 + +# Logic of the following nodes: +# (node_type, +# label/node name, +# args for PKC add-on callback, +# Kodi "content", +# Bool: does this node's xml even point back to PKC add-on callback? +# ) +NODE_TYPES = { + v.PLEX_TYPE_MOVIE: ( + ('ondeck', + utils.lang(39500), # "On Deck" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/onDeck', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'movies', + True), + ('recent', + utils.lang(30174), # "Recently Added" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/recentlyAdded', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'movies', + False), + ('all', + '{self.name}', # We're using this section's name + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/all', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'movies', + False), + ('recommended', + utils.lang(30230), # "Recommended" + { + 'mode': 'browseplex', + 'key': ('/library/sections/{self.section_id}&%s' + % urllib.urlencode({'sort': 'rating:desc'})), + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'movies', + False), + ('genres', + utils.lang(135), # "Genres" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/genre', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'movies', + False), + ('sets', + utils.lang(39501), # "Collections" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/collection', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'movies', + False), + ('random', + utils.lang(30227), # "Random" + { + 'mode': 'browseplex', + 'key': ('/library/sections/{self.section_id}&%s' + % urllib.urlencode({'sort': 'random'})), + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'movies', + False), + ('lastplayed', + utils.lang(568), # "Last played" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/recentlyViewed', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'movies', + False), + ('browse', + utils.lang(39702), # "Browse by folder" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/folder', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'movies', + True), + ('more', + utils.lang(22082), # "More..." + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'movies', + True), + ), + ########################################################### + v.PLEX_TYPE_SHOW: ( + ('ondeck', + utils.lang(39500), # "On Deck" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/onDeck', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'episodes', + True), + ('recent', + utils.lang(30174), # "Recently Added" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/recentlyAdded', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'episodes', + False), + ('all', + '{self.name}', # We're using this section's name + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/all', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'tvshows', + False), + ('recommended', + utils.lang(30230), # "Recommended" + { + 'mode': 'browseplex', + 'key': ('/library/sections/{self.section_id}&%s' + % urllib.urlencode({'sort': 'rating:desc'})), + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'tvshows', + False), + ('genres', + utils.lang(135), # "Genres" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/genre', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'tvshows', + False), + ('sets', + utils.lang(39501), # "Collections" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/collection', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'tvshows', + True), # There are no sets/collections for shows with Kodi + ('random', + utils.lang(30227), # "Random" + { + 'mode': 'browseplex', + 'key': ('/library/sections/{self.section_id}&%s' + % urllib.urlencode({'sort': 'random'})), + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'tvshows', + False), + ('lastplayed', + utils.lang(568), # "Last played" + { + 'mode': 'browseplex', + 'key': ('/library/sections/{self.section_id}/recentlyViewed&%s' + % urllib.urlencode({'type': v.PLEX_TYPE_NUMBER_FROM_PLEX_TYPE[v.PLEX_TYPE_EPISODE]})), + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'episodes', + False), + ('browse', + utils.lang(39702), # "Browse by folder" + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}/folder', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'episodes', + True), + ('more', + utils.lang(22082), # "More..." + { + 'mode': 'browseplex', + 'key': '/library/sections/{self.section_id}', + 'plex_type': '{self.section_type}', + 'section_id': '{self.section_id}' + }, + 'episodes', + True), + ), +} + + +def node_pms(section, node_name, args): + """ + Nodes where the logic resides with the PMS - we're NOT building an + xml that filters and sorts, but point to PKC add-on path + """ + xml = etree.Element('node', attrib={'order': unicode(section.order), + 'type': 'folder'}) + etree.SubElement(xml, 'label').text = node_name + etree.SubElement(xml, 'icon').text = ICON_PATH + etree.SubElement(xml, 'content').text = section.content + etree.SubElement(xml, 'path').text = section.addon_path(args) + return xml + + +def node_recent(section, node_name): + xml = etree.Element('node', + attrib={'order': unicode(section.order), + 'type': 'filter'}) + etree.SubElement(xml, 'match').text = 'all' + rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag', + 'operator': 'is'}) + etree.SubElement(rule, 'value').text = section.name + etree.SubElement(xml, 'label').text = node_name + etree.SubElement(xml, 'icon').text = ICON_PATH + etree.SubElement(xml, 'content').text = section.content + etree.SubElement(xml, + 'order', + attrib={'direction': + 'descending'}).text = 'dateadded' + return xml + + +def node_all(section, node_name): + xml = etree.Element('node', attrib={'order': unicode(section.order), + 'type': 'filter'}) + etree.SubElement(xml, 'match').text = 'all' + rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag', + 'operator': 'is'}) + etree.SubElement(rule, 'value').text = section.name + etree.SubElement(xml, 'label').text = node_name + etree.SubElement(xml, 'icon').text = ICON_PATH + etree.SubElement(xml, 'content').text = section.content + etree.SubElement(xml, + 'order', + attrib={'direction': + 'ascending'}).text = 'sorttitle' + return xml + + +def node_recommended(section, node_name): + xml = etree.Element('node', attrib={'order': unicode(section.order), + 'type': 'filter'}) + etree.SubElement(xml, 'match').text = 'all' + rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag', + 'operator': 'is'}) + etree.SubElement(rule, 'value').text = section.name + # rule = etree.SubElement(xml, 'rule', attrib={'field': 'rating', + # 'operator': 'greaterthan'}) + # etree.SubElement(rule, 'value').text = unicode(RECOMMENDED_SCORE_LOWER_BOUND) + etree.SubElement(xml, 'label').text = node_name + etree.SubElement(xml, 'icon').text = ICON_PATH + etree.SubElement(xml, 'content').text = section.content + etree.SubElement(xml, + 'order', + attrib={'direction': + 'descending'}).text = 'rating' + return xml + + +def node_genres(section, node_name): + xml = etree.Element('node', attrib={'order': unicode(section.order), + 'type': 'filter'}) + etree.SubElement(xml, 'match').text = 'all' + rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag', + 'operator': 'is'}) + etree.SubElement(rule, 'value').text = section.name + etree.SubElement(xml, 'label').text = node_name + etree.SubElement(xml, 'icon').text = ICON_PATH + etree.SubElement(xml, 'content').text = section.content + etree.SubElement(xml, + 'order', + attrib={'direction': + 'ascending'}).text = 'sorttitle' + etree.SubElement(xml, 'group').text = 'genres' + return xml + + +def node_sets(section, node_name): + xml = etree.Element('node', attrib={'order': unicode(section.order), + 'type': 'filter'}) + etree.SubElement(xml, 'match').text = 'all' + rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag', + 'operator': 'is'}) + etree.SubElement(rule, 'value').text = section.name + # "Collections" + etree.SubElement(xml, 'label').text = node_name + etree.SubElement(xml, 'icon').text = ICON_PATH + etree.SubElement(xml, 'content').text = section.content + etree.SubElement(xml, + 'order', + attrib={'direction': + 'ascending'}).text = 'sorttitle' + etree.SubElement(xml, 'group').text = 'sets' + return xml + + +def node_random(section, node_name): + xml = etree.Element('node', attrib={'order': unicode(section.order), + 'type': 'filter'}) + etree.SubElement(xml, 'match').text = 'all' + rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag', + 'operator': 'is'}) + etree.SubElement(rule, 'value').text = section.name + etree.SubElement(xml, 'label').text = node_name + etree.SubElement(xml, 'icon').text = ICON_PATH + etree.SubElement(xml, 'content').text = section.content + etree.SubElement(xml, + 'order', + attrib={'direction': + 'ascending'}).text = 'random' + return xml + + +def node_lastplayed(section, node_name): + xml = etree.Element('node', attrib={'order': unicode(section.order), + 'type': 'filter'}) + etree.SubElement(xml, 'match').text = 'all' + rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag', + 'operator': 'is'}) + etree.SubElement(rule, 'value').text = section.name + rule = etree.SubElement(xml, 'rule', attrib={'field': 'playcount', + 'operator': 'greaterthan'}) + etree.SubElement(rule, 'value').text = 0 + etree.SubElement(xml, 'label').text = node_name + etree.SubElement(xml, 'icon').text = ICON_PATH + etree.SubElement(xml, 'content').text = section.content + etree.SubElement(xml, + 'order', + attrib={'direction': + 'descending'}).text = 'lastplayed' + return xml diff --git a/resources/lib/library_sync/sections.py b/resources/lib/library_sync/sections.py index 80d4ce2b..cfa3bdce 100644 --- a/resources/lib/library_sync/sections.py +++ b/resources/lib/library_sync/sections.py @@ -2,25 +2,404 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, unicode_literals from logging import getLogger +import urllib import copy -from . import videonodes -from ..utils import cast +from . import nodes from ..plex_db import PlexDB +from ..plex_api import API from .. import kodi_db -from .. import itemtypes +from .. import itemtypes, path_ops from .. import plex_functions as PF, music, utils, variables as v, app +from ..utils import etree LOG = getLogger('PLEX.sync.sections') BATCH_SIZE = 500 -VNODES = videonodes.VideoNodes() -PLAYLISTS = {} -NODES = {} SECTIONS = [] -# Need a way to interrupt +# Need a way to interrupt our synching process IS_CANCELED = None +LIBRARY_PATH = path_ops.translate_path('special://profile/library/video/') +# The video library might not yet exist for this user - create it +if not path_ops.exists(LIBRARY_PATH): + path_ops.copy_tree( + src=path_ops.translate_path('special://xbmc/system/library/video'), + dst=LIBRARY_PATH, + preserve_mode=0) # dont copy permission bits so we have write access! +PLAYLISTS_PATH = path_ops.translate_path("special://profile/playlists/video/") +if not path_ops.exists(PLAYLISTS_PATH): + path_ops.makedirs(PLAYLISTS_PATH) + +# Windows variables we set for each node +WINDOW_ARGS = ('index', 'title', 'id', 'path', 'type', 'content', 'artwork') + + +class Section(object): + """ + Setting the attribute section_type will automatically set content and + sync_to_kodi + """ + def __init__(self, index=None, xml_element=None, section_db_element=None): + # Unique Plex id of this Plex library section + self._section_id = None # int + # Building block for window variable + self._node = None # unicode + # Index of this section (as section_id might not be subsequent) + # This follows 1:1 the sequence in with the PMS returns the sections + self._index = None # Codacy-bug + self.index = index # int + # This section's name for the user to display + self.name = None # unicode + # Library type section (NOT the same as the KODI_TYPE_...) + # E.g. 'movies', 'tvshows', 'episodes' + self.content = None # unicode + # Setting the section_type WILL re_set sync_to_kodi! + self._section_type = None # unicode + # Do we sync all items of this section to the Kodi DB? + # This will be set with section_type!! + self.sync_to_kodi = None # bool + # For sections to be synched, the section name will be recorded as a + # tag. This is the corresponding id for this tag + self.kodi_tagid = None # int + # When was this section last successfully/completely synched to the + # Kodi database? + self.last_sync = None # int + # Path to the Kodi userdata library FOLDER for this section + self._path = None # unicode + # Path to the smart playlist for this section + self._playlist_path = None + # "Poster" for this section + self.icon = None # unicode + # Background image for this section + self.artwork = None + # Thumbnail for this section, similar for each section type + self.thumb = None + # Order number in which xmls will be listed inside Kodei + self.order = None + # Original PMS xml for this section, including children + self.xml = None + # Attributes that will be initialized later by full_sync.py + self.iterator = None + self.context = None + self.get_children = None + # A section_type encompasses possible several plex_types! E.g. shows + # contain shows, seasons, episodes + self.plex_type = None + if xml_element is not None: + self.from_xml(xml_element) + elif section_db_element: + self.from_db_element(section_db_element) + + def __repr__(self): + return ("{{" + "'index': {self.index}, " + "'name': '{self.name}', " + "'section_id': {self.section_id}, " + "'section_type': '{self.section_type}', " + "'sync_to_kodi': {self.sync_to_kodi}, " + "'last_sync': {self.last_sync}" + "}}").format(self=self) + __str__ = __repr__ + + def __nonzero__(self): + return (self.section_id is not None and + self.name is not None and + self.section_type is not None) + + def __eq__(self, section): + return (self.section_id == section.section_id and + self.name == section.name and + self.section_type == section.section_type) + __ne__ = not __eq__ + + @property + def section_id(self): + return self._section_id + + @section_id.setter + def section_id(self, value): + self._section_id = value + self._path = path_ops.path.join(LIBRARY_PATH, 'Plex-%s' % value, '') + self._playlist_path = path_ops.path.join(PLAYLISTS_PATH, + 'Plex %s.xsp' % value) + + @property + def section_type(self): + return self._section_type + + @section_type.setter + def section_type(self, value): + self._section_type = value + self.content = v.MEDIATYPE_FROM_PLEX_TYPE[value] + # Default values whether we sync or not based on the Plex type + if value == v.PLEX_TYPE_PHOTO: + self.sync_to_kodi = False + elif not app.SYNC.enable_music and value == v.PLEX_TYPE_ARTIST: + self.sync_to_kodi = False + else: + self.sync_to_kodi = True + + @property + def index(self): + return self._index + + @index.setter + def index(self, value): + self._index = value + self._node = 'Plex.nodes.%s' % value + + @property + def node(self): + return self._node + + @property + def path(self): + return self._path + + @property + def playlist_path(self): + return self._playlist_path + + def from_db_element(self, section_db_element): + self.section_id = section_db_element['section_id'] + self.name = section_db_element['section_name'] + self.section_type = section_db_element['plex_type'] + self.kodi_tagid = section_db_element['kodi_tagid'] + self.sync_to_kodi = section_db_element['sync_to_kodi'] + self.last_sync = section_db_element['last_sync'] + + def from_xml(self, xml_element): + """ + Reads section from a PMS xml (Plex id, name, Plex type) + """ + api = API(xml_element) + self.section_id = utils.cast(int, xml_element.get('key')) + self.name = api.title() + self.section_type = api.plex_type() + self.icon = api.one_artwork('composite') + self.artwork = api.one_artwork('art') + self.thumb = api.one_artwork('thumb') + self.xml = xml_element + + def from_plex_db(self, section_id, plexdb=None): + """ + Reads section with id section_id from the plex.db + """ + if plexdb: + section = plexdb.section(section_id) + else: + with PlexDB(lock=False) as plexdb: + section = plexdb.section(section_id) + if section: + self.from_db_element(section) + + def to_plex_db(self, plexdb=None): + """ + Writes this Section to the plex.db, potentially overwriting + (INSERT OR REPLACE) + """ + if not self: + raise RuntimeError('Section not clearly defined: %s' % self) + if plexdb: + plexdb.add_section(self.section_id, + self.name, + self.section_type, + self.kodi_tagid, + self.sync_to_kodi, + self.last_sync) + else: + with PlexDB(lock=False) as plexdb: + plexdb.add_section(self.section_id, + self.name, + self.section_type, + self.kodi_tagid, + self.sync_to_kodi, + self.last_sync) + + def addon_path(self, args): + """ + Returns the plugin path pointing back to PKC for key in order to browse + args is a dict. Its values may contain string info of the form + {key: '{self.
}'} + """ + args = copy.deepcopy(args) + for key, value in args.iteritems(): + args[key] = value.format(self=self) + return 'plugin://plugin.video.plexkodiconnect?%s' % urllib.urlencode(args) + + def to_kodi(self): + """ + Writes this section's nodes to the library folder in the Kodi userdata + directory + Won't do anything if self.sync_to_kodi is not True + """ + if self.index is None: + raise RuntimeError('Index not initialized') + # Main list entry for this section - which will show the different + # nodes as "submenus" once the user navigates into this section + args = { + 'mode': 'browseplex', + 'key': '/library/sections/%s' % self.section_id, + 'plex_type': self.section_type, + 'section_id': unicode(self.section_id) + } + if not self.sync_to_kodi: + args['synched'] = 'false' + addon_path = self.addon_path(args) + if self.sync_to_kodi and self.section_type in v.PLEX_VIDEOTYPES: + path = 'library://video/Plex-%s' % self.section_id + else: + # No xmls to link to - let's show the listings on the fly + path = addon_path + utils.window('%s.title' % self.node, value=self.name) + utils.window('%s.type' % self.node, value=self.content) + utils.window('%s.content' % self.node, value=path) + utils.window('%s.path' % self.node, + value='ActivateWindow(Videos,%s,return)' % path) + utils.window('%s.id' % self.node, value=str(self.section_id)) + # To let the user navigate into this node when selecting widgets + utils.window('%s.addon_path' % self.node, value=addon_path) + if not self.sync_to_kodi: + self.remove_files_from_kodi() + return + if self.section_type == v.PLEX_TYPE_ARTIST: + # Todo: Write window variables for music + return + if self.section_type == v.PLEX_TYPE_PHOTO: + # Todo: Write window variables for photos + return + + # Create a dedicated directory for this section + if not path_ops.exists(self.path): + path_ops.makedirs(self.path) + # Create a tag just like the section name in the Kodi DB + with kodi_db.KodiVideoDB(lock=False) as kodidb: + self.kodi_tagid = kodidb.create_tag(self.name) + # The xmls are numbered in order of appearance + self.order = 0 + if not path_ops.exists(path_ops.path.join(self.path, 'index.xml')): + LOG.debug('Creating index.xml for section %s', self.name) + xml = etree.Element('node', + attrib={'order': unicode(self.order)}) + etree.SubElement(xml, 'label').text = self.name + etree.SubElement(xml, 'icon').text = self.icon or nodes.ICON_PATH + self._write_xml(xml, 'index.xml') + self.order += 1 + # Create the one smart playlist for this section + if not path_ops.exists(self.playlist_path): + self._write_playlist() + # Now build all nodes for this section - potentially creating xmls + for node in nodes.NODE_TYPES[self.section_type]: + self._build_node(*node) + + def _build_node(self, node_type, node_name, args, content, pms_node): + self.content = content + node_name = node_name.format(self=self) + xml_name = '%s_%s.xml' % (self.section_id, node_type) + path = path_ops.path.join(self.path, xml_name) + if not path_ops.exists(path): + if pms_node: + # Even the xml will point back to the PKC add-on + xml = nodes.node_pms(self, node_name, args) + else: + # Let's use Kodi's logic to sort/filter the Kodi library + xml = getattr(nodes, 'node_%s' % node_type)(self, node_name) + self._write_xml(xml, xml_name) + self.order += 1 + self._window_node(path, node_name, node_type) + + def _write_xml(self, xml, xml_name): + LOG.debug('Creating xml for section %s: %s', self.name, xml_name) + utils.indent(xml) + etree.ElementTree(xml).write(path_ops.path.join(self.path, xml_name), + encoding='utf-8', + xml_declaration=True) + + def _write_playlist(self): + LOG.debug('Creating smart playlist for section %s: %s', + self.name, self.playlist_path) + xml = etree.Element('smartplaylist', + attrib={'type': v.MEDIATYPE_FROM_PLEX_TYPE[self.section_type]}) + etree.SubElement(xml, 'name').text = self.name + etree.SubElement(xml, 'match').text = 'all' + rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag', + 'operator': 'is'}) + etree.SubElement(rule, 'value').text = self.name + utils.indent(xml) + etree.ElementTree(xml).write(self.playlist_path, encoding='utf-8') + + def _window_node(self, path, node_name, node_type): + """ + Will save this section's node to the Kodi window variables + + Uses the same conventions/logic as Emby for Kodi does + """ + if self.section_type == v.PLEX_TYPE_ARTIST: + window_path = 'ActivateWindow(Music,%s,return)' % path + elif self.section_type == v.PLEX_TYPE_PHOTO: + # Check: elif node_type in ('browse', 'homevideos', 'photos'): + window_path = path + else: + window_path = 'ActivateWindow(Videos,%s,return)' % path + # if node_type == 'all': + # var = self.node + # utils.window('%s.index' % var, + # value=path.replace('%s_all.xml' % self.section_id, '')) + # utils.window('%s.title' % var, value=self.name) + # else: + var = '%s.%s' % (self.node, node_type) + utils.window('%s.index' % var, value=path) + utils.window('%s.title' % var, value=node_name) + utils.window('%s.id' % var, value=str(self.section_id)) + utils.window('%s.path' % var, value=window_path) + utils.window('%s.type' % var, value=self.content) + utils.window('%s.content' % var, value=path) + utils.window('%s.artwork' % var, value=self.artwork) + + def remove_files_from_kodi(self): + """ + Removes this sections from the Kodi userdata library folder (if appl.) + Also removes the smart playlist + """ + if self.section_type in (v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_PHOTO): + # No files created for these types + return + if path_ops.exists(self.path): + path_ops.rmtree(self.path, ignore_errors=True) + if path_ops.exists(self.playlist_path): + try: + path_ops.remove(self.playlist_path) + except (OSError, IOError): + LOG.warn('Could not delete smart playlist for section %s: %s', + self.name, self.playlist_path) + + def remove_window_vars(self): + """ + Removes all windows variables 'Plex.nodes..xxx' + """ + if self.index is not None: + _clear_window_vars(self.index) + + def remove_from_plex(self, plexdb=None): + """ + Removes this sections completely from the Plex DB + """ + if plexdb: + plexdb.remove_section(self.section_id) + else: + with PlexDB(lock=False) as plexdb: + plexdb.remove_section(self.section_id) + + def remove(self): + """ + Completely and utterly removes this section from Kodi and Plex DB + as well as from the window variables + """ + self.remove_files_from_kodi() + self.remove_window_vars() + self.remove_from_plex() + def force_full_sync(): """ @@ -32,193 +411,42 @@ def force_full_sync(): plexdb.force_full_sync() -def sync_from_pms(parent_self): - """ - Sync the Plex library sections - """ - global IS_CANCELED - IS_CANCELED = parent_self.isCanceled - try: - return _sync_from_pms() - finally: - IS_CANCELED = None - - -def _sync_from_pms(): - global PLAYLISTS, NODES, SECTIONS - sections = PF.get_plex_sections() - try: - sections.attrib - except AttributeError: - LOG.error("Error download PMS sections, abort") - return False - if app.SYNC.direct_paths is True and app.SYNC.enable_music is True: - # Will reboot Kodi is new library detected - music.excludefromscan_music_folders(xml=sections) - - VNODES.clearProperties() - SECTIONS = [] - NODES = { - v.PLEX_TYPE_MOVIE: [], - v.PLEX_TYPE_SHOW: [], - v.PLEX_TYPE_ARTIST: [], - v.PLEX_TYPE_PHOTO: [] - } - PLAYLISTS = copy.deepcopy(NODES) +def _save_sections_to_plex_db(sections): with PlexDB() as plexdb: - # Backup old sections to delete them later, if needed (at the end - # of this method, only unused sections will be left in old_sections) - old_sections = list(plexdb.all_sections()) - with kodi_db.KodiVideoDB() as kodidb: - for index, section in enumerate(sections): - _process_section(section, - kodidb, - plexdb, - index, - old_sections) - if old_sections: - # Section has been deleted on the PMS - delete_sections(old_sections) - # update sections for all: - with PlexDB() as plexdb: - SECTIONS = list(plexdb.all_sections()) - utils.window('Plex.nodes.total', str(len(sections))) - LOG.info("Finished processing %s library sections: %s", len(sections), SECTIONS) - if app.CONN.machine_identifier != utils.settings('sections_asked_for_machine_identifier'): - LOG.info('First time connecting to this PMS, choosing libraries') - if choose_libraries(): - with PlexDB() as plexdb: - SECTIONS = list(plexdb.all_sections()) - return True + for section in sections: + section.to_plex_db(plexdb=plexdb) -def _process_section(section_xml, kodidb, plexdb, index, old_sections): - global PLAYLISTS, NODES - folder = section_xml.attrib - plex_type = folder['type'] - # Only process supported formats - if plex_type not in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW, - v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_PHOTO): - LOG.error('Unsupported Plex section type: %s', folder) - return - section_id = cast(int, folder['key']) - section_name = folder['title'] - # Prevent duplicate for nodes of the same type - nodes = NODES[plex_type] - # Prevent duplicate for playlists of the same type - playlists = PLAYLISTS[plex_type] - # Get current media folders from plex database - section = plexdb.section(section_id) - if not section: - LOG.info('Creating section id: %s in Plex database.', section_id) - tagid = kodidb.create_tag(section_name) - # Create playlist for the video library - if (section_name not in playlists and - plex_type in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)): - utils.playlist_xsp(plex_type, section_name, section_id) - playlists.append(section_name) - # Create the video node - if section_name not in nodes: - VNODES.viewNode(index, - section_name, - plex_type, - None, - section_id) - nodes.append(section_name) - # Add view to plex database - plexdb.add_section(section_id, - section_name, - plex_type, - tagid, - True, # Sync this new section for now - None) - else: - LOG.info('Found library section id %s, name %s, type %s, tagid %s', - section_id, section['section_name'], section['plex_type'], - section['kodi_tagid']) - # Remove views that are still valid to delete rest later - for section in old_sections: - if section['section_id'] == section_id: - old_sections.remove(section) - break - # View was modified, update with latest info - if section['section_name'] != section_name: - LOG.info('section id: %s new sectionname: %s', - section_id, section_name) - tagid = kodidb.create_tag(section_name) +def _retrieve_old_settings(sections, old_sections): + """ + Overwrites the PKC settings for sections, grabing them from old_sections + if a particular section is in both sections and old_sections - # Update view with new info - plexdb.add_section(section_id, - section_name, - plex_type, - tagid, - section['sync_to_kodi'], # Use "old" setting - section['last_sync']) - - if plexdb.section_id_by_name(section['section_name']) is None: - # The tag could be a combined view. Ensure there's - # no other tags with the same name before deleting - # playlist. - utils.playlist_xsp(plex_type, - section['section_name'], - section_id, - section['plex_type'], - True) - # Delete video node - if plex_type != "musicvideos": - VNODES.viewNode( - indexnumber=index, - tagname=section['section_name'], - mediatype=plex_type, - viewtype=None, - viewid=section_id, - delete=True) - # Added new playlist - if section_name not in playlists and plex_type in v.KODI_VIDEOTYPES: - utils.playlist_xsp(plex_type, - section_name, - section_id) - playlists.append(section_name) - # Add new video node - if section_name not in nodes and plex_type != "musicvideos": - VNODES.viewNode(index, - section_name, - plex_type, - None, - section_id) - nodes.append(section_name) - # Update items with new tag - for kodi_id in plexdb.kodiid_by_sectionid(section_id, plex_type): - kodidb.update_tag( - section['kodi_tagid'], tagid, kodi_id, section['plex_type']) - else: - # Validate the playlist exists or recreate it - if (section_name not in playlists and plex_type in - (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)): - utils.playlist_xsp(plex_type, - section_name, - section_id) - playlists.append(section_name) - # Create the video node if not already exists - if section_name not in nodes and plex_type != "musicvideos": - VNODES.viewNode(index, - section_name, - plex_type, - None, - section_id) - nodes.append(section_name) + Thus sets to the old values: + section.last_sync + section.kodi_tagid + section.sync_to_kodi + section.last_sync + """ + for section in sections: + for old_section in old_sections: + if section == old_section: + section.last_sync = old_section.last_sync + section.kodi_tagid = old_section.kodi_tagid + section.sync_to_kodi = old_section.sync_to_kodi + section.last_sync = old_section.last_sync -def _delete_kodi_db_items(section_id, section_type): - if section_type == v.PLEX_TYPE_MOVIE: +def _delete_kodi_db_items(section): + if section.section_type == v.PLEX_TYPE_MOVIE: kodi_context = kodi_db.KodiVideoDB types = ((v.PLEX_TYPE_MOVIE, itemtypes.Movie), ) - elif section_type == v.PLEX_TYPE_SHOW: + elif section.section_type == v.PLEX_TYPE_SHOW: kodi_context = kodi_db.KodiVideoDB types = ((v.PLEX_TYPE_SHOW, itemtypes.Show), (v.PLEX_TYPE_SEASON, itemtypes.Season), (v.PLEX_TYPE_EPISODE, itemtypes.Episode)) - elif section_type == v.PLEX_TYPE_ARTIST: + elif section.section_type == v.PLEX_TYPE_ARTIST: kodi_context = kodi_db.KodiMusicDB types = ((v.PLEX_TYPE_ARTIST, itemtypes.Artist), (v.PLEX_TYPE_ALBUM, itemtypes.Album), @@ -226,7 +454,7 @@ def _delete_kodi_db_items(section_id, section_type): for plex_type, context in types: while True: with PlexDB() as plexdb: - plex_ids = list(plexdb.plexid_by_sectionid(section_id, + plex_ids = list(plexdb.plexid_by_sectionid(section.section_id, plex_type, BATCH_SIZE)) with kodi_context(texture_db=True) as kodidb: @@ -240,74 +468,175 @@ def _delete_kodi_db_items(section_id, section_type): return True -def delete_sections(old_sections): - """ - Deletes all elements for a Plex section that has been deleted. (e.g. all - TV shows, Seasons and Episodes of a Show section) - """ - LOG.info("Removing entire Plex library sections: %s", old_sections) - for section in old_sections: - # "Deleting " - utils.dialog('notification', - heading='{plex}', - message='%s %s' % (utils.lang(30052), section['section_name']), - icon='{plex}', - sound=False) - if section['plex_type'] == v.PLEX_TYPE_PHOTO: - # not synced - just remove the link in our Plex sections table - pass - else: - if not _delete_kodi_db_items(section['section_id'], section['plex_type']): - return - # Only remove Plex entry if we've removed all items first - with PlexDB() as plexdb: - plexdb.remove_section(section['section_id']) - - -def choose_libraries(): +def _choose_libraries(sections): """ Displays a dialog for the user to select the libraries he wants synched - Returns True if this was successful, False if not + Returns True if the user chose new sections, False if he aborted """ - # Re-set value in order to make sure we got the lastest user input - app.SYNC.enable_music = utils.settings('enableMusic') == 'true' import xbmcgui - sections = [] - preselect = [] + selectable_sections = [] + preselected = [] index = 0 - for section in SECTIONS: - if not app.SYNC.enable_music and section['plex_type'] == v.PLEX_TYPE_ARTIST: + for section in sections: + if not app.SYNC.enable_music and section.section_type == v.PLEX_TYPE_ARTIST: LOG.info('Ignoring music section: %s', section) continue - elif section['plex_type'] == v.PLEX_TYPE_PHOTO: + elif section.section_type == v.PLEX_TYPE_PHOTO: + # We won't ever show Photo sections continue else: - sections.append(section['section_name']) - if section['sync_to_kodi']: - preselect.append(index) + # Offer user the new section + selectable_sections.append(section.name) + # Sections have been either preselected by the user or they are new + if section.sync_to_kodi: + preselected.append(index) index += 1 - # "Select Plex libraries to sync" - selected = xbmcgui.Dialog().multiselect(utils.lang(30524), - sections, - preselect=preselect, - useDetails=False) - if selected is None: - # User canceled - return False - index = 0 - with PlexDB() as plexdb: - for section in SECTIONS: - if not app.SYNC.enable_music and section['plex_type'] == v.PLEX_TYPE_ARTIST: - continue - elif section['plex_type'] == v.PLEX_TYPE_PHOTO: - continue - else: - sync = True if index in selected else False - plexdb.update_section_sync(section['section_id'], sync) - index += 1 - sections = list(plexdb.all_sections()) - LOG.info('Plex libraries to sync: %s', sections) + # Don't ask the user again for this PMS even if user cancel the sync dialog utils.settings('sections_asked_for_machine_identifier', value=app.CONN.machine_identifier) + # "Select Plex libraries to sync" + selected_sections = xbmcgui.Dialog().multiselect(utils.lang(30524), + selectable_sections, + preselect=preselected, + useDetails=False) + if selectable_sections is None: + LOG.info('User chose not to select which libraries to sync') + return False + index = 0 + for section in sections: + if not app.SYNC.enable_music and section.section_type == v.PLEX_TYPE_ARTIST: + continue + elif section.section_type == v.PLEX_TYPE_PHOTO: + continue + else: + section.sync_to_kodi = index in selected_sections + index += 1 return True + + +def delete_playlists(): + """ + Clean up the playlists + """ + path = path_ops.translate_path('special://profile/playlists/video/') + for root, _, files in path_ops.walk(path): + for file in files: + if file.startswith('Plex'): + path_ops.remove(path_ops.path.join(root, file)) + + +def delete_nodes(): + """ + Clean up video nodes + """ + path = path_ops.translate_path("special://profile/library/video/") + for root, dirs, _ in path_ops.walk(path): + for directory in dirs: + if directory.startswith('Plex-'): + path_ops.rmtree(path_ops.path.join(root, directory)) + break + + +def delete_files(): + """ + Deletes both all the Plex-xxx video node xmls as well as smart playlists + """ + delete_nodes() + delete_playlists() + + +def sync_from_pms(parent_self, pick_libraries=False): + """ + Sync the Plex library sections. + pick_libraries=True will prompt the user the select the libraries he + wants to sync + """ + global IS_CANCELED + LOG.info('Starting synching sections from the PMS') + IS_CANCELED = parent_self.isCanceled + try: + return _sync_from_pms(pick_libraries) + finally: + IS_CANCELED = None + LOG.info('Done synching sections from the PMS: %s', SECTIONS) + + +def _sync_from_pms(pick_libraries): + global SECTIONS + # Re-set value in order to make sure we got the lastest user input + app.SYNC.enable_music = utils.settings('enableMusic') == 'true' + xml = PF.get_plex_sections() + if xml is None: + LOG.error("Error download PMS sections, abort") + return False + sections = [] + old_sections = [] + for i, xml_element in enumerate(xml.findall('Directory')): + sections.append(Section(index=i, xml_element=xml_element)) + with PlexDB() as plexdb: + for section_db in plexdb.all_sections(): + old_sections.append(Section(section_db_element=section_db)) + # Update our latest PMS sections with info saved in the PMS DB + _retrieve_old_settings(sections, old_sections) + if (app.CONN.machine_identifier != utils.settings('sections_asked_for_machine_identifier') or + pick_libraries): + if not pick_libraries: + LOG.info('First time connecting to this PMS, choosing libraries') + _choose_libraries(sections) + + # We got everything - save to Plex db in case Kodi restarts before we're + # done here + _save_sections_to_plex_db(sections) + # Tweak some settings so Kodi does NOT scan the music folders + if app.SYNC.direct_paths is True: + # Will reboot Kodi is new library detected + music.excludefromscan_music_folders(sections) + + # Delete all old sections that are obsolete + # This will also delete sections whose name (or type) have changed + for old_section in old_sections: + for section in sections: + if old_section == section: + break + else: + if not old_section.sync_to_kodi: + continue + LOG.info('Deleting entire section: %s', old_section) + # Remove all linked items + if not _delete_kodi_db_items(old_section): + return False + # Remove the section itself + old_section.remove() + + # Time to write the sections to Kodi + for section in sections: + section.to_kodi() + # Counter that tells us how many sections we have - e.g. for skins and + # listings + utils.window('Plex.nodes.total', str(len(sections))) + SECTIONS = sections + return True + + +def _clear_window_vars(index): + node = 'Plex.nodes.%s' % index + utils.window('%s.title' % node, clear=True) + utils.window('%s.type' % node, clear=True) + utils.window('%s.content' % node, clear=True) + utils.window('%s.path' % node, clear=True) + utils.window('%s.id' % node, clear=True) + utils.window('%s.addon_path' % node, clear=True) + for kind in WINDOW_ARGS: + node = 'Plex.nodes.%s.%s' % (index, kind) + utils.window(node, clear=True) + + +def clear_window_vars(): + """ + Removes all references to sections stored in window vars 'Plex.nodes...' + """ + number_of_nodes = int(utils.window('Plex.nodes.total') or 0) + utils.window('Plex.nodes.total', clear=True) + for index in range(number_of_nodes): + _clear_window_vars(index) diff --git a/resources/lib/library_sync/time.py b/resources/lib/library_sync/time.py index bcb4395c..197a084b 100644 --- a/resources/lib/library_sync/time.py +++ b/resources/lib/library_sync/time.py @@ -23,9 +23,7 @@ def sync_pms_time(): # Get all Plex libraries sections = PF.get_plex_sections() - try: - sections.attrib - except AttributeError: + if not sections: LOG.error("Error download PMS views, abort sync_pms_time") return False diff --git a/resources/lib/library_sync/videonodes.py b/resources/lib/library_sync/videonodes.py deleted file mode 100644 index 92ea29f8..00000000 --- a/resources/lib/library_sync/videonodes.py +++ /dev/null @@ -1,488 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -from __future__ import absolute_import, division, unicode_literals -from logging import getLogger - -from ..utils import etree -from .. import utils, path_ops, variables as v, app - -############################################################################### - -LOG = getLogger('PLEX.videonodes') - -############################################################################### - - -class VideoNodes(object): - - @staticmethod - def commonRoot(order, label, tagname, roottype=1): - - if roottype == 0: - # Index - root = etree.Element('node', {'order': "%s" % order}) - elif roottype == 1: - # Filter - root = etree.Element('node', - {'order': "%s" % order, 'type': "filter"}) - etree.SubElement(root, 'match').text = "all" - # Add tag rule - rule = etree.SubElement(root, - 'rule', - {'field': "tag", 'operator': "is"}) - etree.SubElement(rule, 'value').text = tagname - else: - # Folder - root = etree.Element('node', - {'order': "%s" % order, 'type': "folder"}) - - etree.SubElement(root, 'label').text = label - etree.SubElement(root, 'icon').text = "special://home/addons/plugin.video.plexkodiconnect/icon.png" - - return root - - def viewNode(self, indexnumber, tagname, mediatype, viewtype, viewid, - delete=False): - # Plex: reassign mediatype due to Kodi inner workings - # How many items do we get at most? - limit = unicode(app.APP.fetch_pms_item_number) - mediatypes = { - 'movie': 'movies', - 'show': 'tvshows', - 'photo': 'photos', - 'homevideo': 'homevideos', - 'musicvideos': 'musicvideos', - 'artist': 'albums' - } - mediatype = mediatypes[mediatype] - - if viewtype == "mixed": - dirname = "%s-%s" % (viewid, mediatype) - else: - dirname = viewid - - # Returns strings - path = path_ops.translate_path('special://profile/library/video/') - nodepath = path_ops.translate_path( - 'special://profile/library/video/Plex-%s/' % dirname) - - if delete: - if path_ops.exists(nodepath): - path_ops.rmtree(nodepath) - LOG.info("Sucessfully removed videonode: %s." % tagname) - return - - # Verify the video directory - if not path_ops.exists(path): - path_ops.copy_tree( - src=path_ops.translate_path( - 'special://xbmc/system/library/video'), - dst=path_ops.translate_path('special://profile/library/video'), - preserve_mode=0) # do not copy permission bits! - - # Create the node directory - if mediatype != "photos": - if not path_ops.exists(nodepath): - # folder does not exist yet - LOG.debug('Creating folder %s' % nodepath) - path_ops.makedirs(nodepath) - - # Create index entry - nodeXML = "%sindex.xml" % nodepath - # Set windows property - path = "library://video/Plex-%s/" % dirname - for i in range(1, indexnumber): - # Verify to make sure we don't create duplicates - if utils.window('Plex.nodes.%s.index' % i) == path: - return - - if mediatype == "photos": - path = "plugin://plugin.video.plexkodiconnect?mode=browseplex&key=/library/sections/%s&id=%s" % (viewid, viewid) - - utils.window('Plex.nodes.%s.index' % indexnumber, value=path) - - # Root - if not mediatype == "photos": - if viewtype == "mixed": - specialtag = "%s-%s" % (tagname, mediatype) - root = self.commonRoot(order=0, - label=specialtag, - tagname=tagname, - roottype=0) - else: - root = self.commonRoot(order=0, - label=tagname, - tagname=tagname, - roottype=0) - utils.indent(root) - etree.ElementTree(root).write(nodeXML, encoding="UTF-8") - - nodetypes = { - '1': "all", - '2': "recent", - '3': "recentepisodes", - '4': "inprogress", - '5': "inprogressepisodes", - '6': "unwatched", - '7': "nextepisodes", - '8': "sets", - '9': "genres", - '10': "random", - '11': "recommended", - '12': "ondeck", - '13': 'browsefiles', - } - mediatypes = { - # label according to nodetype per mediatype - 'movies': - { - '1': tagname, - '2': 30174, - # '4': 30177, - # '6': 30189, - '8': 39501, - '9': 135, - '10': 30227, - '11': 30230, - '12': 39500, - '13': 39702, - }, - - 'tvshows': - { - '1': tagname, - # '2': 30170, - '3': 30174, - # '4': 30171, - # '5': 30178, - # '7': 30179, - '9': 135, - '10': 30227, - # '11': 30230, - '12': 39500, - '13': 39702, - }, - - 'homevideos': - { - '1': tagname, - '2': 30251, - '11': 30253, - '13': 39702, - '14': 136 - }, - - 'photos': - { - '1': tagname, - '2': 30252, - '8': 30255, - '11': 30254, - '13': 39702 - }, - - 'musicvideos': - { - '1': tagname, - '2': 30256, - '4': 30257, - '6': 30258, - '13': 39702 - }, - - 'albums': - { - '1': tagname, - '2': 517, # Recently played albums - '2': 359, # Recently added albums - '13': 39702, # browse by folder - } - } - - # Key: nodetypes, value: sort order in Kodi - sortorder = { - '1': '3', # "all", - '2': '2', # "recent", - '3': '2', # "recentepisodes", - # '4': # "inprogress", - # '5': # "inprogressepisodes", - # '6': # "unwatched", - # '7': # "nextepisodes", - '8': '7', # "sets", - '9': '6', # "genres", - '10': '8', # "random", - '11': '5', # "recommended", - '12': '1', # "ondeck" - '13': '9', # browse by folder - } - - nodes = mediatypes[mediatype] - for node in nodes: - - nodetype = nodetypes[node] - nodeXML = "%s%s_%s.xml" % (nodepath, viewid, nodetype) - # Get label - stringid = nodes[node] - if node != "1": - label = utils.lang(stringid) - else: - label = stringid - - # Set window properties - if ((mediatype == "homevideos" or mediatype == "photos") and - nodetype == "all"): - # Custom query - path = ("plugin://plugin.video.plexkodiconnect/?id=%s&mode=browseplex&type=%s" - % (viewid, mediatype)) - elif (mediatype == "homevideos" or mediatype == "photos"): - # Custom query - path = ("plugin://plugin.video.plexkodiconnect/?id=%s&mode=browseplex&type=%s&folderid=%s" - % (viewid, mediatype, nodetype)) - elif nodetype == "nextepisodes": - # Custom query - path = "plugin://plugin.video.plexkodiconnect/?id=%s&mode=nextup&limit=%s" % (tagname, limit) - # elif v.KODIVERSION == 14 and nodetype == "recentepisodes": - elif nodetype == "recentepisodes": - # Custom query - path = ("plugin://plugin.video.plexkodiconnect/?id=%s&mode=recentepisodes&type=%s&tagname=%s&limit=%s" - % (viewid, mediatype, tagname, limit)) - elif v.KODIVERSION == 14 and nodetype == "inprogressepisodes": - # Custom query - path = "plugin://plugin.video.plexkodiconnect/?id=%s&mode=inprogressepisodes&limit=%s" % (tagname, limit) - elif nodetype == 'ondeck': - # PLEX custom query - if mediatype == "tvshows": - path = ("plugin://plugin.video.plexkodiconnect/?id=%s&mode=ondeck&type=%s&tagname=%s&limit=%s" - % (viewid, mediatype, tagname, limit)) - elif mediatype =="movies": - # Reset nodetype; we got the label - nodetype = 'inprogress' - elif nodetype == 'browsefiles': - path = 'plugin://plugin.video.plexkodiconnect?mode=browseplex&key=/library/sections/%s/folder' % viewid - else: - path = "library://video/Plex-%s/%s_%s.xml" % (dirname, viewid, nodetype) - - if mediatype == "photos": - windowpath = "ActivateWindow(Pictures,%s,return)" % path - else: - if v.KODIVERSION >= 17: - # Krypton - windowpath = "ActivateWindow(Videos,%s,return)" % path - else: - windowpath = "ActivateWindow(Video,%s,return)" % path - - if nodetype == "all": - - if viewtype == "mixed": - templabel = "%s-%s" % (tagname, mediatype) - else: - templabel = label - - embynode = "Plex.nodes.%s" % indexnumber - utils.window('%s.title' % embynode, value=templabel) - utils.window('%s.path' % embynode, value=windowpath) - utils.window('%s.content' % embynode, value=path) - utils.window('%s.type' % embynode, value=mediatype) - else: - embynode = "Plex.nodes.%s.%s" % (indexnumber, nodetype) - utils.window('%s.title' % embynode, value=label) - utils.window('%s.path' % embynode, value=windowpath) - utils.window('%s.content' % embynode, value=path) - - if mediatype == "photos": - # For photos, we do not create a node in videos but we do want - # the window props to be created. To do: add our photos nodes to - # kodi picture sources somehow - continue - - if path_ops.exists(nodeXML): - # Don't recreate xml if already exists - continue - - # Create the root - if (nodetype in ("nextepisodes", - "ondeck", - 'recentepisodes', - 'browsefiles') or mediatype == "homevideos"): - # Folder type with plugin path - root = self.commonRoot(order=sortorder[node], - label=label, - tagname=tagname, - roottype=2) - else: - root = self.commonRoot(order=sortorder[node], - label=label, - tagname=tagname) - # Set the content type - if mediatype == 'tvshows' and nodetype != 'all': - etree.SubElement(root, 'content').text = 'episodes' - else: - etree.SubElement(root, 'content').text = mediatype - # Now fill the view - if (nodetype in ("nextepisodes", - "ondeck", - 'recentepisodes', - 'browsefiles') or mediatype == "homevideos"): - etree.SubElement(root, 'path').text = path - else: - # Elements per nodetype - if nodetype == "all": - etree.SubElement(root, - 'order', - {'direction': "ascending"}).text = "sorttitle" - elif nodetype == "recent": - etree.SubElement(root, - 'order', - {'direction': "descending"}).text = "dateadded" - etree.SubElement(root, 'limit').text = limit - if utils.settings('MovieShowWatched') == 'false': - rule = etree.SubElement(root, - 'rule', - {'field': "playcount", - 'operator': "is"}) - etree.SubElement(rule, 'value').text = "0" - elif nodetype == "inprogress": - etree.SubElement(root, - 'rule', - {'field': "inprogress", 'operator': "true"}) - etree.SubElement(root, 'limit').text = limit - etree.SubElement( - root, - 'order', - {'direction': 'descending'} - ).text = 'lastplayed' - - elif nodetype == "genres": - etree.SubElement(root, - 'order', - {'direction': "ascending"}).text = "sorttitle" - etree.SubElement(root, 'group').text = "genres" - elif nodetype == "unwatched": - etree.SubElement(root, - 'order', - {'direction': "ascending"}).text = "sorttitle" - rule = etree.SubElement(root, - "rule", - {'field': "playcount", 'operator': "is"}) - etree.SubElement(rule, 'value').text = "0" - elif nodetype == "sets": - etree.SubElement(root, - 'order', - {'direction': "ascending"}).text = "sorttitle" - etree.SubElement(root, 'group').text = "tags" - elif nodetype == "random": - etree.SubElement(root, - 'order', - {'direction': "ascending"}).text = "random" - etree.SubElement(root, 'limit').text = limit - elif nodetype == "recommended": - etree.SubElement(root, - 'order', - {'direction': "descending"}).text = "rating" - etree.SubElement(root, 'limit').text = limit - rule = etree.SubElement(root, - 'rule', - {'field': "playcount", 'operator': "is"}) - etree.SubElement(rule, 'value').text = "0" - rule2 = etree.SubElement(root, - 'rule', - {'field': "rating", 'operator': "greaterthan"}) - etree.SubElement(rule2, 'value').text = "7" - elif nodetype == "recentepisodes": - # Kodi Isengard, Jarvis - etree.SubElement(root, - 'order', - {'direction': "descending"}).text = "dateadded" - etree.SubElement(root, 'limit').text = limit - rule = etree.SubElement(root, - 'rule', - {'field': "playcount", 'operator': "is"}) - etree.SubElement(rule, 'value').text = "0" - elif nodetype == "inprogressepisodes": - # Kodi Isengard, Jarvis - etree.SubElement(root, 'limit').text = limit - rule = etree.SubElement(root, - 'rule', - {'field': "inprogress", 'operator':"true"}) - utils.indent(root) - etree.ElementTree(root).write(path_ops.encode_path(nodeXML), - encoding="UTF-8") - - def singleNode(self, indexnumber, tagname, mediatype, itemtype): - cleantagname = utils.normalize_nodes(tagname) - nodepath = path_ops.translate_path('special://profile/library/video/') - nodeXML = "%splex_%s.xml" % (nodepath, cleantagname) - path = "library://video/plex_%s.xml" % cleantagname - if v.KODIVERSION >= 17: - # Krypton - windowpath = "ActivateWindow(Videos,%s,return)" % path - else: - windowpath = "ActivateWindow(Video,%s,return)" % path - - # Create the video node directory - if not path_ops.exists(nodepath): - # We need to copy over the default items - path_ops.copy_tree( - src=path_ops.translate_path( - 'special://xbmc/system/library/video'), - dst=path_ops.translate_path('special://profile/library/video'), - preserve_mode=0) # do not copy permission bits! - - labels = { - 'Favorite movies': 30180, - 'Favorite tvshows': 30181, - 'channels': 30173 - } - label = utils.lang(labels[tagname]) - embynode = "Plex.nodes.%s" % indexnumber - utils.window('%s.title' % embynode, value=label) - utils.window('%s.path' % embynode, value=windowpath) - utils.window('%s.content' % embynode, value=path) - utils.window('%s.type' % embynode, value=itemtype) - - if path_ops.exists(nodeXML): - # Don't recreate xml if already exists - return - - if itemtype == "channels": - root = self.commonRoot(order=1, - label=label, - tagname=tagname, - roottype=2) - etree.SubElement(root, - 'path').text = "plugin://plugin.video.plexkodiconnect/?id=0&mode=channels" - else: - root = self.commonRoot(order=1, label=label, tagname=tagname) - etree.SubElement(root, - 'order', - {'direction': "ascending"}).text = "sorttitle" - - etree.SubElement(root, 'content').text = mediatype - - utils.indent(root) - etree.ElementTree(root).write(nodeXML, encoding="UTF-8") - - @staticmethod - def clearProperties(): - - LOG.info("Clearing nodes properties.") - plexprops = utils.window('Plex.nodes.total') - propnames = [ - "index","path","title","content", - "inprogress.content","inprogress.title", - "inprogress.content","inprogress.path", - "nextepisodes.title","nextepisodes.content", - "nextepisodes.path","unwatched.title", - "unwatched.content","unwatched.path", - "recent.title","recent.content","recent.path", - "recentepisodes.title","recentepisodes.content", - "recentepisodes.path", "inprogressepisodes.title", - "inprogressepisodes.content","inprogressepisodes.path" - ] - - if plexprops: - totalnodes = int(plexprops) - for i in range(totalnodes): - for prop in propnames: - utils.window('Plex.nodes.%s.%s' % (str(i), prop), - clear=True) diff --git a/resources/lib/music.py b/resources/lib/music.py index a8827064..45b57001 100644 --- a/resources/lib/music.py +++ b/resources/lib/music.py @@ -3,8 +3,8 @@ from __future__ import absolute_import, division, unicode_literals from logging import getLogger -from . import utils from .plex_api import API +from . import utils from . import variables as v ############################################################################### @@ -12,7 +12,7 @@ LOG = getLogger('PLEX.music') ############################################################################### -def excludefromscan_music_folders(xml): +def excludefromscan_music_folders(sections): """ Gets a complete list of paths for music libraries from the PMS. Sets them to be excluded in the advancedsettings.xml from being scanned by Kodi. @@ -24,16 +24,17 @@ def excludefromscan_music_folders(xml): paths = [] reboot = False api = API(item=None) - for library in xml: - if library.attrib['type'] != v.PLEX_TYPE_ARTIST: + for section in sections: + if section.plex_type != v.PLEX_TYPE_ARTIST: # Only look at music libraries continue - for location in library: - if location.tag == 'Location': - path = api.validate_playurl(location.attrib['path'], - typus=v.PLEX_TYPE_ARTIST, - omit_check=True) - paths.append(__turn_to_regex(path)) + if not section.sync_to_kodi: + continue + for location in section.xml.findall('Location'): + path = api.validate_playurl(location.attrib['path'], + typus=v.PLEX_TYPE_ARTIST, + omit_check=True) + paths.append(_turn_to_regex(path)) try: with utils.XmlKodiSetting( 'advancedsettings.xml', @@ -73,7 +74,7 @@ def excludefromscan_music_folders(xml): utils.reboot_kodi(utils.lang(39711)) -def __turn_to_regex(path): +def _turn_to_regex(path): """ Turns a path into regex expression to be fed to Kodi's advancedsettings.xml """ diff --git a/resources/lib/path_ops.py b/resources/lib/path_ops.py index 481cb9e8..9ec23687 100644 --- a/resources/lib/path_ops.py +++ b/resources/lib/path_ops.py @@ -59,7 +59,10 @@ def translate_path(path): def exists(path): - """Returns True if the path [unicode] exists""" + """ + Returns True if the path [unicode] exists. Folders NEED a trailing slash or + backslash!! + """ return xbmcvfs.exists(path.encode(KODI_ENCODING, 'strict')) == 1 diff --git a/resources/lib/plex_api.py b/resources/lib/plex_api.py index f3e86c76..370a62ba 100644 --- a/resources/lib/plex_api.py +++ b/resources/lib/plex_api.py @@ -35,6 +35,7 @@ from logging import getLogger from re import sub from urllib import urlencode, unquote, quote from urlparse import parse_qsl + from xbmcgui import ListItem from .plex_db import PlexDB @@ -149,6 +150,30 @@ class API(object): omit_check=True) return path + def directory_path(self, section_id=None, plex_type=None, old_key=None, + synched=True): + key = self.item.get('fastKey') + if not key: + key = self.item.get('key') + if old_key: + key = '%s/%s' % (old_key, key) + elif not key.startswith('/'): + key = '/library/sections/%s/%s' % (section_id, key) + params = { + 'mode': 'browseplex', + 'key': key, + 'plex_type': plex_type or self.plex_type() + } + if not synched: + # No item to be found in the Kodi DB + params['synched'] = 'false' + if self.item.get('prompt'): + # User input needed, e.g. search for a movie or episode + params['prompt'] = self.item.get('prompt') + if section_id: + params['id'] = section_id + return 'plugin://%s/?%s' % (v.ADDON_ID, urlencode(params)) + def path_and_plex_id(self): """ Returns the Plex key such as '/library/metadata/246922' or None @@ -335,6 +360,26 @@ class API(object): 'UserRating': userrating } + def leave_count(self): + """ + Returns the following dict or None + { + 'totalepisodes': unicode('leafCount'), + 'watchedepisodes': unicode('viewedLeafCount'), + 'unwatchedepisodes': unicode(totalepisodes - watchedepisodes) + } + """ + try: + total = int(self.item.attrib['leafCount']) + watched = int(self.item.attrib['viewedLeafCount']) + return { + 'totalepisodes': unicode(total), + 'watchedepisodes': unicode(watched), + 'unwatchedepisodes': unicode(total - watched) + } + except (KeyError, TypeError): + pass + def collection_list(self): """ Returns a list of tuples of the collection id and tags or an empty list @@ -522,7 +567,7 @@ class API(object): def resume_point(self): """ - Returns the resume point of time in seconds as int. 0 if not found + Returns the resume point of time in seconds as float. 0.0 if not found """ try: resume = float(self.item.attrib['viewOffset']) @@ -588,7 +633,7 @@ class API(object): def premiere_date(self): """ - Returns the "originallyAvailableAt" or None + Returns the "originallyAvailableAt", e.g. "2018-11-16" or None """ return self.item.get('originallyAvailableAt') @@ -859,7 +904,20 @@ class API(object): 'subtitle': subtitlelanguages } - def one_artwork(self, art_kind): + def one_artwork(self, art_kind, aspect=None): + """ + aspect can be: 'square', '16:9', 'poster'. Defaults to 'poster' + """ + aspect = 'poster' if not aspect else aspect + if aspect == 'poster': + width = 1000 + height = 1500 + elif aspect == '16:9': + width = 1920 + height = 1080 + elif aspect == 'square': + width = 1000 + height = 1000 artwork = self.item.get(art_kind) if artwork and not artwork.startswith('http'): if '/composite/' in artwork: @@ -870,27 +928,48 @@ class API(object): args = dict(parse_qsl(args)) width = int(args.get('width', 400)) height = int(args.get('height', 400)) - # Adjust to 4k resolution 3,840x2,160 - scaling = 3840.0 / float(max(width, height)) + # Adjust to 4k resolution 1920x1080 + scaling = 1920.0 / float(max(width, height)) width = int(scaling * width) height = int(scaling * height) except ValueError: # e.g. playlists - width = 3840 - height = 3840 + pass artwork = '%s?width=%s&height=%s' % (artwork, width, height) - artwork = ('%s/photo/:/transcode?width=3840&height=3840&' + artwork = ('%s/photo/:/transcode?width=1920&height=1920&' 'minSize=1&upscale=0&url=%s' % (app.CONN.server, quote(artwork))) artwork = self.attach_plex_token_to_url(artwork) return artwork + def artwork_episode(self, full_artwork): + """ + Episodes are special, they only get the thumb, because all the other + artwork will be saved under season and show EXCEPT if you're + constructing a listitem and the item has NOT been synched to the Kodi db + """ + artworks = {} + # Item is currently NOT in the Kodi DB + art = self.one_artwork('thumb') + if art: + artworks['thumb'] = art + if not full_artwork: + # For episodes, only get the thumb. Everything else stemms from + # either the season or the show + return artworks + for kodi_artwork, plex_artwork in \ + v.KODI_TO_PLEX_ARTWORK_EPISODE.iteritems(): + art = self.one_artwork(plex_artwork) + if art: + artworks[kodi_artwork] = art + return artworks + def artwork(self, kodi_id=None, kodi_type=None, full_artwork=False): """ Gets the URLs to the Plex artwork. Dict keys will be missing if there is no corresponding artwork. Pass kodi_id and kodi_type to grab the artwork saved in the Kodi DB - (thus potentially more artwork, e.g. clearart, discart) + (thus potentially more artwork, e.g. clearart, discart). Output ('max' version) { @@ -905,44 +984,9 @@ class API(object): Passing full_artwork=True returns ALL the artwork for the item, so not just 'thumb' for episodes, but also season and show artwork """ - artworks = {} if self.plex_type() == v.PLEX_TYPE_EPISODE: - # Artwork lookup for episodes is broken for addon paths - # Episodes is a bit special, only get the thumb, because all - # the other artwork will be saved under season and show - # EXCEPT if you're constructing a listitem - if not full_artwork: - art = self.one_artwork('thumb') - if art: - artworks['thumb'] = art - return artworks - for kodi_artwork, plex_artwork in \ - v.KODI_TO_PLEX_ARTWORK_EPISODE.iteritems(): - art = self.one_artwork(plex_artwork) - if art: - artworks[kodi_artwork] = art - if not full_artwork: - return artworks - with PlexDB(lock=False) as plexdb: - db_item = plexdb.item_by_id(self.plex_id(), - v.PLEX_TYPE_EPISODE) - if db_item: - season_id = db_item['parent_id'] - show_id = db_item['grandparent_id'] - else: - return artworks - # Grab artwork from the season - with KodiVideoDB(lock=False) as kodidb: - season_art = kodidb.get_art(season_id, v.KODI_TYPE_SEASON) - for kodi_art in season_art: - artworks['season.%s' % kodi_art] = season_art[kodi_art] - # Grab more artwork from the show - with KodiVideoDB(lock=False) as kodidb: - show_art = kodidb.get_art(show_id, v.KODI_TYPE_SHOW) - for kodi_art in show_art: - artworks['tvshow.%s' % kodi_art] = show_art[kodi_art] - return artworks - + return self.artwork_episode(full_artwork) + artworks = {} if kodi_id: # in Kodi database, potentially with additional e.g. clearart if self.plex_type() in v.PLEX_VIDEOTYPES: @@ -952,9 +996,6 @@ class API(object): with KodiMusicDB(lock=False) as kodidb: return kodidb.get_art(kodi_id, kodi_type) - # Grab artwork from Plex - # if self.plex_type() == v.PLEX_TYPE_EPISODE: - for kodi_artwork, plex_artwork in v.KODI_TO_PLEX_ARTWORK.iteritems(): art = self.one_artwork(plex_artwork) if art: @@ -976,6 +1017,10 @@ class API(object): art = self.one_artwork('thumb') if art: artworks['thumb'] = art + if self.plex_type() == v.PLEX_TYPE_PLAYLIST: + art = self.one_artwork('composite') + if art: + artworks['thumb'] = art return artworks def fanart_artwork(self, artworks): @@ -1649,6 +1694,96 @@ class API(object): pass return listitem + def _create_folder_listitem(self, listitem=None): + """ + Use for video items only + Call on a child level of PMS xml response (e.g. in a for loop) + + listitem : existing xbmcgui.ListItem to work with + otherwise, a new one is created + append_show_title : True to append TV show title to episode title + append_sxxexx : True to append SxxExx to episode title + + Returns XBMC listitem for this PMS library item + """ + title = self.title() + typus = self.plex_type() + + if listitem is None: + listitem = ListItem(title) + else: + listitem.setLabel(title) + # Necessary; Kodi won't start video otherwise! + listitem.setProperty('IsPlayable', 'true') + # Video items, e.g. movies and episodes or clips + people = self.people() + userdata = self.userdata() + metadata = { + 'genre': self.genre_list(), + 'country': self.country_list(), + 'year': self.year(), + 'rating': self.audience_rating(), + 'playcount': userdata['PlayCount'], + 'cast': people['Cast'], + 'director': people['Director'], + 'plot': self.plot(), + 'sorttitle': self.sorttitle(), + 'duration': userdata['Runtime'], + 'studio': self.music_studio_list(), + 'tagline': self.tagline(), + 'writer': people.get('Writer'), + 'premiered': self.premiere_date(), + 'dateadded': self.date_created(), + 'lastplayed': userdata['LastPlayedDate'], + 'mpaa': self.content_rating(), + 'aired': self.premiere_date(), + } + # Do NOT set resumetime - otherwise Kodi always resumes at that time + # even if the user chose to start element from the beginning + # listitem.setProperty('resumetime', str(userdata['Resume'])) + listitem.setProperty('totaltime', str(userdata['Runtime'])) + + if typus == v.PLEX_TYPE_EPISODE: + metadata['mediatype'] = 'episode' + _, _, show, season, episode = self.episode_data() + season = -1 if season is None else int(season) + episode = -1 if episode is None else int(episode) + metadata['episode'] = episode + metadata['sortepisode'] = episode + metadata['season'] = season + metadata['sortseason'] = season + metadata['tvshowtitle'] = show + if season and episode: + if append_sxxexx is True: + title = "S%.2dE%.2d - %s" % (season, episode, title) + if append_show_title is True: + title = "%s - %s " % (show, title) + if append_show_title or append_sxxexx: + listitem.setLabel(title) + elif typus == v.PLEX_TYPE_MOVIE: + metadata['mediatype'] = 'movie' + else: + # E.g. clips, trailers, ... + pass + + plex_id = self.plex_id() + listitem.setProperty('plexid', str(plex_id)) + with PlexDB() as plexdb: + db_item = plexdb.item_by_id(plex_id, self.plex_type()) + if db_item: + metadata['dbid'] = db_item['kodi_id'] + metadata['title'] = title + # Expensive operation + listitem.setInfo('video', infoLabels=metadata) + try: + # Add context menu entry for information screen + listitem.addContextMenuItems([(utils.lang(30032), + 'XBMC.Action(Info)',)]) + except TypeError: + # Kodi fuck-up + pass + return listitem + def disc_number(self): """ Returns the song's disc number as an int or None if not found diff --git a/resources/lib/plex_db/common.py b/resources/lib/plex_db/common.py index 21e391f1..65f5ca6d 100644 --- a/resources/lib/plex_db/common.py +++ b/resources/lib/plex_db/common.py @@ -74,7 +74,10 @@ class PlexDBBase(object): answ = self.album(plex_id) elif plex_type == v.PLEX_TYPE_ARTIST: answ = self.artist(plex_id) - else: + elif plex_type in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_PHOTO, v.PLEX_TYPE_PLAYLIST): + # Will never be synched to Kodi + pass + elif plex_type is None: # SLOW - lookup plex_id in all our tables for kind in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_EPISODE, diff --git a/resources/lib/plex_functions.py b/resources/lib/plex_functions.py index 41f5427f..c4dcdb45 100644 --- a/resources/lib/plex_functions.py +++ b/resources/lib/plex_functions.py @@ -819,7 +819,12 @@ def get_plex_sections(): """ Returns all Plex sections (libraries) of the PMS as an etree xml """ - return DU().downloadUrl('{server}/library/sections') + xml = DU().downloadUrl('{server}/library/sections') + try: + xml[0].attrib + except (TypeError, IndexError, AttributeError): + xml = None + return xml def init_plex_playqueue(plex_id, librarySectionUUID, mediatype='movie', diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index 7169d0c0..90e8616d 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -86,9 +86,6 @@ class Service(object): for prop in WINDOW_PROPERTIES: utils.window(prop, clear=True) - # Load/Reset PKC entirely - important for user/Kodi profile switch - # Clear video nodes properties - library_sync.VideoNodes().clearProperties() clientinfo.getDeviceId() # Init time-offset between Kodi and Plex timing.KODI_PLEX_TIME_OFFSET = float(utils.settings('kodiplextimeoffset') or 0.0) @@ -212,10 +209,8 @@ class Service(object): def switch_plex_user(self): self.log_out() - # First remove playlists of old user - utils.delete_playlists() - # Remove video nodes - utils.delete_nodes() + # First remove playlists and video nodes of old user + library_sync.delete_files() app.ACCOUNT.set_unauthenticated() # Force full sync after login library_sync.force_full_sync() @@ -300,9 +295,7 @@ class Service(object): from .library_sync import sections try: # Get newest sections from the PMS - if not sections.sync_from_pms(self): - return - if not sections.choose_libraries(): + if not sections.sync_from_pms(self, pick_libraries=True): return # Force a full sync app.SYNC.run_lib_scan = 'full' @@ -508,6 +501,9 @@ class Service(object): # Tell all threads to terminate (e.g. several lib sync threads) LOG.debug('Aborting all threads') app.APP.stop_pkc = True + # Load/Reset PKC entirely - important for user/Kodi profile switch + # Clear video nodes properties + library_sync.clear_window_vars() # Will block until threads have quit app.APP.stop_threads() utils.window('plex_service_started', clear=True) diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 2fa3e7f7..a54997bd 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -17,6 +17,7 @@ from functools import wraps import hashlib import re import gc + import xbmc import xbmcaddon import xbmcgui @@ -429,11 +430,10 @@ def wipe_database(): Will also delete all cached artwork. """ LOG.warn('Start wiping') - # Clean up the playlists - delete_playlists() - # Clean up the video nodes - delete_nodes() + from .library_sync.sections import delete_files from . import kodi_db, plex_db + # Clean up the playlists and video nodes + delete_files() # First get the paths to all synced playlists playlist_paths = [] try: @@ -814,76 +814,6 @@ class XmlKodiSetting(object): return element -def playlist_xsp(mediatype, tagname, viewid, viewtype="", delete=False): - """ - Feed with tagname as unicode - """ - path = path_ops.translate_path("special://profile/playlists/video/") - if viewtype == "mixed": - plname = "%s - %s" % (tagname, mediatype) - xsppath = "%sPlex %s - %s.xsp" % (path, viewid, mediatype) - else: - plname = tagname - xsppath = "%sPlex %s.xsp" % (path, viewid) - - # Create the playlist directory - if not path_ops.exists(path): - LOG.info("Creating directory: %s", path) - path_ops.makedirs(path) - - # Only add the playlist if it doesn't already exists - if path_ops.exists(xsppath): - LOG.info('Path %s does exist', xsppath) - if delete: - path_ops.remove(xsppath) - LOG.info("Successfully removed playlist: %s.", tagname) - return - - # Using write process since there's no guarantee the xml declaration works - # with etree - kinds = { - 'homevideos': 'movies', - 'movie': 'movies', - 'show': 'tvshows' - } - LOG.info("Writing playlist file to: %s", xsppath) - with open(path_ops.encode_path(xsppath), 'wb') as filer: - filer.write(try_encode( - '\n' - '\n\t' - 'Plex %s\n\t' - 'all\n\t' - '\n\t\t' - '%s\n\t' - '\n' - '\n' - % (kinds.get(mediatype, mediatype), plname, tagname))) - LOG.info("Successfully added playlist: %s", tagname) - - -def delete_playlists(): - """ - Clean up the playlists - """ - path = path_ops.translate_path('special://profile/playlists/video/') - for root, _, files in path_ops.walk(path): - for file in files: - if file.startswith('Plex'): - path_ops.remove(path_ops.path.join(root, file)) - - -def delete_nodes(): - """ - Clean up video nodes - """ - path = path_ops.translate_path("special://profile/library/video/") - for root, dirs, _ in path_ops.walk(path): - for directory in dirs: - if directory.startswith('Plex-'): - path_ops.rmtree(path_ops.path.join(root, directory)) - break - - def generate_file_md5(path): """ Generates the md5 hash value for the file located at path [unicode]. diff --git a/resources/lib/variables.py b/resources/lib/variables.py index e61ffc87..46b808fb 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -179,6 +179,9 @@ PLEX_TYPE_MUSICVIDEO = 'musicvideo' PLEX_TYPE_PHOTO = 'photo' +PLEX_TYPE_PLAYLIST = 'playlist' +PLEX_TYPE_CHANNEL = 'channel' + # Used for /:/timeline XML messages PLEX_PLAYLIST_TYPE_VIDEO = 'video' PLEX_PLAYLIST_TYPE_AUDIO = 'music' @@ -212,6 +215,8 @@ KODI_TYPE_MUSICVIDEO = 'musicvideo' KODI_TYPE_PHOTO = 'photo' +KODI_TYPE_PLAYLIST = 'playlist' + KODI_VIDEOTYPES = ( KODI_TYPE_VIDEO, KODI_TYPE_MOVIE, @@ -251,7 +256,12 @@ ADDON_TYPE = { PLEX_TYPE_MOVIE: 'plugin.video.plexkodiconnect.movies', PLEX_TYPE_CLIP: 'plugin.video.plexkodiconnect.movies', PLEX_TYPE_EPISODE: 'plugin.video.plexkodiconnect.tvshows', - PLEX_TYPE_SONG: 'plugin.video.plexkodiconnect' + PLEX_TYPE_SEASON: 'plugin.video.plexkodiconnect.tvshows', + PLEX_TYPE_SHOW: 'plugin.video.plexkodiconnect.tvshows', + PLEX_TYPE_SONG: 'plugin.video.plexkodiconnect', + PLEX_TYPE_ALBUM: 'plugin.video.plexkodiconnect', + PLEX_TYPE_ARTIST: 'plugin.video.plexkodiconnect', + PLEX_TYPE_PLAYLIST: 'plugin.video.plexkodiconnect' } ITEMTYPE_FROM_PLEXTYPE = { @@ -276,6 +286,7 @@ ITEMTYPE_FROM_KODITYPE = { KODITYPE_FROM_PLEXTYPE = { PLEX_TYPE_MOVIE: KODI_TYPE_MOVIE, + PLEX_TYPE_CLIP: KODI_TYPE_CLIP, PLEX_TYPE_EPISODE: KODI_TYPE_EPISODE, PLEX_TYPE_SEASON: KODI_TYPE_SEASON, PLEX_TYPE_SHOW: KODI_TYPE_SHOW, @@ -284,7 +295,8 @@ KODITYPE_FROM_PLEXTYPE = { PLEX_TYPE_ALBUM: KODI_TYPE_ALBUM, PLEX_TYPE_PHOTO: KODI_TYPE_PHOTO, 'XXXXXX': 'musicvideo', - 'XXXXXXX': 'genre' + 'XXXXXXX': 'genre', + PLEX_TYPE_PLAYLIST: KODI_TYPE_PLAYLIST } PLEX_TYPE_FROM_KODI_TYPE = { @@ -316,7 +328,6 @@ KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE = { PLEX_TYPE_PHOTO: KODI_TYPE_PHOTO } - KODI_PLAYLIST_TYPE_FROM_KODI_TYPE = { KODI_TYPE_VIDEO: KODI_TYPE_VIDEO, KODI_TYPE_MOVIE: KODI_TYPE_VIDEO, @@ -344,6 +355,22 @@ REMAP_TYPE_FROM_PLEXTYPE = { } +ICON_FROM_PLEXTYPE = { + PLEX_TYPE_VIDEO: 'DefaultAddonAlbumInfo.png', + PLEX_TYPE_MOVIE: 'DefaultMovies.png', + PLEX_TYPE_EPISODE: 'DefaultTvShows.png', + PLEX_TYPE_SEASON: 'DefaultTvShows.png', + PLEX_TYPE_SHOW: 'DefaultTvShows.png', + PLEX_TYPE_CLIP: 'DefaultAddonAlbumInfo.png', + PLEX_TYPE_ARTIST: 'DefaultArtist.png', + PLEX_TYPE_ALBUM: 'DefaultAlbumCover.png', + PLEX_TYPE_SONG: 'DefaultMusicSongs.png', + PLEX_TYPE_AUDIO: 'DefaultAddonAlbumInfo.png', + PLEX_TYPE_PHOTO: 'DefaultPicture.png', + PLEX_TYPE_PLAYLIST: 'DefaultPlaylist.png', + 'mixed': 'DefaultAddonAlbumInfo.png' +} + TRANSLATION_FROM_PLEXTYPE = { PLEX_TYPE_MOVIE: 342, PLEX_TYPE_EPISODE: 20360, @@ -396,6 +423,21 @@ PLEX_TYPE_NUMBER_FROM_PLEX_TYPE = { } +# To be used with e.g. Kodi Widgets +MEDIATYPE_FROM_PLEX_TYPE = { + PLEX_TYPE_MOVIE: 'movies', + PLEX_TYPE_SHOW: 'tvshows', + PLEX_TYPE_SEASON: 'tvshows', + PLEX_TYPE_EPISODE: 'episodes', + PLEX_TYPE_ARTIST: 'artists', + PLEX_TYPE_ALBUM: 'albumbs', + PLEX_TYPE_SONG: 'songs', + PLEX_TYPE_CLIP: 'videos', + PLEX_TYPE_SET: 'movies', + PLEX_TYPE_PHOTO: 'photos', + 'mixed': 'tvshows', +} + KODI_TO_PLEX_ARTWORK = { 'poster': 'thumb', 'banner': 'banner', @@ -403,10 +445,10 @@ KODI_TO_PLEX_ARTWORK = { } KODI_TO_PLEX_ARTWORK_EPISODE = { - 'thumb': 'thumb', - 'poster': 'grandparentThumb', - 'banner': 'banner', - 'fanart': 'art' + 'season.poster': 'parentThumb', + 'tvshow.poster': 'grandparentThumb', + 'tvshow.fanart': 'grandparentArt', + # 'tvshow.banner': 'banner' # Not included in PMS episode metadata } # Might be implemented in the future: 'icon', 'landscape' (16:9) diff --git a/resources/lib/widgets.py b/resources/lib/widgets.py new file mode 100644 index 00000000..7ca6ceb8 --- /dev/null +++ b/resources/lib/widgets.py @@ -0,0 +1,624 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Code from script.module.metadatautils, kodidb.py + +Loads of different functions called in SEPARATE Python instances through +e.g. plugin://... calls. Hence be careful to only rely on window variables. +""" +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +import urllib +try: + from multiprocessing.pool import ThreadPool + SUPPORTS_POOL = True +except Exception: + SUPPORTS_POOL = False + +import xbmc +import xbmcgui +import xbmcvfs + +from .plex_api import API +from .plex_db import PlexDB +from . import json_rpc as js, utils, variables as v + +LOG = getLogger('PLEX.widget') + +# To easily use threadpool, we can only pass one argument +PLEX_TYPE = None +SECTION_ID = None +APPEND_SHOW_TITLE = None +APPEND_SXXEXX = None +SYNCHED = True +# Need to chain the PMS keys +KEY = None + + +def process_method_on_list(method_to_run, items): + """ + helper method that processes a method on each listitem with pooling if the + system supports it + """ + all_items = [] + if SUPPORTS_POOL: + pool = ThreadPool() + try: + all_items = pool.map(method_to_run, items) + except Exception: + # catch exception to prevent threadpool running forever + utils.ERROR(notify=True) + pool.close() + pool.join() + else: + all_items = [method_to_run(items) for item in items] + all_items = filter(None, all_items) + return all_items + + +def get_clean_image(image): + '''helper to strip all kodi tags/formatting of an image path/url''' + if not image: + return "" + if "music@" in image: + # fix for embedded images + thumbcache = xbmc.getCacheThumbName(image).replace(".tbn", ".jpg") + thumbcache = "special://thumbnails/%s/%s" % (thumbcache[0], thumbcache) + if not xbmcvfs.exists(thumbcache): + xbmcvfs.copy(image, thumbcache) + image = thumbcache + if image and "image://" in image: + image = image.replace("image://", "") + image = urllib.unquote(image.encode("utf-8")) + if image.endswith("/"): + image = image[:-1] + if not isinstance(image, unicode): + image = image.decode("utf8") + return image + + +def generate_item(xml_element): + """ + Meant to be consumed by metadatautils.kodidb.prepare_listitem(), and then + subsequently by metadatautils.kodidb.create_listitem() + + Do NOT set resumetime - otherwise Kodi always resumes at that time + even if the user chose to start element from the beginning + listitem.setProperty('resumetime', str(userdata['Resume'])) + + The key 'file' needs to be set later with the item's path + """ + try: + if xml_element.tag in ('Directory', 'Playlist', 'Hub'): + return _generate_folder(xml_element) + else: + return _generate_content(xml_element) + except Exception: + # Usefull to catch everything here since we're using threadpool + LOG.error('xml that caused the crash: "%s": %s', + xml_element.tag, xml_element.attrib) + utils.ERROR(notify=True) + + +def _generate_folder(xml_element): + '''Generates "folder"/"directory" items that user can further navigate''' + api = API(xml_element) + art = api.artwork() + return { + 'title': api.title(), + 'label': api.title(), + 'file': api.directory_path(section_id=SECTION_ID, + plex_type=PLEX_TYPE, + old_key=KEY), + 'icon': 'DefaultFolder.png', + 'art': { + 'thumb': art['thumb'] if 'thumb' in art else + (art['poster'] if 'poster' in art else + 'special://home/addons/%s/icon.png' % v.ADDON_ID), + 'fanart': art['fanart'] if 'fanart' in art else + 'special://home/addons/%s/fanart.jpg' % v.ADDON_ID}, + 'isFolder': True, + 'type': '', + 'IsPlayable': 'false', + } + + +def _generate_content(xml_element): + api = API(xml_element) + plex_type = api.plex_type() + kodi_type = v.KODITYPE_FROM_PLEXTYPE[plex_type] + userdata = api.userdata() + _, _, tvshowtitle, season_no, episode_no = api.episode_data() + db_item = xml_element.get('pkc_db_item') + if db_item: + # Item is synched to the Kodi db - let's use that info + # (will thus e.g. include additional artwork or metadata) + item = js.item_details(db_item['kodi_id'], kodi_type) + else: + people = api.people() + cast = [{ + 'name': x[0], + 'thumbnail': x[1], + 'role': x[2], + 'order': x[3], + } for x in api.people_list()['actor']] + item = { + 'cast': cast, + 'country': api.country_list(), + 'dateadded': api.date_created(), # e.g '2019-01-03 19:40:59' + 'director': people['Director'], # list of [str] + 'duration': userdata['Runtime'], + 'episode': episode_no, + # 'file': '', # e.g. 'videodb://tvshows/titles/20' + 'genre': api.genre_list(), + # 'imdbnumber': '', # e.g.'341663' + 'label': api.title(), # e.g. '1x05. Category 55 Emergency Doomsday Crisis' + 'lastplayed': userdata['LastPlayedDate'], # e.g. '2019-01-04 16:05:03' + 'mpaa': api.content_rating(), # e.g. 'TV-MA' + 'originaltitle': '', # e.g. 'Titans (2018)' + 'playcount': userdata['PlayCount'], # [int] + 'plot': api.plot(), # [str] + 'plotoutline': api.tagline(), + 'premiered': api.premiere_date(), # '2018-10-12' + 'rating': api.audience_rating(), # [float] + 'season': season_no, + 'sorttitle': api.sorttitle(), # 'Titans (2018)' + 'studio': api.music_studio_list(), # e.g. 'DC Universe' + 'tag': [], # List of tags this item belongs to + 'tagline': api.tagline(), + 'thumbnail': '', # e.g. 'image://https%3a%2f%2fassets.tv' + 'title': api.title(), # 'Titans (2018)' + 'type': kodi_type, + 'trailer': api.trailer(), + 'tvshowtitle': tvshowtitle, + 'uniqueid': { + 'imdbnumber': api.provider('imdb') or '', + 'tvdb_id': api.provider('tvdb') or '' + }, + 'votes': '0', # [str]! + 'writer': people['Writer'], # list of [str] + 'year': api.year(), # [int] + } + + if plex_type in (v.PLEX_TYPE_EPISODE, v.PLEX_TYPE_SEASON, v.PLEX_TYPE_SHOW): + leaves = api.leave_count() + if leaves: + item['extraproperties'] = leaves + # Add all the artwork we can + item['art'] = api.artwork(full_artwork=True) + # Add all info for e.g. video and audio streams + item['streamdetails'] = api.mediastreams() + # Cleanup required due to the way metadatautils works + if not item['lastplayed']: + del item['lastplayed'] + for stream in item['streamdetails']['video']: + stream['height'] = utils.cast(int, stream['height']) + stream['width'] = utils.cast(int, stream['width']) + stream['aspect'] = utils.cast(float, stream['aspect']) + item['streamdetails']['subtitle'] = [{'language': x} for x in item['streamdetails']['subtitle']] + # Resume point + resume = api.resume_point() + if resume: + item['resume'] = { + 'position': resume, + 'total': userdata['Runtime'] + } + + item['icon'] = v.ICON_FROM_PLEXTYPE[plex_type] + # Some customization + if plex_type == v.PLEX_TYPE_EPISODE: + # Prefix to the episode's title/label + if season_no is not None and episode_no is not None: + if APPEND_SXXEXX is True: + item['label'] = "S%.2dE%.2d - %s" % (season_no, episode_no, item['label']) + if APPEND_SHOW_TITLE is True: + item['label'] = "%s - %s " % (tvshowtitle, item['label']) + + # Determine the path for this item + key = api.path_and_plex_id() + if key.startswith('/system/services') or key.startswith('http'): + params = { + 'mode': 'plex_node', + 'key': key, + 'offset': xml_element.attrib.get('viewOffset', '0'), + } + url = "plugin://%s?%s" % (v.ADDON_ID, urllib.urlencode(params)) + elif plex_type == v.PLEX_TYPE_PHOTO: + url = api.get_picture_path() + else: + url = api.path() + if not db_item and plex_type == v.PLEX_TYPE_EPISODE: + # Hack - Item is not synched to the Kodi database + # We CANNOT use paths that show up in the Kodi paths table! + url = url.replace('plugin.video.plexkodiconnect.tvshows', + 'plugin.video.plexkodiconnect') + item['file'] = url + return item + + +def attach_kodi_ids(xml): + """ + Attaches the kodi db_item to the xml's children, attribute 'pkc_db_item' + """ + if not SYNCHED: + return + with PlexDB(lock=False) as plexdb: + for child in xml: + api = API(child) + db_item = plexdb.item_by_id(api.plex_id(), api.plex_type()) + child.set('pkc_db_item', db_item) + return xml + + +def prepare_listitem(item): + """ + helper to convert kodi output from json api to compatible format for + listitems + + Code from script.module.metadatautils, kodidb.py + """ + try: + # fix values returned from json to be used as listitem values + properties = item.get("extraproperties", {}) + + # set type + for idvar in [ + ('episode', 'DefaultTVShows.png'), + ('tvshow', 'DefaultTVShows.png'), + ('movie', 'DefaultMovies.png'), + ('song', 'DefaultAudio.png'), + ('album', 'DefaultAudio.png'), + ('artist', 'DefaultArtist.png'), + ('musicvideo', 'DefaultMusicVideos.png'), + ('recording', 'DefaultTVShows.png'), + ('channel', 'DefaultAddonPVRClient.png')]: + dbid = item.get(idvar[0] + "id") + if dbid: + properties["DBID"] = str(dbid) + if not item.get("type"): + item["type"] = idvar[0] + if not item.get("icon"): + item["icon"] = idvar[1] + break + + # general properties + if "genre" in item and isinstance(item['genre'], list): + item["genre"] = " / ".join(item['genre']) + if "studio" in item and isinstance(item['studio'], list): + item["studio"] = " / ".join(item['studio']) + if "writer" in item and isinstance(item['writer'], list): + item["writer"] = " / ".join(item['writer']) + if 'director' in item and isinstance(item['director'], list): + item["director"] = " / ".join(item['director']) + if 'artist' in item and not isinstance(item['artist'], list): + item["artist"] = [item['artist']] + if 'artist' not in item: + item["artist"] = [] + if item['type'] == "album" and 'album' not in item and 'label' in item: + item['album'] = item['label'] + if "duration" not in item and "runtime" in item: + if (item["runtime"] / 60) > 300: + item["duration"] = item["runtime"] / 60 + else: + item["duration"] = item["runtime"] + if "plot" not in item and "comment" in item: + item["plot"] = item["comment"] + if "tvshowtitle" not in item and "showtitle" in item: + item["tvshowtitle"] = item["showtitle"] + if "premiered" not in item and "firstaired" in item: + item["premiered"] = item["firstaired"] + if "firstaired" in item and "aired" not in item: + item["aired"] = item["firstaired"] + if "imdbnumber" not in properties and "imdbnumber" in item: + properties["imdbnumber"] = item["imdbnumber"] + if "imdbnumber" not in properties and "uniqueid" in item: + for value in item["uniqueid"].values(): + if value.startswith("tt"): + properties["imdbnumber"] = value + + properties["dbtype"] = item["type"] + properties["DBTYPE"] = item["type"] + properties["type"] = item["type"] + properties["path"] = item.get("file") + + # cast + list_cast = [] + list_castandrole = [] + item["cast_org"] = item.get("cast", []) + if "cast" in item and isinstance(item["cast"], list): + for castmember in item["cast"]: + if isinstance(castmember, dict): + list_cast.append(castmember.get("name", "")) + list_castandrole.append((castmember["name"], castmember["role"])) + else: + list_cast.append(castmember) + list_castandrole.append((castmember, "")) + + item["cast"] = list_cast + item["castandrole"] = list_castandrole + + if "season" in item and "episode" in item: + properties["episodeno"] = "s%se%s" % (item.get("season"), item.get("episode")) + if "resume" in item: + properties["resumetime"] = str(item['resume']['position']) + properties["totaltime"] = str(item['resume']['total']) + properties['StartOffset'] = str(item['resume']['position']) + + # streamdetails + if "streamdetails" in item: + streamdetails = item["streamdetails"] + audiostreams = streamdetails.get('audio', []) + videostreams = streamdetails.get('video', []) + subtitles = streamdetails.get('subtitle', []) + if len(videostreams) > 0: + stream = videostreams[0] + height = stream.get("height", "") + width = stream.get("width", "") + if height and width: + resolution = "" + if width <= 720 and height <= 480: + resolution = "480" + elif width <= 768 and height <= 576: + resolution = "576" + elif width <= 960 and height <= 544: + resolution = "540" + elif width <= 1280 and height <= 720: + resolution = "720" + elif width <= 1920 and height <= 1080: + resolution = "1080" + elif width * height >= 6000000: + resolution = "4K" + properties["VideoResolution"] = resolution + if stream.get("codec", ""): + properties["VideoCodec"] = str(stream["codec"]) + if stream.get("aspect", ""): + properties["VideoAspect"] = str(round(stream["aspect"], 2)) + item["streamdetails"]["video"] = stream + + # grab details of first audio stream + if len(audiostreams) > 0: + stream = audiostreams[0] + properties["AudioCodec"] = stream.get('codec', '') + properties["AudioChannels"] = str(stream.get('channels', '')) + properties["AudioLanguage"] = stream.get('language', '') + item["streamdetails"]["audio"] = stream + + # grab details of first subtitle + if len(subtitles) > 0: + properties["SubtitleLanguage"] = subtitles[0].get('language', '') + item["streamdetails"]["subtitle"] = subtitles[0] + else: + item["streamdetails"] = {} + item["streamdetails"]["video"] = {'duration': item.get('duration', 0)} + + # additional music properties + if 'album_description' in item: + properties["Album_Description"] = item.get('album_description') + + # pvr properties + if "channellogo" in item: + properties["channellogo"] = item["channellogo"] + properties["channelicon"] = item["channellogo"] + if "episodename" in item: + properties["episodename"] = item["episodename"] + if "channel" in item: + properties["channel"] = item["channel"] + properties["channelname"] = item["channel"] + item["label2"] = item["title"] + + # artwork + art = item.get("art", {}) + if item["type"] in ["episode", "season"]: + if not art.get("fanart") and art.get("season.fanart"): + art["fanart"] = art["season.fanart"] + if not art.get("poster") and art.get("season.poster"): + art["poster"] = art["season.poster"] + if not art.get("landscape") and art.get("season.landscape"): + art["poster"] = art["season.landscape"] + if not art.get("fanart") and art.get("tvshow.fanart"): + art["fanart"] = art.get("tvshow.fanart") + if not art.get("poster") and art.get("tvshow.poster"): + art["poster"] = art.get("tvshow.poster") + if not art.get("clearlogo") and art.get("tvshow.clearlogo"): + art["clearlogo"] = art.get("tvshow.clearlogo") + if not art.get("banner") and art.get("tvshow.banner"): + art["banner"] = art.get("tvshow.banner") + if not art.get("landscape") and art.get("tvshow.landscape"): + art["landscape"] = art.get("tvshow.landscape") + if not art.get("fanart") and item.get('fanart'): + art["fanart"] = item.get('fanart') + if not art.get("thumb") and item.get('thumbnail'): + art["thumb"] = get_clean_image(item.get('thumbnail')) + if not art.get("thumb") and art.get('poster'): + art["thumb"] = get_clean_image(art.get('poster')) + if not art.get("thumb") and item.get('icon'): + art["thumb"] = get_clean_image(item.get('icon')) + if not item.get("thumbnail") and art.get('thumb'): + item["thumbnail"] = art["thumb"] + + # clean art + for key, value in art.iteritems(): + if not isinstance(value, (str, unicode)): + art[key] = "" + elif value: + art[key] = get_clean_image(value) + item["art"] = art + + item["extraproperties"] = properties + + # return the result + return item + + except Exception: + utils.ERROR(notify=True) + LOG.error('item that caused crash: %s', item) + + +def create_listitem(item, as_tuple=True, offscreen=True): + """ + helper to create a kodi listitem from kodi compatible dict with mediainfo + + WARNING: paths, so item['file'] for items NOT synched to the Kodi DB + shall NOT occur in the Kodi paths table! + Kodi information screen does not work otherwise + + Code from script.module.metadatautils, kodidb.py + """ + try: + if v.KODIVERSION > 17: + liz = xbmcgui.ListItem( + label=item.get("label", ""), + label2=item.get("label2", ""), + path=item['file'], + offscreen=offscreen) + else: + liz = xbmcgui.ListItem( + label=item.get("label", ""), + label2=item.get("label2", ""), + path=item['file']) + + # only set isPlayable prop if really needed + if item.get("isFolder", False): + liz.setProperty('IsPlayable', 'false') + elif "plugin://script.skin.helper" not in item['file']: + liz.setProperty('IsPlayable', 'true') + + nodetype = "Video" + if item["type"] in ["song", "album", "artist"]: + nodetype = "Music" + + # extra properties + for key, value in item["extraproperties"].iteritems(): + liz.setProperty(key, value) + + # video infolabels + if nodetype == "Video": + infolabels = { + "title": item.get("title"), + "size": item.get("size"), + "genre": item.get("genre"), + "year": item.get("year"), + "top250": item.get("top250"), + "tracknumber": item.get("tracknumber"), + "rating": item.get("rating"), + "playcount": item.get("playcount"), + "overlay": item.get("overlay"), + "cast": item.get("cast"), + "castandrole": item.get("castandrole"), + "director": item.get("director"), + "mpaa": item.get("mpaa"), + "plot": item.get("plot"), + "plotoutline": item.get("plotoutline"), + "originaltitle": item.get("originaltitle"), + "sorttitle": item.get("sorttitle"), + "duration": item.get("duration"), + "studio": item.get("studio"), + "tagline": item.get("tagline"), + "writer": item.get("writer"), + "tvshowtitle": item.get("tvshowtitle"), + "premiered": item.get("premiered"), + "status": item.get("status"), + "code": item.get("imdbnumber"), + "imdbnumber": item.get("imdbnumber"), + "aired": item.get("aired"), + "credits": item.get("credits"), + "album": item.get("album"), + "artist": item.get("artist"), + "votes": item.get("votes"), + "trailer": item.get("trailer"), + # "progress": item.get('progresspercentage') + } + if item["type"] == "episode": + infolabels["season"] = item["season"] + infolabels["episode"] = item["episode"] + + # streamdetails + if item.get("streamdetails"): + liz.addStreamInfo("video", item["streamdetails"].get("video", {})) + liz.addStreamInfo("audio", item["streamdetails"].get("audio", {})) + liz.addStreamInfo("subtitle", item["streamdetails"].get("subtitle", {})) + + if "dateadded" in item: + infolabels["dateadded"] = item["dateadded"] + if "date" in item: + infolabels["date"] = item["date"] + + # music infolabels + else: + infolabels = { + "title": item.get("title"), + "size": item.get("size"), + "genre": item.get("genre"), + "year": item.get("year"), + "tracknumber": item.get("track"), + "album": item.get("album"), + "artist": " / ".join(item.get('artist')), + "rating": str(item.get("rating", 0)), + "lyrics": item.get("lyrics"), + "playcount": item.get("playcount") + } + if "date" in item: + infolabels["date"] = item["date"] + if "duration" in item: + infolabels["duration"] = item["duration"] + if "lastplayed" in item: + infolabels["lastplayed"] = item["lastplayed"] + + # setting the dbtype and dbid is supported from kodi krypton and up + # PKC hack: ignore empty type + if item["type"] not in ["recording", "channel", "favourite", ""]: + infolabels["mediatype"] = item["type"] + # setting the dbid on music items is not supported ? + if nodetype == "Video" and "DBID" in item["extraproperties"]: + infolabels["dbid"] = item["extraproperties"]["DBID"] + + if "lastplayed" in item: + infolabels["lastplayed"] = item["lastplayed"] + + # assign the infolabels + liz.setInfo(type=nodetype, infoLabels=infolabels) + + # artwork + liz.setArt(item.get("art", {})) + if "icon" in item: + liz.setIconImage(item['icon']) + if "thumbnail" in item: + liz.setThumbnailImage(item['thumbnail']) + + # contextmenu + if item["type"] in ["episode", "season"] and "season" in item and "tvshowid" in item: + # add series and season level to widgets + if "contextmenu" not in item: + item["contextmenu"] = [] + item["contextmenu"] += [ + (xbmc.getLocalizedString(20364), "ActivateWindow(Video,videodb://tvshows/titles/%s/,return)" + % (item["tvshowid"])), + (xbmc.getLocalizedString(20373), "ActivateWindow(Video,videodb://tvshows/titles/%s/%s/,return)" + % (item["tvshowid"], item["season"]))] + if "contextmenu" in item: + liz.addContextMenuItems(item["contextmenu"]) + + if as_tuple: + return (item["file"], liz, item.get("isFolder", False)) + else: + return liz + except Exception: + utils.ERROR(notify=True) + LOG.error('item that should have been turned into a listitem: %s', item) + + +def create_main_entry(item): + '''helper to create a simple (directory) listitem''' + return { + 'title': item[0], + 'label': item[0], + 'file': item[1], + 'icon': item[2], + 'art': { + 'thumb': 'special://home/addons/%s/icon.png' % v.ADDON_ID, + 'fanart': 'special://home/addons/%s/fanart.jpg' % v.ADDON_ID}, + 'isFolder': True, + 'type': '', + 'IsPlayable': 'false' + } + From e50c0c011ecb804210ad58100b99b26c7631c4b4 Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 9 Mar 2019 15:42:33 +0100 Subject: [PATCH 7/8] Allow searching the PMS --- resources/lib/entrypoint.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index 4c6a31f5..229fa20c 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -404,9 +404,19 @@ def browse_plex(key=None, plex_type=None, section_id=None, synched=True, Pass synched=False if the items have NOT been synched to the Kodi DB """ - LOG.debug('Browsing to key %s, section %s, plex_type: %s, synched: %s', - key, section_id, plex_type, synched) + LOG.debug('Browsing to key %s, section %s, plex_type: %s, synched: %s, ' + 'prompt "%s"', key, section_id, plex_type, synched, prompt) app.init(entrypoint=True) + if prompt: + prompt = utils.dialog('input', prompt) + if prompt is None: + # User cancelled + return + prompt = prompt.strip().decode('utf-8') + if '?' not in key: + key = '%s?query=%s' % (key, prompt) + else: + key = '%s&query=%s' % (key, prompt) xml = DU().downloadUrl('{server}%s' % key) try: xml.attrib From dfe281268f3b03a3627b90d7ff3ddb7ace2dccc3 Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 16 Mar 2019 17:15:23 +0100 Subject: [PATCH 8/8] Stable and beta version bump 2.7.6 --- README.md | 4 ++-- addon.xml | 7 +++++-- changelog.txt | 5 ++++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fd3e9242..8ce19325 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![stable version](https://img.shields.io/badge/stable_version-2.7.4-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip) -[![beta version](https://img.shields.io/badge/beta_version-2.7.5-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) +[![stable version](https://img.shields.io/badge/stable_version-2.7.6-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip) +[![beta version](https://img.shields.io/badge/beta_version-2.7.6-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) [![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation) [![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq) diff --git a/addon.xml b/addon.xml index 1aa6734d..139b9bf4 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -77,7 +77,10 @@ Нативна інтеграція Plex в Kodi Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик! Використовуйте на свій ризик - version 2.7.5: + version 2.7.6: +- Make 2.7.5 available for everyone + +version 2.7.5: - Giant overhaul of widgets - Fix some KeyErrors when playing songs - Fix rare cases where playlists were being created diff --git a/changelog.txt b/changelog.txt index bf7668b9..321cbab8 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,7 @@ -version 2.7.5: +version 2.7.6: +- Make 2.7.5 available for everyone + +version 2.7.5 (beta only): - Giant overhaul of widgets - Fix some KeyErrors when playing songs - Fix rare cases where playlists were being created