11# -*- coding: utf-8 -*-
22import copy
3+ import html
34import threading
45import time
56from 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.
0 commit comments