Bump plexapi from 4.15.4 to 4.15.10 (#2251)

* Bump plexapi from 4.15.4 to 4.15.10

Bumps [plexapi](https://github.com/pkkid/python-plexapi) from 4.15.4 to 4.15.10.
- [Release notes](https://github.com/pkkid/python-plexapi/releases)
- [Commits](https://github.com/pkkid/python-plexapi/compare/4.15.4...4.15.10)

---
updated-dependencies:
- dependency-name: plexapi
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update plexapi==4.15.10

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>

[skip ci]
This commit is contained in:
dependabot[bot] 2024-03-02 13:52:45 -08:00 committed by GitHub
parent 040972bcba
commit b1c0972077
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 413 additions and 189 deletions

View file

@ -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/<ratingKey>/art/<artid>).
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/<grandparentRatingKey>/art/<artid>).
grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
grandparentKey (str): API URL of the album artist (/library/metadata/<grandparentRatingKey>).
@ -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://<machineIdentifier>/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'))

View file

@ -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. """

View file

@ -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'))

View file

@ -276,7 +276,7 @@ class Collection(
.. code-block:: python
collection.updateSort(mode="alpha")
collection.sortUpdate(sort="alpha")
"""
if self.smart:

View file

@ -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}"

View file

@ -29,3 +29,8 @@ class Unsupported(PlexApiException):
class Unauthorized(BadRequest):
""" Invalid username/password or token. """
pass
class TwoFactorRequired(Unauthorized):
""" Two factor authentication required. """
pass

View file

@ -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'<Field key="{prefix}{field}" '
f'<Field key="{field}" '
f'title="{fieldTitle}" '
f'type="{fieldType}"/>'
)
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.

View file

@ -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).
<Photo_only_attributes>: 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.
<Track_only_attributes>: 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/<ratingkey>).
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.

View file

@ -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

View file

@ -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).

View file

@ -180,6 +180,8 @@ class Photo(
parentThumb (str): URL to photo album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the photo album for the photo.
ratingKey (int): Unique key identifying the photo.
sourceURI (str): Remote server URI (server://<machineIdentifier>/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/<ratingKey>/thumb/<thumbid>).
@ -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')

View file

@ -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

View file

@ -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):

View file

@ -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.

View file

@ -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://<machineIdentifier>/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/<ratingkey>/theme/<themeid>).
@ -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>).
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/<parentRatingkey>/theme/<themeid>).
parentThumb (str): URL to show thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
@ -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>).
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/<grandparentRatingkey>/theme/<themeid>).
grandparentThumb (str): URL to show thumbnail image (/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
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://<machineIdentifier>/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):

View file

@ -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