Merge pull request #693 from JonnyWong16/feature/library_search

Fix and update library searching
This commit is contained in:
Steffen Fredriksen 2021-03-21 17:38:53 +01:00 committed by GitHub
commit 1beee642fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 958 additions and 306 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View file

@ -52,7 +52,7 @@ class Audio(PlexPartialObject):
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '')
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'audio'

View file

@ -144,34 +144,9 @@ class PlexObject(object):
it only returns those items. By default we convert the xml elements
with the best guess PlexObjects based on tag and type attrs.
etag (str): Only fetch items with the specified tag.
**kwargs (dict): Optionally add attribute filters on the items to fetch. For
example, passing in viewCount=0 will only return matching items. Filtering
is done before the Python objects are built to help keep things speedy.
Note: Because some attribute names are already used as arguments to this
function, such as 'tag', you may still reference the attr tag byappending
an underscore. For example, passing in _tag='foobar' will return all items
where tag='foobar'. Also Note: Case very much matters when specifying kwargs
-- Optionally, operators can be specified by append it
to the end of the attribute name for more complex lookups. For example,
passing in viewCount__gte=0 will return all items where viewCount >= 0.
Available operations include:
* __contains: Value contains specified arg.
* __endswith: Value ends with specified arg.
* __exact: Value matches specified arg.
* __exists (bool): Value is or is not present in the attrs.
* __gt: Value is greater than specified arg.
* __gte: Value is greater than or equal to specified arg.
* __icontains: Case insensative value contains specified arg.
* __iendswith: Case insensative value ends with specified arg.
* __iexact: Case insensative value matches specified arg.
* __in: Value is in a specified list or tuple.
* __iregex: Case insensative value matches the specified regular expression.
* __istartswith: Case insensative value starts with specified arg.
* __lt: Value is less than specified arg.
* __lte: Value is less than or equal to specified arg.
* __regex: Value matches the specified regular expression.
* __startswith: Value starts with specified arg.
**kwargs (dict): Optionally add XML attribute to filter the items.
See :func:`~plexapi.base.PlexObject.fetchItems` for more details
on how this is used.
"""
if ekey is None:
raise BadRequest('ekey was not provided')
@ -185,12 +160,76 @@ class PlexObject(object):
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
""" Load the specified key to find and build all items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
and attrs.
Parameters:
ekey (str): API URL path in Plex to fetch items from.
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
items to be fetched, passing this in will help the parser ensure
it only returns those items. By default we convert the xml elements
with the best guess PlexObjects based on tag and type attrs.
etag (str): Only fetch items with the specified tag.
container_start (None, int): offset to get a subset of the data
container_size (None, int): How many items in data
**kwargs (dict): Optionally add XML attribute to filter the items.
See the details below for more info.
**Filtering XML Attributes**
Any XML attribute can be filtered when fetching results. Filtering is done before
the Python objects are built to help keep things speedy. For example, passing in
``viewCount=0`` will only return matching items where the view count is ``0``.
Note that case matters when specifying attributes. Attributes futher down in the XML
tree can be filtered by *prepending* the attribute with each element tag ``Tag__``.
Examples:
.. code-block:: python
fetchItem(ekey, viewCount=0)
fetchItem(ekey, contentRating="PG")
fetchItem(ekey, Genre__tag="Animation")
fetchItem(ekey, Media__videoCodec="h265")
fetchItem(ekey, Media__Part__container="mp4)
Note that because some attribute names are already used as arguments to this
function, such as ``tag``, you may still reference the attr tag by prepending an
underscore. For example, passing in ``_tag='foobar'`` will return all items where
``tag='foobar'``.
**Using PlexAPI Operators**
Optionally, PlexAPI operators can be specified by *appending* it to the end of the
attribute for more complex lookups. For example, passing in ``viewCount__gte=0``
will return all items where ``viewCount >= 0``.
List of Available Operators:
* ``__contains``: Value contains specified arg.
* ``__endswith``: Value ends with specified arg.
* ``__exact``: Value matches specified arg.
* ``__exists`` (*bool*): Value is or is not present in the attrs.
* ``__gt``: Value is greater than specified arg.
* ``__gte``: Value is greater than or equal to specified arg.
* ``__icontains``: Case insensative value contains specified arg.
* ``__iendswith``: Case insensative value ends with specified arg.
* ``__iexact``: Case insensative value matches specified arg.
* ``__in``: Value is in a specified list or tuple.
* ``__iregex``: Case insensative value matches the specified regular expression.
* ``__istartswith``: Case insensative value starts with specified arg.
* ``__lt``: Value is less than specified arg.
* ``__lte``: Value is less than or equal to specified arg.
* ``__regex``: Value matches the specified regular expression.
* ``__startswith``: Value starts with specified arg.
Examples:
.. code-block:: python
fetchItem(ekey, viewCount__gte=0)
fetchItem(ekey, Media__container__in=["mp4", "mkv"])
fetchItem(ekey, guid__iregex=r"(imdb:\/\/|themoviedb:\/\/)")
fetchItem(ekey, Media__Part__file__startswith="D:\\Movies")
"""
url_kw = {}
@ -204,7 +243,7 @@ class PlexObject(object):
data = self._server.query(ekey, params=url_kw)
items = self.findItems(data, cls, ekey, **kwargs)
librarySectionID = data.attrib.get('librarySectionID')
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
if librarySectionID:
for item in items:
item.librarySectionID = librarySectionID

View file

@ -59,7 +59,7 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin):
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.maxYear = utils.cast(int, data.attrib.get('maxYear'))

File diff suppressed because it is too large Load diff

View file

@ -46,7 +46,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin):
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'photo'
@ -186,7 +186,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin):
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '')
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'photo'

View file

@ -237,7 +237,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
uri = uri + '&limit=%s' % str(limit)
for category, value in kwargs.items():
sectionChoices = section.listChoices(category)
sectionChoices = section.listFilterChoices(category)
for choice in sectionChoices:
if str(choice.title).lower() == str(value).lower():
uri = uri + '&%s=%s' % (category.lower(), str(choice.key))

View file

@ -512,7 +512,7 @@ class PlexServer(PlexObject):
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
def search(self, query, mediatype=None, limit=None):
def search(self, query, mediatype=None, limit=None, sectionId=None):
""" Returns a list of media items or filter categories from the resulting
`Hub Search <https://www.plex.tv/blog/seek-plex-shall-find-leveling-web-app/>`_
against all items in your Plex library. This searches genres, actors, directors,
@ -526,10 +526,11 @@ class PlexServer(PlexObject):
Parameters:
query (str): Query to use when searching your library.
mediatype (str): Optionally limit your search to the specified media type.
mediatype (str, optional): Limit your search to the specified media type.
actor, album, artist, autotag, collection, director, episode, game, genre,
movie, photo, photoalbum, place, playlist, shared, show, tag, track
limit (int): Optionally limit to the specified number of results per Hub.
limit (int, optional): Limit to the specified number of results per Hub.
sectionId (int, optional): The section ID (key) of the library to search within.
"""
results = []
params = {
@ -538,6 +539,8 @@ class PlexServer(PlexObject):
'includeExternalMedia': 1}
if limit:
params['limit'] = limit
if sectionId:
params['sectionId'] = sectionId
key = '/hubs/search?%s' % urlencode(params)
for hub in self.fetchItems(key, Hub):
if mediatype:

View file

@ -48,7 +48,7 @@ class Video(PlexPartialObject):
self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key', '')
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'video'

View file

@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from collections import namedtuple
from datetime import datetime, timedelta
import pytest
from plexapi.exceptions import NotFound
from plexapi.exceptions import BadRequest, NotFound
from . import conftest as utils
@ -25,13 +26,13 @@ def test_library_sectionByID_with_attrs(plex, movies):
assert movies.agent == "tv.plex.agents.movie"
# This seems to fail for some reason.
# my account alloew of sync, didnt find any about settings about the library.
# assert movies.allowSync is ('sync' in plex.ownerFeatures)
# assert movies.allowSync is ("sync" in plex.ownerFeatures)
assert movies.art == "/:/resources/movie-fanart.jpg"
assert utils.is_metadata(
movies.composite, prefix="/library/sections/", contains="/composite/"
)
assert utils.is_datetime(movies.createdAt)
assert movies.filters == "1"
assert movies.filters is True
assert movies._initpath == "/library/sections"
assert utils.is_int(movies.key)
assert movies.language == "en-US"
@ -47,8 +48,8 @@ def test_library_sectionByID_with_attrs(plex, movies):
assert len(movies.uuid) == 36
def test_library_section_get_movie(plex):
assert plex.library.section("Movies").get("Sita Sings the Blues")
def test_library_section_get_movie(movies):
assert movies.get("Sita Sings the Blues")
def test_library_section_movies_all(movies):
@ -143,10 +144,6 @@ def test_library_MovieSection_update_path(movies):
movies.update(path=movies.locations[0])
def test_library_ShowSection_all(tvshows):
assert len(tvshows.all(title__iexact="The 100"))
def test_library_MovieSection_refresh(movies, patched_http_call):
movies.refresh()
@ -173,6 +170,10 @@ def test_library_MovieSection_onDeck(movie, movies, tvshows, episode):
episode.markUnwatched()
def test_library_MovieSection_searchMovies(movies):
assert movies.searchMovies(title="Elephants Dream")
def test_library_MovieSection_recentlyAdded(movies):
assert len(movies.recentlyAdded())
@ -185,10 +186,18 @@ def test_library_MovieSection_collections(movies, collection):
assert len(movies.collections())
def test_library_ShowSection_all(tvshows):
assert len(tvshows.all(title__iexact="The 100"))
def test_library_ShowSection_searchShows(tvshows):
assert tvshows.searchShows(title="The 100")
def test_library_ShowSection_searchSseasons(tvshows):
assert tvshows.searchSeasons(**{"show.title": "The 100"})
def test_library_ShowSection_searchEpisodes(tvshows):
assert tvshows.searchEpisodes(title="Winter Is Coming")
@ -229,10 +238,10 @@ def test_library_PhotoSection_searchPhotos(photos, photoalbum):
assert len(photos.searchPhotos(title))
def test_library_and_section_search_for_movie(plex):
find = "16 blocks"
def test_library_and_section_search_for_movie(plex, movies):
find = "Elephants Dream"
l_search = plex.library.search(find)
s_search = plex.library.section("Movies").search(find)
s_search = movies.search(find)
assert l_search == s_search
@ -244,12 +253,12 @@ def test_library_settings(movies):
def test_library_editAdvanced_default(movies):
movies.editAdvanced(hidden=2)
for setting in movies.settings():
if setting.id == 'hidden':
if setting.id == "hidden":
assert int(setting.value) == 2
movies.editAdvanced(collectionMode=0)
for setting in movies.settings():
if setting.id == 'collectionMode':
if setting.id == "collectionMode":
assert int(setting.value) == 0
movies.reload()
@ -258,17 +267,16 @@ def test_library_editAdvanced_default(movies):
assert str(setting.value) == str(setting.default)
def test_search_with_weird_a(plex):
def test_search_with_weird_a(plex, tvshows):
ep_title = "Coup de Grâce"
result_root = plex.search(ep_title)
result_shows = plex.library.section("TV Shows").searchEpisodes(title=ep_title)
result_shows = tvshows.searchEpisodes(title=ep_title)
assert result_root
assert result_shows
assert result_root == result_shows
def test_crazy_search(plex, movie):
movies = plex.library.section("Movies")
def test_crazy_search(plex, movies, movie):
assert movie in movies.search(
actor=movie.actors[0], sort="titleSort"
), "Unable to search movie by actor."
@ -287,8 +295,7 @@ def test_crazy_search(plex, movie):
assert len(movies.search(container_start=2, container_size=1)) == 2
def test_library_section_timeline(plex):
movies = plex.library.section("Movies")
def test_library_section_timeline(plex, movies):
tl = movies.timeline()
assert tl.TAG == "LibraryTimeline"
assert tl.size > 0
@ -304,3 +311,174 @@ def test_library_section_timeline(plex):
assert utils.is_int(tl.updateQueueSize, gte=0)
assert tl.viewGroup == "secondary"
assert tl.viewMode == 65592
def test_library_MovieSection_hubSearch(movies):
assert movies.hubSearch("Elephants Dream")
def test_library_MovieSection_search(movies, movie):
movie.addLabel("test_search")
movie.addCollection("test_search")
_test_library_search(movies, movie)
movie.removeLabel("test_search", locked=False)
movie.removeCollection("test_search", locked=False)
def test_library_ShowSection_search(tvshows, show):
show.addLabel("test_search")
show.addCollection("test_search")
_test_library_search(tvshows, show)
show.removeLabel("test_search", locked=False)
show.removeCollection("test_search", locked=False)
season = show.season(season=1)
_test_library_search(tvshows, season)
episode = season.episode(episode=1)
_test_library_search(tvshows, episode)
# Additional test for mapping field to the correct libtype
assert tvshows.search(unwatched=True) # equal to episode.unwatched=True
def test_library_MusicSection_search(music, artist):
artist.addGenre("test_search")
artist.addStyle("test_search")
artist.addMood("test_search")
artist.addCollection("test_search")
_test_library_search(music, artist)
artist.removeGenre("test_search", locked=False)
artist.removeStyle("test_search", locked=False)
artist.removeMood("test_search", locked=False)
artist.removeCollection("test_search", locked=False)
album = artist.album("Layers")
album.addGenre("test_search")
album.addStyle("test_search")
album.addMood("test_search")
album.addCollection("test_search")
album.addLabel("test_search")
_test_library_search(music, album)
album.removeGenre("test_search", locked=False)
album.removeStyle("test_search", locked=False)
album.removeMood("test_search", locked=False)
album.removeCollection("test_search", locked=False)
album.removeLabel("test_search", locked=False)
track = album.track(track=1)
track.addMood("test_search")
_test_library_search(music, track)
track.removeMood("test_search", locked=False)
def test_library_PhotoSection_search(photos, photoalbum):
photo = photoalbum.photo("photo1")
photo.addTag("test_search")
_test_library_search(photos, photo)
photo.removeTag("test_search")
def test_library_MovieSection_search_sort(movies):
results = movies.search(sort="titleSort")
titleSort = [r.titleSort for r in results]
assert titleSort == sorted(titleSort)
results_asc = movies.search(sort="titleSort:asc")
titleSort_asc = [r.titleSort for r in results_asc]
assert titleSort == titleSort_asc
results_desc = movies.search(sort="titleSort:desc")
titleSort_desc = [r.titleSort for r in results_desc]
assert titleSort_desc == sorted(titleSort_desc, reverse=True)
def test_library_search_exceptions(movies):
with pytest.raises(BadRequest):
movies.listFilterChoices(field="123abc.title")
with pytest.raises(BadRequest):
movies.search(**{"123abc": True})
with pytest.raises(BadRequest):
movies.search(year="123abc")
with pytest.raises(BadRequest):
movies.search(sort="123abc")
with pytest.raises(NotFound):
movies.getFilterType(libtype='show')
with pytest.raises(NotFound):
movies.getFieldType(fieldType="unknown")
with pytest.raises(NotFound):
movies.listFilterChoices(field="unknown")
with pytest.raises(NotFound):
movies.search(unknown="unknown")
with pytest.raises(NotFound):
movies.search(**{"title<>!=": "unknown"})
with pytest.raises(NotFound):
movies.search(sort="unknown")
with pytest.raises(NotFound):
movies.search(sort="titleSort:bad")
def _test_library_search(library, obj):
# Create & operator
AndOperator = namedtuple('AndOperator', ['key', 'title'])
andOp = AndOperator('&=', 'and')
fields = library.listFields(obj.type)
for field in fields:
fieldAttr = field.key.split(".")[-1]
operators = library.listOperators(field.type)
if field.type in {'tag', 'string'}:
operators += [andOp]
for operator in operators:
if fieldAttr == "unmatched" and operator.key == "!=" or fieldAttr == 'userRating':
continue
value = getattr(obj, fieldAttr, None)
if field.type == "boolean" and value is None:
value = fieldAttr.startswith("unwatched")
if field.type == "tag" and isinstance(value, list) and value and operator.title != 'and':
value = value[0]
elif value is None:
continue
if operator.title == "begins with":
searchValue = value[:3]
elif operator.title == "ends with":
searchValue = value[-3:]
elif "contain" in operator.title:
searchValue = value.split(" ")[0]
elif operator.title == "is less than":
searchValue = value + 1
elif operator.title == "is greater than":
searchValue = max(value - 1, 0)
elif operator.title == "is before":
searchValue = value + timedelta(days=1)
elif operator.title == "is after":
searchValue = value - timedelta(days=1)
else:
searchValue = value
searchFilter = {field.key + operator.key[:-1]: searchValue}
results = library.search(libtype=obj.type, **searchFilter)
if operator.key.startswith("!") or operator.key.startswith(">>") and searchValue == 0:
assert obj not in results
else:
assert obj in results
# Test search again using string tag and date
if field.type in {"tag", "date"}:
if field.type == "tag" and fieldAttr != 'contentRating':
if not isinstance(searchValue, list):
searchValue = [searchValue]
searchValue = [v.tag for v in searchValue]
elif field.type == "date":
searchValue = searchValue.strftime("%Y-%m-%d")
searchFilter = {field.key + operator.key[:-1]: searchValue}
results = library.search(libtype=obj.type, **searchFilter)
if operator.key.startswith("!") or operator.key.startswith(">>") and searchValue == 0:
assert obj not in results
else:
assert obj in results

View file

@ -295,7 +295,7 @@ def create_section(server, section, opts):
cnt = 1
if entry["type"] == SEARCHTYPES["show"]:
show = server.library.sectionByID(
str(entry["sectionID"])
entry["sectionID"]
).get(entry["title"])
cnt = show.leafCount
bar.update(cnt)