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.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.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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')
|
||||||
])
|
])
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue