From b2c16eba078b20691b2fbb632db054fcfb9f00a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Aug 2023 12:10:56 -0700 Subject: [PATCH] Bump plexapi from 4.13.4 to 4.15.0 (#2132) * Bump plexapi from 4.13.4 to 4.15.0 Bumps [plexapi](https://github.com/pkkid/python-plexapi) from 4.13.4 to 4.15.0. - [Release notes](https://github.com/pkkid/python-plexapi/releases) - [Commits](https://github.com/pkkid/python-plexapi/compare/4.13.4...4.15.0) --- updated-dependencies: - dependency-name: plexapi dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Update plexapi==4.15.0 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> [skip ci] --- lib/plexapi/audio.py | 73 ++++--- lib/plexapi/base.py | 222 +++++++++++++--------- lib/plexapi/client.py | 1 + lib/plexapi/collection.py | 9 +- lib/plexapi/config.py | 4 +- lib/plexapi/const.py | 4 +- lib/plexapi/library.py | 360 ++++++++++++++++++++++++----------- lib/plexapi/media.py | 15 +- lib/plexapi/mixins.py | 218 +++++++++++++-------- lib/plexapi/myplex.py | 388 +++++++++++++++++++++++++------------- lib/plexapi/photo.py | 8 +- lib/plexapi/playlist.py | 3 +- lib/plexapi/playqueue.py | 2 +- lib/plexapi/server.py | 48 ++--- lib/plexapi/settings.py | 2 +- lib/plexapi/sonos.py | 1 + lib/plexapi/utils.py | 30 ++- lib/plexapi/video.py | 132 ++++++++----- requirements.txt | 2 +- 19 files changed, 988 insertions(+), 534 deletions(-) diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index e5455fc8..e1382760 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -3,19 +3,17 @@ import os from urllib.parse import quote_plus from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject, PlexSession -from plexapi.exceptions import BadRequest, NotFound +from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession +from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin, - AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, - TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin, - CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin + ArtistEditMixins, AlbumEditMixins, TrackEditMixins ) from plexapi.playlist import Playlist -class Audio(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin): +class Audio(PlexPartialObject, PlayedUnplayedMixin): """ Base class for all audio objects including :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`. @@ -132,8 +130,7 @@ class Artist( Audio, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, ArtMixin, PosterMixin, ThemeMixin, - SortTitleMixin, SummaryMixin, TitleMixin, - CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin + ArtistEditMixins ): """ Represents a single Artist. @@ -181,14 +178,19 @@ class Artist( Parameters: title (str): Title of the album to return. """ - 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 + return self.section().get( + title=title, + libtype='album', + filters={'artist.id': self.ratingKey} + ) def albums(self, **kwargs): """ Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """ - return self.section().search(libtype='album', filters={'artist.id': self.ratingKey}, **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. @@ -244,8 +246,7 @@ class Album( Audio, UnmatchMatchMixin, RatingMixin, ArtMixin, PosterMixin, ThemeUrlMixin, - OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, - CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin + AlbumEditMixins ): """ Represents a single Album. @@ -364,14 +365,14 @@ class Track( Audio, Playable, ExtrasMixin, RatingMixin, ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin, - TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin, - CollectionMixin, LabelMixin, MoodMixin + TrackEditMixins ): """ Represents a single Track. Attributes: TAG (str): 'Directory' TYPE (str): 'track' + chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects. chapterSource (str): Unknown collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. duration (int): Length of the track in milliseconds. @@ -407,6 +408,7 @@ class Track( """ Load attribute values from Plex XML response. """ Audio._loadData(self, data) Playable._loadData(self, data) + self.chapters = self.findItems(data, media.Chapter) self.chapterSource = data.attrib.get('chapterSource') self.collections = self.findItems(data, media.Collection) self.duration = utils.cast(int, data.attrib.get('duration')) @@ -433,18 +435,6 @@ class Track( self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.year = utils.cast(int, data.attrib.get('year')) - def _prettyfilename(self): - """ Returns a filename for use in download. """ - return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}' - - def album(self): - """ Return the track's :class:`~plexapi.audio.Album`. """ - return self.fetchItem(self.parentKey) - - def artist(self): - """ Return the track's :class:`~plexapi.audio.Artist`. """ - return self.fetchItem(self.grandparentKey) - @property def locations(self): """ This does not exist in plex xml response but is added to have a common @@ -460,6 +450,18 @@ class Track( """ Returns the track number. """ return self.index + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}' + + def album(self): + """ Return the track's :class:`~plexapi.audio.Album`. """ + return self.fetchItem(self.parentKey) + + def artist(self): + """ Return the track's :class:`~plexapi.audio.Artist`. """ + return self.fetchItem(self.grandparentKey) + def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ return f'{self.grandparentTitle} - {self.parentTitle} - {self.title}' @@ -480,3 +482,16 @@ class TrackSession(PlexSession, Track): """ Load attribute values from Plex XML response. """ Track._loadData(self, data) PlexSession._loadData(self, data) + + +@utils.registerPlexObject +class TrackHistory(PlexHistory, Track): + """ Represents a single Track history entry + loaded from :func:`~plexapi.server.PlexServer.history`. + """ + _HISTORYTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Track._loadData(self, data) + PlexHistory._loadData(self, data) diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 9f888eed..88a31bbe 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- import re import weakref +from functools import cached_property from urllib.parse import urlencode from xml.etree import ElementTree -from plexapi import log, utils +from plexapi import CONFIG, X_PLEX_CONTAINER_SIZE, log, utils from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported -from plexapi.utils import cached_property USER_DONT_RELOAD_FOR_KEYS = set() _DONT_RELOAD_FOR_KEYS = {'key'} @@ -50,9 +50,14 @@ class PlexObject: self._initpath = initpath or self.key self._parent = weakref.ref(parent) if parent is not None else None self._details_key = None - self._overwriteNone = True # Allow overwriting previous attribute values with `None` when manually reloading - self._autoReload = True # Automatically reload the object when accessing a missing attribute - self._edits = None # Save batch edits for a single API call + + # Allow overwriting previous attribute values with `None` when manually reloading + self._overwriteNone = True + # Automatically reload the object when accessing a missing attribute + self._autoReload = CONFIG.get('plexapi.autoreload', True, bool) + # Attribute to save batch edits for a single API call + self._edits = None + if data is not None: self._loadData(data) self._details_key = self._buildDetailsKey() @@ -87,7 +92,9 @@ class PlexObject: etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type'))) ehash = f'{elem.tag}.{etype}' if etype else elem.tag if initpath == '/status/sessions': - ehash = f"{ehash}.{'session'}" + ehash = f"{ehash}.session" + elif initpath.startswith('/status/sessions/history'): + ehash = f"{ehash}.history" 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: @@ -147,47 +154,14 @@ class PlexObject: elem = ElementTree.fromstring(xml) return self._buildItemOrNone(elem, cls) - def fetchItem(self, ekey, cls=None, **kwargs): - """ Load the specified key to find and build the first item with the - specified tag and attrs. If no tag or attrs are specified then - the first item in the result set is returned. - - Parameters: - ekey (str or int): Path in Plex to fetch items from. If an int is passed - in, the key will be translated to /library/metadata/. This allows - fetching an item only knowing its key-id. - cls (:class:`~plexapi.base.PlexObject`): If you know the class of the - items to be fetched, passing this in will help the parser ensure - it only returns those items. By default we convert the xml elements - with the best guess PlexObjects based on tag and type attrs. - etag (str): Only fetch items with the specified tag. - **kwargs (dict): Optionally add XML attribute to filter the items. - See :func:`~plexapi.base.PlexObject.fetchItems` for more details - on how this is used. - """ - if ekey is None: - raise BadRequest('ekey was not provided') - if isinstance(ekey, int): - ekey = f'/library/metadata/{ekey}' - - data = self._server.query(ekey) - 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(f'Unable to find elem: cls={clsname}, attrs={kwargs}') - - def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs): + def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, maxresults=None, **kwargs): """ Load the specified key to find and build all items with the specified tag and attrs. Parameters: - ekey (str): API URL path in Plex to fetch items from. + ekey (str or List): API URL path in Plex to fetch items from. If a list of ints is passed + in, the key will be translated to /library/metadata/. This allows + fetching multiple items only knowing their key-ids. cls (:class:`~plexapi.base.PlexObject`): If you know the class of the items to be fetched, passing this in will help the parser ensure it only returns those items. By default we convert the xml elements @@ -195,6 +169,7 @@ class PlexObject: etag (str): Only fetch items with the specified tag. container_start (None, int): offset to get a subset of the data container_size (None, int): How many items in data + maxresults (int, optional): Only return the specified number of results. **kwargs (dict): Optionally add XML attribute to filter the items. See the details below for more info. @@ -259,39 +234,80 @@ class PlexObject: if ekey is None: raise BadRequest('ekey was not provided') - 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 + if isinstance(ekey, list) and all(isinstance(key, int) for key in ekey): + ekey = f'/library/metadata/{",".join(str(key) for key in ekey)}' - data = self._server.query(ekey, params=params) - items = self.findItems(data, cls, ekey, **kwargs) + container_start = container_start or 0 + container_size = container_size or X_PLEX_CONTAINER_SIZE + offset = container_start - librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) - if librarySectionID: - for item in items: - item.librarySectionID = librarySectionID - return items + if maxresults is not None: + container_size = min(container_size, maxresults) - 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. + results = [] + subresults = [] + headers = {} + + while True: + headers['X-Plex-Container-Start'] = str(container_start) + headers['X-Plex-Container-Size'] = str(container_size) + + data = self._server.query(ekey, headers=headers) + subresults = self.findItems(data, cls, ekey, **kwargs) + total_size = utils.cast(int, data.attrib.get('totalSize') or data.attrib.get('size')) or len(subresults) + + if not subresults: + if offset > total_size: + log.info('container_start is greater than the number of items') + + librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + if librarySectionID: + for item in subresults: + item.librarySectionID = librarySectionID + + results.extend(subresults) + + wanted_number_of_items = total_size - offset + if maxresults is not None: + wanted_number_of_items = min(maxresults, wanted_number_of_items) + container_size = min(container_size, wanted_number_of_items - len(results)) + + if wanted_number_of_items <= len(results): + break + + container_start += container_size + + if container_start > total_size: + break + + return results + + def fetchItem(self, ekey, cls=None, **kwargs): + """ Load the specified key to find and build the first item with the + specified tag and attrs. If no tag or attrs are specified then + the first item in the result set is returned. + + Parameters: + ekey (str or int): Path in Plex to fetch items from. If an int is passed + in, the key will be translated to /library/metadata/. This allows + fetching an item only knowing its key-id. + cls (:class:`~plexapi.base.PlexObject`): If you know the class of the + items to be fetched, passing this in will help the parser ensure + it only returns those items. By default we convert the xml elements + with the best guess PlexObjects based on tag and type attrs. + etag (str): Only fetch items with the specified tag. + **kwargs (dict): Optionally add XML attribute to filter the items. + See :func:`~plexapi.base.PlexObject.fetchItems` 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 + if isinstance(ekey, int): + ekey = f'/library/metadata/{ekey}' + + try: + return self.fetchItems(ekey, cls, **kwargs)[0] + except IndexError: + clsname = cls.__name__ if cls else 'None' + raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}') from None 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 @@ -315,6 +331,16 @@ class PlexObject: items.append(item) 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. + """ + try: + return self.findItems(data, cls, initpath, rtag, **kwargs)[0] + except IndexError: + return None + def firstAttr(self, *attrs): """ Return the first attribute in attrs that is not None. """ for attr in attrs: @@ -475,7 +501,9 @@ class PlexPartialObject(PlexObject): } def __eq__(self, other): - return other not in [None, []] and self.key == other.key + if isinstance(other, PlexPartialObject): + return other not in [None, []] and self.key == other.key + return NotImplemented def __hash__(self): return hash(repr(self)) @@ -492,7 +520,7 @@ class PlexPartialObject(PlexObject): if attr.startswith('_'): return value if value not in (None, []): return value if self.isFullObject(): return value - if isinstance(self, PlexSession): return value + if isinstance(self, (PlexSession, PlexHistory)): return value if self._autoReload is False: return value # Log the reload. clsname = self.__class__.__name__ @@ -543,13 +571,10 @@ class PlexPartialObject(PlexObject): self._edits.update(kwargs) return self - if 'id' not in kwargs: - kwargs['id'] = self.ratingKey if 'type' not in kwargs: kwargs['type'] = utils.searchType(self._searchType) - part = f'/library/sections/{self.librarySectionID}/all{utils.joinArgs(kwargs)}' - self._server.query(part, method=self._server._session.put) + self.section()._edit(items=self, **kwargs) return self def edit(self, **kwargs): @@ -643,7 +668,7 @@ class PlexPartialObject(PlexObject): 'have not allowed items to be deleted', self.key) raise - def history(self, maxresults=9999999, mindate=None): + def history(self, maxresults=None, mindate=None): """ Get Play History for a media item. Parameters: @@ -681,17 +706,11 @@ class Playable: Albums which are all not playable. Attributes: - 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. playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items). playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items). """ def _loadData(self, data): - 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 self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue @@ -812,7 +831,7 @@ class Playable: """ key = f'/:/progress?key={self.ratingKey}&identifier=com.plexapp.plugins.library&time={time}&state={state}' self._server.query(key) - self._reload(_overwriteNone=False) + return self def updateTimeline(self, time, state='stopped', duration=None): """ Set the timeline progress for this video. @@ -830,7 +849,7 @@ class Playable: 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) + return self class PlexSession(object): @@ -912,6 +931,35 @@ class PlexSession(object): return self._server.query(key, params=params) +class PlexHistory(object): + """ This is a general place to store functions specific to media that is a Plex history item. + + Attributes: + accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. + deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID. + historyKey (str): API URL (/status/sessions/history/). + viewedAt (datetime): Datetime item was last watched. + """ + + def _loadData(self, data): + self.accountID = utils.cast(int, data.attrib.get('accountID')) + self.deviceID = utils.cast(int, data.attrib.get('deviceID')) + self.historyKey = data.attrib.get('historyKey') + self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) + + def _reload(self, **kwargs): + """ Reload the data for the history entry. """ + raise NotImplementedError('History objects cannot be reloaded. Use source() to get the source media item.') + + def source(self): + """ Return the source media object for the history entry. """ + return self.fetchItem(self._details_key) + + def delete(self): + """ Delete the history entry. """ + return self._server.query(self.historyKey, method=self._server._session.delete) + + class MediaContainer(PlexObject): """ Represents a single MediaContainer. diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index b23518ad..2b4283c7 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -3,6 +3,7 @@ import time from xml.etree import ElementTree import requests + from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index 9d754b1b..d4820fe2 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -8,8 +8,7 @@ from plexapi.library import LibrarySection, ManagedHub from plexapi.mixins import ( AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin, ArtMixin, PosterMixin, ThemeMixin, - AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, - LabelMixin + CollectionEditMixins ) from plexapi.utils import deprecated @@ -19,8 +18,7 @@ class Collection( PlexPartialObject, AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin, ArtMixin, PosterMixin, ThemeMixin, - AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, - LabelMixin + CollectionEditMixins ): """ Represents a single Collection. @@ -222,6 +220,7 @@ class Collection( .. code-block:: python collection.updateMode(user="user") + """ if not self.smart: raise BadRequest('Cannot change collection filtering user for a non-smart collection.') @@ -250,6 +249,7 @@ class Collection( .. code-block:: python collection.updateMode(mode="hide") + """ mode_dict = { 'default': -1, @@ -276,6 +276,7 @@ class Collection( .. code-block:: python collection.updateSort(mode="alpha") + """ if self.smart: raise BadRequest('Cannot change collection order for a smart collection.') diff --git a/lib/plexapi/config.py b/lib/plexapi/config.py index 3b93f869..8bbf1f31 100644 --- a/lib/plexapi/config.py +++ b/lib/plexapi/config.py @@ -3,6 +3,8 @@ import os from collections import defaultdict from configparser import ConfigParser +from plexapi import utils + class PlexConfig(ConfigParser): """ PlexAPI configuration object. Settings are stored in an INI file within the @@ -35,7 +37,7 @@ class PlexConfig(ConfigParser): # Second: check the config file has attr section, name = key.lower().split('.') value = self.data.get(section, {}).get(name, default) - return cast(value) if cast else value + return utils.cast(cast, value) if cast else value except: # noqa: E722 return default diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index 86659c57..df86ff5d 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -3,7 +3,7 @@ # Library version MAJOR_VERSION = 4 -MINOR_VERSION = 13 -PATCH_VERSION = 4 +MINOR_VERSION = 15 +PATCH_VERSION = 0 __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 ce2df733..cbca4246 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -1,13 +1,18 @@ # -*- coding: utf-8 -*- import re from datetime import datetime -from urllib.parse import quote_plus, urlencode +from functools import cached_property +from urllib.parse import parse_qs, quote_plus, urlencode, urlparse -from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils +from plexapi import log, media, utils from plexapi.base import OPERATORS, PlexObject from plexapi.exceptions import BadRequest, NotFound +from plexapi.mixins import ( + MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins, + ArtistEditMixins, AlbumEditMixins, TrackEditMixins, PhotoalbumEditMixins, PhotoEditMixins +) from plexapi.settings import Setting -from plexapi.utils import cached_property, deprecated +from plexapi.utils import deprecated class Library(PlexObject): @@ -352,7 +357,7 @@ class Library(PlexObject): part += urlencode(kwargs) return self._server.query(part, method=self._server._session.post) - def history(self, maxresults=9999999, mindate=None): + def history(self, maxresults=None, mindate=None): """ Get Play History for all library Sections for the owner. Parameters: maxresults (int): Only return the specified number of results (optional). @@ -421,40 +426,6 @@ class LibrarySection(PlexObject): self._totalDuration = None self._totalStorage = None - 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 - and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details - on how this is used. - - Parameters: - container_start (None, int): offset to get a subset of the data - container_size (None, int): How many items in data - - """ - 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) - - if '/all' in ekey: - # totalSize is only included in the xml response - # if container size is used. - total_size = data.attrib.get("totalSize") or data.attrib.get("size") - self._totalViewSize = utils.cast(int, total_size) - - items = self.findItems(data, cls, ekey, **kwargs) - - librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) - if librarySectionID: - for item in items: - item.librarySectionID = librarySectionID - return items - @cached_property def totalSize(self): """ Returns the total number of items in the library for the default library type. """ @@ -474,6 +445,20 @@ class LibrarySection(PlexObject): self._getTotalDurationStorage() return self._totalStorage + def __getattribute__(self, attr): + # Intercept to call EditFieldMixin and EditTagMixin methods + # based on the item type being batch multi-edited + value = super().__getattribute__(attr) + if attr.startswith('_'): return value + if callable(value) and 'Mixin' in value.__qualname__: + if not isinstance(self._edits, dict): + raise AttributeError("Must enable batchMultiEdit() to use this method") + elif not hasattr(self._edits['items'][0], attr): + raise AttributeError( + f"Batch multi-editing '{self._edits['items'][0].__class__.__name__}' object has no attribute '{attr}'" + ) + return value + def _getTotalDurationStorage(self): """ Queries the Plex server for the total library duration and storage and caches the values. """ data = self._server.query('/media/providers?includeStorage=1') @@ -565,8 +550,9 @@ class LibrarySection(PlexObject): .. code-block:: python - LibrarySection.addLocations('/path/1') - LibrarySection.addLocations(['/path/1', 'path/2', '/path/3']) + LibrarySection.addLocations('/path/1') + LibrarySection.addLocations(['/path/1', 'path/2', '/path/3']) + """ locations = self.locations if isinstance(location, str): @@ -587,8 +573,9 @@ class LibrarySection(PlexObject): .. code-block:: python - LibrarySection.removeLocations('/path/1') - LibrarySection.removeLocations(['/path/1', 'path/2', '/path/3']) + LibrarySection.removeLocations('/path/1') + LibrarySection.removeLocations(['/path/1', 'path/2', '/path/3']) + """ locations = self.locations if isinstance(location, str): @@ -602,19 +589,24 @@ class LibrarySection(PlexObject): raise BadRequest('You are unable to remove all locations from a library.') return self.edit(location=locations) - def get(self, title): - """ Returns the media item with the specified title. + def get(self, title, **kwargs): + """ Returns the media item with the specified title and kwargs. Parameters: title (str): Title of the item to return. + kwargs (dict): Additional search parameters. + See :func:`~plexapi.library.LibrarySection.search` for more info. Raises: :exc:`~plexapi.exceptions.NotFound`: The title is not found in the library. """ try: - return self.search(title)[0] + return self.search(title, limit=1, **kwargs)[0] except IndexError: - raise NotFound(f"Unable to find item '{title}'") from None + msg = f"Unable to find item with title '{title}'" + if kwargs: + msg += f" and kwargs {kwargs}" + raise NotFound(msg) from None def getGuid(self, guid): """ Returns the media item with the specified external Plex, IMDB, TMDB, or TVDB ID. @@ -781,6 +773,11 @@ class LibrarySection(PlexObject): key = f'/library/sections/{self.key}/onDeck' return self.fetchItems(key) + def continueWatching(self): + """ Return a list of media items in the library's Continue Watching hub. """ + key = f'/hubs/sections/{self.key}/continueWatching/items' + return self.fetchItems(key) + def recentlyAdded(self, maxresults=50, libtype=None): """ Returns a list of media items recently added from this library section. @@ -1261,7 +1258,7 @@ class LibrarySection(PlexObject): return self._server.search(query, mediatype, limit, sectionId=self.key) def search(self, title=None, sort=None, maxresults=None, libtype=None, - container_start=0, container_size=X_PLEX_CONTAINER_SIZE, limit=None, filters=None, **kwargs): + container_start=None, container_size=None, limit=None, filters=None, **kwargs): """ Search the library. The http requests will be batched in container_size. If you are only looking for the first results, it would be wise to set the maxresults option to that amount so the search doesn't iterate over all results on the server. @@ -1517,43 +1514,8 @@ class LibrarySection(PlexObject): """ key, kwargs = self._buildSearchKey( title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs) - return self._search(key, maxresults, container_start, container_size, **kwargs) - - def _search(self, key, maxresults, container_start, container_size, **kwargs): - """ Perform the actual library search and return the results. """ - results = [] - subresults = [] - offset = container_start - - if maxresults is not None: - container_size = min(container_size, maxresults) - - while True: - subresults = self.fetchItems(key, container_start=container_start, - container_size=container_size, **kwargs) - if not len(subresults): - if offset > self._totalViewSize: - log.info("container_start is higher than the number of items in the library") - - results.extend(subresults) - - # self._totalViewSize is not used as a condition in the while loop as - # this require a additional http request. - # self._totalViewSize is updated from self.fetchItems - wanted_number_of_items = self._totalViewSize - offset - if maxresults is not None: - wanted_number_of_items = min(maxresults, wanted_number_of_items) - container_size = min(container_size, maxresults - len(results)) - - if wanted_number_of_items <= len(results): - break - - container_start += container_size - - if container_start > self._totalViewSize: - break - - return results + return self.fetchItems( + key, container_start=container_start, container_size=container_size, maxresults=maxresults, **kwargs) def _locations(self): """ Returns a list of :class:`~plexapi.library.Location` objects @@ -1630,7 +1592,7 @@ class LibrarySection(PlexObject): return myplex.sync(client=client, clientId=clientId, sync_item=sync_item) - def history(self, maxresults=9999999, mindate=None): + def history(self, maxresults=None, mindate=None): """ Get Play History for this library Section for the owner. Parameters: maxresults (int): Only return the specified number of results (optional). @@ -1720,8 +1682,101 @@ class LibrarySection(PlexObject): params['pageType'] = 'list' return self._server._buildWebURL(base=base, **params) + def _validateItems(self, items): + """ Validates the specified items are from this library and of the same type. """ + if not items: + raise BadRequest('No items specified.') + + if not isinstance(items, list): + items = [items] + + itemType = items[0].type + for item in items: + if item.librarySectionID != self.key: + raise BadRequest(f'{item.title} is not from this library.') + elif item.type != itemType: + raise BadRequest(f'Cannot mix items of different type: {itemType} and {item.type}') -class MovieSection(LibrarySection): + return items + + def common(self, items): + """ Returns a :class:`~plexapi.library.Common` object for the specified items. """ + params = { + 'id': ','.join(str(item.ratingKey) for item in self._validateItems(items)), + 'type': utils.searchType(items[0].type) + } + part = f'/library/sections/{self.key}/common{utils.joinArgs(params)}' + return self.fetchItem(part, cls=Common) + + def _edit(self, items=None, **kwargs): + """ Actually edit multiple objects. """ + if isinstance(self._edits, dict): + self._edits.update(kwargs) + return self + + kwargs['id'] = ','.join(str(item.ratingKey) for item in self._validateItems(items)) + if 'type' not in kwargs: + kwargs['type'] = utils.searchType(items[0].type) + + part = f'/library/sections/{self.key}/all{utils.joinArgs(kwargs)}' + self._server.query(part, method=self._server._session.put) + return self + + def multiEdit(self, items, **kwargs): + """ Edit multiple objects at once. + Note: This is a low level method and you need to know all the field/tag keys. + See :class:`~plexapi.LibrarySection.batchMultiEdits` instead. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + :class:`~plexapi.photo.Photo`, or :class:`~plexapi.collection.Collection` + objects to be edited. + kwargs (dict): Dict of settings to edit. + """ + return self._edit(items, **kwargs) + + def batchMultiEdits(self, items): + """ Enable batch multi-editing mode to save API calls. + Must call :func:`~plexapi.library.LibrarySection.saveMultiEdits` at the end to save all the edits. + See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin` + for individual field and tag editing methods. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + :class:`~plexapi.photo.Photo`, or :class:`~plexapi.collection.Collection` + objects to be edited. + + Example: + + .. code-block:: python + + movies = MovieSection.all() + items = [movies[0], movies[3], movies[5]] + + # Batch multi-editing multiple fields and tags in a single API call + MovieSection.batchMultiEdits(items) + MovieSection.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline') \\ + .addCollection('New Collection').removeGenre('Action').addLabel('Favorite') + MovieSection.saveMultiEdits() + + """ + self._edits = {'items': self._validateItems(items)} + return self + + def saveMultiEdits(self): + """ Save all the batch multi-edits. + See :func:`~plexapi.library.LibrarySection.batchMultiEdits` for details. + """ + if not isinstance(self._edits, dict): + raise BadRequest('Batch multi-editing mode not enabled. Must call `batchMultiEdits()` first.') + + edits = self._edits + self._edits = None + self._edit(items=edits.pop('items'), **edits) + return self + + +class MovieSection(LibrarySection, MovieEditMixins): """ Represents a :class:`~plexapi.library.LibrarySection` section containing movies. Attributes: @@ -1781,7 +1836,7 @@ class MovieSection(LibrarySection): return super(MovieSection, self).sync(**kwargs) -class ShowSection(LibrarySection): +class ShowSection(LibrarySection, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins): """ Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows. Attributes: @@ -1865,7 +1920,7 @@ class ShowSection(LibrarySection): return super(ShowSection, self).sync(**kwargs) -class MusicSection(LibrarySection): +class MusicSection(LibrarySection, ArtistEditMixins, AlbumEditMixins, TrackEditMixins): """ Represents a :class:`~plexapi.library.LibrarySection` section containing music artists. Attributes: @@ -1957,7 +2012,7 @@ class MusicSection(LibrarySection): return super(MusicSection, self).sync(**kwargs) -class PhotoSection(LibrarySection): +class PhotoSection(LibrarySection, PhotoalbumEditMixins, PhotoEditMixins): """ Represents a :class:`~plexapi.library.LibrarySection` section containing photos. Attributes: @@ -1979,13 +2034,13 @@ class PhotoSection(LibrarySection): def collections(self, **kwargs): raise NotImplementedError('Collections are not available for a Photo library.') - def searchAlbums(self, title, **kwargs): + def searchAlbums(self, **kwargs): """ Search for a photo album. See :func:`~plexapi.library.LibrarySection.search` for usage. """ - return self.search(libtype='photoalbum', title=title, **kwargs) + return self.search(libtype='photoalbum', **kwargs) - def searchPhotos(self, title, **kwargs): + def searchPhotos(self, **kwargs): """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search` for usage. """ - return self.search(libtype='photo', title=title, **kwargs) + return self.search(libtype='photo', **kwargs) def recentlyAddedAlbums(self, maxresults=50): """ Returns a list of recently added photo albums from this library section. @@ -2157,8 +2212,10 @@ class LibraryMediaTag(PlexObject): reason (str): The reason for the search result. reasonID (int): The reason ID for the search result. reasonTitle (str): The reason title for the search result. + score (float): The score for the search result. type (str): The type of search result (tag). tag (str): The title of the tag. + tagKey (str): The Plex Discover ratingKey (guid) for people. tagType (int): The type ID of the tag. tagValue (int): The value of the tag. thumb (str): The URL for the thumbnail of the tag (if available). @@ -2179,8 +2236,10 @@ class LibraryMediaTag(PlexObject): self.reason = data.attrib.get('reason') self.reasonID = utils.cast(int, data.attrib.get('reasonID')) self.reasonTitle = data.attrib.get('reasonTitle') + self.score = utils.cast(float, data.attrib.get('score')) self.type = data.attrib.get('type') self.tag = data.attrib.get('tag') + self.tagKey = data.attrib.get('tagKey') self.tagType = utils.cast(int, data.attrib.get('tagType')) self.tagValue = utils.cast(int, data.attrib.get('tagValue')) self.thumb = data.attrib.get('thumb') @@ -2222,16 +2281,6 @@ class Autotag(LibraryMediaTag): TAGTYPE = 207 -@utils.registerPlexObject -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. @@ -2958,6 +3007,7 @@ class ManagedHub(PlexObject): managedHub.updateVisibility(recommended=True, home=True, shared=False).reload() # or using chained methods managedHub.promoteRecommended().promoteHome().demoteShared().reload() + """ params = { 'promotedToRecommended': int(self.promotedToRecommended), @@ -3066,7 +3116,6 @@ class Path(PlexObject): Attributes: TAG (str): 'Path' - home (bool): True if the path is the home directory key (str): API URL (/services/browse/) network (bool): True if path is a network location @@ -3098,7 +3147,6 @@ class File(PlexObject): Attributes: TAG (str): 'File' - key (str): API URL (/services/browse/) path (str): Full path to file title (str): File name @@ -3109,3 +3157,105 @@ class File(PlexObject): self.key = data.attrib.get('key') self.path = data.attrib.get('path') self.title = data.attrib.get('title') + + +@utils.registerPlexObject +class Common(PlexObject): + """ Represents a Common element from a library. This object lists common fields between multiple objects. + + Attributes: + TAG (str): 'Common' + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + contentRating (str): Content rating of the items. + countries (List<:class:`~plexapi.media.Country`>): List of countries objects. + directors (List<:class:`~plexapi.media.Director`>): List of director objects. + editionTitle (str): Edition title of the items. + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + grandparentRatingKey (int): Grandparent rating key of the items. + grandparentTitle (str): Grandparent title of the items. + guid (str): Plex GUID of the items. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + index (int): Index of the items. + key (str): API URL (/library/metadata/). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + mixedFields (List): List of mixed fields. + moods (List<:class:`~plexapi.media.Mood`>): List of mood objects. + originallyAvailableAt (datetime): Datetime of the release date of the items. + parentRatingKey (int): Parent rating key of the items. + parentTitle (str): Parent title of the items. + producers (List<:class:`~plexapi.media.Producer`>): List of producer objects. + ratingKey (int): Rating key of the items. + ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + studio (str): Studio name of the items. + styles (List<:class:`~plexapi.media.Style`>): List of style objects. + summary (str): Summary of the items. + tagline (str): Tagline of the items. + tags (List<:class:`~plexapi.media.Tag`>): List of tag objects. + title (str): Title of the items. + titleSort (str): Title to use when sorting of the items. + type (str): Type of the media (common). + writers (List<:class:`~plexapi.media.Writer`>): List of writer objects. + year (int): Year of the items. + """ + TAG = 'Common' + + def _loadData(self, data): + self._data = data + self.collections = self.findItems(data, media.Collection) + self.contentRating = data.attrib.get('contentRating') + self.countries = self.findItems(data, media.Country) + self.directors = self.findItems(data, media.Director) + self.editionTitle = data.attrib.get('editionTitle') + self.fields = self.findItems(data, media.Field) + self.genres = self.findItems(data, media.Genre) + self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) + self.grandparentTitle = data.attrib.get('grandparentTitle') + self.guid = data.attrib.get('guid') + self.guids = self.findItems(data, media.Guid) + self.index = utils.cast(int, data.attrib.get('index')) + self.key = data.attrib.get('key') + self.labels = self.findItems(data, media.Label) + self.mixedFields = data.attrib.get('mixedFields').split(',') + self.moods = self.findItems(data, media.Mood) + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt')) + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentTitle = data.attrib.get('parentTitle') + self.producers = self.findItems(data, media.Producer) + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.ratings = self.findItems(data, media.Rating) + self.roles = self.findItems(data, media.Role) + self.studio = data.attrib.get('studio') + self.styles = self.findItems(data, media.Style) + self.summary = data.attrib.get('summary') + self.tagline = data.attrib.get('tagline') + self.tags = self.findItems(data, media.Tag) + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort') + self.type = data.attrib.get('type') + self.writers = self.findItems(data, media.Writer) + self.year = utils.cast(int, data.attrib.get('year')) + + def __repr__(self): + return '<%s:%s:%s>' % ( + self.__class__.__name__, + self.commonType, + ','.join(str(key) for key in self.ratingKeys) + ) + + @property + def commonType(self): + """ Returns the media type of the common items. """ + parsed_query = parse_qs(urlparse(self._initpath).query) + return utils.reverseSearchType(parsed_query['type'][0]) + + @property + def ratingKeys(self): + """ Returns a list of rating keys for the common items. """ + parsed_query = parse_qs(urlparse(self._initpath).query) + return [int(value.strip()) for value in parsed_query['id'][0].split(',')] + + def items(self): + """ Returns a list of the common items. """ + return self._server.fetchItems(self.ratingKeys) diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index bf401ee0..8793463f 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -415,7 +415,11 @@ class SubtitleStream(MediaPartStream): forced (bool): True if this is a forced subtitle. format (str): The format of the subtitle stream (ex: srt). headerCompression (str): The header compression of the subtitle stream. + providerTitle (str): The provider title where the on-demand subtitle is downloaded from. + score (int): The match score of the on-demand subtitle. + sourceKey (str): The source key of the on-demand subtitle. transient (str): Unknown. + userID (int): The user id of the user that downloaded the on-demand subtitle. """ TAG = 'Stream' STREAMTYPE = 3 @@ -427,7 +431,11 @@ class SubtitleStream(MediaPartStream): self.forced = utils.cast(bool, data.attrib.get('forced', '0')) self.format = data.attrib.get('format') self.headerCompression = data.attrib.get('headerCompression') + self.providerTitle = data.attrib.get('providerTitle') + self.score = utils.cast(int, data.attrib.get('score')) + self.sourceKey = data.attrib.get('sourceKey') self.transient = data.attrib.get('transient') + self.userID = utils.cast(int, data.attrib.get('userID')) def setDefault(self): """ Sets this subtitle stream as the default subtitle stream. """ @@ -955,7 +963,7 @@ class Review(PlexObject): class BaseResource(PlexObject): - """ Base class for all Art, Banner, Poster, and Theme objects. + """ Base class for all Art, Poster, and Theme objects. Attributes: TAG (str): 'Photo' or 'Track' @@ -987,11 +995,6 @@ class Art(BaseResource): TAG = 'Photo' -class Banner(BaseResource): - """ Represents a single Banner object. """ - TAG = 'Photo' - - class Poster(BaseResource): """ Represents a single Poster object. """ TAG = 'Photo' diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index b004ad74..f0c21cfe 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- from datetime import datetime - from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit from plexapi import media, settings, utils @@ -139,7 +138,7 @@ class SplitMergeMixin: if not isinstance(ratingKeys, list): ratingKeys = str(ratingKeys).split(',') - key = f"{self.key}/merge?ids={','.join([str(r) for r in ratingKeys])}" + key = f"{self.key}/merge?ids={','.join(str(r) for r in ratingKeys)}" self._server.query(key, method=self._server._session.put) return self @@ -329,7 +328,19 @@ class ArtUrlMixin: return self._server.url(art, includeToken=True) if art else None -class ArtMixin(ArtUrlMixin): +class ArtLockMixin: + """ Mixin for Plex objects that can have a locked background artwork. """ + + def lockArt(self): + """ Lock the background artwork for a Plex object. """ + return self._edit(**{'art.locked': 1}) + + def unlockArt(self): + """ Unlock the background artwork for a Plex object. """ + return self._edit(**{'art.locked': 0}) + + +class ArtMixin(ArtUrlMixin, ArtLockMixin): """ Mixin for Plex objects that can have background artwork. """ def arts(self): @@ -361,65 +372,6 @@ class ArtMixin(ArtUrlMixin): art.select() return self - def lockArt(self): - """ Lock the background artwork for a Plex object. """ - return self._edit(**{'art.locked': 1}) - - def unlockArt(self): - """ Unlock the background artwork for a Plex object. """ - return self._edit(**{'art.locked': 0}) - - -class BannerUrlMixin: - """ Mixin for Plex objects that can have a banner url. """ - - @property - def bannerUrl(self): - """ Return the banner url for the Plex object. """ - banner = self.firstAttr('banner') - return self._server.url(banner, includeToken=True) if banner else None - - -class BannerMixin(BannerUrlMixin): - """ Mixin for Plex objects that can have banners. """ - - def banners(self): - """ Returns list of available :class:`~plexapi.media.Banner` objects. """ - 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. - - Parameters: - url (str): The full URL to the image to upload. - filepath (str): The full file path the the image to upload or file-like object. - """ - if url: - key = f'/library/metadata/{self.ratingKey}/banners?url={quote_plus(url)}' - self._server.query(key, method=self._server._session.post) - elif filepath: - key = f'/library/metadata/{self.ratingKey}/banners' - data = openOrRead(filepath) - self._server.query(key, method=self._server._session.post, data=data) - return self - - def setBanner(self, banner): - """ Set the banner for a Plex object. - - Parameters: - banner (:class:`~plexapi.media.Banner`): The banner object to select. - """ - banner.select() - return self - - def lockBanner(self): - """ Lock the banner for a Plex object. """ - return self._edit(**{'banner.locked': 1}) - - def unlockBanner(self): - """ Unlock the banner for a Plex object. """ - return self._edit(**{'banner.locked': 0}) - class PosterUrlMixin: """ Mixin for Plex objects that can have a poster url. """ @@ -436,7 +388,19 @@ class PosterUrlMixin: return self.thumbUrl -class PosterMixin(PosterUrlMixin): +class PosterLockMixin: + """ Mixin for Plex objects that can have a locked poster. """ + + def lockPoster(self): + """ Lock the poster for a Plex object. """ + return self._edit(**{'thumb.locked': 1}) + + def unlockPoster(self): + """ Unlock the poster for a Plex object. """ + return self._edit(**{'thumb.locked': 0}) + + +class PosterMixin(PosterUrlMixin, PosterLockMixin): """ Mixin for Plex objects that can have posters. """ def posters(self): @@ -468,14 +432,6 @@ class PosterMixin(PosterUrlMixin): poster.select() return self - def lockPoster(self): - """ Lock the poster for a Plex object. """ - return self._edit(**{'thumb.locked': 1}) - - def unlockPoster(self): - """ Unlock the poster for a Plex object. """ - return self._edit(**{'thumb.locked': 0}) - class ThemeUrlMixin: """ Mixin for Plex objects that can have a theme url. """ @@ -487,7 +443,19 @@ class ThemeUrlMixin: return self._server.url(theme, includeToken=True) if theme else None -class ThemeMixin(ThemeUrlMixin): +class ThemeLockMixin: + """ Mixin for Plex objects that can have a locked theme. """ + + def lockTheme(self): + """ Lock the theme for a Plex object. """ + return self._edit(**{'theme.locked': 1}) + + def unlockTheme(self): + """ Unlock the theme for a Plex object. """ + return self._edit(**{'theme.locked': 0}) + + +class ThemeMixin(ThemeUrlMixin, ThemeLockMixin): """ Mixin for Plex objects that can have themes. """ def themes(self): @@ -520,14 +488,6 @@ class ThemeMixin(ThemeUrlMixin): 'Re-upload the theme using "uploadTheme" to set it.' ) - def lockTheme(self): - """ Lock the theme for a Plex object. """ - return self._edit(**{'theme.locked': 1}) - - def unlockTheme(self): - """ Unlock the theme for a Plex object. """ - return self._edit(**{'theme.locked': 0}) - class EditFieldMixin: """ Mixin for editing Plex object fields. """ @@ -752,6 +712,19 @@ class PhotoCapturedTimeMixin(EditFieldMixin): return self.editField('originallyAvailableAt', capturedTime, locked=locked) +class UserRatingMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a user rating. """ + + def editUserRating(self, userRating, locked=True): + """ Edit the user rating. + + Parameters: + userRating (int): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('userRating', userRating, locked=locked) + + class EditTagsMixin: """ Mixin for editing Plex object tags. """ @@ -781,7 +754,7 @@ class EditTagsMixin: items = [items] if not remove: - tags = getattr(self, self._tagPlural(tag)) + tags = getattr(self, self._tagPlural(tag), []) items = tags + items edits = self._tagHelper(self._tagSingular(tag), items, locked, remove) @@ -822,7 +795,7 @@ class EditTagsMixin: if remove: tagname = f'{tag}[].tag.tag-' - data[tagname] = ','.join([quote(str(t)) for t in items]) + data[tagname] = ','.join(quote(str(t)) for t in items) else: for i, item in enumerate(items): tagname = f'{str(tag)}[{i}].tag.tag' @@ -1135,3 +1108,84 @@ class WatchlistMixin: ratingKey = self.guid.rsplit('/', 1)[-1] data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities") return self.findItems(data) + + +class MovieEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, + StudioMixin, SummaryMixin, TaglineMixin, TitleMixin, UserRatingMixin, + CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin +): + pass + + +class ShowEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, + SummaryMixin, TaglineMixin, TitleMixin, UserRatingMixin, + CollectionMixin, GenreMixin, LabelMixin, +): + pass + + +class SeasonEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, SummaryMixin, TitleMixin, UserRatingMixin, + CollectionMixin, LabelMixin +): + pass + + +class EpisodeEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin, + CollectionMixin, DirectorMixin, LabelMixin, WriterMixin +): + pass + + +class ArtistEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin, + CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin +): + pass + + +class AlbumEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, UserRatingMixin, + CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin +): + pass + + +class TrackEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin, UserRatingMixin, + CollectionMixin, LabelMixin, MoodMixin +): + pass + + +class PhotoalbumEditMixins( + ArtLockMixin, PosterLockMixin, + AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin +): + pass + + +class PhotoEditMixins( + ArtLockMixin, PosterLockMixin, + AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin, + TagMixin +): + pass + + +class CollectionEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin, + LabelMixin +): + pass diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index ca3f026a..c90b5d33 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -7,8 +7,9 @@ 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_CONTAINER_SIZE, - X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, log, logfilter, utils) + +from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, 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 @@ -21,51 +22,76 @@ from requests.status_codes import _codes as codes class MyPlexAccount(PlexObject): """ MyPlex account and profile information. This object represents the data found Account on - the myplex.tv servers at the url https://plex.tv/users/account. You may create this object + the myplex.tv servers at the url https://plex.tv/api/v2/user. You may create this object directly by passing in your username & password (or token). There is also a convenience method provided at :class:`~plexapi.server.PlexServer.myPlexAccount()` which will create and return this object. Parameters: - username (str): Your MyPlex username. - password (str): Your MyPlex password. + username (str): Plex login username if not using a token. + password (str): Plex login password if not using a token. + token (str): Plex authentication token instead of username and password. session (requests.Session, optional): Use your own session object if you want to - cache the http responses from PMS + cache the http responses from PMS. timeout (int): timeout in seconds on initial connect to myplex (default config.TIMEOUT). - code (str): Two-factor authentication code to use when logging in. + code (str): Two-factor authentication code to use when logging in with username and password. + remember (bool): Remember the account token for 14 days (Default True). Attributes: - SIGNIN (str): 'https://plex.tv/users/sign_in.xml' - key (str): 'https://plex.tv/users/account' - authenticationToken (str): Unknown. - certificateVersion (str): Unknown. - cloudSyncDevice (str): Unknown. - email (str): Your current Plex email address. + key (str): 'https://plex.tv/api/v2/user' + adsConsent (str): Unknown. + adsConsentReminderAt (str): Unknown. + adsConsentSetAt (str): Unknown. + anonymous (str): Unknown. + authToken (str): The account token. + backupCodesCreated (bool): If the two-factor authentication backup codes have been created. + confirmed (bool): If the account has been confirmed. + country (str): The account country. + email (str): The account email address. + emailOnlyAuth (bool): If login with email only is enabled. + experimentalFeatures (bool): If experimental features are enabled. + friendlyName (str): Your account full name. entitlements (List): List of devices your allowed to use with this account. - guest (bool): Unknown. - home (bool): Unknown. - homeSize (int): Unknown. - id (int): Your Plex account ID. - locale (str): Your Plex locale - mailing_list_status (str): Your current mailing list status. - maxHomeSize (int): Unknown. + guest (bool): If the account is a Plex Home guest user. + hasPassword (bool): If the account has a password. + home (bool): If the account is a Plex Home user. + homeAdmin (bool): If the account is the Plex Home admin. + homeSize (int): The number of accounts in the Plex Home. + id (int): The Plex account ID. + joinedAt (datetime): Date the account joined Plex. + locale (str): the account locale + mailingListActive (bool): If you are subscribed to the Plex newsletter. + mailingListStatus (str): Your current mailing list status. + maxHomeSize (int): The maximum number of accounts allowed in the Plex Home. 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. + profileAutoSelectAudio (bool): If the account has automatically select audio and subtitle tracks enabled. + profileDefaultAudioLanguage (str): The preferred audio language for the account. + profileDefaultSubtitleLanguage (str): The preferred subtitle language for the account. + profileAutoSelectSubtitle (int): The auto-select subtitle mode + (0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). + profileDefaultSubtitleAccessibility (int): The subtitles for the deaf or hard-of-hearing (SDH) searches mode + (0 = Prefer non-SDH subtitles, 1 = Prefer SDH subtitles, 2 = Only show SDH subtitles, + 3 = Only shown non-SDH subtitles). + profileDefaultSubtitleForced (int): The forced subtitles searches mode + (0 = Prefer non-forced subtitles, 1 = Prefer forced subtitles, 2 = Only show forced subtitles, + 3 = Only show non-forced subtitles). + protected (bool): If the account has a Plex Home PIN enabled. + rememberExpiresAt (datetime): Date the token expires. + restricted (bool): If the account is a Plex Home managed user. roles: (List) Lit of account roles. Plexpass membership listed here. - scrobbleTypes (str): Description - secure (bool): Description - subscriptionActive (bool): True if your subscription is active. - subscriptionFeatures: (List) List of features allowed on your subscription. - subscriptionPlan (str): Name of subscription plan. - subscriptionStatus (str): String representation of `subscriptionActive`. - thumb (str): URL of your account thumbnail. - title (str): Unknown. - Looks like an alias for `username`. - username (str): Your account username. - uuid (str): Unknown. - _token (str): Token used to access this client. - _session (obj): Requests session object used to access this client. + scrobbleTypes (List): Unknown. + subscriptionActive (bool): If the account's Plex Pass subscription is active. + subscriptionDescription (str): Description of the Plex Pass subscription. + subscriptionFeatures: (List) List of features allowed on your Plex Pass subscription. + subscriptionPaymentService (str): Payment service used for your Plex Pass subscription. + subscriptionPlan (str): Name of Plex Pass subscription plan. + subscriptionStatus (str): String representation of ``subscriptionActive``. + subscriptionSubscribedAt (datetime): Date the account subscribed to Plex Pass. + thumb (str): URL of the account thumbnail. + title (str): The title of the account (username or friendly name). + twoFactorEnabled (bool): If two-factor authentication is enabled. + username (str): The account username. + uuid (str): The account UUID. """ FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data HOMEUSERS = 'https://plex.tv/api/home/users' @@ -76,7 +102,8 @@ class MyPlexAccount(PlexObject): FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, 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 + SIGNIN = 'https://plex.tv/api/v2/users/signin' # post with auth + SIGNOUT = 'https://plex.tv/api/v2/users/signout' # delete 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 @@ -85,86 +112,106 @@ class MyPlexAccount(PlexObject): VOD = 'https://vod.provider.plex.tv' # get MUSIC = 'https://music.provider.plex.tv' # get METADATA = 'https://metadata.provider.plex.tv' - # Key may someday switch to the following url. For now the current value works. - # https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId} - key = 'https://plex.tv/users/account' + key = 'https://plex.tv/api/v2/user' - def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None): - self._token = token or CONFIG.get('auth.server_token') + def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None, remember=True): + self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token')) self._session = session or requests.Session() self._sonos_cache = [] self._sonos_cache_timestamp = 0 - data, initpath = self._signin(username, password, code, timeout) + data, initpath = self._signin(username, password, code, remember, timeout) super(MyPlexAccount, self).__init__(self, data, initpath) - def _signin(self, username, password, code, timeout): + def _signin(self, username, password, code, remember, timeout): if self._token: return self.query(self.key), self.key - username = username or CONFIG.get('auth.myplex_username') - password = password or CONFIG.get('auth.myplex_password') + payload = { + 'login': username or CONFIG.get('auth.myplex_username'), + 'password': password or CONFIG.get('auth.myplex_password'), + 'rememberMe': remember + } if code: - password += code - data = self.query(self.SIGNIN, method=self._session.post, auth=(username, password), timeout=timeout) + payload['verificationCode'] = code + data = self.query(self.SIGNIN, method=self._session.post, data=payload, timeout=timeout) return data, self.SIGNIN + def signout(self): + """ Sign out of the Plex account. Invalidates the authentication token. """ + return self.query(self.SIGNOUT, method=self._session.delete) + def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data - self._token = logfilter.add_secret(data.attrib.get('authenticationToken')) + self._token = logfilter.add_secret(data.attrib.get('authToken')) self._webhooks = [] - self.authenticationToken = self._token - self.certificateVersion = data.attrib.get('certificateVersion') - self.cloudSyncDevice = data.attrib.get('cloudSyncDevice') + + self.adsConsent = data.attrib.get('adsConsent') + self.adsConsentReminderAt = data.attrib.get('adsConsentReminderAt') + self.adsConsentSetAt = data.attrib.get('adsConsentSetAt') + self.anonymous = data.attrib.get('anonymous') + self.authToken = self._token + self.backupCodesCreated = utils.cast(bool, data.attrib.get('backupCodesCreated')) + self.confirmed = utils.cast(bool, data.attrib.get('confirmed')) + self.country = data.attrib.get('country') self.email = data.attrib.get('email') + self.emailOnlyAuth = utils.cast(bool, data.attrib.get('emailOnlyAuth')) + self.experimentalFeatures = utils.cast(bool, data.attrib.get('experimentalFeatures')) + self.friendlyName = data.attrib.get('friendlyName') self.guest = utils.cast(bool, data.attrib.get('guest')) + self.hasPassword = utils.cast(bool, data.attrib.get('hasPassword')) self.home = utils.cast(bool, data.attrib.get('home')) + self.homeAdmin = utils.cast(bool, data.attrib.get('homeAdmin')) self.homeSize = utils.cast(int, data.attrib.get('homeSize')) self.id = utils.cast(int, data.attrib.get('id')) + self.joinedAt = utils.toDatetime(data.attrib.get('joinedAt')) self.locale = data.attrib.get('locale') - self.mailing_list_status = data.attrib.get('mailing_list_status') + self.mailingListActive = utils.cast(bool, data.attrib.get('mailingListActive')) + self.mailingListStatus = data.attrib.get('mailingListStatus') 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.protected = utils.cast(bool, data.attrib.get('protected')) + self.rememberExpiresAt = utils.toDatetime(data.attrib.get('rememberExpiresAt')) self.restricted = utils.cast(bool, data.attrib.get('restricted')) - self.scrobbleTypes = data.attrib.get('scrobbleTypes') - self.secure = utils.cast(bool, data.attrib.get('secure')) + self.scrobbleTypes = [utils.cast(int, x) for x in data.attrib.get('scrobbleTypes').split(',')] self.thumb = data.attrib.get('thumb') self.title = data.attrib.get('title') + self.twoFactorEnabled = utils.cast(bool, data.attrib.get('twoFactorEnabled')) self.username = data.attrib.get('username') self.uuid = data.attrib.get('uuid') subscription = data.find('subscription') self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active')) - self.subscriptionStatus = subscription.attrib.get('status') + self.subscriptionDescription = data.attrib.get('subscriptionDescription') + self.subscriptionFeatures = self.listAttrs(subscription, 'id', rtag='features', etag='feature') + self.subscriptionPaymentService = subscription.attrib.get('paymentService') self.subscriptionPlan = subscription.attrib.get('plan') - self.subscriptionFeatures = self.listAttrs(subscription, 'id', etag='feature') + self.subscriptionStatus = subscription.attrib.get('status') + self.subscriptionSubscribedAt = utils.toDatetime(subscription.attrib.get('subscribedAt'), '%Y-%m-%d %H:%M:%S %Z') - self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role') + profile = data.find('profile') + self.profileAutoSelectAudio = utils.cast(bool, profile.attrib.get('autoSelectAudio')) + self.profileDefaultAudioLanguage = profile.attrib.get('defaultAudioLanguage') + self.profileDefaultSubtitleLanguage = profile.attrib.get('defaultSubtitleLanguage') + self.profileAutoSelectSubtitle = utils.cast(int, profile.attrib.get('autoSelectSubtitle')) + self.profileDefaultSubtitleAccessibility = utils.cast(int, profile.attrib.get('defaultSubtitleAccessibility')) + self.profileDefaultSubtitleForces = utils.cast(int, profile.attrib.get('defaultSubtitleForces')) self.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement') + self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role') - # TODO: Fetch missing MyPlexAccount attributes - self.profile_settings = None + # TODO: Fetch missing MyPlexAccount services self.services = None - self.joined_at = None - def device(self, name=None, clientId=None): - """ Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified. + @property + def authenticationToken(self): + """ Returns the authentication token for the account. Alias for ``authToken``. """ + return self.authToken - Parameters: - name (str): Name to match against. - clientId (str): clientIdentifier to match against. - """ - for device in self.devices(): - if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId): - return device - 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. """ - data = self.query(MyPlexDevice.key) - return [MyPlexDevice(self, elem) for elem in data] + def _reload(self, key=None, **kwargs): + """ Perform the actual reload. """ + data = self.query(self.key) + self._loadData(data) + return self def _headers(self, **kwargs): """ Returns dict containing base headers for all requests to the server. """ @@ -188,6 +235,8 @@ class MyPlexAccount(PlexObject): raise Unauthorized(message) elif response.status_code == 404: raise NotFound(message) + elif response.status_code == 422 and "Invalid token" in response.text: + raise Unauthorized(message) else: raise BadRequest(message) if headers.get('Accept') == 'application/json': @@ -195,6 +244,23 @@ class MyPlexAccount(PlexObject): data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None + def device(self, name=None, clientId=None): + """ Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified. + + Parameters: + name (str): Name to match against. + clientId (str): clientIdentifier to match against. + """ + for device in self.devices(): + if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId): + return device + 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. """ + data = self.query(MyPlexDevice.key) + return [MyPlexDevice(self, elem) for elem in data] + def resource(self, name): """ Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified. @@ -784,7 +850,7 @@ class MyPlexAccount(PlexObject): raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}') return response.json()['token'] - def history(self, maxresults=9999999, mindate=None): + def history(self, maxresults=None, mindate=None): """ Get Play History for all library sections on all servers for the owner. Parameters: @@ -817,7 +883,7 @@ class MyPlexAccount(PlexObject): data = self.query(f'{self.MUSIC}/hubs') return self.findItems(data) - def watchlist(self, filter=None, sort=None, libtype=None, maxresults=9999999, **kwargs): + def watchlist(self, filter=None, sort=None, libtype=None, maxresults=None, **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. @@ -857,23 +923,10 @@ 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) - 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'] - - # totalSize is available in first response, update maxresults from it - totalSize = utils.cast(int, data.attrib.get('totalSize')) - if maxresults > totalSize: - maxresults = totalSize - - return self._toOnlineMetadata(results, **kwargs) + key = f'{self.METADATA}/library/sections/watchlist/{filter}{utils.joinArgs(params)}' + return self._toOnlineMetadata(self.fetchItems(key, maxresults=maxresults), **kwargs) def onWatchlist(self, item): """ Returns True if the item is on the user's watchlist. @@ -936,6 +989,48 @@ class MyPlexAccount(PlexObject): data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState") return self.findItem(data, cls=UserState) + def isPlayed(self, item): + """ Return True if the item is played on Discover. + + Parameters: + item (:class:`~plexapi.video.Movie`, + :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or + :class:`~plexapi.video.Episode`): Object from searchDiscover(). + Can be also result from Plex Movie or Plex TV Series agent. + """ + userState = self.userState(item) + return bool(userState.viewCount > 0) if userState.viewCount else False + + def markPlayed(self, item): + """ Mark the Plex object as played on Discover. + + Parameters: + item (:class:`~plexapi.video.Movie`, + :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or + :class:`~plexapi.video.Episode`): Object from searchDiscover(). + Can be also result from Plex Movie or Plex TV Series agent. + """ + key = f'{self.METADATA}/actions/scrobble' + ratingKey = item.guid.rsplit('/', 1)[-1] + params = {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'} + self.query(key, params=params) + return self + + def markUnplayed(self, item): + """ Mark the Plex object as unplayed on Discover. + + Parameters: + item (:class:`~plexapi.video.Movie`, + :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or + :class:`~plexapi.video.Episode`): Object from searchDiscover(). + Can be also result from Plex Movie or Plex TV Series agent. + """ + key = f'{self.METADATA}/actions/unscrobble' + ratingKey = item.guid.rsplit('/', 1)[-1] + params = {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'} + self.query(key, params=params) + return self + 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. @@ -1117,7 +1212,7 @@ class MyPlexUser(PlexObject): raise NotFound(f'Unable to find server {name}') - def history(self, maxresults=9999999, mindate=None): + def history(self, maxresults=None, mindate=None): """ Get all Play History for a user in all shared servers. Parameters: maxresults (int): Only return the specified number of results (optional). @@ -1191,7 +1286,7 @@ class Section(PlexObject): self.sectionId = self.id # For backwards compatibility self.sectionKey = self.key # For backwards compatibility - def history(self, maxresults=9999999, mindate=None): + def history(self, maxresults=None, mindate=None): """ Get all Play History for a user for this section in this shared server. Parameters: maxresults (int): Only return the specified number of results (optional). @@ -1266,21 +1361,25 @@ class MyPlexResource(PlexObject): """ This object represents resources connected to your Plex server that can provide content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml for the data presented here can be found at: - https://plex.tv/api/resources?includeHttps=1&includeRelay=1 + https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1 Attributes: TAG (str): 'Device' - key (str): 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1' - accessToken (str): This resources accesstoken. + key (str): 'https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1' + accessToken (str): This resource's Plex access token. clientIdentifier (str): Unique ID for this resource. connections (list): List of :class:`~plexapi.myplex.ResourceConnection` objects for this resource. createdAt (datetime): Timestamp this resource first connected to your server. device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc). + dnsRebindingProtection (bool): True if the server had DNS rebinding protection. home (bool): Unknown + httpsRequired (bool): True if the resource requires https. lastSeenAt (datetime): Timestamp this resource last connected. name (str): Descriptive name of this resource. + natLoopbackSupported (bool): True if the resource supports NAT loopback. owned (bool): True if this resource is one of your own (you logged into it). + ownerId (int): ID of the user that owns this resource (shared resources only). platform (str): OS the resource is running (Linux, Windows, Chrome, etc.) platformVersion (str): Version of the platform. presence (bool): True if the resource is online @@ -1288,10 +1387,13 @@ class MyPlexResource(PlexObject): productVersion (str): Version of the product. provides (str): List of services this resource provides (client, server, player, pubsub-player, etc.) + publicAddressMatches (bool): True if the public IP address matches the client's public IP address. + relay (bool): True if this resource has the Plex Relay enabled. + sourceTitle (str): Username of the user that owns this resource (shared resources only). synced (bool): Unknown (possibly True if the resource has synced content?) """ - TAG = 'Device' - key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1' + TAG = 'resource' + key = 'https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1' # Default order to prioritize available resource connections DEFAULT_LOCATION_ORDER = ['local', 'remote', 'relay'] @@ -1299,33 +1401,35 @@ class MyPlexResource(PlexObject): def _loadData(self, data): self._data = data - self.name = data.attrib.get('name') self.accessToken = logfilter.add_secret(data.attrib.get('accessToken')) - self.product = data.attrib.get('product') - self.productVersion = data.attrib.get('productVersion') + self.clientIdentifier = data.attrib.get('clientIdentifier') + self.connections = self.findItems(data, ResourceConnection, rtag='connections') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt'), "%Y-%m-%dT%H:%M:%SZ") + self.device = data.attrib.get('device') + self.dnsRebindingProtection = utils.cast(bool, data.attrib.get('dnsRebindingProtection')) + self.home = utils.cast(bool, data.attrib.get('home')) + self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired')) + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'), "%Y-%m-%dT%H:%M:%SZ") + self.name = data.attrib.get('name') + self.natLoopbackSupported = utils.cast(bool, data.attrib.get('natLoopbackSupported')) + self.owned = utils.cast(bool, data.attrib.get('owned')) + self.ownerId = utils.cast(int, data.attrib.get('ownerId', 0)) self.platform = data.attrib.get('platform') self.platformVersion = data.attrib.get('platformVersion') - self.device = data.attrib.get('device') - self.clientIdentifier = data.attrib.get('clientIdentifier') - self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) - self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) - self.provides = data.attrib.get('provides') - self.owned = utils.cast(bool, data.attrib.get('owned')) - self.home = utils.cast(bool, data.attrib.get('home')) - self.synced = utils.cast(bool, data.attrib.get('synced')) self.presence = utils.cast(bool, data.attrib.get('presence')) - self.connections = self.findItems(data, ResourceConnection) + self.product = data.attrib.get('product') + self.productVersion = data.attrib.get('productVersion') + self.provides = data.attrib.get('provides') self.publicAddressMatches = utils.cast(bool, data.attrib.get('publicAddressMatches')) - # This seems to only be available if its not your device (say are shared server) - self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired')) - self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0)) - self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username. + self.relay = utils.cast(bool, data.attrib.get('relay')) + self.sourceTitle = data.attrib.get('sourceTitle') + self.synced = utils.cast(bool, data.attrib.get('synced')) def preferred_connections( self, ssl=None, - locations=DEFAULT_LOCATION_ORDER, - schemes=DEFAULT_SCHEME_ORDER, + locations=None, + schemes=None, ): """ Returns a sorted list of the available connection addresses for this resource. Often times there is more than one address specified for a server or client. @@ -1336,6 +1440,11 @@ class MyPlexResource(PlexObject): only connect to HTTP connections. Set None (default) to connect to any HTTP or HTTPS connection. """ + if locations is None: + locations = self.DEFAULT_LOCATION_ORDER[:] + if schemes is None: + schemes = self.DEFAULT_SCHEME_ORDER[:] + connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations} for connection in self.connections: # Only check non-local connections unless we own the resource @@ -1359,8 +1468,8 @@ class MyPlexResource(PlexObject): self, ssl=None, timeout=None, - locations=DEFAULT_LOCATION_ORDER, - schemes=DEFAULT_SCHEME_ORDER, + locations=None, + schemes=None, ): """ Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object. Uses `MyPlexResource.preferred_connections()` to generate the priority order of connection addresses. @@ -1376,11 +1485,16 @@ class MyPlexResource(PlexObject): Raises: :exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. """ + if locations is None: + locations = self.DEFAULT_LOCATION_ORDER[:] + if schemes is None: + schemes = self.DEFAULT_SCHEME_ORDER[:] + 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 - listargs = [[cls, url, self.accessToken, timeout] for url in connections] + listargs = [[cls, url, self.accessToken, self._server._session, timeout] for url in connections] log.debug('Testing %s resource connections..', len(listargs)) results = utils.threaded(_connect, listargs) return _chooseConnection('Resource', self.name, results) @@ -1392,24 +1506,27 @@ class ResourceConnection(PlexObject): Attributes: TAG (str): 'Connection' - address (str): Local IP address - httpuri (str): Full local address - local (bool): True if local - port (int): 32400 + address (str): The connection IP address + httpuri (str): Full HTTP URL + ipv6 (bool): True if the address is IPv6 + local (bool): True if the address is local + port (int): The connection port protocol (str): HTTP or HTTPS - uri (str): External address + relay (bool): True if the address uses the Plex Relay + uri (str): Full connetion URL """ - TAG = 'Connection' + TAG = 'connection' def _loadData(self, data): self._data = data - self.protocol = data.attrib.get('protocol') self.address = data.attrib.get('address') - self.port = utils.cast(int, data.attrib.get('port')) - self.uri = data.attrib.get('uri') + self.ipv6 = utils.cast(bool, data.attrib.get('IPv6')) self.local = utils.cast(bool, data.attrib.get('local')) - self.httpuri = f'http://{self.address}:{self.port}' + self.port = utils.cast(int, data.attrib.get('port')) + self.protocol = data.attrib.get('protocol') self.relay = utils.cast(bool, data.attrib.get('relay')) + self.uri = data.attrib.get('uri') + self.httpuri = f'http://{self.address}:{self.port}' class MyPlexDevice(PlexObject): @@ -1475,7 +1592,7 @@ class MyPlexDevice(PlexObject): :exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device. """ cls = PlexServer if 'server' in self.provides else PlexClient - listargs = [[cls, url, self.token, timeout] for url in self.connections] + listargs = [[cls, url, self.token, self._server._session, timeout] for url in self.connections] log.debug('Testing %s device connections..', len(listargs)) results = utils.threaded(_connect, listargs) return _chooseConnection('Device', self.name, results) @@ -1725,7 +1842,7 @@ class MyPlexPinLogin: return ElementTree.fromstring(data) if data.strip() else None -def _connect(cls, url, token, timeout, results, i, job_is_done_event=None): +def _connect(cls, url, token, session, timeout, results, i, job_is_done_event=None): """ Connects to the specified cls with url and token. Stores the connection information to results[i] in a threadsafe way. @@ -1733,6 +1850,7 @@ def _connect(cls, url, token, timeout, results, i, job_is_done_event=None): cls: the class which is responsible for establishing connection, basically it's :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` url (str): url which should be passed as `baseurl` argument to cls.__init__() + session (requests.Session): session which sould be passed as `session` argument to cls.__init() token (str): authentication token which should be passed as `baseurl` argument to cls.__init__() timeout (int): timeout which should be passed as `baseurl` argument to cls.__init__() results (list): pre-filled list for results @@ -1742,7 +1860,7 @@ def _connect(cls, url, token, timeout, results, i, job_is_done_event=None): """ starttime = time.time() try: - device = cls(baseurl=url, token=token, timeout=timeout) + device = cls(baseurl=url, token=token, session=session, timeout=timeout) runtime = int(time.time() - starttime) results[i] = (url, token, device, runtime) if X_PLEX_ENABLE_FAST_CONNECT and job_is_done_event: diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index 4c3d89b5..039ac80c 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -8,8 +8,7 @@ from plexapi.exceptions import BadRequest from plexapi.mixins import ( RatingMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, - AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, PhotoCapturedTimeMixin, - TagMixin + PhotoalbumEditMixins, PhotoEditMixins ) @@ -18,7 +17,7 @@ class Photoalbum( PlexPartialObject, RatingMixin, ArtMixin, PosterMixin, - AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin + PhotoalbumEditMixins ): """ Represents a single Photoalbum (collection of photos). @@ -146,8 +145,7 @@ class Photo( PlexPartialObject, Playable, RatingMixin, ArtUrlMixin, PosterUrlMixin, - AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin, - TagMixin + PhotoEditMixins ): """ Represents a single Photo. diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index f5ece634..c435613a 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -433,7 +433,8 @@ class Playlist( """ Copy playlist to another user account. Parameters: - user (str): Username, email or user id of the user to copy the playlist to. + user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username, + email, or user id of the user to copy the playlist to. """ userServer = self._server.switchUser(user) return self.create(server=userServer, title=self.title, items=self.items()) diff --git a/lib/plexapi/playqueue.py b/lib/plexapi/playqueue.py index 4c49f8d2..9835c0dd 100644 --- a/lib/plexapi/playqueue.py +++ b/lib/plexapi/playqueue.py @@ -170,7 +170,7 @@ class PlayQueue(PlexObject): } if isinstance(items, list): - item_keys = ",".join([str(x.ratingKey) for x in items]) + item_keys = ",".join(str(x.ratingKey) for x in items) uri_args = quote_plus(f"/library/metadata/{item_keys}") args["uri"] = f"library:///directory/{uri_args}" args["type"] = items[0].listType diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index 4a1a51ce..69d5f89a 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- +import os +from functools import cached_property from urllib.parse import urlencode from xml.etree import ElementTree import requests -import os -from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log, - logfilter) + +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter from plexapi import utils from plexapi.alert import AlertListener from plexapi.base import PlexObject @@ -17,7 +18,7 @@ from plexapi.media import Conversion, Optimized from plexapi.playlist import Playlist from plexapi.playqueue import PlayQueue from plexapi.settings import Settings -from plexapi.utils import cached_property, deprecated +from plexapi.utils import deprecated from requests.status_codes import _codes as codes # Need these imports to populate utils.PLEXOBJECTS @@ -236,12 +237,13 @@ class PlexServer(PlexObject): q = self.query(f'/security/token?type={type}&scope={scope}') return q.attrib.get('token') - def switchUser(self, username, session=None, timeout=None): + def switchUser(self, user, session=None, timeout=None): """ Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username. Note: Only the admin account can switch to other users. Parameters: - username (str): Username, email or user id of the user to log in to the server. + user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username, + email, or user id of the user to log in to the server. session (requests.Session, optional): Use your own session object if you want to cache the http responses from the server. This will default to the same session as the admin account if no new session is provided. @@ -260,7 +262,8 @@ class PlexServer(PlexObject): userPlex = plex.switchUser("Username") """ - user = self.myPlexAccount().user(username) + from plexapi.myplex import MyPlexUser + user = user if isinstance(user, MyPlexUser) else self.myPlexAccount().user(user) userToken = user.get_token(self.machineIdentifier) if session is None: session = self._session @@ -470,6 +473,7 @@ class PlexServer(PlexObject): sort="episode.originallyAvailableAt:desc", filters={"episode.originallyAvailableAt>>": "4w", "genre": "comedy"} ) + """ return Collection.create( self, title, section, items=items, smart=smart, limit=limit, @@ -535,6 +539,7 @@ class PlexServer(PlexObject): section="Music", m3ufilepath="/path/to/playlist.m3u" ) + """ return Playlist.create( self, title, section=section, items=items, smart=smart, limit=limit, @@ -549,26 +554,28 @@ class PlexServer(PlexObject): """ return PlayQueue.create(self, item, **kwargs) - def downloadDatabases(self, savepath=None, unpack=False): + def downloadDatabases(self, savepath=None, unpack=False, showstatus=False): """ Download databases. Parameters: savepath (str): Defaults to current working dir. unpack (bool): Unpack the zip file. + showstatus(bool): Display a progressbar. """ url = self.url('/diagnostics/databases') - filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack) + filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack, showstatus=showstatus) return filepath - def downloadLogs(self, savepath=None, unpack=False): + def downloadLogs(self, savepath=None, unpack=False, showstatus=False): """ Download server logs. Parameters: savepath (str): Defaults to current working dir. unpack (bool): Unpack the zip file. + showstatus(bool): Display a progressbar. """ url = self.url('/diagnostics/logs') - filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack) + filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack, showstatus=showstatus) return filepath def butlerTasks(self): @@ -588,6 +595,7 @@ class PlexServer(PlexObject): availableTasks = [task.name for task in plex.butlerTasks()] print("Available butler tasks:", availableTasks) + """ validTasks = [task.name for task in self.butlerTasks()] if task not in validTasks: @@ -630,7 +638,7 @@ class PlexServer(PlexObject): # figure out what method this is.. return self.query(part, method=self._session.put) - def history(self, maxresults=9999999, mindate=None, ratingKey=None, accountID=None, librarySectionID=None): + def history(self, maxresults=None, mindate=None, ratingKey=None, accountID=None, librarySectionID=None): """ Returns a list of media items from watched history. If there are many results, they will be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first results, it would be wise to set the maxresults option to that @@ -644,7 +652,6 @@ class PlexServer(PlexObject): accountID (int/str) Request history for a specific account ID. librarySectionID (int/str) Request history for a specific library section ID. """ - results, subresults = [], '_init' args = {'sort': 'viewedAt:desc'} if ratingKey: args['metadataItemID'] = ratingKey @@ -654,14 +661,9 @@ class PlexServer(PlexObject): args['librarySectionID'] = librarySectionID if mindate: args['viewedAt>'] = int(mindate.timestamp()) - args['X-Plex-Container-Start'] = 0 - args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults) - while subresults and maxresults > len(results): - 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'] - return results + + key = f'/status/sessions/history/all{utils.joinArgs(args)}' + return self.fetchItems(key, maxresults=maxresults) def playlists(self, playlistType=None, sectionId=None, title=None, sort=None, **kwargs): """ Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server. @@ -794,6 +796,10 @@ class PlexServer(PlexObject): results += hub.items return results + def continueWatching(self): + """ Return a list of all items in the Continue Watching hub. """ + return self.fetchItems('/hubs/continueWatching/items') + def sessions(self): """ Returns a list of all active session (currently playing) media objects. """ return self.fetchItems('/status/sessions') diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py index ef91391b..c191e368 100644 --- a/lib/plexapi/settings.py +++ b/lib/plexapi/settings.py @@ -81,7 +81,7 @@ class Settings(PlexObject): params[setting.id] = quote(setting._setValue) if not params: raise BadRequest('No setting have been modified.') - querystr = '&'.join([f'{k}={v}' for k, v in params.items()]) + 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() diff --git a/lib/plexapi/sonos.py b/lib/plexapi/sonos.py index a7a57f4d..14f83d31 100644 --- a/lib/plexapi/sonos.py +++ b/lib/plexapi/sonos.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import requests + from plexapi import CONFIG, X_PLEX_IDENTIFIER from plexapi.client import PlexClient from plexapi.exceptions import BadRequest diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index d7d5afd1..d1882fbb 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -15,20 +15,17 @@ from datetime import datetime from getpass import getpass from threading import Event, Thread from urllib.parse import quote +from requests.status_codes import _codes as codes import requests -from plexapi.exceptions import BadRequest, NotFound + +from plexapi.exceptions import BadRequest, NotFound, Unauthorized try: from tqdm import tqdm except ImportError: tqdm = None -try: - from functools import cached_property -except ImportError: - from backports.cached_property import cached_property # noqa: F401 - log = logging.getLogger('plexapi') # Search Types - Plex uses these to filter specific media types when searching. @@ -106,7 +103,7 @@ class SecretsFilter(logging.Filter): self.secrets = secrets or set() def add_secret(self, secret): - if secret is not None: + if secret is not None and secret != '': self.secrets.add(secret) return secret @@ -128,7 +125,9 @@ def registerPlexObject(cls): etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE)) ehash = f'{cls.TAG}.{etype}' if etype else cls.TAG if getattr(cls, '_SESSIONTYPE', None): - ehash = f"{ehash}.{'session'}" + ehash = f"{ehash}.session" + elif getattr(cls, '_HISTORYTYPE', None): + ehash = f"{ehash}.history" if ehash in PLEXOBJECTS: raise Exception(f'Ambiguous PlexObject definition {cls.__name__}(tag={cls.TAG}, type={etype}) ' f'with {PLEXOBJECTS[ehash].__name__}') @@ -391,12 +390,12 @@ def downloadSessionImages(server, filename=None, height=150, width=150, prettyname = media._prettyfilename() filename = f'session_transcode_{media.usernames[0]}_{prettyname}_{int(time.time())}' url = server.transcodeImage(url, height, width, opacity, saturation) - filepath = download(url, filename=filename) + filepath = download(url, server._token, filename=filename) info['username'] = {'filepath': filepath, 'url': url} return info -def download(url, token, filename=None, savepath=None, session=None, chunksize=4024, +def download(url, token, filename=None, savepath=None, session=None, chunksize=4024, # noqa: C901 unpack=False, mocked=False, showstatus=False): """ Helper to download a thumb, videofile or other media item. Returns the local path to the downloaded file. @@ -419,6 +418,17 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 session = session or requests.Session() headers = {'X-Plex-Token': token} response = session.get(url, headers=headers, stream=True) + if response.status_code not in (200, 201, 204): + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + message = f'({response.status_code}) {codename}; {response.url} {errtext}' + if response.status_code == 401: + raise Unauthorized(message) + elif response.status_code == 404: + raise NotFound(message) + else: + raise BadRequest(message) + # make sure the savepath directory exists savepath = savepath or os.getcwd() os.makedirs(savepath, exist_ok=True) diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index d7f4d665..486bb5ca 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -3,19 +3,17 @@ import os from urllib.parse import quote_plus from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject, PlexSession +from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, - ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin, - AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, - StudioMixin, SummaryMixin, TaglineMixin, TitleMixin, - CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin, + ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin, + MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins, WatchlistMixin ) -class Video(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin): +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`. @@ -186,20 +184,20 @@ class Video(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin): .. code-block:: python - # Optimize for mobile using defaults - video.optimize(target="mobile") + # 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 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 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) + # Optimize for tv the next 5 unwatched episodes + show.optimize(target="tv", limit=5, unwatched=True) """ from plexapi.library import Location @@ -309,9 +307,7 @@ class Movie( Video, Playable, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, ArtMixin, PosterMixin, ThemeMixin, - ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, - SummaryMixin, TaglineMixin, TitleMixin, - CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin, + MovieEditMixins, WatchlistMixin ): """ Represents a single Movie. @@ -330,6 +326,7 @@ class Movie( duration (int): Duration of the movie in milliseconds. editionTitle (str): The edition title of the movie (e.g. Director's Cut, Extended Edition, etc.). enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled. + (-1 = Library default, 0 = Disabled) 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. @@ -441,15 +438,20 @@ class Movie( } return self.section().search(filters=filters) + def removeFromContinueWatching(self): + """ Remove the movie from continue watching. """ + key = '/actions/removeFromContinueWatching' + params = {'ratingKey': self.ratingKey} + self._server.query(key, params=params, method=self._server._session.put) + return self + @utils.registerPlexObject class Show( Video, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, - ArtMixin, BannerMixin, PosterMixin, ThemeMixin, - ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, - SummaryMixin, TaglineMixin, TitleMixin, - CollectionMixin, GenreMixin, LabelMixin, + ArtMixin, PosterMixin, ThemeMixin, + ShowEditMixins, WatchlistMixin ): """ Represents a single Show (including all seasons and episodes). @@ -467,12 +469,12 @@ class Show( autoDeletionItemPolicyWatchedLibrary (int): Setting that indicates if episodes are deleted after being watched for the show (0 = Never, 1 = After a day, 7 = After a week, 100 = On next refresh). - banner (str): Key to banner artwork (/library/metadata//banner/). childCount (int): Number of seasons (including Specials) in the show. collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. contentRating (str) Content rating (PG-13; NR; TV-G). duration (int): Typical duration of the show episodes in milliseconds. enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled. + (-1 = Library default, 0 = Disabled). episodeSort (int): Setting that indicates how episodes are sorted for the show (-1 = Library default, 0 = Oldest first, 1 = Newest first). flattenSeasons (int): Setting that indicates if seasons are set to hidden for the show @@ -494,7 +496,8 @@ class Show( roles (List<:class:`~plexapi.media.Role`>): List of role objects. seasonCount (int): Number of seasons (excluding Specials) in the show. showOrdering (str): Setting that indicates the episode ordering for the show - (None = Library default). + (None = Library default, tmdbAiring = The Movie Database (Aired), + aired = TheTVDB (Aired), dvd = TheTVDB (DVD), absolute = TheTVDB (Absolute)). similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment). subtitleLanguage (str): Setting that indicates the preferred subtitle language. @@ -521,7 +524,6 @@ class Show( int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0')) self.autoDeletionItemPolicyWatchedLibrary = utils.cast( int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0')) - self.banner = data.attrib.get('banner') self.childCount = utils.cast(int, data.attrib.get('childCount')) self.collections = self.findItems(data, media.Collection) self.contentRating = data.attrib.get('contentRating') @@ -659,8 +661,7 @@ class Season( Video, AdvancedSettingsMixin, ExtrasMixin, RatingMixin, ArtMixin, PosterMixin, ThemeUrlMixin, - SummaryMixin, TitleMixin, - CollectionMixin, LabelMixin + SeasonEditMixins ): """ Represents a single Show Season (including all episodes). @@ -740,10 +741,12 @@ class Season( """ Returns the season number. """ return self.index - def episodes(self, **kwargs): - """ Returns a list of :class:`~plexapi.video.Episode` objects in the season. """ - key = f'{self.key}/children' - return self.fetchItems(key, Episode, **kwargs) + def onDeck(self): + """ Returns season's On Deck :class:`~plexapi.video.Video` object or `None`. + Will only return a match if the show's On Deck episode is in this season. + """ + data = self._server.query(self._details_key) + return next(iter(self.findItems(data, rtag='OnDeck')), None) def episode(self, title=None, episode=None): """ Returns the episode with the given title or number. @@ -766,17 +769,15 @@ class Season( return self.fetchItem(key, Episode, parentIndex=self.index, index=index) raise BadRequest('Missing argument: title or episode is required') + def episodes(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Episode` objects in the season. """ + key = f'{self.key}/children' + return self.fetchItems(key, Episode, **kwargs) + def get(self, title=None, episode=None): """ Alias to :func:`~plexapi.video.Season.episode`. """ return self.episode(title, episode) - def onDeck(self): - """ Returns season's On Deck :class:`~plexapi.video.Video` object or `None`. - Will only return a match if the show's On Deck episode is in this season. - """ - data = self._server.query(self._details_key) - return next(iter(self.findItems(data, rtag='OnDeck')), None) - def show(self): """ Return the season's :class:`~plexapi.video.Show`. """ return self.fetchItem(self.parentKey) @@ -813,8 +814,7 @@ class Episode( Video, Playable, ExtrasMixin, RatingMixin, ArtMixin, PosterMixin, ThemeUrlMixin, - ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin, - CollectionMixin, DirectorMixin, LabelMixin, WriterMixin + EpisodeEditMixins ): """ Represents a single Shows Episode. @@ -906,7 +906,7 @@ class Episode( # If seasons are hidden, parentKey and parentRatingKey are missing from the XML response. # https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553 - if self.skipParent and not self.parentRatingKey: + if self.skipParent and data.attrib.get('parentRatingKey') is None: # Parse the parentRatingKey from the parentThumb if self.parentThumb and self.parentThumb.startswith('/library/metadata/'): self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3]) @@ -993,6 +993,13 @@ class Episode( """ Returns str, default title for a new syncItem. """ return f'{self.grandparentTitle} - {self.parentTitle} - ({self.seasonEpisode}) {self.title}' + def removeFromContinueWatching(self): + """ Remove the movie from continue watching. """ + key = '/actions/removeFromContinueWatching' + params = {'ratingKey': self.ratingKey} + self._server.query(key, params=params, method=self._server._session.put) + return self + @utils.registerPlexObject class Clip( @@ -1105,3 +1112,42 @@ class ClipSession(PlexSession, Clip): """ Load attribute values from Plex XML response. """ Clip._loadData(self, data) PlexSession._loadData(self, data) + + +@utils.registerPlexObject +class MovieHistory(PlexHistory, Movie): + """ Represents a single Movie history entry + loaded from :func:`~plexapi.server.PlexServer.history`. + """ + _HISTORYTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Movie._loadData(self, data) + PlexHistory._loadData(self, data) + + +@utils.registerPlexObject +class EpisodeHistory(PlexHistory, Episode): + """ Represents a single Episode history entry + loaded from :func:`~plexapi.server.PlexServer.history`. + """ + _HISTORYTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Episode._loadData(self, data) + PlexHistory._loadData(self, data) + + +@utils.registerPlexObject +class ClipHistory(PlexHistory, Clip): + """ Represents a single Clip history entry + loaded from :func:`~plexapi.server.PlexServer.history`. + """ + _HISTORYTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Clip._loadData(self, data) + PlexHistory._loadData(self, data) diff --git a/requirements.txt b/requirements.txt index 472235de..3fc87314 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ MarkupSafe==2.1.3 musicbrainzngs==0.7.1 packaging==23.1 paho-mqtt==1.6.1 -plexapi==4.13.4 +plexapi==4.15.0 portend==3.2.0 profilehooks==1.12.0 PyJWT==2.8.0