mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-05 20:51:15 -07:00
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:
parent
040972bcba
commit
b1c0972077
16 changed files with 413 additions and 189 deletions
|
@ -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'))
|
||||
|
||||
|
|
|
@ -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. """
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -276,7 +276,7 @@ class Collection(
|
|||
|
||||
.. code-block:: python
|
||||
|
||||
collection.updateSort(mode="alpha")
|
||||
collection.sortUpdate(sort="alpha")
|
||||
|
||||
"""
|
||||
if self.smart:
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -29,3 +29,8 @@ class Unsupported(PlexApiException):
|
|||
class Unauthorized(BadRequest):
|
||||
""" Invalid username/password or token. """
|
||||
pass
|
||||
|
||||
|
||||
class TwoFactorRequired(Unauthorized):
|
||||
""" Two factor authentication required. """
|
||||
pass
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue