diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index 370fe0dc..0eb397cc 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import annotations + import os from pathlib import Path from urllib.parse import quote_plus @@ -17,6 +19,7 @@ from plexapi.playlist import Playlist TAudio = TypeVar("TAudio", bound="Audio") +TTrack = TypeVar("TTrack", bound="Track") class Audio(PlexPartialObject, PlayedUnplayedMixin): @@ -532,6 +535,22 @@ class Track( guid_hash = utils.sha1hash(self.parentGuid) return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + def sonicAdventure( + self: TTrack, + to: TTrack, + **kwargs: Any, + ) -> list[TTrack]: + """Returns a sonic adventure from the current track to the specified track. + + Parameters: + to (:class:`~plexapi.audio.Track`): The target track for the sonic adventure. + **kwargs: Additional options passed into :func:`~plexapi.library.MusicSection.sonicAdventure`. + + Returns: + List[:class:`~plexapi.audio.Track`]: list of tracks in the sonic adventure. + """ + return self.section().sonicAdventure(self, to, **kwargs) + @utils.registerPlexObject class TrackSession(PlexSession, Track): diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 52063c00..0852426d 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -1,13 +1,21 @@ # -*- coding: utf-8 -*- import re +from typing import TYPE_CHECKING, Generic, Iterable, List, Optional, TypeVar, Union import weakref from functools import cached_property from urllib.parse import urlencode from xml.etree import ElementTree +from xml.etree.ElementTree import Element from plexapi import CONFIG, X_PLEX_CONTAINER_SIZE, log, utils from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported +if TYPE_CHECKING: + from plexapi.server import PlexServer + +PlexObjectT = TypeVar("PlexObjectT", bound='PlexObject') +MediaContainerT = TypeVar("MediaContainerT", bound="MediaContainer") + USER_DONT_RELOAD_FOR_KEYS = set() _DONT_RELOAD_FOR_KEYS = {'key'} OPERATORS = { @@ -95,7 +103,7 @@ class PlexObject: ehash = f"{ehash}.session" elif initpath.startswith('/status/sessions/history'): ehash = f"{ehash}.history" - ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag)) + ecls = utils.getPlexObject(ehash, default=elem.tag) # log.debug('Building %s as %s', elem.tag, ecls.__name__) if ecls is not None: return ecls(self._server, elem, initpath, parent=self) @@ -116,14 +124,22 @@ class PlexObject: or disable each parameter individually by setting it to False or 0. """ details_key = self.key + params = {} + if details_key and hasattr(self, '_INCLUDES'): - includes = {} for k, v in self._INCLUDES.items(): - value = kwargs.get(k, v) + value = kwargs.pop(k, v) if value not in [False, 0, '0']: - includes[k] = 1 if value is True else value - if includes: - details_key += '?' + urlencode(sorted(includes.items())) + params[k] = 1 if value is True else value + + if details_key and hasattr(self, '_EXCLUDES'): + for k, v in self._EXCLUDES.items(): + value = kwargs.pop(k, None) + if value is not None: + params[k] = 1 if value is True else value + + if params: + details_key += '?' + urlencode(sorted(params.items())) return details_key def _isChildOf(self, **kwargs): @@ -227,7 +243,7 @@ class PlexObject: fetchItem(ekey, viewCount__gte=0) fetchItem(ekey, Media__container__in=["mp4", "mkv"]) - fetchItem(ekey, guid__regex=r"com\.plexapp\.agents\.(imdb|themoviedb)://|tt\d+") + fetchItem(ekey, guid__regex=r"com\\.plexapp\\.agents\\.(imdb|themoviedb)://|tt\d+") fetchItem(ekey, guid__id__regex=r"(imdb|tmdb|tvdb)://") fetchItem(ekey, Media__Part__file__startswith="D:\\Movies") @@ -245,8 +261,7 @@ class PlexObject: if maxresults is not None: container_size = min(container_size, maxresults) - results = [] - subresults = [] + results = MediaContainer[cls](self._server, Element('MediaContainer'), initpath=ekey) headers = {} while True: @@ -324,7 +339,7 @@ class PlexObject: if rtag: data = next(utils.iterXMLBFS(data, rtag), []) # loop through all data elements to find matches - items = [] + items = MediaContainer[cls](self._server, data, initpath=initpath) if data.tag == 'MediaContainer' else [] for elem in data: if self._checkAttrs(elem, **kwargs): item = self._buildItemOrNone(elem, cls, initpath) @@ -498,7 +513,14 @@ class PlexPartialObject(PlexObject): 'includeRelated': 1, 'includeRelatedCount': 1, 'includeReviews': 1, - 'includeStations': 1 + 'includeStations': 1, + } + _EXCLUDES = { + 'excludeElements': ( + 'Media,Genre,Country,Guid,Rating,Collection,Director,Writer,Role,Producer,Similar,Style,Mood,Format' + ), + 'excludeFields': 'summary,tagline', + 'skipRefresh': 1, } def __eq__(self, other): @@ -996,7 +1018,11 @@ class PlexHistory(object): return self._server.query(self.historyKey, method=self._server._session.delete) -class MediaContainer(PlexObject): +class MediaContainer( + Generic[PlexObjectT], + List[PlexObjectT], + PlexObject, +): """ Represents a single MediaContainer. Attributes: @@ -1009,11 +1035,71 @@ class MediaContainer(PlexObject): librarySectionUUID (str): :class:`~plexapi.library.LibrarySection` UUID. mediaTagPrefix (str): "/system/bundle/media/flags/" mediaTagVersion (int): Unknown + offset (int): The offset of current results. size (int): The number of items in the hub. + totalSize (int): The total number of items for the query. """ TAG = 'MediaContainer' + def __init__( + self, + server: "PlexServer", + data: Element, + *args: PlexObjectT, + initpath: Optional[str] = None, + parent: Optional[PlexObject] = None, + ) -> None: + # super calls Generic.__init__ which calls list.__init__ eventually + super().__init__(*args) + PlexObject.__init__(self, server, data, initpath, parent) + + def extend( + self: MediaContainerT, + __iterable: Union[Iterable[PlexObjectT], MediaContainerT], + ) -> None: + curr_size = self.size if self.size is not None else len(self) + super().extend(__iterable) + # update size, totalSize, and offset + if not isinstance(__iterable, MediaContainer): + return + + # prefer the totalSize of the new iterable even if it is smaller + self.totalSize = ( + __iterable.totalSize + if __iterable.totalSize is not None + else self.totalSize + ) # ideally both should be equal + + # the size of the new iterable is added to the current size + self.size = curr_size + ( + __iterable.size if __iterable.size is not None else len(__iterable) + ) + + # the offset is the minimum of the two, prefering older values + if self.offset is not None and __iterable.offset is not None: + self.offset = min(self.offset, __iterable.offset) + else: + self.offset = ( + self.offset if self.offset is not None else __iterable.offset + ) + + # for all other attributes, overwrite with the new iterable's values if previously None + for key in ( + "allowSync", + "augmentationKey", + "identifier", + "librarySectionID", + "librarySectionTitle", + "librarySectionUUID", + "mediaTagPrefix", + "mediaTagVersion", + ): + if (not hasattr(self, key)) or (getattr(self, key) is None): + if not hasattr(__iterable, key): + continue + setattr(self, key, getattr(__iterable, key)) + def _loadData(self, data): self._data = data self.allowSync = utils.cast(int, data.attrib.get('allowSync')) @@ -1024,4 +1110,6 @@ class MediaContainer(PlexObject): self.librarySectionUUID = data.attrib.get('librarySectionUUID') self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.offset = utils.cast(int, data.attrib.get("offset")) self.size = utils.cast(int, data.attrib.get('size')) + self.totalSize = utils.cast(int, data.attrib.get("totalSize")) diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index 809455ea..5f591a4a 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -503,6 +503,8 @@ class Collection( :class:`~plexapi.collection.Collection`: A new instance of the created Collection. """ if smart: + if items: + raise BadRequest('Cannot create a smart collection with items.') return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs) else: return cls._create(server, title, section, items) diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index c427177f..e043d8c2 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -4,6 +4,6 @@ # Library version MAJOR_VERSION = 4 MINOR_VERSION = 15 -PATCH_VERSION = 10 +PATCH_VERSION = 11 __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 5b06f009..662b5462 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- +from __future__ import annotations + import re +from typing import Any, TYPE_CHECKING import warnings from collections import defaultdict from datetime import datetime @@ -17,6 +20,10 @@ from plexapi.settings import Setting from plexapi.utils import deprecated +if TYPE_CHECKING: + from plexapi.audio import Track + + class Library(PlexObject): """ Represents a PlexServer library. This contains all sections of media defined in your Plex server including video, shows and audio. @@ -376,7 +383,8 @@ class Library(PlexObject): part = (f'/library/sections?name={quote_plus(name)}&type={type}&agent={agent}' f'&scanner={quote_plus(scanner)}&language={language}&{urlencode(locations, doseq=True)}') if kwargs: - part += urlencode(kwargs) + prefs_params = {f'prefs[{k}]': v for k, v in kwargs.items()} + part += f'&{urlencode(prefs_params)}' return self._server.query(part, method=self._server._session.post) def history(self, maxresults=None, mindate=None): @@ -655,7 +663,7 @@ class LibrarySection(PlexObject): guidLookup = {} for item in library.all(): guidLookup[item.guid] = item - guidLookup.update({guid.id: item for guid in item.guids}} + guidLookup.update({guid.id: item for guid in item.guids}) result1 = guidLookup['plex://show/5d9c086c46115600200aa2fe'] result2 = guidLookup['imdb://tt0944947'] @@ -2033,6 +2041,31 @@ class MusicSection(LibrarySection, ArtistEditMixins, AlbumEditMixins, TrackEditM kwargs['policy'] = Policy.create(limit) return super(MusicSection, self).sync(**kwargs) + def sonicAdventure( + self, + start: Track | int, + end: Track | int, + **kwargs: Any, + ) -> list[Track]: + """ Returns a list of tracks from this library section that are part of a sonic adventure. + ID's should be of a track, other ID's will return an empty list or items itself or an error. + + Parameters: + start (Track | int): The :class:`~plexapi.audio.Track` or ID of the first track in the sonic adventure. + end (Track | int): The :class:`~plexapi.audio.Track` or ID of the last track in the sonic adventure. + kwargs: Additional parameters to pass to :func:`~plexapi.base.PlexObject.fetchItems`. + + Returns: + List[:class:`~plexapi.audio.Track`]: a list of tracks from this library section + that are part of a sonic adventure. + """ + # can not use Track due to circular import + startID = start if isinstance(start, int) else start.ratingKey + endID = end if isinstance(end, int) else end.ratingKey + + key = f"/library/sections/{self.key}/computePath?startID={startID}&endID={endID}" + return self.fetchItems(key, **kwargs) + class PhotoSection(LibrarySection, PhotoalbumEditMixins, PhotoEditMixins): """ Represents a :class:`~plexapi.library.LibrarySection` section containing photos. diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index 14ef88ed..a50b6200 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -434,6 +434,8 @@ class Playlist( if m3ufilepath: return cls._createFromM3U(server, title, section, m3ufilepath) elif smart: + if items: + raise BadRequest('Cannot create a smart playlist with items.') return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs) else: return cls._create(server, title, items) diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index 467ccf05..bb128532 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -136,6 +136,19 @@ def registerPlexObject(cls): return cls +def getPlexObject(ehash, default): + """ Return the PlexObject class for the specified ehash. This recursively looks up the class + with the highest specificity, falling back to the default class if not found. + """ + cls = PLEXOBJECTS.get(ehash) + if cls is not None: + return cls + if '.' in ehash: + ehash = ehash.rsplit('.', 1)[0] + return getPlexObject(ehash, default=default) + return PLEXOBJECTS.get(default) + + def cast(func, value): """ Cast the specified value to the specified type (returned by func). Currently this only support str, int, float, bool. Should be extended if needed. diff --git a/requirements.txt b/requirements.txt index 63e46b06..af94378a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ musicbrainzngs==0.7.1 packaging==24.0 paho-mqtt==2.0.0 platformdirs==4.2.0 -plexapi==4.15.10 +plexapi==4.15.11 portend==3.2.0 profilehooks==1.12.0 PyJWT==2.8.0