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:
dependabot[bot] 2022-11-12 17:29:35 -08:00 committed by GitHub
parent 3af08f0d07
commit e79da07973
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1791 additions and 724 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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