Merge pull request #781 from JonnyWong16/feature/parse_smart_filter

Add ability to parse the smart filters from collections and playlists
This commit is contained in:
JonnyWong16 2021-07-16 09:21:10 -07:00 committed by GitHub
commit 82fa178952
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 170 additions and 18 deletions

View file

@ -6,13 +6,13 @@ from plexapi.base import PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection from plexapi.library import LibrarySection
from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin 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.playqueue import PlayQueue
from plexapi.utils import deprecated from plexapi.utils import deprecated
@utils.registerPlexObject @utils.registerPlexObject
class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin): class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin, SmartFilterMixin):
""" Represents a single Collection. """ Represents a single Collection.
Attributes: Attributes:
@ -90,6 +90,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
self.userRating = utils.cast(float, data.attrib.get('userRating')) self.userRating = utils.cast(float, data.attrib.get('userRating'))
self._items = None # cache for self.items self._items = None # cache for self.items
self._section = None # cache for self.section self._section = None # cache for self.section
self._filters = None # cache for self.filters
def __len__(self): # pragma: no cover def __len__(self): # pragma: no cover
return len(self.items()) return len(self.items())
@ -141,6 +142,15 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
def children(self): def children(self):
return self.items() 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): def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to. """ Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to.
""" """

View file

@ -921,16 +921,13 @@ class LibrarySection(PlexObject):
sortField = libtype + '.' + filterSort.key sortField = libtype + '.' + filterSort.key
if not sortDir: availableDirections = ['', 'asc', 'desc', 'nullsLast']
sortDir = filterSort.defaultDirection
availableDirections = ['asc', 'desc', 'nullsLast']
if sortDir not in availableDirections: if sortDir not in availableDirections:
raise NotFound('Unknown sort direction "%s". ' raise NotFound('Unknown sort direction "%s". '
'Available sort directions: %s' 'Available sort directions: %s'
% (sortDir, availableDirections)) % (sortDir, availableDirections))
return '%s:%s' % (sortField, sortDir) return '%s:%s' % (sortField, sortDir) if sortDir else sortField
def _validateAdvancedSearch(self, filters, libtype): def _validateAdvancedSearch(self, filters, libtype):
""" Validates an advanced search filter dictionary. """ Validates an advanced search filter dictionary.
@ -2028,7 +2025,11 @@ class FilteringType(PlexObject):
additionalSorts.extend([ additionalSorts.extend([
('absoluteIndex', 'asc', 'Absolute Index') ('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([ additionalSorts.extend([
('addedAt', 'asc', 'Date Added') ('addedAt', 'asc', 'Date Added')
]) ])

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- 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 import media, settings, utils
from plexapi.exceptions import BadRequest, NotFound 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
self._edit_tags('writer', writers, locked=locked, remove=True) 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}

View file

@ -6,13 +6,13 @@ from plexapi import utils
from plexapi.base import Playable, PlexPartialObject from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection 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.playqueue import PlayQueue
from plexapi.utils import deprecated from plexapi.utils import deprecated
@utils.registerPlexObject @utils.registerPlexObject
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin):
""" Represents a single Playlist. """ Represents a single Playlist.
Attributes: Attributes:
@ -61,6 +61,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self._items = None # cache for self.items self._items = None # cache for self.items
self._section = None # cache for self.section self._section = None # cache for self.section
self._filters = None # cache for self.filters
def __len__(self): # pragma: no cover def __len__(self): # pragma: no cover
return len(self.items()) return len(self.items())
@ -107,6 +108,22 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
""" Returns True if this is a photo playlist. """ """ Returns True if this is a photo playlist. """
return self.playlistType == 'photo' 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): def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to. """ 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`. """ """ Alias to :func:`~plexapi.playlist.Playlist.item`. """
return self.item(title) 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): def addItems(self, items):
""" Add items to the playlist. """ Add items to the playlist.

View file

@ -148,6 +148,7 @@ def searchType(libtype):
Parameters: Parameters:
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track, libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
collection) collection)
Raises: Raises:
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype :exc:`~plexapi.exceptions.NotFound`: Unknown libtype
""" """
@ -159,6 +160,24 @@ def searchType(libtype):
raise NotFound('Unknown libtype: %s' % 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): def threaded(callback, listargs):
""" Returns the result of <callback> for each set of `*args` in listargs. Each call """ Returns the result of <callback> for each set of `*args` in listargs. Each call
to <callback> is called concurrently in their own separate threads. to <callback> is called concurrently in their own separate threads.

View file

@ -184,6 +184,35 @@ def test_Collection_createSmart(plex, tvshows):
collection.delete() 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): def test_Collection_exceptions(plex, movies, movie, artist):
title = 'test_Collection_exceptions' title = 'test_Collection_exceptions'
try: try:

View file

@ -182,6 +182,22 @@ def test_Playlist_createSmart(plex, movies, movie):
playlist.delete() 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): def test_Playlist_section(plex, movies, movie):
title = 'test_playlist_section' title = 'test_playlist_section'
try: try:

View file

@ -39,6 +39,15 @@ def test_utils_searchType():
utils.searchType("kekekekeke") 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(): def test_utils_joinArgs():
test_dict = {"genre": "action", "type": 1337} test_dict = {"genre": "action", "type": 1337}
assert utils.joinArgs(test_dict) == "?genre=action&type=1337" assert utils.joinArgs(test_dict) == "?genre=action&type=1337"