From a46cb731cf248b3c1c8cdb8b2c06492e835c23d3 Mon Sep 17 00:00:00 2001 From: croneter Date: Mon, 10 Jun 2019 21:29:42 +0200 Subject: [PATCH] Refactor Plex API --- resources/lib/entrypoint.py | 22 +- resources/lib/itemtypes/movies.py | 82 +- resources/lib/itemtypes/music.py | 84 +- resources/lib/itemtypes/tvshows.py | 148 +- resources/lib/library_sync/fanart.py | 2 +- resources/lib/library_sync/get_metadata.py | 2 +- resources/lib/library_sync/sections.py | 2 +- resources/lib/library_sync/websocket.py | 5 +- resources/lib/playback.py | 21 +- resources/lib/playlist_func.py | 4 +- resources/lib/playlists/__init__.py | 18 +- resources/lib/playlists/kodi_pl.py | 20 +- resources/lib/playlists/pms.py | 4 +- resources/lib/playqueue.py | 2 +- resources/lib/playutils.py | 8 +- resources/lib/plex_api.py | 1817 -------------------- resources/lib/plex_api/__init__.py | 31 + resources/lib/plex_api/artwork.py | 428 +++++ resources/lib/plex_api/base.py | 644 +++++++ resources/lib/plex_api/file.py | 168 ++ resources/lib/plex_api/media.py | 410 +++++ resources/lib/plex_api/user.py | 59 + resources/lib/plex_companion.py | 12 +- resources/lib/plex_functions.py | 37 - resources/lib/transfer.py | 12 +- resources/lib/utils.py | 27 + resources/lib/widgets.py | 123 +- 27 files changed, 2005 insertions(+), 2187 deletions(-) delete mode 100644 resources/lib/plex_api.py create mode 100644 resources/lib/plex_api/__init__.py create mode 100644 resources/lib/plex_api/artwork.py create mode 100644 resources/lib/plex_api/base.py create mode 100644 resources/lib/plex_api/file.py create mode 100644 resources/lib/plex_api/media.py create mode 100644 resources/lib/plex_api/user.py diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index 0c41266b..162d1c38 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -15,7 +15,7 @@ from xbmcgui import ListItem from . import utils from . import path_ops from .downloadutils import DownloadUtils as DU -from .plex_api import API +from .plex_api import API, mass_api from . import plex_functions as PF from . import variables as v # Be careful - your using app in another Python instance! @@ -217,13 +217,13 @@ def show_listing(xml, plex_type=None, section_id=None, synched=True, key=None, # 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) + all_items = mass_api(xml) + all_items = utils.process_method_on_list(widgets.generate_item, all_items) + all_items = utils.process_method_on_list(widgets.prepare_listitem, + all_items) # fill that listing... - all_items = widgets.process_method_on_list(widgets.create_listitem, - all_items) + all_items = utils.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) @@ -397,13 +397,13 @@ def hub(content_type): for entry in reversed(xml): api = API(entry) append = False - if content_type == 'video' and api.plex_type() in v.PLEX_VIDEOTYPES: + if content_type == 'video' and api.plex_type in v.PLEX_VIDEOTYPES: append = True - elif content_type == 'audio' and api.plex_type() in v.PLEX_AUDIOTYPES: + elif content_type == 'audio' and api.plex_type in v.PLEX_AUDIOTYPES: append = True - elif content_type == 'image' and api.plex_type() == v.PLEX_TYPE_PHOTO: + elif content_type == 'image' and api.plex_type == v.PLEX_TYPE_PHOTO: append = True - elif content_type != 'image' and api.plex_type() == v.PLEX_TYPE_PLAYLIST: + 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 diff --git a/resources/lib/itemtypes/movies.py b/resources/lib/itemtypes/movies.py index 6a388d26..7e831c40 100644 --- a/resources/lib/itemtypes/movies.py +++ b/resources/lib/itemtypes/movies.py @@ -20,7 +20,7 @@ class Movie(ItemBase): Process single movie """ api = API(xml) - plex_id = api.plex_id() + plex_id = api.plex_id # Cannot parse XML, abort if not plex_id: LOG.error('Cannot parse XML data for movie: %s', xml.attrib) @@ -35,20 +35,6 @@ class Movie(ItemBase): update_item = False kodi_id = self.kodidb.new_movie_id() - userdata = api.userdata() - playcount = userdata['PlayCount'] - dateplayed = userdata['LastPlayedDate'] - resume = userdata['Resume'] - runtime = userdata['Runtime'] - rating = userdata['Rating'] - - title = api.title() - people = api.people() - genres = api.genre_list() - collections = api.collection_list() - countries = api.country_list() - studios = api.music_studio_list() - # GET THE FILE AND PATH ##### do_indirect = not app.SYNC.direct_paths if app.SYNC.direct_paths: @@ -58,7 +44,7 @@ class Movie(ItemBase): # Something went wrong, trying to use non-direct paths do_indirect = True else: - playurl = api.validate_playurl(playurl, api.plex_type()) + playurl = api.validate_playurl(playurl, api.plex_type) if playurl is None: return False if '\\' in playurl: @@ -92,7 +78,7 @@ class Movie(ItemBase): self.kodidb.update_ratings(kodi_id, v.KODI_TYPE_MOVIE, "default", - rating, + api.rating(), api.votecount(), rating_id) # update new uniqueid Kodi 17 @@ -109,13 +95,13 @@ class Movie(ItemBase): uniqueid = -1 self.kodidb.modify_people(kodi_id, v.KODI_TYPE_MOVIE, - api.people_list()) + api.people()) if app.SYNC.artwork: self.kodidb.modify_artwork(api.artwork(), kodi_id, v.KODI_TYPE_MOVIE) else: - LOG.info("ADD movie plex_id: %s - %s", plex_id, title) + LOG.info("ADD movie plex_id: %s - %s", plex_id, api.title()) file_id = self.kodidb.add_file(filename, kodi_pathid, api.date_created()) @@ -124,7 +110,7 @@ class Movie(ItemBase): kodi_id, v.KODI_TYPE_MOVIE, "default", - rating, + api.rating(), api.votecount()) if api.provider('imdb') is not None: uniqueid = self.kodidb.add_uniqueid_id() @@ -137,7 +123,7 @@ class Movie(ItemBase): uniqueid = -1 self.kodidb.add_people(kodi_id, v.KODI_TYPE_MOVIE, - api.people_list()) + api.people()) if app.SYNC.artwork: self.kodidb.add_artwork(api.artwork(), kodi_id, @@ -146,37 +132,39 @@ class Movie(ItemBase): # Update Kodi's main entry self.kodidb.add_movie(kodi_id, file_id, - title, + api.title(), api.plot(), api.shortplot(), api.tagline(), api.votecount(), rating_id, - api.list_to_string(people['Writer']), + api.list_to_string(api.writers()), api.year(), uniqueid, api.sorttitle(), - runtime, + api.runtime(), api.content_rating(), - api.list_to_string(genres), - api.list_to_string(people['Director']), - title, - api.list_to_string(studios), + api.list_to_string(api.genres()), + api.list_to_string(api.directors()), + api.title(), + api.list_to_string(api.studios()), api.trailer(), - api.list_to_string(countries), + api.list_to_string(api.countries()), playurl, kodi_pathid, api.premiere_date(), - userdata['UserRating']) + api.userrating()) - self.kodidb.modify_countries(kodi_id, v.KODI_TYPE_MOVIE, countries) - self.kodidb.modify_genres(kodi_id, v.KODI_TYPE_MOVIE, genres) + self.kodidb.modify_countries(kodi_id, + v.KODI_TYPE_MOVIE, + api.countries()) + self.kodidb.modify_genres(kodi_id, v.KODI_TYPE_MOVIE, api.genres()) - self.kodidb.modify_streams(file_id, api.mediastreams(), runtime) - self.kodidb.modify_studios(kodi_id, v.KODI_TYPE_MOVIE, studios) + self.kodidb.modify_streams(file_id, api.mediastreams(), api.runtime()) + self.kodidb.modify_studios(kodi_id, v.KODI_TYPE_MOVIE, api.studios()) tags = [section_name] - if collections: - for plex_set_id, set_name in collections: + if api.collections(): + for plex_set_id, set_name in api.collections(): set_api = None tags.append(set_name) # Add any sets from Plex collection tags @@ -211,10 +199,10 @@ class Movie(ItemBase): self.kodidb.modify_tags(kodi_id, v.KODI_TYPE_MOVIE, tags) # Process playstate self.kodidb.set_resume(file_id, - resume, - runtime, - playcount, - dateplayed) + api.resume_point(), + api.runtime(), + api.viewcount(), + api.lastplayed()) self.plexdb.add_movie(plex_id=plex_id, checksum=api.checksum(), section_id=section_id, @@ -267,19 +255,17 @@ class Movie(ItemBase): """ api = API(xml_element) # Get key and db entry on the Kodi db side - db_item = self.plexdb.item_by_id(api.plex_id(), plex_type) + db_item = self.plexdb.item_by_id(api.plex_id, plex_type) if not db_item: LOG.info('Item not yet synced: %s', xml_element.attrib) return False - # Grab the user's viewcount, resume points etc. from PMS' answer - userdata = api.userdata() # Write to Kodi DB self.kodidb.set_resume(db_item['kodi_fileid'], - userdata['Resume'], - userdata['Runtime'], - userdata['PlayCount'], - userdata['LastPlayedDate']) + api.resume_point(), + api.runtime(), + api.viewcount(), + api.lastplayed()) self.kodidb.update_userrating(db_item['kodi_id'], db_item['kodi_type'], - userdata['UserRating']) + api.userrating()) return True diff --git a/resources/lib/itemtypes/music.py b/resources/lib/itemtypes/music.py index f54fac45..433b8794 100644 --- a/resources/lib/itemtypes/music.py +++ b/resources/lib/itemtypes/music.py @@ -42,18 +42,17 @@ class MusicMixin(object): """ api = API(xml_element) # Get key and db entry on the Kodi db side - db_item = self.plexdb.item_by_id(api.plex_id(), plex_type) + db_item = self.plexdb.item_by_id(api.plex_id, plex_type) if not db_item: LOG.info('Item not yet synced: %s', xml_element.attrib) return False # Grab the user's viewcount, resume points etc. from PMS' answer - userdata = api.userdata() self.kodidb.update_userrating(db_item['kodi_id'], db_item['kodi_type'], - userdata['UserRating']) + api.userrating()) if plex_type == v.PLEX_TYPE_SONG: - self.kodidb.set_playcount(userdata['PlayCount'], - userdata['LastPlayedDate'], + self.kodidb.set_playcount(api.viewcount(), + api.lastplayed(), db_item['kodi_id'],) return True @@ -160,7 +159,7 @@ class Artist(MusicMixin, ItemBase): Process a single artist """ api = API(xml) - plex_id = api.plex_id() + plex_id = api.plex_id if not plex_id: LOG.error('Cannot process artist %s', xml.attrib) return @@ -198,7 +197,7 @@ class Artist(MusicMixin, ItemBase): # Kodi doesn't allow that. In case that happens we just merge the # artist entries. kodi_id = self.kodidb.add_artist(api.title(), musicBrainzId) - self.kodidb.update_artist(api.list_to_string(api.genre_list()), + self.kodidb.update_artist(api.list_to_string(api.genres()), api.plot(), thumb, fanart, @@ -224,7 +223,7 @@ class Album(MusicMixin, ItemBase): avoid infinite loops """ api = API(xml) - plex_id = api.plex_id() + plex_id = api.plex_id if not plex_id: LOG.error('Error processing album: %s', xml.attrib) return @@ -274,11 +273,9 @@ class Album(MusicMixin, ItemBase): compilation = 1 break name = api.title() - userdata = api.userdata() # Not yet implemented by Plex, let's use unique last.fm or gracenote musicBrainzId = None - genres = api.genre_list() - genre = api.list_to_string(genres) + genre = api.list_to_string(api.genres()) if app.SYNC.artwork: artworks = api.artwork() if 'poster' in artworks: @@ -300,8 +297,8 @@ class Album(MusicMixin, ItemBase): compilation, api.plot(), thumb, - api.music_studio(), - userdata['UserRating'], + api.list_to_string(api.studios()), + api.userrating(), timing.unix_date_to_kodi(self.last_sync), 'album', kodi_id) @@ -314,8 +311,8 @@ class Album(MusicMixin, ItemBase): compilation, api.plot(), thumb, - api.music_studio(), - userdata['UserRating'], + api.list_to_string(api.studios()), + api.userrating(), timing.unix_date_to_kodi(self.last_sync), 'album', kodi_id) @@ -333,8 +330,8 @@ class Album(MusicMixin, ItemBase): compilation, api.plot(), thumb, - api.music_studio(), - userdata['UserRating'], + api.list_to_string(api.studios()), + api.userrating(), timing.unix_date_to_kodi(self.last_sync), 'album') else: @@ -347,15 +344,15 @@ class Album(MusicMixin, ItemBase): compilation, api.plot(), thumb, - api.music_studio(), - userdata['UserRating'], + api.list_to_string(api.studios()), + api.userrating(), timing.unix_date_to_kodi(self.last_sync), 'album') self.kodidb.add_albumartist(artist_id, kodi_id, api.artist_name()) if v.KODIVERSION < 18: self.kodidb.add_discography(artist_id, name, api.year()) self.kodidb.add_music_genres(kodi_id, - genres, + api.genres(), v.KODI_TYPE_ALBUM) if app.SYNC.artwork: self.kodidb.modify_artwork(artworks, @@ -378,7 +375,7 @@ class Album(MusicMixin, ItemBase): section_name=section_name, section_id=section_id, album_xml=xml, - genres=genres, + genres=api.genres(), genre=genre, compilation=compilation) @@ -391,7 +388,7 @@ class Song(MusicMixin, ItemBase): Process single song/track """ api = API(xml) - plex_id = api.plex_id() + plex_id = api.plex_id if not plex_id: LOG.error('Error processing song: %s', xml.attrib) return @@ -492,11 +489,6 @@ class Song(MusicMixin, ItemBase): # Not yet implemented by Plex musicBrainzId = None comment = None - userdata = api.userdata() - playcount = userdata['PlayCount'] - if playcount is None: - # This is different to Video DB! - playcount = 0 # Getting artists name is complicated if compilation is not None: if compilation == 0: @@ -506,7 +498,7 @@ class Song(MusicMixin, ItemBase): else: # compilation not set artists = xml.get('originalTitle', api.grandparent_title()) - tracknumber = api.track_number() or 0 + tracknumber = api.index() or 0 disc = api.disc_number() or 1 if disc == 1: track = tracknumber @@ -532,7 +524,7 @@ class Song(MusicMixin, ItemBase): # Something went wrong, trying to use non-direct paths do_indirect = True else: - playurl = api.validate_playurl(playurl, api.plex_type()) + playurl = api.validate_playurl(playurl, api.plex_type) if playurl is None: return False if "\\" in playurl: @@ -562,12 +554,12 @@ class Song(MusicMixin, ItemBase): genre, title, track, - userdata['Runtime'], + api.runtime(), year, filename, - playcount, - userdata['LastPlayedDate'], - userdata['UserRating'], + api.viewcount(), + api.lastplayed(), + api.userrating(), comment, mood, api.date_created(), @@ -578,12 +570,12 @@ class Song(MusicMixin, ItemBase): genre, title, track, - userdata['Runtime'], + api.runtime(), year, filename, - playcount, - userdata['LastPlayedDate'], - userdata['UserRating'], + api.viewcount(), + api.lastplayed(), + api.userrating(), comment, mood, api.date_created(), @@ -603,13 +595,13 @@ class Song(MusicMixin, ItemBase): genre, title, track, - userdata['Runtime'], + api.runtime(), year, filename, musicBrainzId, - playcount, - userdata['LastPlayedDate'], - userdata['UserRating'], + api.viewcount(), + api.lastplayed(), + api.userrating(), 0, 0, mood, @@ -622,13 +614,13 @@ class Song(MusicMixin, ItemBase): genre, title, track, - userdata['Runtime'], + api.runtime(), year, filename, musicBrainzId, - playcount, - userdata['LastPlayedDate'], - userdata['UserRating'], + api.viewcount(), + api.lastplayed(), + api.userrating(), 0, 0, mood, @@ -639,7 +631,7 @@ class Song(MusicMixin, ItemBase): parent_id, track, title, - userdata['Runtime']) + api.runtime()) # Link song to artists artist_name = api.grandparent_title() # Do the actual linking diff --git a/resources/lib/itemtypes/tvshows.py b/resources/lib/itemtypes/tvshows.py index 2fd7acd6..30a239bc 100644 --- a/resources/lib/itemtypes/tvshows.py +++ b/resources/lib/itemtypes/tvshows.py @@ -18,27 +18,26 @@ class TvShowMixin(object): """ api = API(xml_element) # Get key and db entry on the Kodi db side - db_item = self.plexdb.item_by_id(api.plex_id(), plex_type) + db_item = self.plexdb.item_by_id(api.plex_id, plex_type) if not db_item: LOG.info('Item not yet synced: %s', xml_element.attrib) return False # Grab the user's viewcount, resume points etc. from PMS' answer - userdata = api.userdata() self.kodidb.update_userrating(db_item['kodi_id'], db_item['kodi_type'], - userdata['UserRating']) + api.userrating()) if plex_type == v.PLEX_TYPE_EPISODE: self.kodidb.set_resume(db_item['kodi_fileid'], - userdata['Resume'], - userdata['Runtime'], - userdata['PlayCount'], - userdata['LastPlayedDate']) + api.resume_point(), + api.runtime(), + api.viewcount(), + api.lastplayed()) if db_item['kodi_fileid_2']: self.kodidb.set_resume(db_item['kodi_fileid_2'], - userdata['Resume'], - userdata['Runtime'], - userdata['PlayCount'], - userdata['LastPlayedDate']) + api.resume_point(), + api.runtime(), + api.viewcount(), + api.lastplayed()) return True def remove(self, plex_id, plex_type=None): @@ -149,7 +148,7 @@ class Show(TvShowMixin, ItemBase): Process a single show """ api = API(xml) - plex_id = api.plex_id() + plex_id = api.plex_id if not plex_id: LOG.error("Cannot parse XML data for TV show: %s", xml.attrib) return @@ -162,16 +161,11 @@ class Show(TvShowMixin, ItemBase): kodi_id = show['kodi_id'] kodi_pathid = show['kodi_pathid'] - genres = api.genre_list() - genre = api.list_to_string(genres) - studios = api.music_studio_list() - studio = api.list_to_string(studios) - # GET THE FILE AND PATH ##### if app.SYNC.direct_paths: # Direct paths is set the Kodi way playurl = api.validate_playurl(api.tv_show_path(), - api.plex_type(), + api.plex_type, folder=True) if playurl is None: return @@ -197,7 +191,7 @@ class Show(TvShowMixin, ItemBase): self.kodidb.update_ratings(kodi_id, v.KODI_TYPE_SHOW, "default", - api.audience_rating(), + api.rating(), api.votecount(), rating_id) if api.provider('tvdb') is not None: @@ -213,7 +207,7 @@ class Show(TvShowMixin, ItemBase): uniqueid = -1 self.kodidb.modify_people(kodi_id, v.KODI_TYPE_SHOW, - api.people_list()) + api.people()) if app.SYNC.artwork: self.kodidb.modify_artwork(api.artwork(), kodi_id, @@ -223,11 +217,11 @@ class Show(TvShowMixin, ItemBase): api.plot(), rating_id, api.premiere_date(), - genre, + api.list_to_string(api.genres()), api.title(), uniqueid, api.content_rating(), - studio, + api.list_to_string(api.studios()), api.sorttitle(), kodi_id) # OR ADD THE TVSHOW ##### @@ -240,7 +234,7 @@ class Show(TvShowMixin, ItemBase): kodi_id, v.KODI_TYPE_SHOW, "default", - api.audience_rating(), + api.rating(), api.votecount()) if api.provider('tvdb'): uniqueid = self.kodidb.add_uniqueid_id() @@ -253,7 +247,7 @@ class Show(TvShowMixin, ItemBase): uniqueid = -1 self.kodidb.add_people(kodi_id, v.KODI_TYPE_SHOW, - api.people_list()) + api.people()) if app.SYNC.artwork: self.kodidb.add_artwork(api.artwork(), kodi_id, @@ -264,18 +258,18 @@ class Show(TvShowMixin, ItemBase): api.plot(), rating_id, api.premiere_date(), - genre, + api.list_to_string(api.genres()), api.title(), uniqueid, api.content_rating(), - studio, + api.list_to_string(api.studios()), api.sorttitle()) - self.kodidb.modify_genres(kodi_id, v.KODI_TYPE_SHOW, genres) + self.kodidb.modify_genres(kodi_id, v.KODI_TYPE_SHOW, api.genres()) # Process studios - self.kodidb.modify_studios(kodi_id, v.KODI_TYPE_SHOW, studios) + self.kodidb.modify_studios(kodi_id, v.KODI_TYPE_SHOW, api.studios()) # Process tags: view, PMS collection tags tags = [section_name] - tags.extend([i for _, i in api.collection_list()]) + tags.extend([i for _, i in api.collections()]) self.kodidb.modify_tags(kodi_id, v.KODI_TYPE_SHOW, tags) self.plexdb.add_show(plex_id=plex_id, checksum=api.checksum(), @@ -292,7 +286,7 @@ class Season(TvShowMixin, ItemBase): Process a single season of a certain tv show """ api = API(xml) - plex_id = api.plex_id() + plex_id = api.plex_id if not plex_id: LOG.error('Error getting plex_id for season, skipping: %s', xml.attrib) @@ -339,7 +333,7 @@ class Season(TvShowMixin, ItemBase): v.KODI_TYPE_SEASON) else: LOG.info('ADD season plex_id %s - %s', plex_id, api.title()) - kodi_id = self.kodidb.add_season(parent_id, api.season_number()) + kodi_id = self.kodidb.add_season(parent_id, api.index()) if app.SYNC.artwork: self.kodidb.add_artwork(artwork, kodi_id, @@ -360,7 +354,7 @@ class Episode(TvShowMixin, ItemBase): Process single episode """ api = API(xml) - plex_id = api.plex_id() + plex_id = api.plex_id if not plex_id: LOG.error('Error getting plex_id for episode, skipping: %s', xml.attrib) @@ -376,58 +370,48 @@ class Episode(TvShowMixin, ItemBase): old_kodi_fileid_2 = episode['kodi_fileid_2'] kodi_pathid = episode['kodi_pathid'] - peoples = api.people() - director = api.list_to_string(peoples['Director']) - writer = api.list_to_string(peoples['Writer']) - userdata = api.userdata() - show_id, season_id, _, season_no, episode_no = api.episode_data() - - if season_no is None: - season_no = -1 - if episode_no is None: - episode_no = -1 airs_before_season = "-1" airs_before_episode = "-1" # The grandparent TV show - show = self.plexdb.show(show_id) + show = self.plexdb.show(api.show_id()) if not show: - LOG.warn('Grandparent TV show %s not found in DB, adding it', show_id) - show_xml = PF.GetPlexMetadata(show_id) + LOG.warn('Grandparent TV show %s not found in DB, adding it', api.show_id()) + show_xml = PF.GetPlexMetadata(api.show_id()) try: show_xml[0].attrib except (TypeError, IndexError, AttributeError): - LOG.error("Grandparent tvshow %s xml download failed", show_id) + LOG.error("Grandparent tvshow %s xml download failed", api.show_id()) return False Show(self.last_sync, plexdb=self.plexdb, kodidb=self.kodidb).add_update(show_xml[0], section_name, section_id) - show = self.plexdb.show(show_id) + show = self.plexdb.show(api.show_id()) if not show: - LOG.error('Still could not find grandparent tv show %s', show_id) + LOG.error('Still could not find grandparent tv show %s', api.show_id()) return grandparent_id = show['kodi_id'] # The parent Season - season = self.plexdb.season(season_id) - if not season and season_id: - LOG.warn('Parent season %s not found in DB, adding it', season_id) - season_xml = PF.GetPlexMetadata(season_id) + season = self.plexdb.season(api.season_id()) + if not season and api.season_id(): + LOG.warn('Parent season %s not found in DB, adding it', api.season_id()) + season_xml = PF.GetPlexMetadata(api.season_id()) try: season_xml[0].attrib except (TypeError, IndexError, AttributeError): - LOG.error("Parent season %s xml download failed", season_id) + LOG.error("Parent season %s xml download failed", api.season_id()) return False Season(self.last_sync, plexdb=self.plexdb, kodidb=self.kodidb).add_update(season_xml[0], section_name, section_id) - season = self.plexdb.season(season_id) + season = self.plexdb.season(api.season_id()) if not season: - LOG.error('Still could not find parent season %s', season_id) + LOG.error('Still could not find parent season %s', api.season_id()) return parent_id = season['kodi_id'] if season else None @@ -453,7 +437,7 @@ class Episode(TvShowMixin, ItemBase): # Set plugin path - do NOT use "intermediate" paths for the show # as with direct paths! filename = api.file_name(force_first_media=True) - path = 'plugin://%s.tvshows/%s/' % (v.ADDON_ID, show_id) + path = 'plugin://%s.tvshows/%s/' % (v.ADDON_ID, api.show_id()) filename = ('%s?plex_id=%s&plex_type=%s&mode=play&filename=%s' % (path, plex_id, v.PLEX_TYPE_EPISODE, filename)) playurl = filename @@ -491,7 +475,7 @@ class Episode(TvShowMixin, ItemBase): self.kodidb.update_ratings(kodi_id, v.KODI_TYPE_EPISODE, "default", - userdata['Rating'], + api.rating(), api.votecount(), ratingid) if api.provider('tvdb'): @@ -507,7 +491,7 @@ class Episode(TvShowMixin, ItemBase): uniqueid = -1 self.kodidb.modify_people(kodi_id, v.KODI_TYPE_EPISODE, - api.people_list()) + api.people()) if app.SYNC.artwork: self.kodidb.modify_artwork(api.artwork(), kodi_id, @@ -515,12 +499,12 @@ class Episode(TvShowMixin, ItemBase): self.kodidb.update_episode(api.title(), api.plot(), ratingid, - writer, + api.list_to_string(api.writers()), api.premiere_date(), api.runtime(), - director, - season_no, - episode_no, + api.list_to_string(api.directors()), + api.season_number(), + api.index(), api.title(), airs_before_season, airs_before_episode, @@ -528,25 +512,25 @@ class Episode(TvShowMixin, ItemBase): kodi_pathid, kodi_fileid, # and NOT kodi_fileid_2 parent_id, - userdata['UserRating'], + api.userrating(), kodi_id) self.kodidb.set_resume(kodi_fileid, api.resume_point(), api.runtime(), - userdata['PlayCount'], - userdata['LastPlayedDate']) + api.viewcount(), + api.lastplayed()) if not app.SYNC.direct_paths: self.kodidb.set_resume(kodi_fileid_2, api.resume_point(), api.runtime(), - userdata['PlayCount'], - userdata['LastPlayedDate']) + api.viewcount(), + api.lastplayed()) self.plexdb.add_episode(plex_id=plex_id, checksum=api.checksum(), section_id=section_id, - show_id=show_id, + show_id=api.show_id(), grandparent_id=grandparent_id, - season_id=season_id, + season_id=api.season_id(), parent_id=parent_id, kodi_id=kodi_id, kodi_fileid=kodi_fileid, @@ -571,7 +555,7 @@ class Episode(TvShowMixin, ItemBase): kodi_id, v.KODI_TYPE_EPISODE, "default", - userdata['Rating'], + api.rating(), api.votecount()) if api.provider('tvdb'): uniqueid = self.kodidb.add_uniqueid_id() @@ -582,7 +566,7 @@ class Episode(TvShowMixin, ItemBase): "tvdb") self.kodidb.add_people(kodi_id, v.KODI_TYPE_EPISODE, - api.people_list()) + api.people()) if app.SYNC.artwork: self.kodidb.add_artwork(api.artwork(), kodi_id, @@ -592,12 +576,12 @@ class Episode(TvShowMixin, ItemBase): api.title(), api.plot(), rating_id, - writer, + api.list_to_string(api.writers()), api.premiere_date(), api.runtime(), - director, - season_no, - episode_no, + api.list_to_string(api.directors()), + api.season_number(), + api.index(), api.title(), grandparent_id, airs_before_season, @@ -605,24 +589,24 @@ class Episode(TvShowMixin, ItemBase): playurl, kodi_pathid, parent_id, - userdata['UserRating']) + api.userrating()) self.kodidb.set_resume(kodi_fileid, api.resume_point(), api.runtime(), - userdata['PlayCount'], - userdata['LastPlayedDate']) + api.viewcount(), + api.lastplayed()) if not app.SYNC.direct_paths: self.kodidb.set_resume(kodi_fileid_2, api.resume_point(), api.runtime(), - userdata['PlayCount'], - userdata['LastPlayedDate']) + api.viewcount(), + api.lastplayed()) self.plexdb.add_episode(plex_id=plex_id, checksum=api.checksum(), section_id=section_id, - show_id=show_id, + show_id=api.show_id(), grandparent_id=grandparent_id, - season_id=season_id, + season_id=api.season_id(), parent_id=parent_id, kodi_id=kodi_id, kodi_fileid=kodi_fileid, diff --git a/resources/lib/library_sync/fanart.py b/resources/lib/library_sync/fanart.py index f14a035b..7a4b59ff 100644 --- a/resources/lib/library_sync/fanart.py +++ b/resources/lib/library_sync/fanart.py @@ -129,7 +129,7 @@ def process_fanart(plex_id, plex_type, refresh=False): db_item['kodi_type']) # Additional fanart for sets/collections if plex_type == v.PLEX_TYPE_MOVIE: - for _, setname in api.collection_list(): + for _, setname in api.collections(): LOG.debug('Getting artwork for movie set %s', setname) with KodiVideoDB() as kodidb: setid = kodidb.create_collection(setname) diff --git a/resources/lib/library_sync/get_metadata.py b/resources/lib/library_sync/get_metadata.py index c60bdc78..5e623de9 100644 --- a/resources/lib/library_sync/get_metadata.py +++ b/resources/lib/library_sync/get_metadata.py @@ -56,7 +56,7 @@ class GetMetadataTask(common.fullsync_mixin, backgroundthread.Task): [(utils.cast(int, x.get('index')), utils.cast(int, x.get('ratingKey'))) for x in COLLECTION_MATCH] item['children'] = {} - for plex_set_id, set_name in api.collection_list(): + for plex_set_id, set_name in api.collections(): if self.isCanceled(): return if plex_set_id not in COLLECTION_XMLS: diff --git a/resources/lib/library_sync/sections.py b/resources/lib/library_sync/sections.py index 5a57be7d..8a2d46b4 100644 --- a/resources/lib/library_sync/sections.py +++ b/resources/lib/library_sync/sections.py @@ -177,7 +177,7 @@ class Section(object): 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.section_type = api.plex_type self.icon = api.one_artwork('composite') self.artwork = api.one_artwork('art') self.thumb = api.one_artwork('thumb') diff --git a/resources/lib/library_sync/websocket.py b/resources/lib/library_sync/websocket.py index db096d23..f68233c1 100644 --- a/resources/lib/library_sync/websocket.py +++ b/resources/lib/library_sync/websocket.py @@ -313,9 +313,8 @@ def process_playing(data): plex_id) continue api = API(xml[0]) - userdata = api.userdata() - session['duration'] = userdata['Runtime'] - session['viewCount'] = userdata['PlayCount'] + session['duration'] = api.runtime() + session['viewCount'] = api.viewcount() # Sometimes, Plex tells us resume points in milliseconds and # not in seconds - thank you very much! if message['viewOffset'] > session['duration']: diff --git a/resources/lib/playback.py b/resources/lib/playback.py index de6c9d2a..bc956d47 100644 --- a/resources/lib/playback.py +++ b/resources/lib/playback.py @@ -332,11 +332,11 @@ def _prep_playlist_stack(xml, resume): for i, item in enumerate(xml): api = API(item) if (app.PLAYSTATE.context_menu_play is False and - api.plex_type() not in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_EPISODE)): + api.plex_type not in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_EPISODE)): # If user chose to play via PMS or force transcode, do not # use the item path stored in the Kodi DB with PlexDB(lock=False) as plexdb: - db_item = plexdb.item_by_id(api.plex_id(), api.plex_type()) + db_item = plexdb.item_by_id(api.plex_id, api.plex_type) kodi_id = db_item['kodi_id'] if db_item else None kodi_type = db_item['kodi_type'] if db_item else None else: @@ -349,7 +349,7 @@ def _prep_playlist_stack(xml, resume): kodi_id = None kodi_type = None for part, _ in enumerate(item[0]): - api.set_part_number(part) + api.part = part if kodi_id is None: # Need to redirect again to PKC to conclude playback path = api.path(force_addon=True, force_first_media=True) @@ -361,7 +361,7 @@ def _prep_playlist_stack(xml, resume): # 'plugin.video.plexkodiconnect', 1) # path = path.replace('plugin.video.plexkodiconnect.movies', # 'plugin.video.plexkodiconnect', 1) - listitem = api.create_listitem() + listitem = api.listitem() listitem.setPath(path.encode('utf-8')) else: # Will add directly via the Kodi DB @@ -458,16 +458,16 @@ def _conclude_playback(playqueue, pos): return PKC listitem attached to result """ LOG.info('Concluding playback for playqueue position %s', pos) - listitem = transfer.PKCListItem() item = playqueue.items[pos] if item.xml is not None: # Got a Plex element api = API(item.xml) - api.set_part_number(item.part) - api.create_listitem(listitem) + api.part = item.part or 0 + listitem = api.listitem(listitem=transfer.PKCListItem) playutils = PlayUtils(api, item) playurl = playutils.getPlayUrl() else: + listitem = transfer.PKCListItem() api = None playurl = item.file if not playurl: @@ -514,10 +514,9 @@ def process_indirect(key, offset, resolve=True): return api = API(xml[0]) - listitem = transfer.PKCListItem() - api.create_listitem(listitem) + listitem = api.listitem(listitem=transfer.PKCListItem) playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()]) + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type]) playqueue.clear() item = PL.Playlist_Item() item.xml = xml[0] @@ -574,7 +573,7 @@ def play_xml(playqueue, xml, offset=None, start_plex_id=None): else playqueue.selectedItemID for startpos, video in enumerate(xml): api = API(video) - if api.plex_id() == start_item: + if api.plex_id == start_item: break else: startpos = 0 diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index 1f00cdca..d32912f8 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -423,8 +423,8 @@ def playlist_item_from_xml(xml_video_element, kodi_id=None, kodi_type=None): """ item = Playlist_Item() api = API(xml_video_element) - item.plex_id = api.plex_id() - item.plex_type = api.plex_type() + item.plex_id = api.plex_id + item.plex_type = api.plex_type # item.id will only be set if you passed in an xml_video_element from e.g. # a playQueue item.id = api.item_id() diff --git a/resources/lib/playlists/__init__.py b/resources/lib/playlists/__init__.py index c223303a..54c90550 100644 --- a/resources/lib/playlists/__init__.py +++ b/resources/lib/playlists/__init__.py @@ -170,32 +170,32 @@ def _full_sync(): return False api = API(xml_playlist) try: - old_plex_ids.remove(api.plex_id()) + old_plex_ids.remove(api.plex_id) except ValueError: pass if not sync_plex_playlist(xml=xml_playlist): continue - playlist = db.get_playlist(plex_id=api.plex_id()) + playlist = db.get_playlist(plex_id=api.plex_id) if not playlist: LOG.debug('New Plex playlist %s discovered: %s', - api.plex_id(), api.title()) + api.plex_id, api.title()) try: - kodi_pl.create(api.plex_id()) + kodi_pl.create(api.plex_id) except PlaylistError: - LOG.info('Skipping creation of playlist %s', api.plex_id()) + LOG.info('Skipping creation of playlist %s', api.plex_id) elif playlist.plex_updatedat != api.updated_at(): LOG.debug('Detected changed Plex playlist %s: %s', - api.plex_id(), api.title()) + api.plex_id, api.title()) # Since we are DELETING a playlist, we need to catch with path! try: kodi_pl.delete(playlist) except PlaylistError: - LOG.info('Skipping recreation of playlist %s', api.plex_id()) + LOG.info('Skipping recreation of playlist %s', api.plex_id) else: try: - kodi_pl.create(api.plex_id()) + kodi_pl.create(api.plex_id) except PlaylistError: - LOG.info('Could not recreate playlist %s', api.plex_id()) + LOG.info('Could not recreate playlist %s', 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(): diff --git a/resources/lib/playlists/kodi_pl.py b/resources/lib/playlists/kodi_pl.py index f9413144..147f935b 100644 --- a/resources/lib/playlists/kodi_pl.py +++ b/resources/lib/playlists/kodi_pl.py @@ -35,7 +35,7 @@ def create(plex_id): raise PlaylistError('Could not get Plex playlist %s' % plex_id) api = API(xml_metadata[0]) playlist = Playlist() - playlist.plex_id = api.plex_id() + playlist.plex_id = api.plex_id playlist.kodi_type = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()] playlist.plex_name = api.title() playlist.plex_updatedat = api.updated_at() @@ -104,24 +104,16 @@ def _write_playlist_to_file(playlist, xml): text = '#EXTCPlayListM3U::M3U\n' for element in xml: api = API(element) - append_season_episode = False - if api.plex_type() == v.PLEX_TYPE_EPISODE: - _, _, show, season_no, episode_no = api.episode_data() - try: - season_no = int(season_no) - episode_no = int(episode_no) - except ValueError: - pass - else: - append_season_episode = True - if append_season_episode: + if api.plex_type == v.PLEX_TYPE_EPISODE: + if api.season_number() is not None and api.index() is not None: text += ('#EXTINF:%s,%s S%.2dE%.2d - %s\n%s\n' - % (api.runtime(), show, season_no, episode_no, + % (api.runtime(), api.show_title(), + api.season_number(), api.index(), api.title(), api.path())) else: # Only append the TV show name text += ('#EXTINF:%s,%s - %s\n%s\n' - % (api.runtime(), show, api.title(), api.path())) + % (api.runtime(), api.show_title(), api.title(), api.path())) else: text += ('#EXTINF:%s,%s\n%s\n' % (api.runtime(), api.title(), api.path())) diff --git a/resources/lib/playlists/pms.py b/resources/lib/playlists/pms.py index b909b8e4..b4de8e4a 100644 --- a/resources/lib/playlists/pms.py +++ b/resources/lib/playlists/pms.py @@ -68,7 +68,7 @@ def initialize(playlist, plex_id): plex_id) raise PlaylistError('Could not initialize Plex playlist %s', plex_id) api = API(xml[0]) - playlist.plex_id = api.plex_id() + playlist.plex_id = api.plex_id playlist.plex_updatedat = api.updated_at() @@ -121,7 +121,7 @@ def add_items(playlist, plex_ids): raise PlaylistError('Could not add items to a new Plex playlist %s' % playlist) api = API(xml[0]) - playlist.plex_id = api.plex_id() + playlist.plex_id = api.plex_id playlist.plex_updatedat = api.updated_at() diff --git a/resources/lib/playqueue.py b/resources/lib/playqueue.py index 929ccc75..f9fed4e4 100644 --- a/resources/lib/playqueue.py +++ b/resources/lib/playqueue.py @@ -86,7 +86,7 @@ def init_playqueue_from_plex_children(plex_id, transient_token=None): playqueue.clear() for i, child in enumerate(xml): api = API(child) - PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id()) + PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id) playqueue.plex_transient_token = transient_token LOG.debug('Firing up Kodi player') app.APP.player.play(playqueue.kodi_pl, None, False, 0) diff --git a/resources/lib/playutils.py b/resources/lib/playutils.py index 773f9fb8..755dded1 100644 --- a/resources/lib/playutils.py +++ b/resources/lib/playutils.py @@ -66,7 +66,7 @@ class PlayUtils(): if path is not None and path.endswith('.strm'): LOG.info('.strm file detected') playurl = self.api.validate_playurl(path, - self.api.plex_type(), + self.api.plex_type, force_check=True) return playurl # set to either 'Direct Stream=1' or 'Transcode=2' @@ -78,7 +78,7 @@ class PlayUtils(): if self.mustTranscode(): return return self.api.validate_playurl(path, - self.api.plex_type(), + self.api.plex_type, force_check=True) def mustTranscode(self): @@ -93,7 +93,7 @@ class PlayUtils(): - video bitrate above specified settings bitrate if the corresponding file settings are set to 'true' """ - if self.api.plex_type() in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SONG): + if self.api.plex_type in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SONG): LOG.info('Plex clip or music track, not transcoding') return False videoCodec = self.api.video_codec() @@ -139,7 +139,7 @@ class PlayUtils(): def isDirectStream(self): # Never transcode Music - if self.api.plex_type() == 'track': + if self.api.plex_type == 'track': return True # set to 'Transcode=2' if utils.settings('playType') == "2": diff --git a/resources/lib/plex_api.py b/resources/lib/plex_api.py deleted file mode 100644 index d3a5e718..00000000 --- a/resources/lib/plex_api.py +++ /dev/null @@ -1,1817 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -Taken from iBaa, https://github.com/iBaa/PlexConnect -Point of time: December 22, 2015 - - -Collection of "connector functions" to Plex Media Server/MyPlex - - -PlexGDM: -loosely based on hippojay's plexGDM: -https://github.com/hippojay/script.plexbmc.helper... /resources/lib/plexgdm.py - - -Plex Media Server communication: -source (somewhat): https://github.com/hippojay/plugin.video.plexbmc -later converted from httplib to urllib2 - - -Transcoder support: -PlexAPI_getTranscodePath() based on getTranscodeURL from pyplex/plexAPI -https://github.com/megawubs/pyplex/blob/master/plexAPI/info.py - - -MyPlex - Basic Authentication: -http://www.voidspace.org.uk/python/articles/urllib2.shtml -http://www.voidspace.org.uk/python/articles/authentication.shtml -http://stackoverflow.com/questions/2407126/python-urllib2-basic-auth-problem -http://stackoverflow.com/questions/111945/is-there-any-way-to-do-http-put-in-python -(and others...) -""" -from __future__ import absolute_import, division, unicode_literals -from logging import getLogger -from re import sub - -from xbmcgui import ListItem - -from .plex_db import PlexDB -from .kodi_db import KodiVideoDB, KodiMusicDB -from .utils import cast -from .downloadutils import DownloadUtils as DU -from . import clientinfo -from . import utils, timing -from . import path_ops -from . import plex_functions as PF -from . import variables as v -from . import app - -############################################################################### -LOG = getLogger('PLEX.plex_api') - -############################################################################### - - -class API(object): - """ - API(item) - - Processes a Plex media server's XML response - - item: xml.etree.ElementTree element - """ - def __init__(self, item): - self.item = item - # which media part in the XML response shall we look at? - self.part = 0 - self.mediastream = None - self.collections = None - - def set_part_number(self, number=None): - """ - Sets the part number to work with (used to deal with Movie with several - parts). - """ - self.part = number or 0 - - def plex_type(self): - """ - Returns the type of media, e.g. 'movie' or 'clip' for trailers as - Unicode or None. - """ - return self.item.get('type') - - def playlist_type(self): - """ - Returns the playlist type ('video', 'audio') or None - """ - return self.item.get('playlistType') - - def updated_at(self): - """ - Returns the last time this item was updated as an int, e.g. - 1524739868 or None - """ - return cast(int, self.item.get('updatedAt')) - - def checksum(self): - """ - Returns the unique int - """ - return int('%s%s' % (self.plex_id(), - self.updated_at() or self.item.get('addedAt', 1541572987))) - - def plex_id(self): - """ - Returns the Plex ratingKey such as 246922 as an integer or None - """ - return cast(int, self.item.get('ratingKey')) - - def path(self, force_first_media=True, force_addon=False, - direct_paths=None): - """ - Returns a "fully qualified path": add-on paths or direct paths - depending on the current settings. Will NOT valide the playurl - Returns unicode or None if something went wrong. - - Pass direct_path=True if you're calling from another Plex python - instance - because otherwise direct paths will evaluate to False! - """ - direct_paths = direct_paths or app.SYNC.direct_paths - filename = self.file_path(force_first_media=force_first_media) - if (not direct_paths or force_addon or - self.plex_type() == v.PLEX_TYPE_CLIP): - if filename and '/' in filename: - filename = filename.rsplit('/', 1) - elif filename: - filename = filename.rsplit('\\', 1) - try: - filename = filename[1] - except (TypeError, IndexError): - filename = None - # Set plugin path and media flags using real filename - if self.plex_type() == v.PLEX_TYPE_EPISODE: - # need to include the plex show id in the path - path = ('plugin://plugin.video.plexkodiconnect.tvshows/%s/' - % self.grandparent_id()) - else: - path = 'plugin://%s/' % v.ADDON_TYPE[self.plex_type()] - path = ('%s?plex_id=%s&plex_type=%s&mode=play&filename=%s' - % (path, self.plex_id(), self.plex_type(), filename)) - else: - # Direct paths is set the Kodi way - path = self.validate_playurl(filename, - self.plex_type(), - 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 utils.extend_url('plugin://%s/' % v.ADDON_ID, params) - - def path_and_plex_id(self): - """ - Returns the Plex key such as '/library/metadata/246922' or None - """ - return self.item.get('key') - - def plex_media_streams(self): - """ - Returns the media streams directly from the PMS xml. - Mind self.mediastream to be set before and self.part! - """ - return self.item[self.mediastream][self.part] - - def file_name(self, force_first_media=False): - """ - Returns only the filename, e.g. 'movie.mkv' as unicode or None if not - found - """ - ans = self.file_path(force_first_media=force_first_media) - if ans is None: - return - if "\\" in ans: - # Local path - filename = ans.rsplit("\\", 1)[1] - else: - try: - # Network share - filename = ans.rsplit("/", 1)[1] - except IndexError: - # E.g. certain Plex channels - filename = None - return filename - - def file_path(self, force_first_media=False): - """ - Returns the direct path to this item, e.g. '\\NAS\movies\movie.mkv' - as unicode or None - - force_first_media=True: - will always use 1st media stream, e.g. when several different - files are present for the same PMS item - """ - if self.mediastream is None and force_first_media is False: - if self.mediastream_number() is None: - return - try: - if force_first_media is False: - ans = cast(str, self.item[self.mediastream][self.part].attrib['file']) - else: - ans = cast(str, self.item[0][self.part].attrib['file']) - except (TypeError, AttributeError, IndexError, KeyError): - return - return utils.unquote(ans) - - def get_picture_path(self): - """ - Returns the item's picture path (transcode, if necessary) as string. - Will always use addon paths, never direct paths - """ - path = self.item[0][0].get('key') - extension = path[path.rfind('.'):].lower() - if app.SYNC.force_transcode_pix or extension not in v.KODI_SUPPORTED_IMAGES: - # Let Plex transcode - # max width/height supported by plex image transcoder is 1920x1080 - path = app.CONN.server + PF.transcode_image_path( - path, - app.ACCOUNT.pms_token, - "%s%s" % (app.CONN.server, path), - 1920, - 1080) - else: - path = self.attach_plex_token_to_url('%s%s' % (app.CONN.server, path)) - # Attach Plex id to url to let it be picked up by our playqueue agent - # later - return '%s&plex_id=%s' % (path, self.plex_id()) - - def tv_show_path(self): - """ - Returns the direct path to the TV show, e.g. '\\NAS\tv\series' - or None - """ - for child in self.item: - if child.tag == 'Location': - return child.get('path') - - def season_number(self): - """ - Returns the 'index' of an XML reply as int. Depicts e.g. season number. - """ - return cast(int, self.item.get('index')) - - def track_number(self): - """ - Returns the 'index' of an XML reply as int. Depicts track number. - """ - return cast(int, self.item.get('index')) - - def date_created(self): - """ - Returns the date when this library item was created. - - If not found, returns 2000-01-01 10:00:00 - """ - res = self.item.get('addedAt') - if res is not None: - return timing.plex_date_to_kodi(res) - else: - return '2000-01-01 10:00:00' - - def viewcount(self): - """ - Returns the play count for the item as an int or the int 0 if not found - """ - return cast(int, self.item.get('viewCount')) or 0 - - def userdata(self): - """ - Returns a dict with None if a value is missing - { - 'Favorite': favorite, # False, because n/a in Plex - 'PlayCount': playcount, - 'Played': played, # True/False - 'LastPlayedDate': lastPlayedDate, - 'Resume': resume, # Resume time in seconds - 'Runtime': runtime, - 'Rating': rating - } - """ - item = self.item.attrib - # Default - attributes not found with Plex - favorite = False - try: - playcount = int(item['viewCount']) - except (KeyError, ValueError): - playcount = None - played = True if playcount else False - - try: - last_played = timing.plex_date_to_kodi(int(item['lastViewedAt'])) - except (KeyError, ValueError): - last_played = None - - if (app.SYNC.indicate_media_versions is True and - self.plex_type() in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_EPISODE)): - userrating = 0 - for _ in self.item.findall('./Media'): - userrating += 1 - # Don't show a value of '1' - userrating = 0 if userrating == 1 else userrating - else: - try: - userrating = int(float(item['userRating'])) - except (KeyError, ValueError): - userrating = 0 - - try: - rating = float(item['audienceRating']) - except (KeyError, ValueError): - try: - rating = float(item['rating']) - except (KeyError, ValueError): - rating = 0.0 - - resume, runtime = self.resume_runtime() - return { - 'Favorite': favorite, - 'PlayCount': playcount, - 'Played': played, - 'LastPlayedDate': last_played, - 'Resume': resume, - 'Runtime': runtime, - 'Rating': rating, - '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 - [(, ), ...] - """ - collections = [] - for child in self.item: - if child.tag == 'Collection': - collections.append((cast(int, child.get('id')), - child.get('tag'))) - return collections - - def people(self): - """ - Returns a dict of lists of people found. - { - 'Director': list, - 'Writer': list, - 'Cast': list of tuples (, ), might be '' - 'Producer': list - } - """ - director = [] - writer = [] - cast = [] - producer = [] - for child in self.item: - if child.tag == 'Director': - director.append(child.attrib['tag']) - elif child.tag == 'Writer': - writer.append(child.attrib['tag']) - elif child.tag == 'Role': - cast.append((child.attrib['tag'], child.get('role', ''))) - elif child.tag == 'Producer': - producer.append(child.attrib['tag']) - return { - 'Director': director, - 'Writer': writer, - 'Cast': cast, - 'Producer': producer - } - - def people_list(self): - """ - Returns a dict with lists of tuples: - { - 'actor': [..., (, , , ), ...], - 'director': [..., (, ), ...], - 'writer': [..., (, ), ...] - } - Everything in unicode, except which is an int. - Only and may be None if not found. - - Kodi does not yet support a Producer. People may appear several times - per category and overall! - """ - people = { - 'actor': [], - 'director': [], - 'writer': [] - } - cast_order = 0 - for child in self.item: - if child.tag == 'Role': - people['actor'].append((child.attrib['tag'], - child.get('thumb'), - child.get('role'), - cast_order)) - cast_order += 1 - elif child.tag == 'Writer': - people['writer'].append((child.attrib['tag'], )) - elif child.tag == 'Director': - people['director'].append((child.attrib['tag'], )) - return people - - def genre_list(self): - """ - Returns a list of genres found. (Not a string) - """ - genre = [] - for child in self.item: - if child.tag == 'Genre': - genre.append(child.attrib['tag']) - return genre - - def guid_html_escaped(self): - """ - Returns the 'guid' attribute, e.g. - 'com.plexapp.agents.thetvdb://76648/2/4?lang=en' - as an HTML-escaped string or None - """ - answ = self.item.get('guid') - if answ is not None: - answ = utils.escape_html(answ) - return answ - - def provider(self, providername=None): - """ - providername: e.g. 'imdb', 'tvdb' - - Return IMDB, e.g. "tt0903624". Returns None if not found - """ - try: - item = self.item.attrib['guid'] - except KeyError: - return None - - if providername == 'imdb': - regex = utils.REGEX_IMDB - elif providername == 'tvdb': - # originally e.g. com.plexapp.agents.thetvdb://276564?lang=en - regex = utils.REGEX_TVDB - else: - return None - - provider = regex.findall(item) - try: - provider = provider[0] - except IndexError: - provider = None - return provider - - def votecount(self): - """ - Not implemented by Plex yet - """ - pass - - def title(self): - """ - Returns the title of the element as unicode or 'Missing Title Name' - """ - return self.item.get('title', 'Missing Title Name') - - def sorttitle(self): - """ - Returns an item's sorting name/title or the title itself if not found - "Missing Title" if both are not present - """ - return self.item.get('titleSort', self.item.get('title', 'Missing Title')) - - def artist_name(self): - """ - Returns the artist name for an album: first it attempts to return - 'parentTitle', if that failes 'originalTitle' - """ - return self.item.get('parentTitle', self.item.get('originalTitle')) - - def plot(self): - """ - Returns the plot or None. - """ - return self.item.get('summary') - - def shortplot(self): - """ - Not yet implemented - """ - pass - - def tagline(self): - """ - Returns a shorter tagline or None - """ - return self.item.get('tagline') - - def audience_rating(self): - """ - Returns the audience rating, 'rating' itself or 0.0 - """ - res = self.item.get('audienceRating') - if res is None: - res = self.item.get('rating') - try: - res = float(res) - except (ValueError, TypeError): - res = 0.0 - return res - - def year(self): - """ - Returns the production(?) year ("year") or None - """ - return self.item.get('year') - - def resume_point(self): - """ - Returns the resume point of time in seconds as float. 0.0 if not found - """ - try: - resume = float(self.item.attrib['viewOffset']) - except (KeyError, ValueError): - resume = 0.0 - return resume * v.PLEX_TO_KODI_TIMEFACTOR - - def runtime(self): - """ - Returns the total duration of the element as int. 0 if not found - """ - try: - runtime = float(self.item.attrib['duration']) - except (KeyError, ValueError): - runtime = 0.0 - return int(runtime * v.PLEX_TO_KODI_TIMEFACTOR) - - def resume_runtime(self): - """ - Resume point of time and runtime/totaltime in rounded to seconds. - Time from Plex server is measured in milliseconds. - Kodi: seconds - - Output is the tuple: - resume, runtime as ints. 0 if not found - """ - try: - runtime = float(self.item.attrib['duration']) - except (KeyError, ValueError): - runtime = 0.0 - try: - resume = float(self.item.attrib['viewOffset']) - except (KeyError, ValueError): - resume = 0.0 - runtime = runtime * v.PLEX_TO_KODI_TIMEFACTOR - resume = resume * v.PLEX_TO_KODI_TIMEFACTOR - return resume, runtime - - def content_rating(self): - """ - Get the content rating or None - """ - mpaa = self.item.get('contentRating') - if mpaa is None: - return - # Convert more complex cases - if mpaa in ("NR", "UR"): - # Kodi seems to not like NR, but will accept Rated Not Rated - mpaa = "Rated Not Rated" - elif mpaa.startswith('gb/'): - mpaa = mpaa.replace('gb/', 'UK:', 1) - return mpaa - - def country_list(self): - """ - Returns a list of all countries found in item. - """ - country = [] - for child in self.item: - if child.tag == 'Country': - country.append(child.attrib['tag']) - return country - - def premiere_date(self): - """ - Returns the "originallyAvailableAt", e.g. "2018-11-16" or None - """ - return self.item.get('originallyAvailableAt') - - def music_studio(self): - """ - Returns the 'studio' or None - """ - return self.replace_studio(self.item.get('studio')) - - def music_studio_list(self): - """ - Returns a list with a single entry for the studio, or an empty list - """ - studio = self.music_studio() - if studio: - return [studio] - return [] - - @staticmethod - def replace_studio(studio_name): - """ - Convert studio for Kodi to properly detect them - """ - if not studio_name: - return - studios = { - 'abc (us)': "ABC", - 'fox (us)': "FOX", - 'mtv (us)': "MTV", - 'showcase (ca)': "Showcase", - 'wgn america': "WGN" - } - return studios.get(studio_name.lower(), studio_name) - - @staticmethod - def list_to_string(listobject): - """ - Smart-joins the listobject into a single string using a " / " separator - If the list is empty, smart_join returns an empty string. - """ - string = " / ".join(listobject) - return string - - def parent_id(self): - """ - Returns the 'parentRatingKey' as a string or None - """ - return cast(int, self.item.get('parentRatingKey')) - - def grandparent_id(self): - """ - Returns the ratingKey for the corresponding grandparent, e.g. a TV show - for episodes, or None - """ - return cast(int, self.item.get('grandparentRatingKey')) - - def grandparent_title(self): - """ - Returns the title for the corresponding grandparent, e.g. a TV show - name for episodes, or None - """ - return self.item.get('grandparentTitle') - - def episode_data(self): - """ - Call on a single episode. - - Output: for the corresponding the TV show and season: - [ - TV show ID, Plex: 'grandparentRatingKey' - TV season ID, Plex: 'grandparentRatingKey' - TV show title, Plex: 'grandparentTitle' - TV show season, Plex: 'parentIndex' - Episode number, Plex: 'index' - ] - """ - return (cast(int, self.item.get('grandparentRatingKey')), - cast(int, self.item.get('parentRatingKey')), - self.item.get('grandparentTitle'), - cast(int, self.item.get('parentIndex')), - cast(int, self.item.get('index'))) - - @staticmethod - def attach_plex_token_to_url(url): - """ - Returns an extended URL with the Plex token included as 'X-Plex-Token=' - - url may or may not already contain a '?' - """ - if not app.ACCOUNT.pms_token: - return url - if '?' not in url: - url = "%s?X-Plex-Token=%s" % (url, app.ACCOUNT.pms_token) - else: - url = "%s&X-Plex-Token=%s" % (url, app.ACCOUNT.pms_token) - return url - - def item_id(self): - """ - Returns current playQueueItemID or if unsuccessful the playListItemID - as Unicode. - If not found, None is returned - """ - return (cast(int, self.item.get('playQueueItemID')) or - cast(int, self.item.get('playListItemID'))) - - def _data_from_part_or_media(self, key): - """ - Retrieves XML data 'key' first from the active part. If unsuccessful, - tries to retrieve the data from the Media response part. - - If all fails, None is returned. - """ - answ = self.item[0][self.part].get(key) - if answ is None: - answ = self.item[0].get(key) - return answ - - def video_codec(self): - """ - Returns the video codec and resolution for the child and part selected. - If any data is not found on a part-level, the Media-level data is - returned. - If that also fails (e.g. for old trailers, None is returned) - - Output: - { - 'videocodec': xxx, e.g. 'h264' - 'resolution': xxx, e.g. '720' or '1080' - 'height': xxx, e.g. '816' - 'width': xxx, e.g. '1920' - 'aspectratio': xxx, e.g. '1.78' - 'bitrate': xxx, e.g. '10642' - 'container': xxx e.g. 'mkv', - 'bitDepth': xxx e.g. '8', '10' - } - """ - answ = { - 'videocodec': self._data_from_part_or_media('videoCodec'), - 'resolution': self._data_from_part_or_media('videoResolution'), - 'height': self._data_from_part_or_media('height'), - 'width': self._data_from_part_or_media('width'), - 'aspectratio': self._data_from_part_or_media('aspectratio'), - 'bitrate': self._data_from_part_or_media('bitrate'), - 'container': self._data_from_part_or_media('container'), - } - try: - answ['bitDepth'] = self.item[0][self.part][self.mediastream].get('bitDepth') - except (TypeError, AttributeError, KeyError, IndexError): - answ['bitDepth'] = None - return answ - - def extras(self): - """ - Returns a list of XML etree elements for each extra, e.g. a trailer. - """ - answ = [] - for extras in self.item.iterfind('Extras'): - for extra in extras: - answ.append(extra) - return answ - - def trailer(self): - """ - Returns the URL for a single trailer (local trailer preferred; first - trailer found returned) or an add-on path to list all Plex extras - if the user setting showExtrasInsteadOfTrailer is set. - Returns None if nothing is found. - """ - url = None - for extras in self.item.iterfind('Extras'): - # There will always be only 1 extras element - if (len(extras) > 0 and - app.SYNC.show_extras_instead_of_playing_trailer): - return ('plugin://%s?mode=route_to_extras&plex_id=%s' - % (v.ADDON_ID, self.plex_id())) - for extra in extras: - try: - typus = int(extra.attrib['extraType']) - except (KeyError, TypeError): - typus = None - if typus != 1: - # Skip non-trailers - continue - if extra.get('guid', '').startswith('file:'): - url = extra.get('ratingKey') - # Always prefer local trailers (first one listed) - break - elif not url: - url = extra.get('ratingKey') - if url: - url = ('plugin://%s.movies/?plex_id=%s&plex_type=%s&mode=play' - % (v.ADDON_ID, url, v.PLEX_TYPE_CLIP)) - return url - - def mediastreams(self): - """ - Returns the media streams for metadata purposes - - Output: each track contains a dictionaries - { - 'video': videotrack-list, 'codec', 'height', 'width', - 'aspect', 'video3DFormat' - 'audio': audiotrack-list, 'codec', 'channels', - 'language' - 'subtitle': list of subtitle languages (or "Unknown") - } - """ - videotracks = [] - audiotracks = [] - subtitlelanguages = [] - try: - # Sometimes, aspectratio is on the "toplevel" - aspect = cast(float, self.item[0].get('aspectRatio')) - except IndexError: - # There is no stream info at all, returning empty - return { - 'video': videotracks, - 'audio': audiotracks, - 'subtitle': subtitlelanguages - } - # Loop over parts - for child in self.item[0]: - container = child.get('container') - # Loop over Streams - for stream in child: - media_type = int(stream.get('streamType', 999)) - track = {} - if media_type == 1: # Video streams - if 'codec' in stream.attrib: - track['codec'] = stream.get('codec').lower() - if "msmpeg4" in track['codec']: - track['codec'] = "divx" - elif "mpeg4" in track['codec']: - pass - elif "h264" in track['codec']: - if container in ("mp4", "mov", "m4v"): - track['codec'] = "avc1" - track['height'] = cast(int, stream.get('height')) - track['width'] = cast(int, stream.get('width')) - # track['Video3DFormat'] = item.get('Video3DFormat') - track['aspect'] = cast(float, - stream.get('aspectRatio') or aspect) - track['duration'] = self.runtime() - track['video3DFormat'] = None - videotracks.append(track) - elif media_type == 2: # Audio streams - if 'codec' in stream.attrib: - track['codec'] = stream.get('codec').lower() - if ("dca" in track['codec'] and - "ma" in stream.get('profile', '').lower()): - track['codec'] = "dtshd_ma" - track['channels'] = cast(int, stream.get('channels')) - # 'unknown' if we cannot get language - track['language'] = stream.get('languageCode', - utils.lang(39310).lower()) - audiotracks.append(track) - elif media_type == 3: # Subtitle streams - # 'unknown' if we cannot get language - subtitlelanguages.append( - stream.get('languageCode', utils.lang(39310)).lower()) - return { - 'video': videotracks, - 'audio': audiotracks, - 'subtitle': subtitlelanguages - } - - 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: - try: - # e.g. Plex collections where artwork already contains - # width and height. Need to upscale for better resolution - artwork, args = artwork.split('?') - args = dict(utils.parse_qsl(args)) - width = int(args.get('width', 400)) - height = int(args.get('height', 400)) - # 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 - pass - artwork = '%s?width=%s&height=%s' % (artwork, width, height) - artwork = ('%s/photo/:/transcode?width=1920&height=1920&' - 'minSize=1&upscale=0&url=%s' - % (app.CONN.server, utils.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). - - Output ('max' version) - { - 'thumb' - 'poster' - 'banner' - 'clearart' - 'clearlogo' - 'fanart' - } - 'landscape' and 'icon' might be implemented later - Passing full_artwork=True returns ALL the artwork for the item, so not - just 'thumb' for episodes, but also season and show artwork - """ - if self.plex_type() == v.PLEX_TYPE_EPISODE: - 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: - with KodiVideoDB(lock=False) as kodidb: - return kodidb.get_art(kodi_id, kodi_type) - else: - with KodiMusicDB(lock=False) as kodidb: - return kodidb.get_art(kodi_id, kodi_type) - - for kodi_artwork, plex_artwork in v.KODI_TO_PLEX_ARTWORK.iteritems(): - art = self.one_artwork(plex_artwork) - if art: - artworks[kodi_artwork] = art - if self.plex_type() in (v.PLEX_TYPE_SONG, v.PLEX_TYPE_ALBUM): - # Get parent item artwork if the main item is missing artwork - if 'fanart' not in artworks: - art = self.one_artwork('parentArt') - if art: - artworks['fanart1'] = art - if 'poster' not in artworks: - art = self.one_artwork('parentThumb') - if art: - artworks['poster'] = art - if self.plex_type() in (v.PLEX_TYPE_SONG, - v.PLEX_TYPE_ALBUM, - v.PLEX_TYPE_ARTIST): - # need to set poster also as thumb - 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): - """ - Downloads additional fanart from third party sources (well, link to - fanart only). - """ - external_id = self.retrieve_external_item_id() - if external_id is not None: - artworks = self.lookup_fanart_tv(external_id[0], artworks) - return artworks - - def retrieve_external_item_id(self, collection=False): - """ - Returns the set - media_id [unicode]: the item's IMDB id for movies or tvdb id for - TV shows - poster [unicode]: path to the item's poster artwork - background [unicode]: path to the item's background artwork - - The last two might be None if not found. Generally None is returned - if unsuccessful. - - If not found in item's Plex metadata, check themovidedb.org. - """ - item = self.item.attrib - media_type = item.get('type') - media_id = None - # Return the saved Plex id's, if applicable - # Always seek collection's ids since not provided by PMS - if collection is False: - if media_type == v.PLEX_TYPE_MOVIE: - media_id = self.provider('imdb') - elif media_type == v.PLEX_TYPE_SHOW: - media_id = self.provider('tvdb') - if media_id is not None: - return media_id, None, None - LOG.info('Plex did not provide ID for IMDB or TVDB. Start ' - 'lookup process') - else: - LOG.debug('Start movie set/collection lookup on themoviedb with %s', - item.get('title', '')) - - api_key = utils.settings('themoviedbAPIKey') - if media_type == v.PLEX_TYPE_SHOW: - media_type = 'tv' - title = item.get('title', '') - # if the title has the year in remove it as tmdb cannot deal with it... - # replace e.g. 'The Americans (2015)' with 'The Americans' - title = sub(r'\s*\(\d{4}\)$', '', title, count=1) - url = 'https://api.themoviedb.org/3/search/%s' % media_type - parameters = { - 'api_key': api_key, - 'language': v.KODILANGUAGE, - 'query': utils.try_encode(title) - } - data = DU().downloadUrl(url, - authenticate=False, - parameters=parameters, - timeout=7) - try: - data.get('test') - except AttributeError: - LOG.warning('Could not download data from FanartTV') - return - if not data.get('results'): - LOG.info('No match found on themoviedb for type: %s, title: %s', - media_type, title) - return - - year = item.get('year') - match_found = None - # find year match - if year: - for entry in data['results']: - if year in entry.get('first_air_date', ''): - match_found = entry - break - elif year in entry.get('release_date', ''): - match_found = entry - break - # find exact match based on title, if we haven't found a year match - if match_found is None: - LOG.info('No themoviedb match found using year %s', year) - replacements = ( - ' ', - '-', - '&', - ',', - ':', - ';' - ) - for entry in data['results']: - name = entry.get('name', entry.get('title', '')) - original_name = entry.get('original_name', '') - title_alt = title.lower() - name_alt = name.lower() - org_name_alt = original_name.lower() - for replace_string in replacements: - title_alt = title_alt.replace(replace_string, '') - name_alt = name_alt.replace(replace_string, '') - org_name_alt = org_name_alt.replace(replace_string, '') - if name == title or original_name == title: - # match found for exact title name - match_found = entry - break - elif (name.split(' (')[0] == title or title_alt == name_alt - or title_alt == org_name_alt): - # match found with substituting some stuff - match_found = entry - break - - # if a match was not found, we accept the closest match from TMDB - if match_found is None and data.get('results'): - LOG.info('Using very first match from themoviedb') - match_found = entry = data.get('results')[0] - - if match_found is None: - LOG.info('Still no themoviedb match for type: %s, title: %s, ' - 'year: %s', media_type, title, year) - LOG.debug('themoviedb answer was %s', data['results']) - return - - LOG.info('Found themoviedb match for %s: %s', - item.get('title'), match_found) - - tmdb_id = str(entry.get('id', '')) - if tmdb_id == '': - LOG.error('No themoviedb ID found, aborting') - return - - if media_type == 'multi' and entry.get('media_type'): - media_type = entry.get('media_type') - name = entry.get('name', entry.get('title')) - # lookup external tmdb_id and perform artwork lookup on fanart.tv - parameters = {'api_key': api_key} - if media_type == 'movie': - url = 'https://api.themoviedb.org/3/movie/%s' % tmdb_id - parameters['append_to_response'] = 'videos' - elif media_type == 'tv': - url = 'https://api.themoviedb.org/3/tv/%s' % tmdb_id - parameters['append_to_response'] = 'external_ids,videos' - media_id, poster, background = None, None, None - for language in [v.KODILANGUAGE, 'en']: - parameters['language'] = language - data = DU().downloadUrl(url, - authenticate=False, - parameters=parameters, - timeout=7) - try: - data.get('test') - except AttributeError: - LOG.warning('Could not download %s with parameters %s', - url, parameters) - continue - if collection is False: - if data.get('imdb_id'): - media_id = str(data.get('imdb_id')) - break - if (data.get('external_ids') and - data['external_ids'].get('tvdb_id')): - media_id = str(data['external_ids']['tvdb_id']) - break - else: - if not data.get('belongs_to_collection'): - continue - media_id = data.get('belongs_to_collection').get('id') - if not media_id: - continue - media_id = str(media_id) - LOG.debug('Retrieved collections tmdb id %s for %s', - media_id, title) - url = 'https://api.themoviedb.org/3/collection/%s' % media_id - data = DU().downloadUrl(url, - authenticate=False, - parameters=parameters, - timeout=7) - try: - data.get('poster_path') - except AttributeError: - LOG.debug('Could not find TheMovieDB poster paths for %s' - ' in the language %s', title, language) - continue - if not poster and data.get('poster_path'): - poster = ('https://image.tmdb.org/t/p/original%s' % - data.get('poster_path')) - if not background and data.get('backdrop_path'): - background = ('https://image.tmdb.org/t/p/original%s' % - data.get('backdrop_path')) - return media_id, poster, background - - def lookup_fanart_tv(self, media_id, artworks): - """ - perform artwork lookup on fanart.tv - - media_id: IMDB id for movies, tvdb id for TV shows - """ - api_key = utils.settings('FanArtTVAPIKey') - typus = self.plex_type() - if typus == v.PLEX_TYPE_SHOW: - typus = 'tv' - - if typus == v.PLEX_TYPE_MOVIE: - url = 'http://webservice.fanart.tv/v3/movies/%s?api_key=%s' \ - % (media_id, api_key) - elif typus == 'tv': - url = 'http://webservice.fanart.tv/v3/tv/%s?api_key=%s' \ - % (media_id, api_key) - else: - # Not supported artwork - return artworks - data = DU().downloadUrl(url, authenticate=False, timeout=15) - try: - data.get('test') - except AttributeError: - LOG.error('Could not download data from FanartTV') - return artworks - - fanart_tv_types = list(v.FANART_TV_TO_KODI_TYPE) - - if typus == v.PLEX_TYPE_ARTIST: - fanart_tv_types.append(("thumb", "folder")) - else: - fanart_tv_types.append(("thumb", "thumb")) - - prefixes = ( - "hd" + typus, - "hd", - typus, - "", - ) - for fanart_tv_type, kodi_type in fanart_tv_types: - # Skip the ones we already have - if kodi_type in artworks: - continue - for prefix in prefixes: - fanarttvimage = prefix + fanart_tv_type - if fanarttvimage not in data: - continue - # select image in preferred language - for entry in data[fanarttvimage]: - if entry.get("lang") == v.KODILANGUAGE: - artworks[kodi_type] = \ - entry.get("url", "").replace(' ', '%20') - break - # just grab the first english OR undefinded one as fallback - # (so we're actually grabbing the more popular one) - if kodi_type not in artworks: - for entry in data[fanarttvimage]: - if entry.get("lang") in ("en", "00"): - artworks[kodi_type] = \ - entry.get("url", "").replace(' ', '%20') - break - - # grab extrafanarts in list - fanartcount = 1 if 'fanart' in artworks else '' - for prefix in prefixes: - fanarttvimage = prefix + 'background' - if fanarttvimage not in data: - continue - for entry in data[fanarttvimage]: - if entry.get("url") is None: - continue - artworks['fanart%s' % fanartcount] = \ - entry['url'].replace(' ', '%20') - try: - fanartcount += 1 - except TypeError: - fanartcount = 1 - if fanartcount >= v.MAX_BACKGROUND_COUNT: - break - return artworks - - def library_section_id(self): - """ - Returns the id of the Plex library section (for e.g. a movies section) - as an int or None - """ - return cast(int, self.item.get('librarySectionID')) - - def collections_match(self, section_id): - """ - Downloads one additional xml from the PMS in order to return a list of - tuples [(collection_id, plex_id), ...] for all collections of the - current item's Plex library sectin - Pass in the collection id of e.g. the movie's metadata - """ - if self.collections is None: - self.collections = PF.collections(section_id) - if self.collections is None: - LOG.error('Could not download collections for %s', - self.library_section_id()) - return [] - self.collections = \ - [(utils.cast(int, x.get('index')), - utils.cast(int, x.get('ratingKey'))) for x in self.collections] - return self.collections - - def set_artwork(self): - """ - Gets the URLs to the Plex artwork, or empty string if not found. - Only call on movies - """ - artworks = {} - # Plex does not get much artwork - go ahead and get the rest from - # fanart tv only for movie or tv show - external_id = self.retrieve_external_item_id(collection=True) - if external_id is not None: - external_id, poster, background = external_id - if poster is not None: - artworks['poster'] = poster - if background is not None: - artworks['fanart'] = background - artworks = self.lookup_fanart_tv(external_id, artworks) - else: - LOG.info('Did not find a set/collection ID on TheMovieDB using %s.' - ' Artwork will be missing.', self.title()) - return artworks - - def should_stream(self): - """ - Returns True if the item's 'optimizedForStreaming' is set, False other- - wise - """ - return cast(bool, self.item[0].get('optimizedForStreaming')) or False - - def mediastream_number(self): - """ - Returns the Media stream as an int (mostly 0). Will let the user choose - if several media streams are present for a PMS item (if settings are - set accordingly) - - Returns None if the user aborted selection (leaving self.mediastream at - its default of None) - """ - # How many streams do we have? - count = 0 - for entry in self.item.iterfind('./Media'): - count += 1 - if (count > 1 and ( - (self.plex_type() != v.PLEX_TYPE_CLIP and - utils.settings('bestQuality') == 'false') - or - (self.plex_type() == v.PLEX_TYPE_CLIP and - utils.settings('bestTrailer') == 'false'))): - # Several streams/files available. - dialoglist = [] - for entry in self.item.iterfind('./Media'): - # Get additional info (filename / languages) - if 'file' in entry[0].attrib: - option = entry[0].get('file') - option = path_ops.basename(option) - else: - option = self.title() or '' - # Languages of audio streams - languages = [] - for stream in entry[0]: - if (cast(int, stream.get('streamType')) == 1 and - 'language' in stream.attrib): - language = stream.get('language') - languages.append(language) - languages = ', '.join(languages) - if languages: - if option: - option = '%s (%s): ' % (option, languages) - else: - option = '%s: ' % languages - else: - option = '%s ' % option - if 'videoResolution' in entry.attrib: - res = entry.get('videoResolution') - option = '%s%sp ' % (option, res) - if 'videoCodec' in entry.attrib: - codec = entry.get('videoCodec') - option = '%s%s' % (option, codec) - option = option.strip() + ' - ' - if 'audioProfile' in entry.attrib: - profile = entry.get('audioProfile') - option = '%s%s ' % (option, profile) - if 'audioCodec' in entry.attrib: - codec = entry.get('audioCodec') - option = '%s%s ' % (option, codec) - option = cast(str, option.strip()) - dialoglist.append(option) - media = utils.dialog('select', 'Select stream', dialoglist) - LOG.info('User chose media stream number: %s', media) - if media == -1: - LOG.info('User cancelled media stream selection') - return - else: - media = 0 - self.mediastream = media - return media - - def transcode_video_path(self, action, quality=None): - """ - - To be called on a VIDEO level of PMS xml response! - - Transcode Video support; returns the URL to get a media started - - Input: - action 'DirectStream' or 'Transcode' - - quality: { - 'videoResolution': e.g. '1024x768', - 'videoQuality': e.g. '60', - 'maxVideoBitrate': e.g. '2000' (in kbits) - } - (one or several of these options) - Output: - final URL to pull in PMS transcoder - - TODO: mediaIndex - """ - if self.mediastream is None and self.mediastream_number() is None: - return - quality = {} if quality is None else quality - xargs = clientinfo.getXArgsDeviceInfo() - # For DirectPlay, path/key of PART is needed - # trailers are 'clip' with PMS xmls - if action == "DirectStream": - path = self.item[self.mediastream][self.part].get('key') - url = app.CONN.server + path - # e.g. Trailers already feature an '?'! - return utils.extend_url(url, xargs) - - # For Transcoding - headers = { - 'X-Plex-Platform': 'Android', - 'X-Plex-Platform-Version': '7.0', - 'X-Plex-Product': 'Plex for Android', - 'X-Plex-Version': '5.8.0.475' - } - # Path/key to VIDEO item of xml PMS response is needed, not part - path = self.item.get('key') - transcode_path = app.CONN.server + \ - '/video/:/transcode/universal/start.m3u8' - args = { - 'audioBoost': utils.settings('audioBoost'), - 'autoAdjustQuality': 0, - 'directPlay': 0, - 'directStream': 1, - 'protocol': 'hls', # seen in the wild: 'dash', 'http', 'hls' - 'session': v.PKC_MACHINE_IDENTIFIER, # TODO: create new unique id - 'fastSeek': 1, - 'path': path, - 'mediaIndex': self.mediastream, - 'partIndex': self.part, - 'hasMDE': 1, - 'location': 'lan', - 'subtitleSize': utils.settings('subtitleSize') - } - LOG.debug("Setting transcode quality to: %s", quality) - xargs.update(headers) - xargs.update(args) - xargs.update(quality) - return utils.extend_url(transcode_path, xargs) - - def cache_external_subs(self): - """ - Downloads external subtitles temporarily to Kodi and returns a list - of their paths - """ - externalsubs = [] - try: - mediastreams = self.item[0][self.part] - except (TypeError, KeyError, IndexError): - return - kodiindex = 0 - fileindex = 0 - for stream in mediastreams: - # Since plex returns all possible tracks together, have to pull - # only external subtitles - only for these a 'key' exists - if cast(int, stream.get('streamType')) != 3: - # Not a subtitle - continue - # Only set for additional external subtitles NOT lying beside video - key = stream.get('key') - # Only set for dedicated subtitle files lying beside video - # ext = stream.attrib.get('format') - if key: - # We do know the language - temporarily download - if stream.get('languageCode') is not None: - language = stream.get('languageCode') - codec = stream.get('codec') - path = self.download_external_subtitles( - "{server}%s" % key, - "subtitle%02d.%s.%s" % (fileindex, language, codec)) - fileindex += 1 - # We don't know the language - no need to download - else: - path = self.attach_plex_token_to_url( - "%s%s" % (app.CONN.server, key)) - externalsubs.append(path) - kodiindex += 1 - LOG.info('Found external subs: %s', externalsubs) - return externalsubs - - @staticmethod - def download_external_subtitles(url, filename): - """ - One cannot pass the subtitle language for ListItems. Workaround; will - download the subtitle at url to the Kodi PKC directory in a temp dir - - Returns the path to the downloaded subtitle or None - """ - path = path_ops.path.join(v.EXTERNAL_SUBTITLE_TEMP_PATH, filename) - response = DU().downloadUrl(url, return_response=True) - try: - response.status_code - except AttributeError: - LOG.error('Could not temporarily download subtitle %s', url) - return - else: - LOG.debug('Writing temp subtitle to %s', path) - with open(path_ops.encode_path(path), 'wb') as filer: - filer.write(response.content) - return path - - def kodi_premiere_date(self): - """ - Takes Plex' originallyAvailableAt of the form "yyyy-mm-dd" and returns - Kodi's "dd.mm.yyyy" or None - """ - date = self.premiere_date() - if date is None: - return - try: - date = sub(r'(\d+)-(\d+)-(\d+)', r'\3.\2.\1', date) - except Exception: - date = None - return date - - def create_listitem(self, listitem=None, append_show_title=False, - append_sxxexx=False): - """ - Return a xbmcgui.ListItem() for this Plex item - """ - if self.plex_type() == v.PLEX_TYPE_PHOTO: - listitem = self._create_photo_listitem(listitem) - # Only set the bare minimum of artwork - listitem.setArt({'icon': 'DefaultPicture.png', - 'fanart': self.one_artwork('thumb')}) - elif self.plex_type() == v.PLEX_TYPE_SONG: - listitem = self._create_audio_listitem(listitem) - listitem.setArt(self.artwork()) - else: - listitem = self._create_video_listitem(listitem, - append_show_title, - append_sxxexx) - self.add_video_streams(listitem) - listitem.setArt(self.artwork(full_artwork=True)) - return listitem - - def _create_photo_listitem(self, listitem=None): - """ - Use for photo items only - """ - title = self.title() - if listitem is None: - listitem = ListItem(title) - else: - listitem.setLabel(title) - metadata = { - 'date': self.kodi_premiere_date(), - 'size': long(self.item[0][0].get('size', 0)), - 'exif:width': self.item[0].get('width', ''), - 'exif:height': self.item[0].get('height', ''), - } - listitem.setInfo(type='image', infoLabels=metadata) - listitem.setProperty('plot', self.plot()) - listitem.setProperty('plexid', str(self.plex_id())) - return listitem - - def _create_video_listitem(self, - listitem=None, - append_show_title=False, - append_sxxexx=False): - """ - 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 - """ - return cast(int, self.item.get('parentIndex')) - - def _create_audio_listitem(self, listitem=None): - """ - Use for songs 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 - - Returns XBMC listitem for this PMS library item - """ - if listitem is None: - listitem = ListItem(self.title()) - else: - listitem.setLabel(self.title()) - listitem.setProperty('IsPlayable', 'true') - userdata = self.userdata() - metadata = { - 'mediatype': 'song', - 'tracknumber': self.track_number(), - 'discnumber': self.track_number(), - 'duration': userdata['Runtime'], - 'year': self.year(), - # Kodi does not support list of str - 'genre': ','.join(self.genre_list()) or None, - 'album': self.item.get('parentTitle'), - 'artist': self.item.get('originalTitle') or self.grandparent_title(), - 'title': self.title(), - 'rating': self.audience_rating(), - 'playcount': userdata['PlayCount'], - 'lastplayed': userdata['LastPlayedDate'], - # lyrics string (On a dark desert highway...) - # userrating integer - range is 1..10 - # comment string (This is a great song) - # listeners integer (25614) - # musicbrainztrackid string (cd1de9af-0b71-4503-9f96-9f5efe27923c) - # musicbrainzartistid string (d87e52c5-bb8d-4da8-b941-9f4928627dc8) - # musicbrainzalbumid string (24944755-2f68-3778-974e-f572a9e30108) - # musicbrainzalbumartistid string (d87e52c5-bb8d-4da8-b941-9f4928627dc8) - } - plex_id = self.plex_id() - listitem.setProperty('plexid', str(plex_id)) - if v.KODIVERSION >= 18: - with PlexDB() as plexdb: - db_item = plexdb.item_by_id(plex_id, self.plex_type()) - if db_item: - metadata['dbid'] = db_item['kodi_id'] - listitem.setInfo('music', infoLabels=metadata) - return listitem - - def add_video_streams(self, listitem): - """ - Add media stream information to xbmcgui.ListItem - """ - for key, value in self.mediastreams().iteritems(): - if value: - listitem.addStreamInfo(key, value) - - def validate_playurl(self, path, typus, force_check=False, folder=False, - omit_check=False): - """ - Returns a valid path for Kodi, e.g. with '\' substituted to '\\' in - Unicode. Returns None if this is not possible - - path : Unicode - typus : Plex type from PMS xml - force_check : Will always try to check validity of path - Will also skip confirmation dialog if path not found - folder : Set to True if path is a folder - omit_check : Will entirely omit validity check if True - """ - if path is None: - return - typus = v.REMAP_TYPE_FROM_PLEXTYPE[typus] - if app.SYNC.remap_path: - path = path.replace(getattr(app.SYNC, 'remapSMB%sOrg' % typus), - getattr(app.SYNC, 'remapSMB%sNew' % typus), - 1) - # There might be backslashes left over: - path = path.replace('\\', '/') - elif app.SYNC.replace_smb_path: - if path.startswith('\\\\'): - path = 'smb:' + path.replace('\\', '/') - if app.SYNC.escape_path: - try: - protocol, hostname, args = path.split(':', 2) - except ValueError: - pass - else: - args = utils.quote(args) - path = '%s:%s:%s' % (protocol, hostname, args) - if (app.SYNC.path_verified and not force_check) or omit_check: - return path - - # exist() needs a / or \ at the end to work for directories - if not folder: - # files - check = path_ops.exists(path) - else: - # directories - if "\\" in path: - if not path.endswith('\\'): - # Add the missing backslash - check = path_ops.exists(path + "\\") - else: - check = path_ops.exists(path) - else: - if not path.endswith('/'): - check = path_ops.exists(path + "/") - else: - check = path_ops.exists(path) - if not check: - if force_check is False: - # Validate the path is correct with user intervention - if self.ask_to_validate(path): - app.APP.stop_threads(block=False) - path = None - app.SYNC.path_verified = True - else: - path = None - elif not force_check: - # Only set the flag if we were not force-checking the path - app.SYNC.path_verified = True - return path - - @staticmethod - def ask_to_validate(url): - """ - Displays a YESNO dialog box: - Kodi can't locate file: . Please verify the path. - You may need to verify your network credentials in the - add-on settings or use different Plex paths. Stop syncing? - - Returns True if sync should stop, else False - """ - LOG.warn('Cannot access file: %s', url) - # Kodi cannot locate the file #s. Please verify your PKC settings. Stop - # syncing? - return utils.yesno_dialog(utils.lang(29999), utils.lang(39031) % url) diff --git a/resources/lib/plex_api/__init__.py b/resources/lib/plex_api/__init__.py new file mode 100644 index 00000000..7a215018 --- /dev/null +++ b/resources/lib/plex_api/__init__.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +plex_api interfaces with all Plex Media Server (and plex.tv) xml responses +""" +from __future__ import absolute_import, division, unicode_literals + +from .base import Base +from .artwork import Artwork +from .file import File +from .media import Media +from .user import User + +from ..plex_db import PlexDB + + +class API(Base, Artwork, File, Media, User): + pass + + +def mass_api(xml): + """ + Pass in an entire XML PMS response with e.g. several movies or episodes + Will Look-up Kodi ids in the Plex.db for every element (thus speeding up + this process for several PMS items!) + """ + apis = [API(x) for x in xml] + with PlexDB(lock=False) as plexdb: + for api in apis: + api.check_db(plexdb=plexdb) + return apis diff --git a/resources/lib/plex_api/artwork.py b/resources/lib/plex_api/artwork.py new file mode 100644 index 00000000..c5f3d62d --- /dev/null +++ b/resources/lib/plex_api/artwork.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +from re import sub + +from ..kodi_db import KodiVideoDB, KodiMusicDB +from ..downloadutils import DownloadUtils as DU +from .. import utils, variables as v, app + +LOG = getLogger('PLEX.api') + + +class Artwork(object): + 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 + else: + raise NotImplementedError('aspect ratio not yet implemented: %s' + % aspect) + artwork = self.xml.get(art_kind) + if not artwork or artwork.startswith('http'): + return artwork + if '/composite/' in artwork: + try: + # e.g. Plex collections where artwork already contains width and + # height. Need to upscale for better resolution + artwork, args = artwork.split('?') + args = dict(utils.parse_qsl(args)) + width = int(args.get('width', 400)) + height = int(args.get('height', 400)) + # 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 + pass + artwork = '%s?width=%s&height=%s' % (artwork, width, height) + artwork = ('%s/photo/:/transcode?width=1920&height=1920&' + 'minSize=1&upscale=0&url=%s' + % (app.CONN.server, utils.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). + + Output ('max' version) + { + 'thumb' + 'poster' + 'banner' + 'clearart' + 'clearlogo' + 'fanart' + } + 'landscape' and 'icon' might be implemented later + Passing full_artwork=True returns ALL the artwork for the item, so not + just 'thumb' for episodes, but also season and show artwork + """ + if self.plex_type == v.PLEX_TYPE_EPISODE: + 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: + with KodiVideoDB(lock=False) as kodidb: + return kodidb.get_art(kodi_id, kodi_type) + else: + with KodiMusicDB(lock=False) as kodidb: + return kodidb.get_art(kodi_id, kodi_type) + + for kodi_artwork, plex_artwork in v.KODI_TO_PLEX_ARTWORK.iteritems(): + art = self.one_artwork(plex_artwork) + if art: + artworks[kodi_artwork] = art + if self.plex_type in (v.PLEX_TYPE_SONG, v.PLEX_TYPE_ALBUM): + # Get parent item artwork if the main item is missing artwork + if 'fanart' not in artworks: + art = self.one_artwork('parentArt') + if art: + artworks['fanart1'] = art + if 'poster' not in artworks: + art = self.one_artwork('parentThumb') + if art: + artworks['poster'] = art + if self.plex_type in (v.PLEX_TYPE_SONG, + v.PLEX_TYPE_ALBUM, + v.PLEX_TYPE_ARTIST): + # need to set poster also as thumb + 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): + """ + Downloads additional fanart from third party sources (well, link to + fanart only). + """ + external_id = self.retrieve_external_item_id() + if external_id is not None: + artworks = self.lookup_fanart_tv(external_id[0], artworks) + return artworks + + def set_artwork(self): + """ + Gets the URLs to the Plex artwork, or empty string if not found. + Only call on movies! + """ + artworks = {} + # Plex does not get much artwork - go ahead and get the rest from + # fanart tv only for movie or tv show + external_id = self.retrieve_external_item_id(collection=True) + if external_id is not None: + external_id, poster, background = external_id + if poster is not None: + artworks['poster'] = poster + if background is not None: + artworks['fanart'] = background + artworks = self.lookup_fanart_tv(external_id, artworks) + else: + LOG.info('Did not find a set/collection ID on TheMovieDB using %s.' + ' Artwork will be missing.', self.title()) + return artworks + + def retrieve_external_item_id(self, collection=False): + """ + Returns the set + media_id [unicode]: the item's IMDB id for movies or tvdb id for + TV shows + poster [unicode]: path to the item's poster artwork + background [unicode]: path to the item's background artwork + + The last two might be None if not found. Generally None is returned + if unsuccessful. + + If not found in item's Plex metadata, check themovidedb.org. + """ + item = self.xml.attrib + media_type = self.plex_type + media_id = None + # Return the saved Plex id's, if applicable + # Always seek collection's ids since not provided by PMS + if collection is False: + if media_type == v.PLEX_TYPE_MOVIE: + media_id = self.provider('imdb') + elif media_type == v.PLEX_TYPE_SHOW: + media_id = self.provider('tvdb') + if media_id is not None: + return media_id, None, None + LOG.info('Plex did not provide ID for IMDB or TVDB. Start ' + 'lookup process') + else: + LOG.debug('Start movie set/collection lookup on themoviedb with %s', + item.get('title', '')) + + api_key = utils.settings('themoviedbAPIKey') + if media_type == v.PLEX_TYPE_SHOW: + media_type = 'tv' + title = self.title() + # if the title has the year in remove it as tmdb cannot deal with it... + # replace e.g. 'The Americans (2015)' with 'The Americans' + title = sub(r'\s*\(\d{4}\)$', '', title, count=1) + url = 'https://api.themoviedb.org/3/search/%s' % media_type + parameters = { + 'api_key': api_key, + 'language': v.KODILANGUAGE, + 'query': title.encode('utf-8') + } + data = DU().downloadUrl(url, + authenticate=False, + parameters=parameters, + timeout=7) + try: + data.get('test') + except AttributeError: + LOG.warning('Could not download data from FanartTV') + return + if not data.get('results'): + LOG.info('No match found on themoviedb for type: %s, title: %s', + media_type, title) + return + + year = item.get('year') + match_found = None + # find year match + if year: + for entry in data['results']: + if year in entry.get('first_air_date', ''): + match_found = entry + break + elif year in entry.get('release_date', ''): + match_found = entry + break + # find exact match based on title, if we haven't found a year match + if match_found is None: + LOG.info('No themoviedb match found using year %s', year) + replacements = ( + ' ', + '-', + '&', + ',', + ':', + ';' + ) + for entry in data['results']: + name = entry.get('name', entry.get('title', '')) + original_name = entry.get('original_name', '') + title_alt = title.lower() + name_alt = name.lower() + org_name_alt = original_name.lower() + for replace_string in replacements: + title_alt = title_alt.replace(replace_string, '') + name_alt = name_alt.replace(replace_string, '') + org_name_alt = org_name_alt.replace(replace_string, '') + if name == title or original_name == title: + # match found for exact title name + match_found = entry + break + elif (name.split(' (')[0] == title or title_alt == name_alt or + title_alt == org_name_alt): + # match found with substituting some stuff + match_found = entry + break + + # if a match was not found, we accept the closest match from TMDB + if match_found is None and data.get('results'): + LOG.info('Using very first match from themoviedb') + match_found = entry = data.get('results')[0] + + if match_found is None: + LOG.info('Still no themoviedb match for type: %s, title: %s, ' + 'year: %s', media_type, title, year) + LOG.debug('themoviedb answer was %s', data['results']) + return + + LOG.info('Found themoviedb match for %s: %s', + item.get('title'), match_found) + + tmdb_id = str(entry.get('id', '')) + if tmdb_id == '': + LOG.error('No themoviedb ID found, aborting') + return + + if media_type == 'multi' and entry.get('media_type'): + media_type = entry.get('media_type') + name = entry.get('name', entry.get('title')) + # lookup external tmdb_id and perform artwork lookup on fanart.tv + parameters = {'api_key': api_key} + if media_type == 'movie': + url = 'https://api.themoviedb.org/3/movie/%s' % tmdb_id + parameters['append_to_response'] = 'videos' + elif media_type == 'tv': + url = 'https://api.themoviedb.org/3/tv/%s' % tmdb_id + parameters['append_to_response'] = 'external_ids,videos' + media_id, poster, background = None, None, None + for language in [v.KODILANGUAGE, 'en']: + parameters['language'] = language + data = DU().downloadUrl(url, + authenticate=False, + parameters=parameters, + timeout=7) + try: + data.get('test') + except AttributeError: + LOG.warning('Could not download %s with parameters %s', + url, parameters) + continue + if collection is False: + if data.get('imdb_id'): + media_id = str(data.get('imdb_id')) + break + if (data.get('external_ids') and + data['external_ids'].get('tvdb_id')): + media_id = str(data['external_ids']['tvdb_id']) + break + else: + if not data.get('belongs_to_collection'): + continue + media_id = data.get('belongs_to_collection').get('id') + if not media_id: + continue + media_id = str(media_id) + LOG.debug('Retrieved collections tmdb id %s for %s', + media_id, title) + url = 'https://api.themoviedb.org/3/collection/%s' % media_id + data = DU().downloadUrl(url, + authenticate=False, + parameters=parameters, + timeout=7) + try: + data.get('poster_path') + except AttributeError: + LOG.debug('Could not find TheMovieDB poster paths for %s' + ' in the language %s', title, language) + continue + if not poster and data.get('poster_path'): + poster = ('https://image.tmdb.org/t/p/original%s' % + data.get('poster_path')) + if not background and data.get('backdrop_path'): + background = ('https://image.tmdb.org/t/p/original%s' % + data.get('backdrop_path')) + return media_id, poster, background + + def lookup_fanart_tv(self, media_id, artworks): + """ + perform artwork lookup on fanart.tv + + media_id: IMDB id for movies, tvdb id for TV shows + """ + api_key = utils.settings('FanArtTVAPIKey') + typus = self.plex_type + if typus == v.PLEX_TYPE_SHOW: + typus = 'tv' + + if typus == v.PLEX_TYPE_MOVIE: + url = 'http://webservice.fanart.tv/v3/movies/%s?api_key=%s' \ + % (media_id, api_key) + elif typus == 'tv': + url = 'http://webservice.fanart.tv/v3/tv/%s?api_key=%s' \ + % (media_id, api_key) + else: + # Not supported artwork + return artworks + data = DU().downloadUrl(url, authenticate=False, timeout=15) + try: + data.get('test') + except AttributeError: + LOG.error('Could not download data from FanartTV') + return artworks + + fanart_tv_types = list(v.FANART_TV_TO_KODI_TYPE) + + if typus == v.PLEX_TYPE_ARTIST: + fanart_tv_types.append(("thumb", "folder")) + else: + fanart_tv_types.append(("thumb", "thumb")) + + prefixes = ( + "hd" + typus, + "hd", + typus, + "", + ) + for fanart_tv_type, kodi_type in fanart_tv_types: + # Skip the ones we already have + if kodi_type in artworks: + continue + for prefix in prefixes: + fanarttvimage = prefix + fanart_tv_type + if fanarttvimage not in data: + continue + # select image in preferred language + for entry in data[fanarttvimage]: + if entry.get("lang") == v.KODILANGUAGE: + artworks[kodi_type] = \ + entry.get("url", "").replace(' ', '%20') + break + # just grab the first english OR undefinded one as fallback + # (so we're actually grabbing the more popular one) + if kodi_type not in artworks: + for entry in data[fanarttvimage]: + if entry.get("lang") in ("en", "00"): + artworks[kodi_type] = \ + entry.get("url", "").replace(' ', '%20') + break + + # grab extrafanarts in list + fanartcount = 1 if 'fanart' in artworks else '' + for prefix in prefixes: + fanarttvimage = prefix + 'background' + if fanarttvimage not in data: + continue + for entry in data[fanarttvimage]: + if entry.get("url") is None: + continue + artworks['fanart%s' % fanartcount] = \ + entry['url'].replace(' ', '%20') + try: + fanartcount += 1 + except TypeError: + fanartcount = 1 + if fanartcount >= v.MAX_BACKGROUND_COUNT: + break + return artworks diff --git a/resources/lib/plex_api/base.py b/resources/lib/plex_api/base.py new file mode 100644 index 00000000..f6c61b73 --- /dev/null +++ b/resources/lib/plex_api/base.py @@ -0,0 +1,644 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +from re import sub + +import xbmcgui + +from ..utils import cast +from ..plex_db import PlexDB +from .. import utils, timing, variables as v, app, plex_functions as PF +from .. import widgets + +LOG = getLogger('PLEX.api') + + +class Base(object): + """ + Processes a Plex media server's XML response + + xml: xml.etree.ElementTree element + """ + def __init__(self, xml): + self.xml = xml + # which media part in the XML response shall we look at if several + # media files are present for the SAME video? (e.g. a 4k and a 1080p + # version) + self.part = 0 + self.mediastream = None + # Make sure we're only checking our Plex DB once + self._checked_db = False + # In order to run through the leaves of the xml only once + self._scanned_children = False + self._genres = [] + self._countries = [] + self._collections = [] + self._people = [] + self._cast = [] + self._directors = [] + self._writers = [] + self._producers = [] + self._locations = [] + self._coll_match = None + # Plex DB attributes + self._section_id = None + self._kodi_id = None + self._last_sync = None + self._last_checksum = None + self._kodi_fileid = None + self._kodi_pathid = None + self._fanart_synced = None + + @property + def tag(self): + """ + Returns the xml etree tag, e.g. 'Directory', 'Playlist', 'Hub', 'Video' + """ + return self.xml.tag + + @property + def attrib(self): + """ + Returns the xml etree attrib dict + """ + return self.xml.attrib + + @property + def plex_id(self): + """ + Returns the Plex ratingKey as an integer or None + """ + return cast(int, self.xml.get('ratingKey')) + + @property + def plex_type(self): + """ + Returns the type of media, e.g. 'movie' or 'clip' for trailers as + Unicode or None. + """ + return self.xml.get('type') + + @property + def section_id(self): + self.check_db() + return self._section_id + + @property + def kodi_id(self): + self.check_db() + return self._kodi_id + + @property + def kodi_type(self): + return v.KODITYPE_FROM_PLEXTYPE[self.plex_type] + + @property + def last_sync(self): + self.check_db() + return self._last_sync + + @property + def last_checksum(self): + self.check_db() + return self._last_checksum + + @property + def kodi_fileid(self): + self.check_db() + return self._kodi_fileid + + @property + def kodi_pathid(self): + self.check_db() + return self._kodi_pathid + + @property + def fanart_synced(self): + self.check_db() + return self._fanart_synced + + def check_db(self, plexdb=None): + """ + Check's whether we synched this item to Kodi. If so, then retrieve the + appropriate Kodi info like the kodi_id and kodi_fileid + + Pass in a plexdb DB-connection for a faster lookup + """ + if self._checked_db: + return + self._checked_db = True + if self.plex_type == v.PLEX_TYPE_CLIP: + # Clips won't ever be synched to Kodi + return + if plexdb: + db_item = plexdb.item_by_id(self.plex_id, self.plex_type) + else: + with PlexDB(lock=False) as plexdb: + db_item = plexdb.item_by_id(self.plex_id, self.plex_type) + if not db_item: + return + self._section_id = db_item['section_id'] + self._kodi_id = db_item['kodi_id'] + self._last_sync = db_item['last_sync'] + self._last_checksum = db_item['checksum'] + if 'kodi_fileid' in db_item: + self._kodi_fileid = db_item['kodi_fileid'] + if 'kodi_pathid' in db_item: + self._kodi_pathid = db_item['kodi_pathid'] + if 'fanart_synced' in db_item: + self._fanart_synced = db_item['fanart_synced'] + + def path_and_plex_id(self): + """ + Returns the Plex key such as '/library/metadata/246922' or None + """ + return self.xml.get('key') + + def item_id(self): + """ + Returns current playQueueItemID or if unsuccessful the playListItemID + as int. + If not found, None is returned + """ + return (cast(int, self.xml.get('playQueueItemID')) or + cast(int, self.xml.get('playListItemID'))) + + def playlist_type(self): + """ + Returns the playlist type ('video', 'audio') or None + """ + return self.xml.get('playlistType') + + def library_section_id(self): + """ + Returns the id of the Plex library section (for e.g. a movies section) + as an int or None + """ + return cast(int, self.xml.get('librarySectionID')) + + def guid_html_escaped(self): + """ + Returns the 'guid' attribute, e.g. + 'com.plexapp.agents.thetvdb://76648/2/4?lang=en' + as an HTML-escaped string or None + """ + guid = self.xml.get('guid') + return utils.escape_html(guid) if guid else None + + def date_created(self): + """ + Returns the date when this library item was created in Kodi-time as + unicode + + If not found, returns 2000-01-01 10:00:00 + """ + res = self.xml.get('addedAt') + return timing.plex_date_to_kodi(res) if res else '2000-01-01 10:00:00' + + def updated_at(self): + """ + Returns the last time this item was updated as an int, e.g. + 1524739868 or None + """ + return cast(int, self.xml.get('updatedAt')) + + def checksum(self): + """ + Returns the unique int . If updatedAt is not set, + addedAt is used. + """ + return int('%s%s' % (self.xml.get('ratingKey'), + self.xml.get('updatedAt') or + self.xml.get('addedAt', '1541572987'))) + + def title(self): + """ + Returns the title of the element as unicode or 'Missing Title' + """ + return self.xml.get('title', 'Missing Title') + + def sorttitle(self): + """ + Returns an item's sorting name/title or the title itself if not found + "Missing Title" if both are not present + """ + return self.xml.get('titleSort', + self.xml.get('title', 'Missing Title')) + + def plex_media_streams(self): + """ + Returns the media streams directly from the PMS xml. + Mind to set self.mediastream and self.part before calling this method! + """ + return self.xml[self.mediastream][self.part] + + def plot(self): + """ + Returns the plot or None. + """ + return self.xml.get('summary') + + def tagline(self): + """ + Returns a shorter tagline of the plot or None + """ + return self.xml.get('tagline') + + def shortplot(self): + """ + Not yet implemented - returns None + """ + pass + + def premiere_date(self): + """ + Returns the "originallyAvailableAt", e.g. "2018-11-16" or None + """ + return self.xml.get('originallyAvailableAt') + + def kodi_premiere_date(self): + """ + Takes Plex' originallyAvailableAt of the form "yyyy-mm-dd" and returns + Kodi's "dd.mm.yyyy" or None + """ + date = self.premiere_date() + if date is None: + return + try: + date = sub(r'(\d+)-(\d+)-(\d+)', r'\3.\2.\1', date) + except Exception: + date = None + return date + + def year(self): + """ + Returns the production(?) year ("year") as Unicode or None + """ + return self.xml.get('year') + + def studios(self): + """ + Returns a list of the 'studio' - currently only ever 1 entry. + Or returns an empty list + """ + return [self.xml.get('studio')] if self.xml.get('studio') else [] + + def content_rating(self): + """ + Get the content rating or None + """ + mpaa = self.xml.get('contentRating') + if not mpaa: + return + # Convert more complex cases + if mpaa in ('NR', 'UR'): + # Kodi seems to not like NR, but will accept Rated Not Rated + mpaa = 'Rated Not Rated' + elif mpaa.startswith('gb/'): + mpaa = mpaa.replace('gb/', 'UK:', 1) + return mpaa + + def rating(self): + """ + Returns the rating [float] first from 'audienceRating', if that fails + from 'rating'. + Returns 0.0 if both are not found + """ + return cast(float, self.xml.get('audienceRating', + self.xml.get('rating'))) or 0.0 + + def votecount(self): + """ + Not implemented by Plex yet - returns None + """ + pass + + def runtime(self): + """ + Returns the total duration of the element in seconds as int. + 0 if not found + """ + runtime = cast(float, self.xml.get('duration')) or 0.0 + return int(runtime * v.PLEX_TO_KODI_TIMEFACTOR) + + def leave_count(self): + """ + Returns the following dict or None + { + 'totalepisodes': unicode('leafCount'), + 'watchedepisodes': unicode('viewedLeafCount'), + 'unwatchedepisodes': unicode(totalepisodes - watchedepisodes) + } + """ + try: + total = int(self.xml.attrib['leafCount']) + watched = int(self.xml.attrib['viewedLeafCount']) + return { + 'totalepisodes': unicode(total), + 'watchedepisodes': unicode(watched), + 'unwatchedepisodes': unicode(total - watched) + } + except (KeyError, TypeError): + pass + + # Stuff having to do with parent and grandparent items + ###################################################### + def index(self): + """ + Returns the 'index' of the element [int]. Depicts e.g. season number of + the season or the track number of the song + """ + return cast(int, self.xml.get('index')) + + def show_id(self): + """ + Returns the episode's tv show's Plex id [int] or None + """ + return self.grandparent_id() + + def show_title(self): + """ + Returns the episode's tv show's name/title [unicode] or None + """ + return self.grandparent_title() + + def season_id(self): + """ + Returns the episode's season's Plex id [int] or None + """ + return self.parent_id() + + def season_number(self): + """ + Returns the episode's season number (e.g. season '2') as an int or None + """ + return self.parent_index() + + def artist_name(self): + """ + Returns the artist name for an album: first it attempts to return + 'parentTitle', if that failes 'originalTitle' + """ + return self.xml.get('parentTitle', self.xml.get('originalTitle')) + + def parent_id(self): + """ + Returns the 'parentRatingKey' as int or None + """ + return cast(int, self.xml.get('parentRatingKey')) + + def parent_index(self): + """ + Returns the 'parentRatingKey' as int or None + """ + return cast(int, self.xml.get('parentIndex')) + + def grandparent_id(self): + """ + Returns the ratingKey for the corresponding grandparent, e.g. a TV show + for episodes, or None + """ + return cast(int, self.xml.get('grandparentRatingKey')) + + def grandparent_title(self): + """ + Returns the title for the corresponding grandparent, e.g. a TV show + name for episodes, or None + """ + return self.xml.get('grandparentTitle') + + def disc_number(self): + """ + Returns the song's disc number as an int or None if not found + """ + return self.parent_index() + + def _scan_children(self): + """ + Ensures that we're scanning the xml's subelements only once + """ + if self._scanned_children: + return + self._scanned_children = True + cast_order = 0 + for child in self.xml: + if child.tag == 'Role': + self._cast.append((child.get('tag'), + child.get('thumb'), + child.get('role'), + cast_order)) + cast_order += 1 + elif child.tag == 'Genre': + self._genres.append(child.get('tag')) + elif child.tag == 'Country': + self._countries.append(child.get('tag')) + elif child.tag == 'Director': + self._directors.append(child.get('tag')) + elif child.tag == 'Writer': + self._writers.append(child.get('tag')) + elif child.tag == 'Producer': + self._producers.append(child.get('tag')) + elif child.tag == 'Location': + self._locations.append(child.get('path')) + elif child.tag == 'Collection': + self._collections.append((cast(int, child.get('id')), + child.get('tag'))) + + def cast(self): + """ + Returns a list of tuples of the cast: + [(, + , + , + )] + """ + self._scan_children() + return self._cast + + def genres(self): + """ + Returns a list of genres found + """ + self._scan_children() + return self._genres + + def countries(self): + """ + Returns a list of all countries + """ + self._scan_children() + return self._countries + + def directors(self): + """ + Returns a list of all directors + """ + + self._scan_children() + return self._directors + + def writers(self): + """ + Returns a list of all writers + """ + + self._scan_children() + return self._writers + + def producers(self): + """ + Returns a list of all producers + """ + self._scan_children() + return self._producers + + def tv_show_path(self): + """ + Returns the direct path to the TV show, e.g. '\\NAS\tv\series' + or None + """ + self._scan_children() + if self._locations: + return self._locations[0] + + def collections(self): + """ + Returns a list of tuples of the collection id and tags or an empty list + [(, ), ...] + """ + self._scan_children() + return self._collections + + def people(self): + """ + Returns a dict with lists of tuples: + { + 'actor': [(, + , + , + )] + 'director': [..., (, ), ...], + 'writer': [..., (, ), ...] + } + Everything in unicode, except which is an int. + Only and may be None if not found. + """ + self._scan_children() + return { + 'actor': self._cast, + 'director': [(x, ) for x in self._directors], + 'writer': [(x, ) for x in self._writers] + } + + def provider(self, providername=None): + """ + providername: e.g. 'imdb', 'tvdb' + + Return IMDB, e.g. "tt0903624". Returns None if not found + """ + item = self.xml.get('guid') + if not item: + return + if providername == 'imdb': + regex = utils.REGEX_IMDB + elif providername == 'tvdb': + # originally e.g. com.plexapp.agents.thetvdb://276564?lang=en + regex = utils.REGEX_TVDB + else: + raise NotImplementedError('Not implemented: %s' % providername) + + provider = regex.findall(item) + try: + provider = provider[0] + except IndexError: + provider = None + return provider + + def extras(self): + """ + Returns an iterator for etree elements for each extra, e.g. trailers + Returns None if no extras are found + """ + extras = self.xml.find('Extras') + if not extras: + return + return (x for x in extras) + + def trailer(self): + """ + Returns the URL for a single trailer (local trailer preferred; first + trailer found returned) or an add-on path to list all Plex extras + if the user setting showExtrasInsteadOfTrailer is set. + Returns None if nothing is found. + """ + url = None + for extras in self.xml.iterfind('Extras'): + # There will always be only 1 extras element + if (len(extras) > 0 and + app.SYNC.show_extras_instead_of_playing_trailer): + return ('plugin://%s?mode=route_to_extras&plex_id=%s' + % (v.ADDON_ID, self.plex_id)) + for extra in extras: + typus = cast(int, extra.get('extraType')) + if typus != 1: + # Skip non-trailers + continue + if extra.get('guid', '').startswith('file:'): + url = extra.get('ratingKey') + # Always prefer local trailers (first one listed) + break + elif not url: + url = extra.get('ratingKey') + if url: + url = ('plugin://%s.movies/?plex_id=%s&plex_type=%s&mode=play' + % (v.ADDON_ID, url, v.PLEX_TYPE_CLIP)) + return url + + def listitem(self, listitem=xbmcgui.ListItem): + """ + Returns a xbmcgui.ListItem() (or PKCListItem) for this Plex element + """ + item = widgets.generate_item(self) + item = widgets.prepare_listitem(item) + return widgets.create_listitem(item, as_tuple=False, listitem=listitem) + + def collections_match(self, section_id): + """ + Downloads one additional xml from the PMS in order to return a list of + tuples [(collection_id, plex_id), ...] for all collections of the + current item's Plex library sectin + Pass in the collection id of e.g. the movie's metadata + """ + if self._coll_match is None: + self._coll_match = PF.collections(section_id) + if self._coll_match is None: + LOG.error('Could not download collections for %s', + self.library_section_id()) + self._coll_match = [] + self._coll_match = \ + [(utils.cast(int, x.get('index')), + utils.cast(int, x.get('ratingKey'))) for x in self._coll_match] + return self._coll_match + + @staticmethod + def attach_plex_token_to_url(url): + """ + Returns an extended URL with the Plex token included as 'X-Plex-Token=' + + url may or may not already contain a '?' + """ + if not app.ACCOUNT.pms_token: + return url + if '?' not in url: + return "%s?X-Plex-Token=%s" % (url, app.ACCOUNT.pms_token) + else: + return "%s&X-Plex-Token=%s" % (url, app.ACCOUNT.pms_token) + + @staticmethod + def list_to_string(input_list): + """ + Concatenates input_list (list of unicodes) with a separator ' / ' + Returns None if the list was empty + """ + return ' / '.join(input_list) or None diff --git a/resources/lib/plex_api/file.py b/resources/lib/plex_api/file.py new file mode 100644 index 00000000..26e708b8 --- /dev/null +++ b/resources/lib/plex_api/file.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals + +from ..utils import cast +from .. import utils, variables as v, app + + +def _transcode_image_path(key, AuthToken, path, width, height): + """ + Transcode Image support + + parameters: + key + AuthToken + path - source path of current XML: path[srcXML] + width + height + result: + final path to image file + """ + # external address - can we get a transcoding request for external images? + if key.startswith('http'): + path = key + elif key.startswith('/'): # internal full path. + path = 'http://127.0.0.1:32400' + key + else: # internal path, add-on + path = 'http://127.0.0.1:32400' + path + '/' + key + # This is bogus (note the extra path component) but ATV is stupid when it + # comes to caching images, it doesn't use querystrings. Fortunately PMS is + # lenient... + transcode_path = ('/photo/:/transcode/%sx%s/%s' + % (width, height, utils.quote_plus(path))) + args = { + 'width': width, + 'height': height, + 'url': path + } + if AuthToken: + args['X-Plex-Token'] = AuthToken + return utils.extend_url(transcode_path, args) + + +class File(object): + def path(self, force_first_media=True, force_addon=False, + direct_paths=None): + """ + Returns a "fully qualified path": add-on paths or direct paths + depending on the current settings. Will NOT valide the playurl + Returns unicode or None if something went wrong. + + Pass direct_path=True if you're calling from another Plex python + instance - because otherwise direct paths will evaluate to False! + """ + direct_paths = direct_paths or app.SYNC.direct_paths + filename = self.file_path(force_first_media=force_first_media) + if (not direct_paths or force_addon or + self.plex_type == v.PLEX_TYPE_CLIP): + if filename and '/' in filename: + filename = filename.rsplit('/', 1) + elif filename: + filename = filename.rsplit('\\', 1) + try: + filename = filename[1] + except (TypeError, IndexError): + filename = None + # Set plugin path and media flags using real filename + if self.plex_type == v.PLEX_TYPE_EPISODE: + # need to include the plex show id in the path + path = ('plugin://plugin.video.plexkodiconnect.tvshows/%s/' + % self.grandparent_id()) + else: + path = 'plugin://%s/' % v.ADDON_TYPE[self.plex_type] + path = ('%s?plex_id=%s&plex_type=%s&mode=play&filename=%s' + % (path, self.plex_id, self.plex_type, filename)) + else: + # Direct paths is set the Kodi way + path = self.validate_playurl(filename, + self.plex_type, + omit_check=True) + return path + + def directory_path(self, section_id=None, plex_type=None, old_key=None, + synched=True): + key = self.xml.get('fastKey') + if not key: + key = self.xml.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.xml.get('prompt'): + # User input needed, e.g. search for a movie or episode + params['prompt'] = self.xml.get('prompt') + if section_id: + params['id'] = section_id + return utils.extend_url('plugin://%s/' % v.ADDON_ID, params) + + def file_name(self, force_first_media=False): + """ + Returns only the filename, e.g. 'movie.mkv' as unicode or None if not + found + """ + ans = self.file_path(force_first_media=force_first_media) + if ans is None: + return + if "\\" in ans: + # Local path + filename = ans.rsplit("\\", 1)[1] + else: + try: + # Network share + filename = ans.rsplit("/", 1)[1] + except IndexError: + # E.g. certain Plex channels + filename = None + return filename + + def file_path(self, force_first_media=False): + """ + Returns the direct path to this item, e.g. '\\NAS\movies\movie.mkv' + as unicode or None + + force_first_media=True: + will always use 1st media stream, e.g. when several different + files are present for the same PMS item + """ + if self.mediastream is None and force_first_media is False: + if self.mediastream_number() is None: + return + try: + if force_first_media is False: + ans = cast(str, self.xml[self.mediastream][self.part].attrib['file']) + else: + ans = cast(str, self.xml[0][self.part].attrib['file']) + except (TypeError, AttributeError, IndexError, KeyError): + return + return utils.unquote(ans) + + def get_picture_path(self): + """ + Returns the item's picture path (transcode, if necessary) as string. + Will always use addon paths, never direct paths + """ + path = self.xml[0][0].get('key') + extension = path[path.rfind('.'):].lower() + if app.SYNC.force_transcode_pix or extension not in v.KODI_SUPPORTED_IMAGES: + # Let Plex transcode + # max width/height supported by plex image transcoder is 1920x1080 + path = app.CONN.server + _transcode_image_path( + path, + app.ACCOUNT.pms_token, + "%s%s" % (app.CONN.server, path), + 1920, + 1080) + else: + path = self.attach_plex_token_to_url('%s%s' % (app.CONN.server, path)) + # Attach Plex id to url to let it be picked up by our playqueue agent + # later + return '%s&plex_id=%s' % (path, self.plex_id) diff --git a/resources/lib/plex_api/media.py b/resources/lib/plex_api/media.py new file mode 100644 index 00000000..cf192864 --- /dev/null +++ b/resources/lib/plex_api/media.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger + +from ..utils import cast +from ..downloadutils import DownloadUtils as DU +from .. import utils, variables as v, app, path_ops, clientinfo + +LOG = getLogger('PLEX.api') + + +class Media(object): + def should_stream(self): + """ + Returns True if the item's 'optimizedForStreaming' is set, False other- + wise + """ + return cast(bool, self.xml[0].get('optimizedForStreaming')) or False + + def _from_part_or_media(self, key): + """ + Retrieves XML data 'key' first from the active part. If unsuccessful, + tries to retrieve the data from the Media response part. + + If all fails, None is returned. + """ + return self.xml[0][self.part].get(key, self.xml[0].get(key)) + + def video_codec(self): + """ + Returns the video codec and resolution for the child and part selected. + If any data is not found on a part-level, the Media-level data is + returned. + If that also fails (e.g. for old trailers, None is returned) + + Output: + { + 'videocodec': xxx, e.g. 'h264' + 'resolution': xxx, e.g. '720' or '1080' + 'height': xxx, e.g. '816' + 'width': xxx, e.g. '1920' + 'aspectratio': xxx, e.g. '1.78' + 'bitrate': xxx, e.g. '10642' + 'container': xxx e.g. 'mkv', + 'bitDepth': xxx e.g. '8', '10' + } + """ + answ = { + 'videocodec': self._from_part_or_media('videoCodec'), + 'resolution': self._from_part_or_media('videoResolution'), + 'height': self._from_part_or_media('height'), + 'width': self._from_part_or_media('width'), + 'aspectratio': self._from_part_or_media('aspectratio'), + 'bitrate': self._from_part_or_media('bitrate'), + 'container': self._from_part_or_media('container'), + } + try: + answ['bitDepth'] = self.xml[0][self.part][self.mediastream].get('bitDepth') + except (TypeError, AttributeError, KeyError, IndexError): + answ['bitDepth'] = None + return answ + + def mediastreams(self): + """ + Returns the media streams for metadata purposes + + Output: each track contains a dictionaries + { + 'video': videotrack-list, 'codec', 'height', 'width', + 'aspect', 'video3DFormat' + 'audio': audiotrack-list, 'codec', 'channels', + 'language' + 'subtitle': list of subtitle languages (or "Unknown") + } + """ + videotracks = [] + audiotracks = [] + subtitlelanguages = [] + try: + # Sometimes, aspectratio is on the "toplevel" + aspect = cast(float, self.xml[0].get('aspectRatio')) + except IndexError: + # There is no stream info at all, returning empty + return { + 'video': videotracks, + 'audio': audiotracks, + 'subtitle': subtitlelanguages + } + # Loop over parts + for child in self.xml[0]: + container = child.get('container') + # Loop over Streams + for stream in child: + media_type = int(stream.get('streamType', 999)) + track = {} + if media_type == 1: # Video streams + if 'codec' in stream.attrib: + track['codec'] = stream.get('codec').lower() + if "msmpeg4" in track['codec']: + track['codec'] = "divx" + elif "mpeg4" in track['codec']: + pass + elif "h264" in track['codec']: + if container in ("mp4", "mov", "m4v"): + track['codec'] = "avc1" + track['height'] = cast(int, stream.get('height')) + track['width'] = cast(int, stream.get('width')) + # track['Video3DFormat'] = item.get('Video3DFormat') + track['aspect'] = cast(float, + stream.get('aspectRatio') or aspect) + track['duration'] = self.runtime() + track['video3DFormat'] = None + videotracks.append(track) + elif media_type == 2: # Audio streams + if 'codec' in stream.attrib: + track['codec'] = stream.get('codec').lower() + if ("dca" in track['codec'] and + "ma" in stream.get('profile', '').lower()): + track['codec'] = "dtshd_ma" + track['channels'] = cast(int, stream.get('channels')) + # 'unknown' if we cannot get language + track['language'] = stream.get('languageCode', + utils.lang(39310).lower()) + audiotracks.append(track) + elif media_type == 3: # Subtitle streams + # 'unknown' if we cannot get language + subtitlelanguages.append( + stream.get('languageCode', utils.lang(39310)).lower()) + return { + 'video': videotracks, + 'audio': audiotracks, + 'subtitle': subtitlelanguages + } + + def mediastream_number(self): + """ + Returns the Media stream as an int (mostly 0). Will let the user choose + if several media streams are present for a PMS item (if settings are + set accordingly) + + Returns None if the user aborted selection (leaving self.mediastream at + its default of None) + """ + # How many streams do we have? + count = 0 + for entry in self.xml.iterfind('./Media'): + count += 1 + if (count > 1 and ( + (self.plex_type != v.PLEX_TYPE_CLIP and + utils.settings('bestQuality') == 'false') + or + (self.plex_type == v.PLEX_TYPE_CLIP and + utils.settings('bestTrailer') == 'false'))): + # Several streams/files available. + dialoglist = [] + for entry in self.xml.iterfind('./Media'): + # Get additional info (filename / languages) + if 'file' in entry[0].attrib: + option = entry[0].get('file') + option = path_ops.basename(option) + else: + option = self.title() or '' + # Languages of audio streams + languages = [] + for stream in entry[0]: + if (cast(int, stream.get('streamType')) == 1 and + 'language' in stream.attrib): + language = stream.get('language') + languages.append(language) + languages = ', '.join(languages) + if languages: + if option: + option = '%s (%s): ' % (option, languages) + else: + option = '%s: ' % languages + else: + option = '%s ' % option + if 'videoResolution' in entry.attrib: + res = entry.get('videoResolution') + option = '%s%sp ' % (option, res) + if 'videoCodec' in entry.attrib: + codec = entry.get('videoCodec') + option = '%s%s' % (option, codec) + option = option.strip() + ' - ' + if 'audioProfile' in entry.attrib: + profile = entry.get('audioProfile') + option = '%s%s ' % (option, profile) + if 'audioCodec' in entry.attrib: + codec = entry.get('audioCodec') + option = '%s%s ' % (option, codec) + option = cast(str, option.strip()) + dialoglist.append(option) + media = utils.dialog('select', 'Select stream', dialoglist) + LOG.info('User chose media stream number: %s', media) + if media == -1: + LOG.info('User cancelled media stream selection') + return + else: + media = 0 + self.mediastream = media + return media + + def transcode_video_path(self, action, quality=None): + """ + + To be called on a VIDEO level of PMS xml response! + + Transcode Video support; returns the URL to get a media started + + Input: + action 'DirectStream' or 'Transcode' + + quality: { + 'videoResolution': e.g. '1024x768', + 'videoQuality': e.g. '60', + 'maxVideoBitrate': e.g. '2000' (in kbits) + } + (one or several of these options) + Output: + final URL to pull in PMS transcoder + + TODO: mediaIndex + """ + if self.mediastream is None and self.mediastream_number() is None: + return + quality = {} if quality is None else quality + xargs = clientinfo.getXArgsDeviceInfo() + # For DirectPlay, path/key of PART is needed + # trailers are 'clip' with PMS xmls + if action == "DirectStream": + path = self.xml[self.mediastream][self.part].get('key') + url = app.CONN.server + path + # e.g. Trailers already feature an '?'! + return utils.extend_url(url, xargs) + + # For Transcoding + headers = { + 'X-Plex-Platform': 'Android', + 'X-Plex-Platform-Version': '7.0', + 'X-Plex-Product': 'Plex for Android', + 'X-Plex-Version': '5.8.0.475' + } + # Path/key to VIDEO item of xml PMS response is needed, not part + path = self.xml.get('key') + transcode_path = app.CONN.server + \ + '/video/:/transcode/universal/start.m3u8' + args = { + 'audioBoost': utils.settings('audioBoost'), + 'autoAdjustQuality': 0, + 'directPlay': 0, + 'directStream': 1, + 'protocol': 'hls', # seen in the wild: 'dash', 'http', 'hls' + 'session': v.PKC_MACHINE_IDENTIFIER, # TODO: create new unique id + 'fastSeek': 1, + 'path': path, + 'mediaIndex': self.mediastream, + 'partIndex': self.part, + 'hasMDE': 1, + 'location': 'lan', + 'subtitleSize': utils.settings('subtitleSize') + } + LOG.debug("Setting transcode quality to: %s", quality) + xargs.update(headers) + xargs.update(args) + xargs.update(quality) + return utils.extend_url(transcode_path, xargs) + + def cache_external_subs(self): + """ + Downloads external subtitles temporarily to Kodi and returns a list + of their paths + """ + externalsubs = [] + try: + mediastreams = self.xml[0][self.part] + except (TypeError, KeyError, IndexError): + return + kodiindex = 0 + fileindex = 0 + for stream in mediastreams: + # Since plex returns all possible tracks together, have to pull + # only external subtitles - only for these a 'key' exists + if cast(int, stream.get('streamType')) != 3: + # Not a subtitle + continue + # Only set for additional external subtitles NOT lying beside video + key = stream.get('key') + # Only set for dedicated subtitle files lying beside video + # ext = stream.attrib.get('format') + if key: + # We do know the language - temporarily download + if stream.get('languageCode') is not None: + language = stream.get('languageCode') + codec = stream.get('codec') + path = self.download_external_subtitles( + "{server}%s" % key, + "subtitle%02d.%s.%s" % (fileindex, language, codec)) + fileindex += 1 + # We don't know the language - no need to download + else: + path = self.attach_plex_token_to_url( + "%s%s" % (app.CONN.server, key)) + externalsubs.append(path) + kodiindex += 1 + LOG.info('Found external subs: %s', externalsubs) + return externalsubs + + @staticmethod + def download_external_subtitles(url, filename): + """ + One cannot pass the subtitle language for ListItems. Workaround; will + download the subtitle at url to the Kodi PKC directory in a temp dir + + Returns the path to the downloaded subtitle or None + """ + path = path_ops.path.join(v.EXTERNAL_SUBTITLE_TEMP_PATH, filename) + response = DU().downloadUrl(url, return_response=True) + try: + response.status_code + except AttributeError: + LOG.error('Could not temporarily download subtitle %s', url) + return + else: + LOG.debug('Writing temp subtitle to %s', path) + with open(path_ops.encode_path(path), 'wb') as filer: + filer.write(response.content) + return path + + def validate_playurl(self, path, typus, force_check=False, folder=False, + omit_check=False): + """ + Returns a valid path for Kodi, e.g. with '\' substituted to '\\' in + Unicode. Returns None if this is not possible + + path : Unicode + typus : Plex type from PMS xml + force_check : Will always try to check validity of path + Will also skip confirmation dialog if path not found + folder : Set to True if path is a folder + omit_check : Will entirely omit validity check if True + """ + if path is None: + return + typus = v.REMAP_TYPE_FROM_PLEXTYPE[typus] + if app.SYNC.remap_path: + path = path.replace(getattr(app.SYNC, 'remapSMB%sOrg' % typus), + getattr(app.SYNC, 'remapSMB%sNew' % typus), + 1) + # There might be backslashes left over: + path = path.replace('\\', '/') + elif app.SYNC.replace_smb_path: + if path.startswith('\\\\'): + path = 'smb:' + path.replace('\\', '/') + if app.SYNC.escape_path: + try: + protocol, hostname, args = path.split(':', 2) + except ValueError: + pass + else: + args = utils.quote(args) + path = '%s:%s:%s' % (protocol, hostname, args) + if (app.SYNC.path_verified and not force_check) or omit_check: + return path + + # exist() needs a / or \ at the end to work for directories + if not folder: + # files + check = path_ops.exists(path) + else: + # directories + if "\\" in path: + if not path.endswith('\\'): + # Add the missing backslash + check = path_ops.exists(path + "\\") + else: + check = path_ops.exists(path) + else: + if not path.endswith('/'): + check = path_ops.exists(path + "/") + else: + check = path_ops.exists(path) + if not check: + if force_check is False: + # Validate the path is correct with user intervention + if self.ask_to_validate(path): + app.APP.stop_threads(block=False) + path = None + app.SYNC.path_verified = True + else: + path = None + elif not force_check: + # Only set the flag if we were not force-checking the path + app.SYNC.path_verified = True + return path + + @staticmethod + def ask_to_validate(url): + """ + Displays a YESNO dialog box: + Kodi can't locate file: . Please verify the path. + You may need to verify your network credentials in the + add-on settings or use different Plex paths. Stop syncing? + + Returns True if sync should stop, else False + """ + LOG.warn('Cannot access file: %s', url) + # Kodi cannot locate the file #s. Please verify your PKC settings. Stop + # syncing? + return utils.yesno_dialog(utils.lang(29999), utils.lang(39031) % url) diff --git a/resources/lib/plex_api/user.py b/resources/lib/plex_api/user.py new file mode 100644 index 00000000..4245fa9b --- /dev/null +++ b/resources/lib/plex_api/user.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals + +from ..utils import cast +from .. import timing, variables as v, app + + +class User(object): + def viewcount(self): + """ + Returns the play count for the item as an int or the int 0 if not found + """ + return cast(int, self.xml.get('viewCount')) or 0 + + def resume_point(self): + """ + Returns the resume point of time in seconds as float. 0.0 if not found + """ + resume = cast(float, self.xml.get('viewOffset')) or 0.0 + return resume * v.PLEX_TO_KODI_TIMEFACTOR + + def resume_point_plex(self): + """ + Returns the resume point of time in microseconds as float. + 0.0 if not found + """ + return cast(float, self.xml.get('viewOffset')) or 0.0 + + def userrating(self): + """ + Returns the userRating [int]. + If the user chose to replace user ratings with the number of different + file versions for a specific video, that number is returned instead + (at most 10) + + 0 is returned if something goes wrong + """ + if (app.SYNC.indicate_media_versions is True and + self.plex_type in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_EPISODE)): + userrating = 0 + for _ in self.xml.findall('./Media'): + userrating += 1 + # Don't show a value of '1' - which we'll always have for normal + # Plex library items + return 0 if userrating == 1 else min(userrating, 10) + else: + return cast(int, self.xml.get('userRating')) or 0 + + def lastplayed(self): + """ + Returns the Kodi timestamp [unicode] for the last point of time, when + this item was played. + Returns None if this fails - item has never been played + """ + try: + return timing.plex_date_to_kodi(int(self.xml.get('lastViewedAt'))) + except TypeError: + pass diff --git a/resources/lib/plex_companion.py b/resources/lib/plex_companion.py index 267e42e0..1035ded8 100644 --- a/resources/lib/plex_companion.py +++ b/resources/lib/plex_companion.py @@ -88,10 +88,10 @@ class PlexCompanion(backgroundthread.KillableThread): LOG.error('Could not download Plex metadata for: %s', data) return api = API(xml[0]) - if api.plex_type() == v.PLEX_TYPE_ALBUM: + if api.plex_type == v.PLEX_TYPE_ALBUM: LOG.debug('Plex music album detected') PQ.init_playqueue_from_plex_children( - api.plex_id(), + api.plex_id, transient_token=data.get('token')) elif data['containerKey'].startswith('/playQueues/'): _, container_key, _ = PF.ParseContainerKey(data['containerKey']) @@ -104,7 +104,7 @@ class PlexCompanion(backgroundthread.KillableThread): icon='{error}') return playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()]) + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type]) playqueue.clear() PL.get_playlist_details_from_xml(playqueue, xml) playqueue.plex_transient_token = data.get('token') @@ -117,8 +117,8 @@ class PlexCompanion(backgroundthread.KillableThread): app.CONN.plex_transient_token = data.get('token') if data.get('offset') != '0': app.PLAYSTATE.resume_playback = True - playback.playback_triage(api.plex_id(), - api.plex_type(), + playback.playback_triage(api.plex_id, + api.plex_type, resolve=False) @staticmethod @@ -153,7 +153,7 @@ class PlexCompanion(backgroundthread.KillableThread): return api = API(xml[0]) playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()]) + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type]) update_playqueue_from_PMS(playqueue, playqueue_id=container_key, repeat=query.get('repeat'), diff --git a/resources/lib/plex_functions.py b/resources/lib/plex_functions.py index b70efe8c..92e75161 100644 --- a/resources/lib/plex_functions.py +++ b/resources/lib/plex_functions.py @@ -1029,40 +1029,3 @@ def GetUserArtworkURL(username): url = user.thumb LOG.debug("Avatar url for user %s is: %s", username, url) return url - - -def transcode_image_path(key, AuthToken, path, width, height): - """ - Transcode Image support - - parameters: - key - AuthToken - path - source path of current XML: path[srcXML] - width - height - result: - final path to image file - """ - # external address - can we get a transcoding request for external images? - if key.startswith('http://') or key.startswith('https://'): - path = key - elif key.startswith('/'): # internal full path. - path = 'http://127.0.0.1:32400' + key - else: # internal path, add-on - path = 'http://127.0.0.1:32400' + path + '/' + key - # This is bogus (note the extra path component) but ATV is stupid when it - # comes to caching images, it doesn't use querystrings. Fortunately PMS is - # lenient... - path = path.encode('utf-8') - transcode_path = ('/photo/:/transcode/%sx%s/%s' - % (width, height, utils.quote_plus(path))) - transcode_path = transcode_path.decode('utf-8') - args = { - 'width': width, - 'height': height, - 'url': path - } - if AuthToken: - args['X-Plex-Token'] = AuthToken - return utils.extend_url(transcode_path, args) diff --git a/resources/lib/transfer.py b/resources/lib/transfer.py index 8539f166..0c12ba28 100644 --- a/resources/lib/transfer.py +++ b/resources/lib/transfer.py @@ -134,7 +134,8 @@ def convert_pkc_to_listitem(pkc_listitem): data = pkc_listitem.data listitem = xbmcgui.ListItem(label=data.get('label'), label2=data.get('label2'), - path=data.get('path')) + path=data.get('path'), + offscreen=True) if data['info']: listitem.setInfo(**data['info']) for stream in data['stream_info']: @@ -147,6 +148,8 @@ def convert_pkc_to_listitem(pkc_listitem): listitem.setProperty(key, cast(str, value)) if data['subtitles']: listitem.setSubtitles(data['subtitles']) + if data['contextmenu']: + listitem.addContextMenuItems(data['contextmenu']) return listitem @@ -157,7 +160,7 @@ class PKCListItem(object): WARNING: set/get path only via setPath and getPath! (not getProperty) """ - def __init__(self, label=None, label2=None, path=None): + def __init__(self, label=None, label2=None, path=None, offscreen=True): self.data = { 'stream_info': [], # (type, values: dict { label: value }) 'art': {}, # dict @@ -167,9 +170,10 @@ class PKCListItem(object): 'path': path, # string 'property': {}, # (key, value) 'subtitles': [], # strings + 'contextmenu': None } - def addContextMenuItems(self, items, replaceItems): + def addContextMenuItems(self, items): """ Adds item(s) to the context menu for media lists. @@ -187,7 +191,7 @@ class PKCListItem(object): Once you use a keyword, all following arguments require the keyword. """ - raise NotImplementedError + self.data['contextmenu'] = items def addStreamInfo(self, type, values): """ diff --git a/resources/lib/utils.py b/resources/lib/utils.py index a6d34735..3cc046d0 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -20,6 +20,12 @@ from functools import wraps import hashlib import re import gc +try: + from multiprocessing.pool import ThreadPool + SUPPORTS_POOL = True +except Exception: + SUPPORTS_POOL = False + import xbmc import xbmcaddon @@ -930,6 +936,27 @@ def generate_file_md5(path): return m.hexdigest().decode('utf-8') +def process_method_on_list(method_to_run, items): + """ + helper method that processes a method on each item 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 + ERROR(notify=True) + pool.close() + pool.join() + else: + all_items = [method_to_run(item) for item in items] + all_items = filter(None, all_items) + return all_items + + ############################################################################### # WRAPPERS diff --git a/resources/lib/widgets.py b/resources/lib/widgets.py index 757b66fa..4afe1d2b 100644 --- a/resources/lib/widgets.py +++ b/resources/lib/widgets.py @@ -8,18 +8,11 @@ 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 -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') @@ -34,27 +27,6 @@ SYNCHED = True 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(item) 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 @@ -82,7 +54,7 @@ def get_clean_image(image): return image.decode('utf-8') -def generate_item(xml_element): +def generate_item(api): """ Meant to be consumed by metadatautils.kodidb.prepare_listitem(), and then subsequently by metadatautils.kodidb.create_listitem() @@ -94,20 +66,19 @@ def generate_item(xml_element): 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) + if api.tag in ('Directory', 'Playlist', 'Hub'): + return _generate_folder(api) else: - return _generate_content(xml_element) + return _generate_content(api) 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) + api.tag, api.attrib) utils.ERROR(notify=True) -def _generate_folder(xml_element): +def _generate_folder(api): '''Generates "folder"/"directory" items that user can further navigate''' - api = API(xml_element) art = api.artwork() return { 'title': api.title(), @@ -128,60 +99,54 @@ def _generate_folder(xml_element): } -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: +def _generate_content(api): + plex_type = api.plex_type + if api.kodi_id: # 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) + item = js.item_details(api.kodi_id, api.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']] + } for x in api.people()['actor']] item = { 'cast': cast, - 'country': api.country_list(), + 'country': api.countries(), 'dateadded': api.date_created(), # e.g '2019-01-03 19:40:59' - 'director': people['Director'], # list of [str] - 'duration': userdata['Runtime'], - 'episode': episode_no, + 'director': api.directors(), # list of [str] + 'duration': api.runtime(), + 'episode': api.index(), # 'file': '', # e.g. 'videodb://tvshows/titles/20' - 'genre': api.genre_list(), + 'genre': api.genres(), # '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' + 'lastplayed': api.lastplayed(), # 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] + 'playcount': api.viewcount(), # [int] 'plot': api.plot(), # [str] 'plotoutline': api.tagline(), 'premiered': api.premiere_date(), # '2018-10-12' - 'rating': api.audience_rating(), # [float] - 'season': season_no, + 'rating': api.rating(), # [float] + 'season': api.season_number(), 'sorttitle': api.sorttitle(), # 'Titans (2018)' - 'studio': api.music_studio_list(), # e.g. 'DC Universe' + 'studio': api.studios(), '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, + 'type': api.kodi_type, 'trailer': api.trailer(), - 'tvshowtitle': tvshowtitle, + 'tvshowtitle': api.show_title(), 'uniqueid': { 'imdbnumber': api.provider('imdb') or '', 'tvdb_id': api.provider('tvdb') or '' }, 'votes': '0', # [str]! - 'writer': people['Writer'], # list of [str] + 'writer': api.writers(), # list of [str] 'year': api.year(), # [int] } @@ -206,18 +171,18 @@ def _generate_content(xml_element): if resume: item['resume'] = { 'position': resume, - 'total': userdata['Runtime'] + 'total': api.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 api.season_number() is not None and api.index() is not None: if APPEND_SXXEXX is True: - item['title'] = "S%.2dE%.2d - %s" % (season_no, episode_no, item['title']) + item['title'] = "S%.2dE%.2d - %s" % (api.season_number(), api.index(), item['title']) if APPEND_SHOW_TITLE is True: - item['title'] = "%s - %s " % (tvshowtitle, item['title']) + item['title'] = "%s - %s " % (api.show_title(), item['title']) item['label'] = item['title'] # Determine the path for this item @@ -226,14 +191,14 @@ def _generate_content(xml_element): params = { 'mode': 'plex_node', 'key': key, - 'offset': xml_element.attrib.get('viewOffset', '0'), + 'offset': api.resume_point_plex() } url = utils.extend_url('plugin://%s' % v.ADDON_ID, 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: + if not api.kodi_id 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', @@ -242,20 +207,6 @@ def _generate_content(xml_element): 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 @@ -460,7 +411,8 @@ def prepare_listitem(item): LOG.error('item that caused crash: %s', item) -def create_listitem(item, as_tuple=True, offscreen=True): +def create_listitem(item, as_tuple=True, offscreen=True, + listitem=xbmcgui.ListItem): """ helper to create a kodi listitem from kodi compatible dict with mediainfo @@ -472,13 +424,13 @@ def create_listitem(item, as_tuple=True, offscreen=True): """ try: if v.KODIVERSION > 17: - liz = xbmcgui.ListItem( + liz = listitem( label=item.get("label", ""), label2=item.get("label2", ""), path=item['file'], offscreen=offscreen) else: - liz = xbmcgui.ListItem( + liz = listitem( label=item.get("label", ""), label2=item.get("label2", ""), path=item['file']) @@ -585,11 +537,9 @@ def create_listitem(item, as_tuple=True, offscreen=True): 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']) + item['art']['icon'] = item['icon'] + liz.setArt(item.get("art", {})) # contextmenu if item["type"] in ["episode", "season"] and "season" in item and "tvshowid" in item: @@ -627,4 +577,3 @@ def create_main_entry(item): 'type': '', 'IsPlayable': 'false' } -