Skip to content

Commit fba0dbc

Browse files
authored
Add support for watchlists and streaming services (#922)
* Remove Plex news * Replace requests with query method for online hubs * Add myPlexAccount watchlist methods * Add mixin for objects that can be added to watchlist * Add tests for watchlist * Always split ratingKey from guid for watchlist * Add method to retrieve streaming service availability * Add test for streaming service availability * Add filtering and sorting for watchlists * Fix watchlist tests * Add method to check if an item is on a user's watchlist * Update watchlist tests * Make account optional for WatchlistMixin * Add Discover search * Use manuallyLoadXML method for Discover search results * Remove webshows and podcasts * Fix WatchlistMixin account AttributeError * Add BadRequest exception when adding to/removing from watchlist * Update watchlist tests for BadRequest * Update watchlist doc strings
1 parent 999f302 commit fba0dbc

File tree

6 files changed

+300
-37
lines changed

6 files changed

+300
-37
lines changed

plexapi/media.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,3 +1112,41 @@ def _loadData(self, data):
11121112
@deprecated('use "languageCodes" instead')
11131113
def languageCode(self):
11141114
return self.languageCodes
1115+
1116+
1117+
@utils.registerPlexObject
1118+
class Availability(PlexObject):
1119+
""" Represents a single online streaming service Availability.
1120+
1121+
Attributes:
1122+
TAG (str): 'Availability'
1123+
country (str): The streaming service country.
1124+
offerType (str): Subscription, buy, or rent from the streaming service.
1125+
platform (str): The platform slug for the streaming service.
1126+
platformColorThumb (str): Thumbnail icon for the streaming service.
1127+
platformInfo (str): The streaming service platform info.
1128+
platformUrl (str): The URL to the media on the streaming service.
1129+
price (float): The price to buy or rent from the streaming service.
1130+
priceDescription (str): The display price to buy or rent from the streaming service.
1131+
quality (str): The video quality on the streaming service.
1132+
title (str): The title of the streaming service.
1133+
url (str): The Plex availability URL.
1134+
"""
1135+
TAG = 'Availability'
1136+
1137+
def __repr__(self):
1138+
return f'<{self.__class__.__name__}:{self.platform}:{self.offerType}>'
1139+
1140+
def _loadData(self, data):
1141+
self._data = data
1142+
self.country = data.attrib.get('country')
1143+
self.offerType = data.attrib.get('offerType')
1144+
self.platform = data.attrib.get('platform')
1145+
self.platformColorThumb = data.attrib.get('platformColorThumb')
1146+
self.platformInfo = data.attrib.get('platformInfo')
1147+
self.platformUrl = data.attrib.get('platformUrl')
1148+
self.price = utils.cast(float, data.attrib.get('price'))
1149+
self.priceDescription = data.attrib.get('priceDescription')
1150+
self.quality = data.attrib.get('quality')
1151+
self.title = data.attrib.get('title')
1152+
self.url = data.attrib.get('url')

plexapi/mixins.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,3 +984,65 @@ def removeWriter(self, writers, locked=True):
984984
locked (bool): True (default) to lock the field, False to unlock the field.
985985
"""
986986
return self.editTags('writer', writers, locked=locked, remove=True)
987+
988+
989+
class WatchlistMixin(object):
990+
""" Mixin for Plex objects that can be added to a user's watchlist. """
991+
992+
def onWatchlist(self, account=None):
993+
""" Returns True if the item is on the user's watchlist.
994+
Also see :func:`~plexapi.myplex.MyPlexAccount.onWatchlist`.
995+
996+
Parameters:
997+
account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account to check item on the watchlist.
998+
Note: This is required if you are not connected to a Plex server instance using the admin account.
999+
"""
1000+
try:
1001+
account = account or self._server.myPlexAccount()
1002+
except AttributeError:
1003+
account = self._server
1004+
return account.onWatchlist(self)
1005+
1006+
def addToWatchlist(self, account=None):
1007+
""" Add this item to the specified user's watchlist.
1008+
Also see :func:`~plexapi.myplex.MyPlexAccount.addToWatchlist`.
1009+
1010+
Parameters:
1011+
account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account to add item to the watchlist.
1012+
Note: This is required if you are not connected to a Plex server instance using the admin account.
1013+
"""
1014+
try:
1015+
account = account or self._server.myPlexAccount()
1016+
except AttributeError:
1017+
account = self._server
1018+
account.addToWatchlist(self)
1019+
1020+
def removeFromWatchlist(self, account=None):
1021+
""" Remove this item from the specified user's watchlist.
1022+
Also see :func:`~plexapi.myplex.MyPlexAccount.removeFromWatchlist`.
1023+
1024+
Parameters:
1025+
account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account to remove item from the watchlist.
1026+
Note: This is required if you are not connected to a Plex server instance using the admin account.
1027+
"""
1028+
try:
1029+
account = account or self._server.myPlexAccount()
1030+
except AttributeError:
1031+
account = self._server
1032+
account.removeFromWatchlist(self)
1033+
1034+
def streamingServices(self, account=None):
1035+
""" Return a list of :class:`~plexapi.media.Availability`
1036+
objects for the available streaming services for this item.
1037+
1038+
Parameters:
1039+
account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account used to retrieve availability.
1040+
Note: This is required if you are not connected to a Plex server instance using the admin account.
1041+
"""
1042+
try:
1043+
account = account or self._server.myPlexAccount()
1044+
except AttributeError:
1045+
account = self._server
1046+
ratingKey = self.guid.rsplit('/', 1)[-1]
1047+
data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities")
1048+
return self.findItems(data)

plexapi/myplex.py

Lines changed: 144 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# -*- coding: utf-8 -*-
22
import copy
3+
import html
34
import threading
45
import time
56
from xml.etree import ElementTree
@@ -72,14 +73,12 @@ class MyPlexAccount(PlexObject):
7273
REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete
7374
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
7475
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
75-
OPTOUTS = 'https://plex.tv/api/v2/user/%(userUUID)s/settings/opt_outs' # get
76+
OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get
7677
LINK = 'https://plex.tv/api/v2/pins/link' # put
7778
# Hub sections
78-
VOD = 'https://vod.provider.plex.tv/' # get
79-
WEBSHOWS = 'https://webshows.provider.plex.tv/' # get
80-
NEWS = 'https://news.provider.plex.tv/' # get
81-
PODCASTS = 'https://podcasts.provider.plex.tv/' # get
82-
MUSIC = 'https://music.provider.plex.tv/' # get
79+
VOD = 'https://vod.provider.plex.tv' # get
80+
MUSIC = 'https://music.provider.plex.tv' # get
81+
METADATA = 'https://metadata.provider.plex.tv'
8382
# Key may someday switch to the following url. For now the current value works.
8483
# https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId}
8584
key = 'https://plex.tv/users/account'
@@ -182,6 +181,8 @@ def query(self, url, method=None, headers=None, timeout=None, **kwargs):
182181
raise NotFound(message)
183182
else:
184183
raise BadRequest(message)
184+
if headers.get('Accept') == 'application/json':
185+
return response.json()
185186
data = response.text.encode('utf8')
186187
return ElementTree.fromstring(data) if data.strip() else None
187188

@@ -698,6 +699,7 @@ def claimToken(self):
698699

699700
def history(self, maxresults=9999999, mindate=None):
700701
""" Get Play History for all library sections on all servers for the owner.
702+
701703
Parameters:
702704
maxresults (int): Only return the specified number of results (optional).
703705
mindate (datetime): Min datetime to return results from.
@@ -709,47 +711,155 @@ def history(self, maxresults=9999999, mindate=None):
709711
hist.extend(conn.history(maxresults=maxresults, mindate=mindate, accountID=1))
710712
return hist
711713

714+
def onlineMediaSources(self):
715+
""" Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut`
716+
"""
717+
url = self.OPTOUTS.format(userUUID=self.uuid)
718+
elem = self.query(url)
719+
return self.findItems(elem, cls=AccountOptOut, etag='optOut')
720+
712721
def videoOnDemand(self):
713722
""" Returns a list of VOD Hub items :class:`~plexapi.library.Hub`
714723
"""
715-
req = requests.get(self.VOD + 'hubs/', headers={'X-Plex-Token': self._token})
716-
elem = ElementTree.fromstring(req.text)
717-
return self.findItems(elem)
724+
data = self.query(f'{self.VOD}/hubs')
725+
return self.findItems(data)
718726

719-
def webShows(self):
720-
""" Returns a list of Webshow Hub items :class:`~plexapi.library.Hub`
727+
def tidal(self):
728+
""" Returns a list of tidal Hub items :class:`~plexapi.library.Hub`
721729
"""
722-
req = requests.get(self.WEBSHOWS + 'hubs/', headers={'X-Plex-Token': self._token})
723-
elem = ElementTree.fromstring(req.text)
724-
return self.findItems(elem)
730+
data = self.query(f'{self.MUSIC}/hubs')
731+
return self.findItems(data)
732+
733+
def watchlist(self, filter=None, sort=None, libtype=None, **kwargs):
734+
""" Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` items in the user's watchlist.
735+
Note: The objects returned are from Plex's online metadata. To get the matching item on a Plex server,
736+
search for the media using the guid.
737+
738+
Parameters:
739+
filter (str, optional): 'available' or 'released' to only return items that are available or released,
740+
otherwise return all items.
741+
sort (str, optional): In the format ``field:dir``. Available fields are ``watchlistedAt`` (Added At),
742+
``titleSort`` (Title), ``originallyAvailableAt`` (Release Date), or ``rating`` (Critic Rating).
743+
``dir`` can be ``asc`` or ``desc``.
744+
libtype (str, optional): 'movie' or 'show' to only return movies or shows, otherwise return all items.
745+
**kwargs (dict): Additional custom filters to apply to the search results.
746+
747+
748+
Example:
749+
750+
.. code-block:: python
751+
752+
# Watchlist for released movies sorted by critic rating in descending order
753+
watchlist = account.watchlist(filter='released', sort='rating:desc', libtype='movie')
754+
item = watchlist[0] # First item in the watchlist
755+
756+
# Search for the item on a Plex server
757+
result = plex.library.search(guid=item.guid, libtype=item.type)
725758
726-
def news(self):
727-
""" Returns a list of News Hub items :class:`~plexapi.library.Hub`
728759
"""
729-
req = requests.get(self.NEWS + 'hubs/sections/all', headers={'X-Plex-Token': self._token})
730-
elem = ElementTree.fromstring(req.text)
731-
return self.findItems(elem)
760+
params = {
761+
'includeCollections': 1,
762+
'includeExternalMedia': 1
763+
}
732764

733-
def podcasts(self):
734-
""" Returns a list of Podcasts Hub items :class:`~plexapi.library.Hub`
765+
if not filter:
766+
filter = 'all'
767+
if sort:
768+
params['sort'] = sort
769+
if libtype:
770+
params['type'] = utils.searchType(libtype)
771+
772+
params.update(kwargs)
773+
data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params)
774+
return self.findItems(data)
775+
776+
def onWatchlist(self, item):
777+
""" Returns True if the item is on the user's watchlist.
778+
779+
Parameters:
780+
item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to check
781+
if it is on the user's watchlist.
735782
"""
736-
req = requests.get(self.PODCASTS + 'hubs/', headers={'X-Plex-Token': self._token})
737-
elem = ElementTree.fromstring(req.text)
738-
return self.findItems(elem)
783+
ratingKey = item.guid.rsplit('/', 1)[-1]
784+
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
785+
return bool(data.find('UserState').attrib.get('watchlistedAt'))
739786

740-
def tidal(self):
741-
""" Returns a list of tidal Hub items :class:`~plexapi.library.Hub`
787+
def addToWatchlist(self, items):
788+
""" Add media items to the user's watchlist
789+
790+
Parameters:
791+
items (List): List of :class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`
792+
objects to be added to the watchlist.
793+
794+
Raises:
795+
:exc:`~plexapi.exceptions.BadRequest`: When trying to add invalid or existing
796+
media to the watchlist.
742797
"""
743-
req = requests.get(self.MUSIC + 'hubs/', headers={'X-Plex-Token': self._token})
744-
elem = ElementTree.fromstring(req.text)
745-
return self.findItems(elem)
798+
if not isinstance(items, list):
799+
items = [items]
800+
801+
for item in items:
802+
if self.onWatchlist(item):
803+
raise BadRequest('"%s" is already on the watchlist' % item.title)
804+
ratingKey = item.guid.rsplit('/', 1)[-1]
805+
self.query(f'{self.METADATA}/actions/addToWatchlist?ratingKey={ratingKey}', method=self._session.put)
746806

747-
def onlineMediaSources(self):
748-
""" Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut`
807+
def removeFromWatchlist(self, items):
808+
""" Remove media items from the user's watchlist
809+
810+
Parameters:
811+
items (List): List of :class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`
812+
objects to be added to the watchlist.
813+
814+
Raises:
815+
:exc:`~plexapi.exceptions.BadRequest`: When trying to remove invalid or non-existing
816+
media to the watchlist.
749817
"""
750-
url = self.OPTOUTS % {'userUUID': self.uuid}
751-
elem = self.query(url)
752-
return self.findItems(elem, cls=AccountOptOut, etag='optOut')
818+
if not isinstance(items, list):
819+
items = [items]
820+
821+
for item in items:
822+
if not self.onWatchlist(item):
823+
raise BadRequest('"%s" is not on the watchlist' % item.title)
824+
ratingKey = item.guid.rsplit('/', 1)[-1]
825+
self.query(f'{self.METADATA}/actions/removeFromWatchlist?ratingKey={ratingKey}', method=self._session.put)
826+
827+
def searchDiscover(self, query, limit=30):
828+
""" Search for movies and TV shows in Discover.
829+
Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects.
830+
831+
Parameters:
832+
query (str): Search query.
833+
limit (int, optional): Limit to the specified number of results. Default 30.
834+
"""
835+
headers = {
836+
'Accept': 'application/json'
837+
}
838+
params = {
839+
'query': query,
840+
'limit ': limit,
841+
'searchTypes': 'movies,tv',
842+
'includeMetadata': 1
843+
}
844+
845+
data = self.query(f'{self.METADATA}/library/search', headers=headers, params=params)
846+
searchResults = data['MediaContainer'].get('SearchResult', [])
847+
848+
results = []
849+
for result in searchResults:
850+
metadata = result['Metadata']
851+
type = metadata['type']
852+
if type == 'movie':
853+
tag = 'Video'
854+
elif type == 'show':
855+
tag = 'Directory'
856+
else:
857+
continue
858+
attrs = ''.join(f'{k}="{html.escape(str(v))}" ' for k, v in metadata.items())
859+
xml = f'<{tag} {attrs}/>'
860+
results.append(self._manuallyLoadXML(xml))
861+
862+
return results
753863

754864
def link(self, pin):
755865
""" Link a device to the account using a pin code.

plexapi/video.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
1111
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
1212
SummaryMixin, TaglineMixin, TitleMixin,
13-
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin
13+
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
14+
WatchlistMixin
1415
)
1516

1617

@@ -280,7 +281,8 @@ class Movie(
280281
ArtMixin, PosterMixin, ThemeMixin,
281282
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
282283
SummaryMixin, TaglineMixin, TitleMixin,
283-
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin
284+
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
285+
WatchlistMixin
284286
):
285287
""" Represents a single Movie.
286288
@@ -394,7 +396,8 @@ class Show(
394396
ArtMixin, BannerMixin, PosterMixin, ThemeMixin,
395397
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
396398
SummaryMixin, TaglineMixin, TitleMixin,
397-
CollectionMixin, GenreMixin, LabelMixin
399+
CollectionMixin, GenreMixin, LabelMixin,
400+
WatchlistMixin
398401
):
399402
""" Represents a single Show (including all seasons and episodes).
400403

0 commit comments

Comments
 (0)