From 7f0abe0fe670dd4003346084940c230ae0b5256e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jan 2022 11:08:49 -0800 Subject: [PATCH] Bump plexapi from 4.8.0 to 4.9.1 (#1627) * Bump plexapi from 4.8.0 to 4.9.1 Bumps [plexapi](https://github.com/pkkid/python-plexapi) from 4.8.0 to 4.9.1. - [Release notes](https://github.com/pkkid/python-plexapi/releases) - [Commits](https://github.com/pkkid/python-plexapi/compare/4.8.0...4.9.1) --- updated-dependencies: - dependency-name: plexapi dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Update plexapi==4.9.1 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> --- lib/plexapi/audio.py | 6 ++ lib/plexapi/const.py | 4 +- lib/plexapi/library.py | 188 ++++++++++++++++++++++++++++----------- lib/plexapi/media.py | 42 ++++++--- lib/plexapi/playlist.py | 10 +++ lib/plexapi/playqueue.py | 41 ++++++++- lib/plexapi/server.py | 17 +++- lib/plexapi/settings.py | 20 +++-- requirements.txt | 2 +- 9 files changed, 254 insertions(+), 76 deletions(-) diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index 656b9250..60b119d9 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -8,6 +8,7 @@ from plexapi.exceptions import BadRequest from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin from plexapi.mixins import RatingMixin, SplitMergeMixin, UnmatchMatchMixin from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin +from plexapi.playlist import Playlist class Audio(PlexPartialObject): @@ -222,6 +223,11 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, S filepaths += track.download(_savepath, keep_original_name, **kwargs) return filepaths + def station(self): + """ Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """ + key = '%s?includeStations=1' % self.key + return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None) + @utils.registerPlexObject class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index 61c96c0b..dc8b5693 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -3,7 +3,7 @@ # Library version MAJOR_VERSION = 4 -MINOR_VERSION = 8 -PATCH_VERSION = 0 +MINOR_VERSION = 9 +PATCH_VERSION = 1 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index 12ae407f..c67ae9b7 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -82,6 +82,28 @@ class Library(PlexObject): except KeyError: raise NotFound('Invalid library sectionID: %s' % sectionID) from None + def hubs(self, sectionID=None, identifier=None, **kwargs): + """ Returns a list of :class:`~plexapi.library.Hub` across all library sections. + + Parameters: + sectionID (int or str or list, optional): + IDs of the sections to limit results or "playlists". + identifier (str or list, optional): + Names of identifiers to limit results. + Available on `Hub` instances as the `hubIdentifier` attribute. + Examples: 'home.continue' or 'home.ondeck' + """ + if sectionID: + if not isinstance(sectionID, list): + sectionID = [sectionID] + kwargs['contentDirectoryID'] = ",".join(map(str, sectionID)) + if identifier: + if not isinstance(identifier, list): + identifier = [identifier] + kwargs['identifier'] = ",".join(identifier) + key = '/hubs%s' % utils.joinArgs(kwargs) + return self.fetchItems(key) + def all(self, **kwargs): """ Returns a list of all media from all library sections. This may be a very large dataset to retrieve. @@ -169,7 +191,7 @@ class Library(PlexObject): name (str): Name of the library agent (str): Example com.plexapp.agents.imdb type (str): movie, show, # check me - location (str): /path/to/files + location (str or list): /path/to/files, ["/path/to/files", "/path/to/morefiles"] language (str): Two letter language fx en kwargs (dict): Advanced options should be passed as a dict. where the id is the key. @@ -308,8 +330,16 @@ class Library(PlexObject): 40:South Africa, 41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States, 48:Uruguay, 49:Venezuela. """ - part = '/library/sections?name=%s&type=%s&agent=%s&scanner=%s&language=%s&location=%s' % ( - quote_plus(name), type, agent, quote_plus(scanner), language, quote_plus(location)) # noqa E126 + if isinstance(location, str): + location = [location] + locations = [] + for path in location: + if not self._server.isBrowsable(path): + raise BadRequest('Path: %s does not exist.' % path) + locations.append(('location', path)) + + part = '/library/sections?name=%s&type=%s&agent=%s&scanner=%s&language=%s&%s' % ( + quote_plus(name), type, agent, quote_plus(scanner), language, urlencode(locations, doseq=True)) # noqa E126 if kwargs: part += urlencode(kwargs) return self._server.query(part, method=self._server._session.post) @@ -486,16 +516,76 @@ class LibrarySection(PlexObject): return self def edit(self, agent=None, **kwargs): - """ Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage. + """ Edit a library. See :class:`~plexapi.library.Library` for example usage. Parameters: + agent (str, optional): The library agent. kwargs (dict): Dict of settings to edit. """ if not agent: agent = self.agent - part = '/library/sections/%s?agent=%s&%s' % (self.key, agent, urlencode(kwargs)) + + locations = [] + if kwargs.get('location'): + if isinstance(kwargs['location'], str): + kwargs['location'] = [kwargs['location']] + for path in kwargs.pop('location'): + if not self._server.isBrowsable(path): + raise BadRequest('Path: %s does not exist.' % path) + locations.append(('location', path)) + + params = list(kwargs.items()) + locations + + part = '/library/sections/%s?agent=%s&%s' % (self.key, agent, urlencode(params, doseq=True)) self._server.query(part, method=self._server._session.put) + def addLocations(self, location): + """ Add a location to a library. + + Parameters: + location (str or list): A single folder path, list of paths. + + Example: + + .. code-block:: python + + LibrarySection.addLocations('/path/1') + LibrarySection.addLocations(['/path/1', 'path/2', '/path/3']) + """ + locations = self.locations + if isinstance(location, str): + location = [location] + for path in location: + if not self._server.isBrowsable(path): + raise BadRequest('Path: %s does not exist.' % path) + locations.append(path) + self.edit(location=locations) + + def removeLocations(self, location): + """ Remove a location from a library. + + Parameters: + location (str or list): A single folder path, list of paths. + + Example: + + .. code-block:: python + + LibrarySection.removeLocations('/path/1') + LibrarySection.removeLocations(['/path/1', 'path/2', '/path/3']) + """ + locations = self.locations + if isinstance(location, str): + location = [location] + for path in location: + if path in locations: + locations.remove(path) + else: + raise BadRequest('Path: %s does not exist in the library.' % location) + if len(locations) == 0: + raise BadRequest('You are unable to remove all locations from a library.') + self.edit(location=locations) + def get(self, title): """ Returns the media item with the specified title. @@ -510,9 +600,7 @@ class LibrarySection(PlexObject): def getGuid(self, guid): """ Returns the media item with the specified external IMDB, TMDB, or TVDB ID. - Note: This search uses a PlexAPI operator so performance may be slow. All items from the - entire Plex library need to be retrieved for each guid search. It is recommended to create - your own lookup dictionary if you are searching for a lot of external guids. + Note: Only available for the Plex Movie and Plex TV Series agents. Parameters: guid (str): The external guid of the item to return. @@ -525,20 +613,23 @@ class LibrarySection(PlexObject): .. code-block:: python - # This will retrieve all items in the entire library 3 times result1 = library.getGuid('imdb://tt0944947') result2 = library.getGuid('tmdb://1399') result3 = library.getGuid('tvdb://121361') - # This will only retrieve all items in the library once to create a lookup dictionary + # Alternatively, create your own guid lookup dictionary for faster performance guidLookup = {guid.id: item for item in library.all() for guid in item.guids} result1 = guidLookup['imdb://tt0944947'] result2 = guidLookup['tmdb://1399'] result3 = guidLookup['tvdb://121361'] """ - key = '/library/sections/%s/all?includeGuids=1' % self.key - return self.fetchItem(key, Guid__id__iexact=guid) + try: + dummy = self.search(maxresults=1)[0] + match = dummy.matches(agent=self.agent, title=guid.replace('://', '-')) + return self.search(guid=match[0].guid)[0] + except IndexError: + raise NotFound("Guid '%s' is not found in the library" % guid) from None def all(self, libtype=None, **kwargs): """ Returns a list of all items from this library section. @@ -556,13 +647,13 @@ class LibrarySection(PlexObject): def hubs(self): """ Returns a list of available :class:`~plexapi.library.Hub` for this library section. """ - key = '/hubs/sections/%s' % self.key + key = '/hubs/sections/%s?includeStations=1' % self.key return self.fetchItems(key) def agents(self): """ Returns a list of available :class:`~plexapi.media.Agent` for this library section. """ - return self._server.agents(utils.searchType(self.type)) + return self._server.agents(self.type) def settings(self): """ Returns a list of all library settings. """ @@ -606,6 +697,36 @@ class LibrarySection(PlexObject): self.edit(**data) + def _lockUnlockAllField(self, field, libtype=None, locked=True): + """ Lock or unlock a field for all items in the library. """ + libtype = libtype or self.TYPE + args = { + 'type': utils.searchType(libtype), + '%s.locked' % field: int(locked) + } + key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args)) + self._server.query(key, method=self._server._session.put) + + def lockAllField(self, field, libtype=None): + """ Lock a field for all items in the library. + + Parameters: + field (str): The field to lock (e.g. thumb, rating, collection). + libtype (str, optional): The library type to lock (movie, show, season, episode, + artist, album, track, photoalbum, photo). Default is the main library type. + """ + self._lockUnlockAllField(field, libtype=libtype, locked=True) + + def unlockAllField(self, field, libtype=None): + """ Unlock a field for all items in the library. + + Parameters: + field (str): The field to unlock (e.g. thumb, rating, collection). + libtype (str, optional): The library type to lock (movie, show, season, episode, + artist, album, track, photoalbum, photo). Default is the main library type. + """ + self._lockUnlockAllField(field, libtype=libtype, locked=False) + def timeline(self): """ Returns a timeline query for this library section. """ key = '/library/sections/%s/timeline' % self.key @@ -1718,9 +1839,8 @@ class MusicSection(LibrarySection): return self.fetchItems(key) def stations(self): - """ Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ - key = '/hubs/sections/%s?includeStations=1' % self.key - return self.fetchItems(key, cls=Station) + """ Returns a list of :class:`~plexapi.playlist.Playlist` stations in this section. """ + return next((hub.items for hub in self.hubs() if hub.context == 'hub.music.stations'), None) def searchArtists(self, **kwargs): """ Search for an artist. See :func:`~plexapi.library.LibrarySection.search` for usage. """ @@ -1934,6 +2054,7 @@ class Hub(PlexObject): context (str): The context of the hub. hubKey (str): API URL for these specific hub items. hubIdentifier (str): The identifier of the hub. + items (list): List of items in the hub. key (str): API URL for the hub. more (bool): True if there are more items to load (call reload() to fetch all items). size (int): The number of items in the hub. @@ -2086,39 +2207,6 @@ class Place(HubMediaTag): TAGTYPE = 400 -@utils.registerPlexObject -class Station(PlexObject): - """ Represents the Station area in the MusicSection. - - Attributes: - TITLE (str): 'Stations' - TYPE (str): 'station' - hubIdentifier (str): Unknown. - size (int): Number of items found. - title (str): Title of this Hub. - type (str): Type of items in the Hub. - more (str): Unknown. - style (str): Unknown - items (str): List of items in the Hub. - """ - TITLE = 'Stations' - TYPE = 'station' - - def _loadData(self, data): - """ Load attribute values from Plex XML response. """ - self._data = data - self.hubIdentifier = data.attrib.get('hubIdentifier') - self.size = utils.cast(int, data.attrib.get('size')) - self.title = data.attrib.get('title') - self.type = data.attrib.get('type') - self.more = data.attrib.get('more') - self.style = data.attrib.get('style') - self.items = self.findItems(data) - - def __len__(self): - return self.size - - class FilteringType(PlexObject): """ Represents a single filtering Type object for a library. diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 95385c4a..87c33383 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -6,6 +6,7 @@ from urllib.parse import quote_plus from plexapi import log, settings, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest +from plexapi.utils import deprecated @utils.registerPlexObject @@ -1058,31 +1059,50 @@ class Agent(PlexObject): self.hasAttribution = data.attrib.get('hasAttribution') self.hasPrefs = data.attrib.get('hasPrefs') self.identifier = data.attrib.get('identifier') + self.name = data.attrib.get('name') self.primary = data.attrib.get('primary') self.shortIdentifier = self.identifier.rsplit('.', 1)[1] - if 'mediaType' in self._initpath: - self.name = data.attrib.get('name') - self.languageCode = [] - for code in data: - self.languageCode += [code.attrib.get('code')] - else: - self.mediaTypes = [AgentMediaType(server=self._server, data=d) for d in data] - def _settings(self): + if 'mediaType' in self._initpath: + self.languageCodes = self.listAttrs(data, 'code', etag='Language') + self.mediaTypes = [] + else: + self.languageCodes = [] + self.mediaTypes = self.findItems(data, cls=AgentMediaType) + + @property + @deprecated('use "languageCodes" instead') + def languageCode(self): + return self.languageCodes + + def settings(self): key = '/:/plugins/%s/prefs' % self.identifier data = self._server.query(key) return self.findItems(data, cls=settings.Setting) + @deprecated('use "settings" instead') + def _settings(self): + return self.settings() + class AgentMediaType(Agent): + """ Represents a single Agent MediaType. + + Attributes: + TAG (str): 'MediaType' + """ + TAG = 'MediaType' def __repr__(self): uid = self._clean(self.firstAttr('name')) return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p]) def _loadData(self, data): + self.languageCodes = self.listAttrs(data, 'code', etag='Language') self.mediaType = utils.cast(int, data.attrib.get('mediaType')) self.name = data.attrib.get('name') - self.languageCode = [] - for code in data: - self.languageCode += [code.attrib.get('code')] + + @property + @deprecated('use "languageCodes" instead') + def languageCode(self): + return self.languageCodes diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index e470b76d..ee4694d2 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -29,7 +29,11 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi icon (str): Icon URI string for smart playlists. key (str): API URL (/playlist/). leafCount (int): Number of items in the playlist view. + librarySectionID (int): Library section identifier (radio only) + librarySectionKey (str): Library section key (radio only) + librarySectionTitle (str): Library section title (radio only) playlistType (str): 'audio', 'video', or 'photo' + radio (bool): If this playlist represents a radio station ratingKey (int): Unique key identifying the playlist. smart (bool): True if the playlist is a smart playlist. summary (str): Summary of the playlist. @@ -54,7 +58,11 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi self.icon = data.attrib.get('icon') self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50 self.leafCount = utils.cast(int, data.attrib.get('leafCount')) + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.playlistType = data.attrib.get('playlistType') + self.radio = utils.cast(bool, data.attrib.get('radio', 0)) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.smart = utils.cast(bool, data.attrib.get('smart')) self.summary = data.attrib.get('summary') @@ -169,6 +177,8 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi def items(self): """ Returns a list of all items in the playlist. """ + if self.radio: + return [] if self._items is None: key = '%s/items' % self.key items = self.fetchItems(key) diff --git a/lib/plexapi/playqueue.py b/lib/plexapi/playqueue.py index ca6fda63..6697532a 100644 --- a/lib/plexapi/playqueue.py +++ b/lib/plexapi/playqueue.py @@ -175,8 +175,11 @@ class PlayQueue(PlexObject): args["uri"] = "library:///directory/{uri_args}".format(uri_args=uri_args) args["type"] = items[0].listType elif items.type == "playlist": - args["playlistID"] = items.ratingKey args["type"] = items.playlistType + if items.radio: + args["uri"] = f"server://{server.machineIdentifier}/{server.library.identifier}{items.key}" + else: + args["playlistID"] = items.ratingKey else: uuid = items.section().uuid args["type"] = items.listType @@ -192,6 +195,42 @@ class PlayQueue(PlexObject): c._server = server return c + @classmethod + def fromStationKey(cls, server, key): + """Create and return a new :class:`~plexapi.playqueue.PlayQueue`. + + This is a convenience method to create a `PlayQueue` for + radio stations when only the `key` string is available. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server you are connected to. + key (str): A station key as provided by :func:`~plexapi.library.LibrarySection.hubs()` + or :func:`~plexapi.audio.Artist.station()` + + Example: + + .. code-block:: python + + from plexapi.playqueue import PlayQueue + music = server.library.section("Music") + artist = music.get("Artist Name") + station = artist.station() + key = station.key # "/library/metadata/12855/station/8bd39616-dbdb-459e-b8da-f46d0b170af4?type=10" + pq = PlayQueue.fromStationKey(server, key) + client = server.clients()[0] + client.playMedia(pq) + """ + args = { + "type": "audio", + "uri": f"server://{server.machineIdentifier}/{server.library.identifier}{key}" + } + path = f"/playQueues{utils.joinArgs(args)}" + data = server.query(path, method=server._session.post) + c = cls(server, data, initpath=path) + c.playQueueType = args["type"] + c._server = server + return c + def addItem(self, item, playNext=False, refresh=True): """ Append the provided item to the "Up Next" section of the PlayQueue. diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index ab359b3f..cb672197 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -3,6 +3,7 @@ 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 utils @@ -228,10 +229,10 @@ class PlexServer(PlexObject): return activities def agents(self, mediaType=None): - """ Returns the :class:`~plexapi.media.Agent` objects this server has available. """ + """ Returns a list of :class:`~plexapi.media.Agent` objects this server has available. """ key = '/system/agents' if mediaType: - key += '?mediaType=%s' % mediaType + key += '?mediaType=%s' % utils.searchType(mediaType) return self.fetchItems(key) def createToken(self, type='delegation', scope='all'): @@ -384,6 +385,18 @@ class PlexServer(PlexObject): for path, paths, files in self.walk(_path): yield path, paths, files + def isBrowsable(self, path): + """ Returns True if the Plex server can browse the given path. + + Parameters: + path (:class:`~plexapi.library.Path` or str): Full path to browse. + """ + if isinstance(path, Path): + path = path.path + path = os.path.normpath(path) + paths = [p.path for p in self.browse(os.path.dirname(path), includeFiles=False)] + return path in paths + def clients(self): """ Returns list of all :class:`~plexapi.client.PlexClient` objects connected to server. """ items = [] diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py index 30c1c583..81fdf9b1 100644 --- a/lib/plexapi/settings.py +++ b/lib/plexapi/settings.py @@ -113,17 +113,19 @@ class Setting(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._setValue = None + self.type = data.attrib.get('type') + self.advanced = utils.cast(bool, data.attrib.get('advanced')) + self.default = self._cast(data.attrib.get('default')) + self.enumValues = self._getEnumValues(data) + self.group = data.attrib.get('group') + self.hidden = utils.cast(bool, data.attrib.get('hidden')) self.id = data.attrib.get('id') self.label = data.attrib.get('label') + self.option = data.attrib.get('option') + self.secure = utils.cast(bool, data.attrib.get('secure')) self.summary = data.attrib.get('summary') - self.type = data.attrib.get('type') - self.default = self._cast(data.attrib.get('default')) self.value = self._cast(data.attrib.get('value')) - self.hidden = utils.cast(bool, data.attrib.get('hidden')) - self.advanced = utils.cast(bool, data.attrib.get('advanced')) - self.group = data.attrib.get('group') - self.enumValues = self._getEnumValues(data) + self._setValue = None def _cast(self, value): """ Cast the specific value to the type of this setting. """ @@ -132,8 +134,8 @@ class Setting(PlexObject): return value def _getEnumValues(self, data): - """ Returns a list of dictionary of valis value for this setting. """ - enumstr = data.attrib.get('enumValues') + """ Returns a list or dictionary of values for this setting. """ + enumstr = data.attrib.get('enumValues') or data.attrib.get('values') if not enumstr: return None if ':' in enumstr: diff --git a/requirements.txt b/requirements.txt index 0d82a99a..d9f84441 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ musicbrainzngs==0.7.1 oauthlib==3.1.1 packaging==21.3 paho-mqtt==1.6.1 -plexapi==4.8.0 +plexapi==4.9.1 portend==3.1.0 profilehooks==1.12.0 PyJWT==2.3.0