diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index 621183b3..ff9e1e1a 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -3,10 +3,10 @@ import os from urllib.parse import quote_plus from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject -from plexapi.exceptions import BadRequest +from plexapi.base import Playable, PlexPartialObject, PlexSession +from plexapi.exceptions import BadRequest, NotFound from plexapi.mixins import ( - AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, + AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin, @@ -15,7 +15,7 @@ from plexapi.mixins import ( from plexapi.playlist import Playlist -class Audio(PlexPartialObject): +class Audio(PlexPartialObject, PlayedUnplayedMixin): """ Base class for all audio objects including :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`. @@ -46,7 +46,6 @@ class Audio(PlexPartialObject): userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars). viewCount (int): Count of times the item was played. """ - METADATA_TYPE = 'track' def _loadData(self, data): @@ -121,7 +120,7 @@ class Audio(PlexPartialObject): section = self._server.library.sectionByID(self.librarySectionID) - sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key)) + sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}' sync_item.policy = Policy.create(limit) sync_item.mediaSettings = MediaSettings.createMusic(bitrate) @@ -146,6 +145,7 @@ class Artist( collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. countries (List<:class:`~plexapi.media.Country`>): List country objects. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. key (str): API URL (/library/metadata/). labels (List<:class:`~plexapi.media.Label`>): List of label objects. locations (List): List of folder paths where the artist is found on disk. @@ -163,6 +163,7 @@ class Artist( self.collections = self.findItems(data, media.Collection) self.countries = self.findItems(data, media.Country) self.genres = self.findItems(data, media.Genre) + self.guids = self.findItems(data, media.Guid) self.key = self.key.replace('/children', '') # FIX_BUG_50 self.labels = self.findItems(data, media.Label) self.locations = self.listAttrs(data, 'path', etag='Location') @@ -180,13 +181,14 @@ class Artist( Parameters: title (str): Title of the album to return. """ - key = f"/library/sections/{self.librarySectionID}/all?artist.id={self.ratingKey}&type=9" - return self.fetchItem(key, Album, title__iexact=title) + try: + return self.section().search(title, libtype='album', filters={'artist.id': self.ratingKey})[0] + except IndexError: + raise NotFound(f"Unable to find album '{title}'") from None def albums(self, **kwargs): """ Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """ - key = f"/library/sections/{self.librarySectionID}/all?artist.id={self.ratingKey}&type=9" - return self.fetchItems(key, Album, **kwargs) + return self.section().search(libtype='album', filters={'artist.id': self.ratingKey}, **kwargs) def track(self, title=None, album=None, track=None): """ Returns the :class:`~plexapi.audio.Track` that matches the specified title. @@ -199,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: @@ -208,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): @@ -233,7 +235,7 @@ class Artist( def station(self): """ Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """ - key = '%s?includeStations=1' % self.key + key = f'{self.key}?includeStations=1' return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None) @@ -253,6 +255,7 @@ class Album( collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. formats (List<:class:`~plexapi.media.Format`>): List of format objects. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. key (str): API URL (/library/metadata/). labels (List<:class:`~plexapi.media.Label`>): List of label objects. leafCount (int): Number of items in the album view. @@ -280,6 +283,7 @@ class Album( self.collections = self.findItems(data, media.Collection) self.formats = self.findItems(data, media.Format) self.genres = self.findItems(data, media.Genre) + self.guids = self.findItems(data, media.Guid) self.key = self.key.replace('/children', '') # FIX_BUG_50 self.labels = self.findItems(data, media.Label) self.leafCount = utils.cast(int, data.attrib.get('leafCount')) @@ -312,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): @@ -325,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): @@ -352,7 +356,7 @@ class Album( def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ - return '%s - %s' % (self.parentTitle, self.title) + return f'{self.parentTitle} - {self.title}' @utils.registerPlexObject @@ -380,6 +384,7 @@ class Track( grandparentThumb (str): URL to album artist thumbnail image (/library/metadata//thumb/). grandparentTitle (str): Name of the album artist for the track. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. labels (List<:class:`~plexapi.media.Label`>): List of label objects. media (List<:class:`~plexapi.media.Media`>): List of media objects. originalTitle (str): The artist for the track. @@ -390,7 +395,7 @@ class Track( parentThumb (str): URL to album thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the album for the track. primaryExtraKey (str) API URL for the primary extra for the track. - ratingCount (int): Number of ratings contributing to the rating score. + ratingCount (int): Number of listeners who have scrobbled this track, as reported by Last.fm. skipCount (int): Number of times the track has been skipped. viewOffset (int): View offset in milliseconds. year (int): Year the track was released. @@ -412,6 +417,7 @@ class Track( self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentTitle = data.attrib.get('grandparentTitle') + self.guids = self.findItems(data, media.Guid) self.labels = self.findItems(data, media.Label) self.media = self.findItems(data, media.Media) self.originalTitle = data.attrib.get('originalTitle') @@ -429,8 +435,7 @@ class Track( def _prettyfilename(self): """ Returns a filename for use in download. """ - return '%s - %s - %s - %s' % ( - self.grandparentTitle, self.parentTitle, str(self.trackNumber).zfill(2), self.title) + return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}' def album(self): """ Return the track's :class:`~plexapi.audio.Album`. """ @@ -457,8 +462,21 @@ class Track( def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ - return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) + return f'{self.grandparentTitle} - {self.parentTitle} - {self.title}' def _getWebURL(self, base=None): """ Get the Plex Web URL with the correct parameters. """ return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey) + + +@utils.registerPlexObject +class TrackSession(PlexSession, Track): + """ Represents a single Track session + loaded from :func:`~plexapi.server.PlexServer.sessions`. + """ + _SESSIONTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Track._loadData(self, data) + PlexSession._loadData(self, data) diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index e80b540f..2cb78c53 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -1,15 +1,14 @@ # -*- coding: utf-8 -*- import re import weakref -from urllib.parse import quote_plus, urlencode +from urllib.parse import urlencode from xml.etree import ElementTree from plexapi import log, utils from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported USER_DONT_RELOAD_FOR_KEYS = set() -_DONT_RELOAD_FOR_KEYS = {'key', 'session'} -_DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'} +_DONT_RELOAD_FOR_KEYS = {'key'} OPERATORS = { 'exact': lambda v, q: v == q, 'iexact': lambda v, q: v.lower() == q.lower(), @@ -58,15 +57,11 @@ class PlexObject: self._details_key = self._buildDetailsKey() def __repr__(self): - uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri')) + uid = self._clean(self.firstAttr('_baseurl', 'ratingKey', 'id', 'key', 'playQueueID', 'uri')) name = self._clean(self.firstAttr('title', 'name', 'username', 'product', 'tag', 'value')) - return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p]) + return f"<{':'.join([p for p in [self.__class__.__name__, uid, name] if p])}>" def __setattr__(self, attr, value): - # Don't overwrite session specific attr with [] - if attr in _DONT_OVERWRITE_SESSION_KEYS and value == []: - value = getattr(self, attr, []) - overwriteNone = self.__dict__.get('_overwriteNone') # Don't overwrite an attr with None unless it's a private variable or overwrite None is True if value is not None or attr.startswith('_') or attr not in self.__dict__ or overwriteNone: @@ -89,12 +84,14 @@ class PlexObject: return cls(self._server, elem, initpath, parent=self) # cls is not specified, try looking it up in PLEXOBJECTS etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type'))) - ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag + ehash = f'{elem.tag}.{etype}' if etype else elem.tag + if initpath == '/status/sessions': + ehash = f"{ehash}.{'session'}" ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag)) # log.debug('Building %s as %s', elem.tag, ecls.__name__) if ecls is not None: return ecls(self._server, elem, initpath) - raise UnknownType("Unknown library type <%s type='%s'../>" % (elem.tag, etype)) + raise UnknownType(f"Unknown library type <{elem.tag} type='{etype}'../>") def _buildItemOrNone(self, elem, cls=None, initpath=None): """ Calls :func:`~plexapi.base.PlexObject._buildItem` but returns @@ -170,17 +167,19 @@ class PlexObject: if ekey is None: raise BadRequest('ekey was not provided') if isinstance(ekey, int): - ekey = '/library/metadata/%s' % ekey + ekey = f'/library/metadata/{ekey}' + data = self._server.query(ekey) - librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) - for elem in data: - if self._checkAttrs(elem, **kwargs): - item = self._buildItem(elem, cls, ekey) - if librarySectionID: - item.librarySectionID = librarySectionID - return item + item = self.findItem(data, cls, ekey, **kwargs) + + if item: + librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + if librarySectionID: + item.librarySectionID = librarySectionID + return item + clsname = cls.__name__ if cls else 'None' - raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs)) + raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}') 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 @@ -256,15 +255,16 @@ class PlexObject: fetchItem(ekey, Media__Part__file__startswith="D:\\Movies") """ - url_kw = {} - if container_start is not None: - url_kw["X-Plex-Container-Start"] = container_start - if container_size is not None: - url_kw["X-Plex-Container-Size"] = container_size - if ekey is None: raise BadRequest('ekey was not provided') - data = self._server.query(ekey, params=url_kw) + + params = {} + if container_start is not None: + params["X-Plex-Container-Start"] = container_start + if container_size is not None: + params["X-Plex-Container-Size"] = container_size + + data = self._server.query(ekey, params=params) items = self.findItems(data, cls, ekey, **kwargs) librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) @@ -273,6 +273,25 @@ class PlexObject: item.librarySectionID = librarySectionID return items + def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs): + """ Load the specified data to find and build the first items with the specified tag + and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details + on how this is used. + """ + # filter on cls attrs if specified + if cls and cls.TAG and 'tag' not in kwargs: + kwargs['etag'] = cls.TAG + if cls and cls.TYPE and 'type' not in kwargs: + kwargs['type'] = cls.TYPE + # rtag to iter on a specific root tag + if rtag: + data = next(data.iter(rtag), []) + # loop through all data elements to find matches + for elem in data: + if self._checkAttrs(elem, **kwargs): + item = self._buildItemOrNone(elem, cls, initpath) + return item + def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs): """ Load the specified data to find and build all items with the specified tag and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details @@ -309,7 +328,7 @@ class PlexObject: if rtag: data = next(utils.iterXMLBFS(data, rtag), []) for elem in data: - kwargs['%s__exists' % attr] = True + kwargs[f'{attr}__exists'] = True if self._checkAttrs(elem, **kwargs): results.append(elem.attrib.get(attr)) return results @@ -380,7 +399,7 @@ class PlexObject: def _getAttrOperator(self, attr): for op, operator in OPERATORS.items(): - if attr.endswith('__%s' % op): + if attr.endswith(f'__{op}'): attr = attr.rsplit('__', 1)[0] return attr, op, operator # default to exact match @@ -468,16 +487,16 @@ class PlexPartialObject(PlexObject): value = super(PlexPartialObject, self).__getattribute__(attr) # Check a few cases where we don't want to reload if attr in _DONT_RELOAD_FOR_KEYS: return value - if attr in _DONT_OVERWRITE_SESSION_KEYS: return value if attr in USER_DONT_RELOAD_FOR_KEYS: return value if attr.startswith('_'): return value if value not in (None, []): return value if self.isFullObject(): return value + if isinstance(self, PlexSession): return value if self._autoReload is False: return value # Log the reload. clsname = self.__class__.__name__ title = self.__dict__.get('title', self.__dict__.get('name')) - objname = "%s '%s'" % (clsname, title) if title else clsname + objname = f"{clsname} '{title}'" if title else clsname log.debug("Reloading %s for attr '%s'", objname, attr) # Reload and return the value self._reload(_overwriteNone=False) @@ -502,7 +521,7 @@ class PlexPartialObject(PlexObject): * Generate intro video markers: Detects show intros, exposing the 'Skip Intro' button in clients. """ - key = '/%s/analyze' % self.key.lstrip('/') + key = f"/{self.key.lstrip('/')}/analyze" self._server.query(key, method=self._server._session.put) def isFullObject(self): @@ -528,8 +547,7 @@ class PlexPartialObject(PlexObject): if 'type' not in kwargs: kwargs['type'] = utils.searchType(self._searchType) - part = '/library/sections/%s/all%s' % (self.librarySectionID, - utils.joinArgs(kwargs)) + part = f'/library/sections/{self.librarySectionID}/all{utils.joinArgs(kwargs)}' self._server.query(part, method=self._server._session.put) return self @@ -608,7 +626,7 @@ class PlexPartialObject(PlexObject): the refresh process is interrupted (the Server is turned off, internet connection dies, etc). """ - key = '%s/refresh' % self.key + key = f'{self.key}/refresh' self._server.query(key, method=self._server._session.put) def section(self): @@ -655,12 +673,6 @@ class Playable: Albums which are all not playable. Attributes: - sessionKey (int): Active session key. - usernames (str): Username of the person playing this item (for active sessions). - players (:class:`~plexapi.client.PlexClient`): Client objects playing this item (for active sessions). - session (:class:`~plexapi.media.Session`): Session object, for a playing media file. - transcodeSessions (:class:`~plexapi.media.TranscodeSession`): Transcode Session object - if item is being transcoded (None otherwise). viewedAt (datetime): Datetime item was last viewed (history). accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID. @@ -669,11 +681,6 @@ class Playable: """ def _loadData(self, data): - self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) # session - self.usernames = self.listAttrs(data, 'title', etag='User') # session - self.players = self.findItems(data, etag='Player') # session - self.transcodeSessions = self.findItems(data, etag='TranscodeSession') # session - self.session = self.findItems(data, etag='Session') # session self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history self.accountID = utils.cast(int, data.attrib.get('accountID')) # history self.deviceID = utils.cast(int, data.attrib.get('deviceID')) # history @@ -692,7 +699,7 @@ class Playable: :exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL. """ if self.TYPE not in ('movie', 'episode', 'track', 'clip'): - raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE) + raise Unsupported(f'Fetching stream URL for {self.TYPE} is unsupported.') mvb = params.get('maxVideoBitrate') vr = params.get('videoResolution', '') params = { @@ -710,8 +717,10 @@ class Playable: streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video' # sort the keys since the randomness fucks with my tests.. sorted_params = sorted(params.items(), key=lambda val: val[0]) - return self._server.url('/%s/:/transcode/universal/start.m3u8?%s' % - (streamtype, urlencode(sorted_params)), includeToken=True) + return self._server.url( + f'/{streamtype}/:/transcode/universal/start.m3u8?{urlencode(sorted_params)}', + includeToken=True + ) def iterParts(self): """ Iterates over the parts of this media item. """ @@ -751,7 +760,7 @@ class Playable: for part in parts: if not keep_original_name: - filename = utils.cleanFilename('%s.%s' % (self._prettyfilename(), part.container)) + filename = utils.cleanFilename(f'{self._prettyfilename()}.{part.container}') else: filename = part.file @@ -759,7 +768,7 @@ class Playable: # So this seems to be a a lot slower but allows transcode. download_url = self.getStreamURL(**kwargs) else: - download_url = self._server.url('%s?download=1' % part.key) + download_url = self._server.url(f'{part.key}?download=1') filepath = utils.download( download_url, @@ -774,24 +783,19 @@ class Playable: return filepaths - def stop(self, reason=''): - """ Stop playback for a media item. """ - key = '/status/sessions/terminate?sessionId=%s&reason=%s' % (self.session[0].id, quote_plus(reason)) - return self._server.query(key) - def updateProgress(self, time, state='stopped'): """ Set the watched progress for this video. - Note that setting the time to 0 will not work. - Use `markWatched` or `markUnwatched` to achieve - that goal. + Note that setting the time to 0 will not work. + Use :func:`~plexapi.mixins.PlayedMixin.markPlayed` or + :func:`~plexapi.mixins.PlayedMixin.markUnplayed` to achieve + that goal. Parameters: time (int): milliseconds watched state (string): state of the video, default 'stopped' """ - key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey, - time, state) + key = f'/:/progress?key={self.ratingKey}&identifier=com.plexapp.plugins.library&time={time}&state={state}' self._server.query(key) self._reload(_overwriteNone=False) @@ -808,12 +812,94 @@ class Playable: durationStr = durationStr + str(duration) else: durationStr = durationStr + str(self.duration) - key = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s' - key %= (self.ratingKey, self.key, time, state, durationStr) + key = (f'/:/timeline?ratingKey={self.ratingKey}&key={self.key}&' + f'identifier=com.plexapp.plugins.library&time={int(time)}&state={state}{durationStr}') self._server.query(key) self._reload(_overwriteNone=False) +class PlexSession(object): + """ This is a general place to store functions specific to media that is a Plex Session. + + Attributes: + live (bool): True if this is a live tv session. + player (:class:`~plexapi.client.PlexClient`): PlexClient object for the session. + session (:class:`~plexapi.media.Session`): Session object for the session + if the session is using bandwidth (None otherwise). + sessionKey (int): The session key for the session. + transcodeSession (:class:`~plexapi.media.TranscodeSession`): TranscodeSession object + if item is being transcoded (None otherwise). + """ + + def _loadData(self, data): + self.live = utils.cast(bool, data.attrib.get('live', '0')) + self.player = self.findItem(data, etag='Player') + self.session = self.findItem(data, etag='Session') + self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) + self.transcodeSession = self.findItem(data, etag='TranscodeSession') + + user = data.find('User') + self._username = user.attrib.get('title') + self._userId = utils.cast(int, user.attrib.get('id')) + self._user = None # Cache for user object + + # For backwards compatibility + self.players = [self.player] if self.player else [] + self.sessions = [self.session] if self.session else [] + self.transcodeSessions = [self.transcodeSession] if self.transcodeSession else [] + self.usernames = [self._username] if self._username else [] + + @property + def user(self): + """ Returns the :class:`~plexapi.myplex.MyPlexAccount` object (for admin) + or :class:`~plexapi.myplex.MyPlexUser` object (for users) for this session. + """ + if self._user is None: + myPlexAccount = self._server.myPlexAccount() + if self._userId == 1: + self._user = myPlexAccount + else: + self._user = myPlexAccount.user(self._username) + return self._user + + def reload(self): + """ Reload the data for the session. + Note: This will return the object as-is if the session is no longer active. + """ + return self._reload() + + def _reload(self, _autoReload=False, **kwargs): + """ Perform the actual reload. """ + # Do not auto reload sessions + if _autoReload: + return self + + key = self._initpath + data = self._server.query(key) + for elem in data: + if elem.attrib.get('sessionKey') == str(self.sessionKey): + self._loadData(elem) + break + return self + + def source(self): + """ Return the source media object for the session. """ + return self.fetchItem(self._details_key) + + def stop(self, reason=''): + """ Stop playback for the session. + + Parameters: + reason (str): Message displayed to the user for stopping playback. + """ + params = { + 'sessionId': self.session.id, + 'reason': reason, + } + key = '/status/sessions/terminate' + return self._server.query(key, params=params) + + class MediaContainer(PlexObject): """ Represents a single MediaContainer. diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index cc976a58..b23518ad 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -94,7 +94,7 @@ class PlexClient(PlexObject): self._initpath = self.key data = self.query(self.key, timeout=timeout) if not data: - raise NotFound("Client not found at %s" % self._baseurl) + raise NotFound(f"Client not found at {self._baseurl}") if self._clientIdentifier: client = next( ( @@ -106,8 +106,7 @@ class PlexClient(PlexObject): ) if client is None: raise NotFound( - "Client with identifier %s not found at %s" - % (self._clientIdentifier, self._baseurl) + f"Client with identifier {self._clientIdentifier} not found at {self._baseurl}" ) else: client = data[0] @@ -136,12 +135,15 @@ class PlexClient(PlexObject): # Add this in next breaking release. # if self._initpath == 'status/sessions': self.device = data.attrib.get('device') # session + self.profile = data.attrib.get('profile') # session self.model = data.attrib.get('model') # session self.state = data.attrib.get('state') # session self.vendor = data.attrib.get('vendor') # session self.version = data.attrib.get('version') # session - self.local = utils.cast(bool, data.attrib.get('local', 0)) - self.address = data.attrib.get('address') # session + self.local = utils.cast(bool, data.attrib.get('local', 0)) # session + self.relayed = utils.cast(bool, data.attrib.get('relayed', 0)) # session + self.secure = utils.cast(bool, data.attrib.get('secure', 0)) # session + self.address = data.attrib.get('address') # session self.remotePublicAddress = data.attrib.get('remotePublicAddress') self.userID = data.attrib.get('userID') @@ -183,7 +185,7 @@ class PlexClient(PlexObject): if response.status_code not in (200, 201, 204): codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) + message = f'({response.status_code}) {codename}; {response.url} {errtext}' if response.status_code == 401: raise Unauthorized(message) elif response.status_code == 404: @@ -210,8 +212,7 @@ class PlexClient(PlexObject): controller = command.split('/')[0] headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier} if controller not in self.protocolCapabilities: - log.debug("Client %s doesn't support %s controller." - "What your trying might not work" % (self.title, controller)) + log.debug("Client %s doesn't support %s controller. What your trying might not work", self.title, controller) proxy = self._proxyThroughServer if proxy is None else proxy query = self._server.query if proxy else self.query @@ -225,7 +226,7 @@ class PlexClient(PlexObject): self.sendCommand(ClientTimeline.key, wait=0) params['commandID'] = self._nextCommandId() - key = '/player/%s%s' % (command, utils.joinArgs(params)) + key = f'/player/{command}{utils.joinArgs(params)}' try: return query(key, headers=headers) @@ -250,8 +251,8 @@ class PlexClient(PlexObject): raise BadRequest('PlexClient object missing baseurl.') if self._token and (includeToken or self._showSecrets): delim = '&' if '?' in key else '?' - return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token) - return '%s%s' % (self._baseurl, key) + return f'{self._baseurl}{key}{delim}X-Plex-Token={self._token}' + return f'{self._baseurl}{key}' # --------------------- # Navigation Commands @@ -514,7 +515,7 @@ class PlexClient(PlexObject): 'offset': offset, 'key': media.key or playqueue.selectedItem.key, 'type': mediatype, - 'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID, + 'containerKey': f'/playQueues/{playqueue.playQueueID}?window=100&own=1', **params, } token = media._server.createToken() @@ -620,7 +621,7 @@ class ClientTimeline(PlexObject): self.protocol = data.attrib.get('protocol') self.providerIdentifier = data.attrib.get('providerIdentifier') self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) - self.repeat = utils.cast(bool, data.attrib.get('repeat')) + self.repeat = utils.cast(int, data.attrib.get('repeat')) self.seekRange = data.attrib.get('seekRange') self.shuffle = utils.cast(bool, data.attrib.get('shuffle')) self.state = data.attrib.get('state') diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index 44f1e1f7..ff26cf32 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -4,7 +4,7 @@ from urllib.parse import quote_plus from plexapi import media, utils from plexapi.base import PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported -from plexapi.library import LibrarySection +from plexapi.library import LibrarySection, ManagedHub from plexapi.mixins import ( AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin, ArtMixin, PosterMixin, ThemeMixin, @@ -184,16 +184,28 @@ class Collection( for item in self.items(): if item.title.lower() == title.lower(): return item - raise NotFound('Item with title "%s" not found in the collection' % title) + raise NotFound(f'Item with title "{title}" not found in the collection') def items(self): """ Returns a list of all items in the collection. """ if self._items is None: - key = '%s/children' % self.key + key = f'{self.key}/children' items = self.fetchItems(key) self._items = items return self._items + def visibility(self): + """ Returns the :class:`~plexapi.library.ManagedHub` for this collection. """ + key = f'/hubs/sections/{self.librarySectionID}/manage?metadataItemId={self.ratingKey}' + data = self._server.query(key) + hub = self.findItem(data, cls=ManagedHub) + if hub is None: + hub = ManagedHub(self._server, data, parent=self) + hub.identifier = f'custom.collection.{self.librarySectionID}.{self.ratingKey}' + hub.title = self.title + hub._promoted = False + return hub + def get(self, title): """ Alias to :func:`~plexapi.library.Collection.item`. """ return self.item(title) @@ -221,8 +233,8 @@ class Collection( } key = user_dict.get(user) if key is None: - raise BadRequest('Unknown collection filtering user: %s. Options %s' % (user, list(user_dict))) - self.editAdvanced(collectionFilterBasedOnUser=key) + raise BadRequest(f'Unknown collection filtering user: {user}. Options {list(user_dict)}') + return self.editAdvanced(collectionFilterBasedOnUser=key) def modeUpdate(self, mode=None): """ Update the collection mode advanced setting. @@ -248,8 +260,8 @@ class Collection( } key = mode_dict.get(mode) if key is None: - raise BadRequest('Unknown collection mode: %s. Options %s' % (mode, list(mode_dict))) - self.editAdvanced(collectionMode=key) + raise BadRequest(f'Unknown collection mode: {mode}. Options {list(mode_dict)}') + return self.editAdvanced(collectionMode=key) def sortUpdate(self, sort=None): """ Update the collection order advanced setting. @@ -276,8 +288,8 @@ class Collection( } key = sort_dict.get(sort) if key is None: - raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) - self.editAdvanced(collectionSort=key) + raise BadRequest(f'Unknown sort dir: {sort}. Options: {list(sort_dict)}') + return self.editAdvanced(collectionSort=key) def addItems(self, items): """ Add items to the collection. @@ -298,17 +310,16 @@ class Collection( ratingKeys = [] for item in items: if item.type != self.subtype: # pragma: no cover - raise BadRequest('Can not mix media types when building a collection: %s and %s' % - (self.subtype, item.type)) + raise BadRequest(f'Can not mix media types when building a collection: {self.subtype} and {item.type}') ratingKeys.append(str(item.ratingKey)) ratingKeys = ','.join(ratingKeys) - uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys) + uri = f'{self._server._uriRoot()}/library/metadata/{ratingKeys}' - key = '%s/items%s' % (self.key, utils.joinArgs({ - 'uri': uri - })) + args = {'uri': uri} + key = f"{self.key}/items{utils.joinArgs(args)}" self._server.query(key, method=self._server._session.put) + return self def removeItems(self, items): """ Remove items from the collection. @@ -327,17 +338,18 @@ class Collection( items = [items] for item in items: - key = '%s/items/%s' % (self.key, item.ratingKey) + key = f'{self.key}/items/{item.ratingKey}' self._server.query(key, method=self._server._session.delete) + return self def moveItem(self, item, after=None): """ Move an item to a new position in the collection. Parameters: - items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, - or :class:`~plexapi.photo.Photo` objects to be moved in the collection. + item (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` object to be moved in the collection. after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, - or :class:`~plexapi.photo.Photo` objects to move the item after in the collection. + or :class:`~plexapi.photo.Photo` object to move the item after in the collection. Raises: :class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart collection. @@ -345,12 +357,13 @@ class Collection( if self.smart: raise BadRequest('Cannot move items in a smart collection.') - key = '%s/items/%s/move' % (self.key, item.ratingKey) + key = f'{self.key}/items/{item.ratingKey}/move' if after: - key += '?after=%s' % after.ratingKey + key += f'?after={after.ratingKey}' self._server.query(key, method=self._server._session.put) + return self def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs): """ Update the filters for a smart collection. @@ -376,12 +389,12 @@ class Collection( section = self.section() searchKey = section._buildSearchKey( sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) - uri = '%s%s' % (self._server._uriRoot(), searchKey) + uri = f'{self._server._uriRoot()}{searchKey}' - key = '%s/items%s' % (self.key, utils.joinArgs({ - 'uri': uri - })) + args = {'uri': uri} + key = f"{self.key}/items{utils.joinArgs(args)}" self._server.query(key, method=self._server._session.put) + return self @deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead') def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs): @@ -438,15 +451,10 @@ class Collection( ratingKeys.append(str(item.ratingKey)) ratingKeys = ','.join(ratingKeys) - uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys) + uri = f'{server._uriRoot()}/library/metadata/{ratingKeys}' - key = '/library/collections%s' % utils.joinArgs({ - 'uri': uri, - 'type': utils.searchType(itemType), - 'title': title, - 'smart': 0, - 'sectionId': section.key - }) + args = {'uri': uri, 'type': utils.searchType(itemType), 'title': title, 'smart': 0, 'sectionId': section.key} + key = f"/library/collections{utils.joinArgs(args)}" data = server.query(key, method=server._session.post)[0] return cls(server, data, initpath=key) @@ -460,15 +468,10 @@ class Collection( searchKey = section._buildSearchKey( sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) - uri = '%s%s' % (server._uriRoot(), searchKey) + uri = f'{server._uriRoot()}{searchKey}' - key = '/library/collections%s' % utils.joinArgs({ - 'uri': uri, - 'type': utils.searchType(libtype), - 'title': title, - 'smart': 1, - 'sectionId': section.key - }) + args = {'uri': uri, 'type': utils.searchType(libtype), 'title': title, 'smart': 1, 'sectionId': section.key} + key = f"/library/collections{utils.joinArgs(args)}" data = server.query(key, method=server._session.post)[0] return cls(server, data, initpath=key) @@ -547,9 +550,8 @@ class Collection( sync_item.metadataType = self.metadataType sync_item.machineIdentifier = self._server.machineIdentifier - sync_item.location = 'library:///directory/%s' % quote_plus( - '%s/children?excludeAllLeaves=1' % (self.key) - ) + key = quote_plus(f'{self.key}/children?excludeAllLeaves=1') + sync_item.location = f'library:///directory/{key}' sync_item.policy = Policy.create(limit, unwatched) if self.isVideo: diff --git a/lib/plexapi/config.py b/lib/plexapi/config.py index 77bb8953..3b93f869 100644 --- a/lib/plexapi/config.py +++ b/lib/plexapi/config.py @@ -29,7 +29,7 @@ class PlexConfig(ConfigParser): """ try: # First: check environment variable is set - envkey = 'PLEXAPI_%s' % key.upper().replace('.', '_') + envkey = f"PLEXAPI_{key.upper().replace('.', '_')}" value = os.environ.get(envkey) if value is None: # Second: check the config file has attr diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index 250389fb..8ac71b57 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -3,7 +3,7 @@ # Library version MAJOR_VERSION = 4 -MINOR_VERSION = 11 -PATCH_VERSION = 2 +MINOR_VERSION = 13 +PATCH_VERSION = 1 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index 41892ef9..647a89f0 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import re from datetime import datetime -from urllib.parse import quote, quote_plus, urlencode +from urllib.parse import quote_plus, urlencode from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils from plexapi.base import OPERATORS, PlexObject @@ -65,7 +65,7 @@ class Library(PlexObject): try: return self._sectionsByTitle[normalized_title] except KeyError: - raise NotFound('Invalid library section: %s' % title) from None + raise NotFound(f'Invalid library section: {title}') from None def sectionByID(self, sectionID): """ Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID. @@ -81,7 +81,7 @@ class Library(PlexObject): try: return self._sectionsByID[sectionID] except KeyError: - raise NotFound('Invalid library sectionID: %s' % sectionID) from None + raise NotFound(f'Invalid library sectionID: {sectionID}') from None def hubs(self, sectionID=None, identifier=None, **kwargs): """ Returns a list of :class:`~plexapi.library.Hub` across all library sections. @@ -102,7 +102,7 @@ class Library(PlexObject): if not isinstance(identifier, list): identifier = [identifier] kwargs['identifier'] = ",".join(identifier) - key = '/hubs%s' % utils.joinArgs(kwargs) + key = f'/hubs{utils.joinArgs(kwargs)}' return self.fetchItems(key) def all(self, **kwargs): @@ -139,7 +139,7 @@ class Library(PlexObject): args['type'] = utils.searchType(libtype) for attr, value in kwargs.items(): args[attr] = value - key = '/library/all%s' % utils.joinArgs(args) + key = f'/library/all{utils.joinArgs(args)}' return self.fetchItems(key) def cleanBundles(self): @@ -150,11 +150,13 @@ class Library(PlexObject): """ # TODO: Should this check the response for success or the correct mediaprefix? self._server.query('/library/clean/bundles?async=1', method=self._server._session.put) + return self def emptyTrash(self): """ If a library has items in the Library Trash, use this option to empty the Trash. """ for section in self.sections(): section.emptyTrash() + return self def optimize(self): """ The Optimize option cleans up the server database from unused or fragmented data. @@ -162,21 +164,25 @@ class Library(PlexObject): library, you may like to optimize the database. """ self._server.query('/library/optimize?async=1', method=self._server._session.put) + return self def update(self): """ Scan this library for new items.""" self._server.query('/library/sections/all/refresh') + return self def cancelUpdate(self): """ Cancel a library update. """ key = '/library/sections/all/refresh' self._server.query(key, method=self._server._session.delete) + return self def refresh(self): """ Forces a download of fresh media information from the internet. This can take a long time. Any locked fields are not modified. """ self._server.query('/library/sections/all/refresh?force=1') + return self def deleteMediaPreviews(self): """ Delete the preview thumbnails for the all sections. This cannot be @@ -184,6 +190,7 @@ class Library(PlexObject): """ for section in self.sections(): section.deleteMediaPreviews() + return self def add(self, name='', type='', agent='', scanner='', location='', language='en', *args, **kwargs): """ Simplified add for the most common options. @@ -336,11 +343,11 @@ class Library(PlexObject): locations = [] for path in location: if not self._server.isBrowsable(path): - raise BadRequest('Path: %s does not exist.' % path) + raise BadRequest(f'Path: {path} does not exist.') locations.append(('location', path)) - part = '/library/sections?name=%s&type=%s&agent=%s&scanner=%s&language=%s&%s' % ( - quote_plus(name), type, agent, quote_plus(scanner), language, urlencode(locations, doseq=True)) # noqa E126 + part = (f'/library/sections?name={quote_plus(name)}&type={type}&agent={agent}' + f'&scanner={quote_plus(scanner)}&language={language}&{urlencode(locations, doseq=True)}') if kwargs: part += urlencode(kwargs) return self._server.query(part, method=self._server._session.post) @@ -356,6 +363,16 @@ class Library(PlexObject): hist.extend(section.history(maxresults=maxresults, mindate=mindate)) return hist + def tags(self, tag): + """ Returns a list of :class:`~plexapi.library.LibraryMediaTag` objects for the specified tag. + + Parameters: + tag (str): Tag name (see :data:`~plexapi.utils.TAGTYPES`). + """ + tagType = utils.tagType(tag) + data = self._server.query(f'/library/tags?type={tagType}') + return self.findItems(data) + class LibrarySection(PlexObject): """ Base class for a single library section. @@ -466,8 +483,8 @@ class LibrarySection(PlexObject): xpath = ( './MediaProvider[@identifier="com.plexapp.plugins.library"]' '/Feature[@type="content"]' - '/Directory[@id="%s"]' - ) % self.key + f'/Directory[@id="{self.key}"]' + ) directory = next(iter(data.findall(xpath)), None) if directory: self._totalDuration = utils.cast(int, directory.attrib.get('durationTotal')) @@ -495,16 +512,16 @@ class LibrarySection(PlexObject): args['clusterZoomLevel'] = 1 else: args['type'] = utils.searchType(libtype) - part = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args)) + part = f'/library/sections/{self.key}/all{utils.joinArgs(args)}' data = self._server.query(part) return utils.cast(int, data.attrib.get("totalSize")) def delete(self): """ Delete a library section. """ try: - return self._server.query('/library/sections/%s' % self.key, method=self._server._session.delete) + return self._server.query(f'/library/sections/{self.key}', method=self._server._session.delete) except BadRequest: # pragma: no cover - msg = 'Failed to delete library %s' % self.key + msg = f'Failed to delete library {self.key}' msg += 'You may need to allow this permission in your Plex settings.' log.error(msg) raise @@ -532,13 +549,14 @@ class LibrarySection(PlexObject): kwargs['location'] = [kwargs['location']] for path in kwargs.pop('location'): if not self._server.isBrowsable(path): - raise BadRequest('Path: %s does not exist.' % path) + raise BadRequest(f'Path: {path} does not exist.') locations.append(('location', path)) params = list(kwargs.items()) + locations - part = '/library/sections/%s?agent=%s&%s' % (self.key, agent, urlencode(params, doseq=True)) + part = f'/library/sections/{self.key}?agent={agent}&{urlencode(params, doseq=True)}' self._server.query(part, method=self._server._session.put) + return self def addLocations(self, location): """ Add a location to a library. @@ -558,9 +576,9 @@ class LibrarySection(PlexObject): location = [location] for path in location: if not self._server.isBrowsable(path): - raise BadRequest('Path: %s does not exist.' % path) + raise BadRequest(f'Path: {path} does not exist.') locations.append(path) - self.edit(location=locations) + return self.edit(location=locations) def removeLocations(self, location): """ Remove a location from a library. @@ -582,10 +600,10 @@ class LibrarySection(PlexObject): if path in locations: locations.remove(path) else: - raise BadRequest('Path: %s does not exist in the library.' % location) + raise BadRequest(f'Path: {location} does not exist in the library.') if len(locations) == 0: raise BadRequest('You are unable to remove all locations from a library.') - self.edit(location=locations) + return self.edit(location=locations) def get(self, title): """ Returns the media item with the specified title. @@ -596,8 +614,10 @@ class LibrarySection(PlexObject): Raises: :exc:`~plexapi.exceptions.NotFound`: The title is not found in the library. """ - key = '/library/sections/%s/all?includeGuids=1&title=%s' % (self.key, quote(title, safe='')) - return self.fetchItem(key, title__iexact=title) + try: + return self.search(title)[0] + except IndexError: + raise NotFound(f"Unable to find item '{title}'") from None def getGuid(self, guid): """ Returns the media item with the specified external Plex, IMDB, TMDB, or TVDB ID. @@ -642,7 +662,7 @@ class LibrarySection(PlexObject): match = dummy.matches(agent=self.agent, title=guid.replace('://', '-')) return self.search(guid=match[0].guid)[0] except IndexError: - raise NotFound("Guid '%s' is not found in the library" % guid) from None + raise NotFound(f"Guid '{guid}' is not found in the library") from None def all(self, libtype=None, **kwargs): """ Returns a list of all items from this library section. @@ -654,13 +674,25 @@ class LibrarySection(PlexObject): def folders(self): """ Returns a list of available :class:`~plexapi.library.Folder` for this library section. """ - key = '/library/sections/%s/folder' % self.key + key = f'/library/sections/{self.key}/folder' return self.fetchItems(key, Folder) + def managedHubs(self): + """ Returns a list of available :class:`~plexapi.library.ManagedHub` for this library section. + """ + key = f'/hubs/sections/{self.key}/manage' + return self.fetchItems(key, ManagedHub) + + def resetManagedHubs(self): + """ Reset the managed hub customizations for this library section. + """ + key = f'/hubs/sections/{self.key}/manage' + self._server.query(key, method=self._server._session.delete) + def hubs(self): """ Returns a list of available :class:`~plexapi.library.Hub` for this library section. """ - key = '/hubs/sections/%s?includeStations=1' % self.key + key = f'/hubs/sections/{self.key}?includeStations=1' return self.fetchItems(key) def agents(self): @@ -670,7 +702,7 @@ class LibrarySection(PlexObject): def settings(self): """ Returns a list of all library settings. """ - key = '/library/sections/%s/prefs' % self.key + key = f'/library/sections/{self.key}/prefs' data = self._server.query(key) return self.findItems(data, cls=Setting) @@ -678,7 +710,7 @@ class LibrarySection(PlexObject): """ Edit a library's advanced settings. """ data = {} idEnums = {} - key = 'prefs[%s]' + key = 'prefs[{}]' for setting in self.settings(): if setting.type != 'bool': @@ -690,35 +722,36 @@ class LibrarySection(PlexObject): try: enums = idEnums[settingID] except KeyError: - raise NotFound('%s not found in %s' % (value, list(idEnums.keys()))) + raise NotFound(f'{value} not found in {list(idEnums.keys())}') if value in enums: - data[key % settingID] = value + data[key.format(settingID)] = value else: - raise NotFound('%s not found in %s' % (value, enums)) + raise NotFound(f'{value} not found in {enums}') - self.edit(**data) + return self.edit(**data) def defaultAdvanced(self): """ Edit all of library's advanced settings to default. """ data = {} - key = 'prefs[%s]' + key = 'prefs[{}]' for setting in self.settings(): if setting.type == 'bool': - data[key % setting.id] = int(setting.default) + data[key.format(setting.id)] = int(setting.default) else: - data[key % setting.id] = setting.default + data[key.format(setting.id)] = setting.default - self.edit(**data) + return self.edit(**data) def _lockUnlockAllField(self, field, libtype=None, locked=True): """ Lock or unlock a field for all items in the library. """ libtype = libtype or self.TYPE args = { 'type': utils.searchType(libtype), - '%s.locked' % field: int(locked) + f'{field}.locked': int(locked) } - key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args)) + key = f'/library/sections/{self.key}/all{utils.joinArgs(args)}' self._server.query(key, method=self._server._session.put) + return self def lockAllField(self, field, libtype=None): """ Lock a field for all items in the library. @@ -728,7 +761,7 @@ class LibrarySection(PlexObject): libtype (str, optional): The library type to lock (movie, show, season, episode, artist, album, track, photoalbum, photo). Default is the main library type. """ - self._lockUnlockAllField(field, libtype=libtype, locked=True) + return self._lockUnlockAllField(field, libtype=libtype, locked=True) def unlockAllField(self, field, libtype=None): """ Unlock a field for all items in the library. @@ -738,17 +771,17 @@ class LibrarySection(PlexObject): libtype (str, optional): The library type to lock (movie, show, season, episode, artist, album, track, photoalbum, photo). Default is the main library type. """ - self._lockUnlockAllField(field, libtype=libtype, locked=False) + return self._lockUnlockAllField(field, libtype=libtype, locked=False) def timeline(self): """ Returns a timeline query for this library section. """ - key = '/library/sections/%s/timeline' % self.key + key = f'/library/sections/{self.key}/timeline' data = self._server.query(key) return LibraryTimeline(self, data) def onDeck(self): """ Returns a list of media items on deck from this library section. """ - key = '/library/sections/%s/onDeck' % self.key + key = f'/library/sections/{self.key}/onDeck' return self.fetchItems(key) def recentlyAdded(self, maxresults=50, libtype=None): @@ -763,20 +796,22 @@ class LibrarySection(PlexObject): return self.search(sort='addedAt:desc', maxresults=maxresults, libtype=libtype) def firstCharacter(self): - key = '/library/sections/%s/firstCharacter' % self.key + key = f'/library/sections/{self.key}/firstCharacter' return self.fetchItems(key, cls=FirstCharacter) def analyze(self): """ Run an analysis on all of the items in this library section. See See :func:`~plexapi.base.PlexPartialObject.analyze` for more details. """ - key = '/library/sections/%s/analyze' % self.key + key = f'/library/sections/{self.key}/analyze' self._server.query(key, method=self._server._session.put) + return self def emptyTrash(self): """ If a section has items in the Trash, use this option to empty the Trash. """ - key = '/library/sections/%s/emptyTrash' % self.key + key = f'/library/sections/{self.key}/emptyTrash' self._server.query(key, method=self._server._session.put) + return self def update(self, path=None): """ Scan this section for new media. @@ -784,47 +819,55 @@ class LibrarySection(PlexObject): Parameters: path (str, optional): Full path to folder to scan. """ - key = '/library/sections/%s/refresh' % self.key + key = f'/library/sections/{self.key}/refresh' if path is not None: - key += '?path=%s' % quote_plus(path) + key += f'?path={quote_plus(path)}' self._server.query(key) + return self def cancelUpdate(self): """ Cancel update of this Library Section. """ - key = '/library/sections/%s/refresh' % self.key + key = f'/library/sections/{self.key}/refresh' self._server.query(key, method=self._server._session.delete) + return self def refresh(self): """ Forces a download of fresh media information from the internet. This can take a long time. Any locked fields are not modified. """ - key = '/library/sections/%s/refresh?force=1' % self.key + key = f'/library/sections/{self.key}/refresh?force=1' self._server.query(key) + return self def deleteMediaPreviews(self): """ Delete the preview thumbnails for items in this library. This cannot be undone. Recreating media preview files can take hours or even days. """ - key = '/library/sections/%s/indexes' % self.key + key = f'/library/sections/{self.key}/indexes' self._server.query(key, method=self._server._session.delete) + return self def _loadFilters(self): """ Retrieves and caches the list of :class:`~plexapi.library.FilteringType` and list of :class:`~plexapi.library.FilteringFieldType` for this library section. """ - _key = ('/library/sections/%s/%s?includeMeta=1&includeAdvanced=1' - '&X-Plex-Container-Start=0&X-Plex-Container-Size=0') + _key = ('/library/sections/{key}/{filter}?includeMeta=1&includeAdvanced=1' + '&X-Plex-Container-Start=0&X-Plex-Container-Size=0') - key = _key % (self.key, 'all') + key = _key.format(key=self.key, filter='all') data = self._server.query(key) self._filterTypes = self.findItems(data, FilteringType, rtag='Meta') self._fieldTypes = self.findItems(data, FilteringFieldType, rtag='Meta') if self.TYPE != 'photo': # No collections for photo library - key = _key % (self.key, 'collections') + key = _key.format(key=self.key, filter='collections') data = self._server.query(key) self._filterTypes.extend(self.findItems(data, FilteringType, rtag='Meta')) + # Manually add guid field type, only allowing "is" operator + guidFieldType = '' + self._fieldTypes.append(self._manuallyLoadXML(guidFieldType, FilteringFieldType)) + def filterTypes(self): """ Returns a list of available :class:`~plexapi.library.FilteringType` for this library section. """ if self._filterTypes is None: @@ -846,9 +889,8 @@ class LibrarySection(PlexObject): return next(f for f in self.filterTypes() if f.type == libtype) except StopIteration: availableLibtypes = [f.type for f in self.filterTypes()] - raise NotFound('Unknown libtype "%s" for this library. ' - 'Available libtypes: %s' - % (libtype, availableLibtypes)) from None + raise NotFound(f'Unknown libtype "{libtype}" for this library. ' + f'Available libtypes: {availableLibtypes}') from None def fieldTypes(self): """ Returns a list of available :class:`~plexapi.library.FilteringFieldType` for this library section. """ @@ -870,9 +912,8 @@ class LibrarySection(PlexObject): return next(f for f in self.fieldTypes() if f.type == fieldType) except StopIteration: availableFieldTypes = [f.type for f in self.fieldTypes()] - raise NotFound('Unknown field type "%s" for this library. ' - 'Available field types: %s' - % (fieldType, availableFieldTypes)) from None + raise NotFound(f'Unknown field type "{fieldType}" for this library. ' + f'Available field types: {availableFieldTypes}') from None def listFilters(self, libtype=None): """ Returns a list of available :class:`~plexapi.library.FilteringFilter` for a specified libtype. @@ -947,7 +988,7 @@ class LibrarySection(PlexObject): field = 'genre' # Available filter field from listFields() filterField = next(f for f in library.listFields() if f.key.endswith(field)) availableOperators = [o.key for o in library.listOperators(filterField.type)] - print("Available operators for %s:" % field, availableOperators) + print(f"Available operators for {field}:", availableOperators) """ return self.getFieldType(fieldType).operators @@ -974,22 +1015,21 @@ class LibrarySection(PlexObject): field = 'genre' # Available filter field from listFilters() availableChoices = [f.title for f in library.listFilterChoices(field)] - print("Available choices for %s:" % field, availableChoices) + print(f"Available choices for {field}:", availableChoices) """ if isinstance(field, str): match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+)', field) if not match: - raise BadRequest('Invalid filter field: %s' % field) + raise BadRequest(f'Invalid filter field: {field}') _libtype, field = match.groups() libtype = _libtype or libtype or self.TYPE try: field = next(f for f in self.listFilters(libtype) if f.filter == field) except StopIteration: availableFilters = [f.filter for f in self.listFilters(libtype)] - raise NotFound('Unknown filter field "%s" for libtype "%s". ' - 'Available filters: %s' - % (field, libtype, availableFilters)) from None + raise NotFound(f'Unknown filter field "{field}" for libtype "{libtype}". ' + f'Available filters: {availableFilters}') from None data = self._server.query(field.key) return self.findItems(data, FilterChoice) @@ -1000,7 +1040,7 @@ class LibrarySection(PlexObject): """ match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+)([!<>=&]*)', field) if not match: - raise BadRequest('Invalid filter field: %s' % field) + raise BadRequest(f'Invalid filter field: {field}') _libtype, field, operator = match.groups() libtype = _libtype or libtype or self.TYPE @@ -1014,9 +1054,8 @@ class LibrarySection(PlexObject): break else: availableFields = [f.key for f in self.listFields(libtype)] - raise NotFound('Unknown filter field "%s" for libtype "%s". ' - 'Available filter fields: %s' - % (field, libtype, availableFields)) from None + raise NotFound(f'Unknown filter field "{field}" for libtype "{libtype}". ' + f'Available filter fields: {availableFields}') from None field = filterField.key operator = self._validateFieldOperator(filterField, operator) @@ -1047,9 +1086,8 @@ class LibrarySection(PlexObject): next(o for o in fieldType.operators if o.key == operator) except StopIteration: availableOperators = [o.key for o in self.listOperators(filterField.type)] - raise NotFound('Unknown operator "%s" for filter field "%s". ' - 'Available operators: %s' - % (operator, filterField.key, availableOperators)) from None + raise NotFound(f'Unknown operator "{operator}" for filter field "{filterField.key}". ' + f'Available operators: {availableOperators}') from None return '&=' if and_operator else operator @@ -1077,8 +1115,8 @@ class LibrarySection(PlexObject): value = self._validateFieldValueTag(value, filterField, libtype) results.append(str(value)) except (ValueError, AttributeError): - raise BadRequest('Invalid value "%s" for filter field "%s", value should be type %s' - % (value, filterField.key, fieldType.type)) from None + raise BadRequest(f'Invalid value "{value}" for filter field "{filterField.key}", ' + f'value should be type {fieldType.type}') from None return results @@ -1100,7 +1138,7 @@ class LibrarySection(PlexObject): """ if isinstance(value, FilterChoice): return value.key - if isinstance(value, media.MediaTag): + if isinstance(value, (media.MediaTag, LibraryMediaTag)): value = str(value.id or value.tag) else: value = str(value) @@ -1131,11 +1169,11 @@ class LibrarySection(PlexObject): Returns the validated sort field string. """ if isinstance(sort, FilteringSort): - return '%s.%s:%s' % (libtype or self.TYPE, sort.key, sort.defaultDirection) + return f'{libtype or self.TYPE}.{sort.key}:{sort.defaultDirection}' match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+):?([a-zA-Z]*)', sort.strip()) if not match: - raise BadRequest('Invalid filter sort: %s' % sort) + raise BadRequest(f'Invalid filter sort: {sort}') _libtype, sortField, sortDir = match.groups() libtype = _libtype or libtype or self.TYPE @@ -1143,19 +1181,16 @@ class LibrarySection(PlexObject): filterSort = next(f for f in self.listSorts(libtype) if f.key == sortField) except StopIteration: availableSorts = [f.key for f in self.listSorts(libtype)] - raise NotFound('Unknown sort field "%s" for libtype "%s". ' - 'Available sort fields: %s' - % (sortField, libtype, availableSorts)) from None + raise NotFound(f'Unknown sort field "{sortField}" for libtype "{libtype}". ' + f'Available sort fields: {availableSorts}') from None sortField = libtype + '.' + filterSort.key availableDirections = ['', 'asc', 'desc', 'nullsLast'] if sortDir not in availableDirections: - raise NotFound('Unknown sort direction "%s". ' - 'Available sort directions: %s' - % (sortDir, availableDirections)) + raise NotFound(f'Unknown sort direction "{sortDir}". Available sort directions: {availableDirections}') - return '%s:%s' % (sortField, sortDir) if sortDir else sortField + return f'{sortField}:{sortDir}' if sortDir else sortField def _validateAdvancedSearch(self, filters, libtype): """ Validates an advanced search filter dictionary. @@ -1177,7 +1212,7 @@ class LibrarySection(PlexObject): for value in values: validatedFilters.extend(self._validateAdvancedSearch(value, libtype)) - validatedFilters.append('%s=1' % field.lower()) + validatedFilters.append(f'{field.lower()}=1') del validatedFilters[-1] validatedFilters.append('pop=1') @@ -1216,7 +1251,7 @@ class LibrarySection(PlexObject): joined_args = utils.joinArgs(args).lstrip('?') joined_filter_args = '&'.join(filter_args) if filter_args else '' params = '&'.join([joined_args, joined_filter_args]).strip('&') - key = '/library/sections/%s/all?%s' % (self.key, params) + key = f'/library/sections/{self.key}/all?{params}' if returnKwargs: return key, kwargs @@ -1374,48 +1409,47 @@ class LibrarySection(PlexObject): **Using Plex Operators** - Operators can be appended to the filter field to narrow down results with more granularity. If no - operator is specified, the default operator is assumed to be ``=``. The following is a list of - possible operators depending on the data type of the filter being applied. A special ``&`` operator - can also be used to ``AND`` together a list of values. + Operators can be appended to the filter field to narrow down results with more granularity. + The following is a list of possible operators depending on the data type of the filter being applied. + A special ``&`` operator can also be used to ``AND`` together a list of values. Type: :class:`~plexapi.media.MediaTag` or *subtitleLanguage* or *audioLanguage* - * ``=``: ``is`` - * ``!=``: ``is not`` + * no operator: ``is`` + * ``!``: ``is not`` Type: *int* - * ``=``: ``is`` - * ``!=``: ``is not`` - * ``>>=``: ``is greater than`` - * ``<<=``: ``is less than`` + * no operator: ``is`` + * ``!``: ``is not`` + * ``>>``: ``is greater than`` + * ``<<``: ``is less than`` Type: *str* - * ``=``: ``contains`` - * ``!=``: ``does not contain`` - * ``==``: ``is`` - * ``!==``: ``is not`` - * ``<=``: ``begins with`` - * ``>=``: ``ends with`` + * no operator: ``contains`` + * ``!``: ``does not contain`` + * ``=``: ``is`` + * ``!=``: ``is not`` + * ``<``: ``begins with`` + * ``>``: ``ends with`` Type: *bool* - * ``=``: ``is true`` - * ``!=``: ``is false`` + * no operator: ``is true`` + * ``!``: ``is false`` Type: *datetime* - * ``<<=``: ``is before`` - * ``>>=``: ``is after`` + * ``<<``: ``is before`` + * ``>>``: ``is after`` - Type: *resolution* + Type: *resolution* or *guid* - * ``=``: ``is`` + * no operator: ``is`` Operators cannot be included directly in the function parameters so the filters - must be provided as a filters dictionary. The trailing ``=`` on the operator may be excluded. + must be provided as a filters dictionary. Examples: @@ -1593,7 +1627,7 @@ class LibrarySection(PlexObject): key = self._buildSearchKey(title=title, sort=sort, libtype=libtype, **kwargs) - sync_item.location = 'library://%s/directory/%s' % (self.uuid, quote_plus(key)) + sync_item.location = f'library://{self.uuid}/directory/{quote_plus(key)}' sync_item.policy = policy sync_item.mediaSettings = mediaSettings @@ -1628,7 +1662,7 @@ class LibrarySection(PlexObject): try: return self.collections(title=title, title__iexact=title)[0] except IndexError: - raise NotFound('Unable to find collection with title "%s".' % title) from None + raise NotFound(f'Unable to find collection with title "{title}".') from None def collections(self, **kwargs): """ Returns a list of collections from this library section. @@ -1657,7 +1691,7 @@ class LibrarySection(PlexObject): try: return self.playlists(title=title, title__iexact=title)[0] except IndexError: - raise NotFound('Unable to find playlist with title "%s".' % title) from None + raise NotFound(f'Unable to find playlist with title "{title}".') from None def playlists(self, sort=None, **kwargs): """ Returns a list of playlists from this library section. """ @@ -1848,7 +1882,7 @@ class MusicSection(LibrarySection): def albums(self): """ Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ - key = '/library/sections/%s/albums' % self.key + key = f'/library/sections/{self.key}/albums' return self.fetchItems(key) def stations(self): @@ -2110,10 +2144,11 @@ class Hub(PlexObject): return self._section -class HubMediaTag(PlexObject): - """ Base class of hub media tag search results. +class LibraryMediaTag(PlexObject): + """ Base class of library media tags. Attributes: + TAG (str): 'Directory' count (int): The number of items where this tag is found. filter (str): The URL filter for the tag. id (int): The id of the tag. @@ -2156,53 +2191,33 @@ class HubMediaTag(PlexObject): def items(self, *args, **kwargs): """ Return the list of items within this tag. """ if not self.key: - raise BadRequest('Key is not defined for this tag: %s' % self.tag) + raise BadRequest(f'Key is not defined for this tag: {self.tag}') return self.fetchItems(self.key) @utils.registerPlexObject -class Tag(HubMediaTag): - """ Represents a single Tag hub search media tag. +class Aperture(LibraryMediaTag): + """ Represents a single Aperture library media tag. Attributes: - TAGTYPE (int): 0 + TAGTYPE (int): 202 """ - TAGTYPE = 0 + TAGTYPE = 202 @utils.registerPlexObject -class Genre(HubMediaTag): - """ Represents a single Genre hub search media tag. +class Art(LibraryMediaTag): + """ Represents a single Art library media tag. Attributes: - TAGTYPE (int): 1 + TAGTYPE (int): 313 """ - TAGTYPE = 1 + TAGTYPE = 313 @utils.registerPlexObject -class Director(HubMediaTag): - """ Represents a single Director hub search media tag. - - Attributes: - TAGTYPE (int): 4 - """ - TAGTYPE = 4 - - -@utils.registerPlexObject -class Actor(HubMediaTag): - """ Represents a single Actor hub search media tag. - - Attributes: - TAGTYPE (int): 6 - """ - TAGTYPE = 6 - - -@utils.registerPlexObject -class AutoTag(HubMediaTag): - """ Represents a single AutoTag hub search media tag. +class Autotag(LibraryMediaTag): + """ Represents a single Autotag library media tag. Attributes: TAGTYPE (int): 207 @@ -2211,8 +2226,210 @@ class AutoTag(HubMediaTag): @utils.registerPlexObject -class Place(HubMediaTag): - """ Represents a single Place hub search media tag. +class Banner(LibraryMediaTag): + """ Represents a single Banner library media tag. + + Attributes: + TAGTYPE (int): 311 + """ + TAGTYPE = 311 + + +@utils.registerPlexObject +class Chapter(LibraryMediaTag): + """ Represents a single Chapter library media tag. + + Attributes: + TAGTYPE (int): 9 + """ + TAGTYPE = 9 + + +@utils.registerPlexObject +class Collection(LibraryMediaTag): + """ Represents a single Collection library media tag. + + Attributes: + TAGTYPE (int): 2 + """ + TAGTYPE = 2 + + +@utils.registerPlexObject +class Concert(LibraryMediaTag): + """ Represents a single Concert library media tag. + + Attributes: + TAGTYPE (int): 306 + """ + TAGTYPE = 306 + + +@utils.registerPlexObject +class Country(LibraryMediaTag): + """ Represents a single Country library media tag. + + Attributes: + TAGTYPE (int): 8 + """ + TAGTYPE = 8 + + +@utils.registerPlexObject +class Device(LibraryMediaTag): + """ Represents a single Device library media tag. + + Attributes: + TAGTYPE (int): 206 + """ + TAGTYPE = 206 + + +@utils.registerPlexObject +class Director(LibraryMediaTag): + """ Represents a single Director library media tag. + + Attributes: + TAGTYPE (int): 4 + """ + TAGTYPE = 4 + + +@utils.registerPlexObject +class Exposure(LibraryMediaTag): + """ Represents a single Exposure library media tag. + + Attributes: + TAGTYPE (int): 203 + """ + TAGTYPE = 203 + + +@utils.registerPlexObject +class Format(LibraryMediaTag): + """ Represents a single Format library media tag. + + Attributes: + TAGTYPE (int): 302 + """ + TAGTYPE = 302 + + +@utils.registerPlexObject +class Genre(LibraryMediaTag): + """ Represents a single Genre library media tag. + + Attributes: + TAGTYPE (int): 1 + """ + TAGTYPE = 1 + + +@utils.registerPlexObject +class Guid(LibraryMediaTag): + """ Represents a single Guid library media tag. + + Attributes: + TAGTYPE (int): 314 + """ + TAGTYPE = 314 + + +@utils.registerPlexObject +class ISO(LibraryMediaTag): + """ Represents a single ISO library media tag. + + Attributes: + TAGTYPE (int): 204 + """ + TAGTYPE = 204 + + +@utils.registerPlexObject +class Label(LibraryMediaTag): + """ Represents a single Label library media tag. + + Attributes: + TAGTYPE (int): 11 + """ + TAGTYPE = 11 + + +@utils.registerPlexObject +class Lens(LibraryMediaTag): + """ Represents a single Lens library media tag. + + Attributes: + TAGTYPE (int): 205 + """ + TAGTYPE = 205 + + +@utils.registerPlexObject +class Make(LibraryMediaTag): + """ Represents a single Make library media tag. + + Attributes: + TAGTYPE (int): 200 + """ + TAGTYPE = 200 + + +@utils.registerPlexObject +class Marker(LibraryMediaTag): + """ Represents a single Marker library media tag. + + Attributes: + TAGTYPE (int): 12 + """ + TAGTYPE = 12 + + +@utils.registerPlexObject +class MediaProcessingTarget(LibraryMediaTag): + """ Represents a single MediaProcessingTarget library media tag. + + Attributes: + TAG (str): 'Tag' + TAGTYPE (int): 42 + """ + TAG = 'Tag' + TAGTYPE = 42 + + +@utils.registerPlexObject +class Model(LibraryMediaTag): + """ Represents a single Model library media tag. + + Attributes: + TAGTYPE (int): 201 + """ + TAGTYPE = 201 + + +@utils.registerPlexObject +class Mood(LibraryMediaTag): + """ Represents a single Mood library media tag. + + Attributes: + TAGTYPE (int): 300 + """ + TAGTYPE = 300 + + +@utils.registerPlexObject +class Network(LibraryMediaTag): + """ Represents a single Network library media tag. + + Attributes: + TAGTYPE (int): 319 + """ + TAGTYPE = 319 + + +@utils.registerPlexObject +class Place(LibraryMediaTag): + """ Represents a single Place library media tag. Attributes: TAGTYPE (int): 400 @@ -2220,6 +2437,116 @@ class Place(HubMediaTag): TAGTYPE = 400 +@utils.registerPlexObject +class Poster(LibraryMediaTag): + """ Represents a single Poster library media tag. + + Attributes: + TAGTYPE (int): 312 + """ + TAGTYPE = 312 + + +@utils.registerPlexObject +class Producer(LibraryMediaTag): + """ Represents a single Producer library media tag. + + Attributes: + TAGTYPE (int): 7 + """ + TAGTYPE = 7 + + +@utils.registerPlexObject +class RatingImage(LibraryMediaTag): + """ Represents a single RatingImage library media tag. + + Attributes: + TAGTYPE (int): 316 + """ + TAGTYPE = 316 + + +@utils.registerPlexObject +class Review(LibraryMediaTag): + """ Represents a single Review library media tag. + + Attributes: + TAGTYPE (int): 10 + """ + TAGTYPE = 10 + + +@utils.registerPlexObject +class Role(LibraryMediaTag): + """ Represents a single Role library media tag. + + Attributes: + TAGTYPE (int): 6 + """ + TAGTYPE = 6 + + +@utils.registerPlexObject +class Similar(LibraryMediaTag): + """ Represents a single Similar library media tag. + + Attributes: + TAGTYPE (int): 305 + """ + TAGTYPE = 305 + + +@utils.registerPlexObject +class Studio(LibraryMediaTag): + """ Represents a single Studio library media tag. + + Attributes: + TAGTYPE (int): 318 + """ + TAGTYPE = 318 + + +@utils.registerPlexObject +class Style(LibraryMediaTag): + """ Represents a single Style library media tag. + + Attributes: + TAGTYPE (int): 301 + """ + TAGTYPE = 301 + + +@utils.registerPlexObject +class Tag(LibraryMediaTag): + """ Represents a single Tag library media tag. + + Attributes: + TAGTYPE (int): 0 + """ + TAGTYPE = 0 + + +@utils.registerPlexObject +class Theme(LibraryMediaTag): + """ Represents a single Theme library media tag. + + Attributes: + TAGTYPE (int): 317 + """ + TAGTYPE = 317 + + +@utils.registerPlexObject +class Writer(LibraryMediaTag): + """ Represents a single Writer library media tag. + + Attributes: + TAGTYPE (int): 5 + """ + TAGTYPE = 5 + + class FilteringType(PlexObject): """ Represents a single filtering Type object for a library. @@ -2237,7 +2564,7 @@ class FilteringType(PlexObject): def __repr__(self): _type = self._clean(self.firstAttr('type')) - return '<%s>' % ':'.join([p for p in [self.__class__.__name__, _type] if p]) + return f"<{':'.join([p for p in [self.__class__.__name__, _type] if p])}>" def _loadData(self, data): self._data = data @@ -2288,12 +2615,13 @@ class FilteringType(PlexObject): manualFilters = [] for filterTag, filterType, filterTitle in additionalFilters: - filterKey = '/library/sections/%s/%s?type=%s' % ( - self._librarySectionID, filterTag, utils.searchType(self.type) - ) + filterKey = f'/library/sections/{self._librarySectionID}/{filterTag}?type={utils.searchType(self.type)}' filterXML = ( - '' - % (filterTag, filterType, filterKey, filterTitle) + f'' ) manualFilters.append(self._manuallyLoadXML(filterXML, FilteringFilter)) @@ -2307,7 +2635,7 @@ class FilteringType(PlexObject): additionalSorts = [ ('guid', 'asc', 'Guid'), ('id', 'asc', 'Rating Key'), - ('index', 'asc', '%s Number' % self.type.capitalize()), + ('index', 'asc', f'{self.type.capitalize()} Number'), ('summary', 'asc', 'Summary'), ('tagline', 'asc', 'Tagline'), ('updatedAt', 'asc', 'Date Updated') @@ -2334,8 +2662,10 @@ class FilteringType(PlexObject): manualSorts = [] for sortField, sortDir, sortTitle in additionalSorts: sortXML = ( - '' - % (sortDir, sortField, sortField, sortTitle) + f'' ) manualSorts.append(self._manuallyLoadXML(sortXML, FilteringSort)) @@ -2347,10 +2677,10 @@ class FilteringType(PlexObject): """ # Fields: (key, type, title) additionalFields = [ - ('guid', 'string', 'Guid'), + ('guid', 'guid', 'Guid'), ('id', 'integer', 'Rating Key'), - ('index', 'integer', '%s Number' % self.type.capitalize()), - ('lastRatedAt', 'date', '%s Last Rated' % self.type.capitalize()), + ('index', 'integer', f'{self.type.capitalize()} Number'), + ('lastRatedAt', 'date', f'{self.type.capitalize()} Last Rated'), ('updatedAt', 'date', 'Date Updated') ] @@ -2403,8 +2733,9 @@ class FilteringType(PlexObject): manualFields = [] for field, fieldType, fieldTitle in additionalFields: fieldXML = ( - '' - % (prefix, field, fieldTitle, fieldType) + f'' ) manualFields.append(self._manuallyLoadXML(fieldXML, FilteringField)) @@ -2495,7 +2826,7 @@ class FilteringFieldType(PlexObject): def __repr__(self): _type = self._clean(self.firstAttr('type')) - return '<%s>' % ':'.join([p for p in [self.__class__.__name__, _type] if p]) + return f"<{':'.join([p for p in [self.__class__.__name__, _type] if p])}>" def _loadData(self, data): """ Load attribute values from Plex XML response. """ @@ -2546,6 +2877,137 @@ class FilterChoice(PlexObject): self.type = data.attrib.get('type') +class ManagedHub(PlexObject): + """ Represents a Managed Hub (recommendation) inside a library. + + Attributes: + TAG (str): 'Hub' + deletable (bool): True if the Hub can be deleted (promoted collection). + homeVisibility (str): Promoted home visibility (none, all, admin, or shared). + identifier (str): Hub identifier for the managed hub. + promotedToOwnHome (bool): Promoted to own home. + promotedToRecommended (bool): Promoted to recommended. + promotedToSharedHome (bool): Promoted to shared home. + recommendationsVisibility (str): Promoted recommendation visibility (none or all). + title (str): Title of managed hub. + """ + TAG = 'Hub' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.deletable = utils.cast(bool, data.attrib.get('deletable', True)) + self.homeVisibility = data.attrib.get('homeVisibility', 'none') + self.identifier = data.attrib.get('identifier') + self.promotedToOwnHome = utils.cast(bool, data.attrib.get('promotedToOwnHome', False)) + self.promotedToRecommended = utils.cast(bool, data.attrib.get('promotedToRecommended', False)) + self.promotedToSharedHome = utils.cast(bool, data.attrib.get('promotedToSharedHome', False)) + self.recommendationsVisibility = data.attrib.get('recommendationsVisibility', 'none') + self.title = data.attrib.get('title') + self._promoted = True # flag to indicate if this hub has been promoted on the list of managed recommendations + + parent = self._parent() + self.librarySectionID = parent.key if isinstance(parent, LibrarySection) else parent.librarySectionID + + def reload(self): + """ Reload the data for this managed hub. """ + key = f'/hubs/sections/{self.librarySectionID}/manage' + hub = self.fetchItem(key, self.__class__, identifier=self.identifier) + self.__dict__.update(hub.__dict__) + return self + + def move(self, after=None): + """ Move a managed hub to a new position in the library's Managed Recommendations. + + Parameters: + after (obj): :class:`~plexapi.library.ManagedHub` object to move the item after in the collection. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to move a Hub that is not a Managed Recommendation. + """ + if not self._promoted: + raise BadRequest('Collection must be a Managed Recommendation to be moved') + key = f'/hubs/sections/{self.librarySectionID}/manage/{self.identifier}/move' + if after: + key = f'{key}?after={after.identifier}' + self._server.query(key, method=self._server._session.put) + + def remove(self): + """ Removes a managed hub from the library's Managed Recommendations. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to remove a Hub that is not a Managed Recommendation + or when the Hub cannot be removed. + """ + if not self._promoted: + raise BadRequest('Collection must be a Managed Recommendation to be removed') + if not self.deletable: + raise BadRequest(f'{self.title} managed hub cannot be removed' % self.title) + key = f'/hubs/sections/{self.librarySectionID}/manage/{self.identifier}' + self._server.query(key, method=self._server._session.delete) + + def updateVisibility(self, recommended=None, home=None, shared=None): + """ Update the managed hub's visibility settings. + + Parameters: + recommended (bool): True to make visible on your Library Recommended page. False to hide. Default None. + home (bool): True to make visible on your Home page. False to hide. Default None. + shared (bool): True to make visible on your Friends' Home page. False to hide. Default None. + + Example: + + .. code-block:: python + + managedHub.updateVisibility(recommended=True, home=True, shared=False).reload() + # or using chained methods + managedHub.promoteRecommended().promoteHome().demoteShared().reload() + """ + params = { + 'promotedToRecommended': int(self.promotedToRecommended), + 'promotedToOwnHome': int(self.promotedToOwnHome), + 'promotedToSharedHome': int(self.promotedToSharedHome), + } + if recommended is not None: + params['promotedToRecommended'] = int(recommended) + if home is not None: + params['promotedToOwnHome'] = int(home) + if shared is not None: + params['promotedToSharedHome'] = int(shared) + + if not self._promoted: + params['metadataItemId'] = self.identifier.rsplit('.')[-1] + key = f'/hubs/sections/{self.librarySectionID}/manage' + self._server.query(key, method=self._server._session.post, params=params) + else: + key = f'/hubs/sections/{self.librarySectionID}/manage/{self.identifier}' + self._server.query(key, method=self._server._session.put, params=params) + return self.reload() + + def promoteRecommended(self): + """ Show the managed hub on your Library Recommended Page. """ + return self.updateVisibility(recommended=True) + + def demoteRecommended(self): + """ Hide the managed hub on your Library Recommended Page. """ + return self.updateVisibility(recommended=False) + + def promoteHome(self): + """ Show the managed hub on your Home Page. """ + return self.updateVisibility(home=True) + + def demoteHome(self): + """ Hide the manged hub on your Home Page. """ + return self.updateVisibility(home=False) + + def promoteShared(self): + """ Show the managed hub on your Friends' Home Page. """ + return self.updateVisibility(shared=True) + + def demoteShared(self): + """ Hide the managed hub on your Friends' Home Page. """ + return self.updateVisibility(shared=False) + + class Folder(PlexObject): """ Represents a Folder inside a library. diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 23ac7700..ccdf9fab 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -64,6 +64,7 @@ class Media(PlexObject): self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) self.parts = self.findItems(data, MediaPart) self.proxyType = utils.cast(int, data.attrib.get('proxyType')) + self.selected = utils.cast(bool, data.attrib.get('selected')) self.target = data.attrib.get('target') self.title = data.attrib.get('title') self.videoCodec = data.attrib.get('videoCodec') @@ -71,6 +72,7 @@ class Media(PlexObject): self.videoProfile = data.attrib.get('videoProfile') self.videoResolution = data.attrib.get('videoResolution') self.width = utils.cast(int, data.attrib.get('width')) + self.uuid = data.attrib.get('uuid') if self._isChildOf(etag='Photo'): self.aperture = data.attrib.get('aperture') @@ -89,12 +91,11 @@ class Media(PlexObject): return self.proxyType == utils.SEARCHTYPES['optimizedVersion'] def delete(self): - part = '%s/media/%s' % (self._parentKey, self.id) + part = f'{self._parentKey}/media/{self.id}' try: return self._server.query(part, method=self._server._session.delete) except BadRequest: - log.error("Failed to delete %s. This could be because you haven't allowed " - "items to be deleted" % part) + log.error("Failed to delete %s. This could be because you haven't allowed items to be deleted", part) raise @@ -146,7 +147,9 @@ class MediaPart(PlexObject): self.key = data.attrib.get('key') self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) self.packetLength = utils.cast(int, data.attrib.get('packetLength')) + self.protocol = data.attrib.get('protocol') self.requiredBandwidths = data.attrib.get('requiredBandwidths') + self.selected = utils.cast(bool, data.attrib.get('selected')) self.size = utils.cast(int, data.attrib.get('size')) self.streams = self._buildStreams(data) self.syncItemId = utils.cast(int, data.attrib.get('syncItemId')) @@ -188,10 +191,11 @@ class MediaPart(PlexObject): stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default """ if isinstance(stream, AudioStream): - key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream.id) + key = f"/library/parts/{self.id}?audioStreamID={stream.id}&allParts=1" else: - key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream) + key = f"/library/parts/{self.id}?audioStreamID={stream}&allParts=1" self._server.query(key, method=self._server._session.put) + return self def setDefaultSubtitleStream(self, stream): """ Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart. @@ -200,15 +204,17 @@ class MediaPart(PlexObject): stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default. """ if isinstance(stream, SubtitleStream): - key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream.id) + key = f"/library/parts/{self.id}?subtitleStreamID={stream.id}&allParts=1" else: - key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream) + key = f"/library/parts/{self.id}?subtitleStreamID={stream}&allParts=1" self._server.query(key, method=self._server._session.put) + return self def resetDefaultSubtitleStream(self): """ Set default subtitle of this MediaPart to 'none'. """ - key = "/library/parts/%d?subtitleStreamID=0&allParts=1" % (self.id) + key = f"/library/parts/{self.id}?subtitleStreamID=0&allParts=1" self._server.query(key, method=self._server._session.put) + return self class MediaPartStream(PlexObject): @@ -225,6 +231,7 @@ class MediaPartStream(PlexObject): index (int): The index of the stream. language (str): The language of the stream (ex: English, ไทย). languageCode (str): The ASCII language code of the stream (ex: eng, tha). + languageTag (str): The two letter language tag of the stream (ex: en, fr). requiredBandwidths (str): The required bandwidths to stream the file. selected (bool): True if this stream is selected. streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`, @@ -238,14 +245,17 @@ class MediaPartStream(PlexObject): self._data = data self.bitrate = utils.cast(int, data.attrib.get('bitrate')) self.codec = data.attrib.get('codec') + self.decision = data.attrib.get('decision') self.default = utils.cast(bool, data.attrib.get('default')) self.displayTitle = data.attrib.get('displayTitle') self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle') - self.key = data.attrib.get('key') self.id = utils.cast(int, data.attrib.get('id')) self.index = utils.cast(int, data.attrib.get('index', '-1')) + self.key = data.attrib.get('key') self.language = data.attrib.get('language') self.languageCode = data.attrib.get('languageCode') + self.languageTag = data.attrib.get('languageTag') + self.location = data.attrib.get('location') self.requiredBandwidths = data.attrib.get('requiredBandwidths') self.selected = utils.cast(bool, data.attrib.get('selected', '0')) self.streamType = utils.cast(int, data.attrib.get('streamType')) @@ -570,22 +580,22 @@ class Optimized(PlexObject): """ Returns a list of all :class:`~plexapi.media.Video` objects in this optimized item. """ - key = '%s/%s/items' % (self._initpath, self.id) + key = f'{self._initpath}/{self.id}/items' return self.fetchItems(key) def remove(self): """ Remove an Optimized item""" - key = '%s/%s' % (self._initpath, self.id) + key = f'{self._initpath}/{self.id}' self._server.query(key, method=self._server._session.delete) def rename(self, title): """ Rename an Optimized item""" - key = '%s/%s?Item[title]=%s' % (self._initpath, self.id, title) + key = f'{self._initpath}/{self.id}?Item[title]={title}' self._server.query(key, method=self._server._session.put) def reprocess(self, ratingKey): """ Reprocess a removed Conversion item that is still a listed Optimize item""" - key = '%s/%s/%s/enable' % (self._initpath, self.id, ratingKey) + key = f'{self._initpath}/{self.id}/{ratingKey}/enable' self._server.query(key, method=self._server._session.put) @@ -631,7 +641,7 @@ class Conversion(PlexObject): def remove(self): """ Remove Conversion from queue """ - key = '/playlists/%s/items/%s/%s/disable' % (self.playlistID, self.generatorID, self.ratingKey) + key = f'/playlists/{self.playlistID}/items/{self.generatorID}/{self.ratingKey}/disable' self._server.query(key, method=self._server._session.put) def move(self, after): @@ -646,7 +656,7 @@ class Conversion(PlexObject): conversions[3].move(conversions[1].playQueueItemID) """ - key = '%s/items/%s/move?after=%s' % (self._initpath, self.playQueueItemID, after) + key = f'{self._initpath}/items/{self.playQueueItemID}/move?after={after}' self._server.query(key, method=self._server._session.put) @@ -665,6 +675,10 @@ class MediaTag(PlexObject): thumb (str): URL to thumbnail image for :class:`~plexapi.media.Role` only. """ + def __str__(self): + """ Returns the tag name. """ + return self.tag + def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data @@ -682,14 +696,12 @@ class MediaTag(PlexObject): self._parentType = parent.TYPE if self._librarySectionKey and self.filter: - self.key = '%s/all?%s&type=%s' % ( - self._librarySectionKey, self.filter, utils.searchType(self._parentType)) + self.key = f'{self._librarySectionKey}/all?{self.filter}&type={utils.searchType(self._parentType)}' def items(self): """ Return the list of items within this tag. """ if not self.key: - raise BadRequest('Key is not defined for this tag: %s. ' - 'Reload the parent object.' % self.tag) + raise BadRequest(f'Key is not defined for this tag: {self.tag}. Reload the parent object.') return self.fetchItems(self.key) @@ -707,7 +719,7 @@ class Collection(MediaTag): def collection(self): """ Return the :class:`~plexapi.collection.Collection` object for this collection tag. """ - key = '%s/collections' % self._librarySectionKey + key = f'{self._librarySectionKey}/collections' return self.fetchItem(key, etag='Directory', index=self.id) @@ -871,7 +883,7 @@ class GuidTag(PlexObject): """ Base class for guid tags used only for Guids, as they contain only a string identifier Attributes: - id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB). + id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB, MBID). """ def _loadData(self, data): @@ -938,7 +950,7 @@ class BaseResource(PlexObject): def select(self): key = self._initpath[:-1] - data = '%s?url=%s' % (key, quote_plus(self.ratingKey)) + data = f'{key}?url={quote_plus(self.ratingKey)}' try: self._server.query(data, method=self._server._session.put) except xml.etree.ElementTree.ParseError: @@ -967,7 +979,7 @@ class Theme(BaseResource): @utils.registerPlexObject class Chapter(PlexObject): - """ Represents a single Writer media tag. + """ Represents a single Chapter media tag. Attributes: TAG (str): 'Chapter' @@ -998,8 +1010,8 @@ class Marker(PlexObject): name = self._clean(self.firstAttr('type')) start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start'))) end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end'))) - offsets = '%s-%s' % (start, end) - return '<%s>' % ':'.join([self.__class__.__name__, name, offsets]) + offsets = f'{start}-{end}' + return f"<{':'.join([self.__class__.__name__, name, offsets])}>" def _loadData(self, data): self._data = data @@ -1036,7 +1048,7 @@ class SearchResult(PlexObject): def __repr__(self): name = self._clean(self.firstAttr('name')) score = self._clean(self.firstAttr('score')) - return '<%s>' % ':'.join([p for p in [self.__class__.__name__, name, score] if p]) + return f"<{':'.join([p for p in [self.__class__.__name__, name, score] if p])}>" def _loadData(self, data): self._data = data @@ -1058,7 +1070,7 @@ class Agent(PlexObject): def __repr__(self): uid = self._clean(self.firstAttr('shortIdentifier')) - return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p]) + return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" def _loadData(self, data): self._data = data @@ -1082,7 +1094,7 @@ class Agent(PlexObject): return self.languageCodes def settings(self): - key = '/:/plugins/%s/prefs' % self.identifier + key = f'/:/plugins/{self.identifier}/prefs' data = self._server.query(key) return self.findItems(data, cls=settings.Setting) @@ -1101,7 +1113,7 @@ class AgentMediaType(Agent): def __repr__(self): uid = self._clean(self.firstAttr('name')) - return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p]) + return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" def _loadData(self, data): self.languageCodes = self.listAttrs(data, 'code', etag='Language') diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index 4a6e1f0c..16414cf5 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -27,37 +27,38 @@ class AdvancedSettingsMixin: return next(p for p in prefs if p.id == pref) except StopIteration: availablePrefs = [p.id for p in prefs] - raise NotFound('Unknown preference "%s" for %s. ' - 'Available preferences: %s' - % (pref, self.TYPE, availablePrefs)) from None + raise NotFound(f'Unknown preference "{pref}" for {self.TYPE}. ' + f'Available preferences: {availablePrefs}') from None def editAdvanced(self, **kwargs): """ Edit a Plex object's advanced settings. """ data = {} - key = '%s/prefs?' % self.key + key = f'{self.key}/prefs?' preferences = {pref.id: pref for pref in self.preferences() if pref.enumValues} for settingID, value in kwargs.items(): try: pref = preferences[settingID] except KeyError: - raise NotFound('%s not found in %s' % (value, list(preferences.keys()))) + raise NotFound(f'{value} not found in {list(preferences.keys())}') enumValues = pref.enumValues if enumValues.get(value, enumValues.get(str(value))): data[settingID] = value else: - raise NotFound('%s not found in %s' % (value, list(enumValues))) + raise NotFound(f'{value} not found in {list(enumValues)}') url = key + urlencode(data) self._server.query(url, method=self._server._session.put) + return self def defaultAdvanced(self): """ Edit all of a Plex object's advanced settings to default. """ data = {} - key = '%s/prefs?' % self.key + key = f'{self.key}/prefs?' for preference in self.preferences(): data[preference.id] = preference.default url = key + urlencode(data) self._server.query(url, method=self._server._session.put) + return self class SmartFilterMixin: @@ -125,8 +126,9 @@ class SplitMergeMixin: def split(self): """ Split duplicated Plex object into separate objects. """ - key = '/library/metadata/%s/split' % self.ratingKey - return self._server.query(key, method=self._server._session.put) + key = f'{self.key}/split' + self._server.query(key, method=self._server._session.put) + return self def merge(self, ratingKeys): """ Merge other Plex objects into the current object. @@ -137,8 +139,9 @@ class SplitMergeMixin: if not isinstance(ratingKeys, list): ratingKeys = str(ratingKeys).split(',') - key = '%s/merge?ids=%s' % (self.key, ','.join([str(r) for r in ratingKeys])) - return self._server.query(key, method=self._server._session.put) + key = f"{self.key}/merge?ids={','.join([str(r) for r in ratingKeys])}" + self._server.query(key, method=self._server._session.put) + return self class UnmatchMatchMixin: @@ -146,7 +149,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 +180,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]): @@ -191,7 +194,7 @@ class UnmatchMatchMixin: params['title'] = title if year is None: - params['year'] = self.year + params['year'] = getattr(self, 'year', '') else: params['year'] = year @@ -216,13 +219,13 @@ 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: searchResult = autoMatch[0] else: - raise NotFound('No matches found using this agent: (%s:%s)' % (agent, autoMatch)) + raise NotFound(f'No matches found using this agent: ({agent}:{autoMatch})') elif not searchResult: raise NotFound('fixMatch() requires either auto=True or ' 'searchResult=:class:`~plexapi.media.SearchResult`.') @@ -232,6 +235,7 @@ class UnmatchMatchMixin: data = key + '?' + urlencode(params) self._server.query(data, method=self._server._session.put) + return self class ExtrasMixin: @@ -250,8 +254,48 @@ class HubsMixin: def hubs(self): """ Returns a list of :class:`~plexapi.library.Hub` objects. """ from plexapi.library import Hub - data = self._server.query(self._details_key) - return self.findItems(data, Hub, rtag='Related') + key = f'{self.key}/related' + data = self._server.query(key) + return self.findItems(data, Hub) + + +class PlayedUnplayedMixin: + """ Mixin for Plex objects that can be marked played and unplayed. """ + + @property + def isPlayed(self): + """ Returns True if this video is played. """ + return bool(self.viewCount > 0) if self.viewCount else False + + def markPlayed(self): + """ Mark the Plex object as played. """ + key = '/:/scrobble' + params = {'key': self.ratingKey, 'identifier': 'com.plexapp.plugins.library'} + self._server.query(key, params=params) + return self + + def markUnplayed(self): + """ Mark the Plex object as unplayed. """ + key = '/:/unscrobble' + params = {'key': self.ratingKey, 'identifier': 'com.plexapp.plugins.library'} + self._server.query(key, params=params) + return self + + @property + @deprecated('use "isPlayed" instead', stacklevel=3) + def isWatched(self): + """ Returns True if the show is watched. """ + return self.isPlayed + + @deprecated('use "markPlayed" instead') + def markWatched(self): + """ Mark the video as played. """ + self.markPlayed() + + @deprecated('use "markUnplayed" instead') + def markUnwatched(self): + """ Mark the video as unplayed. """ + self.markUnplayed() class RatingMixin: @@ -270,8 +314,9 @@ class RatingMixin: rating = -1 elif not isinstance(rating, (int, float)) or rating < 0 or rating > 10: raise BadRequest('Rating must be between 0 to 10.') - key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rating) + key = f'/:/rate?key={self.ratingKey}&identifier=com.plexapp.plugins.library&rating={rating}' self._server.query(key, method=self._server._session.put) + return self class ArtUrlMixin: @@ -289,7 +334,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'/library/metadata/{self.ratingKey}/arts', cls=media.Art) def uploadArt(self, url=None, filepath=None): """ Upload a background artwork from a url or filepath. @@ -299,12 +344,13 @@ 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'/library/metadata/{self.ratingKey}/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'/library/metadata/{self.ratingKey}/arts' data = open(filepath, 'rb').read() self._server.query(key, method=self._server._session.post, data=data) + return self def setArt(self, art): """ Set the background artwork for a Plex object. @@ -313,6 +359,7 @@ class ArtMixin(ArtUrlMixin): art (:class:`~plexapi.media.Art`): The art object to select. """ art.select() + return self def lockArt(self): """ Lock the background artwork for a Plex object. """ @@ -338,7 +385,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'/library/metadata/{self.ratingKey}/banners', cls=media.Banner) def uploadBanner(self, url=None, filepath=None): """ Upload a banner from a url or filepath. @@ -348,12 +395,13 @@ 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'/library/metadata/{self.ratingKey}/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'/library/metadata/{self.ratingKey}/banners' data = open(filepath, 'rb').read() self._server.query(key, method=self._server._session.post, data=data) + return self def setBanner(self, banner): """ Set the banner for a Plex object. @@ -362,6 +410,7 @@ class BannerMixin(BannerUrlMixin): banner (:class:`~plexapi.media.Banner`): The banner object to select. """ banner.select() + return self def lockBanner(self): """ Lock the banner for a Plex object. """ @@ -392,7 +441,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'/library/metadata/{self.ratingKey}/posters', cls=media.Poster) def uploadPoster(self, url=None, filepath=None): """ Upload a poster from a url or filepath. @@ -402,12 +451,13 @@ 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'/library/metadata/{self.ratingKey}/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'/library/metadata/{self.ratingKey}/posters' data = open(filepath, 'rb').read() self._server.query(key, method=self._server._session.post, data=data) + return self def setPoster(self, poster): """ Set the poster for a Plex object. @@ -416,6 +466,7 @@ class PosterMixin(PosterUrlMixin): poster (:class:`~plexapi.media.Poster`): The poster object to select. """ poster.select() + return self def lockPoster(self): """ Lock the poster for a Plex object. """ @@ -441,7 +492,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'/library/metadata/{self.ratingKey}/themes', cls=media.Theme) def uploadTheme(self, url=None, filepath=None): """ Upload a theme from url or filepath. @@ -453,12 +504,13 @@ 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'/library/metadata/{self.ratingKey}/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'/library/metadata/{self.ratingKey}/themes' data = open(filepath, 'rb').read() self._server.query(key, method=self._server._session.post, data=data) + return self def setTheme(self, theme): raise NotImplementedError( @@ -468,11 +520,11 @@ class ThemeMixin(ThemeUrlMixin): def lockTheme(self): """ Lock the theme for a Plex object. """ - self._edit(**{'theme.locked': 1}) + return self._edit(**{'theme.locked': 1}) def unlockTheme(self): """ Unlock the theme for a Plex object. """ - self._edit(**{'theme.locked': 0}) + return self._edit(**{'theme.locked': 0}) class EditFieldMixin: @@ -496,8 +548,8 @@ class EditFieldMixin: """ edits = { - '%s.value' % field: value or '', - '%s.locked' % field: 1 if locked else 0 + f'{field}.value': value or '', + f'{field}.locked': 1 if locked else 0 } edits.update(kwargs) return self._edit(**edits) @@ -516,6 +568,19 @@ class ContentRatingMixin(EditFieldMixin): return self.editField('contentRating', contentRating, locked=locked) +class EditionTitleMixin(EditFieldMixin): + """ Mixin for Plex objects that can have an edition title. """ + + def editEditionTitle(self, editionTitle, locked=True): + """ Edit the edition title. Plex Pass is required to edit this field. + + Parameters: + editionTitle (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('editionTitle', editionTitle, locked=locked) + + class OriginallyAvailableMixin(EditFieldMixin): """ Mixin for Plex objects that can have an originally available date. """ @@ -680,7 +745,7 @@ class EditTagsMixin: Parameters: tag (str): Name of the tag to edit. - items (List): List of tags to add or remove. + items (List or List<:class:`~plexapi.media.MediaTag`>): List of tags to add or remove. locked (bool): True (default) to lock the tags, False to unlock the tags. remove (bool): True to remove the tags in items. @@ -695,9 +760,11 @@ class EditTagsMixin: if not isinstance(items, list): items = [items] - value = getattr(self, self._tagPlural(tag)) - existing_tags = [t.tag for t in value if t and remove is False] - edits = self._tagHelper(self._tagSingular(tag), existing_tags + items, locked, remove) + if not remove: + tags = getattr(self, self._tagPlural(tag)) + items = tags + items + + edits = self._tagHelper(self._tagSingular(tag), items, locked, remove) edits.update(kwargs) return self._edit(**edits) @@ -730,15 +797,15 @@ class EditTagsMixin: items = [items] data = { - '%s.locked' % tag: 1 if locked else 0 + f'{tag}.locked': 1 if locked else 0 } if remove: - tagname = '%s[].tag.tag-' % tag - data[tagname] = ','.join(items) + tagname = f'{tag}[].tag.tag-' + data[tagname] = ','.join([str(t) for t in items]) else: for i, item in enumerate(items): - tagname = '%s[%s].tag.tag' % (tag, i) + tagname = f'{str(tag)}[{i}].tag.tag' data[tagname] = item return data @@ -751,7 +818,7 @@ class CollectionMixin(EditTagsMixin): """ Add a collection tag(s). Parameters: - collections (list): List of strings. + collections (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('collection', collections, locked=locked) @@ -760,7 +827,7 @@ class CollectionMixin(EditTagsMixin): """ Remove a collection tag(s). Parameters: - collections (list): List of strings. + collections (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('collection', collections, locked=locked, remove=True) @@ -773,7 +840,7 @@ class CountryMixin(EditTagsMixin): """ Add a country tag(s). Parameters: - countries (list): List of strings. + countries (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('country', countries, locked=locked) @@ -782,7 +849,7 @@ class CountryMixin(EditTagsMixin): """ Remove a country tag(s). Parameters: - countries (list): List of strings. + countries (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('country', countries, locked=locked, remove=True) @@ -795,7 +862,7 @@ class DirectorMixin(EditTagsMixin): """ Add a director tag(s). Parameters: - directors (list): List of strings. + directors (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('director', directors, locked=locked) @@ -804,7 +871,7 @@ class DirectorMixin(EditTagsMixin): """ Remove a director tag(s). Parameters: - directors (list): List of strings. + directors (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('director', directors, locked=locked, remove=True) @@ -817,7 +884,7 @@ class GenreMixin(EditTagsMixin): """ Add a genre tag(s). Parameters: - genres (list): List of strings. + genres (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('genre', genres, locked=locked) @@ -826,7 +893,7 @@ class GenreMixin(EditTagsMixin): """ Remove a genre tag(s). Parameters: - genres (list): List of strings. + genres (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('genre', genres, locked=locked, remove=True) @@ -839,7 +906,7 @@ class LabelMixin(EditTagsMixin): """ Add a label tag(s). Parameters: - labels (list): List of strings. + labels (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('label', labels, locked=locked) @@ -848,7 +915,7 @@ class LabelMixin(EditTagsMixin): """ Remove a label tag(s). Parameters: - labels (list): List of strings. + labels (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('label', labels, locked=locked, remove=True) @@ -861,7 +928,7 @@ class MoodMixin(EditTagsMixin): """ Add a mood tag(s). Parameters: - moods (list): List of strings. + moods (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('mood', moods, locked=locked) @@ -870,7 +937,7 @@ class MoodMixin(EditTagsMixin): """ Remove a mood tag(s). Parameters: - moods (list): List of strings. + moods (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('mood', moods, locked=locked, remove=True) @@ -883,7 +950,7 @@ class ProducerMixin(EditTagsMixin): """ Add a producer tag(s). Parameters: - producers (list): List of strings. + producers (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('producer', producers, locked=locked) @@ -892,7 +959,7 @@ class ProducerMixin(EditTagsMixin): """ Remove a producer tag(s). Parameters: - producers (list): List of strings. + producers (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('producer', producers, locked=locked, remove=True) @@ -905,7 +972,7 @@ class SimilarArtistMixin(EditTagsMixin): """ Add a similar artist tag(s). Parameters: - artists (list): List of strings. + artists (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('similar', artists, locked=locked) @@ -914,7 +981,7 @@ class SimilarArtistMixin(EditTagsMixin): """ Remove a similar artist tag(s). Parameters: - artists (list): List of strings. + artists (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('similar', artists, locked=locked, remove=True) @@ -927,7 +994,7 @@ class StyleMixin(EditTagsMixin): """ Add a style tag(s). Parameters: - styles (list): List of strings. + styles (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('style', styles, locked=locked) @@ -936,7 +1003,7 @@ class StyleMixin(EditTagsMixin): """ Remove a style tag(s). Parameters: - styles (list): List of strings. + styles (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('style', styles, locked=locked, remove=True) @@ -949,7 +1016,7 @@ class TagMixin(EditTagsMixin): """ Add a tag(s). Parameters: - tags (list): List of strings. + tags (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('tag', tags, locked=locked) @@ -958,7 +1025,7 @@ class TagMixin(EditTagsMixin): """ Remove a tag(s). Parameters: - tags (list): List of strings. + tags (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('tag', tags, locked=locked, remove=True) @@ -971,7 +1038,7 @@ class WriterMixin(EditTagsMixin): """ Add a writer tag(s). Parameters: - writers (list): List of strings. + writers (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('writer', writers, locked=locked) @@ -980,7 +1047,7 @@ class WriterMixin(EditTagsMixin): """ Remove a writer tag(s). Parameters: - writers (list): List of strings. + writers (List or List<:class:`~plexapi.media.MediaTag`>): List of tags. locked (bool): True (default) to lock the field, False to unlock the field. """ return self.editTags('writer', writers, locked=locked, remove=True) @@ -1016,6 +1083,7 @@ class WatchlistMixin: except AttributeError: account = self._server account.addToWatchlist(self) + return self def removeFromWatchlist(self, account=None): """ Remove this item from the specified user's watchlist. @@ -1030,6 +1098,7 @@ class WatchlistMixin: except AttributeError: account = self._server account.removeFromWatchlist(self) + return self def streamingServices(self, account=None): """ Return a list of :class:`~plexapi.media.Availability` diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index b7e72d0f..7bfbe7ab 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -3,11 +3,12 @@ import copy import html import threading import time +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from xml.etree import ElementTree import requests -from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, - X_PLEX_IDENTIFIER, log, logfilter, utils) +from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, + X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, log, logfilter, utils) from plexapi.base import PlexObject from plexapi.client import PlexClient from plexapi.exceptions import BadRequest, NotFound, Unauthorized @@ -47,6 +48,7 @@ class MyPlexAccount(PlexObject): locale (str): Your Plex locale mailing_list_status (str): Your current mailing list status. maxHomeSize (int): Unknown. + pin (str): The hashed Plex Home PIN. queueEmail (str): Email address to add items to your `Watch Later` queue. queueUid (str): Unknown. restricted (bool): Unknown. @@ -65,16 +67,19 @@ class MyPlexAccount(PlexObject): _session (obj): Requests session object used to access this client. """ FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data + HOMEUSERS = 'https://plex.tv/api/home/users' HOMEUSERCREATE = 'https://plex.tv/api/home/users?title={title}' # post with data EXISTINGUSER = 'https://plex.tv/api/home/users?invitedEmail={username}' # post with data FRIENDSERVERS = 'https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}' # put with data PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete - REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete + HOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete, put + MANAGEDHOMEUSER = 'https://plex.tv/api/v2/home/users/restricted/{userId}' # put SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get LINK = 'https://plex.tv/api/v2/pins/link' # put + VIEWSTATESYNC = 'https://plex.tv/api/v2/user/view_state_sync' # put # Hub sections VOD = 'https://vod.provider.plex.tv' # get MUSIC = 'https://music.provider.plex.tv' # get @@ -115,6 +120,7 @@ class MyPlexAccount(PlexObject): self.locale = data.attrib.get('locale') self.mailing_list_status = data.attrib.get('mailing_list_status') self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize')) + self.pin = data.attrib.get('pin') self.queueEmail = data.attrib.get('queueEmail') self.queueUid = data.attrib.get('queueUid') self.restricted = utils.cast(bool, data.attrib.get('restricted')) @@ -150,7 +156,7 @@ class MyPlexAccount(PlexObject): for device in self.devices(): if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId): return device - raise NotFound('Unable to find device %s' % name) + raise NotFound(f'Unable to find device {name}') def devices(self): """ Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """ @@ -174,7 +180,7 @@ class MyPlexAccount(PlexObject): if response.status_code not in (200, 201, 204): # pragma: no cover codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) + message = f'({response.status_code}) {codename}; {response.url} {errtext}' if response.status_code == 401: raise Unauthorized(message) elif response.status_code == 404: @@ -195,7 +201,7 @@ class MyPlexAccount(PlexObject): for resource in self.resources(): if resource.name.lower() == name.lower(): return resource - raise NotFound('Unable to find resource %s' % name) + raise NotFound(f'Unable to find resource {name}') def resources(self): """ Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """ @@ -366,7 +372,8 @@ class MyPlexAccount(PlexObject): """ Remove the specified user from your friends. Parameters: - user (str): :class:`~plexapi.myplex.MyPlexUser`, username, or email of the user to be removed. + user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`, + username, or email of the user to be removed. """ user = user if isinstance(user, MyPlexUser) else self.user(user) url = self.FRIENDUPDATE.format(userId=user.id) @@ -376,17 +383,89 @@ class MyPlexAccount(PlexObject): """ Remove the specified user from your home users. Parameters: - user (str): :class:`~plexapi.myplex.MyPlexUser`, username, or email of the user to be removed. + user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`, + username, or email of the user to be removed. """ user = user if isinstance(user, MyPlexUser) else self.user(user) - url = self.REMOVEHOMEUSER.format(userId=user.id) + url = self.HOMEUSER.format(userId=user.id) return self.query(url, self._session.delete) - def acceptInvite(self, user): - """ Accept a pending firend invite from the specified user. + def switchHomeUser(self, user): + """ Returns a new :class:`~plexapi.myplex.MyPlexAccount` object switched to the given home user. Parameters: - user (str): :class:`~plexapi.myplex.MyPlexInvite`, username, or email of the friend invite to accept. + user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`, + username, or email of the home user to switch to. + + Example: + + .. code-block:: python + + from plexapi.myplex import MyPlexAccount + # Login to a Plex Home account + account = MyPlexAccount('', '') + # Switch to a different Plex Home user + userAccount = account.switchHomeUser('Username') + + """ + user = user if isinstance(user, MyPlexUser) else self.user(user) + url = f'{self.HOMEUSERS}/{user.id}/switch' + data = self.query(url, self._session.post) + userToken = data.attrib.get('authenticationToken') + return MyPlexAccount(token=userToken) + + def setPin(self, newPin, currentPin=None): + """ Set a new Plex Home PIN for the account. + + Parameters: + newPin (str): New PIN to set for the account. + currentPin (str): Current PIN for the account (required to change the PIN). + """ + url = self.HOMEUSER.format(userId=self.id) + params = {'pin': newPin} + if currentPin: + params['currentPin'] = currentPin + return self.query(url, self._session.put, params=params) + + def removePin(self, currentPin): + """ Remove the Plex Home PIN for the account. + + Parameters: + currentPin (str): Current PIN for the account (required to remove the PIN). + """ + return self.setPin('', currentPin) + + def setManagedUserPin(self, user, newPin): + """ Set a new Plex Home PIN for a managed home user. This must be done from the Plex Home admin account. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser` + or username of the managed home user. + newPin (str): New PIN to set for the managed home user. + """ + user = user if isinstance(user, MyPlexUser) else self.user(user) + url = self.MANAGEDHOMEUSER.format(userId=user.id) + params = {'pin': newPin} + return self.query(url, self._session.post, params=params) + + def removeManagedUserPin(self, user): + """ Remove the Plex Home PIN for a managed home user. This must be done from the Plex Home admin account. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser` + or username of the managed home user. + """ + user = user if isinstance(user, MyPlexUser) else self.user(user) + url = self.MANAGEDHOMEUSER.format(userId=user.id) + params = {'removePin': 1} + return self.query(url, self._session.post, params=params) + + def acceptInvite(self, user): + """ Accept a pending friend invite from the specified user. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexInvite` or str): :class:`~plexapi.myplex.MyPlexInvite`, + username, or email of the friend invite to accept. """ invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeSent=False) params = { @@ -394,14 +473,15 @@ class MyPlexAccount(PlexObject): 'home': int(invite.home), 'server': int(invite.server) } - url = MyPlexInvite.REQUESTS + '/%s' % invite.id + utils.joinArgs(params) + url = MyPlexInvite.REQUESTS + f'/{invite.id}' + utils.joinArgs(params) return self.query(url, self._session.put) def cancelInvite(self, user): """ Cancel a pending firend invite for the specified user. Parameters: - user (str): :class:`~plexapi.myplex.MyPlexInvite`, username, or email of the friend invite to cancel. + user (:class:`~plexapi.myplex.MyPlexInvite` or str): :class:`~plexapi.myplex.MyPlexInvite`, + username, or email of the friend invite to cancel. """ invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeReceived=False) params = { @@ -409,7 +489,7 @@ class MyPlexAccount(PlexObject): 'home': int(invite.home), 'server': int(invite.server) } - url = MyPlexInvite.REQUESTED + '/%s' % invite.id + utils.joinArgs(params) + url = MyPlexInvite.REQUESTED + f'/{invite.id}' + utils.joinArgs(params) return self.query(url, self._session.delete) def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None, @@ -497,7 +577,7 @@ class MyPlexAccount(PlexObject): (user.username.lower(), user.email.lower(), str(user.id))): return user - raise NotFound('Unable to find user %s' % username) + raise NotFound(f'Unable to find user {username}') def users(self): """ Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account. @@ -520,7 +600,7 @@ class MyPlexAccount(PlexObject): (invite.username.lower(), invite.email.lower(), str(invite.id))): return invite - raise NotFound('Unable to find invite %s' % username) + raise NotFound(f'Unable to find invite {username}') def pendingInvites(self, includeSent=True, includeReceived=True): """ Returns a list of all :class:`~plexapi.myplex.MyPlexInvite` objects connected to your account. @@ -545,7 +625,7 @@ class MyPlexAccount(PlexObject): # Get a list of all section ids for looking up each section. allSectionIds = {} machineIdentifier = server.machineIdentifier if isinstance(server, PlexServer) else server - url = self.PLEXSERVERS.replace('{machineId}', machineIdentifier) + url = self.PLEXSERVERS.format(machineId=machineIdentifier) data = self.query(url, self._session.get) for elem in data[0]: _id = utils.cast(int, elem.attrib.get('id')) @@ -567,8 +647,8 @@ class MyPlexAccount(PlexObject): values = [] for key, vals in filterDict.items(): if key not in ('contentRating', 'label', 'contentRating!', 'label!'): - raise BadRequest('Unknown filter key: %s', key) - values.append('%s=%s' % (key, '%2C'.join(vals))) + raise BadRequest(f'Unknown filter key: {key}') + values.append(f"{key}={'%2C'.join(vals)}") return '|'.join(values) def addWebhook(self, url): @@ -579,12 +659,12 @@ class MyPlexAccount(PlexObject): def deleteWebhook(self, url): urls = copy.copy(self._webhooks) if url not in urls: - raise BadRequest('Webhook does not exist: %s' % url) + raise BadRequest(f'Webhook does not exist: {url}') urls.remove(url) return self.setWebhooks(urls) def setWebhooks(self, urls): - log.info('Setting webhooks: %s' % urls) + log.info('Setting webhooks: %s', urls) data = {'urls[]': urls} if len(urls) else {'urls': ''} data = self.query(self.WEBHOOKS, self._session.post, data=data) self._webhooks = self.listAttrs(data, 'url', etag='webhook') @@ -655,7 +735,7 @@ class MyPlexAccount(PlexObject): break if not client: - raise BadRequest('Unable to find client by clientId=%s', clientId) + raise BadRequest(f'Unable to find client by clientId={clientId}') if 'sync-target' not in client.provides: raise BadRequest("Received client doesn't provides sync-target") @@ -694,7 +774,7 @@ class MyPlexAccount(PlexObject): if response.status_code not in (200, 201, 204): # pragma: no cover codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}') return response.json()['token'] def history(self, maxresults=9999999, mindate=None): @@ -730,7 +810,7 @@ class MyPlexAccount(PlexObject): data = self.query(f'{self.MUSIC}/hubs') return self.findItems(data) - def watchlist(self, filter=None, sort=None, libtype=None, **kwargs): + def watchlist(self, filter=None, sort=None, libtype=None, maxresults=9999999, **kwargs): """ Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` items in the user's watchlist. Note: The objects returned are from Plex's online metadata. To get the matching item on a Plex server, search for the media using the guid. @@ -742,6 +822,7 @@ class MyPlexAccount(PlexObject): ``titleSort`` (Title), ``originallyAvailableAt`` (Release Date), or ``rating`` (Critic Rating). ``dir`` can be ``asc`` or ``desc``. libtype (str, optional): 'movie' or 'show' to only return movies or shows, otherwise return all items. + maxresults (int, optional): Only return the specified number of results. **kwargs (dict): Additional custom filters to apply to the search results. @@ -769,9 +850,18 @@ class MyPlexAccount(PlexObject): if libtype: params['type'] = utils.searchType(libtype) + params['X-Plex-Container-Start'] = 0 + params['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults) params.update(kwargs) - data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params) - return self.findItems(data) + + results, subresults = [], '_init' + while subresults and maxresults > len(results): + data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params) + subresults = self.findItems(data) + results += subresults[:maxresults - len(results)] + params['X-Plex-Container-Start'] += params['X-Plex-Container-Size'] + + return self._toOnlineMetadata(results) def onWatchlist(self, item): """ Returns True if the item is on the user's watchlist. @@ -780,9 +870,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 @@ -800,9 +888,10 @@ class MyPlexAccount(PlexObject): for item in items: if self.onWatchlist(item): - raise BadRequest('"%s" is already on the watchlist' % item.title) + raise BadRequest(f'"{item.title}" is already on the watchlist') ratingKey = item.guid.rsplit('/', 1)[-1] self.query(f'{self.METADATA}/actions/addToWatchlist?ratingKey={ratingKey}', method=self._session.put) + return self def removeFromWatchlist(self, items): """ Remove media items from the user's watchlist @@ -820,33 +909,49 @@ class MyPlexAccount(PlexObject): for item in items: if not self.onWatchlist(item): - raise BadRequest('"%s" is not on the watchlist' % item.title) + raise BadRequest(f'"{item.title}" is not on the watchlist') ratingKey = item.guid.rsplit('/', 1)[-1] self.query(f'{self.METADATA}/actions/removeFromWatchlist?ratingKey={ratingKey}', method=self._session.put) + return self - 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") + return self.findItem(data, cls=UserState) + + 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.get('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': @@ -859,7 +964,33 @@ class MyPlexAccount(PlexObject): xml = f'<{tag} {attrs}/>' results.append(self._manuallyLoadXML(xml)) - return results + return self._toOnlineMetadata(results) + + @property + def viewStateSync(self): + """ Returns True or False if syncing of watch state and ratings + is enabled or disabled, respectively, for the account. + """ + headers = {'Accept': 'application/json'} + data = self.query(self.VIEWSTATESYNC, headers=headers) + return data.get('consent') + + def enableViewStateSync(self): + """ Enable syncing of watch state and ratings for the account. """ + self._updateViewStateSync(True) + + def disableViewStateSync(self): + """ Disable syncing of watch state and ratings for the account. """ + self._updateViewStateSync(False) + + def _updateViewStateSync(self, consent): + """ Enable or disable syncing of watch state and ratings for the account. + + Parameters: + consent (bool): True to enable, False to disable. + """ + params = {'consent': consent} + self.query(self.VIEWSTATESYNC, method=self._session.put, params=params) def link(self, pin): """ Link a device to the account using a pin code. @@ -874,6 +1005,26 @@ 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 + server = PlexServer(self.METADATA, self._token) + + if not isinstance(objs, list): + objs = [objs] + for obj in objs: + obj._server = server + + # 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 @@ -937,7 +1088,7 @@ class MyPlexUser(PlexObject): if utils.cast(int, item.attrib.get('userID')) == self.id: return item.attrib.get('accessToken') except Exception: - log.exception('Failed to get access token for %s' % self.title) + log.exception('Failed to get access token for %s', self.title) def server(self, name): """ Returns the :class:`~plexapi.myplex.MyPlexServerShare` that matches the name specified. @@ -949,7 +1100,7 @@ class MyPlexUser(PlexObject): if name.lower() == server.name.lower(): return server - raise NotFound('Unable to find server %s' % name) + raise NotFound(f'Unable to find server {name}') def history(self, maxresults=9999999, mindate=None): """ Get all Play History for a user in all shared servers. @@ -1077,7 +1228,7 @@ class MyPlexServerShare(PlexObject): if name.lower() == section.title.lower(): return section - raise NotFound('Unable to find section %s' % name) + raise NotFound(f'Unable to find section {name}') def sections(self): """ Returns a list of all :class:`~plexapi.myplex.Section` objects shared with this user. @@ -1158,7 +1309,6 @@ class MyPlexResource(PlexObject): def preferred_connections( self, ssl=None, - timeout=None, locations=DEFAULT_LOCATION_ORDER, schemes=DEFAULT_SCHEME_ORDER, ): @@ -1170,7 +1320,6 @@ class MyPlexResource(PlexObject): ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to only connect to HTTP connections. Set None (default) to connect to any HTTP or HTTPS connection. - timeout (int, optional): The timeout in seconds to attempt each connection. """ connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations} for connection in self.connections: @@ -1212,7 +1361,7 @@ class MyPlexResource(PlexObject): Raises: :exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. """ - connections = self.preferred_connections(ssl, timeout, locations, schemes) + connections = self.preferred_connections(ssl, locations, schemes) # Try connecting to all known resource connections in parallel, but # only return the first server (in order) that provides a response. cls = PlexServer if 'server' in self.provides else PlexClient @@ -1244,7 +1393,7 @@ class ResourceConnection(PlexObject): self.port = utils.cast(int, data.attrib.get('port')) self.uri = data.attrib.get('uri') self.local = utils.cast(bool, data.attrib.get('local')) - self.httpuri = 'http://%s:%s' % (self.address, self.port) + self.httpuri = f'http://{self.address}:{self.port}' self.relay = utils.cast(bool, data.attrib.get('relay')) @@ -1318,7 +1467,7 @@ class MyPlexDevice(PlexObject): def delete(self): """ Remove this device from your account. """ - key = 'https://plex.tv/devices/%s.xml' % self.id + key = f'https://plex.tv/devices/{self.id}.xml' self._server.query(key, self._server._session.delete) def syncItems(self): @@ -1355,11 +1504,12 @@ class MyPlexPinLogin: session (requests.Session, optional): Use your own session object if you want to cache the http responses from PMS requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT). + headers (dict): A dict of X-Plex headers to send with requests. + oauth (bool): True to use Plex OAuth instead of PIN login. Attributes: PINS (str): 'https://plex.tv/api/v2/pins' CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}' - LINK (str): 'https://plex.tv/api/v2/pins/link' POLLINTERVAL (int): 1 finished (bool): Whether the pin login has finished or not. expired (bool): Whether the pin login has expired or not. @@ -1370,12 +1520,13 @@ class MyPlexPinLogin: CHECKPINS = 'https://plex.tv/api/v2/pins/{pinid}' # get POLLINTERVAL = 1 - def __init__(self, session=None, requestTimeout=None, headers=None): + def __init__(self, session=None, requestTimeout=None, headers=None, oauth=False): super(MyPlexPinLogin, self).__init__() self._session = session or requests.Session() self._requestTimeout = requestTimeout or TIMEOUT self.headers = headers + self._oauth = oauth self._loginTimeout = None self._callback = None self._thread = None @@ -1390,8 +1541,36 @@ class MyPlexPinLogin: @property def pin(self): + """ Return the 4 character PIN used for linking a device at https://plex.tv/link. """ + if self._oauth: + raise BadRequest('Cannot use PIN for Plex OAuth login') return self._code + def oauthUrl(self, forwardUrl=None): + """ Return the Plex OAuth url for login. + + Parameters: + forwardUrl (str, optional): The url to redirect the client to after login. + """ + if not self._oauth: + raise BadRequest('Must use "MyPlexPinLogin(oauth=True)" for Plex OAuth login.') + + headers = self._headers() + params = { + 'clientID': headers['X-Plex-Client-Identifier'], + 'context[device][product]': headers['X-Plex-Product'], + 'context[device][version]': headers['X-Plex-Version'], + 'context[device][platform]': headers['X-Plex-Platform'], + 'context[device][platformVersion]': headers['X-Plex-Platform-Version'], + 'context[device][device]': headers['X-Plex-Device'], + 'context[device][deviceName]': headers['X-Plex-Device-Name'], + 'code': self._code + } + if forwardUrl: + params['forwardUrl'] = forwardUrl + + return f'https://app.plex.tv/auth/#!?{urlencode(params)}' + def run(self, callback=None, timeout=None): """ Starts the thread which monitors the PIN login state. Parameters: @@ -1455,7 +1634,13 @@ class MyPlexPinLogin: def _getCode(self): url = self.PINS - response = self._query(url, self._session.post) + + if self._oauth: + params = {'strong': True} + else: + params = None + + response = self._query(url, self._session.post, params=params) if not response: return None @@ -1520,7 +1705,7 @@ class MyPlexPinLogin: if not response.ok: # pragma: no cover codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}') data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None @@ -1564,7 +1749,7 @@ def _chooseConnection(ctype, name, results): if results: log.debug('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token) return results[0] - raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name)) + raise NotFound(f'Unable to connect to {ctype.lower()}: {name}') class AccountOptOut(PlexObject): @@ -1593,8 +1778,8 @@ class AccountOptOut(PlexObject): :exc:`~plexapi.exceptions.NotFound`: ``option`` str not found in CHOICES. """ if option not in self.CHOICES: - raise NotFound('%s not found in available choices: %s' % (option, self.CHOICES)) - url = self._server.OPTOUTS % {'userUUID': self._server.uuid} + raise NotFound(f'{option} not found in available choices: {self.CHOICES}') + url = self._server.OPTOUTS.format(userUUID=self._server.uuid) params = {'key': self.key, 'value': option} self._server.query(url, method=self._server._session.post, params=params) self.value = option # assume query successful and set the value to option @@ -1614,5 +1799,35 @@ class AccountOptOut(PlexObject): :exc:`~plexapi.exceptions.BadRequest`: When trying to opt out 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(f'{self.key} does not have the option to opt out managed users.') 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')) diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index cb6329ea..28556650 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -3,7 +3,7 @@ import os from urllib.parse import quote_plus from plexapi import media, utils, video -from plexapi.base import Playable, PlexPartialObject +from plexapi.base import Playable, PlexPartialObject, PlexSession from plexapi.exceptions import BadRequest from plexapi.mixins import ( RatingMixin, @@ -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): @@ -226,7 +226,7 @@ class Photo( def _prettyfilename(self): """ Returns a filename for use in download. """ if self.parentTitle: - return '%s - %s' % (self.parentTitle, self.title) + return f'{self.parentTitle} - {self.title}' return self.title def photoalbum(self): @@ -282,7 +282,7 @@ class Photo( section = self.section() - sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key)) + sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}' sync_item.policy = Policy.create(limit) sync_item.mediaSettings = MediaSettings.createPhoto(resolution) @@ -291,3 +291,16 @@ class Photo( def _getWebURL(self, base=None): """ Get the Plex Web URL with the correct parameters. """ return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1) + + +@utils.registerPlexObject +class PhotoSession(PlexSession, Photo): + """ Represents a single Photo session + loaded from :func:`~plexapi.server.PlexServer.sessions`. + """ + _SESSIONTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Photo._loadData(self, data) + PlexSession._loadData(self, data) diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index 5241e261..b4174c84 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -127,7 +127,7 @@ class Playlist( for _item in self.items(): if _item.ratingKey == item.ratingKey: return _item.playlistItemID - raise NotFound('Item with title "%s" not found in the playlist' % item.title) + raise NotFound(f'Item with title "{item.title}" not found in the playlist') def filters(self): """ Returns the search filter dict for smart playlist. @@ -177,14 +177,14 @@ class Playlist( for item in self.items(): if item.title.lower() == title.lower(): return item - raise NotFound('Item with title "%s" not found in the playlist' % title) + raise NotFound(f'Item with title "{title}" not found in the playlist') def items(self): """ Returns a list of all items in the playlist. """ if self.radio: return [] if self._items is None: - key = '%s/items' % self.key + key = f'{self.key}/items' items = self.fetchItems(key) self._items = items return self._items @@ -212,17 +212,17 @@ class Playlist( ratingKeys = [] for item in items: if item.listType != self.playlistType: # pragma: no cover - raise BadRequest('Can not mix media types when building a playlist: %s and %s' % - (self.playlistType, item.listType)) + raise BadRequest(f'Can not mix media types when building a playlist: ' + f'{self.playlistType} and {item.listType}') ratingKeys.append(str(item.ratingKey)) ratingKeys = ','.join(ratingKeys) - uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys) + uri = f'{self._server._uriRoot()}/library/metadata/{ratingKeys}' - key = '%s/items%s' % (self.key, utils.joinArgs({ - 'uri': uri - })) + args = {'uri': uri} + key = f"{self.key}/items{utils.joinArgs(args)}" self._server.query(key, method=self._server._session.put) + return self @deprecated('use "removeItems" instead', stacklevel=3) def removeItem(self, item): @@ -247,8 +247,9 @@ class Playlist( for item in items: playlistItemID = self._getPlaylistItemID(item) - key = '%s/items/%s' % (self.key, playlistItemID) + key = f'{self.key}/items/{playlistItemID}' self._server.query(key, method=self._server._session.delete) + return self def moveItem(self, item, after=None): """ Move an item to a new position in the playlist. @@ -267,13 +268,14 @@ class Playlist( raise BadRequest('Cannot move items in a smart playlist.') playlistItemID = self._getPlaylistItemID(item) - key = '%s/items/%s/move' % (self.key, playlistItemID) + key = f'{self.key}/items/{playlistItemID}/move' if after: afterPlaylistItemID = self._getPlaylistItemID(after) - key += '?after=%s' % afterPlaylistItemID + key += f'?after={afterPlaylistItemID}' self._server.query(key, method=self._server._session.put) + return self def updateFilters(self, limit=None, sort=None, filters=None, **kwargs): """ Update the filters for a smart playlist. @@ -297,17 +299,18 @@ class Playlist( section = self.section() searchKey = section._buildSearchKey( sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs) - uri = '%s%s' % (self._server._uriRoot(), searchKey) + uri = f'{self._server._uriRoot()}{searchKey}' - key = '%s/items%s' % (self.key, utils.joinArgs({ - 'uri': uri - })) + args = {'uri': uri} + key = f"{self.key}/items{utils.joinArgs(args)}" self._server.query(key, method=self._server._session.put) + return self def _edit(self, **kwargs): """ Actually edit the playlist. """ - key = '%s%s' % (self.key, utils.joinArgs(kwargs)) + key = f'{self.key}{utils.joinArgs(kwargs)}' self._server.query(key, method=self._server._session.put) + return self def edit(self, title=None, summary=None): """ Edit the playlist. @@ -321,7 +324,7 @@ class Playlist( args['title'] = title if summary: args['summary'] = summary - self._edit(**args) + return self._edit(**args) def delete(self): """ Delete the playlist. """ @@ -348,14 +351,10 @@ class Playlist( ratingKeys.append(str(item.ratingKey)) ratingKeys = ','.join(ratingKeys) - uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys) + uri = f'{server._uriRoot()}/library/metadata/{ratingKeys}' - key = '/playlists%s' % utils.joinArgs({ - 'uri': uri, - 'type': listType, - 'title': title, - 'smart': 0 - }) + args = {'uri': uri, 'type': listType, 'title': title, 'smart': 0} + key = f"/playlists{utils.joinArgs(args)}" data = server.query(key, method=server._session.post)[0] return cls(server, data, initpath=key) @@ -369,14 +368,10 @@ class Playlist( searchKey = section._buildSearchKey( sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) - uri = '%s%s' % (server._uriRoot(), searchKey) + uri = f'{server._uriRoot()}{searchKey}' - key = '/playlists%s' % utils.joinArgs({ - 'uri': uri, - 'type': section.CONTENT_TYPE, - 'title': title, - 'smart': 1, - }) + args = {'uri': uri, 'type': section.CONTENT_TYPE, 'title': title, 'smart': 1} + key = f"/playlists{utils.joinArgs(args)}" data = server.query(key, method=server._session.post)[0] return cls(server, data, initpath=key) @@ -465,7 +460,7 @@ class Playlist( sync_item.metadataType = self.metadataType sync_item.machineIdentifier = self._server.machineIdentifier - sync_item.location = 'playlist:///%s' % quote_plus(self.guid) + sync_item.location = f'playlist:///{quote_plus(self.guid)}' sync_item.policy = Policy.create(limit, unwatched) if self.isVideo: diff --git a/lib/plexapi/playqueue.py b/lib/plexapi/playqueue.py index 6697532a..6f4646dc 100644 --- a/lib/plexapi/playqueue.py +++ b/lib/plexapi/playqueue.py @@ -3,7 +3,7 @@ from urllib.parse import quote_plus from plexapi import utils from plexapi.base import PlexObject -from plexapi.exceptions import BadRequest, Unsupported +from plexapi.exceptions import BadRequest class PlayQueue(PlexObject): @@ -13,7 +13,7 @@ class PlayQueue(PlexObject): TAG (str): 'PlayQueue' TYPE (str): 'playqueue' identifier (str): com.plexapp.plugins.library - items (list): List of :class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist` + items (list): List of :class:`~plexapi.base.Playable` or :class:`~plexapi.playlist.Playlist` mediaTagPrefix (str): Fx /system/bundle/media/flags/ mediaTagVersion (int): Fx 1485957738 playQueueID (int): ID of the PlayQueue. @@ -27,7 +27,7 @@ class PlayQueue(PlexObject): playQueueSourceURI (str): Original URI used to create the PlayQueue. playQueueTotalCount (int): How many items in the PlayQueue. playQueueVersion (int): Version of the PlayQueue. Increments every time a change is made to the PlayQueue. - selectedItem (:class:`~plexapi.media.Media`): Media object for the currently selected item. + selectedItem (:class:`~plexapi.base.Playable`): Media object for the currently selected item. _server (:class:`~plexapi.server.PlexServer`): PlexServer associated with the PlayQueue. size (int): Alias for playQueueTotalCount. """ @@ -90,10 +90,10 @@ class PlayQueue(PlexObject): return matches[0] elif len(matches) > 1: raise BadRequest( - "{item} occurs multiple times in this PlayQueue, provide exact item".format(item=item) + f"{item} occurs multiple times in this PlayQueue, provide exact item" ) else: - raise BadRequest("{item} not valid for this PlayQueue".format(item=item)) + raise BadRequest(f"{item} not valid for this PlayQueue") @classmethod def get( @@ -128,7 +128,7 @@ class PlayQueue(PlexObject): if center: args["center"] = center - path = "/playQueues/{playQueueID}{args}".format(playQueueID=playQueueID, args=utils.joinArgs(args)) + path = f"/playQueues/{playQueueID}{utils.joinArgs(args)}" data = server.query(path, method=server._session.get) c = cls(server, data, initpath=path) c._server = server @@ -150,9 +150,9 @@ class PlayQueue(PlexObject): Parameters: server (:class:`~plexapi.server.PlexServer`): Server you are connected to. - items (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): + items (:class:`~plexapi.base.Playable` or :class:`~plexapi.playlist.Playlist`): A media item, list of media items, or Playlist. - startItem (:class:`~plexapi.media.Media`, optional): + startItem (:class:`~plexapi.base.Playable`, optional): Media item in the PlayQueue where playback should begin. shuffle (int, optional): Start the playqueue shuffled. repeat (int, optional): Start the playqueue shuffled. @@ -171,8 +171,8 @@ class PlayQueue(PlexObject): if isinstance(items, list): item_keys = ",".join([str(x.ratingKey) for x in items]) - uri_args = quote_plus("/library/metadata/{item_keys}".format(item_keys=item_keys)) - args["uri"] = "library:///directory/{uri_args}".format(uri_args=uri_args) + uri_args = quote_plus(f"/library/metadata/{item_keys}") + args["uri"] = f"library:///directory/{uri_args}" args["type"] = items[0].listType elif items.type == "playlist": args["type"] = items.playlistType @@ -183,15 +183,14 @@ class PlayQueue(PlexObject): else: uuid = items.section().uuid args["type"] = items.listType - args["uri"] = "library://{uuid}/item/{key}".format(uuid=uuid, key=items.key) + args["uri"] = f"library://{uuid}/item/{items.key}" if startItem: args["key"] = startItem.key - path = "/playQueues{args}".format(args=utils.joinArgs(args)) + path = f"/playQueues{utils.joinArgs(args)}" data = server.query(path, method=server._session.post) c = cls(server, data, initpath=path) - c.playQueueType = args["type"] c._server = server return c @@ -227,7 +226,6 @@ class PlayQueue(PlexObject): path = f"/playQueues{utils.joinArgs(args)}" data = server.query(path, method=server._session.post) c = cls(server, data, initpath=path) - c.playQueueType = args["type"] c._server = server return c @@ -237,7 +235,7 @@ class PlayQueue(PlexObject): Items can only be added to the section immediately following the current playing item. Parameters: - item (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist. + item (:class:`~plexapi.base.Playable` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist. playNext (bool, optional): If True, add this item to the front of the "Up Next" section. If False, the item will be appended to the end of the "Up Next" section. Only has an effect if an item has already been added to the "Up Next" section. @@ -250,21 +248,17 @@ class PlayQueue(PlexObject): args = {} if item.type == "playlist": args["playlistID"] = item.ratingKey - itemType = item.playlistType else: uuid = item.section().uuid - itemType = item.listType - args["uri"] = "library://{uuid}/item{key}".format(uuid=uuid, key=item.key) - - if itemType != self.playQueueType: - raise Unsupported("Item type does not match PlayQueue type") + args["uri"] = f"library://{uuid}/item{item.key}" if playNext: args["next"] = 1 - path = "/playQueues/{playQueueID}{args}".format(playQueueID=self.playQueueID, args=utils.joinArgs(args)) + path = f"/playQueues/{self.playQueueID}{utils.joinArgs(args)}" data = self._server.query(path, method=self._server._session.put) self._loadData(data) + return self def moveItem(self, item, after=None, refresh=True): """ @@ -290,11 +284,10 @@ class PlayQueue(PlexObject): after = self.getQueueItem(after) args["after"] = after.playQueueItemID - path = "/playQueues/{playQueueID}/items/{playQueueItemID}/move{args}".format( - playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID, args=utils.joinArgs(args) - ) + path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}/move{utils.joinArgs(args)}" data = self._server.query(path, method=self._server._session.put) self._loadData(data) + return self def removeItem(self, item, refresh=True): """Remove an item from the PlayQueue. @@ -309,20 +302,21 @@ class PlayQueue(PlexObject): if item not in self: item = self.getQueueItem(item) - path = "/playQueues/{playQueueID}/items/{playQueueItemID}".format( - playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID - ) + path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}" data = self._server.query(path, method=self._server._session.delete) self._loadData(data) + return self def clear(self): """Remove all items from the PlayQueue.""" - path = "/playQueues/{playQueueID}/items".format(playQueueID=self.playQueueID) + path = f"/playQueues/{self.playQueueID}/items" data = self._server.query(path, method=self._server._session.delete) self._loadData(data) + return self def refresh(self): """Refresh the PlayQueue from the Plex server.""" - path = "/playQueues/{playQueueID}".format(playQueueID=self.playQueueID) + path = f"/playQueues/{self.playQueueID}" data = self._server.query(path, method=self._server._session.get) self._loadData(data) + return self diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index e5c36ad6..168efd40 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -171,7 +171,7 @@ class PlexServer(PlexObject): return headers def _uriRoot(self): - return 'server://%s/com.plexapp.plugins.library' % self.machineIdentifier + return f'server://{self.machineIdentifier}/com.plexapp.plugins.library' @property def library(self): @@ -232,7 +232,7 @@ class PlexServer(PlexObject): """ Returns a list of :class:`~plexapi.media.Agent` objects this server has available. """ key = '/system/agents' if mediaType: - key += '?mediaType=%s' % utils.searchType(mediaType) + key += f'?mediaType={utils.searchType(mediaType)}' return self.fetchItems(key) def createToken(self, type='delegation', scope='all'): @@ -240,7 +240,7 @@ class PlexServer(PlexObject): if not self._token: # Handle unclaimed servers return None - q = self.query('/security/token?type=%s&scope=%s' % (type, scope)) + q = self.query(f'/security/token?type={type}&scope={scope}') return q.attrib.get('token') def switchUser(self, username, session=None, timeout=None): @@ -291,7 +291,7 @@ class PlexServer(PlexObject): try: return next(account for account in self.systemAccounts() if account.id == accountID) except StopIteration: - raise NotFound('Unknown account with accountID=%s' % accountID) from None + raise NotFound(f'Unknown account with accountID={accountID}') from None def systemDevices(self): """ Returns a list of :class:`~plexapi.server.SystemDevice` objects this server contains. """ @@ -309,7 +309,7 @@ class PlexServer(PlexObject): try: return next(device for device in self.systemDevices() if device.id == deviceID) except StopIteration: - raise NotFound('Unknown device with deviceID=%s' % deviceID) from None + raise NotFound(f'Unknown device with deviceID={deviceID}') from None def myPlexAccount(self): """ Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same @@ -351,7 +351,7 @@ class PlexServer(PlexObject): key = path.key elif path is not None: base64path = utils.base64str(path) - key = '/services/browse/%s' % base64path + key = f'/services/browse/{base64path}' else: key = '/services/browse' if includeFiles: @@ -406,7 +406,7 @@ class PlexServer(PlexObject): log.warning('%s did not advertise a port, checking plex.tv.', elem.attrib.get('name')) ports = self._myPlexClientPorts() if ports is None else ports port = ports.get(elem.attrib.get('machineIdentifier')) - baseurl = 'http://%s:%s' % (elem.attrib['host'], port) + baseurl = f"http://{elem.attrib['host']}:{port}" items.append(PlexClient(baseurl=baseurl, server=self, token=self._token, data=elem, connect=False)) @@ -425,7 +425,7 @@ class PlexServer(PlexObject): if client and client.title == name: return client - raise NotFound('Unknown client name: %s' % name) + raise NotFound(f'Unknown client name: {name}') def createCollection(self, title, section, items=None, smart=False, limit=None, libtype=None, sort=None, filters=None, **kwargs): @@ -547,6 +547,7 @@ class PlexServer(PlexObject): f'Invalid butler task: {task}. Available tasks are: {validTasks}' ) self.query(f'/butler/{task}', method=self._session.post) + return self @deprecated('use "checkForUpdate" instead') def check_for_update(self, force=True, download=False): @@ -559,7 +560,7 @@ class PlexServer(PlexObject): force (bool): Force server to check for new releases download (bool): Download if a update is available. """ - part = '/updater/check?download=%s' % (1 if download else 0) + part = f'/updater/check?download={1 if download else 0}' if force: self.query(part, method=self._session.put) releases = self.fetchItems('/updater/status') @@ -608,7 +609,7 @@ class PlexServer(PlexObject): args['X-Plex-Container-Start'] = 0 args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults) while subresults and maxresults > len(results): - key = '/status/sessions/history/all%s' % utils.joinArgs(args) + key = f'/status/sessions/history/all{utils.joinArgs(args)}' subresults = self.fetchItems(key) results += subresults[:maxresults - len(results)] args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] @@ -635,7 +636,7 @@ class PlexServer(PlexObject): # TODO: Automatically retrieve and validate sort field similar to LibrarySection.search() args['sort'] = sort - key = '/playlists%s' % utils.joinArgs(args) + key = f'/playlists{utils.joinArgs(args)}' return self.fetchItems(key, **kwargs) def playlist(self, title): @@ -650,7 +651,7 @@ class PlexServer(PlexObject): try: return self.playlists(title=title, title__iexact=title)[0] except IndexError: - raise NotFound('Unable to find playlist with title "%s".' % title) from None + raise NotFound(f'Unable to find playlist with title "{title}".') from None def optimizedItems(self, removeAll=None): """ Returns list of all :class:`~plexapi.media.Optimized` objects connected to server. """ @@ -659,7 +660,7 @@ class PlexServer(PlexObject): self.query(key, method=self._server._session.delete) else: backgroundProcessing = self.fetchItem('/playlists?type=42') - return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized) + return self.fetchItems(f'{backgroundProcessing.key}/items', cls=Optimized) @deprecated('use "plexapi.media.Optimized.items()" instead') def optimizedItem(self, optimizedID): @@ -668,7 +669,7 @@ class PlexServer(PlexObject): """ backgroundProcessing = self.fetchItem('/playlists?type=42') - return self.fetchItem('%s/items/%s/items' % (backgroundProcessing.key, optimizedID)) + return self.fetchItem(f'{backgroundProcessing.key}/items/{optimizedID}/items') def conversions(self, pause=None): """ Returns list of all :class:`~plexapi.media.Conversion` objects connected to server. """ @@ -697,7 +698,7 @@ class PlexServer(PlexObject): if response.status_code not in (200, 201, 204): codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) + message = f'({response.status_code}) {codename}; {response.url} {errtext}' if response.status_code == 401: raise Unauthorized(message) elif response.status_code == 404: @@ -736,7 +737,7 @@ class PlexServer(PlexObject): params['limit'] = limit if sectionId: params['sectionId'] = sectionId - key = '/hubs/search?%s' % urlencode(params) + key = f'/hubs/search?{urlencode(params)}' for hub in self.fetchItems(key, Hub): if mediatype: if hub.type == mediatype: @@ -753,21 +754,27 @@ class PlexServer(PlexObject): """ Returns a list of all active :class:`~plexapi.media.TranscodeSession` objects. """ return self.fetchItems('/transcode/sessions') - def startAlertListener(self, callback=None): + def startAlertListener(self, callback=None, callbackError=None): """ Creates a websocket connection to the Plex Server to optionally receive notifications. These often include messages from Plex about media scans as well as updates to currently running Transcode Sessions. - NOTE: You need websocket-client installed in order to use this feature. - >> pip install websocket-client + Returns a new :class:`~plexapi.alert.AlertListener` object. + + Note: ``websocket-client`` must be installed in order to use this feature. + + .. code-block:: python + + >> pip install websocket-client Parameters: callback (func): Callback function to call on received messages. + callbackError (func): Callback function to call on errors. Raises: :exc:`~plexapi.exception.Unsupported`: Websocket-client not installed. """ - notifier = AlertListener(self, callback) + notifier = AlertListener(self, callback, callbackError) notifier.start() return notifier @@ -809,7 +816,7 @@ class PlexServer(PlexObject): if imageFormat is not None: params['format'] = imageFormat.lower() - key = '/photo/:/transcode%s' % utils.joinArgs(params) + key = f'/photo/:/transcode{utils.joinArgs(params)}' return self.url(key, includeToken=True) def url(self, key, includeToken=None): @@ -818,8 +825,8 @@ class PlexServer(PlexObject): """ if self._token and (includeToken or self._showSecrets): delim = '&' if '?' in key else '?' - return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token) - return '%s%s' % (self._baseurl, key) + return f'{self._baseurl}{key}{delim}X-Plex-Token={self._token}' + return f'{self._baseurl}{key}' def refreshSynclist(self): """ Force PMS to download new SyncList from Plex.tv. """ @@ -853,7 +860,7 @@ class PlexServer(PlexObject): log.debug('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.') raise BadRequest('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.') value = 1 if toggle is True else 0 - return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put) + return self.query(f'/:/prefs?allowMediaDeletion={value}', self._session.put) def bandwidth(self, timespan=None, **kwargs): """ Returns a list of :class:`~plexapi.server.StatisticsBandwidth` objects @@ -904,8 +911,7 @@ class PlexServer(PlexObject): gigabytes = round(bandwidth.bytes / 1024**3, 3) local = 'local' if bandwidth.lan else 'remote' date = bandwidth.at.strftime('%Y-%m-%d') - print('%s used %s GB of %s bandwidth on %s from %s' - % (account.name, gigabytes, local, date, device.name)) + print(f'{account.name} used {gigabytes} GB of {local} bandwidth on {date} from {device.name}') """ params = {} @@ -923,19 +929,19 @@ class PlexServer(PlexObject): try: params['timespan'] = timespans[timespan] except KeyError: - raise BadRequest('Invalid timespan specified: %s. ' - 'Available timespans: %s' % (timespan, ', '.join(timespans.keys()))) + raise BadRequest(f"Invalid timespan specified: {timespan}. " + f"Available timespans: {', '.join(timespans.keys())}") filters = {'accountID', 'at', 'at<', 'at>', 'bytes', 'bytes<', 'bytes>', 'deviceID', 'lan'} for key, value in kwargs.items(): if key not in filters: - raise BadRequest('Unknown filter: %s=%s' % (key, value)) + raise BadRequest(f'Unknown filter: {key}={value}') if key.startswith('at'): try: value = utils.cast(int, value.timestamp()) except AttributeError: - raise BadRequest('Time frame filter must be a datetime object: %s=%s' % (key, value)) + raise BadRequest(f'Time frame filter must be a datetime object: {key}={value}') elif key.startswith('bytes') or key == 'lan': value = utils.cast(int, value) elif key == 'accountID': @@ -943,7 +949,7 @@ class PlexServer(PlexObject): value = 1 # The admin account is accountID=1 params[key] = value - key = '/statistics/bandwidth?%s' % urlencode(params) + key = f'/statistics/bandwidth?{urlencode(params)}' return self.fetchItems(key, StatisticsBandwidth) def resources(self): @@ -966,13 +972,9 @@ class PlexServer(PlexObject): base = 'https://app.plex.tv/desktop/' if endpoint: - return '%s#!/server/%s/%s%s' % ( - base, self.machineIdentifier, endpoint, utils.joinArgs(kwargs) - ) + return f'{base}#!/server/{self.machineIdentifier}/{endpoint}{utils.joinArgs(kwargs)}' else: - return '%s#!/media/%s/com.plexapp.plugins.library%s' % ( - base, self.machineIdentifier, utils.joinArgs(kwargs) - ) + return f'{base}#!/media/{self.machineIdentifier}/com.plexapp.plugins.library{utils.joinArgs(kwargs)}' def getWebURL(self, base=None, playlistTab=None): """ Returns the Plex Web URL for the server. @@ -983,7 +985,7 @@ class PlexServer(PlexObject): playlistTab (str): The playlist tab (audio, video, photo). Only used for the playlist URL. """ if playlistTab is not None: - params = {'source': 'playlists', 'pivot': 'playlists.%s' % playlistTab} + params = {'source': 'playlists', 'pivot': f'playlists.{playlistTab}'} else: params = {'key': '/hubs', 'pageType': 'hub'} return self._buildWebURL(base=base, **params) @@ -1115,7 +1117,7 @@ class SystemDevice(PlexObject): self.clientIdentifier = data.attrib.get('clientIdentifier') self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.id = utils.cast(int, data.attrib.get('id')) - self.key = '/devices/%s' % self.id + self.key = f'/devices/{self.id}' self.name = data.attrib.get('name') self.platform = data.attrib.get('platform') @@ -1146,12 +1148,14 @@ class StatisticsBandwidth(PlexObject): self.timespan = utils.cast(int, data.attrib.get('timespan')) def __repr__(self): - return '<%s>' % ':'.join([p for p in [ - self.__class__.__name__, - self._clean(self.accountID), - self._clean(self.deviceID), - self._clean(int(self.at.timestamp())) - ] if p]) + return '<{}>'.format( + ':'.join([p for p in [ + self.__class__.__name__, + self._clean(self.accountID), + self._clean(self.deviceID), + self._clean(int(self.at.timestamp())) + ] if p]) + ) def account(self): """ Returns the :class:`~plexapi.server.SystemAccount` associated with the bandwidth data. """ @@ -1186,10 +1190,7 @@ class StatisticsResources(PlexObject): self.timespan = utils.cast(int, data.attrib.get('timespan')) def __repr__(self): - return '<%s>' % ':'.join([p for p in [ - self.__class__.__name__, - self._clean(int(self.at.timestamp())) - ] if p]) + return f"<{':'.join([p for p in [self.__class__.__name__, self._clean(int(self.at.timestamp()))] if p])}>" @utils.registerPlexObject diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py index e0adba00..b6b4d2e6 100644 --- a/lib/plexapi/settings.py +++ b/lib/plexapi/settings.py @@ -51,7 +51,7 @@ class Settings(PlexObject): id = utils.lowerFirst(id) if id in self._settings: return self._settings[id] - raise NotFound('Invalid setting id: %s' % id) + raise NotFound(f'Invalid setting id: {id}') def groups(self): """ Returns a dict of lists for all :class:`~plexapi.settings.Setting` @@ -77,12 +77,12 @@ class Settings(PlexObject): params = {} for setting in self.all(): if setting._setValue: - log.info('Saving PlexServer setting %s = %s' % (setting.id, setting._setValue)) + log.info('Saving PlexServer setting %s = %s', setting.id, setting._setValue) params[setting.id] = quote(setting._setValue) if not params: raise BadRequest('No setting have been modified.') - querystr = '&'.join(['%s=%s' % (k, v) for k, v in params.items()]) - url = '%s?%s' % (self.key, querystr) + querystr = '&'.join([f'{k}={v}' for k, v in params.items()]) + url = f'{self.key}?{querystr}' self._server.query(url, self._server._session.put) self.reload() @@ -149,16 +149,16 @@ class Setting(PlexObject): # check a few things up front if not isinstance(value, self.TYPES[self.type]['type']): badtype = type(value).__name__ - raise BadRequest('Invalid value for %s: a %s is required, not %s' % (self.id, self.type, badtype)) + raise BadRequest(f'Invalid value for {self.id}: a {self.type} is required, not {badtype}') if self.enumValues and value not in self.enumValues: - raise BadRequest('Invalid value for %s: %s not in %s' % (self.id, value, list(self.enumValues))) + raise BadRequest(f'Invalid value for {self.id}: {value} not in {list(self.enumValues)}') # store value off to the side until we call settings.save() tostr = self.TYPES[self.type]['tostr'] self._setValue = tostr(value) def toUrl(self): """Helper for urls""" - return '%s=%s' % (self.id, self._value or self.value) + return f'{self.id}={self._value or self.value}' @utils.registerPlexObject @@ -174,6 +174,6 @@ class Preferences(Setting): def _default(self): """ Set the default value for this setting.""" - key = '%s/prefs?' % self._initpath - url = key + '%s=%s' % (self.id, self.default) + key = f'{self._initpath}/prefs?' + url = key + f'{self.id}={self.default}' self._server.query(url, method=self._server._session.put) diff --git a/lib/plexapi/sonos.py b/lib/plexapi/sonos.py index 35956b99..a7a57f4d 100644 --- a/lib/plexapi/sonos.py +++ b/lib/plexapi/sonos.py @@ -96,9 +96,7 @@ class PlexSonosClient(PlexClient): { "type": "music", "providerIdentifier": "com.plexapp.plugins.library", - "containerKey": "/playQueues/{}?own=1".format( - playqueue.playQueueID - ), + "containerKey": f"/playQueues/{playqueue.playQueueID}?own=1", "key": media.key, "offset": offset, "machineIdentifier": media._server.machineIdentifier, diff --git a/lib/plexapi/sync.py b/lib/plexapi/sync.py index 0a02f4e9..66468c30 100644 --- a/lib/plexapi/sync.py +++ b/lib/plexapi/sync.py @@ -81,13 +81,13 @@ class SyncItem(PlexObject): """ Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. """ server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier] if len(server) == 0: - raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier) + raise NotFound(f'Unable to find server with uuid {self.machineIdentifier}') return server[0] def getMedia(self): """ Returns list of :class:`~plexapi.base.Playable` which belong to this sync item. """ server = self.server().connect() - key = '/sync/items/%s' % self.id + key = f'/sync/items/{self.id}' return server.fetchItems(key) def markDownloaded(self, media): @@ -97,7 +97,7 @@ class SyncItem(PlexObject): Parameters: media (base.Playable): the media to be marked as downloaded. """ - url = '/sync/%s/item/%s/downloaded' % (self.clientIdentifier, media.ratingKey) + url = f'/sync/{self.clientIdentifier}/item/{media.ratingKey}/downloaded' media._server.query(url, method=requests.put) def delete(self): @@ -159,13 +159,14 @@ class Status: self.itemsCount = plexapi.utils.cast(int, itemsCount) def __repr__(self): - return '<%s>:%s' % (self.__class__.__name__, dict( + d = dict( itemsCount=self.itemsCount, itemsCompleteCount=self.itemsCompleteCount, itemsDownloadedCount=self.itemsDownloadedCount, itemsReadyCount=self.itemsReadyCount, itemsSuccessfulCount=self.itemsSuccessfulCount - )) + ) + return f'<{self.__class__.__name__}>:{d}' class MediaSettings: diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index 9c43b23a..36827311 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import base64 import functools +import json import logging import os import re @@ -26,10 +27,66 @@ except ImportError: log = logging.getLogger('plexapi') # Search Types - Plex uses these to filter specific media types when searching. -# Library Types - Populated at runtime -SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7, - 'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14, - 'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'optimizedVersion': 42, 'userPlaylistItem': 1001} +SEARCHTYPES = { + 'movie': 1, + 'show': 2, + 'season': 3, + 'episode': 4, + 'trailer': 5, + 'comic': 6, + 'person': 7, + 'artist': 8, + 'album': 9, + 'track': 10, + 'picture': 11, + 'clip': 12, + 'photo': 13, + 'photoalbum': 14, + 'playlist': 15, + 'playlistFolder': 16, + 'collection': 18, + 'optimizedVersion': 42, + 'userPlaylistItem': 1001, +} +# Tag Types - Plex uses these to filter specific tags when searching. +TAGTYPES = { + 'tag': 0, + 'genre': 1, + 'collection': 2, + 'director': 4, + 'writer': 5, + 'role': 6, + 'producer': 7, + 'country': 8, + 'chapter': 9, + 'review': 10, + 'label': 11, + 'marker': 12, + 'mediaProcessingTarget': 42, + 'make': 200, + 'model': 201, + 'aperture': 202, + 'exposure': 203, + 'iso': 204, + 'lens': 205, + 'device': 206, + 'autotag': 207, + 'mood': 300, + 'style': 301, + 'format': 302, + 'similar': 305, + 'concert': 306, + 'banner': 311, + 'poster': 312, + 'art': 313, + 'guid': 314, + 'ratingImage': 316, + 'theme': 317, + 'studio': 318, + 'network': 319, + 'place': 400, +} +# Plex Objects - Populated at runtime PLEXOBJECTS = {} @@ -60,10 +117,12 @@ def registerPlexObject(cls): buildItem() below for an example. """ etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE)) - ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG + ehash = f'{cls.TAG}.{etype}' if etype else cls.TAG + if getattr(cls, '_SESSIONTYPE', None): + ehash = f"{ehash}.{'session'}" if ehash in PLEXOBJECTS: - raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' % - (cls.__name__, cls.TAG, etype, PLEXOBJECTS[ehash].__name__)) + raise Exception(f'Ambiguous PlexObject definition {cls.__name__}(tag={cls.TAG}, type={etype}) ' + f'with {PLEXOBJECTS[ehash].__name__}') PLEXOBJECTS[ehash] = cls return cls @@ -106,8 +165,8 @@ def joinArgs(args): arglist = [] for key in sorted(args, key=lambda x: x.lower()): value = str(args[key]) - arglist.append('%s=%s' % (key, quote(value, safe=''))) - return '?%s' % '&'.join(arglist) + arglist.append(f"{key}={quote(value, safe='')}") + return f"?{'&'.join(arglist)}" def lowerFirst(s): @@ -149,8 +208,7 @@ def searchType(libtype): """ Returns the integer value of the library string type. Parameters: - libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track, - collection) + libtype (str): LibType to lookup (See :data:`~plexapi.utils.SEARCHTYPES`) Raises: :exc:`~plexapi.exceptions.NotFound`: Unknown libtype @@ -160,7 +218,7 @@ def searchType(libtype): return libtype if SEARCHTYPES.get(libtype) is not None: return SEARCHTYPES[libtype] - raise NotFound('Unknown libtype: %s' % libtype) + raise NotFound(f'Unknown libtype: {libtype}') def reverseSearchType(libtype): @@ -178,7 +236,42 @@ def reverseSearchType(libtype): for k, v in SEARCHTYPES.items(): if libtype == v: return k - raise NotFound('Unknown libtype: %s' % libtype) + raise NotFound(f'Unknown libtype: {libtype}') + + +def tagType(tag): + """ Returns the integer value of the library tag type. + + Parameters: + tag (str): Tag to lookup (See :data:`~plexapi.utils.TAGTYPES`) + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown tag + """ + tag = str(tag) + if tag in [str(v) for v in TAGTYPES.values()]: + return tag + if TAGTYPES.get(tag) is not None: + return TAGTYPES[tag] + raise NotFound(f'Unknown tag: {tag}') + + +def reverseTagType(tag): + """ Returns the string value of the library tag type. + + Parameters: + tag (int): Integer value of the library tag type. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown tag + """ + if tag in TAGTYPES: + return tag + tag = int(tag) + for k, v in TAGTYPES.items(): + if tag == v: + return k + raise NotFound(f'Unknown tag: {tag}') def threaded(callback, listargs): @@ -255,7 +348,7 @@ def toList(value, itemcast=None, delim=','): def cleanFilename(filename, replace='_'): - whitelist = "-_.()[] {}{}".format(string.ascii_letters, string.digits) + whitelist = f"-_.()[] {string.ascii_letters}{string.digits}" cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode() cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename) return cleaned_filename @@ -283,11 +376,11 @@ def downloadSessionImages(server, filename=None, height=150, width=150, if media.thumb: url = media.thumb if part.indexes: # always use bif images if available. - url = '/library/parts/%s/indexes/%s/%s' % (part.id, part.indexes.lower(), media.viewOffset) + url = f'/library/parts/{part.id}/indexes/{part.indexes.lower()}/{media.viewOffset}' if url: if filename is None: prettyname = media._prettyfilename() - filename = 'session_transcode_%s_%s_%s' % (media.usernames[0], prettyname, int(time.time())) + filename = f'session_transcode_{media.usernames[0]}_{prettyname}_{int(time.time())}' url = server.transcodeImage(url, height, width, opacity, saturation) filepath = download(url, filename=filename) info['username'] = {'filepath': filepath, 'url': url} @@ -374,13 +467,13 @@ def getMyPlexAccount(opts=None): # pragma: no cover from plexapi.myplex import MyPlexAccount # 1. Check command-line options if opts and opts.username and opts.password: - print('Authenticating with Plex.tv as %s..' % opts.username) + print(f'Authenticating with Plex.tv as {opts.username}..') return MyPlexAccount(opts.username, opts.password) # 2. Check Plexconfig (environment variables and config.ini) config_username = CONFIG.get('auth.myplex_username') config_password = CONFIG.get('auth.myplex_password') if config_username and config_password: - print('Authenticating with Plex.tv as %s..' % config_username) + print(f'Authenticating with Plex.tv as {config_username}..') return MyPlexAccount(config_username, config_password) config_token = CONFIG.get('auth.server_token') if config_token: @@ -389,12 +482,12 @@ def getMyPlexAccount(opts=None): # pragma: no cover # 3. Prompt for username and password on the command line username = input('What is your plex.tv username: ') password = getpass('What is your plex.tv password: ') - print('Authenticating with Plex.tv as %s..' % username) + print(f'Authenticating with Plex.tv as {username}..') return MyPlexAccount(username, password) def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover - """ Helper function to create a new MyPlexDevice. + """ Helper function to create a new MyPlexDevice. Returns a new MyPlexDevice instance. Parameters: headers (dict): Provide the X-Plex- headers for the new device. @@ -417,6 +510,33 @@ def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover return account.device(clientId=clientIdentifier) +def plexOAuth(headers, forwardUrl=None, timeout=120): # pragma: no cover + """ Helper function for Plex OAuth login. Returns a new MyPlexAccount instance. + + Parameters: + headers (dict): Provide the X-Plex- headers for the new device. + A unique X-Plex-Client-Identifier is required. + forwardUrl (str, optional): The url to redirect the client to after login. + timeout (int, optional): Timeout in seconds to wait for device login. Default 120 seconds. + """ + from plexapi.myplex import MyPlexAccount, MyPlexPinLogin + + if 'X-Plex-Client-Identifier' not in headers: + raise BadRequest('The X-Plex-Client-Identifier header is required.') + + pinlogin = MyPlexPinLogin(headers=headers, oauth=True) + print('Login to Plex at the following url:') + print(pinlogin.oauthUrl(forwardUrl)) + pinlogin.run(timeout=timeout) + pinlogin.waitForLogin() + + if pinlogin.token: + print('Login successful!') + return MyPlexAccount(token=pinlogin.token) + else: + print('Login failed.') + + def choose(msg, items, attr): # pragma: no cover """ Command line helper to display a list of choices, asking the user to choose one of the options. @@ -428,12 +548,12 @@ def choose(msg, items, attr): # pragma: no cover print() for index, i in enumerate(items): name = attr(i) if callable(attr) else getattr(i, attr) - print(' %s: %s' % (index, name)) + print(f' {index}: {name}') print() # Request choice from the user while True: try: - inp = input('%s: ' % msg) + inp = input(f'{msg}: ') if any(s in inp for s in (':', '::', '-')): idx = slice(*map(lambda x: int(x.strip()) if x.strip() else None, inp.split(':'))) return items[idx] @@ -452,8 +572,7 @@ def getAgentIdentifier(section, agent): if agent in identifiers: return ag.identifier agents += identifiers - raise NotFound('Could not find "%s" in agents list (%s)' % - (agent, ', '.join(agents))) + raise NotFound(f"Could not find \"{agent}\" in agents list ({', '.join(agents)})") def base64str(text): @@ -467,7 +586,7 @@ def deprecated(message, stacklevel=2): when the function is used.""" @functools.wraps(func) def wrapper(*args, **kwargs): - msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message) + msg = f'Call to deprecated function or method "{func.__name__}", {message}.' warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel) log.warning(msg) return func(*args, **kwargs) @@ -485,3 +604,17 @@ def iterXMLBFS(root, tag=None): if tag is None or node.tag == tag: yield node queue.extend(list(node)) + + +def toJson(obj, **kwargs): + """ Convert an object to a JSON string. + + Parameters: + obj (object): The object to convert. + **kwargs (dict): Keyword arguments to pass to ``json.dumps()``. + """ + def serialize(obj): + if isinstance(obj, datetime): + return obj.isoformat() + return {k: v for k, v in obj.__dict__.items() if not k.startswith('_')} + return json.dumps(obj, default=serialize, **kwargs) diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 95f8336d..0d664c14 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -1,21 +1,21 @@ # -*- coding: utf-8 -*- import os -from urllib.parse import quote_plus, urlencode +from urllib.parse import quote_plus from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject +from plexapi.base import Playable, PlexPartialObject, PlexSession from plexapi.exceptions import BadRequest from plexapi.mixins import ( - AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, + AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin, - ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, + ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, SummaryMixin, TaglineMixin, TitleMixin, CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin, WatchlistMixin ) -class Video(PlexPartialObject): +class Video(PlexPartialObject, PlayedUnplayedMixin): """ Base class for all video objects including :class:`~plexapi.video.Movie`, :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`, :class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`. @@ -71,25 +71,10 @@ class Video(PlexPartialObject): self.userRating = utils.cast(float, data.attrib.get('userRating')) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) - @property - def isWatched(self): - """ Returns True if this video is watched. """ - return bool(self.viewCount > 0) if self.viewCount else False - def url(self, part): """ Returns the full url for something. Typically used for getting a specific image. """ return self._server.url(part, includeToken=True) if part else None - def markWatched(self): - """ Mark the video as played. """ - key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey - self._server.query(key) - - def markUnwatched(self): - """ Mark the video as unplayed. """ - key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey - self._server.query(key) - def augmentation(self): """ Returns a list of :class:`~plexapi.library.Hub` objects. Augmentation returns hub items relating to online media sources @@ -132,7 +117,7 @@ class Video(PlexPartialObject): def uploadSubtitles(self, filepath): """ Upload Subtitle file for video. """ - url = '%s/subtitles' % self.key + url = f'{self.key}/subtitles' filename = os.path.basename(filepath) subFormat = os.path.splitext(filepath)[1][1:] with open(filepath, 'rb') as subfile: @@ -141,6 +126,7 @@ class Video(PlexPartialObject): } headers = {'Accept': 'text/plain, */*'} self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers) + return self def removeSubtitles(self, streamID=None, streamTitle=None): """ Remove Subtitle from movie's subtitles listing. @@ -151,74 +137,103 @@ class Video(PlexPartialObject): for stream in self.subtitleStreams(): if streamID == stream.id or streamTitle == stream.title: self._server.query(stream.key, self._server._session.delete) + return self - def optimize(self, title=None, target="", targetTagID=None, locationID=-1, policyScope='all', - policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None): - """ Optimize item + def optimize(self, title='', target='', deviceProfile='', videoQuality=None, + locationID=-1, limit=None, unwatched=False): + """ Create an optimized version of the video. - locationID (int): -1 in folder with original items - 2 library path id - library path id is found in library.locations[i].id + Parameters: + title (str, optional): Title of the optimized video. + target (str, optional): Target quality profile: + "Optimized for Mobile" ("mobile"), "Optimized for TV" ("tv"), "Original Quality" ("original"), + or custom quality profile name (default "Custom: {deviceProfile}"). + deviceProfile (str, optional): Custom quality device profile: + "Android", "iOS", "Universal Mobile", "Universal TV", "Windows Phone", "Windows", "Xbox One". + Required if ``target`` is custom. + videoQuality (int, optional): Index of the quality profile, one of ``VIDEO_QUALITY_*`` + values defined in the :mod:`~plexapi.sync` module. Only used if ``target`` is custom. + locationID (int or :class:`~plexapi.library.Location`, optional): Default -1 for + "In folder with original items", otherwise a :class:`~plexapi.library.Location` object or ID. + See examples below. + limit (int, optional): Maximum count of items to optimize, unlimited if ``None``. + unwatched (bool, optional): ``True`` to only optimized unwatched videos. - target (str): custom quality name. - if none provided use "Custom: {deviceProfile}" - - targetTagID (int): Default quality settings - 1 Mobile - 2 TV - 3 Original Quality - - deviceProfile (str): Android, IOS, Universal TV, Universal Mobile, Windows Phone, - Windows, Xbox One + Raises: + :exc:`~plexapi.exceptions.BadRequest`: Unknown quality profile target + or missing deviceProfile and videoQuality. + :exc:`~plexapi.exceptions.BadRequest`: Unknown location ID. Example: - Optimize for Mobile - item.optimize(targetTagID="Mobile") or item.optimize(targetTagID=1") - Optimize for Android 10 MBPS 1080p - item.optimize(deviceProfile="Android", videoQuality=10) - Optimize for IOS Original Quality - item.optimize(deviceProfile="IOS", videoQuality=-1) - * see sync.py VIDEO_QUALITIES for additional information for using videoQuality + .. code-block:: python + + # Optimize for mobile using defaults + video.optimize(target="mobile") + + # Optimize for Android at 10 Mbps 1080p + from plexapi.sync import VIDEO_QUALITY_10_MBPS_1080p + video.optimize(deviceProfile="Android", videoQuality=sync.VIDEO_QUALITY_10_MBPS_1080p) + + # Optimize for iOS at original quality in library location + from plexapi.sync import VIDEO_QUALITY_ORIGINAL + locations = plex.library.section("Movies")._locations() + video.optimize(deviceProfile="iOS", videoQuality=VIDEO_QUALITY_ORIGINAL, locationID=locations[0]) + + # Optimize for tv the next 5 unwatched episodes + show.optimize(target="tv", limit=5, unwatched=True) + """ - tagValues = [1, 2, 3] - tagKeys = ["Mobile", "TV", "Original Quality"] - tagIDs = tagKeys + tagValues - - if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None): - raise BadRequest('Unexpected or missing quality profile.') - - libraryLocationIDs = [location.id for location in self.section()._locations()] - libraryLocationIDs.append(-1) - - if locationID not in libraryLocationIDs: - raise BadRequest('Unexpected library path ID. %s not in %s' % - (locationID, libraryLocationIDs)) - - if isinstance(targetTagID, str): - tagIndex = tagKeys.index(targetTagID) - targetTagID = tagValues[tagIndex] - - if title is None: - title = self.title + from plexapi.library import Location + from plexapi.sync import Policy, MediaSettings backgroundProcessing = self.fetchItem('/playlists?type=42') - key = '%s/items?' % backgroundProcessing.key + key = f'{backgroundProcessing.key}/items' + + tags = {t.tag.lower(): t.id for t in self._server.library.tags('mediaProcessingTarget')} + # Additional keys for shorthand values + tags['mobile'] = tags['optimized for mobile'] + tags['tv'] = tags['optimized for tv'] + tags['original'] = tags['original quality'] + + targetTagID = tags.get(target.lower(), '') + if not targetTagID and (not deviceProfile or videoQuality is None): + raise BadRequest('Unknown quality profile target or missing deviceProfile and videoQuality.') + if targetTagID: + target = '' + elif deviceProfile and not target: + target = f'Custom: {deviceProfile}' + + section = self.section() + libraryLocationIDs = [-1] + [location.id for location in section._locations()] + if isinstance(locationID, Location): + locationID = locationID.id + if locationID not in libraryLocationIDs: + raise BadRequest(f'Unknown location ID "{locationID}" not in {libraryLocationIDs}') + + if isinstance(self, (Show, Season)): + uri = f'library:///directory/{quote_plus(f"{self.key}/children")}' + else: + uri = f'library://{section.uuid}/item/{quote_plus(self.key)}' + + policy = Policy.create(limit, unwatched) + params = { 'Item[type]': 42, + 'Item[title]': title or self._defaultSyncTitle(), 'Item[target]': target, - 'Item[targetTagID]': targetTagID if targetTagID else '', + 'Item[targetTagID]': targetTagID, 'Item[locationID]': locationID, - 'Item[Policy][scope]': policyScope, - 'Item[Policy][value]': policyValue, - 'Item[Policy][unwatched]': policyUnwatched + 'Item[Location][uri]': uri, + 'Item[Policy][scope]': policy.scope, + 'Item[Policy][value]': str(policy.value), + 'Item[Policy][unwatched]': str(int(policy.unwatched)), } if deviceProfile: params['Item[Device][profile]'] = deviceProfile if videoQuality: - from plexapi.sync import MediaSettings mediaSettings = MediaSettings.createVideo(videoQuality) params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution @@ -227,14 +242,11 @@ class Video(PlexPartialObject): params['Item[MediaSettings][subtitleSize]'] = '' params['Item[MediaSettings][musicBitrate]'] = '' params['Item[MediaSettings][photoQuality]'] = '' + params['Item[MediaSettings][photoResolution]'] = '' - titleParam = {'Item[title]': title} - section = self._server.library.sectionByID(self.librarySectionID) - params['Item[Location][uri]'] = 'library://' + section.uuid + '/item/' + \ - quote_plus(self.key + '?includeExternalMedia=1') - - data = key + urlencode(params) + '&' + urlencode(titleParam) - return self._server.query(data, method=self._server._session.put) + url = key + utils.joinArgs(params) + self._server.query(url, method=self._server._session.put) + return self def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None): """ Add current video (movie, tv-show, season or episode) as sync item for specified device. @@ -267,7 +279,7 @@ class Video(PlexPartialObject): section = self._server.library.sectionByID(self.librarySectionID) - sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key)) + sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}' sync_item.policy = Policy.create(limit, unwatched) sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) @@ -279,7 +291,7 @@ class Movie( Video, Playable, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, ArtMixin, PosterMixin, ThemeMixin, - ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, + ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, SummaryMixin, TaglineMixin, TitleMixin, CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin, WatchlistMixin @@ -298,6 +310,7 @@ class Movie( countries (List<:class:`~plexapi.media.Country`>): List of countries objects. directors (List<:class:`~plexapi.media.Director`>): List of director objects. duration (int): Duration of the movie in milliseconds. + editionTitle (str): The edition title of the movie (e.g. Director's Cut, Extended Edition, etc.). genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. labels (List<:class:`~plexapi.media.Label`>): List of label objects. @@ -338,6 +351,7 @@ class Movie( self.countries = self.findItems(data, media.Country) self.directors = self.findItems(data, media.Director) self.duration = utils.cast(int, data.attrib.get('duration')) + self.editionTitle = data.attrib.get('editionTitle') self.genres = self.findItems(data, media.Genre) self.guids = self.findItems(data, media.Guid) self.labels = self.findItems(data, media.Label) @@ -381,13 +395,23 @@ class Movie( def _prettyfilename(self): """ Returns a filename for use in download. """ - return '%s (%s)' % (self.title, self.year) + return f'{self.title} ({self.year})' def reviews(self): """ Returns a list of :class:`~plexapi.media.Review` objects. """ data = self._server.query(self._details_key) return self.findItems(data, media.Review, rtag='Video') + def editions(self): + """ Returns a list of :class:`~plexapi.video.Movie` objects + for other editions of the same movie. + """ + filters = { + 'guid': self.guid, + 'id!': self.ratingKey + } + return self.section().search(filters=filters) + @utils.registerPlexObject class Show( @@ -499,8 +523,8 @@ class Show( return self.roles @property - def isWatched(self): - """ Returns True if the show is fully watched. """ + def isPlayed(self): + """ Returns True if the show is fully played. """ return bool(self.viewedLeafCount == self.leafCount) def onDeck(self): @@ -520,7 +544,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,8 +557,8 @@ 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 - return self.fetchItems(key, Season, **kwargs) + key = f'{self.key}/children?excludeAllLeaves=1' + return self.fetchItems(key, Season, container_size=self.childCount, **kwargs) def episode(self, title=None, season=None, episode=None): """ Find a episode using a title or season and episode. @@ -547,7 +571,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 +580,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): @@ -583,7 +607,7 @@ class Show( """ filepaths = [] for episode in self.episodes(): - _savepath = os.path.join(savepath, 'Season %s' % str(episode.seasonNumber).zfill(2)) if subfolders else savepath + _savepath = os.path.join(savepath, f'Season {str(episode.seasonNumber).zfill(2)}') if subfolders else savepath filepaths += episode.download(_savepath, keep_original_name, **kwargs) return filepaths @@ -647,15 +671,17 @@ class Season( yield episode def __repr__(self): - return '<%s>' % ':'.join([p for p in [ - self.__class__.__name__, - self.key.replace('/library/metadata/', '').replace('/children', ''), - '%s-s%s' % (self.parentTitle.replace(' ', '-')[:20], self.seasonNumber), - ] if p]) + return '<{}>'.format( + ':'.join([p for p in [ + self.__class__.__name__, + self.key.replace('/library/metadata/', '').replace('/children', ''), + f"{self.parentTitle.replace(' ', '-')[:20]}-{self.seasonNumber}", + ] if p]) + ) @property - def isWatched(self): - """ Returns True if the season is fully watched. """ + def isPlayed(self): + """ Returns True if the season is fully played. """ return bool(self.viewedLeafCount == self.leafCount) @property @@ -665,7 +691,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 +704,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): @@ -728,7 +754,7 @@ class Season( def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ - return '%s - %s' % (self.parentTitle, self.title) + return f'{self.parentTitle} - {self.title}' @utils.registerPlexObject @@ -835,18 +861,20 @@ class Episode( if not self.parentRatingKey and self.grandparentRatingKey: self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey if self.parentRatingKey: - self.parentKey = '/library/metadata/%s' % self.parentRatingKey + self.parentKey = f'/library/metadata/{self.parentRatingKey}' def __repr__(self): - return '<%s>' % ':'.join([p for p in [ - self.__class__.__name__, - self.key.replace('/library/metadata/', '').replace('/children', ''), - '%s-%s' % (self.grandparentTitle.replace(' ', '-')[:20], self.seasonEpisode), - ] if p]) + return '<{}>'.format( + ':'.join([p for p in [ + self.__class__.__name__, + self.key.replace('/library/metadata/', '').replace('/children', ''), + f"{self.grandparentTitle.replace(' ', '-')[:20]}-{self.seasonEpisode}", + ] if p]) + ) def _prettyfilename(self): """ Returns a filename for use in download. """ - return '%s - %s - %s' % (self.grandparentTitle, self.seasonEpisode, self.title) + return f'{self.grandparentTitle} - {self.seasonEpisode} - {self.title}' @property def actors(self): @@ -878,7 +906,7 @@ class Episode( @property def seasonEpisode(self): """ Returns the s00e00 string containing the season and episode numbers. """ - return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2)) + return f's{str(self.seasonNumber).zfill(2)}e{str(self.episodeNumber).zfill(2)}' @property def hasCommercialMarker(self): @@ -905,7 +933,7 @@ class Episode( def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ - return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title) + return f'{self.grandparentTitle} - {self.parentTitle} - ({self.seasonEpisode}) {self.title}' @utils.registerPlexObject @@ -979,4 +1007,43 @@ class Extra(Clip): def _prettyfilename(self): """ Returns a filename for use in download. """ - return '%s (%s)' % (self.title, self.subtype) + return f'{self.title} ({self.subtype})' + + +@utils.registerPlexObject +class MovieSession(PlexSession, Movie): + """ Represents a single Movie session + loaded from :func:`~plexapi.server.PlexServer.sessions`. + """ + _SESSIONTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Movie._loadData(self, data) + PlexSession._loadData(self, data) + + +@utils.registerPlexObject +class EpisodeSession(PlexSession, Episode): + """ Represents a single Episode session + loaded from :func:`~plexapi.server.PlexServer.sessions`. + """ + _SESSIONTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Episode._loadData(self, data) + PlexSession._loadData(self, data) + + +@utils.registerPlexObject +class ClipSession(PlexSession, Clip): + """ Represents a single Clip session + loaded from :func:`~plexapi.server.PlexServer.sessions`. + """ + _SESSIONTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Clip._loadData(self, data) + PlexSession._loadData(self, data) diff --git a/requirements.txt b/requirements.txt index c505d8ae..a0dcd531 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ MarkupSafe==2.1.1 musicbrainzngs==0.7.1 packaging==21.3 paho-mqtt==1.6.1 -plexapi==4.12.1 +plexapi==4.13.1 portend==3.1.0 profilehooks==1.12.0 PyJWT==2.4.0