mirror of
https://github.com/pkkid/python-plexapi
synced 2024-11-15 00:17:33 +00:00
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:
commit
82fa178952
8 changed files with 170 additions and 18 deletions
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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')
|
||||
])
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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 <callback> for each set of `*args` in listargs. Each call
|
||||
to <callback> is called concurrently in their own separate threads.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue