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:
JonnyWong16 2022-07-20 19:37:48 -07:00 committed by GitHub
parent 117af58897
commit 3107e25a0e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 118 additions and 41 deletions

View file

@ -201,7 +201,7 @@ class Artist(
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing. :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: if title is not None:
return self.fetchItem(key, Track, title__iexact=title) return self.fetchItem(key, Track, title__iexact=title)
elif album is not None and track is not None: elif album is not None and track is not None:
@ -210,7 +210,7 @@ class Artist(
def tracks(self, **kwargs): def tracks(self, **kwargs):
""" Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """ """ 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) return self.fetchItems(key, Track, **kwargs)
def get(self, title=None, album=None, track=None): def get(self, title=None, album=None, track=None):
@ -316,7 +316,7 @@ class Album(
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing. :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): if title is not None and not isinstance(title, int):
return self.fetchItem(key, Track, title__iexact=title) return self.fetchItem(key, Track, title__iexact=title)
elif track is not None or isinstance(title, int): elif track is not None or isinstance(title, int):
@ -329,7 +329,7 @@ class Album(
def tracks(self, **kwargs): def tracks(self, **kwargs):
""" Returns a list of :class:`~plexapi.audio.Track` objects in the album. """ """ 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) return self.fetchItems(key, Track, **kwargs)
def get(self, title=None, track=None): def get(self, title=None, track=None):

View file

@ -125,7 +125,7 @@ class SplitMergeMixin:
def split(self): def split(self):
""" Split duplicated Plex object into separate objects. """ """ 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) return self._server.query(key, method=self._server._session.put)
def merge(self, ratingKeys): def merge(self, ratingKeys):
@ -146,7 +146,7 @@ class UnmatchMatchMixin:
def unmatch(self): def unmatch(self):
""" Unmatches metadata match from object. """ """ 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) self._server.query(key, method=self._server._session.put)
def matches(self, agent=None, title=None, year=None, language=None): 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 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} params = {'manual': 1}
if agent and not any([title, year, language]): if agent and not any([title, year, language]):
@ -216,7 +216,7 @@ class UnmatchMatchMixin:
~plexapi.base.matches() ~plexapi.base.matches()
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) 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: if auto:
autoMatch = self.matches(agent=agent) autoMatch = self.matches(agent=agent)
if autoMatch: if autoMatch:
@ -289,7 +289,7 @@ class ArtMixin(ArtUrlMixin):
def arts(self): def arts(self):
""" Returns list of available :class:`~plexapi.media.Art` objects. """ """ 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): def uploadArt(self, url=None, filepath=None):
""" Upload a background artwork from a url or filepath. """ 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. filepath (str): The full file path the the image to upload.
""" """
if url: 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) self._server.query(key, method=self._server._session.post)
elif filepath: elif filepath:
key = '/library/metadata/%s/arts?' % self.ratingKey key = f'{self.key}/arts'
data = open(filepath, 'rb').read() data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data) self._server.query(key, method=self._server._session.post, data=data)
@ -338,7 +338,7 @@ class BannerMixin(BannerUrlMixin):
def banners(self): def banners(self):
""" Returns list of available :class:`~plexapi.media.Banner` objects. """ """ 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): def uploadBanner(self, url=None, filepath=None):
""" Upload a banner from a url or filepath. """ 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. filepath (str): The full file path the the image to upload.
""" """
if url: 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) self._server.query(key, method=self._server._session.post)
elif filepath: elif filepath:
key = '/library/metadata/%s/banners?' % self.ratingKey key = f'{self.key}/banners'
data = open(filepath, 'rb').read() data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data) self._server.query(key, method=self._server._session.post, data=data)
@ -392,7 +392,7 @@ class PosterMixin(PosterUrlMixin):
def posters(self): def posters(self):
""" Returns list of available :class:`~plexapi.media.Poster` objects. """ """ 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): def uploadPoster(self, url=None, filepath=None):
""" Upload a poster from a url or filepath. """ 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. filepath (str): The full file path the the image to upload.
""" """
if url: 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) self._server.query(key, method=self._server._session.post)
elif filepath: elif filepath:
key = '/library/metadata/%s/posters?' % self.ratingKey key = f'{self.key}/posters'
data = open(filepath, 'rb').read() data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data) self._server.query(key, method=self._server._session.post, data=data)
@ -441,7 +441,7 @@ class ThemeMixin(ThemeUrlMixin):
def themes(self): def themes(self):
""" Returns list of available :class:`~plexapi.media.Theme` objects. """ """ 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): def uploadTheme(self, url=None, filepath=None):
""" Upload a theme from url or filepath. """ 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. filepath (str): The full file path to the theme to upload.
""" """
if url: 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) self._server.query(key, method=self._server._session.post)
elif filepath: elif filepath:
key = '/library/metadata/%s/themes?' % self.ratingKey key = f'{self.key}/themes'
data = open(filepath, 'rb').read() data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data) self._server.query(key, method=self._server._session.post, data=data)

View file

@ -3,6 +3,7 @@ import copy
import html import html
import threading import threading
import time import time
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from xml.etree import ElementTree from xml.etree import ElementTree
import requests import requests
@ -800,7 +801,7 @@ class MyPlexAccount(PlexObject):
params.update(kwargs) params.update(kwargs)
data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params) 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): def onWatchlist(self, item):
""" Returns True if the item is on the user's watchlist. """ 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 item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to check
if it is on the user's watchlist. if it is on the user's watchlist.
""" """
ratingKey = item.guid.rsplit('/', 1)[-1] return bool(self.userState(item).watchlistedAt)
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
return bool(data.find('UserState').attrib.get('watchlistedAt'))
def addToWatchlist(self, items): def addToWatchlist(self, items):
""" Add media items to the user's watchlist """ Add media items to the user's watchlist
@ -853,29 +852,45 @@ class MyPlexAccount(PlexObject):
ratingKey = item.guid.rsplit('/', 1)[-1] ratingKey = item.guid.rsplit('/', 1)[-1]
self.query(f'{self.METADATA}/actions/removeFromWatchlist?ratingKey={ratingKey}', method=self._session.put) 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. """ Search for movies and TV shows in Discover.
Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects. Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects.
Parameters: Parameters:
query (str): Search query. query (str): Search query.
limit (int, optional): Limit to the specified number of results. Default 30. 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 = { headers = {
'Accept': 'application/json' 'Accept': 'application/json'
} }
params = { params = {
'query': query, 'query': query,
'limit ': limit, 'limit ': limit,
'searchTypes': 'movies,tv', 'searchTypes': libtype,
'includeMetadata': 1 'includeMetadata': 1
} }
data = self.query(f'{self.METADATA}/library/search', headers=headers, params=params) 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 = [] results = []
for result in searchResults: for result in searchResult:
metadata = result['Metadata'] metadata = result['Metadata']
type = metadata['type'] type = metadata['type']
if type == 'movie': if type == 'movie':
@ -888,7 +903,7 @@ class MyPlexAccount(PlexObject):
xml = f'<{tag} {attrs}/>' xml = f'<{tag} {attrs}/>'
results.append(self._manuallyLoadXML(xml)) results.append(self._manuallyLoadXML(xml))
return results return self._toOnlineMetadata(results)
def link(self, pin): def link(self, pin):
""" Link a device to the account using a pin code. """ Link a device to the account using a pin code.
@ -903,6 +918,24 @@ class MyPlexAccount(PlexObject):
data = {'code': pin} data = {'code': pin}
self.query(self.LINK, self._session.put, headers=headers, data=data) 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): class MyPlexUser(PlexObject):
""" This object represents non-signed in users such as friends and linked """ 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': if self.key == 'tv.plex.provider.music':
raise BadRequest('%s does not have the option to opt out managed users.' % self.key) raise BadRequest('%s does not have the option to opt out managed users.' % self.key)
self._updateOptOut('opt_out_managed') 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'))

View file

@ -79,12 +79,12 @@ class Photoalbum(
Parameters: Parameters:
title (str): Title of the photo album to return. 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) return self.fetchItem(key, Photoalbum, title__iexact=title)
def albums(self, **kwargs): def albums(self, **kwargs):
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in the album. """ """ 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) return self.fetchItems(key, Photoalbum, **kwargs)
def photo(self, title): def photo(self, title):
@ -93,12 +93,12 @@ class Photoalbum(
Parameters: Parameters:
title (str): Title of the photo to return. 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) return self.fetchItem(key, Photo, title__iexact=title)
def photos(self, **kwargs): def photos(self, **kwargs):
""" Returns a list of :class:`~plexapi.photo.Photo` objects in the album. """ """ 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) return self.fetchItems(key, Photo, **kwargs)
def clip(self, title): def clip(self, title):
@ -107,12 +107,12 @@ class Photoalbum(
Parameters: Parameters:
title (str): Title of the clip to return. 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) return self.fetchItem(key, video.Clip, title__iexact=title)
def clips(self, **kwargs): def clips(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Clip` objects in the album. """ """ 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) return self.fetchItems(key, video.Clip, **kwargs)
def get(self, title): def get(self, title):

View file

@ -520,7 +520,7 @@ class Show(
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. :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): if title is not None and not isinstance(title, int):
return self.fetchItem(key, Season, title__iexact=title) return self.fetchItem(key, Season, title__iexact=title)
elif season is not None or isinstance(title, int): elif season is not None or isinstance(title, int):
@ -533,7 +533,7 @@ class Show(
def seasons(self, **kwargs): def seasons(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Season` objects in the show. """ """ 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) return self.fetchItems(key, Season, **kwargs)
def episode(self, title=None, season=None, episode=None): def episode(self, title=None, season=None, episode=None):
@ -547,7 +547,7 @@ class Show(
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing. :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: if title is not None:
return self.fetchItem(key, Episode, title__iexact=title) return self.fetchItem(key, Episode, title__iexact=title)
elif season is not None and episode is not None: elif season is not None and episode is not None:
@ -556,7 +556,7 @@ class Show(
def episodes(self, **kwargs): def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects in the show. """ """ 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) return self.fetchItems(key, Episode, **kwargs)
def get(self, title=None, season=None, episode=None): def get(self, title=None, season=None, episode=None):
@ -665,7 +665,7 @@ class Season(
def episodes(self, **kwargs): def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """ """ 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) return self.fetchItems(key, Episode, **kwargs)
def episode(self, title=None, episode=None): def episode(self, title=None, episode=None):
@ -678,7 +678,7 @@ class Season(
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. :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): if title is not None and not isinstance(title, int):
return self.fetchItem(key, Episode, title__iexact=title) return self.fetchItem(key, Episode, title__iexact=title)
elif episode is not None or isinstance(title, int): elif episode is not None or isinstance(title, int):

View file

@ -304,3 +304,17 @@ def test_myplex_watchlist(account, movie, show, artist):
# Test adding invalid item to watchlist # Test adding invalid item to watchlist
with pytest.raises(BadRequest): with pytest.raises(BadRequest):
account.addToWatchlist(artist) 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)