diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index 2a169877..370fe0dc 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -3,6 +3,8 @@ import os from pathlib import Path from urllib.parse import quote_plus +from typing import Any, Dict, List, Optional, TypeVar + from plexapi import media, utils from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession from plexapi.exceptions import BadRequest @@ -14,6 +16,9 @@ from plexapi.mixins import ( from plexapi.playlist import Playlist +TAudio = TypeVar("TAudio", bound="Audio") + + class Audio(PlexPartialObject, PlayedUnplayedMixin): """ Base class for all audio objects including :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`. @@ -22,6 +27,7 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin): addedAt (datetime): Datetime the item was added to the library. art (str): URL to artwork image (/library/metadata//art/). artBlurHash (str): BlurHash string for artwork image. + distance (float): Sonic Distance of the item from the seed item. fields (List<:class:`~plexapi.media.Field`>): List of field objects. guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c). index (int): Plex index number (often the track number). @@ -53,6 +59,7 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin): self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') + self.distance = utils.cast(float, data.attrib.get('distance')) self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') self.index = utils.cast(int, data.attrib.get('index')) @@ -125,6 +132,37 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin): return myplex.sync(sync_item, client=client, clientId=clientId) + def sonicallySimilar( + self: TAudio, + limit: Optional[int] = None, + maxDistance: Optional[float] = None, + **kwargs, + ) -> List[TAudio]: + """Returns a list of sonically similar audio items. + + Parameters: + limit (int): Maximum count of items to return. Default 50 (server default) + maxDistance (float): Maximum distance between tracks, 0.0 - 1.0. Default 0.25 (server default). + **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.fetchItems`. + + Returns: + List[:class:`~plexapi.audio.Audio`]: list of sonically similar audio items. + """ + + key = f"{self.key}/nearest" + params: Dict[str, Any] = {} + if limit is not None: + params['limit'] = limit + if maxDistance is not None: + params['maxDistance'] = maxDistance + key += utils.joinArgs(params) + + return self.fetchItems( + key, + cls=type(self), + **kwargs, + ) + @utils.registerPlexObject class Artist( @@ -189,7 +227,7 @@ class Artist( """ Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """ return self.section().search( libtype='album', - filters={'artist.id': self.ratingKey}, + filters={**kwargs.pop('filters', {}), 'artist.id': self.ratingKey}, **kwargs ) @@ -251,7 +289,7 @@ class Artist( @utils.registerPlexObject class Album( Audio, - UnmatchMatchMixin, RatingMixin, + SplitMergeMixin, UnmatchMatchMixin, RatingMixin, ArtMixin, PosterMixin, ThemeUrlMixin, AlbumEditMixins ): @@ -389,6 +427,7 @@ class Track( chapterSource (str): Unknown collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. duration (int): Length of the track in milliseconds. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. grandparentArt (str): URL to album artist artwork (/library/metadata//art/). grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). grandparentKey (str): API URL of the album artist (/library/metadata/). @@ -411,6 +450,8 @@ class Track( primaryExtraKey (str) API URL for the primary extra for the track. ratingCount (int): Number of listeners who have scrobbled this track, as reported by Last.fm. skipCount (int): Number of times the track has been skipped. + sourceURI (str): Remote server URI (server:///com.plexapp.plugins.library) + (remote playlist item only). viewOffset (int): View offset in milliseconds. year (int): Year the track was released. """ @@ -425,6 +466,7 @@ class Track( self.chapterSource = data.attrib.get('chapterSource') self.collections = self.findItems(data, media.Collection) self.duration = utils.cast(int, data.attrib.get('duration')) + self.genres = self.findItems(data, media.Genre) self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentKey = data.attrib.get('grandparentKey') @@ -445,6 +487,7 @@ class Track( self.primaryExtraKey = data.attrib.get('primaryExtraKey') self.ratingCount = utils.cast(int, data.attrib.get('ratingCount')) self.skipCount = utils.cast(int, data.attrib.get('skipCount')) + self.sourceURI = data.attrib.get('source') # remote playlist item self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.year = utils.cast(int, data.attrib.get('year')) diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 822e40ea..52063c00 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -22,12 +22,12 @@ OPERATORS = { 'lt': lambda v, q: v < q, 'lte': lambda v, q: v <= q, 'startswith': lambda v, q: v.startswith(q), - 'istartswith': lambda v, q: v.lower().startswith(q), + 'istartswith': lambda v, q: v.lower().startswith(q.lower()), 'endswith': lambda v, q: v.endswith(q), - 'iendswith': lambda v, q: v.lower().endswith(q), + 'iendswith': lambda v, q: v.lower().endswith(q.lower()), 'exists': lambda v, q: v is not None if q else v is None, - 'regex': lambda v, q: re.match(q, v), - 'iregex': lambda v, q: re.match(q, v, flags=re.IGNORECASE), + 'regex': lambda v, q: bool(re.search(q, v)), + 'iregex': lambda v, q: bool(re.search(q, v, flags=re.IGNORECASE)), } @@ -98,7 +98,7 @@ class PlexObject: ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag)) # log.debug('Building %s as %s', elem.tag, ecls.__name__) if ecls is not None: - return ecls(self._server, elem, initpath) + return ecls(self._server, elem, initpath, parent=self) raise UnknownType(f"Unknown library type <{elem.tag} type='{etype}'../>") def _buildItemOrNone(self, elem, cls=None, initpath=None): @@ -227,7 +227,8 @@ class PlexObject: fetchItem(ekey, viewCount__gte=0) fetchItem(ekey, Media__container__in=["mp4", "mkv"]) - fetchItem(ekey, guid__iregex=r"(imdb://|themoviedb://)") + 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") """ @@ -439,7 +440,7 @@ class PlexObject: attrstr = parts[1] if len(parts) == 2 else None if attrstr: results = [] if results is None else results - for child in [c for c in elem if c.tag.lower() == attr.lower()]: + for child in (c for c in elem if c.tag.lower() == attr.lower()): results += self._getAttrValue(child, attrstr, results) return [r for r in results if r is not None] # check were looking for the tag @@ -565,6 +566,14 @@ class PlexPartialObject(PlexObject): """ Returns True if this is not a full object. """ return not self.isFullObject() + def isLocked(self, field: str): + """ Returns True if the specified field is locked, otherwise False. + + Parameters: + field (str): The name of the field. + """ + return next((f.locked for f in self.fields if f.name == field), False) + def _edit(self, **kwargs): """ Actually edit an object. """ if isinstance(self._edits, dict): @@ -763,6 +772,30 @@ class Playable: for part in item.parts: yield part + def videoStreams(self): + """ Returns a list of :class:`~plexapi.media.videoStream` objects for all MediaParts. """ + if self.isPartialObject(): + self.reload() + return sum((part.videoStreams() for part in self.iterParts()), []) + + def audioStreams(self): + """ Returns a list of :class:`~plexapi.media.AudioStream` objects for all MediaParts. """ + if self.isPartialObject(): + self.reload() + return sum((part.audioStreams() for part in self.iterParts()), []) + + def subtitleStreams(self): + """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """ + if self.isPartialObject(): + self.reload() + return sum((part.subtitleStreams() for part in self.iterParts()), []) + + def lyricStreams(self): + """ Returns a list of :class:`~plexapi.media.LyricStream` objects for all MediaParts. """ + if self.isPartialObject(): + self.reload() + return sum((part.lyricStreams() for part in self.iterParts()), []) + def play(self, client): """ Start playback on the specified client. @@ -953,8 +986,10 @@ class PlexHistory(object): 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) + """ Return the source media object for the history entry + or None if the media no longer exists on the server. + """ + return self.fetchItem(self._details_key) if self._details_key else None def delete(self): """ Delete the history entry. """ diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index 279b4974..76513e79 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import time +import weakref from xml.etree import ElementTree import requests @@ -62,7 +63,8 @@ class PlexClient(PlexObject): key = '/resources' def __init__(self, server=None, data=None, initpath=None, baseurl=None, - identifier=None, token=None, connect=True, session=None, timeout=None): + identifier=None, token=None, connect=True, session=None, timeout=None, + parent=None): super(PlexClient, self).__init__(server, data, initpath) self._baseurl = baseurl.strip('/') if baseurl else None self._clientIdentifier = identifier @@ -76,6 +78,7 @@ class PlexClient(PlexObject): self._last_call = 0 self._timeline_cache = [] self._timeline_cache_timestamp = 0 + self._parent = weakref.ref(parent) if parent is not None else None if not any([data is not None, initpath, baseurl, token]): self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433') self._token = logfilter.add_secret(CONFIG.get('auth.client_token')) diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index 8bc5f286..809455ea 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -276,7 +276,7 @@ class Collection( .. code-block:: python - collection.updateSort(mode="alpha") + collection.sortUpdate(sort="alpha") """ if self.smart: diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index 8a172e98..c427177f 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 = 4 +PATCH_VERSION = 10 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/lib/plexapi/exceptions.py b/lib/plexapi/exceptions.py index c269c38e..182feb13 100644 --- a/lib/plexapi/exceptions.py +++ b/lib/plexapi/exceptions.py @@ -29,3 +29,8 @@ class Unsupported(PlexApiException): class Unauthorized(BadRequest): """ Invalid username/password or token. """ pass + + +class TwoFactorRequired(Unauthorized): + """ Two factor authentication required. """ + pass diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index 87d59eac..5b06f009 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import re +import warnings +from collections import defaultdict from datetime import datetime from functools import cached_property from urllib.parse import parse_qs, quote_plus, urlencode, urlparse @@ -41,14 +43,22 @@ class Library(PlexObject): def _loadSections(self): """ Loads and caches all the library sections. """ key = '/library/sections' - self._sectionsByID = {} - self._sectionsByTitle = {} + sectionsByID = {} + sectionsByTitle = defaultdict(list) + libcls = { + 'movie': MovieSection, + 'show': ShowSection, + 'artist': MusicSection, + 'photo': PhotoSection, + } + for elem in self._server.query(key): - for cls in (MovieSection, ShowSection, MusicSection, PhotoSection): - if elem.attrib.get('type') == cls.TYPE: - section = cls(self._server, elem, key) - self._sectionsByID[section.key] = section - self._sectionsByTitle[section.title.lower().strip()] = section + section = libcls.get(elem.attrib.get('type'), LibrarySection)(self._server, elem, initpath=key) + sectionsByID[section.key] = section + sectionsByTitle[section.title.lower().strip()].append(section) + + self._sectionsByID = sectionsByID + self._sectionsByTitle = dict(sectionsByTitle) def sections(self): """ Returns a list of all media sections in this library. Library sections may be any of @@ -60,18 +70,30 @@ class Library(PlexObject): def section(self, title): """ Returns the :class:`~plexapi.library.LibrarySection` that matches the specified title. + Note: Multiple library sections with the same title is ambiguous. + Use :func:`~plexapi.library.Library.sectionByID` instead for an exact match. Parameters: title (str): Title of the section to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: The library section title is not found on the server. """ normalized_title = title.lower().strip() if not self._sectionsByTitle or normalized_title not in self._sectionsByTitle: self._loadSections() try: - return self._sectionsByTitle[normalized_title] + sections = self._sectionsByTitle[normalized_title] except KeyError: raise NotFound(f'Invalid library section: {title}') from None + if len(sections) > 1: + warnings.warn( + 'Multiple library sections with the same title found, use "sectionByID" instead. ' + 'Returning the last section.' + ) + return sections[-1] + def sectionByID(self, sectionID): """ Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID. @@ -2727,7 +2749,9 @@ class FilteringType(PlexObject): ('id', 'integer', 'Rating Key'), ('index', 'integer', f'{self.type.capitalize()} Number'), ('lastRatedAt', 'date', f'{self.type.capitalize()} Last Rated'), - ('updatedAt', 'date', 'Date Updated') + ('updatedAt', 'date', 'Date Updated'), + ('group', 'string', 'SQL Group By Statement'), + ('having', 'string', 'SQL Having Clause') ] if self.type == 'movie': @@ -2778,11 +2802,14 @@ class FilteringType(PlexObject): manualFields = [] for field, fieldType, fieldTitle in additionalFields: + if field not in {'group', 'having'}: + field = f"{prefix}{field}" fieldXML = ( - f'' ) + manualFields.append(self._manuallyLoadXML(fieldXML, FilteringField)) return manualFields @@ -2922,6 +2949,10 @@ class FilterChoice(PlexObject): self.title = data.attrib.get('title') self.type = data.attrib.get('type') + def items(self): + """ Returns a list of items for this filter choice. """ + return self.fetchItems(self.fastKey) + class ManagedHub(PlexObject): """ Represents a Managed Hub (recommendation) inside a library. diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 369bb759..43c5636d 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -37,7 +37,7 @@ class Media(PlexObject): videoResolution (str): The video resolution of the media (ex: sd). width (int): The width of the video in pixels (ex: 608). - : The following attributes are only available for photos. + Photo_only_attributes: The following attributes are only available for photos. * aperture (str): The aperture used to take the photo. * exposure (str): The exposure used to take the photo. @@ -74,13 +74,13 @@ class Media(PlexObject): self.width = utils.cast(int, data.attrib.get('width')) self.uuid = data.attrib.get('uuid') - if self._isChildOf(etag='Photo'): - self.aperture = data.attrib.get('aperture') - self.exposure = data.attrib.get('exposure') - self.iso = utils.cast(int, data.attrib.get('iso')) - self.lens = data.attrib.get('lens') - self.make = data.attrib.get('make') - self.model = data.attrib.get('model') + # Photo only attributes + self.aperture = data.attrib.get('aperture') + self.exposure = data.attrib.get('exposure') + self.iso = utils.cast(int, data.attrib.get('iso')) + self.lens = data.attrib.get('lens') + self.make = data.attrib.get('make') + self.model = data.attrib.get('model') parent = self._parent() self._parentKey = parent.key @@ -158,11 +158,8 @@ class MediaPart(PlexObject): self.videoProfile = data.attrib.get('videoProfile') def _buildStreams(self, data): - streams = [] - for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream): - items = self.findItems(data, cls, streamType=cls.STREAMTYPE) - streams.extend(items) - return streams + """ Returns a list of :class:`~plexapi.media.MediaPartStream` objects in this MediaPart. """ + return self.findItems(data) @property def hasPreviewThumbnails(self): @@ -216,7 +213,7 @@ class MediaPart(PlexObject): else: params['subtitleStreamID'] = stream - self._server.query(key, method=self._server._session.put) + self._server.query(key, method=self._server._session.put, params=params) return self def resetSelectedSubtitleStream(self): @@ -384,7 +381,7 @@ class AudioStream(MediaPartStream): samplingRate (int): The sampling rate of the audio stream (ex: xxx) streamIdentifier (int): The stream identifier of the audio stream. - : The following attributes are only available for tracks. + Track_only_attributes: The following attributes are only available for tracks. * albumGain (float): The gain for the album. * albumPeak (float): The peak for the album. @@ -411,16 +408,16 @@ class AudioStream(MediaPartStream): self.samplingRate = utils.cast(int, data.attrib.get('samplingRate')) self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier')) - if self._isChildOf(etag='Track'): - self.albumGain = utils.cast(float, data.attrib.get('albumGain')) - self.albumPeak = utils.cast(float, data.attrib.get('albumPeak')) - self.albumRange = utils.cast(float, data.attrib.get('albumRange')) - self.endRamp = data.attrib.get('endRamp') - self.gain = utils.cast(float, data.attrib.get('gain')) - self.loudness = utils.cast(float, data.attrib.get('loudness')) - self.lra = utils.cast(float, data.attrib.get('lra')) - self.peak = utils.cast(float, data.attrib.get('peak')) - self.startRamp = data.attrib.get('startRamp') + # Track only attributes + self.albumGain = utils.cast(float, data.attrib.get('albumGain')) + self.albumPeak = utils.cast(float, data.attrib.get('albumPeak')) + self.albumRange = utils.cast(float, data.attrib.get('albumRange')) + self.endRamp = data.attrib.get('endRamp') + self.gain = utils.cast(float, data.attrib.get('gain')) + self.loudness = utils.cast(float, data.attrib.get('loudness')) + self.lra = utils.cast(float, data.attrib.get('lra')) + self.peak = utils.cast(float, data.attrib.get('peak')) + self.startRamp = data.attrib.get('startRamp') def setSelected(self): """ Sets this audio stream as the selected audio stream. @@ -444,8 +441,10 @@ 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. + hearingImpaired (bool): True if this is a hearing impaired (SDH) subtitle. + perfectMatch (bool): True if the on-demand subtitle is a perfect match. providerTitle (str): The provider title where the on-demand subtitle is downloaded from. - score (int): The match score of the on-demand subtitle. + score (int): The match score (download count) 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. @@ -460,6 +459,8 @@ 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.hearingImpaired = utils.cast(bool, data.attrib.get('hearingImpaired', '0')) + self.perfectMatch = utils.cast(bool, data.attrib.get('perfectMatch')) self.providerTitle = data.attrib.get('providerTitle') self.score = utils.cast(int, data.attrib.get('score')) self.sourceKey = data.attrib.get('sourceKey') @@ -1003,7 +1004,8 @@ class BaseResource(PlexObject): Attributes: TAG (str): 'Photo' or 'Track' key (str): API URL (/library/metadata/). - provider (str): The source of the art or poster, None for Theme objects. + provider (str): The source of the resource. 'local' for local files (e.g. theme.mp3), + None if uploaded or agent-/plugin-supplied. ratingKey (str): Unique key identifying the resource. selected (bool): True if the resource is currently selected. thumb (str): The URL to retrieve the resource thumbnail. diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index e1cce54b..60c24e26 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from collections import deque from datetime import datetime +from typing import Deque, Set, Tuple, Union from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit from plexapi import media, settings, utils @@ -61,63 +63,96 @@ class AdvancedSettingsMixin: class SmartFilterMixin: - """ Mixing for Plex objects that can have smart filters. """ + """ Mixin for Plex objects that can have smart filters. """ + + def _parseFilterGroups(self, feed: Deque[Tuple[str, str]], returnOn: Union[Set[str], None] = None) -> dict: + """ Parse filter groups from input lines between push and pop. """ + currentFiltersStack: list[dict] = [] + operatorForStack = None + if returnOn is None: + returnOn = set("pop") + else: + returnOn.add("pop") + allowedLogicalOperators = ["and", "or"] # first is the default + + while feed: + key, value = feed.popleft() # consume the first item + if key == "push": + # recurse and add the result to the current stack + currentFiltersStack.append( + self._parseFilterGroups(feed, returnOn) + ) + elif key in returnOn: + # stop iterating and return the current stack + if not key == "pop": + feed.appendleft((key, value)) # put the item back + break + + elif key in allowedLogicalOperators: + # set the operator + if operatorForStack and not operatorForStack == key: + raise ValueError( + "cannot have different logical operators for the same" + " filter group" + ) + operatorForStack = key + + else: + # add the key value pair to the current filter + currentFiltersStack.append({key: value}) + + if not operatorForStack and len(currentFiltersStack) > 1: + # consider 'and' as the default operator + operatorForStack = allowedLogicalOperators[0] + + if operatorForStack: + return {operatorForStack: currentFiltersStack} + return currentFiltersStack.pop() + + def _parseQueryFeed(self, feed: "deque[Tuple[str, str]]") -> dict: + """ Parse the query string into a dict. """ + filtersDict = {} + special_keys = {"type", "sort"} + integer_keys = {"includeGuids", "limit"} + as_is_keys = {"group", "having"} + reserved_keys = special_keys | integer_keys | as_is_keys + while feed: + key, value = feed.popleft() + if key in integer_keys: + filtersDict[key] = int(value) + elif key in as_is_keys: + filtersDict[key] = value + elif key == "type": + filtersDict["libtype"] = utils.reverseSearchType(value) + elif key == "sort": + filtersDict["sort"] = value.split(",") + else: + feed.appendleft((key, value)) # put the item back + filter_group = self._parseFilterGroups( + feed, returnOn=reserved_keys + ) + if "filters" in filtersDict: + filtersDict["filters"] = { + "and": [filtersDict["filters"], filter_group] + } + else: + filtersDict["filters"] = filter_group + + return filtersDict def _parseFilters(self, content): """ Parse the content string and returns the filter dict. """ content = urlsplit(unquote(content)) - filters = {} - filterOp = 'and' - filterGroups = [[]] + feed = deque() for key, value in parse_qsl(content.query): # Move = sign to key when operator is == - if value.startswith('='): - key += '=' - value = value[1:] + if value.startswith("="): + key, value = f"{key}=", value[1:] - if key == 'includeGuids': - filters['includeGuids'] = int(value) - elif key == 'type': - filters['libtype'] = utils.reverseSearchType(value) - elif key == 'sort': - filters['sort'] = value.split(',') - elif key == 'limit': - filters['limit'] = int(value) - elif key == 'push': - filterGroups[-1].append([]) - filterGroups.append(filterGroups[-1][-1]) - elif key == 'and': - filterOp = 'and' - elif key == 'or': - filterOp = 'or' - elif key == 'pop': - filterGroups[-1].insert(0, filterOp) - filterGroups.pop() - else: - filterGroups[-1].append({key: value}) + feed.append((key, value)) - if filterGroups: - filters['filters'] = self._formatFilterGroups(filterGroups.pop()) - return filters - - def _formatFilterGroups(self, groups): - """ Formats the filter groups into the advanced search rules. """ - if len(groups) == 1 and isinstance(groups[0], list): - groups = groups.pop() - - filterOp = 'and' - rules = [] - - for g in groups: - if isinstance(g, list): - rules.append(self._formatFilterGroups(g)) - elif isinstance(g, dict): - rules.append(g) - elif g in {'and', 'or'}: - filterOp = g - - return {filterOp: rules} + return self._parseQueryFeed(feed) class SplitMergeMixin: @@ -281,19 +316,16 @@ class PlayedUnplayedMixin: return self @property - @deprecated('use "isPlayed" instead', stacklevel=3) def isWatched(self): - """ Returns True if the show is watched. """ + """ Alias to self.isPlayed. """ return self.isPlayed - @deprecated('use "markPlayed" instead') def markWatched(self): - """ Mark the video as played. """ + """ Alias to :func:`~plexapi.mixins.PlayedUnplayedMixin.markPlayed`. """ self.markPlayed() - @deprecated('use "markUnplayed" instead') def markUnwatched(self): - """ Mark the video as unplayed. """ + """ Alias to :func:`~plexapi.mixins.PlayedUnplayedMixin.markUnplayed`. """ self.markUnplayed() @@ -755,7 +787,8 @@ class EditTagsMixin: if not remove: tags = getattr(self, self._tagPlural(tag), []) - items = tags + items + if isinstance(tags, list): + items = tags + items edits = self._tagHelper(self._tagSingular(tag), items, locked, remove) edits.update(kwargs) @@ -1163,7 +1196,7 @@ class AlbumEditMixins( class TrackEditMixins( ArtLockMixin, PosterLockMixin, ThemeLockMixin, AddedAtMixin, TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin, UserRatingMixin, - CollectionMixin, LabelMixin, MoodMixin + CollectionMixin, GenreMixin, LabelMixin, MoodMixin ): pass @@ -1189,3 +1222,10 @@ class CollectionEditMixins( LabelMixin ): pass + + +class PlaylistEditMixins( + ArtLockMixin, PosterLockMixin, + SortTitleMixin, SummaryMixin, TitleMixin +): + pass diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index ede2276d..8d697924 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -12,7 +12,7 @@ from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, log, logfilter, utils) from plexapi.base import PlexObject from plexapi.client import PlexClient -from plexapi.exceptions import BadRequest, NotFound, Unauthorized +from plexapi.exceptions import BadRequest, NotFound, Unauthorized, TwoFactorRequired from plexapi.library import LibrarySection from plexapi.server import PlexServer from plexapi.sonos import PlexSonosClient @@ -108,6 +108,7 @@ class MyPlexAccount(PlexObject): OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get LINK = 'https://plex.tv/api/v2/pins/link' # put VIEWSTATESYNC = 'https://plex.tv/api/v2/user/view_state_sync' # put + PING = 'https://plex.tv/api/v2/ping' # Hub sections VOD = 'https://vod.provider.plex.tv' # get MUSIC = 'https://music.provider.plex.tv' # get @@ -236,6 +237,8 @@ class MyPlexAccount(PlexObject): errtext = response.text.replace('\n', ' ') message = f'({response.status_code}) {codename}; {response.url} {errtext}' if response.status_code == 401: + if "verification code" in response.text: + raise TwoFactorRequired(message) raise Unauthorized(message) elif response.status_code == 404: raise NotFound(message) @@ -250,6 +253,15 @@ class MyPlexAccount(PlexObject): data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None + def ping(self): + """ Ping the Plex.tv API. + This will refresh the authentication token to prevent it from expiring. + """ + pong = self.query(self.PING) + if pong is not None: + return utils.cast(bool, pong.text) + return False + def device(self, name=None, clientId=None): """ Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified. @@ -1694,7 +1706,9 @@ class MyPlexPinLogin: @property def pin(self): - """ Return the 4 character PIN used for linking a device at https://plex.tv/link. """ + """ Return the 4 character PIN used for linking a device at + https://plex.tv/link. + """ if self._oauth: raise BadRequest('Cannot use PIN for Plex OAuth login') return self._code @@ -1726,6 +1740,7 @@ class MyPlexPinLogin: def run(self, callback=None, timeout=None): """ Starts the thread which monitors the PIN login state. + Parameters: callback (Callable[str]): Callback called with the received authentication token (optional). timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional). @@ -1748,6 +1763,7 @@ class MyPlexPinLogin: def waitForLogin(self): """ Waits for the PIN login to succeed or expire. + Parameters: callback (Callable[str]): Callback called with the received authentication token (optional). timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional). diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index 8737d814..c68b3613 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -180,6 +180,8 @@ class Photo( parentThumb (str): URL to photo album thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the photo album for the photo. ratingKey (int): Unique key identifying the photo. + sourceURI (str): Remote server URI (server:///com.plexapp.plugins.library) + (remote playlist item only). summary (str): Summary of the photo. tags (List<:class:`~plexapi.media.Tag`>): List of tag objects. thumb (str): URL to thumbnail image (/library/metadata//thumb/). @@ -218,6 +220,7 @@ class Photo( self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.sourceURI = data.attrib.get('source') # remote playlist item self.summary = data.attrib.get('summary') self.tags = self.findItems(data, media.Tag) self.thumb = data.attrib.get('thumb') diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index 44073ee7..14ef88ed 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -7,7 +7,7 @@ from plexapi import media, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection, MusicSection -from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin +from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin, PlaylistEditMixins from plexapi.utils import deprecated @@ -15,7 +15,8 @@ from plexapi.utils import deprecated class Playlist( PlexPartialObject, Playable, SmartFilterMixin, - ArtMixin, PosterMixin + ArtMixin, PosterMixin, + PlaylistEditMixins ): """ Represents a single Playlist. @@ -42,6 +43,7 @@ class Playlist( smart (bool): True if the playlist is a smart playlist. summary (str): Summary of the playlist. title (str): Name of the playlist. + titleSort (str): Title to use when sorting (defaults to title). type (str): 'playlist' updatedAt (datetime): Datetime the playlist was updated. """ @@ -71,6 +73,7 @@ class Playlist( self.smart = utils.cast(bool, data.attrib.get('smart')) self.summary = data.attrib.get('summary') self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort', self.title) self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self._items = None # cache for self.items @@ -224,7 +227,7 @@ class Playlist( self._server.query(key, method=self._server._session.put) return self - @deprecated('use "removeItems" instead', stacklevel=3) + @deprecated('use "removeItems" instead') def removeItem(self, item): self.removeItems(item) @@ -308,10 +311,15 @@ class Playlist( def _edit(self, **kwargs): """ Actually edit the playlist. """ + if isinstance(self._edits, dict): + self._edits.update(kwargs) + return self + key = f'{self.key}{utils.joinArgs(kwargs)}' self._server.query(key, method=self._server._session.put) return self + @deprecated('use "editTitle" and "editSummary" instead') def edit(self, title=None, summary=None): """ Edit the playlist. @@ -384,7 +392,7 @@ class Playlist( key = f"/playlists/upload{utils.joinArgs(args)}" server.query(key, method=server._session.post) try: - return server.playlists(sectionId=section.key, guid__endswith=m3ufilepath)[0].edit(title=title).reload() + return server.playlists(sectionId=section.key, guid__endswith=m3ufilepath)[0].editTitle(title).reload() except IndexError: raise BadRequest('Failed to create playlist from m3u file.') from None diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index 52a203a8..bdd330f7 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -355,8 +355,7 @@ class PlexServer(PlexObject): key = f'/services/browse/{base64path}' else: key = '/services/browse' - if includeFiles: - key += '?includeFiles=1' + key += f'?includeFiles={int(includeFiles)}' # starting with PMS v1.32.7.7621 this must set explicitly return self.fetchItems(key) def walk(self, path=None): diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index 8478f2d4..467ccf05 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -144,22 +144,21 @@ def cast(func, value): func (func): Callback function to used cast to type (int, bool, float). value (any): value to be cast and returned. """ - if value is not None: - if func == bool: - if value in (1, True, "1", "true"): - return True - elif value in (0, False, "0", "false"): - return False - else: - raise ValueError(value) + if value is None: + return value + if func == bool: + if value in (1, True, "1", "true"): + return True + if value in (0, False, "0", "false"): + return False + raise ValueError(value) - elif func in (int, float): - try: - return func(value) - except ValueError: - return float('nan') - return func(value) - return value + if func in (int, float): + try: + return func(value) + except ValueError: + return float('nan') + return func(value) def joinArgs(args): @@ -329,7 +328,7 @@ def toDatetime(value, format=None): return None try: return datetime.fromtimestamp(value) - except (OSError, OverflowError): + except (OSError, OverflowError, ValueError): try: return datetime.fromtimestamp(0) + timedelta(seconds=value) except OverflowError: @@ -407,7 +406,7 @@ def downloadSessionImages(server, filename=None, height=150, width=150, return info -def download(url, token, filename=None, savepath=None, session=None, chunksize=4024, # noqa: C901 +def download(url, token, filename=None, savepath=None, session=None, chunksize=4096, # 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. diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index e95b12ff..727ba0f8 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -97,64 +97,88 @@ class Video(PlexPartialObject, PlayedUnplayedMixin): """ Returns str, default title for a new syncItem. """ return self.title - def videoStreams(self): - """ Returns a list of :class:`~plexapi.media.videoStream` objects for all MediaParts. """ - streams = [] - - if self.isPartialObject(): - self.reload() - - parts = self.iterParts() - for part in parts: - streams += part.videoStreams() - return streams - - def audioStreams(self): - """ Returns a list of :class:`~plexapi.media.AudioStream` objects for all MediaParts. """ - streams = [] - - if self.isPartialObject(): - self.reload() - - parts = self.iterParts() - for part in parts: - streams += part.audioStreams() - return streams - - def subtitleStreams(self): - """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """ - streams = [] - - if self.isPartialObject(): - self.reload() - - parts = self.iterParts() - for part in parts: - streams += part.subtitleStreams() - return streams - def uploadSubtitles(self, filepath): - """ Upload Subtitle file for video. """ + """ Upload a subtitle file for the video. + + Parameters: + filepath (str): Path to subtitle file. + """ url = f'{self.key}/subtitles' filename = os.path.basename(filepath) subFormat = os.path.splitext(filepath)[1][1:] + params = { + 'title': filename, + 'format': subFormat, + } + headers = {'Accept': 'text/plain, */*'} with open(filepath, 'rb') as subfile: - params = {'title': filename, - 'format': subFormat - } - headers = {'Accept': 'text/plain, */*'} self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers) return self - def removeSubtitles(self, streamID=None, streamTitle=None): - """ Remove Subtitle from movie's subtitles listing. + def searchSubtitles(self, language='en', hearingImpaired=0, forced=0): + """ Search for on-demand subtitles for the video. + See https://support.plex.tv/articles/subtitle-search/. - Note: If subtitle file is located inside video directory it will bbe deleted. - Files outside of video directory are not effected. + Parameters: + language (str, optional): Language code (ISO 639-1) of the subtitles to search for. + Default 'en'. + hearingImpaired (int, optional): Search option for SDH subtitles. + Default 0. + (0 = Prefer non-SDH subtitles, 1 = Prefer SDH subtitles, + 2 = Only show SDH subtitles, 3 = Only show non-SDH subtitles) + forced (int, optional): Search option for forced subtitles. + Default 0. + (0 = Prefer non-forced subtitles, 1 = Prefer forced subtitles, + 2 = Only show forced subtitles, 3 = Only show non-forced subtitles) + + Returns: + List<:class:`~plexapi.media.SubtitleStream`>: List of SubtitleStream objects. """ - for stream in self.subtitleStreams(): - if streamID == stream.id or streamTitle == stream.title: - self._server.query(stream.key, self._server._session.delete) + params = { + 'language': language, + 'hearingImpaired': hearingImpaired, + 'forced': forced, + } + key = f'{self.key}/subtitles{utils.joinArgs(params)}' + return self.fetchItems(key) + + def downloadSubtitles(self, subtitleStream): + """ Download on-demand subtitles for the video. + See https://support.plex.tv/articles/subtitle-search/. + + Note: This method is asynchronous and returns immediately before subtitles are fully downloaded. + + Parameters: + subtitleStream (:class:`~plexapi.media.SubtitleStream`): + Subtitle object returned from :func:`~plexapi.video.Video.searchSubtitles`. + """ + key = f'{self.key}/subtitles' + params = {'key': subtitleStream.key} + self._server.query(key, self._server._session.put, params=params) + return self + + def removeSubtitles(self, subtitleStream=None, streamID=None, streamTitle=None): + """ Remove an upload or downloaded subtitle from the video. + + Note: If the subtitle file is located inside video directory it will be deleted. + Files outside of video directory are not affected. + Embedded subtitles cannot be removed. + + Parameters: + subtitleStream (:class:`~plexapi.media.SubtitleStream`, optional): Subtitle object to remove. + streamID (int, optional): ID of the subtitle stream to remove. + streamTitle (str, optional): Title of the subtitle stream to remove. + """ + if subtitleStream is None: + try: + subtitleStream = next( + stream for stream in self.subtitleStreams() + if streamID == stream.id or streamTitle == stream.title + ) + except StopIteration: + raise BadRequest(f"Subtitle stream with ID '{streamID}' or title '{streamTitle}' not found.") from None + + self._server.query(subtitleStream.key, self._server._session.delete) return self def optimize(self, title='', target='', deviceProfile='', videoQuality=None, @@ -344,7 +368,10 @@ class Movie( ratingImage (str): Key to critic rating image (rottentomatoes://image.rating.rotten). ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. roles (List<:class:`~plexapi.media.Role`>): List of role objects. + slug (str): The clean watch.plex.tv URL identifier for the movie. similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. + sourceURI (str): Remote server URI (server:///com.plexapp.plugins.library) + (remote playlist item only). studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment). tagline (str): Movie tag line (Back 2 Work; Who says men can't change?). theme (str): URL to theme resource (/library/metadata//theme/). @@ -387,7 +414,9 @@ class Movie( self.ratingImage = data.attrib.get('ratingImage') self.ratings = self.findItems(data, media.Rating) self.roles = self.findItems(data, media.Role) + self.slug = data.attrib.get('slug') self.similar = self.findItems(data, media.Similar) + self.sourceURI = data.attrib.get('source') # remote playlist item self.studio = data.attrib.get('studio') self.tagline = data.attrib.get('tagline') self.theme = data.attrib.get('theme') @@ -507,6 +536,7 @@ class Show( (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. + slug (str): The clean watch.plex.tv URL identifier for the show. studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment). subtitleLanguage (str): Setting that indicates the preferred subtitle language. subtitleMode (int): Setting that indicates the auto-select subtitle mode. @@ -556,8 +586,9 @@ class Show( self.seasonCount = utils.cast(int, data.attrib.get('seasonCount', self.childCount)) self.showOrdering = data.attrib.get('showOrdering') self.similar = self.findItems(data, media.Similar) + self.slug = data.attrib.get('slug') self.studio = data.attrib.get('studio') - self.subtitleLanguage = data.attrib.get('audioLanguage', '') + self.subtitleLanguage = data.attrib.get('subtitleLanguage', '') self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) self.tagline = data.attrib.get('tagline') self.theme = data.attrib.get('theme') @@ -693,6 +724,7 @@ class Season( parentIndex (int): Plex index number for the show. parentKey (str): API URL of the show (/library/metadata/). parentRatingKey (int): Unique key identifying the show. + parentSlug (str): The clean watch.plex.tv URL identifier for the show. parentStudio (str): Studio that created show. parentTheme (str): URL to show theme resource (/library/metadata//theme/). parentThumb (str): URL to show thumbnail image (/library/metadata//thumb/). @@ -722,12 +754,13 @@ class Season( self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) self.parentKey = data.attrib.get('parentKey') self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentSlug = data.attrib.get('parentSlug') self.parentStudio = data.attrib.get('parentStudio') self.parentTheme = data.attrib.get('parentTheme') self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.ratings = self.findItems(data, media.Rating) - self.subtitleLanguage = data.attrib.get('audioLanguage', '') + self.subtitleLanguage = data.attrib.get('subtitleLanguage', '') self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) @@ -853,6 +886,7 @@ class Episode( grandparentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6). grandparentKey (str): API URL of the show (/library/metadata/). grandparentRatingKey (int): Unique key identifying the show. + grandparentSlug (str): The clean watch.plex.tv URL identifier for the show. grandparentTheme (str): URL to show theme resource (/library/metadata//theme/). grandparentThumb (str): URL to show thumbnail image (/library/metadata//thumb/). grandparentTitle (str): Name of the show for the episode. @@ -874,6 +908,8 @@ class Episode( ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. roles (List<:class:`~plexapi.media.Role`>): List of role objects. skipParent (bool): True if the show's seasons are set to hidden. + sourceURI (str): Remote server URI (server:///com.plexapp.plugins.library) + (remote playlist item only). viewOffset (int): View offset in milliseconds. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. year (int): Year the episode was released. @@ -898,6 +934,7 @@ class Episode( self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentKey = data.attrib.get('grandparentKey') self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) + self.grandparentSlug = data.attrib.get('grandparentSlug') self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentTitle = data.attrib.get('grandparentTitle') @@ -916,6 +953,7 @@ class Episode( self.ratings = self.findItems(data, media.Rating) self.roles = self.findItems(data, media.Role) self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) + self.sourceURI = data.attrib.get('source') # remote playlist item self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.writers = self.findItems(data, media.Writer) self.year = utils.cast(int, data.attrib.get('year')) @@ -961,11 +999,9 @@ class Episode( @cached_property def _season(self): """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ - if not self.grandparentKey: - return None - return self.fetchItem( - f'{self.grandparentKey}/children?excludeAllLeaves=1&index={self.parentIndex}' - ) + if self.grandparentKey and self.parentIndex is not None: + return self.fetchItem(f'{self.grandparentKey}/children?excludeAllLeaves=1&index={self.parentIndex}') + return None def __repr__(self): return '<{}>'.format( @@ -1003,7 +1039,11 @@ class Episode( @cached_property def seasonNumber(self): """ Returns the episode's season number. """ - return self.parentIndex if isinstance(self.parentIndex, int) else self._season.seasonNumber + if isinstance(self.parentIndex, int): + return self.parentIndex + elif self._season: + return self._season.index + return None @property def seasonEpisode(self): diff --git a/requirements.txt b/requirements.txt index bc0a4bd6..73b6e088 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ musicbrainzngs==0.7.1 packaging==23.1 paho-mqtt==1.6.1 platformdirs==3.11.0 -plexapi==4.15.4 +plexapi==4.15.10 portend==3.2.0 profilehooks==1.12.0 PyJWT==2.8.0