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 pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
from typing import Any, Dict, List, Optional, TypeVar
from plexapi import media, utils from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
@ -14,6 +16,9 @@ from plexapi.mixins import (
from plexapi.playlist import Playlist from plexapi.playlist import Playlist
TAudio = TypeVar("TAudio", bound="Audio")
class Audio(PlexPartialObject, PlayedUnplayedMixin): class Audio(PlexPartialObject, PlayedUnplayedMixin):
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`, """ Base class for all audio objects including :class:`~plexapi.audio.Artist`,
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`. :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. addedAt (datetime): Datetime the item was added to the library.
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>). art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
artBlurHash (str): BlurHash string for artwork image. 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. fields (List<:class:`~plexapi.media.Field`>): List of field objects.
guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c). guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c).
index (int): Plex index number (often the track number). 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.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash') self.artBlurHash = data.attrib.get('artBlurHash')
self.distance = utils.cast(float, data.attrib.get('distance'))
self.fields = self.findItems(data, media.Field) self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index')) 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) 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 @utils.registerPlexObject
class Artist( class Artist(
@ -189,7 +227,7 @@ class Artist(
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """ """ Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
return self.section().search( return self.section().search(
libtype='album', libtype='album',
filters={'artist.id': self.ratingKey}, filters={**kwargs.pop('filters', {}), 'artist.id': self.ratingKey},
**kwargs **kwargs
) )
@ -251,7 +289,7 @@ class Artist(
@utils.registerPlexObject @utils.registerPlexObject
class Album( class Album(
Audio, Audio,
UnmatchMatchMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeUrlMixin, ArtMixin, PosterMixin, ThemeUrlMixin,
AlbumEditMixins AlbumEditMixins
): ):
@ -389,6 +427,7 @@ class Track(
chapterSource (str): Unknown chapterSource (str): Unknown
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
duration (int): Length of the track in milliseconds. 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>). grandparentArt (str): URL to album artist artwork (/library/metadata/<grandparentRatingKey>/art/<artid>).
grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
grandparentKey (str): API URL of the album artist (/library/metadata/<grandparentRatingKey>). 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. 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. 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. 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. viewOffset (int): View offset in milliseconds.
year (int): Year the track was released. year (int): Year the track was released.
""" """
@ -425,6 +466,7 @@ class Track(
self.chapterSource = data.attrib.get('chapterSource') self.chapterSource = data.attrib.get('chapterSource')
self.collections = self.findItems(data, media.Collection) self.collections = self.findItems(data, media.Collection)
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.genres = self.findItems(data, media.Genre)
self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey') self.grandparentKey = data.attrib.get('grandparentKey')
@ -445,6 +487,7 @@ class Track(
self.primaryExtraKey = data.attrib.get('primaryExtraKey') self.primaryExtraKey = data.attrib.get('primaryExtraKey')
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount')) self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
self.skipCount = utils.cast(int, data.attrib.get('skipCount')) 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.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))

View file

@ -22,12 +22,12 @@ OPERATORS = {
'lt': lambda v, q: v < q, 'lt': lambda v, q: v < q,
'lte': lambda v, q: v <= q, 'lte': lambda v, q: v <= q,
'startswith': lambda v, q: v.startswith(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), '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, 'exists': lambda v, q: v is not None if q else v is None,
'regex': lambda v, q: re.match(q, v), 'regex': lambda v, q: bool(re.search(q, v)),
'iregex': lambda v, q: re.match(q, v, flags=re.IGNORECASE), '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)) ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
# log.debug('Building %s as %s', elem.tag, ecls.__name__) # log.debug('Building %s as %s', elem.tag, ecls.__name__)
if ecls is not None: 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}'../>") raise UnknownType(f"Unknown library type <{elem.tag} type='{etype}'../>")
def _buildItemOrNone(self, elem, cls=None, initpath=None): def _buildItemOrNone(self, elem, cls=None, initpath=None):
@ -227,7 +227,8 @@ class PlexObject:
fetchItem(ekey, viewCount__gte=0) fetchItem(ekey, viewCount__gte=0)
fetchItem(ekey, Media__container__in=["mp4", "mkv"]) 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") fetchItem(ekey, Media__Part__file__startswith="D:\\Movies")
""" """
@ -439,7 +440,7 @@ class PlexObject:
attrstr = parts[1] if len(parts) == 2 else None attrstr = parts[1] if len(parts) == 2 else None
if attrstr: if attrstr:
results = [] if results is None else results 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) results += self._getAttrValue(child, attrstr, results)
return [r for r in results if r is not None] return [r for r in results if r is not None]
# check were looking for the tag # check were looking for the tag
@ -565,6 +566,14 @@ class PlexPartialObject(PlexObject):
""" Returns True if this is not a full object. """ """ Returns True if this is not a full object. """
return not self.isFullObject() 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): def _edit(self, **kwargs):
""" Actually edit an object. """ """ Actually edit an object. """
if isinstance(self._edits, dict): if isinstance(self._edits, dict):
@ -763,6 +772,30 @@ class Playable:
for part in item.parts: for part in item.parts:
yield part 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): def play(self, client):
""" Start playback on the specified 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.') raise NotImplementedError('History objects cannot be reloaded. Use source() to get the source media item.')
def source(self): def source(self):
""" Return the source media object for the history entry. """ """ Return the source media object for the history entry
return self.fetchItem(self._details_key) 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): def delete(self):
""" Delete the history entry. """ """ Delete the history entry. """

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import time import time
import weakref
from xml.etree import ElementTree from xml.etree import ElementTree
import requests import requests
@ -62,7 +63,8 @@ class PlexClient(PlexObject):
key = '/resources' key = '/resources'
def __init__(self, server=None, data=None, initpath=None, baseurl=None, 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) super(PlexClient, self).__init__(server, data, initpath)
self._baseurl = baseurl.strip('/') if baseurl else None self._baseurl = baseurl.strip('/') if baseurl else None
self._clientIdentifier = identifier self._clientIdentifier = identifier
@ -76,6 +78,7 @@ class PlexClient(PlexObject):
self._last_call = 0 self._last_call = 0
self._timeline_cache = [] self._timeline_cache = []
self._timeline_cache_timestamp = 0 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]): if not any([data is not None, initpath, baseurl, token]):
self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433') self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433')
self._token = logfilter.add_secret(CONFIG.get('auth.client_token')) self._token = logfilter.add_secret(CONFIG.get('auth.client_token'))

View file

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

View file

@ -4,6 +4,6 @@
# Library version # Library version
MAJOR_VERSION = 4 MAJOR_VERSION = 4
MINOR_VERSION = 15 MINOR_VERSION = 15
PATCH_VERSION = 4 PATCH_VERSION = 10
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}"

View file

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

View file

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
import warnings
from collections import defaultdict
from datetime import datetime from datetime import datetime
from functools import cached_property from functools import cached_property
from urllib.parse import parse_qs, quote_plus, urlencode, urlparse from urllib.parse import parse_qs, quote_plus, urlencode, urlparse
@ -41,14 +43,22 @@ class Library(PlexObject):
def _loadSections(self): def _loadSections(self):
""" Loads and caches all the library sections. """ """ Loads and caches all the library sections. """
key = '/library/sections' key = '/library/sections'
self._sectionsByID = {} sectionsByID = {}
self._sectionsByTitle = {} sectionsByTitle = defaultdict(list)
libcls = {
'movie': MovieSection,
'show': ShowSection,
'artist': MusicSection,
'photo': PhotoSection,
}
for elem in self._server.query(key): for elem in self._server.query(key):
for cls in (MovieSection, ShowSection, MusicSection, PhotoSection): section = libcls.get(elem.attrib.get('type'), LibrarySection)(self._server, elem, initpath=key)
if elem.attrib.get('type') == cls.TYPE: sectionsByID[section.key] = section
section = cls(self._server, elem, key) sectionsByTitle[section.title.lower().strip()].append(section)
self._sectionsByID[section.key] = section
self._sectionsByTitle[section.title.lower().strip()] = section self._sectionsByID = sectionsByID
self._sectionsByTitle = dict(sectionsByTitle)
def sections(self): def sections(self):
""" Returns a list of all media sections in this library. Library sections may be any of """ 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): def section(self, title):
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified 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: Parameters:
title (str): Title of the section to return. 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() normalized_title = title.lower().strip()
if not self._sectionsByTitle or normalized_title not in self._sectionsByTitle: if not self._sectionsByTitle or normalized_title not in self._sectionsByTitle:
self._loadSections() self._loadSections()
try: try:
return self._sectionsByTitle[normalized_title] sections = self._sectionsByTitle[normalized_title]
except KeyError: except KeyError:
raise NotFound(f'Invalid library section: {title}') from None 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): def sectionByID(self, sectionID):
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID. """ Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID.
@ -2727,7 +2749,9 @@ class FilteringType(PlexObject):
('id', 'integer', 'Rating Key'), ('id', 'integer', 'Rating Key'),
('index', 'integer', f'{self.type.capitalize()} Number'), ('index', 'integer', f'{self.type.capitalize()} Number'),
('lastRatedAt', 'date', f'{self.type.capitalize()} Last Rated'), ('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': if self.type == 'movie':
@ -2778,11 +2802,14 @@ class FilteringType(PlexObject):
manualFields = [] manualFields = []
for field, fieldType, fieldTitle in additionalFields: for field, fieldType, fieldTitle in additionalFields:
if field not in {'group', 'having'}:
field = f"{prefix}{field}"
fieldXML = ( fieldXML = (
f'<Field key="{prefix}{field}" ' f'<Field key="{field}" '
f'title="{fieldTitle}" ' f'title="{fieldTitle}" '
f'type="{fieldType}"/>' f'type="{fieldType}"/>'
) )
manualFields.append(self._manuallyLoadXML(fieldXML, FilteringField)) manualFields.append(self._manuallyLoadXML(fieldXML, FilteringField))
return manualFields return manualFields
@ -2922,6 +2949,10 @@ class FilterChoice(PlexObject):
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.type = data.attrib.get('type') 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): class ManagedHub(PlexObject):
""" Represents a Managed Hub (recommendation) inside a library. """ 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). videoResolution (str): The video resolution of the media (ex: sd).
width (int): The width of the video in pixels (ex: 608). 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. * aperture (str): The aperture used to take the photo.
* exposure (str): The exposure used to take the photo. * exposure (str): The exposure used to take the photo.
@ -74,7 +74,7 @@ class Media(PlexObject):
self.width = utils.cast(int, data.attrib.get('width')) self.width = utils.cast(int, data.attrib.get('width'))
self.uuid = data.attrib.get('uuid') self.uuid = data.attrib.get('uuid')
if self._isChildOf(etag='Photo'): # Photo only attributes
self.aperture = data.attrib.get('aperture') self.aperture = data.attrib.get('aperture')
self.exposure = data.attrib.get('exposure') self.exposure = data.attrib.get('exposure')
self.iso = utils.cast(int, data.attrib.get('iso')) self.iso = utils.cast(int, data.attrib.get('iso'))
@ -158,11 +158,8 @@ class MediaPart(PlexObject):
self.videoProfile = data.attrib.get('videoProfile') self.videoProfile = data.attrib.get('videoProfile')
def _buildStreams(self, data): def _buildStreams(self, data):
streams = [] """ Returns a list of :class:`~plexapi.media.MediaPartStream` objects in this MediaPart. """
for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream): return self.findItems(data)
items = self.findItems(data, cls, streamType=cls.STREAMTYPE)
streams.extend(items)
return streams
@property @property
def hasPreviewThumbnails(self): def hasPreviewThumbnails(self):
@ -216,7 +213,7 @@ class MediaPart(PlexObject):
else: else:
params['subtitleStreamID'] = stream 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 return self
def resetSelectedSubtitleStream(self): def resetSelectedSubtitleStream(self):
@ -384,7 +381,7 @@ class AudioStream(MediaPartStream):
samplingRate (int): The sampling rate of the audio stream (ex: xxx) samplingRate (int): The sampling rate of the audio stream (ex: xxx)
streamIdentifier (int): The stream identifier of the audio stream. 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. * albumGain (float): The gain for the album.
* albumPeak (float): The peak for the album. * albumPeak (float): The peak for the album.
@ -411,7 +408,7 @@ class AudioStream(MediaPartStream):
self.samplingRate = utils.cast(int, data.attrib.get('samplingRate')) self.samplingRate = utils.cast(int, data.attrib.get('samplingRate'))
self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier')) self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier'))
if self._isChildOf(etag='Track'): # Track only attributes
self.albumGain = utils.cast(float, data.attrib.get('albumGain')) self.albumGain = utils.cast(float, data.attrib.get('albumGain'))
self.albumPeak = utils.cast(float, data.attrib.get('albumPeak')) self.albumPeak = utils.cast(float, data.attrib.get('albumPeak'))
self.albumRange = utils.cast(float, data.attrib.get('albumRange')) self.albumRange = utils.cast(float, data.attrib.get('albumRange'))
@ -444,8 +441,10 @@ class SubtitleStream(MediaPartStream):
forced (bool): True if this is a forced subtitle. forced (bool): True if this is a forced subtitle.
format (str): The format of the subtitle stream (ex: srt). format (str): The format of the subtitle stream (ex: srt).
headerCompression (str): The header compression of the subtitle stream. 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. 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. sourceKey (str): The source key of the on-demand subtitle.
transient (str): Unknown. transient (str): Unknown.
userID (int): The user id of the user that downloaded the on-demand subtitle. 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.forced = utils.cast(bool, data.attrib.get('forced', '0'))
self.format = data.attrib.get('format') self.format = data.attrib.get('format')
self.headerCompression = data.attrib.get('headerCompression') 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.providerTitle = data.attrib.get('providerTitle')
self.score = utils.cast(int, data.attrib.get('score')) self.score = utils.cast(int, data.attrib.get('score'))
self.sourceKey = data.attrib.get('sourceKey') self.sourceKey = data.attrib.get('sourceKey')
@ -1003,7 +1004,8 @@ class BaseResource(PlexObject):
Attributes: Attributes:
TAG (str): 'Photo' or 'Track' TAG (str): 'Photo' or 'Track'
key (str): API URL (/library/metadata/<ratingkey>). 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. ratingKey (str): Unique key identifying the resource.
selected (bool): True if the resource is currently selected. selected (bool): True if the resource is currently selected.
thumb (str): The URL to retrieve the resource thumbnail. thumb (str): The URL to retrieve the resource thumbnail.

View file

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from collections import deque
from datetime import datetime from datetime import datetime
from typing import Deque, Set, Tuple, Union
from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit
from plexapi import media, settings, utils from plexapi import media, settings, utils
@ -61,63 +63,96 @@ class AdvancedSettingsMixin:
class SmartFilterMixin: 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): def _parseFilters(self, content):
""" Parse the content string and returns the filter dict. """ """ Parse the content string and returns the filter dict. """
content = urlsplit(unquote(content)) content = urlsplit(unquote(content))
filters = {} feed = deque()
filterOp = 'and'
filterGroups = [[]]
for key, value in parse_qsl(content.query): for key, value in parse_qsl(content.query):
# Move = sign to key when operator is == # Move = sign to key when operator is ==
if value.startswith('='): if value.startswith("="):
key += '=' key, value = f"{key}=", value[1:]
value = value[1:]
if key == 'includeGuids': feed.append((key, value))
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})
if filterGroups: return self._parseQueryFeed(feed)
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}
class SplitMergeMixin: class SplitMergeMixin:
@ -281,19 +316,16 @@ class PlayedUnplayedMixin:
return self return self
@property @property
@deprecated('use "isPlayed" instead', stacklevel=3)
def isWatched(self): def isWatched(self):
""" Returns True if the show is watched. """ """ Alias to self.isPlayed. """
return self.isPlayed return self.isPlayed
@deprecated('use "markPlayed" instead')
def markWatched(self): def markWatched(self):
""" Mark the video as played. """ """ Alias to :func:`~plexapi.mixins.PlayedUnplayedMixin.markPlayed`. """
self.markPlayed() self.markPlayed()
@deprecated('use "markUnplayed" instead')
def markUnwatched(self): def markUnwatched(self):
""" Mark the video as unplayed. """ """ Alias to :func:`~plexapi.mixins.PlayedUnplayedMixin.markUnplayed`. """
self.markUnplayed() self.markUnplayed()
@ -755,6 +787,7 @@ class EditTagsMixin:
if not remove: if not remove:
tags = getattr(self, self._tagPlural(tag), []) tags = getattr(self, self._tagPlural(tag), [])
if isinstance(tags, list):
items = tags + items items = tags + items
edits = self._tagHelper(self._tagSingular(tag), items, locked, remove) edits = self._tagHelper(self._tagSingular(tag), items, locked, remove)
@ -1163,7 +1196,7 @@ class AlbumEditMixins(
class TrackEditMixins( class TrackEditMixins(
ArtLockMixin, PosterLockMixin, ThemeLockMixin, ArtLockMixin, PosterLockMixin, ThemeLockMixin,
AddedAtMixin, TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin, UserRatingMixin, AddedAtMixin, TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin, UserRatingMixin,
CollectionMixin, LabelMixin, MoodMixin CollectionMixin, GenreMixin, LabelMixin, MoodMixin
): ):
pass pass
@ -1189,3 +1222,10 @@ class CollectionEditMixins(
LabelMixin LabelMixin
): ):
pass 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) log, logfilter, utils)
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.client import PlexClient 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.library import LibrarySection
from plexapi.server import PlexServer from plexapi.server import PlexServer
from plexapi.sonos import PlexSonosClient from plexapi.sonos import PlexSonosClient
@ -108,6 +108,7 @@ class MyPlexAccount(PlexObject):
OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get
LINK = 'https://plex.tv/api/v2/pins/link' # put LINK = 'https://plex.tv/api/v2/pins/link' # put
VIEWSTATESYNC = 'https://plex.tv/api/v2/user/view_state_sync' # put VIEWSTATESYNC = 'https://plex.tv/api/v2/user/view_state_sync' # put
PING = 'https://plex.tv/api/v2/ping'
# Hub sections # Hub sections
VOD = 'https://vod.provider.plex.tv' # get VOD = 'https://vod.provider.plex.tv' # get
MUSIC = 'https://music.provider.plex.tv' # get MUSIC = 'https://music.provider.plex.tv' # get
@ -236,6 +237,8 @@ class MyPlexAccount(PlexObject):
errtext = response.text.replace('\n', ' ') errtext = response.text.replace('\n', ' ')
message = f'({response.status_code}) {codename}; {response.url} {errtext}' message = f'({response.status_code}) {codename}; {response.url} {errtext}'
if response.status_code == 401: if response.status_code == 401:
if "verification code" in response.text:
raise TwoFactorRequired(message)
raise Unauthorized(message) raise Unauthorized(message)
elif response.status_code == 404: elif response.status_code == 404:
raise NotFound(message) raise NotFound(message)
@ -250,6 +253,15 @@ class MyPlexAccount(PlexObject):
data = response.text.encode('utf8') data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None 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): def device(self, name=None, clientId=None):
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified. """ Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
@ -1694,7 +1706,9 @@ class MyPlexPinLogin:
@property @property
def pin(self): 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: if self._oauth:
raise BadRequest('Cannot use PIN for Plex OAuth login') raise BadRequest('Cannot use PIN for Plex OAuth login')
return self._code return self._code
@ -1726,6 +1740,7 @@ class MyPlexPinLogin:
def run(self, callback=None, timeout=None): def run(self, callback=None, timeout=None):
""" Starts the thread which monitors the PIN login state. """ Starts the thread which monitors the PIN login state.
Parameters: Parameters:
callback (Callable[str]): Callback called with the received authentication token (optional). callback (Callable[str]): Callback called with the received authentication token (optional).
timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional). timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional).
@ -1748,6 +1763,7 @@ class MyPlexPinLogin:
def waitForLogin(self): def waitForLogin(self):
""" Waits for the PIN login to succeed or expire. """ Waits for the PIN login to succeed or expire.
Parameters: Parameters:
callback (Callable[str]): Callback called with the received authentication token (optional). callback (Callable[str]): Callback called with the received authentication token (optional).
timeout (int): Timeout in seconds waiting for the PIN login to succeed (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>). parentThumb (str): URL to photo album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the photo album for the photo. parentTitle (str): Name of the photo album for the photo.
ratingKey (int): Unique key identifying 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. summary (str): Summary of the photo.
tags (List<:class:`~plexapi.media.Tag`>): List of tag objects. tags (List<:class:`~plexapi.media.Tag`>): List of tag objects.
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>). thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
@ -218,6 +220,7 @@ class Photo(
self.parentThumb = data.attrib.get('parentThumb') self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle') self.parentTitle = data.attrib.get('parentTitle')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) 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.summary = data.attrib.get('summary')
self.tags = self.findItems(data, media.Tag) self.tags = self.findItems(data, media.Tag)
self.thumb = data.attrib.get('thumb') 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.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection, MusicSection 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 from plexapi.utils import deprecated
@ -15,7 +15,8 @@ from plexapi.utils import deprecated
class Playlist( class Playlist(
PlexPartialObject, Playable, PlexPartialObject, Playable,
SmartFilterMixin, SmartFilterMixin,
ArtMixin, PosterMixin ArtMixin, PosterMixin,
PlaylistEditMixins
): ):
""" Represents a single Playlist. """ Represents a single Playlist.
@ -42,6 +43,7 @@ class Playlist(
smart (bool): True if the playlist is a smart playlist. smart (bool): True if the playlist is a smart playlist.
summary (str): Summary of the playlist. summary (str): Summary of the playlist.
title (str): Name of the playlist. title (str): Name of the playlist.
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'playlist' type (str): 'playlist'
updatedAt (datetime): Datetime the playlist was updated. updatedAt (datetime): Datetime the playlist was updated.
""" """
@ -71,6 +73,7 @@ class Playlist(
self.smart = utils.cast(bool, data.attrib.get('smart')) self.smart = utils.cast(bool, data.attrib.get('smart'))
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self._items = None # cache for self.items self._items = None # cache for self.items
@ -224,7 +227,7 @@ class Playlist(
self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
return self return self
@deprecated('use "removeItems" instead', stacklevel=3) @deprecated('use "removeItems" instead')
def removeItem(self, item): def removeItem(self, item):
self.removeItems(item) self.removeItems(item)
@ -308,10 +311,15 @@ class Playlist(
def _edit(self, **kwargs): def _edit(self, **kwargs):
""" Actually edit the playlist. """ """ Actually edit the playlist. """
if isinstance(self._edits, dict):
self._edits.update(kwargs)
return self
key = f'{self.key}{utils.joinArgs(kwargs)}' key = f'{self.key}{utils.joinArgs(kwargs)}'
self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
return self return self
@deprecated('use "editTitle" and "editSummary" instead')
def edit(self, title=None, summary=None): def edit(self, title=None, summary=None):
""" Edit the playlist. """ Edit the playlist.
@ -384,7 +392,7 @@ class Playlist(
key = f"/playlists/upload{utils.joinArgs(args)}" key = f"/playlists/upload{utils.joinArgs(args)}"
server.query(key, method=server._session.post) server.query(key, method=server._session.post)
try: 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: except IndexError:
raise BadRequest('Failed to create playlist from m3u file.') from None 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}' key = f'/services/browse/{base64path}'
else: else:
key = '/services/browse' key = '/services/browse'
if includeFiles: key += f'?includeFiles={int(includeFiles)}' # starting with PMS v1.32.7.7621 this must set explicitly
key += '?includeFiles=1'
return self.fetchItems(key) return self.fetchItems(key)
def walk(self, path=None): 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). func (func): Callback function to used cast to type (int, bool, float).
value (any): value to be cast and returned. value (any): value to be cast and returned.
""" """
if value is not None: if value is None:
return value
if func == bool: if func == bool:
if value in (1, True, "1", "true"): if value in (1, True, "1", "true"):
return True return True
elif value in (0, False, "0", "false"): if value in (0, False, "0", "false"):
return False return False
else:
raise ValueError(value) raise ValueError(value)
elif func in (int, float): if func in (int, float):
try: try:
return func(value) return func(value)
except ValueError: except ValueError:
return float('nan') return float('nan')
return func(value) return func(value)
return value
def joinArgs(args): def joinArgs(args):
@ -329,7 +328,7 @@ def toDatetime(value, format=None):
return None return None
try: try:
return datetime.fromtimestamp(value) return datetime.fromtimestamp(value)
except (OSError, OverflowError): except (OSError, OverflowError, ValueError):
try: try:
return datetime.fromtimestamp(0) + timedelta(seconds=value) return datetime.fromtimestamp(0) + timedelta(seconds=value)
except OverflowError: except OverflowError:
@ -407,7 +406,7 @@ def downloadSessionImages(server, filename=None, height=150, width=150,
return info 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): unpack=False, mocked=False, showstatus=False):
""" Helper to download a thumb, videofile or other media item. Returns the local """ Helper to download a thumb, videofile or other media item. Returns the local
path to the downloaded file. path to the downloaded file.

View file

@ -97,64 +97,88 @@ class Video(PlexPartialObject, PlayedUnplayedMixin):
""" Returns str, default title for a new syncItem. """ """ Returns str, default title for a new syncItem. """
return self.title 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): 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' url = f'{self.key}/subtitles'
filename = os.path.basename(filepath) filename = os.path.basename(filepath)
subFormat = os.path.splitext(filepath)[1][1:] subFormat = os.path.splitext(filepath)[1][1:]
with open(filepath, 'rb') as subfile: params = {
params = {'title': filename, 'title': filename,
'format': subFormat 'format': subFormat,
} }
headers = {'Accept': 'text/plain, */*'} headers = {'Accept': 'text/plain, */*'}
with open(filepath, 'rb') as subfile:
self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers) self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers)
return self return self
def removeSubtitles(self, streamID=None, streamTitle=None): def searchSubtitles(self, language='en', hearingImpaired=0, forced=0):
""" Remove Subtitle from movie's subtitles listing. """ 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. Parameters:
Files outside of video directory are not effected. 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(): params = {
if streamID == stream.id or streamTitle == stream.title: 'language': language,
self._server.query(stream.key, self._server._session.delete) '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 return self
def optimize(self, title='', target='', deviceProfile='', videoQuality=None, 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). ratingImage (str): Key to critic rating image (rottentomatoes://image.rating.rotten).
ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects.
roles (List<:class:`~plexapi.media.Role`>): List of role 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. 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). 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?). 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>). theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
@ -387,7 +414,9 @@ class Movie(
self.ratingImage = data.attrib.get('ratingImage') self.ratingImage = data.attrib.get('ratingImage')
self.ratings = self.findItems(data, media.Rating) self.ratings = self.findItems(data, media.Rating)
self.roles = self.findItems(data, media.Role) self.roles = self.findItems(data, media.Role)
self.slug = data.attrib.get('slug')
self.similar = self.findItems(data, media.Similar) self.similar = self.findItems(data, media.Similar)
self.sourceURI = data.attrib.get('source') # remote playlist item
self.studio = data.attrib.get('studio') self.studio = data.attrib.get('studio')
self.tagline = data.attrib.get('tagline') self.tagline = data.attrib.get('tagline')
self.theme = data.attrib.get('theme') self.theme = data.attrib.get('theme')
@ -507,6 +536,7 @@ class Show(
(None = Library default, tmdbAiring = The Movie Database (Aired), (None = Library default, tmdbAiring = The Movie Database (Aired),
aired = TheTVDB (Aired), dvd = TheTVDB (DVD), absolute = TheTVDB (Absolute)). aired = TheTVDB (Aired), dvd = TheTVDB (DVD), absolute = TheTVDB (Absolute)).
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. 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). studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
subtitleLanguage (str): Setting that indicates the preferred subtitle language. subtitleLanguage (str): Setting that indicates the preferred subtitle language.
subtitleMode (int): Setting that indicates the auto-select subtitle mode. 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.seasonCount = utils.cast(int, data.attrib.get('seasonCount', self.childCount))
self.showOrdering = data.attrib.get('showOrdering') self.showOrdering = data.attrib.get('showOrdering')
self.similar = self.findItems(data, media.Similar) self.similar = self.findItems(data, media.Similar)
self.slug = data.attrib.get('slug')
self.studio = data.attrib.get('studio') 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.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1'))
self.tagline = data.attrib.get('tagline') self.tagline = data.attrib.get('tagline')
self.theme = data.attrib.get('theme') self.theme = data.attrib.get('theme')
@ -693,6 +724,7 @@ class Season(
parentIndex (int): Plex index number for the show. parentIndex (int): Plex index number for the show.
parentKey (str): API URL of the show (/library/metadata/<parentRatingKey>). parentKey (str): API URL of the show (/library/metadata/<parentRatingKey>).
parentRatingKey (int): Unique key identifying the show. 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. parentStudio (str): Studio that created show.
parentTheme (str): URL to show theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>). parentTheme (str): URL to show theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>).
parentThumb (str): URL to show thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>). 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.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
self.parentKey = data.attrib.get('parentKey') self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentSlug = data.attrib.get('parentSlug')
self.parentStudio = data.attrib.get('parentStudio') self.parentStudio = data.attrib.get('parentStudio')
self.parentTheme = data.attrib.get('parentTheme') self.parentTheme = data.attrib.get('parentTheme')
self.parentThumb = data.attrib.get('parentThumb') self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle') self.parentTitle = data.attrib.get('parentTitle')
self.ratings = self.findItems(data, media.Rating) 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.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1'))
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year')) 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). grandparentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6).
grandparentKey (str): API URL of the show (/library/metadata/<grandparentRatingKey>). grandparentKey (str): API URL of the show (/library/metadata/<grandparentRatingKey>).
grandparentRatingKey (int): Unique key identifying the show. 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>). grandparentTheme (str): URL to show theme resource (/library/metadata/<grandparentRatingkey>/theme/<themeid>).
grandparentThumb (str): URL to show thumbnail image (/library/metadata/<grandparentRatingKey>/thumb/<thumbid>). grandparentThumb (str): URL to show thumbnail image (/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
grandparentTitle (str): Name of the show for the episode. 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. ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects.
roles (List<:class:`~plexapi.media.Role`>): List of role objects. roles (List<:class:`~plexapi.media.Role`>): List of role objects.
skipParent (bool): True if the show's seasons are set to hidden. 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. viewOffset (int): View offset in milliseconds.
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
year (int): Year the episode was released. year (int): Year the episode was released.
@ -898,6 +934,7 @@ class Episode(
self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey') self.grandparentKey = data.attrib.get('grandparentKey')
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
self.grandparentSlug = data.attrib.get('grandparentSlug')
self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentTheme = data.attrib.get('grandparentTheme')
self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle') self.grandparentTitle = data.attrib.get('grandparentTitle')
@ -916,6 +953,7 @@ class Episode(
self.ratings = self.findItems(data, media.Rating) self.ratings = self.findItems(data, media.Rating)
self.roles = self.findItems(data, media.Role) self.roles = self.findItems(data, media.Role)
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) 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.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.writers = self.findItems(data, media.Writer) self.writers = self.findItems(data, media.Writer)
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
@ -961,11 +999,9 @@ class Episode(
@cached_property @cached_property
def _season(self): def _season(self):
""" Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """
if not self.grandparentKey: if self.grandparentKey and self.parentIndex is not None:
return self.fetchItem(f'{self.grandparentKey}/children?excludeAllLeaves=1&index={self.parentIndex}')
return None return None
return self.fetchItem(
f'{self.grandparentKey}/children?excludeAllLeaves=1&index={self.parentIndex}'
)
def __repr__(self): def __repr__(self):
return '<{}>'.format( return '<{}>'.format(
@ -1003,7 +1039,11 @@ class Episode(
@cached_property @cached_property
def seasonNumber(self): def seasonNumber(self):
""" Returns the episode's season number. """ """ 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 @property
def seasonEpisode(self): def seasonEpisode(self):

View file

@ -28,7 +28,7 @@ musicbrainzngs==0.7.1
packaging==23.1 packaging==23.1
paho-mqtt==1.6.1 paho-mqtt==1.6.1
platformdirs==3.11.0 platformdirs==3.11.0
plexapi==4.15.4 plexapi==4.15.10
portend==3.2.0 portend==3.2.0
profilehooks==1.12.0 profilehooks==1.12.0
PyJWT==2.8.0 PyJWT==2.8.0