mirror of
https://github.com/pkkid/python-plexapi
synced 2024-11-25 05:00:22 +00:00
Fix searching and browsing Discover results (#970)
* Fix discover search results * Plex API response changed to separate "Free on Demand" and "More Ways To Watch" * Add test for searchDiscover * Manually set watchlist and discover result to online metadata objects * Replace all key format ratingKey with key * Add libtype argument to searchDiscover * Update tests for searchDiscover libtype * Add includeUserState=1 parameter to Discover results details key * Add method to return UserState object
This commit is contained in:
parent
117af58897
commit
3107e25a0e
6 changed files with 118 additions and 41 deletions
|
@ -201,7 +201,7 @@ class Artist(
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
key = f'{self.key}/allLeaves'
|
||||
if title is not None:
|
||||
return self.fetchItem(key, Track, title__iexact=title)
|
||||
elif album is not None and track is not None:
|
||||
|
@ -210,7 +210,7 @@ class Artist(
|
|||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
key = f'{self.key}/allLeaves'
|
||||
return self.fetchItems(key, Track, **kwargs)
|
||||
|
||||
def get(self, title=None, album=None, track=None):
|
||||
|
@ -316,7 +316,7 @@ class Album(
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
if title is not None and not isinstance(title, int):
|
||||
return self.fetchItem(key, Track, title__iexact=title)
|
||||
elif track is not None or isinstance(title, int):
|
||||
|
@ -329,7 +329,7 @@ class Album(
|
|||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Track, **kwargs)
|
||||
|
||||
def get(self, title=None, track=None):
|
||||
|
|
|
@ -125,7 +125,7 @@ class SplitMergeMixin:
|
|||
|
||||
def split(self):
|
||||
""" Split duplicated Plex object into separate objects. """
|
||||
key = '/library/metadata/%s/split' % self.ratingKey
|
||||
key = f'{self.key}/split'
|
||||
return self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def merge(self, ratingKeys):
|
||||
|
@ -146,7 +146,7 @@ class UnmatchMatchMixin:
|
|||
|
||||
def unmatch(self):
|
||||
""" Unmatches metadata match from object. """
|
||||
key = '/library/metadata/%s/unmatch' % self.ratingKey
|
||||
key = f'{self.key}/unmatch'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def matches(self, agent=None, title=None, year=None, language=None):
|
||||
|
@ -177,7 +177,7 @@ class UnmatchMatchMixin:
|
|||
|
||||
For 2 to 7, the agent and language is automatically filled in
|
||||
"""
|
||||
key = '/library/metadata/%s/matches' % self.ratingKey
|
||||
key = f'{self.key}/matches'
|
||||
params = {'manual': 1}
|
||||
|
||||
if agent and not any([title, year, language]):
|
||||
|
@ -216,7 +216,7 @@ class UnmatchMatchMixin:
|
|||
~plexapi.base.matches()
|
||||
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.)
|
||||
"""
|
||||
key = '/library/metadata/%s/match' % self.ratingKey
|
||||
key = f'{self.key}/match'
|
||||
if auto:
|
||||
autoMatch = self.matches(agent=agent)
|
||||
if autoMatch:
|
||||
|
@ -289,7 +289,7 @@ class ArtMixin(ArtUrlMixin):
|
|||
|
||||
def arts(self):
|
||||
""" Returns list of available :class:`~plexapi.media.Art` objects. """
|
||||
return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=media.Art)
|
||||
return self.fetchItems(f'{self.key}/arts', cls=media.Art)
|
||||
|
||||
def uploadArt(self, url=None, filepath=None):
|
||||
""" Upload a background artwork from a url or filepath.
|
||||
|
@ -299,10 +299,10 @@ class ArtMixin(ArtUrlMixin):
|
|||
filepath (str): The full file path the the image to upload.
|
||||
"""
|
||||
if url:
|
||||
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
|
||||
key = f'{self.key}/arts?url={quote_plus(url)}'
|
||||
self._server.query(key, method=self._server._session.post)
|
||||
elif filepath:
|
||||
key = '/library/metadata/%s/arts?' % self.ratingKey
|
||||
key = f'{self.key}/arts'
|
||||
data = open(filepath, 'rb').read()
|
||||
self._server.query(key, method=self._server._session.post, data=data)
|
||||
|
||||
|
@ -338,7 +338,7 @@ class BannerMixin(BannerUrlMixin):
|
|||
|
||||
def banners(self):
|
||||
""" Returns list of available :class:`~plexapi.media.Banner` objects. """
|
||||
return self.fetchItems('/library/metadata/%s/banners' % self.ratingKey, cls=media.Banner)
|
||||
return self.fetchItems(f'{self.key}/banners', cls=media.Banner)
|
||||
|
||||
def uploadBanner(self, url=None, filepath=None):
|
||||
""" Upload a banner from a url or filepath.
|
||||
|
@ -348,10 +348,10 @@ class BannerMixin(BannerUrlMixin):
|
|||
filepath (str): The full file path the the image to upload.
|
||||
"""
|
||||
if url:
|
||||
key = '/library/metadata/%s/banners?url=%s' % (self.ratingKey, quote_plus(url))
|
||||
key = f'{self.key}/banners?url={quote_plus(url)}'
|
||||
self._server.query(key, method=self._server._session.post)
|
||||
elif filepath:
|
||||
key = '/library/metadata/%s/banners?' % self.ratingKey
|
||||
key = f'{self.key}/banners'
|
||||
data = open(filepath, 'rb').read()
|
||||
self._server.query(key, method=self._server._session.post, data=data)
|
||||
|
||||
|
@ -392,7 +392,7 @@ class PosterMixin(PosterUrlMixin):
|
|||
|
||||
def posters(self):
|
||||
""" Returns list of available :class:`~plexapi.media.Poster` objects. """
|
||||
return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=media.Poster)
|
||||
return self.fetchItems(f'{self.key}/posters', cls=media.Poster)
|
||||
|
||||
def uploadPoster(self, url=None, filepath=None):
|
||||
""" Upload a poster from a url or filepath.
|
||||
|
@ -402,10 +402,10 @@ class PosterMixin(PosterUrlMixin):
|
|||
filepath (str): The full file path the the image to upload.
|
||||
"""
|
||||
if url:
|
||||
key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url))
|
||||
key = f'{self.key}/posters?url={quote_plus(url)}'
|
||||
self._server.query(key, method=self._server._session.post)
|
||||
elif filepath:
|
||||
key = '/library/metadata/%s/posters?' % self.ratingKey
|
||||
key = f'{self.key}/posters'
|
||||
data = open(filepath, 'rb').read()
|
||||
self._server.query(key, method=self._server._session.post, data=data)
|
||||
|
||||
|
@ -441,7 +441,7 @@ class ThemeMixin(ThemeUrlMixin):
|
|||
|
||||
def themes(self):
|
||||
""" Returns list of available :class:`~plexapi.media.Theme` objects. """
|
||||
return self.fetchItems('/library/metadata/%s/themes' % self.ratingKey, cls=media.Theme)
|
||||
return self.fetchItems(f'{self.key}/themes', cls=media.Theme)
|
||||
|
||||
def uploadTheme(self, url=None, filepath=None):
|
||||
""" Upload a theme from url or filepath.
|
||||
|
@ -453,10 +453,10 @@ class ThemeMixin(ThemeUrlMixin):
|
|||
filepath (str): The full file path to the theme to upload.
|
||||
"""
|
||||
if url:
|
||||
key = '/library/metadata/%s/themes?url=%s' % (self.ratingKey, quote_plus(url))
|
||||
key = f'{self.key}/themes?url={quote_plus(url)}'
|
||||
self._server.query(key, method=self._server._session.post)
|
||||
elif filepath:
|
||||
key = '/library/metadata/%s/themes?' % self.ratingKey
|
||||
key = f'{self.key}/themes'
|
||||
data = open(filepath, 'rb').read()
|
||||
self._server.query(key, method=self._server._session.post, data=data)
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import copy
|
|||
import html
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
|
@ -800,7 +801,7 @@ class MyPlexAccount(PlexObject):
|
|||
|
||||
params.update(kwargs)
|
||||
data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params)
|
||||
return self.findItems(data)
|
||||
return self._toOnlineMetadata(self.findItems(data))
|
||||
|
||||
def onWatchlist(self, item):
|
||||
""" Returns True if the item is on the user's watchlist.
|
||||
|
@ -809,9 +810,7 @@ class MyPlexAccount(PlexObject):
|
|||
item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to check
|
||||
if it is on the user's watchlist.
|
||||
"""
|
||||
ratingKey = item.guid.rsplit('/', 1)[-1]
|
||||
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
|
||||
return bool(data.find('UserState').attrib.get('watchlistedAt'))
|
||||
return bool(self.userState(item).watchlistedAt)
|
||||
|
||||
def addToWatchlist(self, items):
|
||||
""" Add media items to the user's watchlist
|
||||
|
@ -853,29 +852,45 @@ class MyPlexAccount(PlexObject):
|
|||
ratingKey = item.guid.rsplit('/', 1)[-1]
|
||||
self.query(f'{self.METADATA}/actions/removeFromWatchlist?ratingKey={ratingKey}', method=self._session.put)
|
||||
|
||||
def searchDiscover(self, query, limit=30):
|
||||
def userState(self, item):
|
||||
""" Returns a :class:`~plexapi.myplex.UserState` object for the specified item.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to return the user state.
|
||||
"""
|
||||
ratingKey = item.guid.rsplit('/', 1)[-1]
|
||||
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
|
||||
# TODO: change to findItem after PR#931 is merged
|
||||
return self.findItems(data, cls=UserState)[0]
|
||||
|
||||
def searchDiscover(self, query, limit=30, libtype=None):
|
||||
""" Search for movies and TV shows in Discover.
|
||||
Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects.
|
||||
|
||||
Parameters:
|
||||
query (str): Search query.
|
||||
limit (int, optional): Limit to the specified number of results. Default 30.
|
||||
libtype (str, optional): 'movie' or 'show' to only return movies or shows, otherwise return all items.
|
||||
"""
|
||||
libtypes = {'movie': 'movies', 'show': 'tv'}
|
||||
libtype = libtypes.get(libtype, 'movies,tv')
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
params = {
|
||||
'query': query,
|
||||
'limit ': limit,
|
||||
'searchTypes': 'movies,tv',
|
||||
'searchTypes': libtype,
|
||||
'includeMetadata': 1
|
||||
}
|
||||
|
||||
data = self.query(f'{self.METADATA}/library/search', headers=headers, params=params)
|
||||
searchResults = data['MediaContainer'].get('SearchResult', [])
|
||||
searchResults = data['MediaContainer'].get('SearchResults', [])
|
||||
searchResult = next((s['SearchResult'] for s in searchResults if s.get('id') == 'external'), [])
|
||||
|
||||
results = []
|
||||
for result in searchResults:
|
||||
for result in searchResult:
|
||||
metadata = result['Metadata']
|
||||
type = metadata['type']
|
||||
if type == 'movie':
|
||||
|
@ -888,7 +903,7 @@ class MyPlexAccount(PlexObject):
|
|||
xml = f'<{tag} {attrs}/>'
|
||||
results.append(self._manuallyLoadXML(xml))
|
||||
|
||||
return results
|
||||
return self._toOnlineMetadata(results)
|
||||
|
||||
def link(self, pin):
|
||||
""" Link a device to the account using a pin code.
|
||||
|
@ -903,6 +918,24 @@ class MyPlexAccount(PlexObject):
|
|||
data = {'code': pin}
|
||||
self.query(self.LINK, self._session.put, headers=headers, data=data)
|
||||
|
||||
def _toOnlineMetadata(self, objs):
|
||||
""" Convert a list of media objects to online metadata objects. """
|
||||
# TODO: Add proper support for metadata.provider.plex.tv
|
||||
# Temporary workaround to allow reloading and browsing of online media objects
|
||||
if not isinstance(objs, list):
|
||||
objs = [objs]
|
||||
for obj in objs:
|
||||
obj._server = PlexServer(self.METADATA, self._token)
|
||||
|
||||
# Parse details key to modify query string
|
||||
url = urlsplit(obj._details_key)
|
||||
query = dict(parse_qsl(url.query))
|
||||
query['includeUserState'] = 1
|
||||
query.pop('includeFields', None)
|
||||
obj._details_key = urlunsplit((url.scheme, url.netloc, url.path, urlencode(query), url.fragment))
|
||||
|
||||
return objs
|
||||
|
||||
|
||||
class MyPlexUser(PlexObject):
|
||||
""" This object represents non-signed in users such as friends and linked
|
||||
|
@ -1645,3 +1678,33 @@ class AccountOptOut(PlexObject):
|
|||
if self.key == 'tv.plex.provider.music':
|
||||
raise BadRequest('%s does not have the option to opt out managed users.' % self.key)
|
||||
self._updateOptOut('opt_out_managed')
|
||||
|
||||
|
||||
class UserState(PlexObject):
|
||||
""" Represents a single UserState
|
||||
|
||||
Attributes:
|
||||
TAG (str): UserState
|
||||
lastViewedAt (datetime): Datetime the item was last played.
|
||||
ratingKey (str): Unique key identifying the item.
|
||||
type (str): The media type of the item.
|
||||
viewCount (int): Count of times the item was played.
|
||||
viewedLeafCount (int): Number of items marked as played in the show/season.
|
||||
viewOffset (int): Time offset in milliseconds from the start of the content
|
||||
viewState (bool): True or False if the item has been played.
|
||||
watchlistedAt (datetime): Datetime the item was added to the watchlist.
|
||||
"""
|
||||
TAG = 'UserState'
|
||||
|
||||
def __repr__(self):
|
||||
return f'<{self.__class__.__name__}:{self.ratingKey}>'
|
||||
|
||||
def _loadData(self, data):
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.type = data.attrib.get('type')
|
||||
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount', 0))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.viewState = data.attrib.get('viewState') == 'complete'
|
||||
self.watchlistedAt = utils.toDatetime(data.attrib.get('watchlistedAt'))
|
||||
|
|
|
@ -79,12 +79,12 @@ class Photoalbum(
|
|||
Parameters:
|
||||
title (str): Title of the photo album to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItem(key, Photoalbum, title__iexact=title)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Photoalbum, **kwargs)
|
||||
|
||||
def photo(self, title):
|
||||
|
@ -93,12 +93,12 @@ class Photoalbum(
|
|||
Parameters:
|
||||
title (str): Title of the photo to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItem(key, Photo, title__iexact=title)
|
||||
|
||||
def photos(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photo` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Photo, **kwargs)
|
||||
|
||||
def clip(self, title):
|
||||
|
@ -107,12 +107,12 @@ class Photoalbum(
|
|||
Parameters:
|
||||
title (str): Title of the clip to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItem(key, video.Clip, title__iexact=title)
|
||||
|
||||
def clips(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Clip` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, video.Clip, **kwargs)
|
||||
|
||||
def get(self, title):
|
||||
|
|
|
@ -520,7 +520,7 @@ class Show(
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
|
||||
key = f'{self.key}/children?excludeAllLeaves=1'
|
||||
if title is not None and not isinstance(title, int):
|
||||
return self.fetchItem(key, Season, title__iexact=title)
|
||||
elif season is not None or isinstance(title, int):
|
||||
|
@ -533,7 +533,7 @@ class Show(
|
|||
|
||||
def seasons(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Season` objects in the show. """
|
||||
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
|
||||
key = f'{self.key}/children?excludeAllLeaves=1'
|
||||
return self.fetchItems(key, Season, **kwargs)
|
||||
|
||||
def episode(self, title=None, season=None, episode=None):
|
||||
|
@ -547,7 +547,7 @@ class Show(
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
key = f'{self.key}/allLeaves'
|
||||
if title is not None:
|
||||
return self.fetchItem(key, Episode, title__iexact=title)
|
||||
elif season is not None and episode is not None:
|
||||
|
@ -556,7 +556,7 @@ class Show(
|
|||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects in the show. """
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
key = f'{self.key}/allLeaves'
|
||||
return self.fetchItems(key, Episode, **kwargs)
|
||||
|
||||
def get(self, title=None, season=None, episode=None):
|
||||
|
@ -665,7 +665,7 @@ class Season(
|
|||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Episode, **kwargs)
|
||||
|
||||
def episode(self, title=None, episode=None):
|
||||
|
@ -678,7 +678,7 @@ class Season(
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
if title is not None and not isinstance(title, int):
|
||||
return self.fetchItem(key, Episode, title__iexact=title)
|
||||
elif episode is not None or isinstance(title, int):
|
||||
|
|
|
@ -304,3 +304,17 @@ def test_myplex_watchlist(account, movie, show, artist):
|
|||
# Test adding invalid item to watchlist
|
||||
with pytest.raises(BadRequest):
|
||||
account.addToWatchlist(artist)
|
||||
|
||||
|
||||
def test_myplex_searchDiscover(account, movie, show):
|
||||
guids = lambda x: [r.guid for r in x]
|
||||
|
||||
results = account.searchDiscover(movie.title)
|
||||
assert movie.guid in guids(results)
|
||||
results = account.searchDiscover(movie.title, libtype="show")
|
||||
assert movie.guid not in guids(results)
|
||||
|
||||
results = account.searchDiscover(show.title)
|
||||
assert show.guid in [r.guid for r in results]
|
||||
results = account.searchDiscover(show.title, libtype="movie")
|
||||
assert show.guid not in guids(results)
|
||||
|
|
Loading…
Reference in a new issue