mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-07 05:31: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 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'))
|
||||||
|
|
||||||
|
|
|
@ -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. """
|
||||||
|
|
|
@ -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'))
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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,13 +74,13 @@ 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'))
|
||||||
self.lens = data.attrib.get('lens')
|
self.lens = data.attrib.get('lens')
|
||||||
self.make = data.attrib.get('make')
|
self.make = data.attrib.get('make')
|
||||||
self.model = data.attrib.get('model')
|
self.model = data.attrib.get('model')
|
||||||
|
|
||||||
parent = self._parent()
|
parent = self._parent()
|
||||||
self._parentKey = parent.key
|
self._parentKey = parent.key
|
||||||
|
@ -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,16 +408,16 @@ 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'))
|
||||||
self.endRamp = data.attrib.get('endRamp')
|
self.endRamp = data.attrib.get('endRamp')
|
||||||
self.gain = utils.cast(float, data.attrib.get('gain'))
|
self.gain = utils.cast(float, data.attrib.get('gain'))
|
||||||
self.loudness = utils.cast(float, data.attrib.get('loudness'))
|
self.loudness = utils.cast(float, data.attrib.get('loudness'))
|
||||||
self.lra = utils.cast(float, data.attrib.get('lra'))
|
self.lra = utils.cast(float, data.attrib.get('lra'))
|
||||||
self.peak = utils.cast(float, data.attrib.get('peak'))
|
self.peak = utils.cast(float, data.attrib.get('peak'))
|
||||||
self.startRamp = data.attrib.get('startRamp')
|
self.startRamp = data.attrib.get('startRamp')
|
||||||
|
|
||||||
def setSelected(self):
|
def setSelected(self):
|
||||||
""" Sets this audio stream as the selected audio stream.
|
""" 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.
|
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.
|
||||||
|
|
|
@ -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,7 +787,8 @@ class EditTagsMixin:
|
||||||
|
|
||||||
if not remove:
|
if not remove:
|
||||||
tags = getattr(self, self._tagPlural(tag), [])
|
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 = self._tagHelper(self._tagSingular(tag), items, locked, remove)
|
||||||
edits.update(kwargs)
|
edits.update(kwargs)
|
||||||
|
@ -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
|
||||||
|
|
|
@ -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).
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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:
|
||||||
if func == bool:
|
return value
|
||||||
if value in (1, True, "1", "true"):
|
if func == bool:
|
||||||
return True
|
if value in (1, True, "1", "true"):
|
||||||
elif value in (0, False, "0", "false"):
|
return True
|
||||||
return False
|
if value in (0, False, "0", "false"):
|
||||||
else:
|
return False
|
||||||
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.
|
||||||
|
|
|
@ -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:]
|
||||||
|
params = {
|
||||||
|
'title': filename,
|
||||||
|
'format': subFormat,
|
||||||
|
}
|
||||||
|
headers = {'Accept': 'text/plain, */*'}
|
||||||
with open(filepath, 'rb') as subfile:
|
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)
|
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 None
|
return self.fetchItem(f'{self.grandparentKey}/children?excludeAllLeaves=1&index={self.parentIndex}')
|
||||||
return self.fetchItem(
|
return None
|
||||||
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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue