diff --git a/plexapi/collection.py b/plexapi/collection.py index 0eb20924..930e767d 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -6,13 +6,13 @@ from plexapi.base import PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin -from plexapi.mixins import LabelMixin +from plexapi.mixins import LabelMixin, SmartFilterMixin from plexapi.playqueue import PlayQueue from plexapi.utils import deprecated @utils.registerPlexObject -class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin): +class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin, SmartFilterMixin): """ Represents a single Collection. Attributes: @@ -90,6 +90,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin self.userRating = utils.cast(float, data.attrib.get('userRating')) self._items = None # cache for self.items self._section = None # cache for self.section + self._filters = None # cache for self.filters def __len__(self): # pragma: no cover return len(self.items()) @@ -141,6 +142,15 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin def children(self): return self.items() + def filters(self): + """ Returns the search filter dict for smart collection. + The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search` + to get the list of items. + """ + if self.smart and self._filters is None: + self._filters = self._parseFilters(self.content) + return self._filters + def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to. """ diff --git a/plexapi/library.py b/plexapi/library.py index f1fdaad8..5c0a777b 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -921,16 +921,13 @@ class LibrarySection(PlexObject): sortField = libtype + '.' + filterSort.key - if not sortDir: - sortDir = filterSort.defaultDirection - - availableDirections = ['asc', 'desc', 'nullsLast'] + availableDirections = ['', 'asc', 'desc', 'nullsLast'] if sortDir not in availableDirections: raise NotFound('Unknown sort direction "%s". ' 'Available sort directions: %s' % (sortDir, availableDirections)) - return '%s:%s' % (sortField, sortDir) + return '%s:%s' % (sortField, sortDir) if sortDir else sortField def _validateAdvancedSearch(self, filters, libtype): """ Validates an advanced search filter dictionary. @@ -2028,7 +2025,11 @@ class FilteringType(PlexObject): additionalSorts.extend([ ('absoluteIndex', 'asc', 'Absolute Index') ]) - if self.type == 'collection': + elif self.type == 'photo': + additionalSorts.extend([ + ('viewUpdatedAt', 'desc', 'View Updated At') + ]) + elif self.type == 'collection': additionalSorts.extend([ ('addedAt', 'asc', 'Date Added') ]) diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 2ff12a20..b5e7e649 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from urllib.parse import quote_plus, urlencode +from urllib.parse import parse_qsl, quote_plus, unquote, urlencode, urlsplit from plexapi import media, settings, utils from plexapi.exceptions import BadRequest, NotFound @@ -559,3 +559,61 @@ class WriterMixin(object): locked (bool): True (default) to lock the field, False to unlock the field. """ self._edit_tags('writer', writers, locked=locked, remove=True) + + +class SmartFilterMixin(object): + """ Mixing for Plex objects that can have smart filters. """ + + def _parseFilters(self, content): + """ Parse the content string and returns the filter dict. """ + content = urlsplit(unquote(content)) + filters = {} + filterOp = 'and' + filterGroups = [[]] + + for key, value in parse_qsl(content.query): + # Move = sign to key when operator is == + if value.startswith('='): + key += '=' + value = value[1:] + + if key == 'type': + filters['libtype'] = utils.reverseSearchType(value) + elif key == 'sort': + filters['sort'] = value.split(',') + elif key == 'limit': + filters['limit'] = int(value) + elif key == 'push': + filterGroups[-1].append([]) + filterGroups.append(filterGroups[-1][-1]) + elif key == 'and': + filterOp = 'and' + elif key == 'or': + filterOp = 'or' + elif key == 'pop': + filterGroups[-1].insert(0, filterOp) + filterGroups.pop() + else: + filterGroups[-1].append({key: value}) + + if filterGroups: + filters['filters'] = self._formatFilterGroups(filterGroups.pop()) + return filters + + def _formatFilterGroups(self, groups): + """ Formats the filter groups into the advanced search rules. """ + if len(groups) == 1 and isinstance(groups[0], list): + groups = groups.pop() + + filterOp = 'and' + rules = [] + + for g in groups: + if isinstance(g, list): + rules.append(self._formatFilterGroups(g)) + elif isinstance(g, dict): + rules.append(g) + elif g in {'and', 'or'}: + filterOp = g + + return {filterOp: rules} diff --git a/plexapi/playlist.py b/plexapi/playlist.py index c99626d0..8333bc66 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -6,13 +6,13 @@ from plexapi import utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection -from plexapi.mixins import ArtMixin, PosterMixin +from plexapi.mixins import ArtMixin, PosterMixin, SmartFilterMixin from plexapi.playqueue import PlayQueue from plexapi.utils import deprecated @utils.registerPlexObject -class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): +class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin): """ Represents a single Playlist. Attributes: @@ -61,6 +61,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self._items = None # cache for self.items self._section = None # cache for self.section + self._filters = None # cache for self.filters def __len__(self): # pragma: no cover return len(self.items()) @@ -107,6 +108,22 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): """ Returns True if this is a photo playlist. """ return self.playlistType == 'photo' + def _getPlaylistItemID(self, item): + """ Match an item to a playlist item and return the item playlistItemID. """ + for _item in self.items(): + if _item.ratingKey == item.ratingKey: + return _item.playlistItemID + raise NotFound('Item with title "%s" not found in the playlist' % item.title) + + def filters(self): + """ Returns the search filter dict for smart playlist. + The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search` + to get the list of items. + """ + if self.smart and self._filters is None: + self._filters = self._parseFilters(self.content) + return self._filters + def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to. @@ -160,13 +177,6 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): """ Alias to :func:`~plexapi.playlist.Playlist.item`. """ return self.item(title) - def _getPlaylistItemID(self, item): - """ Match an item to a playlist item and return the item playlistItemID. """ - for _item in self.items(): - if _item.ratingKey == item.ratingKey: - return _item.playlistItemID - raise NotFound('Item with title "%s" not found in the playlist' % item.title) - def addItems(self, items): """ Add items to the playlist. diff --git a/plexapi/utils.py b/plexapi/utils.py index 5fe31caa..310200f6 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -148,6 +148,7 @@ def searchType(libtype): Parameters: libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track, collection) + Raises: :exc:`~plexapi.exceptions.NotFound`: Unknown libtype """ @@ -159,6 +160,24 @@ def searchType(libtype): raise NotFound('Unknown libtype: %s' % libtype) +def reverseSearchType(libtype): + """ Returns the string value of the library type. + + Parameters: + libtype (int): Integer value of the library type. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown libtype + """ + if libtype in SEARCHTYPES: + return libtype + libtype = int(libtype) + for k, v in SEARCHTYPES.items(): + if libtype == v: + return k + raise NotFound('Unknown libtype: %s' % libtype) + + def threaded(callback, listargs): """ Returns the result of for each set of `*args` in listargs. Each call to is called concurrently in their own separate threads. diff --git a/tests/test_collection.py b/tests/test_collection.py index 67aeff0e..e580a131 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -184,6 +184,35 @@ def test_Collection_createSmart(plex, tvshows): collection.delete() +def test_Collection_smartFilters(plex, movies): + title = "test_Collection_smartFilters" + advancedFilters = { + 'and': [ + { + 'or': [ + {'title': 'elephant'}, + {'title=': 'Big Buck Bunny'} + ] + }, + {'year>>': 1990}, + {'unwatched': True} + ] + } + try: + collection = plex.createCollection( + title=title, + section=movies, + smart=True, + limit=5, + sort="year", + filters=advancedFilters + ) + filters = collection.filters() + assert movies.search(**filters) == collection.items() + finally: + collection.delete() + + def test_Collection_exceptions(plex, movies, movie, artist): title = 'test_Collection_exceptions' try: diff --git a/tests/test_playlist.py b/tests/test_playlist.py index f5faff86..e5ddcd74 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -182,6 +182,22 @@ def test_Playlist_createSmart(plex, movies, movie): playlist.delete() +def test_Playlist_smartFilters(plex, tvshows): + try: + playlist = plex.createPlaylist( + title="smart_playlist_filters", + smart=True, + section=tvshows, + limit=5, + sort=["season.index:nullsLast", "episode.index:nullsLast", "show.titleSort"], + filters={"or": [{"show.title": "game"}, {'show.title': "100"}]} + ) + filters = playlist.filters() + assert tvshows.search(**filters) == playlist.items() + finally: + playlist.delete() + + def test_Playlist_section(plex, movies, movie): title = 'test_playlist_section' try: diff --git a/tests/test_utils.py b/tests/test_utils.py index 9febf0dc..0e62535d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -39,6 +39,15 @@ def test_utils_searchType(): utils.searchType("kekekekeke") +def test_utils_reverseSearchType(): + st = utils.reverseSearchType(1) + assert st == "movie" + movie = utils.reverseSearchType("movie") + assert movie == "movie" + with pytest.raises(NotFound): + utils.reverseSearchType(-1) + + def test_utils_joinArgs(): test_dict = {"genre": "action", "type": 1337} assert utils.joinArgs(test_dict) == "?genre=action&type=1337"