diff --git a/plexapi/audio.py b/plexapi/audio.py index 542fa7cc2..455c2a40a 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -156,14 +156,12 @@ def sonicallySimilar( Returns: List[:class:`~plexapi.audio.Audio`]: list of sonically similar audio items. """ - - key = f"{self.key}/nearest" params: Dict[str, Any] = {} if limit is not None: params['limit'] = limit if maxDistance is not None: params['maxDistance'] = maxDistance - key += utils.joinArgs(params) + key = self._buildQueryKey(f"{self.key}/nearest", **params) return self.fetchItems( key, @@ -280,7 +278,7 @@ def track(self, title=None, album=None, track=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing. """ - key = f'{self.key}/allLeaves' + key = self._buildQueryKey(f'{self.key}/allLeaves') if title is not None: return self.fetchItem(key, Track, title__iexact=title) elif album is not None and track is not None: @@ -289,7 +287,7 @@ def track(self, title=None, album=None, track=None): def tracks(self, **kwargs): """ Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """ - key = f'{self.key}/allLeaves' + key = self._buildQueryKey(f"{self.key}/allLeaves") return self.fetchItems(key, Track, **kwargs) def get(self, title=None, album=None, track=None): @@ -329,7 +327,7 @@ def popularTracks(self): def station(self): """ Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """ - key = f'{self.key}?includeStations=1' + key = self._buildQueryKey(f'{self.key}', includeStations=1) return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None) @property @@ -440,7 +438,7 @@ def track(self, title=None, track=None): Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing. """ - key = f'{self.key}/children' + key = self._buildQueryKey(f'{self.key}/children') if title is not None and not isinstance(title, int): return self.fetchItem(key, Track, title__iexact=title) elif track is not None or isinstance(title, int): @@ -453,7 +451,7 @@ def track(self, title=None, track=None): def tracks(self, **kwargs): """ Returns a list of :class:`~plexapi.audio.Track` objects in the album. """ - key = f'{self.key}/children' + key = self._buildQueryKey(f'{self.key}/children') return self.fetchItems(key, Track, **kwargs) def get(self, title=None, track=None): @@ -462,7 +460,8 @@ def get(self, title=None, track=None): def artist(self): """ Return the album's :class:`~plexapi.audio.Artist`. """ - return self.fetchItem(self.parentKey) + key = self._buildQueryKey(self.parentKey) + return self.fetchItem(key) def download(self, savepath=None, keep_original_name=False, **kwargs): """ Download all tracks from the album. See :func:`~plexapi.base.Playable.download` for details. @@ -609,11 +608,13 @@ def _prettyfilename(self): def album(self): """ Return the track's :class:`~plexapi.audio.Album`. """ - return self.fetchItem(self.parentKey) + key = self._buildQueryKey(self.parentKey) + return self.fetchItem(key) def artist(self): """ Return the track's :class:`~plexapi.audio.Artist`. """ - return self.fetchItem(self.grandparentKey) + key = self._buildQueryKey(self.grandparentKey) + return self.fetchItem(key) def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ diff --git a/plexapi/base.py b/plexapi/base.py index adf0c6b08..bc4d6a5f0 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -191,9 +191,10 @@ def _buildQueryKey(self, key, **kwargs): return None args = {'includeGuids': 1, **kwargs} - params = utils.joinArgs(args) + params = utils.joinArgs(args).lstrip('?') + delim = '&' if '?' in key else '?' - return f"{key}{params}" + return f"{key}{delim}{params}" def _isChildOf(self, **kwargs): """ Returns True if this object is a child of the given attributes. diff --git a/plexapi/collection.py b/plexapi/collection.py index def67a73e..6fad6859c 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -196,7 +196,7 @@ def item(self, title): @cached_data_property def _items(self): """ Cache for the items. """ - key = f'{self.key}/children' + key = self._buildQueryKey(f'{self.key}/children') return self.fetchItems(key) def items(self): diff --git a/plexapi/library.py b/plexapi/library.py index 9073ec4c4..da4e80491 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -136,7 +136,7 @@ def hubs(self, sectionID=None, identifier=None, **kwargs): if not isinstance(identifier, list): identifier = [identifier] kwargs['identifier'] = ",".join(identifier) - key = f'/hubs{utils.joinArgs(kwargs)}' + key = self._buildQueryKey('/hubs', **kwargs) return self.fetchItems(key) def all(self, **kwargs): @@ -151,11 +151,13 @@ def all(self, **kwargs): def onDeck(self): """ Returns a list of all media items on deck. """ - return self.fetchItems('/library/onDeck') + key = self._buildQueryKey('/library/onDeck') + return self.fetchItems(key) def recentlyAdded(self): """ Returns a list of all media items recently added. """ - return self.fetchItems('/library/recentlyAdded') + key = self._buildQueryKey('/library/recentlyAdded') + return self.fetchItems(key) def search(self, title=None, libtype=None, **kwargs): """ Searching within a library section is much more powerful. It seems certain @@ -173,7 +175,7 @@ def search(self, title=None, libtype=None, **kwargs): args['type'] = utils.searchType(libtype) for attr, value in kwargs.items(): args[attr] = value - key = f'/library/all{utils.joinArgs(args)}' + key = self._buildQueryKey('/library/all', **args) return self.fetchItems(key) def cleanBundles(self): @@ -711,7 +713,7 @@ def resetManagedHubs(self): def hubs(self): """ Returns a list of available :class:`~plexapi.library.Hub` for this library section. """ - key = f'/hubs/sections/{self.key}?includeStations=1' + key = self._buildQueryKey(f'/hubs/sections/{self.key}', includeStations=1) return self.fetchItems(key) def agents(self): @@ -800,12 +802,12 @@ def timeline(self): def onDeck(self): """ Returns a list of media items on deck from this library section. """ - key = f'/library/sections/{self.key}/onDeck' + key = self._buildQueryKey(f'/library/sections/{self.key}/onDeck') return self.fetchItems(key) def continueWatching(self): """ Return a list of media items in the library's Continue Watching hub. """ - key = f'/hubs/sections/{self.key}/continueWatching/items' + key = self._buildQueryKey(f'/hubs/sections/{self.key}/continueWatching/items') return self.fetchItems(key) def recentlyAdded(self, maxresults=50, libtype=None): @@ -1955,7 +1957,7 @@ class MusicSection(LibrarySection, ArtistEditMixins, AlbumEditMixins, TrackEditM def albums(self): """ Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ - key = f'/library/sections/{self.key}/albums' + key = self._buildQueryKey(f'/library/sections/{self.key}/albums') return self.fetchItems(key) def stations(self): @@ -2054,7 +2056,7 @@ def sonicAdventure( startID = start if isinstance(start, int) else start.ratingKey endID = end if isinstance(end, int) else end.ratingKey - key = f"/library/sections/{self.key}/computePath?startID={startID}&endID={endID}" + key = self._buildQueryKey(f"/library/sections/{self.key}/computePath", startID=startID, endID=endID) return self.fetchItems(key, **kwargs) @@ -2233,7 +2235,8 @@ def _partialItems(self): def _items(self): """ Cache for items. """ if self.more and self.key: # If there are more items to load, fetch them - items = self.fetchItems(self.key) + key = self._buildQueryKey(self.key) + items = self.fetchItems(key) self.more = False self.size = len(items) return items @@ -2309,11 +2312,12 @@ def _loadData(self, data): self.tagValue = utils.cast(int, data.attrib.get('tagValue')) self.thumb = data.attrib.get('thumb') - def items(self, *args, **kwargs): + def items(self): """ Return the list of items within this tag. """ if not self.key: raise BadRequest(f'Key is not defined for this tag: {self.tag}') - return self.fetchItems(self.key) + key = self._buildQueryKey(self.key) + return self.fetchItems(key) @utils.registerPlexObject @@ -2997,7 +3001,8 @@ def _loadData(self, data): def items(self): """ Returns a list of items for this filter choice. """ - return self.fetchItems(self.fastKey) + key = self._buildQueryKey(self.fastKey) + return self.fetchItems(key) class ManagedHub(PlexObject): diff --git a/plexapi/media.py b/plexapi/media.py index 465eaf748..bb5e92f72 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -755,7 +755,8 @@ def items(self): """ Return the list of items within this tag. """ if not self.key: raise BadRequest(f'Key is not defined for this tag: {self.tag}. Reload the parent object.') - return self.fetchItems(self.key) + key = self._buildQueryKey(self.key) + return self.fetchItems(key) @utils.registerPlexObject diff --git a/plexapi/mixins/objects.py b/plexapi/mixins/objects.py index 56ab0b5cd..2646f1f09 100644 --- a/plexapi/mixins/objects.py +++ b/plexapi/mixins/objects.py @@ -4,7 +4,7 @@ class ExtrasMixin: def extras(self): """ Returns a list of :class:`~plexapi.video.Extra` objects. """ from plexapi.video import Extra - key = f'{self.key}/extras' + key = self._buildQueryKey(f'{self.key}/extras') return self.fetchItems(key, cls=Extra) @@ -14,5 +14,5 @@ class HubsMixin: def hubs(self): """ Returns a list of :class:`~plexapi.library.Hub` objects. """ from plexapi.library import Hub - key = f'{self.key}/related' + key = self._buildQueryKey(f'{self.key}/related') return self.fetchItems(key, cls=Hub) diff --git a/plexapi/photo.py b/plexapi/photo.py index 7f4658f52..efdcf88da 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -79,12 +79,12 @@ def album(self, title): Parameters: title (str): Title of the photo album to return. """ - key = f'{self.key}/children' + key = self._buildQueryKey(f'{self.key}/children') return self.fetchItem(key, Photoalbum, title__iexact=title) def albums(self, **kwargs): """ Returns a list of :class:`~plexapi.photo.Photoalbum` objects in the album. """ - key = f'{self.key}/children' + key = self._buildQueryKey(f'{self.key}/children') return self.fetchItems(key, Photoalbum, **kwargs) def photo(self, title): @@ -93,12 +93,12 @@ def photo(self, title): Parameters: title (str): Title of the photo to return. """ - key = f'{self.key}/children' + key = self._buildQueryKey(f'{self.key}/children') return self.fetchItem(key, Photo, title__iexact=title) def photos(self, **kwargs): """ Returns a list of :class:`~plexapi.photo.Photo` objects in the album. """ - key = f'{self.key}/children' + key = self._buildQueryKey(f'{self.key}/children') return self.fetchItems(key, Photo, **kwargs) def clip(self, title): @@ -107,12 +107,12 @@ def clip(self, title): Parameters: title (str): Title of the clip to return. """ - key = f'{self.key}/children' + key = self._buildQueryKey(f'{self.key}/children') return self.fetchItem(key, video.Clip, title__iexact=title) def clips(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Clip` objects in the album. """ - key = f'{self.key}/children' + key = self._buildQueryKey(f'{self.key}/children') return self.fetchItems(key, video.Clip, **kwargs) def get(self, title): @@ -250,7 +250,8 @@ def _prettyfilename(self): def photoalbum(self): """ Return the photo's :class:`~plexapi.photo.Photoalbum`. """ - return self.fetchItem(self.parentKey) + key = self._buildQueryKey(self.parentKey) + return self.fetchItem(key) def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` the item belongs to. """ diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 80dcb2dd7..c42517b16 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -187,7 +187,7 @@ def _items(self): if self.radio: return [] - key = f'{self.key}/items' + key = self._buildQueryKey(f'{self.key}/items') items = self.fetchItems(key) # Cache server connections to avoid reconnecting for each item diff --git a/plexapi/video.py b/plexapi/video.py index a56b58e5a..910104a00 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -697,7 +697,7 @@ def onDeck(self): """ Returns show's On Deck :class:`~plexapi.video.Video` object or `None`. If show is unwatched, return will likely be the first episode. """ - key = f'{self.key}?includeOnDeck=1' + key = self._buildQueryKey(f'{self.key}', includeOnDeck=1) return next(iter(self.fetchItems(key, cls=Episode, rtag='OnDeck')), None) def season(self, title=None, season=None): @@ -893,7 +893,7 @@ def onDeck(self): """ Returns season's On Deck :class:`~plexapi.video.Video` object or `None`. Will only return a match if the show's On Deck episode is in this season. """ - key = f'{self.key}?includeOnDeck=1' + key = self._buildQueryKey(f'{self.key}', includeOnDeck=1) return next(iter(self.fetchItems(key, cls=Episode, rtag='OnDeck')), None) def episode(self, title=None, episode=None): @@ -928,7 +928,8 @@ def get(self, title=None, episode=None): def show(self): """ Return the season's :class:`~plexapi.video.Show`. """ - return self.fetchItem(self._buildQueryKey(self.parentKey)) + key = self._buildQueryKey(self.parentKey) + return self.fetchItem(key) def watched(self): """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ @@ -1218,11 +1219,13 @@ def hasPreviewThumbnails(self): def season(self): """" Return the episode's :class:`~plexapi.video.Season`. """ - return self.fetchItem(self._buildQueryKey(self.parentKey)) + key = self._buildQueryKey(self.parentKey) + return self.fetchItem(key) def show(self): """" Return the episode's :class:`~plexapi.video.Show`. """ - return self.fetchItem(self._buildQueryKey(self.grandparentKey)) + key = self._buildQueryKey(self.grandparentKey) + return self.fetchItem(key) def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ diff --git a/tests/test_fetch_items.py b/tests/test_fetch_items.py index 749117634..c11b26974 100644 --- a/tests/test_fetch_items.py +++ b/tests/test_fetch_items.py @@ -39,3 +39,25 @@ def test_find_items_empty_data(plex): assert len(result) == 0 result = plex.findItems(Element("MediaContainer")) assert isinstance(result, MediaContainer) + + +def test_build_query_key(plex): + key = '/test/key' + key_with_query = f'{key}?foo=bar' + kwargs = {'index': 1, 'type': 2} + + query_key = plex._buildQueryKey(key) + assert query_key.startswith(key) + assert '?includeGuids=1' in query_key + + query_key = plex._buildQueryKey(key, **kwargs) + query_params = [] + for k, v in kwargs.items(): + query_param = f'{k}={v}' + assert query_param in query_key + query_params.append(query_param) + + query_key = plex._buildQueryKey(key_with_query, **kwargs) + assert query_key.startswith(key_with_query) + assert '&includeGuids=1' in query_key + assert f'&{"&".join(query_params)}' in query_key