mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-07 05:31:15 -07:00
Bump plexapi from 4.12.1 to 4.13.1 (#1888)
Bumps [plexapi](https://github.com/pkkid/python-plexapi) from 4.12.1 to 4.13.1. - [Release notes](https://github.com/pkkid/python-plexapi/releases) - [Commits](https://github.com/pkkid/python-plexapi/compare/4.12.1...4.13.1) --- updated-dependencies: - dependency-name: plexapi dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> [skip ci]
This commit is contained in:
parent
3af08f0d07
commit
e79da07973
20 changed files with 1791 additions and 724 deletions
|
@ -3,10 +3,10 @@ import os
|
|||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.base import Playable, PlexPartialObject, PlexSession
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.mixins import (
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
|
||||
OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
|
||||
TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin,
|
||||
|
@ -15,7 +15,7 @@ from plexapi.mixins import (
|
|||
from plexapi.playlist import Playlist
|
||||
|
||||
|
||||
class Audio(PlexPartialObject):
|
||||
class Audio(PlexPartialObject, PlayedUnplayedMixin):
|
||||
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
|
||||
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
|
||||
|
||||
|
@ -46,7 +46,6 @@ class Audio(PlexPartialObject):
|
|||
userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars).
|
||||
viewCount (int): Count of times the item was played.
|
||||
"""
|
||||
|
||||
METADATA_TYPE = 'track'
|
||||
|
||||
def _loadData(self, data):
|
||||
|
@ -121,7 +120,7 @@ class Audio(PlexPartialObject):
|
|||
|
||||
section = self._server.library.sectionByID(self.librarySectionID)
|
||||
|
||||
sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key))
|
||||
sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}'
|
||||
sync_item.policy = Policy.create(limit)
|
||||
sync_item.mediaSettings = MediaSettings.createMusic(bitrate)
|
||||
|
||||
|
@ -146,6 +145,7 @@ class Artist(
|
|||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
countries (List<:class:`~plexapi.media.Country`>): List country objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
locations (List<str>): List of folder paths where the artist is found on disk.
|
||||
|
@ -163,6 +163,7 @@ class Artist(
|
|||
self.collections = self.findItems(data, media.Collection)
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
|
@ -180,13 +181,14 @@ class Artist(
|
|||
Parameters:
|
||||
title (str): Title of the album to return.
|
||||
"""
|
||||
key = f"/library/sections/{self.librarySectionID}/all?artist.id={self.ratingKey}&type=9"
|
||||
return self.fetchItem(key, Album, title__iexact=title)
|
||||
try:
|
||||
return self.section().search(title, libtype='album', filters={'artist.id': self.ratingKey})[0]
|
||||
except IndexError:
|
||||
raise NotFound(f"Unable to find album '{title}'") from None
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
|
||||
key = f"/library/sections/{self.librarySectionID}/all?artist.id={self.ratingKey}&type=9"
|
||||
return self.fetchItems(key, Album, **kwargs)
|
||||
return self.section().search(libtype='album', filters={'artist.id': self.ratingKey}, **kwargs)
|
||||
|
||||
def track(self, title=None, album=None, track=None):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
@ -199,7 +201,7 @@ class Artist(
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
key = f'{self.key}/allLeaves'
|
||||
if title is not None:
|
||||
return self.fetchItem(key, Track, title__iexact=title)
|
||||
elif album is not None and track is not None:
|
||||
|
@ -208,7 +210,7 @@ class Artist(
|
|||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
key = f'{self.key}/allLeaves'
|
||||
return self.fetchItems(key, Track, **kwargs)
|
||||
|
||||
def get(self, title=None, album=None, track=None):
|
||||
|
@ -233,7 +235,7 @@ class Artist(
|
|||
|
||||
def station(self):
|
||||
""" Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """
|
||||
key = '%s?includeStations=1' % self.key
|
||||
key = f'{self.key}?includeStations=1'
|
||||
return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None)
|
||||
|
||||
|
||||
|
@ -253,6 +255,7 @@ class Album(
|
|||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
formats (List<:class:`~plexapi.media.Format`>): List of format objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
leafCount (int): Number of items in the album view.
|
||||
|
@ -280,6 +283,7 @@ class Album(
|
|||
self.collections = self.findItems(data, media.Collection)
|
||||
self.formats = self.findItems(data, media.Format)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
|
@ -312,7 +316,7 @@ class Album(
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
if title is not None and not isinstance(title, int):
|
||||
return self.fetchItem(key, Track, title__iexact=title)
|
||||
elif track is not None or isinstance(title, int):
|
||||
|
@ -325,7 +329,7 @@ class Album(
|
|||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Track, **kwargs)
|
||||
|
||||
def get(self, title=None, track=None):
|
||||
|
@ -352,7 +356,7 @@ class Album(
|
|||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return '%s - %s' % (self.parentTitle, self.title)
|
||||
return f'{self.parentTitle} - {self.title}'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
|
@ -380,6 +384,7 @@ class Track(
|
|||
grandparentThumb (str): URL to album artist thumbnail image
|
||||
(/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
|
||||
grandparentTitle (str): Name of the album artist for the track.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
originalTitle (str): The artist for the track.
|
||||
|
@ -390,7 +395,7 @@ class Track(
|
|||
parentThumb (str): URL to album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the album for the track.
|
||||
primaryExtraKey (str) API URL for the primary extra for the track.
|
||||
ratingCount (int): Number of ratings contributing to the rating score.
|
||||
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.
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year the track was released.
|
||||
|
@ -412,6 +417,7 @@ class Track(
|
|||
self.grandparentTheme = data.attrib.get('grandparentTheme')
|
||||
self.grandparentThumb = data.attrib.get('grandparentThumb')
|
||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.originalTitle = data.attrib.get('originalTitle')
|
||||
|
@ -429,8 +435,7 @@ class Track(
|
|||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s - %s - %s - %s' % (
|
||||
self.grandparentTitle, self.parentTitle, str(self.trackNumber).zfill(2), self.title)
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}'
|
||||
|
||||
def album(self):
|
||||
""" Return the track's :class:`~plexapi.audio.Album`. """
|
||||
|
@ -457,8 +462,21 @@ class Track(
|
|||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title)
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - {self.title}'
|
||||
|
||||
def _getWebURL(self, base=None):
|
||||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class TrackSession(PlexSession, Track):
|
||||
""" Represents a single Track session
|
||||
loaded from :func:`~plexapi.server.PlexServer.sessions`.
|
||||
"""
|
||||
_SESSIONTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Track._loadData(self, data)
|
||||
PlexSession._loadData(self, data)
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import weakref
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
from urllib.parse import urlencode
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from plexapi import log, utils
|
||||
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
|
||||
|
||||
USER_DONT_RELOAD_FOR_KEYS = set()
|
||||
_DONT_RELOAD_FOR_KEYS = {'key', 'session'}
|
||||
_DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'}
|
||||
_DONT_RELOAD_FOR_KEYS = {'key'}
|
||||
OPERATORS = {
|
||||
'exact': lambda v, q: v == q,
|
||||
'iexact': lambda v, q: v.lower() == q.lower(),
|
||||
|
@ -58,15 +57,11 @@ class PlexObject:
|
|||
self._details_key = self._buildDetailsKey()
|
||||
|
||||
def __repr__(self):
|
||||
uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri'))
|
||||
uid = self._clean(self.firstAttr('_baseurl', 'ratingKey', 'id', 'key', 'playQueueID', 'uri'))
|
||||
name = self._clean(self.firstAttr('title', 'name', 'username', 'product', 'tag', 'value'))
|
||||
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p])
|
||||
return f"<{':'.join([p for p in [self.__class__.__name__, uid, name] if p])}>"
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
# Don't overwrite session specific attr with []
|
||||
if attr in _DONT_OVERWRITE_SESSION_KEYS and value == []:
|
||||
value = getattr(self, attr, [])
|
||||
|
||||
overwriteNone = self.__dict__.get('_overwriteNone')
|
||||
# Don't overwrite an attr with None unless it's a private variable or overwrite None is True
|
||||
if value is not None or attr.startswith('_') or attr not in self.__dict__ or overwriteNone:
|
||||
|
@ -89,12 +84,14 @@ class PlexObject:
|
|||
return cls(self._server, elem, initpath, parent=self)
|
||||
# cls is not specified, try looking it up in PLEXOBJECTS
|
||||
etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type')))
|
||||
ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag
|
||||
ehash = f'{elem.tag}.{etype}' if etype else elem.tag
|
||||
if initpath == '/status/sessions':
|
||||
ehash = f"{ehash}.{'session'}"
|
||||
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)
|
||||
raise UnknownType("Unknown library type <%s type='%s'../>" % (elem.tag, etype))
|
||||
raise UnknownType(f"Unknown library type <{elem.tag} type='{etype}'../>")
|
||||
|
||||
def _buildItemOrNone(self, elem, cls=None, initpath=None):
|
||||
""" Calls :func:`~plexapi.base.PlexObject._buildItem` but returns
|
||||
|
@ -170,17 +167,19 @@ class PlexObject:
|
|||
if ekey is None:
|
||||
raise BadRequest('ekey was not provided')
|
||||
if isinstance(ekey, int):
|
||||
ekey = '/library/metadata/%s' % ekey
|
||||
ekey = f'/library/metadata/{ekey}'
|
||||
|
||||
data = self._server.query(ekey)
|
||||
item = self.findItem(data, cls, ekey, **kwargs)
|
||||
|
||||
if item:
|
||||
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
for elem in data:
|
||||
if self._checkAttrs(elem, **kwargs):
|
||||
item = self._buildItem(elem, cls, ekey)
|
||||
if librarySectionID:
|
||||
item.librarySectionID = librarySectionID
|
||||
return item
|
||||
|
||||
clsname = cls.__name__ if cls else 'None'
|
||||
raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs))
|
||||
raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}')
|
||||
|
||||
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
|
||||
""" Load the specified key to find and build all items with the specified tag
|
||||
|
@ -256,15 +255,16 @@ class PlexObject:
|
|||
fetchItem(ekey, Media__Part__file__startswith="D:\\Movies")
|
||||
|
||||
"""
|
||||
url_kw = {}
|
||||
if container_start is not None:
|
||||
url_kw["X-Plex-Container-Start"] = container_start
|
||||
if container_size is not None:
|
||||
url_kw["X-Plex-Container-Size"] = container_size
|
||||
|
||||
if ekey is None:
|
||||
raise BadRequest('ekey was not provided')
|
||||
data = self._server.query(ekey, params=url_kw)
|
||||
|
||||
params = {}
|
||||
if container_start is not None:
|
||||
params["X-Plex-Container-Start"] = container_start
|
||||
if container_size is not None:
|
||||
params["X-Plex-Container-Size"] = container_size
|
||||
|
||||
data = self._server.query(ekey, params=params)
|
||||
items = self.findItems(data, cls, ekey, **kwargs)
|
||||
|
||||
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
|
@ -273,6 +273,25 @@ class PlexObject:
|
|||
item.librarySectionID = librarySectionID
|
||||
return items
|
||||
|
||||
def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
||||
""" Load the specified data to find and build the first items with the specified tag
|
||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||
on how this is used.
|
||||
"""
|
||||
# filter on cls attrs if specified
|
||||
if cls and cls.TAG and 'tag' not in kwargs:
|
||||
kwargs['etag'] = cls.TAG
|
||||
if cls and cls.TYPE and 'type' not in kwargs:
|
||||
kwargs['type'] = cls.TYPE
|
||||
# rtag to iter on a specific root tag
|
||||
if rtag:
|
||||
data = next(data.iter(rtag), [])
|
||||
# loop through all data elements to find matches
|
||||
for elem in data:
|
||||
if self._checkAttrs(elem, **kwargs):
|
||||
item = self._buildItemOrNone(elem, cls, initpath)
|
||||
return item
|
||||
|
||||
def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
||||
""" Load the specified data to find and build all items with the specified tag
|
||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||
|
@ -309,7 +328,7 @@ class PlexObject:
|
|||
if rtag:
|
||||
data = next(utils.iterXMLBFS(data, rtag), [])
|
||||
for elem in data:
|
||||
kwargs['%s__exists' % attr] = True
|
||||
kwargs[f'{attr}__exists'] = True
|
||||
if self._checkAttrs(elem, **kwargs):
|
||||
results.append(elem.attrib.get(attr))
|
||||
return results
|
||||
|
@ -380,7 +399,7 @@ class PlexObject:
|
|||
|
||||
def _getAttrOperator(self, attr):
|
||||
for op, operator in OPERATORS.items():
|
||||
if attr.endswith('__%s' % op):
|
||||
if attr.endswith(f'__{op}'):
|
||||
attr = attr.rsplit('__', 1)[0]
|
||||
return attr, op, operator
|
||||
# default to exact match
|
||||
|
@ -468,16 +487,16 @@ class PlexPartialObject(PlexObject):
|
|||
value = super(PlexPartialObject, self).__getattribute__(attr)
|
||||
# Check a few cases where we don't want to reload
|
||||
if attr in _DONT_RELOAD_FOR_KEYS: return value
|
||||
if attr in _DONT_OVERWRITE_SESSION_KEYS: return value
|
||||
if attr in USER_DONT_RELOAD_FOR_KEYS: return value
|
||||
if attr.startswith('_'): return value
|
||||
if value not in (None, []): return value
|
||||
if self.isFullObject(): return value
|
||||
if isinstance(self, PlexSession): return value
|
||||
if self._autoReload is False: return value
|
||||
# Log the reload.
|
||||
clsname = self.__class__.__name__
|
||||
title = self.__dict__.get('title', self.__dict__.get('name'))
|
||||
objname = "%s '%s'" % (clsname, title) if title else clsname
|
||||
objname = f"{clsname} '{title}'" if title else clsname
|
||||
log.debug("Reloading %s for attr '%s'", objname, attr)
|
||||
# Reload and return the value
|
||||
self._reload(_overwriteNone=False)
|
||||
|
@ -502,7 +521,7 @@ class PlexPartialObject(PlexObject):
|
|||
* Generate intro video markers: Detects show intros, exposing the
|
||||
'Skip Intro' button in clients.
|
||||
"""
|
||||
key = '/%s/analyze' % self.key.lstrip('/')
|
||||
key = f"/{self.key.lstrip('/')}/analyze"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def isFullObject(self):
|
||||
|
@ -528,8 +547,7 @@ class PlexPartialObject(PlexObject):
|
|||
if 'type' not in kwargs:
|
||||
kwargs['type'] = utils.searchType(self._searchType)
|
||||
|
||||
part = '/library/sections/%s/all%s' % (self.librarySectionID,
|
||||
utils.joinArgs(kwargs))
|
||||
part = f'/library/sections/{self.librarySectionID}/all{utils.joinArgs(kwargs)}'
|
||||
self._server.query(part, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
|
@ -608,7 +626,7 @@ class PlexPartialObject(PlexObject):
|
|||
the refresh process is interrupted (the Server is turned off, internet
|
||||
connection dies, etc).
|
||||
"""
|
||||
key = '%s/refresh' % self.key
|
||||
key = f'{self.key}/refresh'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def section(self):
|
||||
|
@ -655,12 +673,6 @@ class Playable:
|
|||
Albums which are all not playable.
|
||||
|
||||
Attributes:
|
||||
sessionKey (int): Active session key.
|
||||
usernames (str): Username of the person playing this item (for active sessions).
|
||||
players (:class:`~plexapi.client.PlexClient`): Client objects playing this item (for active sessions).
|
||||
session (:class:`~plexapi.media.Session`): Session object, for a playing media file.
|
||||
transcodeSessions (:class:`~plexapi.media.TranscodeSession`): Transcode Session object
|
||||
if item is being transcoded (None otherwise).
|
||||
viewedAt (datetime): Datetime item was last viewed (history).
|
||||
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
|
||||
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
|
||||
|
@ -669,11 +681,6 @@ class Playable:
|
|||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) # session
|
||||
self.usernames = self.listAttrs(data, 'title', etag='User') # session
|
||||
self.players = self.findItems(data, etag='Player') # session
|
||||
self.transcodeSessions = self.findItems(data, etag='TranscodeSession') # session
|
||||
self.session = self.findItems(data, etag='Session') # session
|
||||
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
|
||||
self.accountID = utils.cast(int, data.attrib.get('accountID')) # history
|
||||
self.deviceID = utils.cast(int, data.attrib.get('deviceID')) # history
|
||||
|
@ -692,7 +699,7 @@ class Playable:
|
|||
:exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
|
||||
"""
|
||||
if self.TYPE not in ('movie', 'episode', 'track', 'clip'):
|
||||
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
|
||||
raise Unsupported(f'Fetching stream URL for {self.TYPE} is unsupported.')
|
||||
mvb = params.get('maxVideoBitrate')
|
||||
vr = params.get('videoResolution', '')
|
||||
params = {
|
||||
|
@ -710,8 +717,10 @@ class Playable:
|
|||
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
|
||||
# sort the keys since the randomness fucks with my tests..
|
||||
sorted_params = sorted(params.items(), key=lambda val: val[0])
|
||||
return self._server.url('/%s/:/transcode/universal/start.m3u8?%s' %
|
||||
(streamtype, urlencode(sorted_params)), includeToken=True)
|
||||
return self._server.url(
|
||||
f'/{streamtype}/:/transcode/universal/start.m3u8?{urlencode(sorted_params)}',
|
||||
includeToken=True
|
||||
)
|
||||
|
||||
def iterParts(self):
|
||||
""" Iterates over the parts of this media item. """
|
||||
|
@ -751,7 +760,7 @@ class Playable:
|
|||
|
||||
for part in parts:
|
||||
if not keep_original_name:
|
||||
filename = utils.cleanFilename('%s.%s' % (self._prettyfilename(), part.container))
|
||||
filename = utils.cleanFilename(f'{self._prettyfilename()}.{part.container}')
|
||||
else:
|
||||
filename = part.file
|
||||
|
||||
|
@ -759,7 +768,7 @@ class Playable:
|
|||
# So this seems to be a a lot slower but allows transcode.
|
||||
download_url = self.getStreamURL(**kwargs)
|
||||
else:
|
||||
download_url = self._server.url('%s?download=1' % part.key)
|
||||
download_url = self._server.url(f'{part.key}?download=1')
|
||||
|
||||
filepath = utils.download(
|
||||
download_url,
|
||||
|
@ -774,24 +783,19 @@ class Playable:
|
|||
|
||||
return filepaths
|
||||
|
||||
def stop(self, reason=''):
|
||||
""" Stop playback for a media item. """
|
||||
key = '/status/sessions/terminate?sessionId=%s&reason=%s' % (self.session[0].id, quote_plus(reason))
|
||||
return self._server.query(key)
|
||||
|
||||
def updateProgress(self, time, state='stopped'):
|
||||
""" Set the watched progress for this video.
|
||||
|
||||
Note that setting the time to 0 will not work.
|
||||
Use `markWatched` or `markUnwatched` to achieve
|
||||
Use :func:`~plexapi.mixins.PlayedMixin.markPlayed` or
|
||||
:func:`~plexapi.mixins.PlayedMixin.markUnplayed` to achieve
|
||||
that goal.
|
||||
|
||||
Parameters:
|
||||
time (int): milliseconds watched
|
||||
state (string): state of the video, default 'stopped'
|
||||
"""
|
||||
key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey,
|
||||
time, state)
|
||||
key = f'/:/progress?key={self.ratingKey}&identifier=com.plexapp.plugins.library&time={time}&state={state}'
|
||||
self._server.query(key)
|
||||
self._reload(_overwriteNone=False)
|
||||
|
||||
|
@ -808,12 +812,94 @@ class Playable:
|
|||
durationStr = durationStr + str(duration)
|
||||
else:
|
||||
durationStr = durationStr + str(self.duration)
|
||||
key = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s'
|
||||
key %= (self.ratingKey, self.key, time, state, durationStr)
|
||||
key = (f'/:/timeline?ratingKey={self.ratingKey}&key={self.key}&'
|
||||
f'identifier=com.plexapp.plugins.library&time={int(time)}&state={state}{durationStr}')
|
||||
self._server.query(key)
|
||||
self._reload(_overwriteNone=False)
|
||||
|
||||
|
||||
class PlexSession(object):
|
||||
""" This is a general place to store functions specific to media that is a Plex Session.
|
||||
|
||||
Attributes:
|
||||
live (bool): True if this is a live tv session.
|
||||
player (:class:`~plexapi.client.PlexClient`): PlexClient object for the session.
|
||||
session (:class:`~plexapi.media.Session`): Session object for the session
|
||||
if the session is using bandwidth (None otherwise).
|
||||
sessionKey (int): The session key for the session.
|
||||
transcodeSession (:class:`~plexapi.media.TranscodeSession`): TranscodeSession object
|
||||
if item is being transcoded (None otherwise).
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
self.live = utils.cast(bool, data.attrib.get('live', '0'))
|
||||
self.player = self.findItem(data, etag='Player')
|
||||
self.session = self.findItem(data, etag='Session')
|
||||
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey'))
|
||||
self.transcodeSession = self.findItem(data, etag='TranscodeSession')
|
||||
|
||||
user = data.find('User')
|
||||
self._username = user.attrib.get('title')
|
||||
self._userId = utils.cast(int, user.attrib.get('id'))
|
||||
self._user = None # Cache for user object
|
||||
|
||||
# For backwards compatibility
|
||||
self.players = [self.player] if self.player else []
|
||||
self.sessions = [self.session] if self.session else []
|
||||
self.transcodeSessions = [self.transcodeSession] if self.transcodeSession else []
|
||||
self.usernames = [self._username] if self._username else []
|
||||
|
||||
@property
|
||||
def user(self):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexAccount` object (for admin)
|
||||
or :class:`~plexapi.myplex.MyPlexUser` object (for users) for this session.
|
||||
"""
|
||||
if self._user is None:
|
||||
myPlexAccount = self._server.myPlexAccount()
|
||||
if self._userId == 1:
|
||||
self._user = myPlexAccount
|
||||
else:
|
||||
self._user = myPlexAccount.user(self._username)
|
||||
return self._user
|
||||
|
||||
def reload(self):
|
||||
""" Reload the data for the session.
|
||||
Note: This will return the object as-is if the session is no longer active.
|
||||
"""
|
||||
return self._reload()
|
||||
|
||||
def _reload(self, _autoReload=False, **kwargs):
|
||||
""" Perform the actual reload. """
|
||||
# Do not auto reload sessions
|
||||
if _autoReload:
|
||||
return self
|
||||
|
||||
key = self._initpath
|
||||
data = self._server.query(key)
|
||||
for elem in data:
|
||||
if elem.attrib.get('sessionKey') == str(self.sessionKey):
|
||||
self._loadData(elem)
|
||||
break
|
||||
return self
|
||||
|
||||
def source(self):
|
||||
""" Return the source media object for the session. """
|
||||
return self.fetchItem(self._details_key)
|
||||
|
||||
def stop(self, reason=''):
|
||||
""" Stop playback for the session.
|
||||
|
||||
Parameters:
|
||||
reason (str): Message displayed to the user for stopping playback.
|
||||
"""
|
||||
params = {
|
||||
'sessionId': self.session.id,
|
||||
'reason': reason,
|
||||
}
|
||||
key = '/status/sessions/terminate'
|
||||
return self._server.query(key, params=params)
|
||||
|
||||
|
||||
class MediaContainer(PlexObject):
|
||||
""" Represents a single MediaContainer.
|
||||
|
||||
|
|
|
@ -94,7 +94,7 @@ class PlexClient(PlexObject):
|
|||
self._initpath = self.key
|
||||
data = self.query(self.key, timeout=timeout)
|
||||
if not data:
|
||||
raise NotFound("Client not found at %s" % self._baseurl)
|
||||
raise NotFound(f"Client not found at {self._baseurl}")
|
||||
if self._clientIdentifier:
|
||||
client = next(
|
||||
(
|
||||
|
@ -106,8 +106,7 @@ class PlexClient(PlexObject):
|
|||
)
|
||||
if client is None:
|
||||
raise NotFound(
|
||||
"Client with identifier %s not found at %s"
|
||||
% (self._clientIdentifier, self._baseurl)
|
||||
f"Client with identifier {self._clientIdentifier} not found at {self._baseurl}"
|
||||
)
|
||||
else:
|
||||
client = data[0]
|
||||
|
@ -136,11 +135,14 @@ class PlexClient(PlexObject):
|
|||
# Add this in next breaking release.
|
||||
# if self._initpath == 'status/sessions':
|
||||
self.device = data.attrib.get('device') # session
|
||||
self.profile = data.attrib.get('profile') # session
|
||||
self.model = data.attrib.get('model') # session
|
||||
self.state = data.attrib.get('state') # session
|
||||
self.vendor = data.attrib.get('vendor') # session
|
||||
self.version = data.attrib.get('version') # session
|
||||
self.local = utils.cast(bool, data.attrib.get('local', 0))
|
||||
self.local = utils.cast(bool, data.attrib.get('local', 0)) # session
|
||||
self.relayed = utils.cast(bool, data.attrib.get('relayed', 0)) # session
|
||||
self.secure = utils.cast(bool, data.attrib.get('secure', 0)) # session
|
||||
self.address = data.attrib.get('address') # session
|
||||
self.remotePublicAddress = data.attrib.get('remotePublicAddress')
|
||||
self.userID = data.attrib.get('userID')
|
||||
|
@ -183,7 +185,7 @@ class PlexClient(PlexObject):
|
|||
if response.status_code not in (200, 201, 204):
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
||||
message = f'({response.status_code}) {codename}; {response.url} {errtext}'
|
||||
if response.status_code == 401:
|
||||
raise Unauthorized(message)
|
||||
elif response.status_code == 404:
|
||||
|
@ -210,8 +212,7 @@ class PlexClient(PlexObject):
|
|||
controller = command.split('/')[0]
|
||||
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
|
||||
if controller not in self.protocolCapabilities:
|
||||
log.debug("Client %s doesn't support %s controller."
|
||||
"What your trying might not work" % (self.title, controller))
|
||||
log.debug("Client %s doesn't support %s controller. What your trying might not work", self.title, controller)
|
||||
|
||||
proxy = self._proxyThroughServer if proxy is None else proxy
|
||||
query = self._server.query if proxy else self.query
|
||||
|
@ -225,7 +226,7 @@ class PlexClient(PlexObject):
|
|||
self.sendCommand(ClientTimeline.key, wait=0)
|
||||
|
||||
params['commandID'] = self._nextCommandId()
|
||||
key = '/player/%s%s' % (command, utils.joinArgs(params))
|
||||
key = f'/player/{command}{utils.joinArgs(params)}'
|
||||
|
||||
try:
|
||||
return query(key, headers=headers)
|
||||
|
@ -250,8 +251,8 @@ class PlexClient(PlexObject):
|
|||
raise BadRequest('PlexClient object missing baseurl.')
|
||||
if self._token and (includeToken or self._showSecrets):
|
||||
delim = '&' if '?' in key else '?'
|
||||
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
|
||||
return '%s%s' % (self._baseurl, key)
|
||||
return f'{self._baseurl}{key}{delim}X-Plex-Token={self._token}'
|
||||
return f'{self._baseurl}{key}'
|
||||
|
||||
# ---------------------
|
||||
# Navigation Commands
|
||||
|
@ -514,7 +515,7 @@ class PlexClient(PlexObject):
|
|||
'offset': offset,
|
||||
'key': media.key or playqueue.selectedItem.key,
|
||||
'type': mediatype,
|
||||
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
|
||||
'containerKey': f'/playQueues/{playqueue.playQueueID}?window=100&own=1',
|
||||
**params,
|
||||
}
|
||||
token = media._server.createToken()
|
||||
|
@ -620,7 +621,7 @@ class ClientTimeline(PlexObject):
|
|||
self.protocol = data.attrib.get('protocol')
|
||||
self.providerIdentifier = data.attrib.get('providerIdentifier')
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.repeat = utils.cast(bool, data.attrib.get('repeat'))
|
||||
self.repeat = utils.cast(int, data.attrib.get('repeat'))
|
||||
self.seekRange = data.attrib.get('seekRange')
|
||||
self.shuffle = utils.cast(bool, data.attrib.get('shuffle'))
|
||||
self.state = data.attrib.get('state')
|
||||
|
|
|
@ -4,7 +4,7 @@ from urllib.parse import quote_plus
|
|||
from plexapi import media, utils
|
||||
from plexapi.base import PlexPartialObject
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
||||
from plexapi.library import LibrarySection
|
||||
from plexapi.library import LibrarySection, ManagedHub
|
||||
from plexapi.mixins import (
|
||||
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
|
@ -184,16 +184,28 @@ class Collection(
|
|||
for item in self.items():
|
||||
if item.title.lower() == title.lower():
|
||||
return item
|
||||
raise NotFound('Item with title "%s" not found in the collection' % title)
|
||||
raise NotFound(f'Item with title "{title}" not found in the collection')
|
||||
|
||||
def items(self):
|
||||
""" Returns a list of all items in the collection. """
|
||||
if self._items is None:
|
||||
key = '%s/children' % self.key
|
||||
key = f'{self.key}/children'
|
||||
items = self.fetchItems(key)
|
||||
self._items = items
|
||||
return self._items
|
||||
|
||||
def visibility(self):
|
||||
""" Returns the :class:`~plexapi.library.ManagedHub` for this collection. """
|
||||
key = f'/hubs/sections/{self.librarySectionID}/manage?metadataItemId={self.ratingKey}'
|
||||
data = self._server.query(key)
|
||||
hub = self.findItem(data, cls=ManagedHub)
|
||||
if hub is None:
|
||||
hub = ManagedHub(self._server, data, parent=self)
|
||||
hub.identifier = f'custom.collection.{self.librarySectionID}.{self.ratingKey}'
|
||||
hub.title = self.title
|
||||
hub._promoted = False
|
||||
return hub
|
||||
|
||||
def get(self, title):
|
||||
""" Alias to :func:`~plexapi.library.Collection.item`. """
|
||||
return self.item(title)
|
||||
|
@ -221,8 +233,8 @@ class Collection(
|
|||
}
|
||||
key = user_dict.get(user)
|
||||
if key is None:
|
||||
raise BadRequest('Unknown collection filtering user: %s. Options %s' % (user, list(user_dict)))
|
||||
self.editAdvanced(collectionFilterBasedOnUser=key)
|
||||
raise BadRequest(f'Unknown collection filtering user: {user}. Options {list(user_dict)}')
|
||||
return self.editAdvanced(collectionFilterBasedOnUser=key)
|
||||
|
||||
def modeUpdate(self, mode=None):
|
||||
""" Update the collection mode advanced setting.
|
||||
|
@ -248,8 +260,8 @@ class Collection(
|
|||
}
|
||||
key = mode_dict.get(mode)
|
||||
if key is None:
|
||||
raise BadRequest('Unknown collection mode: %s. Options %s' % (mode, list(mode_dict)))
|
||||
self.editAdvanced(collectionMode=key)
|
||||
raise BadRequest(f'Unknown collection mode: {mode}. Options {list(mode_dict)}')
|
||||
return self.editAdvanced(collectionMode=key)
|
||||
|
||||
def sortUpdate(self, sort=None):
|
||||
""" Update the collection order advanced setting.
|
||||
|
@ -276,8 +288,8 @@ class Collection(
|
|||
}
|
||||
key = sort_dict.get(sort)
|
||||
if key is None:
|
||||
raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict)))
|
||||
self.editAdvanced(collectionSort=key)
|
||||
raise BadRequest(f'Unknown sort dir: {sort}. Options: {list(sort_dict)}')
|
||||
return self.editAdvanced(collectionSort=key)
|
||||
|
||||
def addItems(self, items):
|
||||
""" Add items to the collection.
|
||||
|
@ -298,17 +310,16 @@ class Collection(
|
|||
ratingKeys = []
|
||||
for item in items:
|
||||
if item.type != self.subtype: # pragma: no cover
|
||||
raise BadRequest('Can not mix media types when building a collection: %s and %s' %
|
||||
(self.subtype, item.type))
|
||||
raise BadRequest(f'Can not mix media types when building a collection: {self.subtype} and {item.type}')
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys)
|
||||
uri = f'{self._server._uriRoot()}/library/metadata/{ratingKeys}'
|
||||
|
||||
key = '%s/items%s' % (self.key, utils.joinArgs({
|
||||
'uri': uri
|
||||
}))
|
||||
args = {'uri': uri}
|
||||
key = f"{self.key}/items{utils.joinArgs(args)}"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def removeItems(self, items):
|
||||
""" Remove items from the collection.
|
||||
|
@ -327,17 +338,18 @@ class Collection(
|
|||
items = [items]
|
||||
|
||||
for item in items:
|
||||
key = '%s/items/%s' % (self.key, item.ratingKey)
|
||||
key = f'{self.key}/items/{item.ratingKey}'
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
return self
|
||||
|
||||
def moveItem(self, item, after=None):
|
||||
""" Move an item to a new position in the collection.
|
||||
|
||||
Parameters:
|
||||
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` objects to be moved in the collection.
|
||||
item (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` object to be moved in the collection.
|
||||
after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` objects to move the item after in the collection.
|
||||
or :class:`~plexapi.photo.Photo` object to move the item after in the collection.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart collection.
|
||||
|
@ -345,12 +357,13 @@ class Collection(
|
|||
if self.smart:
|
||||
raise BadRequest('Cannot move items in a smart collection.')
|
||||
|
||||
key = '%s/items/%s/move' % (self.key, item.ratingKey)
|
||||
key = f'{self.key}/items/{item.ratingKey}/move'
|
||||
|
||||
if after:
|
||||
key += '?after=%s' % after.ratingKey
|
||||
key += f'?after={after.ratingKey}'
|
||||
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs):
|
||||
""" Update the filters for a smart collection.
|
||||
|
@ -376,12 +389,12 @@ class Collection(
|
|||
section = self.section()
|
||||
searchKey = section._buildSearchKey(
|
||||
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs)
|
||||
uri = '%s%s' % (self._server._uriRoot(), searchKey)
|
||||
uri = f'{self._server._uriRoot()}{searchKey}'
|
||||
|
||||
key = '%s/items%s' % (self.key, utils.joinArgs({
|
||||
'uri': uri
|
||||
}))
|
||||
args = {'uri': uri}
|
||||
key = f"{self.key}/items{utils.joinArgs(args)}"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
@deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead')
|
||||
def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs):
|
||||
|
@ -438,15 +451,10 @@ class Collection(
|
|||
ratingKeys.append(str(item.ratingKey))
|
||||
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys)
|
||||
uri = f'{server._uriRoot()}/library/metadata/{ratingKeys}'
|
||||
|
||||
key = '/library/collections%s' % utils.joinArgs({
|
||||
'uri': uri,
|
||||
'type': utils.searchType(itemType),
|
||||
'title': title,
|
||||
'smart': 0,
|
||||
'sectionId': section.key
|
||||
})
|
||||
args = {'uri': uri, 'type': utils.searchType(itemType), 'title': title, 'smart': 0, 'sectionId': section.key}
|
||||
key = f"/library/collections{utils.joinArgs(args)}"
|
||||
data = server.query(key, method=server._session.post)[0]
|
||||
return cls(server, data, initpath=key)
|
||||
|
||||
|
@ -460,15 +468,10 @@ class Collection(
|
|||
|
||||
searchKey = section._buildSearchKey(
|
||||
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs)
|
||||
uri = '%s%s' % (server._uriRoot(), searchKey)
|
||||
uri = f'{server._uriRoot()}{searchKey}'
|
||||
|
||||
key = '/library/collections%s' % utils.joinArgs({
|
||||
'uri': uri,
|
||||
'type': utils.searchType(libtype),
|
||||
'title': title,
|
||||
'smart': 1,
|
||||
'sectionId': section.key
|
||||
})
|
||||
args = {'uri': uri, 'type': utils.searchType(libtype), 'title': title, 'smart': 1, 'sectionId': section.key}
|
||||
key = f"/library/collections{utils.joinArgs(args)}"
|
||||
data = server.query(key, method=server._session.post)[0]
|
||||
return cls(server, data, initpath=key)
|
||||
|
||||
|
@ -547,9 +550,8 @@ class Collection(
|
|||
sync_item.metadataType = self.metadataType
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
sync_item.location = 'library:///directory/%s' % quote_plus(
|
||||
'%s/children?excludeAllLeaves=1' % (self.key)
|
||||
)
|
||||
key = quote_plus(f'{self.key}/children?excludeAllLeaves=1')
|
||||
sync_item.location = f'library:///directory/{key}'
|
||||
sync_item.policy = Policy.create(limit, unwatched)
|
||||
|
||||
if self.isVideo:
|
||||
|
|
|
@ -29,7 +29,7 @@ class PlexConfig(ConfigParser):
|
|||
"""
|
||||
try:
|
||||
# First: check environment variable is set
|
||||
envkey = 'PLEXAPI_%s' % key.upper().replace('.', '_')
|
||||
envkey = f"PLEXAPI_{key.upper().replace('.', '_')}"
|
||||
value = os.environ.get(envkey)
|
||||
if value is None:
|
||||
# Second: check the config file has attr
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
# Library version
|
||||
MAJOR_VERSION = 4
|
||||
MINOR_VERSION = 11
|
||||
PATCH_VERSION = 2
|
||||
MINOR_VERSION = 13
|
||||
PATCH_VERSION = 1
|
||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -64,6 +64,7 @@ class Media(PlexObject):
|
|||
self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming'))
|
||||
self.parts = self.findItems(data, MediaPart)
|
||||
self.proxyType = utils.cast(int, data.attrib.get('proxyType'))
|
||||
self.selected = utils.cast(bool, data.attrib.get('selected'))
|
||||
self.target = data.attrib.get('target')
|
||||
self.title = data.attrib.get('title')
|
||||
self.videoCodec = data.attrib.get('videoCodec')
|
||||
|
@ -71,6 +72,7 @@ class Media(PlexObject):
|
|||
self.videoProfile = data.attrib.get('videoProfile')
|
||||
self.videoResolution = data.attrib.get('videoResolution')
|
||||
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')
|
||||
|
@ -89,12 +91,11 @@ class Media(PlexObject):
|
|||
return self.proxyType == utils.SEARCHTYPES['optimizedVersion']
|
||||
|
||||
def delete(self):
|
||||
part = '%s/media/%s' % (self._parentKey, self.id)
|
||||
part = f'{self._parentKey}/media/{self.id}'
|
||||
try:
|
||||
return self._server.query(part, method=self._server._session.delete)
|
||||
except BadRequest:
|
||||
log.error("Failed to delete %s. This could be because you haven't allowed "
|
||||
"items to be deleted" % part)
|
||||
log.error("Failed to delete %s. This could be because you haven't allowed items to be deleted", part)
|
||||
raise
|
||||
|
||||
|
||||
|
@ -146,7 +147,9 @@ class MediaPart(PlexObject):
|
|||
self.key = data.attrib.get('key')
|
||||
self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming'))
|
||||
self.packetLength = utils.cast(int, data.attrib.get('packetLength'))
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
||||
self.selected = utils.cast(bool, data.attrib.get('selected'))
|
||||
self.size = utils.cast(int, data.attrib.get('size'))
|
||||
self.streams = self._buildStreams(data)
|
||||
self.syncItemId = utils.cast(int, data.attrib.get('syncItemId'))
|
||||
|
@ -188,10 +191,11 @@ class MediaPart(PlexObject):
|
|||
stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default
|
||||
"""
|
||||
if isinstance(stream, AudioStream):
|
||||
key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream.id)
|
||||
key = f"/library/parts/{self.id}?audioStreamID={stream.id}&allParts=1"
|
||||
else:
|
||||
key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream)
|
||||
key = f"/library/parts/{self.id}?audioStreamID={stream}&allParts=1"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def setDefaultSubtitleStream(self, stream):
|
||||
""" Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart.
|
||||
|
@ -200,15 +204,17 @@ class MediaPart(PlexObject):
|
|||
stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default.
|
||||
"""
|
||||
if isinstance(stream, SubtitleStream):
|
||||
key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream.id)
|
||||
key = f"/library/parts/{self.id}?subtitleStreamID={stream.id}&allParts=1"
|
||||
else:
|
||||
key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream)
|
||||
key = f"/library/parts/{self.id}?subtitleStreamID={stream}&allParts=1"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def resetDefaultSubtitleStream(self):
|
||||
""" Set default subtitle of this MediaPart to 'none'. """
|
||||
key = "/library/parts/%d?subtitleStreamID=0&allParts=1" % (self.id)
|
||||
key = f"/library/parts/{self.id}?subtitleStreamID=0&allParts=1"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
|
||||
class MediaPartStream(PlexObject):
|
||||
|
@ -225,6 +231,7 @@ class MediaPartStream(PlexObject):
|
|||
index (int): The index of the stream.
|
||||
language (str): The language of the stream (ex: English, ไทย).
|
||||
languageCode (str): The ASCII language code of the stream (ex: eng, tha).
|
||||
languageTag (str): The two letter language tag of the stream (ex: en, fr).
|
||||
requiredBandwidths (str): The required bandwidths to stream the file.
|
||||
selected (bool): True if this stream is selected.
|
||||
streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`,
|
||||
|
@ -238,14 +245,17 @@ class MediaPartStream(PlexObject):
|
|||
self._data = data
|
||||
self.bitrate = utils.cast(int, data.attrib.get('bitrate'))
|
||||
self.codec = data.attrib.get('codec')
|
||||
self.decision = data.attrib.get('decision')
|
||||
self.default = utils.cast(bool, data.attrib.get('default'))
|
||||
self.displayTitle = data.attrib.get('displayTitle')
|
||||
self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
|
||||
self.key = data.attrib.get('key')
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.index = utils.cast(int, data.attrib.get('index', '-1'))
|
||||
self.key = data.attrib.get('key')
|
||||
self.language = data.attrib.get('language')
|
||||
self.languageCode = data.attrib.get('languageCode')
|
||||
self.languageTag = data.attrib.get('languageTag')
|
||||
self.location = data.attrib.get('location')
|
||||
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
||||
self.selected = utils.cast(bool, data.attrib.get('selected', '0'))
|
||||
self.streamType = utils.cast(int, data.attrib.get('streamType'))
|
||||
|
@ -570,22 +580,22 @@ class Optimized(PlexObject):
|
|||
""" Returns a list of all :class:`~plexapi.media.Video` objects
|
||||
in this optimized item.
|
||||
"""
|
||||
key = '%s/%s/items' % (self._initpath, self.id)
|
||||
key = f'{self._initpath}/{self.id}/items'
|
||||
return self.fetchItems(key)
|
||||
|
||||
def remove(self):
|
||||
""" Remove an Optimized item"""
|
||||
key = '%s/%s' % (self._initpath, self.id)
|
||||
key = f'{self._initpath}/{self.id}'
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
|
||||
def rename(self, title):
|
||||
""" Rename an Optimized item"""
|
||||
key = '%s/%s?Item[title]=%s' % (self._initpath, self.id, title)
|
||||
key = f'{self._initpath}/{self.id}?Item[title]={title}'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def reprocess(self, ratingKey):
|
||||
""" Reprocess a removed Conversion item that is still a listed Optimize item"""
|
||||
key = '%s/%s/%s/enable' % (self._initpath, self.id, ratingKey)
|
||||
key = f'{self._initpath}/{self.id}/{ratingKey}/enable'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
|
||||
|
@ -631,7 +641,7 @@ class Conversion(PlexObject):
|
|||
|
||||
def remove(self):
|
||||
""" Remove Conversion from queue """
|
||||
key = '/playlists/%s/items/%s/%s/disable' % (self.playlistID, self.generatorID, self.ratingKey)
|
||||
key = f'/playlists/{self.playlistID}/items/{self.generatorID}/{self.ratingKey}/disable'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def move(self, after):
|
||||
|
@ -646,7 +656,7 @@ class Conversion(PlexObject):
|
|||
conversions[3].move(conversions[1].playQueueItemID)
|
||||
"""
|
||||
|
||||
key = '%s/items/%s/move?after=%s' % (self._initpath, self.playQueueItemID, after)
|
||||
key = f'{self._initpath}/items/{self.playQueueItemID}/move?after={after}'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
|
||||
|
@ -665,6 +675,10 @@ class MediaTag(PlexObject):
|
|||
thumb (str): URL to thumbnail image for :class:`~plexapi.media.Role` only.
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
""" Returns the tag name. """
|
||||
return self.tag
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
|
@ -682,14 +696,12 @@ class MediaTag(PlexObject):
|
|||
self._parentType = parent.TYPE
|
||||
|
||||
if self._librarySectionKey and self.filter:
|
||||
self.key = '%s/all?%s&type=%s' % (
|
||||
self._librarySectionKey, self.filter, utils.searchType(self._parentType))
|
||||
self.key = f'{self._librarySectionKey}/all?{self.filter}&type={utils.searchType(self._parentType)}'
|
||||
|
||||
def items(self):
|
||||
""" Return the list of items within this tag. """
|
||||
if not self.key:
|
||||
raise BadRequest('Key is not defined for this tag: %s. '
|
||||
'Reload the parent object.' % self.tag)
|
||||
raise BadRequest(f'Key is not defined for this tag: {self.tag}. Reload the parent object.')
|
||||
return self.fetchItems(self.key)
|
||||
|
||||
|
||||
|
@ -707,7 +719,7 @@ class Collection(MediaTag):
|
|||
def collection(self):
|
||||
""" Return the :class:`~plexapi.collection.Collection` object for this collection tag.
|
||||
"""
|
||||
key = '%s/collections' % self._librarySectionKey
|
||||
key = f'{self._librarySectionKey}/collections'
|
||||
return self.fetchItem(key, etag='Directory', index=self.id)
|
||||
|
||||
|
||||
|
@ -871,7 +883,7 @@ class GuidTag(PlexObject):
|
|||
""" Base class for guid tags used only for Guids, as they contain only a string identifier
|
||||
|
||||
Attributes:
|
||||
id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB).
|
||||
id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB, MBID).
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
|
@ -938,7 +950,7 @@ class BaseResource(PlexObject):
|
|||
|
||||
def select(self):
|
||||
key = self._initpath[:-1]
|
||||
data = '%s?url=%s' % (key, quote_plus(self.ratingKey))
|
||||
data = f'{key}?url={quote_plus(self.ratingKey)}'
|
||||
try:
|
||||
self._server.query(data, method=self._server._session.put)
|
||||
except xml.etree.ElementTree.ParseError:
|
||||
|
@ -967,7 +979,7 @@ class Theme(BaseResource):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class Chapter(PlexObject):
|
||||
""" Represents a single Writer media tag.
|
||||
""" Represents a single Chapter media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Chapter'
|
||||
|
@ -998,8 +1010,8 @@ class Marker(PlexObject):
|
|||
name = self._clean(self.firstAttr('type'))
|
||||
start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start')))
|
||||
end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end')))
|
||||
offsets = '%s-%s' % (start, end)
|
||||
return '<%s>' % ':'.join([self.__class__.__name__, name, offsets])
|
||||
offsets = f'{start}-{end}'
|
||||
return f"<{':'.join([self.__class__.__name__, name, offsets])}>"
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
|
@ -1036,7 +1048,7 @@ class SearchResult(PlexObject):
|
|||
def __repr__(self):
|
||||
name = self._clean(self.firstAttr('name'))
|
||||
score = self._clean(self.firstAttr('score'))
|
||||
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, name, score] if p])
|
||||
return f"<{':'.join([p for p in [self.__class__.__name__, name, score] if p])}>"
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
|
@ -1058,7 +1070,7 @@ class Agent(PlexObject):
|
|||
|
||||
def __repr__(self):
|
||||
uid = self._clean(self.firstAttr('shortIdentifier'))
|
||||
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p])
|
||||
return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>"
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
|
@ -1082,7 +1094,7 @@ class Agent(PlexObject):
|
|||
return self.languageCodes
|
||||
|
||||
def settings(self):
|
||||
key = '/:/plugins/%s/prefs' % self.identifier
|
||||
key = f'/:/plugins/{self.identifier}/prefs'
|
||||
data = self._server.query(key)
|
||||
return self.findItems(data, cls=settings.Setting)
|
||||
|
||||
|
@ -1101,7 +1113,7 @@ class AgentMediaType(Agent):
|
|||
|
||||
def __repr__(self):
|
||||
uid = self._clean(self.firstAttr('name'))
|
||||
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p])
|
||||
return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>"
|
||||
|
||||
def _loadData(self, data):
|
||||
self.languageCodes = self.listAttrs(data, 'code', etag='Language')
|
||||
|
|
|
@ -27,37 +27,38 @@ class AdvancedSettingsMixin:
|
|||
return next(p for p in prefs if p.id == pref)
|
||||
except StopIteration:
|
||||
availablePrefs = [p.id for p in prefs]
|
||||
raise NotFound('Unknown preference "%s" for %s. '
|
||||
'Available preferences: %s'
|
||||
% (pref, self.TYPE, availablePrefs)) from None
|
||||
raise NotFound(f'Unknown preference "{pref}" for {self.TYPE}. '
|
||||
f'Available preferences: {availablePrefs}') from None
|
||||
|
||||
def editAdvanced(self, **kwargs):
|
||||
""" Edit a Plex object's advanced settings. """
|
||||
data = {}
|
||||
key = '%s/prefs?' % self.key
|
||||
key = f'{self.key}/prefs?'
|
||||
preferences = {pref.id: pref for pref in self.preferences() if pref.enumValues}
|
||||
for settingID, value in kwargs.items():
|
||||
try:
|
||||
pref = preferences[settingID]
|
||||
except KeyError:
|
||||
raise NotFound('%s not found in %s' % (value, list(preferences.keys())))
|
||||
raise NotFound(f'{value} not found in {list(preferences.keys())}')
|
||||
|
||||
enumValues = pref.enumValues
|
||||
if enumValues.get(value, enumValues.get(str(value))):
|
||||
data[settingID] = value
|
||||
else:
|
||||
raise NotFound('%s not found in %s' % (value, list(enumValues)))
|
||||
raise NotFound(f'{value} not found in {list(enumValues)}')
|
||||
url = key + urlencode(data)
|
||||
self._server.query(url, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def defaultAdvanced(self):
|
||||
""" Edit all of a Plex object's advanced settings to default. """
|
||||
data = {}
|
||||
key = '%s/prefs?' % self.key
|
||||
key = f'{self.key}/prefs?'
|
||||
for preference in self.preferences():
|
||||
data[preference.id] = preference.default
|
||||
url = key + urlencode(data)
|
||||
self._server.query(url, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
|
||||
class SmartFilterMixin:
|
||||
|
@ -125,8 +126,9 @@ class SplitMergeMixin:
|
|||
|
||||
def split(self):
|
||||
""" Split duplicated Plex object into separate objects. """
|
||||
key = '/library/metadata/%s/split' % self.ratingKey
|
||||
return self._server.query(key, method=self._server._session.put)
|
||||
key = f'{self.key}/split'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def merge(self, ratingKeys):
|
||||
""" Merge other Plex objects into the current object.
|
||||
|
@ -137,8 +139,9 @@ class SplitMergeMixin:
|
|||
if not isinstance(ratingKeys, list):
|
||||
ratingKeys = str(ratingKeys).split(',')
|
||||
|
||||
key = '%s/merge?ids=%s' % (self.key, ','.join([str(r) for r in ratingKeys]))
|
||||
return self._server.query(key, method=self._server._session.put)
|
||||
key = f"{self.key}/merge?ids={','.join([str(r) for r in ratingKeys])}"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
|
||||
class UnmatchMatchMixin:
|
||||
|
@ -146,7 +149,7 @@ class UnmatchMatchMixin:
|
|||
|
||||
def unmatch(self):
|
||||
""" Unmatches metadata match from object. """
|
||||
key = '/library/metadata/%s/unmatch' % self.ratingKey
|
||||
key = f'{self.key}/unmatch'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def matches(self, agent=None, title=None, year=None, language=None):
|
||||
|
@ -177,7 +180,7 @@ class UnmatchMatchMixin:
|
|||
|
||||
For 2 to 7, the agent and language is automatically filled in
|
||||
"""
|
||||
key = '/library/metadata/%s/matches' % self.ratingKey
|
||||
key = f'{self.key}/matches'
|
||||
params = {'manual': 1}
|
||||
|
||||
if agent and not any([title, year, language]):
|
||||
|
@ -191,7 +194,7 @@ class UnmatchMatchMixin:
|
|||
params['title'] = title
|
||||
|
||||
if year is None:
|
||||
params['year'] = self.year
|
||||
params['year'] = getattr(self, 'year', '')
|
||||
else:
|
||||
params['year'] = year
|
||||
|
||||
|
@ -216,13 +219,13 @@ class UnmatchMatchMixin:
|
|||
~plexapi.base.matches()
|
||||
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.)
|
||||
"""
|
||||
key = '/library/metadata/%s/match' % self.ratingKey
|
||||
key = f'{self.key}/match'
|
||||
if auto:
|
||||
autoMatch = self.matches(agent=agent)
|
||||
if autoMatch:
|
||||
searchResult = autoMatch[0]
|
||||
else:
|
||||
raise NotFound('No matches found using this agent: (%s:%s)' % (agent, autoMatch))
|
||||
raise NotFound(f'No matches found using this agent: ({agent}:{autoMatch})')
|
||||
elif not searchResult:
|
||||
raise NotFound('fixMatch() requires either auto=True or '
|
||||
'searchResult=:class:`~plexapi.media.SearchResult`.')
|
||||
|
@ -232,6 +235,7 @@ class UnmatchMatchMixin:
|
|||
|
||||
data = key + '?' + urlencode(params)
|
||||
self._server.query(data, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
|
||||
class ExtrasMixin:
|
||||
|
@ -250,8 +254,48 @@ class HubsMixin:
|
|||
def hubs(self):
|
||||
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
|
||||
from plexapi.library import Hub
|
||||
data = self._server.query(self._details_key)
|
||||
return self.findItems(data, Hub, rtag='Related')
|
||||
key = f'{self.key}/related'
|
||||
data = self._server.query(key)
|
||||
return self.findItems(data, Hub)
|
||||
|
||||
|
||||
class PlayedUnplayedMixin:
|
||||
""" Mixin for Plex objects that can be marked played and unplayed. """
|
||||
|
||||
@property
|
||||
def isPlayed(self):
|
||||
""" Returns True if this video is played. """
|
||||
return bool(self.viewCount > 0) if self.viewCount else False
|
||||
|
||||
def markPlayed(self):
|
||||
""" Mark the Plex object as played. """
|
||||
key = '/:/scrobble'
|
||||
params = {'key': self.ratingKey, 'identifier': 'com.plexapp.plugins.library'}
|
||||
self._server.query(key, params=params)
|
||||
return self
|
||||
|
||||
def markUnplayed(self):
|
||||
""" Mark the Plex object as unplayed. """
|
||||
key = '/:/unscrobble'
|
||||
params = {'key': self.ratingKey, 'identifier': 'com.plexapp.plugins.library'}
|
||||
self._server.query(key, params=params)
|
||||
return self
|
||||
|
||||
@property
|
||||
@deprecated('use "isPlayed" instead', stacklevel=3)
|
||||
def isWatched(self):
|
||||
""" Returns True if the show is watched. """
|
||||
return self.isPlayed
|
||||
|
||||
@deprecated('use "markPlayed" instead')
|
||||
def markWatched(self):
|
||||
""" Mark the video as played. """
|
||||
self.markPlayed()
|
||||
|
||||
@deprecated('use "markUnplayed" instead')
|
||||
def markUnwatched(self):
|
||||
""" Mark the video as unplayed. """
|
||||
self.markUnplayed()
|
||||
|
||||
|
||||
class RatingMixin:
|
||||
|
@ -270,8 +314,9 @@ class RatingMixin:
|
|||
rating = -1
|
||||
elif not isinstance(rating, (int, float)) or rating < 0 or rating > 10:
|
||||
raise BadRequest('Rating must be between 0 to 10.')
|
||||
key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rating)
|
||||
key = f'/:/rate?key={self.ratingKey}&identifier=com.plexapp.plugins.library&rating={rating}'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
|
||||
class ArtUrlMixin:
|
||||
|
@ -289,7 +334,7 @@ class ArtMixin(ArtUrlMixin):
|
|||
|
||||
def arts(self):
|
||||
""" Returns list of available :class:`~plexapi.media.Art` objects. """
|
||||
return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=media.Art)
|
||||
return self.fetchItems(f'/library/metadata/{self.ratingKey}/arts', cls=media.Art)
|
||||
|
||||
def uploadArt(self, url=None, filepath=None):
|
||||
""" Upload a background artwork from a url or filepath.
|
||||
|
@ -299,12 +344,13 @@ class ArtMixin(ArtUrlMixin):
|
|||
filepath (str): The full file path the the image to upload.
|
||||
"""
|
||||
if url:
|
||||
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
|
||||
key = f'/library/metadata/{self.ratingKey}/arts?url={quote_plus(url)}'
|
||||
self._server.query(key, method=self._server._session.post)
|
||||
elif filepath:
|
||||
key = '/library/metadata/%s/arts?' % self.ratingKey
|
||||
key = f'/library/metadata/{self.ratingKey}/arts'
|
||||
data = open(filepath, 'rb').read()
|
||||
self._server.query(key, method=self._server._session.post, data=data)
|
||||
return self
|
||||
|
||||
def setArt(self, art):
|
||||
""" Set the background artwork for a Plex object.
|
||||
|
@ -313,6 +359,7 @@ class ArtMixin(ArtUrlMixin):
|
|||
art (:class:`~plexapi.media.Art`): The art object to select.
|
||||
"""
|
||||
art.select()
|
||||
return self
|
||||
|
||||
def lockArt(self):
|
||||
""" Lock the background artwork for a Plex object. """
|
||||
|
@ -338,7 +385,7 @@ class BannerMixin(BannerUrlMixin):
|
|||
|
||||
def banners(self):
|
||||
""" Returns list of available :class:`~plexapi.media.Banner` objects. """
|
||||
return self.fetchItems('/library/metadata/%s/banners' % self.ratingKey, cls=media.Banner)
|
||||
return self.fetchItems(f'/library/metadata/{self.ratingKey}/banners', cls=media.Banner)
|
||||
|
||||
def uploadBanner(self, url=None, filepath=None):
|
||||
""" Upload a banner from a url or filepath.
|
||||
|
@ -348,12 +395,13 @@ class BannerMixin(BannerUrlMixin):
|
|||
filepath (str): The full file path the the image to upload.
|
||||
"""
|
||||
if url:
|
||||
key = '/library/metadata/%s/banners?url=%s' % (self.ratingKey, quote_plus(url))
|
||||
key = f'/library/metadata/{self.ratingKey}/banners?url={quote_plus(url)}'
|
||||
self._server.query(key, method=self._server._session.post)
|
||||
elif filepath:
|
||||
key = '/library/metadata/%s/banners?' % self.ratingKey
|
||||
key = f'/library/metadata/{self.ratingKey}/banners'
|
||||
data = open(filepath, 'rb').read()
|
||||
self._server.query(key, method=self._server._session.post, data=data)
|
||||
return self
|
||||
|
||||
def setBanner(self, banner):
|
||||
""" Set the banner for a Plex object.
|
||||
|
@ -362,6 +410,7 @@ class BannerMixin(BannerUrlMixin):
|
|||
banner (:class:`~plexapi.media.Banner`): The banner object to select.
|
||||
"""
|
||||
banner.select()
|
||||
return self
|
||||
|
||||
def lockBanner(self):
|
||||
""" Lock the banner for a Plex object. """
|
||||
|
@ -392,7 +441,7 @@ class PosterMixin(PosterUrlMixin):
|
|||
|
||||
def posters(self):
|
||||
""" Returns list of available :class:`~plexapi.media.Poster` objects. """
|
||||
return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=media.Poster)
|
||||
return self.fetchItems(f'/library/metadata/{self.ratingKey}/posters', cls=media.Poster)
|
||||
|
||||
def uploadPoster(self, url=None, filepath=None):
|
||||
""" Upload a poster from a url or filepath.
|
||||
|
@ -402,12 +451,13 @@ class PosterMixin(PosterUrlMixin):
|
|||
filepath (str): The full file path the the image to upload.
|
||||
"""
|
||||
if url:
|
||||
key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url))
|
||||
key = f'/library/metadata/{self.ratingKey}/posters?url={quote_plus(url)}'
|
||||
self._server.query(key, method=self._server._session.post)
|
||||
elif filepath:
|
||||
key = '/library/metadata/%s/posters?' % self.ratingKey
|
||||
key = f'/library/metadata/{self.ratingKey}/posters'
|
||||
data = open(filepath, 'rb').read()
|
||||
self._server.query(key, method=self._server._session.post, data=data)
|
||||
return self
|
||||
|
||||
def setPoster(self, poster):
|
||||
""" Set the poster for a Plex object.
|
||||
|
@ -416,6 +466,7 @@ class PosterMixin(PosterUrlMixin):
|
|||
poster (:class:`~plexapi.media.Poster`): The poster object to select.
|
||||
"""
|
||||
poster.select()
|
||||
return self
|
||||
|
||||
def lockPoster(self):
|
||||
""" Lock the poster for a Plex object. """
|
||||
|
@ -441,7 +492,7 @@ class ThemeMixin(ThemeUrlMixin):
|
|||
|
||||
def themes(self):
|
||||
""" Returns list of available :class:`~plexapi.media.Theme` objects. """
|
||||
return self.fetchItems('/library/metadata/%s/themes' % self.ratingKey, cls=media.Theme)
|
||||
return self.fetchItems(f'/library/metadata/{self.ratingKey}/themes', cls=media.Theme)
|
||||
|
||||
def uploadTheme(self, url=None, filepath=None):
|
||||
""" Upload a theme from url or filepath.
|
||||
|
@ -453,12 +504,13 @@ class ThemeMixin(ThemeUrlMixin):
|
|||
filepath (str): The full file path to the theme to upload.
|
||||
"""
|
||||
if url:
|
||||
key = '/library/metadata/%s/themes?url=%s' % (self.ratingKey, quote_plus(url))
|
||||
key = f'/library/metadata/{self.ratingKey}/themes?url={quote_plus(url)}'
|
||||
self._server.query(key, method=self._server._session.post)
|
||||
elif filepath:
|
||||
key = '/library/metadata/%s/themes?' % self.ratingKey
|
||||
key = f'/library/metadata/{self.ratingKey}/themes'
|
||||
data = open(filepath, 'rb').read()
|
||||
self._server.query(key, method=self._server._session.post, data=data)
|
||||
return self
|
||||
|
||||
def setTheme(self, theme):
|
||||
raise NotImplementedError(
|
||||
|
@ -468,11 +520,11 @@ class ThemeMixin(ThemeUrlMixin):
|
|||
|
||||
def lockTheme(self):
|
||||
""" Lock the theme for a Plex object. """
|
||||
self._edit(**{'theme.locked': 1})
|
||||
return self._edit(**{'theme.locked': 1})
|
||||
|
||||
def unlockTheme(self):
|
||||
""" Unlock the theme for a Plex object. """
|
||||
self._edit(**{'theme.locked': 0})
|
||||
return self._edit(**{'theme.locked': 0})
|
||||
|
||||
|
||||
class EditFieldMixin:
|
||||
|
@ -496,8 +548,8 @@ class EditFieldMixin:
|
|||
|
||||
"""
|
||||
edits = {
|
||||
'%s.value' % field: value or '',
|
||||
'%s.locked' % field: 1 if locked else 0
|
||||
f'{field}.value': value or '',
|
||||
f'{field}.locked': 1 if locked else 0
|
||||
}
|
||||
edits.update(kwargs)
|
||||
return self._edit(**edits)
|
||||
|
@ -516,6 +568,19 @@ class ContentRatingMixin(EditFieldMixin):
|
|||
return self.editField('contentRating', contentRating, locked=locked)
|
||||
|
||||
|
||||
class EditionTitleMixin(EditFieldMixin):
|
||||
""" Mixin for Plex objects that can have an edition title. """
|
||||
|
||||
def editEditionTitle(self, editionTitle, locked=True):
|
||||
""" Edit the edition title. Plex Pass is required to edit this field.
|
||||
|
||||
Parameters:
|
||||
editionTitle (str): The new value.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editField('editionTitle', editionTitle, locked=locked)
|
||||
|
||||
|
||||
class OriginallyAvailableMixin(EditFieldMixin):
|
||||
""" Mixin for Plex objects that can have an originally available date. """
|
||||
|
||||
|
@ -680,7 +745,7 @@ class EditTagsMixin:
|
|||
|
||||
Parameters:
|
||||
tag (str): Name of the tag to edit.
|
||||
items (List<str>): List of tags to add or remove.
|
||||
items (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags to add or remove.
|
||||
locked (bool): True (default) to lock the tags, False to unlock the tags.
|
||||
remove (bool): True to remove the tags in items.
|
||||
|
||||
|
@ -695,9 +760,11 @@ class EditTagsMixin:
|
|||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
|
||||
value = getattr(self, self._tagPlural(tag))
|
||||
existing_tags = [t.tag for t in value if t and remove is False]
|
||||
edits = self._tagHelper(self._tagSingular(tag), existing_tags + items, locked, remove)
|
||||
if not remove:
|
||||
tags = getattr(self, self._tagPlural(tag))
|
||||
items = tags + items
|
||||
|
||||
edits = self._tagHelper(self._tagSingular(tag), items, locked, remove)
|
||||
edits.update(kwargs)
|
||||
return self._edit(**edits)
|
||||
|
||||
|
@ -730,15 +797,15 @@ class EditTagsMixin:
|
|||
items = [items]
|
||||
|
||||
data = {
|
||||
'%s.locked' % tag: 1 if locked else 0
|
||||
f'{tag}.locked': 1 if locked else 0
|
||||
}
|
||||
|
||||
if remove:
|
||||
tagname = '%s[].tag.tag-' % tag
|
||||
data[tagname] = ','.join(items)
|
||||
tagname = f'{tag}[].tag.tag-'
|
||||
data[tagname] = ','.join([str(t) for t in items])
|
||||
else:
|
||||
for i, item in enumerate(items):
|
||||
tagname = '%s[%s].tag.tag' % (tag, i)
|
||||
tagname = f'{str(tag)}[{i}].tag.tag'
|
||||
data[tagname] = item
|
||||
|
||||
return data
|
||||
|
@ -751,7 +818,7 @@ class CollectionMixin(EditTagsMixin):
|
|||
""" Add a collection tag(s).
|
||||
|
||||
Parameters:
|
||||
collections (list): List of strings.
|
||||
collections (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('collection', collections, locked=locked)
|
||||
|
@ -760,7 +827,7 @@ class CollectionMixin(EditTagsMixin):
|
|||
""" Remove a collection tag(s).
|
||||
|
||||
Parameters:
|
||||
collections (list): List of strings.
|
||||
collections (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('collection', collections, locked=locked, remove=True)
|
||||
|
@ -773,7 +840,7 @@ class CountryMixin(EditTagsMixin):
|
|||
""" Add a country tag(s).
|
||||
|
||||
Parameters:
|
||||
countries (list): List of strings.
|
||||
countries (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('country', countries, locked=locked)
|
||||
|
@ -782,7 +849,7 @@ class CountryMixin(EditTagsMixin):
|
|||
""" Remove a country tag(s).
|
||||
|
||||
Parameters:
|
||||
countries (list): List of strings.
|
||||
countries (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('country', countries, locked=locked, remove=True)
|
||||
|
@ -795,7 +862,7 @@ class DirectorMixin(EditTagsMixin):
|
|||
""" Add a director tag(s).
|
||||
|
||||
Parameters:
|
||||
directors (list): List of strings.
|
||||
directors (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('director', directors, locked=locked)
|
||||
|
@ -804,7 +871,7 @@ class DirectorMixin(EditTagsMixin):
|
|||
""" Remove a director tag(s).
|
||||
|
||||
Parameters:
|
||||
directors (list): List of strings.
|
||||
directors (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('director', directors, locked=locked, remove=True)
|
||||
|
@ -817,7 +884,7 @@ class GenreMixin(EditTagsMixin):
|
|||
""" Add a genre tag(s).
|
||||
|
||||
Parameters:
|
||||
genres (list): List of strings.
|
||||
genres (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('genre', genres, locked=locked)
|
||||
|
@ -826,7 +893,7 @@ class GenreMixin(EditTagsMixin):
|
|||
""" Remove a genre tag(s).
|
||||
|
||||
Parameters:
|
||||
genres (list): List of strings.
|
||||
genres (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('genre', genres, locked=locked, remove=True)
|
||||
|
@ -839,7 +906,7 @@ class LabelMixin(EditTagsMixin):
|
|||
""" Add a label tag(s).
|
||||
|
||||
Parameters:
|
||||
labels (list): List of strings.
|
||||
labels (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('label', labels, locked=locked)
|
||||
|
@ -848,7 +915,7 @@ class LabelMixin(EditTagsMixin):
|
|||
""" Remove a label tag(s).
|
||||
|
||||
Parameters:
|
||||
labels (list): List of strings.
|
||||
labels (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('label', labels, locked=locked, remove=True)
|
||||
|
@ -861,7 +928,7 @@ class MoodMixin(EditTagsMixin):
|
|||
""" Add a mood tag(s).
|
||||
|
||||
Parameters:
|
||||
moods (list): List of strings.
|
||||
moods (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('mood', moods, locked=locked)
|
||||
|
@ -870,7 +937,7 @@ class MoodMixin(EditTagsMixin):
|
|||
""" Remove a mood tag(s).
|
||||
|
||||
Parameters:
|
||||
moods (list): List of strings.
|
||||
moods (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('mood', moods, locked=locked, remove=True)
|
||||
|
@ -883,7 +950,7 @@ class ProducerMixin(EditTagsMixin):
|
|||
""" Add a producer tag(s).
|
||||
|
||||
Parameters:
|
||||
producers (list): List of strings.
|
||||
producers (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('producer', producers, locked=locked)
|
||||
|
@ -892,7 +959,7 @@ class ProducerMixin(EditTagsMixin):
|
|||
""" Remove a producer tag(s).
|
||||
|
||||
Parameters:
|
||||
producers (list): List of strings.
|
||||
producers (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('producer', producers, locked=locked, remove=True)
|
||||
|
@ -905,7 +972,7 @@ class SimilarArtistMixin(EditTagsMixin):
|
|||
""" Add a similar artist tag(s).
|
||||
|
||||
Parameters:
|
||||
artists (list): List of strings.
|
||||
artists (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('similar', artists, locked=locked)
|
||||
|
@ -914,7 +981,7 @@ class SimilarArtistMixin(EditTagsMixin):
|
|||
""" Remove a similar artist tag(s).
|
||||
|
||||
Parameters:
|
||||
artists (list): List of strings.
|
||||
artists (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('similar', artists, locked=locked, remove=True)
|
||||
|
@ -927,7 +994,7 @@ class StyleMixin(EditTagsMixin):
|
|||
""" Add a style tag(s).
|
||||
|
||||
Parameters:
|
||||
styles (list): List of strings.
|
||||
styles (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('style', styles, locked=locked)
|
||||
|
@ -936,7 +1003,7 @@ class StyleMixin(EditTagsMixin):
|
|||
""" Remove a style tag(s).
|
||||
|
||||
Parameters:
|
||||
styles (list): List of strings.
|
||||
styles (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('style', styles, locked=locked, remove=True)
|
||||
|
@ -949,7 +1016,7 @@ class TagMixin(EditTagsMixin):
|
|||
""" Add a tag(s).
|
||||
|
||||
Parameters:
|
||||
tags (list): List of strings.
|
||||
tags (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('tag', tags, locked=locked)
|
||||
|
@ -958,7 +1025,7 @@ class TagMixin(EditTagsMixin):
|
|||
""" Remove a tag(s).
|
||||
|
||||
Parameters:
|
||||
tags (list): List of strings.
|
||||
tags (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('tag', tags, locked=locked, remove=True)
|
||||
|
@ -971,7 +1038,7 @@ class WriterMixin(EditTagsMixin):
|
|||
""" Add a writer tag(s).
|
||||
|
||||
Parameters:
|
||||
writers (list): List of strings.
|
||||
writers (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('writer', writers, locked=locked)
|
||||
|
@ -980,7 +1047,7 @@ class WriterMixin(EditTagsMixin):
|
|||
""" Remove a writer tag(s).
|
||||
|
||||
Parameters:
|
||||
writers (list): List of strings.
|
||||
writers (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editTags('writer', writers, locked=locked, remove=True)
|
||||
|
@ -1016,6 +1083,7 @@ class WatchlistMixin:
|
|||
except AttributeError:
|
||||
account = self._server
|
||||
account.addToWatchlist(self)
|
||||
return self
|
||||
|
||||
def removeFromWatchlist(self, account=None):
|
||||
""" Remove this item from the specified user's watchlist.
|
||||
|
@ -1030,6 +1098,7 @@ class WatchlistMixin:
|
|||
except AttributeError:
|
||||
account = self._server
|
||||
account.removeFromWatchlist(self)
|
||||
return self
|
||||
|
||||
def streamingServices(self, account=None):
|
||||
""" Return a list of :class:`~plexapi.media.Availability`
|
||||
|
|
|
@ -3,11 +3,12 @@ import copy
|
|||
import html
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT,
|
||||
X_PLEX_IDENTIFIER, log, logfilter, utils)
|
||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE,
|
||||
X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, log, logfilter, utils)
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||
|
@ -47,6 +48,7 @@ class MyPlexAccount(PlexObject):
|
|||
locale (str): Your Plex locale
|
||||
mailing_list_status (str): Your current mailing list status.
|
||||
maxHomeSize (int): Unknown.
|
||||
pin (str): The hashed Plex Home PIN.
|
||||
queueEmail (str): Email address to add items to your `Watch Later` queue.
|
||||
queueUid (str): Unknown.
|
||||
restricted (bool): Unknown.
|
||||
|
@ -65,16 +67,19 @@ class MyPlexAccount(PlexObject):
|
|||
_session (obj): Requests session object used to access this client.
|
||||
"""
|
||||
FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data
|
||||
HOMEUSERS = 'https://plex.tv/api/home/users'
|
||||
HOMEUSERCREATE = 'https://plex.tv/api/home/users?title={title}' # post with data
|
||||
EXISTINGUSER = 'https://plex.tv/api/home/users?invitedEmail={username}' # post with data
|
||||
FRIENDSERVERS = 'https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}' # put with data
|
||||
PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get
|
||||
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete
|
||||
REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete
|
||||
HOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete, put
|
||||
MANAGEDHOMEUSER = 'https://plex.tv/api/v2/home/users/restricted/{userId}' # put
|
||||
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
|
||||
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
||||
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
|
||||
# Hub sections
|
||||
VOD = 'https://vod.provider.plex.tv' # get
|
||||
MUSIC = 'https://music.provider.plex.tv' # get
|
||||
|
@ -115,6 +120,7 @@ class MyPlexAccount(PlexObject):
|
|||
self.locale = data.attrib.get('locale')
|
||||
self.mailing_list_status = data.attrib.get('mailing_list_status')
|
||||
self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
|
||||
self.pin = data.attrib.get('pin')
|
||||
self.queueEmail = data.attrib.get('queueEmail')
|
||||
self.queueUid = data.attrib.get('queueUid')
|
||||
self.restricted = utils.cast(bool, data.attrib.get('restricted'))
|
||||
|
@ -150,7 +156,7 @@ class MyPlexAccount(PlexObject):
|
|||
for device in self.devices():
|
||||
if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId):
|
||||
return device
|
||||
raise NotFound('Unable to find device %s' % name)
|
||||
raise NotFound(f'Unable to find device {name}')
|
||||
|
||||
def devices(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """
|
||||
|
@ -174,7 +180,7 @@ class MyPlexAccount(PlexObject):
|
|||
if response.status_code not in (200, 201, 204): # pragma: no cover
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
||||
message = f'({response.status_code}) {codename}; {response.url} {errtext}'
|
||||
if response.status_code == 401:
|
||||
raise Unauthorized(message)
|
||||
elif response.status_code == 404:
|
||||
|
@ -195,7 +201,7 @@ class MyPlexAccount(PlexObject):
|
|||
for resource in self.resources():
|
||||
if resource.name.lower() == name.lower():
|
||||
return resource
|
||||
raise NotFound('Unable to find resource %s' % name)
|
||||
raise NotFound(f'Unable to find resource {name}')
|
||||
|
||||
def resources(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """
|
||||
|
@ -366,7 +372,8 @@ class MyPlexAccount(PlexObject):
|
|||
""" Remove the specified user from your friends.
|
||||
|
||||
Parameters:
|
||||
user (str): :class:`~plexapi.myplex.MyPlexUser`, username, or email of the user to be removed.
|
||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`,
|
||||
username, or email of the user to be removed.
|
||||
"""
|
||||
user = user if isinstance(user, MyPlexUser) else self.user(user)
|
||||
url = self.FRIENDUPDATE.format(userId=user.id)
|
||||
|
@ -376,17 +383,89 @@ class MyPlexAccount(PlexObject):
|
|||
""" Remove the specified user from your home users.
|
||||
|
||||
Parameters:
|
||||
user (str): :class:`~plexapi.myplex.MyPlexUser`, username, or email of the user to be removed.
|
||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`,
|
||||
username, or email of the user to be removed.
|
||||
"""
|
||||
user = user if isinstance(user, MyPlexUser) else self.user(user)
|
||||
url = self.REMOVEHOMEUSER.format(userId=user.id)
|
||||
url = self.HOMEUSER.format(userId=user.id)
|
||||
return self.query(url, self._session.delete)
|
||||
|
||||
def acceptInvite(self, user):
|
||||
""" Accept a pending firend invite from the specified user.
|
||||
def switchHomeUser(self, user):
|
||||
""" Returns a new :class:`~plexapi.myplex.MyPlexAccount` object switched to the given home user.
|
||||
|
||||
Parameters:
|
||||
user (str): :class:`~plexapi.myplex.MyPlexInvite`, username, or email of the friend invite to accept.
|
||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`,
|
||||
username, or email of the home user to switch to.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
# Login to a Plex Home account
|
||||
account = MyPlexAccount('<USERNAME>', '<PASSWORD>')
|
||||
# Switch to a different Plex Home user
|
||||
userAccount = account.switchHomeUser('Username')
|
||||
|
||||
"""
|
||||
user = user if isinstance(user, MyPlexUser) else self.user(user)
|
||||
url = f'{self.HOMEUSERS}/{user.id}/switch'
|
||||
data = self.query(url, self._session.post)
|
||||
userToken = data.attrib.get('authenticationToken')
|
||||
return MyPlexAccount(token=userToken)
|
||||
|
||||
def setPin(self, newPin, currentPin=None):
|
||||
""" Set a new Plex Home PIN for the account.
|
||||
|
||||
Parameters:
|
||||
newPin (str): New PIN to set for the account.
|
||||
currentPin (str): Current PIN for the account (required to change the PIN).
|
||||
"""
|
||||
url = self.HOMEUSER.format(userId=self.id)
|
||||
params = {'pin': newPin}
|
||||
if currentPin:
|
||||
params['currentPin'] = currentPin
|
||||
return self.query(url, self._session.put, params=params)
|
||||
|
||||
def removePin(self, currentPin):
|
||||
""" Remove the Plex Home PIN for the account.
|
||||
|
||||
Parameters:
|
||||
currentPin (str): Current PIN for the account (required to remove the PIN).
|
||||
"""
|
||||
return self.setPin('', currentPin)
|
||||
|
||||
def setManagedUserPin(self, user, newPin):
|
||||
""" Set a new Plex Home PIN for a managed home user. This must be done from the Plex Home admin account.
|
||||
|
||||
Parameters:
|
||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`
|
||||
or username of the managed home user.
|
||||
newPin (str): New PIN to set for the managed home user.
|
||||
"""
|
||||
user = user if isinstance(user, MyPlexUser) else self.user(user)
|
||||
url = self.MANAGEDHOMEUSER.format(userId=user.id)
|
||||
params = {'pin': newPin}
|
||||
return self.query(url, self._session.post, params=params)
|
||||
|
||||
def removeManagedUserPin(self, user):
|
||||
""" Remove the Plex Home PIN for a managed home user. This must be done from the Plex Home admin account.
|
||||
|
||||
Parameters:
|
||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`
|
||||
or username of the managed home user.
|
||||
"""
|
||||
user = user if isinstance(user, MyPlexUser) else self.user(user)
|
||||
url = self.MANAGEDHOMEUSER.format(userId=user.id)
|
||||
params = {'removePin': 1}
|
||||
return self.query(url, self._session.post, params=params)
|
||||
|
||||
def acceptInvite(self, user):
|
||||
""" Accept a pending friend invite from the specified user.
|
||||
|
||||
Parameters:
|
||||
user (:class:`~plexapi.myplex.MyPlexInvite` or str): :class:`~plexapi.myplex.MyPlexInvite`,
|
||||
username, or email of the friend invite to accept.
|
||||
"""
|
||||
invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeSent=False)
|
||||
params = {
|
||||
|
@ -394,14 +473,15 @@ class MyPlexAccount(PlexObject):
|
|||
'home': int(invite.home),
|
||||
'server': int(invite.server)
|
||||
}
|
||||
url = MyPlexInvite.REQUESTS + '/%s' % invite.id + utils.joinArgs(params)
|
||||
url = MyPlexInvite.REQUESTS + f'/{invite.id}' + utils.joinArgs(params)
|
||||
return self.query(url, self._session.put)
|
||||
|
||||
def cancelInvite(self, user):
|
||||
""" Cancel a pending firend invite for the specified user.
|
||||
|
||||
Parameters:
|
||||
user (str): :class:`~plexapi.myplex.MyPlexInvite`, username, or email of the friend invite to cancel.
|
||||
user (:class:`~plexapi.myplex.MyPlexInvite` or str): :class:`~plexapi.myplex.MyPlexInvite`,
|
||||
username, or email of the friend invite to cancel.
|
||||
"""
|
||||
invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeReceived=False)
|
||||
params = {
|
||||
|
@ -409,7 +489,7 @@ class MyPlexAccount(PlexObject):
|
|||
'home': int(invite.home),
|
||||
'server': int(invite.server)
|
||||
}
|
||||
url = MyPlexInvite.REQUESTED + '/%s' % invite.id + utils.joinArgs(params)
|
||||
url = MyPlexInvite.REQUESTED + f'/{invite.id}' + utils.joinArgs(params)
|
||||
return self.query(url, self._session.delete)
|
||||
|
||||
def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None,
|
||||
|
@ -497,7 +577,7 @@ class MyPlexAccount(PlexObject):
|
|||
(user.username.lower(), user.email.lower(), str(user.id))):
|
||||
return user
|
||||
|
||||
raise NotFound('Unable to find user %s' % username)
|
||||
raise NotFound(f'Unable to find user {username}')
|
||||
|
||||
def users(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account.
|
||||
|
@ -520,7 +600,7 @@ class MyPlexAccount(PlexObject):
|
|||
(invite.username.lower(), invite.email.lower(), str(invite.id))):
|
||||
return invite
|
||||
|
||||
raise NotFound('Unable to find invite %s' % username)
|
||||
raise NotFound(f'Unable to find invite {username}')
|
||||
|
||||
def pendingInvites(self, includeSent=True, includeReceived=True):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexInvite` objects connected to your account.
|
||||
|
@ -545,7 +625,7 @@ class MyPlexAccount(PlexObject):
|
|||
# Get a list of all section ids for looking up each section.
|
||||
allSectionIds = {}
|
||||
machineIdentifier = server.machineIdentifier if isinstance(server, PlexServer) else server
|
||||
url = self.PLEXSERVERS.replace('{machineId}', machineIdentifier)
|
||||
url = self.PLEXSERVERS.format(machineId=machineIdentifier)
|
||||
data = self.query(url, self._session.get)
|
||||
for elem in data[0]:
|
||||
_id = utils.cast(int, elem.attrib.get('id'))
|
||||
|
@ -567,8 +647,8 @@ class MyPlexAccount(PlexObject):
|
|||
values = []
|
||||
for key, vals in filterDict.items():
|
||||
if key not in ('contentRating', 'label', 'contentRating!', 'label!'):
|
||||
raise BadRequest('Unknown filter key: %s', key)
|
||||
values.append('%s=%s' % (key, '%2C'.join(vals)))
|
||||
raise BadRequest(f'Unknown filter key: {key}')
|
||||
values.append(f"{key}={'%2C'.join(vals)}")
|
||||
return '|'.join(values)
|
||||
|
||||
def addWebhook(self, url):
|
||||
|
@ -579,12 +659,12 @@ class MyPlexAccount(PlexObject):
|
|||
def deleteWebhook(self, url):
|
||||
urls = copy.copy(self._webhooks)
|
||||
if url not in urls:
|
||||
raise BadRequest('Webhook does not exist: %s' % url)
|
||||
raise BadRequest(f'Webhook does not exist: {url}')
|
||||
urls.remove(url)
|
||||
return self.setWebhooks(urls)
|
||||
|
||||
def setWebhooks(self, urls):
|
||||
log.info('Setting webhooks: %s' % urls)
|
||||
log.info('Setting webhooks: %s', urls)
|
||||
data = {'urls[]': urls} if len(urls) else {'urls': ''}
|
||||
data = self.query(self.WEBHOOKS, self._session.post, data=data)
|
||||
self._webhooks = self.listAttrs(data, 'url', etag='webhook')
|
||||
|
@ -655,7 +735,7 @@ class MyPlexAccount(PlexObject):
|
|||
break
|
||||
|
||||
if not client:
|
||||
raise BadRequest('Unable to find client by clientId=%s', clientId)
|
||||
raise BadRequest(f'Unable to find client by clientId={clientId}')
|
||||
|
||||
if 'sync-target' not in client.provides:
|
||||
raise BadRequest("Received client doesn't provides sync-target")
|
||||
|
@ -694,7 +774,7 @@ class MyPlexAccount(PlexObject):
|
|||
if response.status_code not in (200, 201, 204): # pragma: no cover
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
||||
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
|
||||
return response.json()['token']
|
||||
|
||||
def history(self, maxresults=9999999, mindate=None):
|
||||
|
@ -730,7 +810,7 @@ class MyPlexAccount(PlexObject):
|
|||
data = self.query(f'{self.MUSIC}/hubs')
|
||||
return self.findItems(data)
|
||||
|
||||
def watchlist(self, filter=None, sort=None, libtype=None, **kwargs):
|
||||
def watchlist(self, filter=None, sort=None, libtype=None, maxresults=9999999, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` items in the user's watchlist.
|
||||
Note: The objects returned are from Plex's online metadata. To get the matching item on a Plex server,
|
||||
search for the media using the guid.
|
||||
|
@ -742,6 +822,7 @@ class MyPlexAccount(PlexObject):
|
|||
``titleSort`` (Title), ``originallyAvailableAt`` (Release Date), or ``rating`` (Critic Rating).
|
||||
``dir`` can be ``asc`` or ``desc``.
|
||||
libtype (str, optional): 'movie' or 'show' to only return movies or shows, otherwise return all items.
|
||||
maxresults (int, optional): Only return the specified number of results.
|
||||
**kwargs (dict): Additional custom filters to apply to the search results.
|
||||
|
||||
|
||||
|
@ -769,9 +850,18 @@ class MyPlexAccount(PlexObject):
|
|||
if libtype:
|
||||
params['type'] = utils.searchType(libtype)
|
||||
|
||||
params['X-Plex-Container-Start'] = 0
|
||||
params['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
||||
params.update(kwargs)
|
||||
|
||||
results, subresults = [], '_init'
|
||||
while subresults and maxresults > len(results):
|
||||
data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params)
|
||||
return self.findItems(data)
|
||||
subresults = self.findItems(data)
|
||||
results += subresults[:maxresults - len(results)]
|
||||
params['X-Plex-Container-Start'] += params['X-Plex-Container-Size']
|
||||
|
||||
return self._toOnlineMetadata(results)
|
||||
|
||||
def onWatchlist(self, item):
|
||||
""" Returns True if the item is on the user's watchlist.
|
||||
|
@ -780,9 +870,7 @@ class MyPlexAccount(PlexObject):
|
|||
item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to check
|
||||
if it is on the user's watchlist.
|
||||
"""
|
||||
ratingKey = item.guid.rsplit('/', 1)[-1]
|
||||
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
|
||||
return bool(data.find('UserState').attrib.get('watchlistedAt'))
|
||||
return bool(self.userState(item).watchlistedAt)
|
||||
|
||||
def addToWatchlist(self, items):
|
||||
""" Add media items to the user's watchlist
|
||||
|
@ -800,9 +888,10 @@ class MyPlexAccount(PlexObject):
|
|||
|
||||
for item in items:
|
||||
if self.onWatchlist(item):
|
||||
raise BadRequest('"%s" is already on the watchlist' % item.title)
|
||||
raise BadRequest(f'"{item.title}" is already on the watchlist')
|
||||
ratingKey = item.guid.rsplit('/', 1)[-1]
|
||||
self.query(f'{self.METADATA}/actions/addToWatchlist?ratingKey={ratingKey}', method=self._session.put)
|
||||
return self
|
||||
|
||||
def removeFromWatchlist(self, items):
|
||||
""" Remove media items from the user's watchlist
|
||||
|
@ -820,33 +909,49 @@ class MyPlexAccount(PlexObject):
|
|||
|
||||
for item in items:
|
||||
if not self.onWatchlist(item):
|
||||
raise BadRequest('"%s" is not on the watchlist' % item.title)
|
||||
raise BadRequest(f'"{item.title}" is not on the watchlist')
|
||||
ratingKey = item.guid.rsplit('/', 1)[-1]
|
||||
self.query(f'{self.METADATA}/actions/removeFromWatchlist?ratingKey={ratingKey}', method=self._session.put)
|
||||
return self
|
||||
|
||||
def searchDiscover(self, query, limit=30):
|
||||
def userState(self, item):
|
||||
""" Returns a :class:`~plexapi.myplex.UserState` object for the specified item.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to return the user state.
|
||||
"""
|
||||
ratingKey = item.guid.rsplit('/', 1)[-1]
|
||||
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
|
||||
return self.findItem(data, cls=UserState)
|
||||
|
||||
def searchDiscover(self, query, limit=30, libtype=None):
|
||||
""" Search for movies and TV shows in Discover.
|
||||
Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects.
|
||||
|
||||
Parameters:
|
||||
query (str): Search query.
|
||||
limit (int, optional): Limit to the specified number of results. Default 30.
|
||||
libtype (str, optional): 'movie' or 'show' to only return movies or shows, otherwise return all items.
|
||||
"""
|
||||
libtypes = {'movie': 'movies', 'show': 'tv'}
|
||||
libtype = libtypes.get(libtype, 'movies,tv')
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
params = {
|
||||
'query': query,
|
||||
'limit ': limit,
|
||||
'searchTypes': 'movies,tv',
|
||||
'searchTypes': libtype,
|
||||
'includeMetadata': 1
|
||||
}
|
||||
|
||||
data = self.query(f'{self.METADATA}/library/search', headers=headers, params=params)
|
||||
searchResults = data['MediaContainer'].get('SearchResult', [])
|
||||
searchResults = data['MediaContainer'].get('SearchResults', [])
|
||||
searchResult = next((s.get('SearchResult', []) for s in searchResults if s.get('id') == 'external'), [])
|
||||
|
||||
results = []
|
||||
for result in searchResults:
|
||||
for result in searchResult:
|
||||
metadata = result['Metadata']
|
||||
type = metadata['type']
|
||||
if type == 'movie':
|
||||
|
@ -859,7 +964,33 @@ class MyPlexAccount(PlexObject):
|
|||
xml = f'<{tag} {attrs}/>'
|
||||
results.append(self._manuallyLoadXML(xml))
|
||||
|
||||
return results
|
||||
return self._toOnlineMetadata(results)
|
||||
|
||||
@property
|
||||
def viewStateSync(self):
|
||||
""" Returns True or False if syncing of watch state and ratings
|
||||
is enabled or disabled, respectively, for the account.
|
||||
"""
|
||||
headers = {'Accept': 'application/json'}
|
||||
data = self.query(self.VIEWSTATESYNC, headers=headers)
|
||||
return data.get('consent')
|
||||
|
||||
def enableViewStateSync(self):
|
||||
""" Enable syncing of watch state and ratings for the account. """
|
||||
self._updateViewStateSync(True)
|
||||
|
||||
def disableViewStateSync(self):
|
||||
""" Disable syncing of watch state and ratings for the account. """
|
||||
self._updateViewStateSync(False)
|
||||
|
||||
def _updateViewStateSync(self, consent):
|
||||
""" Enable or disable syncing of watch state and ratings for the account.
|
||||
|
||||
Parameters:
|
||||
consent (bool): True to enable, False to disable.
|
||||
"""
|
||||
params = {'consent': consent}
|
||||
self.query(self.VIEWSTATESYNC, method=self._session.put, params=params)
|
||||
|
||||
def link(self, pin):
|
||||
""" Link a device to the account using a pin code.
|
||||
|
@ -874,6 +1005,26 @@ class MyPlexAccount(PlexObject):
|
|||
data = {'code': pin}
|
||||
self.query(self.LINK, self._session.put, headers=headers, data=data)
|
||||
|
||||
def _toOnlineMetadata(self, objs):
|
||||
""" Convert a list of media objects to online metadata objects. """
|
||||
# TODO: Add proper support for metadata.provider.plex.tv
|
||||
# Temporary workaround to allow reloading and browsing of online media objects
|
||||
server = PlexServer(self.METADATA, self._token)
|
||||
|
||||
if not isinstance(objs, list):
|
||||
objs = [objs]
|
||||
for obj in objs:
|
||||
obj._server = server
|
||||
|
||||
# Parse details key to modify query string
|
||||
url = urlsplit(obj._details_key)
|
||||
query = dict(parse_qsl(url.query))
|
||||
query['includeUserState'] = 1
|
||||
query.pop('includeFields', None)
|
||||
obj._details_key = urlunsplit((url.scheme, url.netloc, url.path, urlencode(query), url.fragment))
|
||||
|
||||
return objs
|
||||
|
||||
|
||||
class MyPlexUser(PlexObject):
|
||||
""" This object represents non-signed in users such as friends and linked
|
||||
|
@ -937,7 +1088,7 @@ class MyPlexUser(PlexObject):
|
|||
if utils.cast(int, item.attrib.get('userID')) == self.id:
|
||||
return item.attrib.get('accessToken')
|
||||
except Exception:
|
||||
log.exception('Failed to get access token for %s' % self.title)
|
||||
log.exception('Failed to get access token for %s', self.title)
|
||||
|
||||
def server(self, name):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexServerShare` that matches the name specified.
|
||||
|
@ -949,7 +1100,7 @@ class MyPlexUser(PlexObject):
|
|||
if name.lower() == server.name.lower():
|
||||
return server
|
||||
|
||||
raise NotFound('Unable to find server %s' % name)
|
||||
raise NotFound(f'Unable to find server {name}')
|
||||
|
||||
def history(self, maxresults=9999999, mindate=None):
|
||||
""" Get all Play History for a user in all shared servers.
|
||||
|
@ -1077,7 +1228,7 @@ class MyPlexServerShare(PlexObject):
|
|||
if name.lower() == section.title.lower():
|
||||
return section
|
||||
|
||||
raise NotFound('Unable to find section %s' % name)
|
||||
raise NotFound(f'Unable to find section {name}')
|
||||
|
||||
def sections(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.Section` objects shared with this user.
|
||||
|
@ -1158,7 +1309,6 @@ class MyPlexResource(PlexObject):
|
|||
def preferred_connections(
|
||||
self,
|
||||
ssl=None,
|
||||
timeout=None,
|
||||
locations=DEFAULT_LOCATION_ORDER,
|
||||
schemes=DEFAULT_SCHEME_ORDER,
|
||||
):
|
||||
|
@ -1170,7 +1320,6 @@ class MyPlexResource(PlexObject):
|
|||
ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to
|
||||
only connect to HTTP connections. Set None (default) to connect to any
|
||||
HTTP or HTTPS connection.
|
||||
timeout (int, optional): The timeout in seconds to attempt each connection.
|
||||
"""
|
||||
connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations}
|
||||
for connection in self.connections:
|
||||
|
@ -1212,7 +1361,7 @@ class MyPlexResource(PlexObject):
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||
"""
|
||||
connections = self.preferred_connections(ssl, timeout, locations, schemes)
|
||||
connections = self.preferred_connections(ssl, locations, schemes)
|
||||
# Try connecting to all known resource connections in parallel, but
|
||||
# only return the first server (in order) that provides a response.
|
||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||
|
@ -1244,7 +1393,7 @@ class ResourceConnection(PlexObject):
|
|||
self.port = utils.cast(int, data.attrib.get('port'))
|
||||
self.uri = data.attrib.get('uri')
|
||||
self.local = utils.cast(bool, data.attrib.get('local'))
|
||||
self.httpuri = 'http://%s:%s' % (self.address, self.port)
|
||||
self.httpuri = f'http://{self.address}:{self.port}'
|
||||
self.relay = utils.cast(bool, data.attrib.get('relay'))
|
||||
|
||||
|
||||
|
@ -1318,7 +1467,7 @@ class MyPlexDevice(PlexObject):
|
|||
|
||||
def delete(self):
|
||||
""" Remove this device from your account. """
|
||||
key = 'https://plex.tv/devices/%s.xml' % self.id
|
||||
key = f'https://plex.tv/devices/{self.id}.xml'
|
||||
self._server.query(key, self._server._session.delete)
|
||||
|
||||
def syncItems(self):
|
||||
|
@ -1355,11 +1504,12 @@ class MyPlexPinLogin:
|
|||
session (requests.Session, optional): Use your own session object if you want to
|
||||
cache the http responses from PMS
|
||||
requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT).
|
||||
headers (dict): A dict of X-Plex headers to send with requests.
|
||||
oauth (bool): True to use Plex OAuth instead of PIN login.
|
||||
|
||||
Attributes:
|
||||
PINS (str): 'https://plex.tv/api/v2/pins'
|
||||
CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}'
|
||||
LINK (str): 'https://plex.tv/api/v2/pins/link'
|
||||
POLLINTERVAL (int): 1
|
||||
finished (bool): Whether the pin login has finished or not.
|
||||
expired (bool): Whether the pin login has expired or not.
|
||||
|
@ -1370,12 +1520,13 @@ class MyPlexPinLogin:
|
|||
CHECKPINS = 'https://plex.tv/api/v2/pins/{pinid}' # get
|
||||
POLLINTERVAL = 1
|
||||
|
||||
def __init__(self, session=None, requestTimeout=None, headers=None):
|
||||
def __init__(self, session=None, requestTimeout=None, headers=None, oauth=False):
|
||||
super(MyPlexPinLogin, self).__init__()
|
||||
self._session = session or requests.Session()
|
||||
self._requestTimeout = requestTimeout or TIMEOUT
|
||||
self.headers = headers
|
||||
|
||||
self._oauth = oauth
|
||||
self._loginTimeout = None
|
||||
self._callback = None
|
||||
self._thread = None
|
||||
|
@ -1390,8 +1541,36 @@ class MyPlexPinLogin:
|
|||
|
||||
@property
|
||||
def pin(self):
|
||||
""" 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
|
||||
|
||||
def oauthUrl(self, forwardUrl=None):
|
||||
""" Return the Plex OAuth url for login.
|
||||
|
||||
Parameters:
|
||||
forwardUrl (str, optional): The url to redirect the client to after login.
|
||||
"""
|
||||
if not self._oauth:
|
||||
raise BadRequest('Must use "MyPlexPinLogin(oauth=True)" for Plex OAuth login.')
|
||||
|
||||
headers = self._headers()
|
||||
params = {
|
||||
'clientID': headers['X-Plex-Client-Identifier'],
|
||||
'context[device][product]': headers['X-Plex-Product'],
|
||||
'context[device][version]': headers['X-Plex-Version'],
|
||||
'context[device][platform]': headers['X-Plex-Platform'],
|
||||
'context[device][platformVersion]': headers['X-Plex-Platform-Version'],
|
||||
'context[device][device]': headers['X-Plex-Device'],
|
||||
'context[device][deviceName]': headers['X-Plex-Device-Name'],
|
||||
'code': self._code
|
||||
}
|
||||
if forwardUrl:
|
||||
params['forwardUrl'] = forwardUrl
|
||||
|
||||
return f'https://app.plex.tv/auth/#!?{urlencode(params)}'
|
||||
|
||||
def run(self, callback=None, timeout=None):
|
||||
""" Starts the thread which monitors the PIN login state.
|
||||
Parameters:
|
||||
|
@ -1455,7 +1634,13 @@ class MyPlexPinLogin:
|
|||
|
||||
def _getCode(self):
|
||||
url = self.PINS
|
||||
response = self._query(url, self._session.post)
|
||||
|
||||
if self._oauth:
|
||||
params = {'strong': True}
|
||||
else:
|
||||
params = None
|
||||
|
||||
response = self._query(url, self._session.post, params=params)
|
||||
if not response:
|
||||
return None
|
||||
|
||||
|
@ -1520,7 +1705,7 @@ class MyPlexPinLogin:
|
|||
if not response.ok: # pragma: no cover
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
||||
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
|
||||
data = response.text.encode('utf8')
|
||||
return ElementTree.fromstring(data) if data.strip() else None
|
||||
|
||||
|
@ -1564,7 +1749,7 @@ def _chooseConnection(ctype, name, results):
|
|||
if results:
|
||||
log.debug('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token)
|
||||
return results[0]
|
||||
raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name))
|
||||
raise NotFound(f'Unable to connect to {ctype.lower()}: {name}')
|
||||
|
||||
|
||||
class AccountOptOut(PlexObject):
|
||||
|
@ -1593,8 +1778,8 @@ class AccountOptOut(PlexObject):
|
|||
:exc:`~plexapi.exceptions.NotFound`: ``option`` str not found in CHOICES.
|
||||
"""
|
||||
if option not in self.CHOICES:
|
||||
raise NotFound('%s not found in available choices: %s' % (option, self.CHOICES))
|
||||
url = self._server.OPTOUTS % {'userUUID': self._server.uuid}
|
||||
raise NotFound(f'{option} not found in available choices: {self.CHOICES}')
|
||||
url = self._server.OPTOUTS.format(userUUID=self._server.uuid)
|
||||
params = {'key': self.key, 'value': option}
|
||||
self._server.query(url, method=self._server._session.post, params=params)
|
||||
self.value = option # assume query successful and set the value to option
|
||||
|
@ -1614,5 +1799,35 @@ class AccountOptOut(PlexObject):
|
|||
:exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music.
|
||||
"""
|
||||
if self.key == 'tv.plex.provider.music':
|
||||
raise BadRequest('%s does not have the option to opt out managed users.' % self.key)
|
||||
raise BadRequest(f'{self.key} does not have the option to opt out managed users.')
|
||||
self._updateOptOut('opt_out_managed')
|
||||
|
||||
|
||||
class UserState(PlexObject):
|
||||
""" Represents a single UserState
|
||||
|
||||
Attributes:
|
||||
TAG (str): UserState
|
||||
lastViewedAt (datetime): Datetime the item was last played.
|
||||
ratingKey (str): Unique key identifying the item.
|
||||
type (str): The media type of the item.
|
||||
viewCount (int): Count of times the item was played.
|
||||
viewedLeafCount (int): Number of items marked as played in the show/season.
|
||||
viewOffset (int): Time offset in milliseconds from the start of the content
|
||||
viewState (bool): True or False if the item has been played.
|
||||
watchlistedAt (datetime): Datetime the item was added to the watchlist.
|
||||
"""
|
||||
TAG = 'UserState'
|
||||
|
||||
def __repr__(self):
|
||||
return f'<{self.__class__.__name__}:{self.ratingKey}>'
|
||||
|
||||
def _loadData(self, data):
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.type = data.attrib.get('type')
|
||||
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount', 0))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.viewState = data.attrib.get('viewState') == 'complete'
|
||||
self.watchlistedAt = utils.toDatetime(data.attrib.get('watchlistedAt'))
|
||||
|
|
|
@ -3,7 +3,7 @@ import os
|
|||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils, video
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.base import Playable, PlexPartialObject, PlexSession
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.mixins import (
|
||||
RatingMixin,
|
||||
|
@ -79,12 +79,12 @@ class Photoalbum(
|
|||
Parameters:
|
||||
title (str): Title of the photo album to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItem(key, Photoalbum, title__iexact=title)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Photoalbum, **kwargs)
|
||||
|
||||
def photo(self, title):
|
||||
|
@ -93,12 +93,12 @@ class Photoalbum(
|
|||
Parameters:
|
||||
title (str): Title of the photo to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItem(key, Photo, title__iexact=title)
|
||||
|
||||
def photos(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photo` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Photo, **kwargs)
|
||||
|
||||
def clip(self, title):
|
||||
|
@ -107,12 +107,12 @@ class Photoalbum(
|
|||
Parameters:
|
||||
title (str): Title of the clip to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItem(key, video.Clip, title__iexact=title)
|
||||
|
||||
def clips(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Clip` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, video.Clip, **kwargs)
|
||||
|
||||
def get(self, title):
|
||||
|
@ -226,7 +226,7 @@ class Photo(
|
|||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
if self.parentTitle:
|
||||
return '%s - %s' % (self.parentTitle, self.title)
|
||||
return f'{self.parentTitle} - {self.title}'
|
||||
return self.title
|
||||
|
||||
def photoalbum(self):
|
||||
|
@ -282,7 +282,7 @@ class Photo(
|
|||
|
||||
section = self.section()
|
||||
|
||||
sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key))
|
||||
sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}'
|
||||
sync_item.policy = Policy.create(limit)
|
||||
sync_item.mediaSettings = MediaSettings.createPhoto(resolution)
|
||||
|
||||
|
@ -291,3 +291,16 @@ class Photo(
|
|||
def _getWebURL(self, base=None):
|
||||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class PhotoSession(PlexSession, Photo):
|
||||
""" Represents a single Photo session
|
||||
loaded from :func:`~plexapi.server.PlexServer.sessions`.
|
||||
"""
|
||||
_SESSIONTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Photo._loadData(self, data)
|
||||
PlexSession._loadData(self, data)
|
||||
|
|
|
@ -127,7 +127,7 @@ class Playlist(
|
|||
for _item in self.items():
|
||||
if _item.ratingKey == item.ratingKey:
|
||||
return _item.playlistItemID
|
||||
raise NotFound('Item with title "%s" not found in the playlist' % item.title)
|
||||
raise NotFound(f'Item with title "{item.title}" not found in the playlist')
|
||||
|
||||
def filters(self):
|
||||
""" Returns the search filter dict for smart playlist.
|
||||
|
@ -177,14 +177,14 @@ class Playlist(
|
|||
for item in self.items():
|
||||
if item.title.lower() == title.lower():
|
||||
return item
|
||||
raise NotFound('Item with title "%s" not found in the playlist' % title)
|
||||
raise NotFound(f'Item with title "{title}" not found in the playlist')
|
||||
|
||||
def items(self):
|
||||
""" Returns a list of all items in the playlist. """
|
||||
if self.radio:
|
||||
return []
|
||||
if self._items is None:
|
||||
key = '%s/items' % self.key
|
||||
key = f'{self.key}/items'
|
||||
items = self.fetchItems(key)
|
||||
self._items = items
|
||||
return self._items
|
||||
|
@ -212,17 +212,17 @@ class Playlist(
|
|||
ratingKeys = []
|
||||
for item in items:
|
||||
if item.listType != self.playlistType: # pragma: no cover
|
||||
raise BadRequest('Can not mix media types when building a playlist: %s and %s' %
|
||||
(self.playlistType, item.listType))
|
||||
raise BadRequest(f'Can not mix media types when building a playlist: '
|
||||
f'{self.playlistType} and {item.listType}')
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys)
|
||||
uri = f'{self._server._uriRoot()}/library/metadata/{ratingKeys}'
|
||||
|
||||
key = '%s/items%s' % (self.key, utils.joinArgs({
|
||||
'uri': uri
|
||||
}))
|
||||
args = {'uri': uri}
|
||||
key = f"{self.key}/items{utils.joinArgs(args)}"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
@deprecated('use "removeItems" instead', stacklevel=3)
|
||||
def removeItem(self, item):
|
||||
|
@ -247,8 +247,9 @@ class Playlist(
|
|||
|
||||
for item in items:
|
||||
playlistItemID = self._getPlaylistItemID(item)
|
||||
key = '%s/items/%s' % (self.key, playlistItemID)
|
||||
key = f'{self.key}/items/{playlistItemID}'
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
return self
|
||||
|
||||
def moveItem(self, item, after=None):
|
||||
""" Move an item to a new position in the playlist.
|
||||
|
@ -267,13 +268,14 @@ class Playlist(
|
|||
raise BadRequest('Cannot move items in a smart playlist.')
|
||||
|
||||
playlistItemID = self._getPlaylistItemID(item)
|
||||
key = '%s/items/%s/move' % (self.key, playlistItemID)
|
||||
key = f'{self.key}/items/{playlistItemID}/move'
|
||||
|
||||
if after:
|
||||
afterPlaylistItemID = self._getPlaylistItemID(after)
|
||||
key += '?after=%s' % afterPlaylistItemID
|
||||
key += f'?after={afterPlaylistItemID}'
|
||||
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def updateFilters(self, limit=None, sort=None, filters=None, **kwargs):
|
||||
""" Update the filters for a smart playlist.
|
||||
|
@ -297,17 +299,18 @@ class Playlist(
|
|||
section = self.section()
|
||||
searchKey = section._buildSearchKey(
|
||||
sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs)
|
||||
uri = '%s%s' % (self._server._uriRoot(), searchKey)
|
||||
uri = f'{self._server._uriRoot()}{searchKey}'
|
||||
|
||||
key = '%s/items%s' % (self.key, utils.joinArgs({
|
||||
'uri': uri
|
||||
}))
|
||||
args = {'uri': uri}
|
||||
key = f"{self.key}/items{utils.joinArgs(args)}"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def _edit(self, **kwargs):
|
||||
""" Actually edit the playlist. """
|
||||
key = '%s%s' % (self.key, utils.joinArgs(kwargs))
|
||||
key = f'{self.key}{utils.joinArgs(kwargs)}'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def edit(self, title=None, summary=None):
|
||||
""" Edit the playlist.
|
||||
|
@ -321,7 +324,7 @@ class Playlist(
|
|||
args['title'] = title
|
||||
if summary:
|
||||
args['summary'] = summary
|
||||
self._edit(**args)
|
||||
return self._edit(**args)
|
||||
|
||||
def delete(self):
|
||||
""" Delete the playlist. """
|
||||
|
@ -348,14 +351,10 @@ class Playlist(
|
|||
ratingKeys.append(str(item.ratingKey))
|
||||
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys)
|
||||
uri = f'{server._uriRoot()}/library/metadata/{ratingKeys}'
|
||||
|
||||
key = '/playlists%s' % utils.joinArgs({
|
||||
'uri': uri,
|
||||
'type': listType,
|
||||
'title': title,
|
||||
'smart': 0
|
||||
})
|
||||
args = {'uri': uri, 'type': listType, 'title': title, 'smart': 0}
|
||||
key = f"/playlists{utils.joinArgs(args)}"
|
||||
data = server.query(key, method=server._session.post)[0]
|
||||
return cls(server, data, initpath=key)
|
||||
|
||||
|
@ -369,14 +368,10 @@ class Playlist(
|
|||
|
||||
searchKey = section._buildSearchKey(
|
||||
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs)
|
||||
uri = '%s%s' % (server._uriRoot(), searchKey)
|
||||
uri = f'{server._uriRoot()}{searchKey}'
|
||||
|
||||
key = '/playlists%s' % utils.joinArgs({
|
||||
'uri': uri,
|
||||
'type': section.CONTENT_TYPE,
|
||||
'title': title,
|
||||
'smart': 1,
|
||||
})
|
||||
args = {'uri': uri, 'type': section.CONTENT_TYPE, 'title': title, 'smart': 1}
|
||||
key = f"/playlists{utils.joinArgs(args)}"
|
||||
data = server.query(key, method=server._session.post)[0]
|
||||
return cls(server, data, initpath=key)
|
||||
|
||||
|
@ -465,7 +460,7 @@ class Playlist(
|
|||
sync_item.metadataType = self.metadataType
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
sync_item.location = 'playlist:///%s' % quote_plus(self.guid)
|
||||
sync_item.location = f'playlist:///{quote_plus(self.guid)}'
|
||||
sync_item.policy = Policy.create(limit, unwatched)
|
||||
|
||||
if self.isVideo:
|
||||
|
|
|
@ -3,7 +3,7 @@ from urllib.parse import quote_plus
|
|||
|
||||
from plexapi import utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest, Unsupported
|
||||
from plexapi.exceptions import BadRequest
|
||||
|
||||
|
||||
class PlayQueue(PlexObject):
|
||||
|
@ -13,7 +13,7 @@ class PlayQueue(PlexObject):
|
|||
TAG (str): 'PlayQueue'
|
||||
TYPE (str): 'playqueue'
|
||||
identifier (str): com.plexapp.plugins.library
|
||||
items (list): List of :class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`
|
||||
items (list): List of :class:`~plexapi.base.Playable` or :class:`~plexapi.playlist.Playlist`
|
||||
mediaTagPrefix (str): Fx /system/bundle/media/flags/
|
||||
mediaTagVersion (int): Fx 1485957738
|
||||
playQueueID (int): ID of the PlayQueue.
|
||||
|
@ -27,7 +27,7 @@ class PlayQueue(PlexObject):
|
|||
playQueueSourceURI (str): Original URI used to create the PlayQueue.
|
||||
playQueueTotalCount (int): How many items in the PlayQueue.
|
||||
playQueueVersion (int): Version of the PlayQueue. Increments every time a change is made to the PlayQueue.
|
||||
selectedItem (:class:`~plexapi.media.Media`): Media object for the currently selected item.
|
||||
selectedItem (:class:`~plexapi.base.Playable`): Media object for the currently selected item.
|
||||
_server (:class:`~plexapi.server.PlexServer`): PlexServer associated with the PlayQueue.
|
||||
size (int): Alias for playQueueTotalCount.
|
||||
"""
|
||||
|
@ -90,10 +90,10 @@ class PlayQueue(PlexObject):
|
|||
return matches[0]
|
||||
elif len(matches) > 1:
|
||||
raise BadRequest(
|
||||
"{item} occurs multiple times in this PlayQueue, provide exact item".format(item=item)
|
||||
f"{item} occurs multiple times in this PlayQueue, provide exact item"
|
||||
)
|
||||
else:
|
||||
raise BadRequest("{item} not valid for this PlayQueue".format(item=item))
|
||||
raise BadRequest(f"{item} not valid for this PlayQueue")
|
||||
|
||||
@classmethod
|
||||
def get(
|
||||
|
@ -128,7 +128,7 @@ class PlayQueue(PlexObject):
|
|||
if center:
|
||||
args["center"] = center
|
||||
|
||||
path = "/playQueues/{playQueueID}{args}".format(playQueueID=playQueueID, args=utils.joinArgs(args))
|
||||
path = f"/playQueues/{playQueueID}{utils.joinArgs(args)}"
|
||||
data = server.query(path, method=server._session.get)
|
||||
c = cls(server, data, initpath=path)
|
||||
c._server = server
|
||||
|
@ -150,9 +150,9 @@ class PlayQueue(PlexObject):
|
|||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
items (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`):
|
||||
items (:class:`~plexapi.base.Playable` or :class:`~plexapi.playlist.Playlist`):
|
||||
A media item, list of media items, or Playlist.
|
||||
startItem (:class:`~plexapi.media.Media`, optional):
|
||||
startItem (:class:`~plexapi.base.Playable`, optional):
|
||||
Media item in the PlayQueue where playback should begin.
|
||||
shuffle (int, optional): Start the playqueue shuffled.
|
||||
repeat (int, optional): Start the playqueue shuffled.
|
||||
|
@ -171,8 +171,8 @@ class PlayQueue(PlexObject):
|
|||
|
||||
if isinstance(items, list):
|
||||
item_keys = ",".join([str(x.ratingKey) for x in items])
|
||||
uri_args = quote_plus("/library/metadata/{item_keys}".format(item_keys=item_keys))
|
||||
args["uri"] = "library:///directory/{uri_args}".format(uri_args=uri_args)
|
||||
uri_args = quote_plus(f"/library/metadata/{item_keys}")
|
||||
args["uri"] = f"library:///directory/{uri_args}"
|
||||
args["type"] = items[0].listType
|
||||
elif items.type == "playlist":
|
||||
args["type"] = items.playlistType
|
||||
|
@ -183,15 +183,14 @@ class PlayQueue(PlexObject):
|
|||
else:
|
||||
uuid = items.section().uuid
|
||||
args["type"] = items.listType
|
||||
args["uri"] = "library://{uuid}/item/{key}".format(uuid=uuid, key=items.key)
|
||||
args["uri"] = f"library://{uuid}/item/{items.key}"
|
||||
|
||||
if startItem:
|
||||
args["key"] = startItem.key
|
||||
|
||||
path = "/playQueues{args}".format(args=utils.joinArgs(args))
|
||||
path = f"/playQueues{utils.joinArgs(args)}"
|
||||
data = server.query(path, method=server._session.post)
|
||||
c = cls(server, data, initpath=path)
|
||||
c.playQueueType = args["type"]
|
||||
c._server = server
|
||||
return c
|
||||
|
||||
|
@ -227,7 +226,6 @@ class PlayQueue(PlexObject):
|
|||
path = f"/playQueues{utils.joinArgs(args)}"
|
||||
data = server.query(path, method=server._session.post)
|
||||
c = cls(server, data, initpath=path)
|
||||
c.playQueueType = args["type"]
|
||||
c._server = server
|
||||
return c
|
||||
|
||||
|
@ -237,7 +235,7 @@ class PlayQueue(PlexObject):
|
|||
Items can only be added to the section immediately following the current playing item.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist.
|
||||
item (:class:`~plexapi.base.Playable` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist.
|
||||
playNext (bool, optional): If True, add this item to the front of the "Up Next" section.
|
||||
If False, the item will be appended to the end of the "Up Next" section.
|
||||
Only has an effect if an item has already been added to the "Up Next" section.
|
||||
|
@ -250,21 +248,17 @@ class PlayQueue(PlexObject):
|
|||
args = {}
|
||||
if item.type == "playlist":
|
||||
args["playlistID"] = item.ratingKey
|
||||
itemType = item.playlistType
|
||||
else:
|
||||
uuid = item.section().uuid
|
||||
itemType = item.listType
|
||||
args["uri"] = "library://{uuid}/item{key}".format(uuid=uuid, key=item.key)
|
||||
|
||||
if itemType != self.playQueueType:
|
||||
raise Unsupported("Item type does not match PlayQueue type")
|
||||
args["uri"] = f"library://{uuid}/item{item.key}"
|
||||
|
||||
if playNext:
|
||||
args["next"] = 1
|
||||
|
||||
path = "/playQueues/{playQueueID}{args}".format(playQueueID=self.playQueueID, args=utils.joinArgs(args))
|
||||
path = f"/playQueues/{self.playQueueID}{utils.joinArgs(args)}"
|
||||
data = self._server.query(path, method=self._server._session.put)
|
||||
self._loadData(data)
|
||||
return self
|
||||
|
||||
def moveItem(self, item, after=None, refresh=True):
|
||||
"""
|
||||
|
@ -290,11 +284,10 @@ class PlayQueue(PlexObject):
|
|||
after = self.getQueueItem(after)
|
||||
args["after"] = after.playQueueItemID
|
||||
|
||||
path = "/playQueues/{playQueueID}/items/{playQueueItemID}/move{args}".format(
|
||||
playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID, args=utils.joinArgs(args)
|
||||
)
|
||||
path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}/move{utils.joinArgs(args)}"
|
||||
data = self._server.query(path, method=self._server._session.put)
|
||||
self._loadData(data)
|
||||
return self
|
||||
|
||||
def removeItem(self, item, refresh=True):
|
||||
"""Remove an item from the PlayQueue.
|
||||
|
@ -309,20 +302,21 @@ class PlayQueue(PlexObject):
|
|||
if item not in self:
|
||||
item = self.getQueueItem(item)
|
||||
|
||||
path = "/playQueues/{playQueueID}/items/{playQueueItemID}".format(
|
||||
playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID
|
||||
)
|
||||
path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}"
|
||||
data = self._server.query(path, method=self._server._session.delete)
|
||||
self._loadData(data)
|
||||
return self
|
||||
|
||||
def clear(self):
|
||||
"""Remove all items from the PlayQueue."""
|
||||
path = "/playQueues/{playQueueID}/items".format(playQueueID=self.playQueueID)
|
||||
path = f"/playQueues/{self.playQueueID}/items"
|
||||
data = self._server.query(path, method=self._server._session.delete)
|
||||
self._loadData(data)
|
||||
return self
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the PlayQueue from the Plex server."""
|
||||
path = "/playQueues/{playQueueID}".format(playQueueID=self.playQueueID)
|
||||
path = f"/playQueues/{self.playQueueID}"
|
||||
data = self._server.query(path, method=self._server._session.get)
|
||||
self._loadData(data)
|
||||
return self
|
||||
|
|
|
@ -171,7 +171,7 @@ class PlexServer(PlexObject):
|
|||
return headers
|
||||
|
||||
def _uriRoot(self):
|
||||
return 'server://%s/com.plexapp.plugins.library' % self.machineIdentifier
|
||||
return f'server://{self.machineIdentifier}/com.plexapp.plugins.library'
|
||||
|
||||
@property
|
||||
def library(self):
|
||||
|
@ -232,7 +232,7 @@ class PlexServer(PlexObject):
|
|||
""" Returns a list of :class:`~plexapi.media.Agent` objects this server has available. """
|
||||
key = '/system/agents'
|
||||
if mediaType:
|
||||
key += '?mediaType=%s' % utils.searchType(mediaType)
|
||||
key += f'?mediaType={utils.searchType(mediaType)}'
|
||||
return self.fetchItems(key)
|
||||
|
||||
def createToken(self, type='delegation', scope='all'):
|
||||
|
@ -240,7 +240,7 @@ class PlexServer(PlexObject):
|
|||
if not self._token:
|
||||
# Handle unclaimed servers
|
||||
return None
|
||||
q = self.query('/security/token?type=%s&scope=%s' % (type, scope))
|
||||
q = self.query(f'/security/token?type={type}&scope={scope}')
|
||||
return q.attrib.get('token')
|
||||
|
||||
def switchUser(self, username, session=None, timeout=None):
|
||||
|
@ -291,7 +291,7 @@ class PlexServer(PlexObject):
|
|||
try:
|
||||
return next(account for account in self.systemAccounts() if account.id == accountID)
|
||||
except StopIteration:
|
||||
raise NotFound('Unknown account with accountID=%s' % accountID) from None
|
||||
raise NotFound(f'Unknown account with accountID={accountID}') from None
|
||||
|
||||
def systemDevices(self):
|
||||
""" Returns a list of :class:`~plexapi.server.SystemDevice` objects this server contains. """
|
||||
|
@ -309,7 +309,7 @@ class PlexServer(PlexObject):
|
|||
try:
|
||||
return next(device for device in self.systemDevices() if device.id == deviceID)
|
||||
except StopIteration:
|
||||
raise NotFound('Unknown device with deviceID=%s' % deviceID) from None
|
||||
raise NotFound(f'Unknown device with deviceID={deviceID}') from None
|
||||
|
||||
def myPlexAccount(self):
|
||||
""" Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same
|
||||
|
@ -351,7 +351,7 @@ class PlexServer(PlexObject):
|
|||
key = path.key
|
||||
elif path is not None:
|
||||
base64path = utils.base64str(path)
|
||||
key = '/services/browse/%s' % base64path
|
||||
key = f'/services/browse/{base64path}'
|
||||
else:
|
||||
key = '/services/browse'
|
||||
if includeFiles:
|
||||
|
@ -406,7 +406,7 @@ class PlexServer(PlexObject):
|
|||
log.warning('%s did not advertise a port, checking plex.tv.', elem.attrib.get('name'))
|
||||
ports = self._myPlexClientPorts() if ports is None else ports
|
||||
port = ports.get(elem.attrib.get('machineIdentifier'))
|
||||
baseurl = 'http://%s:%s' % (elem.attrib['host'], port)
|
||||
baseurl = f"http://{elem.attrib['host']}:{port}"
|
||||
items.append(PlexClient(baseurl=baseurl, server=self,
|
||||
token=self._token, data=elem, connect=False))
|
||||
|
||||
|
@ -425,7 +425,7 @@ class PlexServer(PlexObject):
|
|||
if client and client.title == name:
|
||||
return client
|
||||
|
||||
raise NotFound('Unknown client name: %s' % name)
|
||||
raise NotFound(f'Unknown client name: {name}')
|
||||
|
||||
def createCollection(self, title, section, items=None, smart=False, limit=None,
|
||||
libtype=None, sort=None, filters=None, **kwargs):
|
||||
|
@ -547,6 +547,7 @@ class PlexServer(PlexObject):
|
|||
f'Invalid butler task: {task}. Available tasks are: {validTasks}'
|
||||
)
|
||||
self.query(f'/butler/{task}', method=self._session.post)
|
||||
return self
|
||||
|
||||
@deprecated('use "checkForUpdate" instead')
|
||||
def check_for_update(self, force=True, download=False):
|
||||
|
@ -559,7 +560,7 @@ class PlexServer(PlexObject):
|
|||
force (bool): Force server to check for new releases
|
||||
download (bool): Download if a update is available.
|
||||
"""
|
||||
part = '/updater/check?download=%s' % (1 if download else 0)
|
||||
part = f'/updater/check?download={1 if download else 0}'
|
||||
if force:
|
||||
self.query(part, method=self._session.put)
|
||||
releases = self.fetchItems('/updater/status')
|
||||
|
@ -608,7 +609,7 @@ class PlexServer(PlexObject):
|
|||
args['X-Plex-Container-Start'] = 0
|
||||
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
||||
while subresults and maxresults > len(results):
|
||||
key = '/status/sessions/history/all%s' % utils.joinArgs(args)
|
||||
key = f'/status/sessions/history/all{utils.joinArgs(args)}'
|
||||
subresults = self.fetchItems(key)
|
||||
results += subresults[:maxresults - len(results)]
|
||||
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
|
||||
|
@ -635,7 +636,7 @@ class PlexServer(PlexObject):
|
|||
# TODO: Automatically retrieve and validate sort field similar to LibrarySection.search()
|
||||
args['sort'] = sort
|
||||
|
||||
key = '/playlists%s' % utils.joinArgs(args)
|
||||
key = f'/playlists{utils.joinArgs(args)}'
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def playlist(self, title):
|
||||
|
@ -650,7 +651,7 @@ class PlexServer(PlexObject):
|
|||
try:
|
||||
return self.playlists(title=title, title__iexact=title)[0]
|
||||
except IndexError:
|
||||
raise NotFound('Unable to find playlist with title "%s".' % title) from None
|
||||
raise NotFound(f'Unable to find playlist with title "{title}".') from None
|
||||
|
||||
def optimizedItems(self, removeAll=None):
|
||||
""" Returns list of all :class:`~plexapi.media.Optimized` objects connected to server. """
|
||||
|
@ -659,7 +660,7 @@ class PlexServer(PlexObject):
|
|||
self.query(key, method=self._server._session.delete)
|
||||
else:
|
||||
backgroundProcessing = self.fetchItem('/playlists?type=42')
|
||||
return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized)
|
||||
return self.fetchItems(f'{backgroundProcessing.key}/items', cls=Optimized)
|
||||
|
||||
@deprecated('use "plexapi.media.Optimized.items()" instead')
|
||||
def optimizedItem(self, optimizedID):
|
||||
|
@ -668,7 +669,7 @@ class PlexServer(PlexObject):
|
|||
"""
|
||||
|
||||
backgroundProcessing = self.fetchItem('/playlists?type=42')
|
||||
return self.fetchItem('%s/items/%s/items' % (backgroundProcessing.key, optimizedID))
|
||||
return self.fetchItem(f'{backgroundProcessing.key}/items/{optimizedID}/items')
|
||||
|
||||
def conversions(self, pause=None):
|
||||
""" Returns list of all :class:`~plexapi.media.Conversion` objects connected to server. """
|
||||
|
@ -697,7 +698,7 @@ class PlexServer(PlexObject):
|
|||
if response.status_code not in (200, 201, 204):
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
||||
message = f'({response.status_code}) {codename}; {response.url} {errtext}'
|
||||
if response.status_code == 401:
|
||||
raise Unauthorized(message)
|
||||
elif response.status_code == 404:
|
||||
|
@ -736,7 +737,7 @@ class PlexServer(PlexObject):
|
|||
params['limit'] = limit
|
||||
if sectionId:
|
||||
params['sectionId'] = sectionId
|
||||
key = '/hubs/search?%s' % urlencode(params)
|
||||
key = f'/hubs/search?{urlencode(params)}'
|
||||
for hub in self.fetchItems(key, Hub):
|
||||
if mediatype:
|
||||
if hub.type == mediatype:
|
||||
|
@ -753,21 +754,27 @@ class PlexServer(PlexObject):
|
|||
""" Returns a list of all active :class:`~plexapi.media.TranscodeSession` objects. """
|
||||
return self.fetchItems('/transcode/sessions')
|
||||
|
||||
def startAlertListener(self, callback=None):
|
||||
def startAlertListener(self, callback=None, callbackError=None):
|
||||
""" Creates a websocket connection to the Plex Server to optionally receive
|
||||
notifications. These often include messages from Plex about media scans
|
||||
as well as updates to currently running Transcode Sessions.
|
||||
|
||||
NOTE: You need websocket-client installed in order to use this feature.
|
||||
Returns a new :class:`~plexapi.alert.AlertListener` object.
|
||||
|
||||
Note: ``websocket-client`` must be installed in order to use this feature.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>> pip install websocket-client
|
||||
|
||||
Parameters:
|
||||
callback (func): Callback function to call on received messages.
|
||||
callbackError (func): Callback function to call on errors.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exception.Unsupported`: Websocket-client not installed.
|
||||
"""
|
||||
notifier = AlertListener(self, callback)
|
||||
notifier = AlertListener(self, callback, callbackError)
|
||||
notifier.start()
|
||||
return notifier
|
||||
|
||||
|
@ -809,7 +816,7 @@ class PlexServer(PlexObject):
|
|||
if imageFormat is not None:
|
||||
params['format'] = imageFormat.lower()
|
||||
|
||||
key = '/photo/:/transcode%s' % utils.joinArgs(params)
|
||||
key = f'/photo/:/transcode{utils.joinArgs(params)}'
|
||||
return self.url(key, includeToken=True)
|
||||
|
||||
def url(self, key, includeToken=None):
|
||||
|
@ -818,8 +825,8 @@ class PlexServer(PlexObject):
|
|||
"""
|
||||
if self._token and (includeToken or self._showSecrets):
|
||||
delim = '&' if '?' in key else '?'
|
||||
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
|
||||
return '%s%s' % (self._baseurl, key)
|
||||
return f'{self._baseurl}{key}{delim}X-Plex-Token={self._token}'
|
||||
return f'{self._baseurl}{key}'
|
||||
|
||||
def refreshSynclist(self):
|
||||
""" Force PMS to download new SyncList from Plex.tv. """
|
||||
|
@ -853,7 +860,7 @@ class PlexServer(PlexObject):
|
|||
log.debug('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.')
|
||||
raise BadRequest('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.')
|
||||
value = 1 if toggle is True else 0
|
||||
return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put)
|
||||
return self.query(f'/:/prefs?allowMediaDeletion={value}', self._session.put)
|
||||
|
||||
def bandwidth(self, timespan=None, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.server.StatisticsBandwidth` objects
|
||||
|
@ -904,8 +911,7 @@ class PlexServer(PlexObject):
|
|||
gigabytes = round(bandwidth.bytes / 1024**3, 3)
|
||||
local = 'local' if bandwidth.lan else 'remote'
|
||||
date = bandwidth.at.strftime('%Y-%m-%d')
|
||||
print('%s used %s GB of %s bandwidth on %s from %s'
|
||||
% (account.name, gigabytes, local, date, device.name))
|
||||
print(f'{account.name} used {gigabytes} GB of {local} bandwidth on {date} from {device.name}')
|
||||
|
||||
"""
|
||||
params = {}
|
||||
|
@ -923,19 +929,19 @@ class PlexServer(PlexObject):
|
|||
try:
|
||||
params['timespan'] = timespans[timespan]
|
||||
except KeyError:
|
||||
raise BadRequest('Invalid timespan specified: %s. '
|
||||
'Available timespans: %s' % (timespan, ', '.join(timespans.keys())))
|
||||
raise BadRequest(f"Invalid timespan specified: {timespan}. "
|
||||
f"Available timespans: {', '.join(timespans.keys())}")
|
||||
|
||||
filters = {'accountID', 'at', 'at<', 'at>', 'bytes', 'bytes<', 'bytes>', 'deviceID', 'lan'}
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key not in filters:
|
||||
raise BadRequest('Unknown filter: %s=%s' % (key, value))
|
||||
raise BadRequest(f'Unknown filter: {key}={value}')
|
||||
if key.startswith('at'):
|
||||
try:
|
||||
value = utils.cast(int, value.timestamp())
|
||||
except AttributeError:
|
||||
raise BadRequest('Time frame filter must be a datetime object: %s=%s' % (key, value))
|
||||
raise BadRequest(f'Time frame filter must be a datetime object: {key}={value}')
|
||||
elif key.startswith('bytes') or key == 'lan':
|
||||
value = utils.cast(int, value)
|
||||
elif key == 'accountID':
|
||||
|
@ -943,7 +949,7 @@ class PlexServer(PlexObject):
|
|||
value = 1 # The admin account is accountID=1
|
||||
params[key] = value
|
||||
|
||||
key = '/statistics/bandwidth?%s' % urlencode(params)
|
||||
key = f'/statistics/bandwidth?{urlencode(params)}'
|
||||
return self.fetchItems(key, StatisticsBandwidth)
|
||||
|
||||
def resources(self):
|
||||
|
@ -966,13 +972,9 @@ class PlexServer(PlexObject):
|
|||
base = 'https://app.plex.tv/desktop/'
|
||||
|
||||
if endpoint:
|
||||
return '%s#!/server/%s/%s%s' % (
|
||||
base, self.machineIdentifier, endpoint, utils.joinArgs(kwargs)
|
||||
)
|
||||
return f'{base}#!/server/{self.machineIdentifier}/{endpoint}{utils.joinArgs(kwargs)}'
|
||||
else:
|
||||
return '%s#!/media/%s/com.plexapp.plugins.library%s' % (
|
||||
base, self.machineIdentifier, utils.joinArgs(kwargs)
|
||||
)
|
||||
return f'{base}#!/media/{self.machineIdentifier}/com.plexapp.plugins.library{utils.joinArgs(kwargs)}'
|
||||
|
||||
def getWebURL(self, base=None, playlistTab=None):
|
||||
""" Returns the Plex Web URL for the server.
|
||||
|
@ -983,7 +985,7 @@ class PlexServer(PlexObject):
|
|||
playlistTab (str): The playlist tab (audio, video, photo). Only used for the playlist URL.
|
||||
"""
|
||||
if playlistTab is not None:
|
||||
params = {'source': 'playlists', 'pivot': 'playlists.%s' % playlistTab}
|
||||
params = {'source': 'playlists', 'pivot': f'playlists.{playlistTab}'}
|
||||
else:
|
||||
params = {'key': '/hubs', 'pageType': 'hub'}
|
||||
return self._buildWebURL(base=base, **params)
|
||||
|
@ -1115,7 +1117,7 @@ class SystemDevice(PlexObject):
|
|||
self.clientIdentifier = data.attrib.get('clientIdentifier')
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.key = '/devices/%s' % self.id
|
||||
self.key = f'/devices/{self.id}'
|
||||
self.name = data.attrib.get('name')
|
||||
self.platform = data.attrib.get('platform')
|
||||
|
||||
|
@ -1146,12 +1148,14 @@ class StatisticsBandwidth(PlexObject):
|
|||
self.timespan = utils.cast(int, data.attrib.get('timespan'))
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
return '<{}>'.format(
|
||||
':'.join([p for p in [
|
||||
self.__class__.__name__,
|
||||
self._clean(self.accountID),
|
||||
self._clean(self.deviceID),
|
||||
self._clean(int(self.at.timestamp()))
|
||||
] if p])
|
||||
)
|
||||
|
||||
def account(self):
|
||||
""" Returns the :class:`~plexapi.server.SystemAccount` associated with the bandwidth data. """
|
||||
|
@ -1186,10 +1190,7 @@ class StatisticsResources(PlexObject):
|
|||
self.timespan = utils.cast(int, data.attrib.get('timespan'))
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
self.__class__.__name__,
|
||||
self._clean(int(self.at.timestamp()))
|
||||
] if p])
|
||||
return f"<{':'.join([p for p in [self.__class__.__name__, self._clean(int(self.at.timestamp()))] if p])}>"
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
|
|
|
@ -51,7 +51,7 @@ class Settings(PlexObject):
|
|||
id = utils.lowerFirst(id)
|
||||
if id in self._settings:
|
||||
return self._settings[id]
|
||||
raise NotFound('Invalid setting id: %s' % id)
|
||||
raise NotFound(f'Invalid setting id: {id}')
|
||||
|
||||
def groups(self):
|
||||
""" Returns a dict of lists for all :class:`~plexapi.settings.Setting`
|
||||
|
@ -77,12 +77,12 @@ class Settings(PlexObject):
|
|||
params = {}
|
||||
for setting in self.all():
|
||||
if setting._setValue:
|
||||
log.info('Saving PlexServer setting %s = %s' % (setting.id, setting._setValue))
|
||||
log.info('Saving PlexServer setting %s = %s', setting.id, setting._setValue)
|
||||
params[setting.id] = quote(setting._setValue)
|
||||
if not params:
|
||||
raise BadRequest('No setting have been modified.')
|
||||
querystr = '&'.join(['%s=%s' % (k, v) for k, v in params.items()])
|
||||
url = '%s?%s' % (self.key, querystr)
|
||||
querystr = '&'.join([f'{k}={v}' for k, v in params.items()])
|
||||
url = f'{self.key}?{querystr}'
|
||||
self._server.query(url, self._server._session.put)
|
||||
self.reload()
|
||||
|
||||
|
@ -149,16 +149,16 @@ class Setting(PlexObject):
|
|||
# check a few things up front
|
||||
if not isinstance(value, self.TYPES[self.type]['type']):
|
||||
badtype = type(value).__name__
|
||||
raise BadRequest('Invalid value for %s: a %s is required, not %s' % (self.id, self.type, badtype))
|
||||
raise BadRequest(f'Invalid value for {self.id}: a {self.type} is required, not {badtype}')
|
||||
if self.enumValues and value not in self.enumValues:
|
||||
raise BadRequest('Invalid value for %s: %s not in %s' % (self.id, value, list(self.enumValues)))
|
||||
raise BadRequest(f'Invalid value for {self.id}: {value} not in {list(self.enumValues)}')
|
||||
# store value off to the side until we call settings.save()
|
||||
tostr = self.TYPES[self.type]['tostr']
|
||||
self._setValue = tostr(value)
|
||||
|
||||
def toUrl(self):
|
||||
"""Helper for urls"""
|
||||
return '%s=%s' % (self.id, self._value or self.value)
|
||||
return f'{self.id}={self._value or self.value}'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
|
@ -174,6 +174,6 @@ class Preferences(Setting):
|
|||
|
||||
def _default(self):
|
||||
""" Set the default value for this setting."""
|
||||
key = '%s/prefs?' % self._initpath
|
||||
url = key + '%s=%s' % (self.id, self.default)
|
||||
key = f'{self._initpath}/prefs?'
|
||||
url = key + f'{self.id}={self.default}'
|
||||
self._server.query(url, method=self._server._session.put)
|
||||
|
|
|
@ -96,9 +96,7 @@ class PlexSonosClient(PlexClient):
|
|||
{
|
||||
"type": "music",
|
||||
"providerIdentifier": "com.plexapp.plugins.library",
|
||||
"containerKey": "/playQueues/{}?own=1".format(
|
||||
playqueue.playQueueID
|
||||
),
|
||||
"containerKey": f"/playQueues/{playqueue.playQueueID}?own=1",
|
||||
"key": media.key,
|
||||
"offset": offset,
|
||||
"machineIdentifier": media._server.machineIdentifier,
|
||||
|
|
|
@ -81,13 +81,13 @@ class SyncItem(PlexObject):
|
|||
""" Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. """
|
||||
server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier]
|
||||
if len(server) == 0:
|
||||
raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier)
|
||||
raise NotFound(f'Unable to find server with uuid {self.machineIdentifier}')
|
||||
return server[0]
|
||||
|
||||
def getMedia(self):
|
||||
""" Returns list of :class:`~plexapi.base.Playable` which belong to this sync item. """
|
||||
server = self.server().connect()
|
||||
key = '/sync/items/%s' % self.id
|
||||
key = f'/sync/items/{self.id}'
|
||||
return server.fetchItems(key)
|
||||
|
||||
def markDownloaded(self, media):
|
||||
|
@ -97,7 +97,7 @@ class SyncItem(PlexObject):
|
|||
Parameters:
|
||||
media (base.Playable): the media to be marked as downloaded.
|
||||
"""
|
||||
url = '/sync/%s/item/%s/downloaded' % (self.clientIdentifier, media.ratingKey)
|
||||
url = f'/sync/{self.clientIdentifier}/item/{media.ratingKey}/downloaded'
|
||||
media._server.query(url, method=requests.put)
|
||||
|
||||
def delete(self):
|
||||
|
@ -159,13 +159,14 @@ class Status:
|
|||
self.itemsCount = plexapi.utils.cast(int, itemsCount)
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>:%s' % (self.__class__.__name__, dict(
|
||||
d = dict(
|
||||
itemsCount=self.itemsCount,
|
||||
itemsCompleteCount=self.itemsCompleteCount,
|
||||
itemsDownloadedCount=self.itemsDownloadedCount,
|
||||
itemsReadyCount=self.itemsReadyCount,
|
||||
itemsSuccessfulCount=self.itemsSuccessfulCount
|
||||
))
|
||||
)
|
||||
return f'<{self.__class__.__name__}>:{d}'
|
||||
|
||||
|
||||
class MediaSettings:
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import base64
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
@ -26,10 +27,66 @@ except ImportError:
|
|||
log = logging.getLogger('plexapi')
|
||||
|
||||
# Search Types - Plex uses these to filter specific media types when searching.
|
||||
# Library Types - Populated at runtime
|
||||
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7,
|
||||
'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14,
|
||||
'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'optimizedVersion': 42, 'userPlaylistItem': 1001}
|
||||
SEARCHTYPES = {
|
||||
'movie': 1,
|
||||
'show': 2,
|
||||
'season': 3,
|
||||
'episode': 4,
|
||||
'trailer': 5,
|
||||
'comic': 6,
|
||||
'person': 7,
|
||||
'artist': 8,
|
||||
'album': 9,
|
||||
'track': 10,
|
||||
'picture': 11,
|
||||
'clip': 12,
|
||||
'photo': 13,
|
||||
'photoalbum': 14,
|
||||
'playlist': 15,
|
||||
'playlistFolder': 16,
|
||||
'collection': 18,
|
||||
'optimizedVersion': 42,
|
||||
'userPlaylistItem': 1001,
|
||||
}
|
||||
# Tag Types - Plex uses these to filter specific tags when searching.
|
||||
TAGTYPES = {
|
||||
'tag': 0,
|
||||
'genre': 1,
|
||||
'collection': 2,
|
||||
'director': 4,
|
||||
'writer': 5,
|
||||
'role': 6,
|
||||
'producer': 7,
|
||||
'country': 8,
|
||||
'chapter': 9,
|
||||
'review': 10,
|
||||
'label': 11,
|
||||
'marker': 12,
|
||||
'mediaProcessingTarget': 42,
|
||||
'make': 200,
|
||||
'model': 201,
|
||||
'aperture': 202,
|
||||
'exposure': 203,
|
||||
'iso': 204,
|
||||
'lens': 205,
|
||||
'device': 206,
|
||||
'autotag': 207,
|
||||
'mood': 300,
|
||||
'style': 301,
|
||||
'format': 302,
|
||||
'similar': 305,
|
||||
'concert': 306,
|
||||
'banner': 311,
|
||||
'poster': 312,
|
||||
'art': 313,
|
||||
'guid': 314,
|
||||
'ratingImage': 316,
|
||||
'theme': 317,
|
||||
'studio': 318,
|
||||
'network': 319,
|
||||
'place': 400,
|
||||
}
|
||||
# Plex Objects - Populated at runtime
|
||||
PLEXOBJECTS = {}
|
||||
|
||||
|
||||
|
@ -60,10 +117,12 @@ def registerPlexObject(cls):
|
|||
buildItem() below for an example.
|
||||
"""
|
||||
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
|
||||
ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG
|
||||
ehash = f'{cls.TAG}.{etype}' if etype else cls.TAG
|
||||
if getattr(cls, '_SESSIONTYPE', None):
|
||||
ehash = f"{ehash}.{'session'}"
|
||||
if ehash in PLEXOBJECTS:
|
||||
raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' %
|
||||
(cls.__name__, cls.TAG, etype, PLEXOBJECTS[ehash].__name__))
|
||||
raise Exception(f'Ambiguous PlexObject definition {cls.__name__}(tag={cls.TAG}, type={etype}) '
|
||||
f'with {PLEXOBJECTS[ehash].__name__}')
|
||||
PLEXOBJECTS[ehash] = cls
|
||||
return cls
|
||||
|
||||
|
@ -106,8 +165,8 @@ def joinArgs(args):
|
|||
arglist = []
|
||||
for key in sorted(args, key=lambda x: x.lower()):
|
||||
value = str(args[key])
|
||||
arglist.append('%s=%s' % (key, quote(value, safe='')))
|
||||
return '?%s' % '&'.join(arglist)
|
||||
arglist.append(f"{key}={quote(value, safe='')}")
|
||||
return f"?{'&'.join(arglist)}"
|
||||
|
||||
|
||||
def lowerFirst(s):
|
||||
|
@ -149,8 +208,7 @@ def searchType(libtype):
|
|||
""" Returns the integer value of the library string type.
|
||||
|
||||
Parameters:
|
||||
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
|
||||
collection)
|
||||
libtype (str): LibType to lookup (See :data:`~plexapi.utils.SEARCHTYPES`)
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
||||
|
@ -160,7 +218,7 @@ def searchType(libtype):
|
|||
return libtype
|
||||
if SEARCHTYPES.get(libtype) is not None:
|
||||
return SEARCHTYPES[libtype]
|
||||
raise NotFound('Unknown libtype: %s' % libtype)
|
||||
raise NotFound(f'Unknown libtype: {libtype}')
|
||||
|
||||
|
||||
def reverseSearchType(libtype):
|
||||
|
@ -178,7 +236,42 @@ def reverseSearchType(libtype):
|
|||
for k, v in SEARCHTYPES.items():
|
||||
if libtype == v:
|
||||
return k
|
||||
raise NotFound('Unknown libtype: %s' % libtype)
|
||||
raise NotFound(f'Unknown libtype: {libtype}')
|
||||
|
||||
|
||||
def tagType(tag):
|
||||
""" Returns the integer value of the library tag type.
|
||||
|
||||
Parameters:
|
||||
tag (str): Tag to lookup (See :data:`~plexapi.utils.TAGTYPES`)
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: Unknown tag
|
||||
"""
|
||||
tag = str(tag)
|
||||
if tag in [str(v) for v in TAGTYPES.values()]:
|
||||
return tag
|
||||
if TAGTYPES.get(tag) is not None:
|
||||
return TAGTYPES[tag]
|
||||
raise NotFound(f'Unknown tag: {tag}')
|
||||
|
||||
|
||||
def reverseTagType(tag):
|
||||
""" Returns the string value of the library tag type.
|
||||
|
||||
Parameters:
|
||||
tag (int): Integer value of the library tag type.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: Unknown tag
|
||||
"""
|
||||
if tag in TAGTYPES:
|
||||
return tag
|
||||
tag = int(tag)
|
||||
for k, v in TAGTYPES.items():
|
||||
if tag == v:
|
||||
return k
|
||||
raise NotFound(f'Unknown tag: {tag}')
|
||||
|
||||
|
||||
def threaded(callback, listargs):
|
||||
|
@ -255,7 +348,7 @@ def toList(value, itemcast=None, delim=','):
|
|||
|
||||
|
||||
def cleanFilename(filename, replace='_'):
|
||||
whitelist = "-_.()[] {}{}".format(string.ascii_letters, string.digits)
|
||||
whitelist = f"-_.()[] {string.ascii_letters}{string.digits}"
|
||||
cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
|
||||
cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename)
|
||||
return cleaned_filename
|
||||
|
@ -283,11 +376,11 @@ def downloadSessionImages(server, filename=None, height=150, width=150,
|
|||
if media.thumb:
|
||||
url = media.thumb
|
||||
if part.indexes: # always use bif images if available.
|
||||
url = '/library/parts/%s/indexes/%s/%s' % (part.id, part.indexes.lower(), media.viewOffset)
|
||||
url = f'/library/parts/{part.id}/indexes/{part.indexes.lower()}/{media.viewOffset}'
|
||||
if url:
|
||||
if filename is None:
|
||||
prettyname = media._prettyfilename()
|
||||
filename = 'session_transcode_%s_%s_%s' % (media.usernames[0], prettyname, int(time.time()))
|
||||
filename = f'session_transcode_{media.usernames[0]}_{prettyname}_{int(time.time())}'
|
||||
url = server.transcodeImage(url, height, width, opacity, saturation)
|
||||
filepath = download(url, filename=filename)
|
||||
info['username'] = {'filepath': filepath, 'url': url}
|
||||
|
@ -374,13 +467,13 @@ def getMyPlexAccount(opts=None): # pragma: no cover
|
|||
from plexapi.myplex import MyPlexAccount
|
||||
# 1. Check command-line options
|
||||
if opts and opts.username and opts.password:
|
||||
print('Authenticating with Plex.tv as %s..' % opts.username)
|
||||
print(f'Authenticating with Plex.tv as {opts.username}..')
|
||||
return MyPlexAccount(opts.username, opts.password)
|
||||
# 2. Check Plexconfig (environment variables and config.ini)
|
||||
config_username = CONFIG.get('auth.myplex_username')
|
||||
config_password = CONFIG.get('auth.myplex_password')
|
||||
if config_username and config_password:
|
||||
print('Authenticating with Plex.tv as %s..' % config_username)
|
||||
print(f'Authenticating with Plex.tv as {config_username}..')
|
||||
return MyPlexAccount(config_username, config_password)
|
||||
config_token = CONFIG.get('auth.server_token')
|
||||
if config_token:
|
||||
|
@ -389,12 +482,12 @@ def getMyPlexAccount(opts=None): # pragma: no cover
|
|||
# 3. Prompt for username and password on the command line
|
||||
username = input('What is your plex.tv username: ')
|
||||
password = getpass('What is your plex.tv password: ')
|
||||
print('Authenticating with Plex.tv as %s..' % username)
|
||||
print(f'Authenticating with Plex.tv as {username}..')
|
||||
return MyPlexAccount(username, password)
|
||||
|
||||
|
||||
def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover
|
||||
""" Helper function to create a new MyPlexDevice.
|
||||
""" Helper function to create a new MyPlexDevice. Returns a new MyPlexDevice instance.
|
||||
|
||||
Parameters:
|
||||
headers (dict): Provide the X-Plex- headers for the new device.
|
||||
|
@ -417,6 +510,33 @@ def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover
|
|||
return account.device(clientId=clientIdentifier)
|
||||
|
||||
|
||||
def plexOAuth(headers, forwardUrl=None, timeout=120): # pragma: no cover
|
||||
""" Helper function for Plex OAuth login. Returns a new MyPlexAccount instance.
|
||||
|
||||
Parameters:
|
||||
headers (dict): Provide the X-Plex- headers for the new device.
|
||||
A unique X-Plex-Client-Identifier is required.
|
||||
forwardUrl (str, optional): The url to redirect the client to after login.
|
||||
timeout (int, optional): Timeout in seconds to wait for device login. Default 120 seconds.
|
||||
"""
|
||||
from plexapi.myplex import MyPlexAccount, MyPlexPinLogin
|
||||
|
||||
if 'X-Plex-Client-Identifier' not in headers:
|
||||
raise BadRequest('The X-Plex-Client-Identifier header is required.')
|
||||
|
||||
pinlogin = MyPlexPinLogin(headers=headers, oauth=True)
|
||||
print('Login to Plex at the following url:')
|
||||
print(pinlogin.oauthUrl(forwardUrl))
|
||||
pinlogin.run(timeout=timeout)
|
||||
pinlogin.waitForLogin()
|
||||
|
||||
if pinlogin.token:
|
||||
print('Login successful!')
|
||||
return MyPlexAccount(token=pinlogin.token)
|
||||
else:
|
||||
print('Login failed.')
|
||||
|
||||
|
||||
def choose(msg, items, attr): # pragma: no cover
|
||||
""" Command line helper to display a list of choices, asking the
|
||||
user to choose one of the options.
|
||||
|
@ -428,12 +548,12 @@ def choose(msg, items, attr): # pragma: no cover
|
|||
print()
|
||||
for index, i in enumerate(items):
|
||||
name = attr(i) if callable(attr) else getattr(i, attr)
|
||||
print(' %s: %s' % (index, name))
|
||||
print(f' {index}: {name}')
|
||||
print()
|
||||
# Request choice from the user
|
||||
while True:
|
||||
try:
|
||||
inp = input('%s: ' % msg)
|
||||
inp = input(f'{msg}: ')
|
||||
if any(s in inp for s in (':', '::', '-')):
|
||||
idx = slice(*map(lambda x: int(x.strip()) if x.strip() else None, inp.split(':')))
|
||||
return items[idx]
|
||||
|
@ -452,8 +572,7 @@ def getAgentIdentifier(section, agent):
|
|||
if agent in identifiers:
|
||||
return ag.identifier
|
||||
agents += identifiers
|
||||
raise NotFound('Could not find "%s" in agents list (%s)' %
|
||||
(agent, ', '.join(agents)))
|
||||
raise NotFound(f"Could not find \"{agent}\" in agents list ({', '.join(agents)})")
|
||||
|
||||
|
||||
def base64str(text):
|
||||
|
@ -467,7 +586,7 @@ def deprecated(message, stacklevel=2):
|
|||
when the function is used."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message)
|
||||
msg = f'Call to deprecated function or method "{func.__name__}", {message}.'
|
||||
warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel)
|
||||
log.warning(msg)
|
||||
return func(*args, **kwargs)
|
||||
|
@ -485,3 +604,17 @@ def iterXMLBFS(root, tag=None):
|
|||
if tag is None or node.tag == tag:
|
||||
yield node
|
||||
queue.extend(list(node))
|
||||
|
||||
|
||||
def toJson(obj, **kwargs):
|
||||
""" Convert an object to a JSON string.
|
||||
|
||||
Parameters:
|
||||
obj (object): The object to convert.
|
||||
**kwargs (dict): Keyword arguments to pass to ``json.dumps()``.
|
||||
"""
|
||||
def serialize(obj):
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return {k: v for k, v in obj.__dict__.items() if not k.startswith('_')}
|
||||
return json.dumps(obj, default=serialize, **kwargs)
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.base import Playable, PlexPartialObject, PlexSession
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.mixins import (
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
||||
ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
|
||||
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
||||
ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
||||
SummaryMixin, TaglineMixin, TitleMixin,
|
||||
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
|
||||
WatchlistMixin
|
||||
)
|
||||
|
||||
|
||||
class Video(PlexPartialObject):
|
||||
class Video(PlexPartialObject, PlayedUnplayedMixin):
|
||||
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
||||
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
|
||||
|
@ -71,25 +71,10 @@ class Video(PlexPartialObject):
|
|||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if this video is watched. """
|
||||
return bool(self.viewCount > 0) if self.viewCount else False
|
||||
|
||||
def url(self, part):
|
||||
""" Returns the full url for something. Typically used for getting a specific image. """
|
||||
return self._server.url(part, includeToken=True) if part else None
|
||||
|
||||
def markWatched(self):
|
||||
""" Mark the video as played. """
|
||||
key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||
self._server.query(key)
|
||||
|
||||
def markUnwatched(self):
|
||||
""" Mark the video as unplayed. """
|
||||
key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||
self._server.query(key)
|
||||
|
||||
def augmentation(self):
|
||||
""" Returns a list of :class:`~plexapi.library.Hub` objects.
|
||||
Augmentation returns hub items relating to online media sources
|
||||
|
@ -132,7 +117,7 @@ class Video(PlexPartialObject):
|
|||
|
||||
def uploadSubtitles(self, filepath):
|
||||
""" Upload Subtitle file for video. """
|
||||
url = '%s/subtitles' % self.key
|
||||
url = f'{self.key}/subtitles'
|
||||
filename = os.path.basename(filepath)
|
||||
subFormat = os.path.splitext(filepath)[1][1:]
|
||||
with open(filepath, 'rb') as subfile:
|
||||
|
@ -141,6 +126,7 @@ class Video(PlexPartialObject):
|
|||
}
|
||||
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.
|
||||
|
@ -151,74 +137,103 @@ class Video(PlexPartialObject):
|
|||
for stream in self.subtitleStreams():
|
||||
if streamID == stream.id or streamTitle == stream.title:
|
||||
self._server.query(stream.key, self._server._session.delete)
|
||||
return self
|
||||
|
||||
def optimize(self, title=None, target="", targetTagID=None, locationID=-1, policyScope='all',
|
||||
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None):
|
||||
""" Optimize item
|
||||
def optimize(self, title='', target='', deviceProfile='', videoQuality=None,
|
||||
locationID=-1, limit=None, unwatched=False):
|
||||
""" Create an optimized version of the video.
|
||||
|
||||
locationID (int): -1 in folder with original items
|
||||
2 library path id
|
||||
library path id is found in library.locations[i].id
|
||||
Parameters:
|
||||
title (str, optional): Title of the optimized video.
|
||||
target (str, optional): Target quality profile:
|
||||
"Optimized for Mobile" ("mobile"), "Optimized for TV" ("tv"), "Original Quality" ("original"),
|
||||
or custom quality profile name (default "Custom: {deviceProfile}").
|
||||
deviceProfile (str, optional): Custom quality device profile:
|
||||
"Android", "iOS", "Universal Mobile", "Universal TV", "Windows Phone", "Windows", "Xbox One".
|
||||
Required if ``target`` is custom.
|
||||
videoQuality (int, optional): Index of the quality profile, one of ``VIDEO_QUALITY_*``
|
||||
values defined in the :mod:`~plexapi.sync` module. Only used if ``target`` is custom.
|
||||
locationID (int or :class:`~plexapi.library.Location`, optional): Default -1 for
|
||||
"In folder with original items", otherwise a :class:`~plexapi.library.Location` object or ID.
|
||||
See examples below.
|
||||
limit (int, optional): Maximum count of items to optimize, unlimited if ``None``.
|
||||
unwatched (bool, optional): ``True`` to only optimized unwatched videos.
|
||||
|
||||
target (str): custom quality name.
|
||||
if none provided use "Custom: {deviceProfile}"
|
||||
|
||||
targetTagID (int): Default quality settings
|
||||
1 Mobile
|
||||
2 TV
|
||||
3 Original Quality
|
||||
|
||||
deviceProfile (str): Android, IOS, Universal TV, Universal Mobile, Windows Phone,
|
||||
Windows, Xbox One
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: Unknown quality profile target
|
||||
or missing deviceProfile and videoQuality.
|
||||
:exc:`~plexapi.exceptions.BadRequest`: Unknown location ID.
|
||||
|
||||
Example:
|
||||
Optimize for Mobile
|
||||
item.optimize(targetTagID="Mobile") or item.optimize(targetTagID=1")
|
||||
Optimize for Android 10 MBPS 1080p
|
||||
item.optimize(deviceProfile="Android", videoQuality=10)
|
||||
Optimize for IOS Original Quality
|
||||
item.optimize(deviceProfile="IOS", videoQuality=-1)
|
||||
|
||||
* see sync.py VIDEO_QUALITIES for additional information for using videoQuality
|
||||
.. code-block:: python
|
||||
|
||||
# Optimize for mobile using defaults
|
||||
video.optimize(target="mobile")
|
||||
|
||||
# Optimize for Android at 10 Mbps 1080p
|
||||
from plexapi.sync import VIDEO_QUALITY_10_MBPS_1080p
|
||||
video.optimize(deviceProfile="Android", videoQuality=sync.VIDEO_QUALITY_10_MBPS_1080p)
|
||||
|
||||
# Optimize for iOS at original quality in library location
|
||||
from plexapi.sync import VIDEO_QUALITY_ORIGINAL
|
||||
locations = plex.library.section("Movies")._locations()
|
||||
video.optimize(deviceProfile="iOS", videoQuality=VIDEO_QUALITY_ORIGINAL, locationID=locations[0])
|
||||
|
||||
# Optimize for tv the next 5 unwatched episodes
|
||||
show.optimize(target="tv", limit=5, unwatched=True)
|
||||
|
||||
"""
|
||||
tagValues = [1, 2, 3]
|
||||
tagKeys = ["Mobile", "TV", "Original Quality"]
|
||||
tagIDs = tagKeys + tagValues
|
||||
|
||||
if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None):
|
||||
raise BadRequest('Unexpected or missing quality profile.')
|
||||
|
||||
libraryLocationIDs = [location.id for location in self.section()._locations()]
|
||||
libraryLocationIDs.append(-1)
|
||||
|
||||
if locationID not in libraryLocationIDs:
|
||||
raise BadRequest('Unexpected library path ID. %s not in %s' %
|
||||
(locationID, libraryLocationIDs))
|
||||
|
||||
if isinstance(targetTagID, str):
|
||||
tagIndex = tagKeys.index(targetTagID)
|
||||
targetTagID = tagValues[tagIndex]
|
||||
|
||||
if title is None:
|
||||
title = self.title
|
||||
from plexapi.library import Location
|
||||
from plexapi.sync import Policy, MediaSettings
|
||||
|
||||
backgroundProcessing = self.fetchItem('/playlists?type=42')
|
||||
key = '%s/items?' % backgroundProcessing.key
|
||||
key = f'{backgroundProcessing.key}/items'
|
||||
|
||||
tags = {t.tag.lower(): t.id for t in self._server.library.tags('mediaProcessingTarget')}
|
||||
# Additional keys for shorthand values
|
||||
tags['mobile'] = tags['optimized for mobile']
|
||||
tags['tv'] = tags['optimized for tv']
|
||||
tags['original'] = tags['original quality']
|
||||
|
||||
targetTagID = tags.get(target.lower(), '')
|
||||
if not targetTagID and (not deviceProfile or videoQuality is None):
|
||||
raise BadRequest('Unknown quality profile target or missing deviceProfile and videoQuality.')
|
||||
if targetTagID:
|
||||
target = ''
|
||||
elif deviceProfile and not target:
|
||||
target = f'Custom: {deviceProfile}'
|
||||
|
||||
section = self.section()
|
||||
libraryLocationIDs = [-1] + [location.id for location in section._locations()]
|
||||
if isinstance(locationID, Location):
|
||||
locationID = locationID.id
|
||||
if locationID not in libraryLocationIDs:
|
||||
raise BadRequest(f'Unknown location ID "{locationID}" not in {libraryLocationIDs}')
|
||||
|
||||
if isinstance(self, (Show, Season)):
|
||||
uri = f'library:///directory/{quote_plus(f"{self.key}/children")}'
|
||||
else:
|
||||
uri = f'library://{section.uuid}/item/{quote_plus(self.key)}'
|
||||
|
||||
policy = Policy.create(limit, unwatched)
|
||||
|
||||
params = {
|
||||
'Item[type]': 42,
|
||||
'Item[title]': title or self._defaultSyncTitle(),
|
||||
'Item[target]': target,
|
||||
'Item[targetTagID]': targetTagID if targetTagID else '',
|
||||
'Item[targetTagID]': targetTagID,
|
||||
'Item[locationID]': locationID,
|
||||
'Item[Policy][scope]': policyScope,
|
||||
'Item[Policy][value]': policyValue,
|
||||
'Item[Policy][unwatched]': policyUnwatched
|
||||
'Item[Location][uri]': uri,
|
||||
'Item[Policy][scope]': policy.scope,
|
||||
'Item[Policy][value]': str(policy.value),
|
||||
'Item[Policy][unwatched]': str(int(policy.unwatched)),
|
||||
}
|
||||
|
||||
if deviceProfile:
|
||||
params['Item[Device][profile]'] = deviceProfile
|
||||
|
||||
if videoQuality:
|
||||
from plexapi.sync import MediaSettings
|
||||
mediaSettings = MediaSettings.createVideo(videoQuality)
|
||||
params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality
|
||||
params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution
|
||||
|
@ -227,14 +242,11 @@ class Video(PlexPartialObject):
|
|||
params['Item[MediaSettings][subtitleSize]'] = ''
|
||||
params['Item[MediaSettings][musicBitrate]'] = ''
|
||||
params['Item[MediaSettings][photoQuality]'] = ''
|
||||
params['Item[MediaSettings][photoResolution]'] = ''
|
||||
|
||||
titleParam = {'Item[title]': title}
|
||||
section = self._server.library.sectionByID(self.librarySectionID)
|
||||
params['Item[Location][uri]'] = 'library://' + section.uuid + '/item/' + \
|
||||
quote_plus(self.key + '?includeExternalMedia=1')
|
||||
|
||||
data = key + urlencode(params) + '&' + urlencode(titleParam)
|
||||
return self._server.query(data, method=self._server._session.put)
|
||||
url = key + utils.joinArgs(params)
|
||||
self._server.query(url, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None):
|
||||
""" Add current video (movie, tv-show, season or episode) as sync item for specified device.
|
||||
|
@ -267,7 +279,7 @@ class Video(PlexPartialObject):
|
|||
|
||||
section = self._server.library.sectionByID(self.librarySectionID)
|
||||
|
||||
sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key))
|
||||
sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}'
|
||||
sync_item.policy = Policy.create(limit, unwatched)
|
||||
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
|
||||
|
||||
|
@ -279,7 +291,7 @@ class Movie(
|
|||
Video, Playable,
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
||||
ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
||||
SummaryMixin, TaglineMixin, TitleMixin,
|
||||
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
|
||||
WatchlistMixin
|
||||
|
@ -298,6 +310,7 @@ class Movie(
|
|||
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
|
||||
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
|
||||
duration (int): Duration of the movie in milliseconds.
|
||||
editionTitle (str): The edition title of the movie (e.g. Director's Cut, Extended Edition, etc.).
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
|
@ -338,6 +351,7 @@ class Movie(
|
|||
self.countries = self.findItems(data, media.Country)
|
||||
self.directors = self.findItems(data, media.Director)
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.editionTitle = data.attrib.get('editionTitle')
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
|
@ -381,13 +395,23 @@ class Movie(
|
|||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s (%s)' % (self.title, self.year)
|
||||
return f'{self.title} ({self.year})'
|
||||
|
||||
def reviews(self):
|
||||
""" Returns a list of :class:`~plexapi.media.Review` objects. """
|
||||
data = self._server.query(self._details_key)
|
||||
return self.findItems(data, media.Review, rtag='Video')
|
||||
|
||||
def editions(self):
|
||||
""" Returns a list of :class:`~plexapi.video.Movie` objects
|
||||
for other editions of the same movie.
|
||||
"""
|
||||
filters = {
|
||||
'guid': self.guid,
|
||||
'id!': self.ratingKey
|
||||
}
|
||||
return self.section().search(filters=filters)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Show(
|
||||
|
@ -499,8 +523,8 @@ class Show(
|
|||
return self.roles
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if the show is fully watched. """
|
||||
def isPlayed(self):
|
||||
""" Returns True if the show is fully played. """
|
||||
return bool(self.viewedLeafCount == self.leafCount)
|
||||
|
||||
def onDeck(self):
|
||||
|
@ -520,7 +544,7 @@ class Show(
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
|
||||
key = f'{self.key}/children?excludeAllLeaves=1'
|
||||
if title is not None and not isinstance(title, int):
|
||||
return self.fetchItem(key, Season, title__iexact=title)
|
||||
elif season is not None or isinstance(title, int):
|
||||
|
@ -533,8 +557,8 @@ class Show(
|
|||
|
||||
def seasons(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Season` objects in the show. """
|
||||
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
|
||||
return self.fetchItems(key, Season, **kwargs)
|
||||
key = f'{self.key}/children?excludeAllLeaves=1'
|
||||
return self.fetchItems(key, Season, container_size=self.childCount, **kwargs)
|
||||
|
||||
def episode(self, title=None, season=None, episode=None):
|
||||
""" Find a episode using a title or season and episode.
|
||||
|
@ -547,7 +571,7 @@ class Show(
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
key = f'{self.key}/allLeaves'
|
||||
if title is not None:
|
||||
return self.fetchItem(key, Episode, title__iexact=title)
|
||||
elif season is not None and episode is not None:
|
||||
|
@ -556,7 +580,7 @@ class Show(
|
|||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects in the show. """
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
key = f'{self.key}/allLeaves'
|
||||
return self.fetchItems(key, Episode, **kwargs)
|
||||
|
||||
def get(self, title=None, season=None, episode=None):
|
||||
|
@ -583,7 +607,7 @@ class Show(
|
|||
"""
|
||||
filepaths = []
|
||||
for episode in self.episodes():
|
||||
_savepath = os.path.join(savepath, 'Season %s' % str(episode.seasonNumber).zfill(2)) if subfolders else savepath
|
||||
_savepath = os.path.join(savepath, f'Season {str(episode.seasonNumber).zfill(2)}') if subfolders else savepath
|
||||
filepaths += episode.download(_savepath, keep_original_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
|
@ -647,15 +671,17 @@ class Season(
|
|||
yield episode
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
return '<{}>'.format(
|
||||
':'.join([p for p in [
|
||||
self.__class__.__name__,
|
||||
self.key.replace('/library/metadata/', '').replace('/children', ''),
|
||||
'%s-s%s' % (self.parentTitle.replace(' ', '-')[:20], self.seasonNumber),
|
||||
f"{self.parentTitle.replace(' ', '-')[:20]}-{self.seasonNumber}",
|
||||
] if p])
|
||||
)
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if the season is fully watched. """
|
||||
def isPlayed(self):
|
||||
""" Returns True if the season is fully played. """
|
||||
return bool(self.viewedLeafCount == self.leafCount)
|
||||
|
||||
@property
|
||||
|
@ -665,7 +691,7 @@ class Season(
|
|||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Episode, **kwargs)
|
||||
|
||||
def episode(self, title=None, episode=None):
|
||||
|
@ -678,7 +704,7 @@ class Season(
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
key = f'{self.key}/children'
|
||||
if title is not None and not isinstance(title, int):
|
||||
return self.fetchItem(key, Episode, title__iexact=title)
|
||||
elif episode is not None or isinstance(title, int):
|
||||
|
@ -728,7 +754,7 @@ class Season(
|
|||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return '%s - %s' % (self.parentTitle, self.title)
|
||||
return f'{self.parentTitle} - {self.title}'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
|
@ -835,18 +861,20 @@ class Episode(
|
|||
if not self.parentRatingKey and self.grandparentRatingKey:
|
||||
self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey
|
||||
if self.parentRatingKey:
|
||||
self.parentKey = '/library/metadata/%s' % self.parentRatingKey
|
||||
self.parentKey = f'/library/metadata/{self.parentRatingKey}'
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
return '<{}>'.format(
|
||||
':'.join([p for p in [
|
||||
self.__class__.__name__,
|
||||
self.key.replace('/library/metadata/', '').replace('/children', ''),
|
||||
'%s-%s' % (self.grandparentTitle.replace(' ', '-')[:20], self.seasonEpisode),
|
||||
f"{self.grandparentTitle.replace(' ', '-')[:20]}-{self.seasonEpisode}",
|
||||
] if p])
|
||||
)
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s - %s - %s' % (self.grandparentTitle, self.seasonEpisode, self.title)
|
||||
return f'{self.grandparentTitle} - {self.seasonEpisode} - {self.title}'
|
||||
|
||||
@property
|
||||
def actors(self):
|
||||
|
@ -878,7 +906,7 @@ class Episode(
|
|||
@property
|
||||
def seasonEpisode(self):
|
||||
""" Returns the s00e00 string containing the season and episode numbers. """
|
||||
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2))
|
||||
return f's{str(self.seasonNumber).zfill(2)}e{str(self.episodeNumber).zfill(2)}'
|
||||
|
||||
@property
|
||||
def hasCommercialMarker(self):
|
||||
|
@ -905,7 +933,7 @@ class Episode(
|
|||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title)
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - ({self.seasonEpisode}) {self.title}'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
|
@ -979,4 +1007,43 @@ class Extra(Clip):
|
|||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s (%s)' % (self.title, self.subtype)
|
||||
return f'{self.title} ({self.subtype})'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class MovieSession(PlexSession, Movie):
|
||||
""" Represents a single Movie session
|
||||
loaded from :func:`~plexapi.server.PlexServer.sessions`.
|
||||
"""
|
||||
_SESSIONTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Movie._loadData(self, data)
|
||||
PlexSession._loadData(self, data)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class EpisodeSession(PlexSession, Episode):
|
||||
""" Represents a single Episode session
|
||||
loaded from :func:`~plexapi.server.PlexServer.sessions`.
|
||||
"""
|
||||
_SESSIONTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Episode._loadData(self, data)
|
||||
PlexSession._loadData(self, data)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class ClipSession(PlexSession, Clip):
|
||||
""" Represents a single Clip session
|
||||
loaded from :func:`~plexapi.server.PlexServer.sessions`.
|
||||
"""
|
||||
_SESSIONTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Clip._loadData(self, data)
|
||||
PlexSession._loadData(self, data)
|
||||
|
|
|
@ -27,7 +27,7 @@ MarkupSafe==2.1.1
|
|||
musicbrainzngs==0.7.1
|
||||
packaging==21.3
|
||||
paho-mqtt==1.6.1
|
||||
plexapi==4.12.1
|
||||
plexapi==4.13.1
|
||||
portend==3.1.0
|
||||
profilehooks==1.12.0
|
||||
PyJWT==2.4.0
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue