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 urllib.parse import quote_plus
from plexapi import media, utils 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.exceptions import BadRequest, NotFound
from plexapi.mixins import ( from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin, TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin,
@ -15,7 +15,7 @@ from plexapi.mixins import (
from plexapi.playlist import Playlist from plexapi.playlist import Playlist
class Audio(PlexPartialObject): class Audio(PlexPartialObject, PlayedUnplayedMixin):
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`, """ Base class for all audio objects including :class:`~plexapi.audio.Artist`,
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`. :class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
@ -46,7 +46,6 @@ class Audio(PlexPartialObject):
userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars). userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars).
viewCount (int): Count of times the item was played. viewCount (int): Count of times the item was played.
""" """
METADATA_TYPE = 'track' METADATA_TYPE = 'track'
def _loadData(self, data): def _loadData(self, data):
@ -121,7 +120,7 @@ class Audio(PlexPartialObject):
section = self._server.library.sectionByID(self.librarySectionID) 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.policy = Policy.create(limit)
sync_item.mediaSettings = MediaSettings.createMusic(bitrate) sync_item.mediaSettings = MediaSettings.createMusic(bitrate)
@ -146,6 +145,7 @@ class Artist(
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
countries (List<:class:`~plexapi.media.Country`>): List country objects. countries (List<:class:`~plexapi.media.Country`>): List country objects.
genres (List<:class:`~plexapi.media.Genre`>): List of genre 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>). key (str): API URL (/library/metadata/<ratingkey>).
labels (List<:class:`~plexapi.media.Label`>): List of label objects. labels (List<:class:`~plexapi.media.Label`>): List of label objects.
locations (List<str>): List of folder paths where the artist is found on disk. 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.collections = self.findItems(data, media.Collection)
self.countries = self.findItems(data, media.Country) self.countries = self.findItems(data, media.Country)
self.genres = self.findItems(data, media.Genre) self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.key = self.key.replace('/children', '') # FIX_BUG_50 self.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label) self.labels = self.findItems(data, media.Label)
self.locations = self.listAttrs(data, 'path', etag='Location') self.locations = self.listAttrs(data, 'path', etag='Location')
@ -180,13 +181,14 @@ class Artist(
Parameters: Parameters:
title (str): Title of the album to return. title (str): Title of the album to return.
""" """
key = f"/library/sections/{self.librarySectionID}/all?artist.id={self.ratingKey}&type=9" try:
return self.fetchItem(key, Album, title__iexact=title) 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): def albums(self, **kwargs):
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """ """ 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.section().search(libtype='album', filters={'artist.id': self.ratingKey}, **kwargs)
return self.fetchItems(key, Album, **kwargs)
def track(self, title=None, album=None, track=None): def track(self, title=None, album=None, track=None):
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title. """ Returns the :class:`~plexapi.audio.Track` that matches the specified title.
@ -199,7 +201,7 @@ class Artist(
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing. :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: if title is not None:
return self.fetchItem(key, Track, title__iexact=title) return self.fetchItem(key, Track, title__iexact=title)
elif album is not None and track is not None: elif album is not None and track is not None:
@ -208,7 +210,7 @@ class Artist(
def tracks(self, **kwargs): def tracks(self, **kwargs):
""" Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """ """ 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) return self.fetchItems(key, Track, **kwargs)
def get(self, title=None, album=None, track=None): def get(self, title=None, album=None, track=None):
@ -233,7 +235,7 @@ class Artist(
def station(self): def station(self):
""" Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """ """ 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) 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. collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
formats (List<:class:`~plexapi.media.Format`>): List of format objects. formats (List<:class:`~plexapi.media.Format`>): List of format objects.
genres (List<:class:`~plexapi.media.Genre`>): List of genre 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>). key (str): API URL (/library/metadata/<ratingkey>).
labels (List<:class:`~plexapi.media.Label`>): List of label objects. labels (List<:class:`~plexapi.media.Label`>): List of label objects.
leafCount (int): Number of items in the album view. leafCount (int): Number of items in the album view.
@ -280,6 +283,7 @@ class Album(
self.collections = self.findItems(data, media.Collection) self.collections = self.findItems(data, media.Collection)
self.formats = self.findItems(data, media.Format) self.formats = self.findItems(data, media.Format)
self.genres = self.findItems(data, media.Genre) self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.key = self.key.replace('/children', '') # FIX_BUG_50 self.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label) self.labels = self.findItems(data, media.Label)
self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
@ -312,7 +316,7 @@ class Album(
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing. :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): if title is not None and not isinstance(title, int):
return self.fetchItem(key, Track, title__iexact=title) return self.fetchItem(key, Track, title__iexact=title)
elif track is not None or isinstance(title, int): elif track is not None or isinstance(title, int):
@ -325,7 +329,7 @@ class Album(
def tracks(self, **kwargs): def tracks(self, **kwargs):
""" Returns a list of :class:`~plexapi.audio.Track` objects in the album. """ """ 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) return self.fetchItems(key, Track, **kwargs)
def get(self, title=None, track=None): def get(self, title=None, track=None):
@ -352,7 +356,7 @@ class Album(
def _defaultSyncTitle(self): def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """ """ Returns str, default title for a new syncItem. """
return '%s - %s' % (self.parentTitle, self.title) return f'{self.parentTitle} - {self.title}'
@utils.registerPlexObject @utils.registerPlexObject
@ -380,6 +384,7 @@ class Track(
grandparentThumb (str): URL to album artist thumbnail image grandparentThumb (str): URL to album artist thumbnail image
(/library/metadata/<grandparentRatingKey>/thumb/<thumbid>). (/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
grandparentTitle (str): Name of the album artist for the track. 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. labels (List<:class:`~plexapi.media.Label`>): List of label objects.
media (List<:class:`~plexapi.media.Media`>): List of media objects. media (List<:class:`~plexapi.media.Media`>): List of media objects.
originalTitle (str): The artist for the track. 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>). parentThumb (str): URL to album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the album for the track. parentTitle (str): Name of the album for the track.
primaryExtraKey (str) API URL for the primary extra for the track. primaryExtraKey (str) API URL for the primary extra for the track.
ratingCount (int): Number of 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. skipCount (int): Number of times the track has been skipped.
viewOffset (int): View offset in milliseconds. viewOffset (int): View offset in milliseconds.
year (int): Year the track was released. year (int): Year the track was released.
@ -412,6 +417,7 @@ class Track(
self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentTheme = data.attrib.get('grandparentTheme')
self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle') self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guids = self.findItems(data, media.Guid)
self.labels = self.findItems(data, media.Label) self.labels = self.findItems(data, media.Label)
self.media = self.findItems(data, media.Media) self.media = self.findItems(data, media.Media)
self.originalTitle = data.attrib.get('originalTitle') self.originalTitle = data.attrib.get('originalTitle')
@ -429,8 +435,7 @@ class Track(
def _prettyfilename(self): def _prettyfilename(self):
""" Returns a filename for use in download. """ """ Returns a filename for use in download. """
return '%s - %s - %s - %s' % ( return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}'
self.grandparentTitle, self.parentTitle, str(self.trackNumber).zfill(2), self.title)
def album(self): def album(self):
""" Return the track's :class:`~plexapi.audio.Album`. """ """ Return the track's :class:`~plexapi.audio.Album`. """
@ -457,8 +462,21 @@ class Track(
def _defaultSyncTitle(self): def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """ """ 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): def _getWebURL(self, base=None):
""" Get the Plex Web URL with the correct parameters. """ """ Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey) 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 -*- # -*- coding: utf-8 -*-
import re import re
import weakref import weakref
from urllib.parse import quote_plus, urlencode from urllib.parse import urlencode
from xml.etree import ElementTree from xml.etree import ElementTree
from plexapi import log, utils from plexapi import log, utils
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
USER_DONT_RELOAD_FOR_KEYS = set() USER_DONT_RELOAD_FOR_KEYS = set()
_DONT_RELOAD_FOR_KEYS = {'key', 'session'} _DONT_RELOAD_FOR_KEYS = {'key'}
_DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'}
OPERATORS = { OPERATORS = {
'exact': lambda v, q: v == q, 'exact': lambda v, q: v == q,
'iexact': lambda v, q: v.lower() == q.lower(), 'iexact': lambda v, q: v.lower() == q.lower(),
@ -58,15 +57,11 @@ class PlexObject:
self._details_key = self._buildDetailsKey() self._details_key = self._buildDetailsKey()
def __repr__(self): 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')) 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): 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') overwriteNone = self.__dict__.get('_overwriteNone')
# Don't overwrite an attr with None unless it's a private variable or overwrite None is True # 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: 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) return cls(self._server, elem, initpath, parent=self)
# cls is not specified, try looking it up in PLEXOBJECTS # cls is not specified, try looking it up in PLEXOBJECTS
etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type'))) 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)) ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
# log.debug('Building %s as %s', elem.tag, ecls.__name__) # log.debug('Building %s as %s', elem.tag, ecls.__name__)
if ecls is not None: if ecls is not None:
return ecls(self._server, elem, initpath) return ecls(self._server, elem, initpath)
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): def _buildItemOrNone(self, elem, cls=None, initpath=None):
""" Calls :func:`~plexapi.base.PlexObject._buildItem` but returns """ Calls :func:`~plexapi.base.PlexObject._buildItem` but returns
@ -170,17 +167,19 @@ class PlexObject:
if ekey is None: if ekey is None:
raise BadRequest('ekey was not provided') raise BadRequest('ekey was not provided')
if isinstance(ekey, int): if isinstance(ekey, int):
ekey = '/library/metadata/%s' % ekey ekey = f'/library/metadata/{ekey}'
data = self._server.query(ekey) data = self._server.query(ekey)
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) item = self.findItem(data, cls, ekey, **kwargs)
for elem in data:
if self._checkAttrs(elem, **kwargs): if item:
item = self._buildItem(elem, cls, ekey) librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
if librarySectionID: if librarySectionID:
item.librarySectionID = librarySectionID item.librarySectionID = librarySectionID
return item return item
clsname = cls.__name__ if cls else 'None' 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): 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 """ 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") 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: if ekey is None:
raise BadRequest('ekey was not provided') 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) items = self.findItems(data, cls, ekey, **kwargs)
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
@ -273,6 +273,25 @@ class PlexObject:
item.librarySectionID = librarySectionID item.librarySectionID = librarySectionID
return items 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): 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 """ 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 and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
@ -309,7 +328,7 @@ class PlexObject:
if rtag: if rtag:
data = next(utils.iterXMLBFS(data, rtag), []) data = next(utils.iterXMLBFS(data, rtag), [])
for elem in data: for elem in data:
kwargs['%s__exists' % attr] = True kwargs[f'{attr}__exists'] = True
if self._checkAttrs(elem, **kwargs): if self._checkAttrs(elem, **kwargs):
results.append(elem.attrib.get(attr)) results.append(elem.attrib.get(attr))
return results return results
@ -380,7 +399,7 @@ class PlexObject:
def _getAttrOperator(self, attr): def _getAttrOperator(self, attr):
for op, operator in OPERATORS.items(): for op, operator in OPERATORS.items():
if attr.endswith('__%s' % op): if attr.endswith(f'__{op}'):
attr = attr.rsplit('__', 1)[0] attr = attr.rsplit('__', 1)[0]
return attr, op, operator return attr, op, operator
# default to exact match # default to exact match
@ -468,16 +487,16 @@ class PlexPartialObject(PlexObject):
value = super(PlexPartialObject, self).__getattribute__(attr) value = super(PlexPartialObject, self).__getattribute__(attr)
# Check a few cases where we don't want to reload # Check a few cases where we don't want to reload
if attr in _DONT_RELOAD_FOR_KEYS: return value 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 in USER_DONT_RELOAD_FOR_KEYS: return value
if attr.startswith('_'): return value if attr.startswith('_'): return value
if value not in (None, []): return value if value not in (None, []): return value
if self.isFullObject(): return value if self.isFullObject(): return value
if isinstance(self, PlexSession): return value
if self._autoReload is False: return value if self._autoReload is False: return value
# Log the reload. # Log the reload.
clsname = self.__class__.__name__ clsname = self.__class__.__name__
title = self.__dict__.get('title', self.__dict__.get('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) log.debug("Reloading %s for attr '%s'", objname, attr)
# Reload and return the value # Reload and return the value
self._reload(_overwriteNone=False) self._reload(_overwriteNone=False)
@ -502,7 +521,7 @@ class PlexPartialObject(PlexObject):
* Generate intro video markers: Detects show intros, exposing the * Generate intro video markers: Detects show intros, exposing the
'Skip Intro' button in clients. '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) self._server.query(key, method=self._server._session.put)
def isFullObject(self): def isFullObject(self):
@ -528,8 +547,7 @@ class PlexPartialObject(PlexObject):
if 'type' not in kwargs: if 'type' not in kwargs:
kwargs['type'] = utils.searchType(self._searchType) kwargs['type'] = utils.searchType(self._searchType)
part = '/library/sections/%s/all%s' % (self.librarySectionID, part = f'/library/sections/{self.librarySectionID}/all{utils.joinArgs(kwargs)}'
utils.joinArgs(kwargs))
self._server.query(part, method=self._server._session.put) self._server.query(part, method=self._server._session.put)
return self return self
@ -608,7 +626,7 @@ class PlexPartialObject(PlexObject):
the refresh process is interrupted (the Server is turned off, internet the refresh process is interrupted (the Server is turned off, internet
connection dies, etc). connection dies, etc).
""" """
key = '%s/refresh' % self.key key = f'{self.key}/refresh'
self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
def section(self): def section(self):
@ -655,12 +673,6 @@ class Playable:
Albums which are all not playable. Albums which are all not playable.
Attributes: 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). viewedAt (datetime): Datetime item was last viewed (history).
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID. deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
@ -669,11 +681,6 @@ class Playable:
""" """
def _loadData(self, data): 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.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
self.accountID = utils.cast(int, data.attrib.get('accountID')) # history self.accountID = utils.cast(int, data.attrib.get('accountID')) # history
self.deviceID = utils.cast(int, data.attrib.get('deviceID')) # 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. :exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
""" """
if self.TYPE not in ('movie', 'episode', 'track', 'clip'): 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') mvb = params.get('maxVideoBitrate')
vr = params.get('videoResolution', '') vr = params.get('videoResolution', '')
params = { params = {
@ -710,8 +717,10 @@ class Playable:
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video' streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
# sort the keys since the randomness fucks with my tests.. # sort the keys since the randomness fucks with my tests..
sorted_params = sorted(params.items(), key=lambda val: val[0]) sorted_params = sorted(params.items(), key=lambda val: val[0])
return self._server.url('/%s/:/transcode/universal/start.m3u8?%s' % return self._server.url(
(streamtype, urlencode(sorted_params)), includeToken=True) f'/{streamtype}/:/transcode/universal/start.m3u8?{urlencode(sorted_params)}',
includeToken=True
)
def iterParts(self): def iterParts(self):
""" Iterates over the parts of this media item. """ """ Iterates over the parts of this media item. """
@ -751,7 +760,7 @@ class Playable:
for part in parts: for part in parts:
if not keep_original_name: if not keep_original_name:
filename = utils.cleanFilename('%s.%s' % (self._prettyfilename(), part.container)) filename = utils.cleanFilename(f'{self._prettyfilename()}.{part.container}')
else: else:
filename = part.file filename = part.file
@ -759,7 +768,7 @@ class Playable:
# So this seems to be a a lot slower but allows transcode. # So this seems to be a a lot slower but allows transcode.
download_url = self.getStreamURL(**kwargs) download_url = self.getStreamURL(**kwargs)
else: else:
download_url = self._server.url('%s?download=1' % part.key) download_url = self._server.url(f'{part.key}?download=1')
filepath = utils.download( filepath = utils.download(
download_url, download_url,
@ -774,24 +783,19 @@ class Playable:
return filepaths 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'): def updateProgress(self, time, state='stopped'):
""" Set the watched progress for this video. """ Set the watched progress for this video.
Note that setting the time to 0 will not work. Note that setting the time to 0 will not work.
Use `markWatched` or `markUnwatched` to achieve Use :func:`~plexapi.mixins.PlayedMixin.markPlayed` or
that goal. :func:`~plexapi.mixins.PlayedMixin.markUnplayed` to achieve
that goal.
Parameters: Parameters:
time (int): milliseconds watched time (int): milliseconds watched
state (string): state of the video, default 'stopped' state (string): state of the video, default 'stopped'
""" """
key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey, key = f'/:/progress?key={self.ratingKey}&identifier=com.plexapp.plugins.library&time={time}&state={state}'
time, state)
self._server.query(key) self._server.query(key)
self._reload(_overwriteNone=False) self._reload(_overwriteNone=False)
@ -808,12 +812,94 @@ class Playable:
durationStr = durationStr + str(duration) durationStr = durationStr + str(duration)
else: else:
durationStr = durationStr + str(self.duration) durationStr = durationStr + str(self.duration)
key = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s' key = (f'/:/timeline?ratingKey={self.ratingKey}&key={self.key}&'
key %= (self.ratingKey, self.key, time, state, durationStr) f'identifier=com.plexapp.plugins.library&time={int(time)}&state={state}{durationStr}')
self._server.query(key) self._server.query(key)
self._reload(_overwriteNone=False) 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): class MediaContainer(PlexObject):
""" Represents a single MediaContainer. """ Represents a single MediaContainer.

View file

@ -94,7 +94,7 @@ class PlexClient(PlexObject):
self._initpath = self.key self._initpath = self.key
data = self.query(self.key, timeout=timeout) data = self.query(self.key, timeout=timeout)
if not data: if not data:
raise NotFound("Client not found at %s" % self._baseurl) raise NotFound(f"Client not found at {self._baseurl}")
if self._clientIdentifier: if self._clientIdentifier:
client = next( client = next(
( (
@ -106,8 +106,7 @@ class PlexClient(PlexObject):
) )
if client is None: if client is None:
raise NotFound( raise NotFound(
"Client with identifier %s not found at %s" f"Client with identifier {self._clientIdentifier} not found at {self._baseurl}"
% (self._clientIdentifier, self._baseurl)
) )
else: else:
client = data[0] client = data[0]
@ -136,12 +135,15 @@ class PlexClient(PlexObject):
# Add this in next breaking release. # Add this in next breaking release.
# if self._initpath == 'status/sessions': # if self._initpath == 'status/sessions':
self.device = data.attrib.get('device') # session self.device = data.attrib.get('device') # session
self.profile = data.attrib.get('profile') # session
self.model = data.attrib.get('model') # session self.model = data.attrib.get('model') # session
self.state = data.attrib.get('state') # session self.state = data.attrib.get('state') # session
self.vendor = data.attrib.get('vendor') # session self.vendor = data.attrib.get('vendor') # session
self.version = data.attrib.get('version') # 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.address = data.attrib.get('address') # 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.remotePublicAddress = data.attrib.get('remotePublicAddress')
self.userID = data.attrib.get('userID') self.userID = data.attrib.get('userID')
@ -183,7 +185,7 @@ class PlexClient(PlexObject):
if response.status_code not in (200, 201, 204): if response.status_code not in (200, 201, 204):
codename = codes.get(response.status_code)[0] codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ') 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: if response.status_code == 401:
raise Unauthorized(message) raise Unauthorized(message)
elif response.status_code == 404: elif response.status_code == 404:
@ -210,8 +212,7 @@ class PlexClient(PlexObject):
controller = command.split('/')[0] controller = command.split('/')[0]
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier} headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
if controller not in self.protocolCapabilities: if controller not in self.protocolCapabilities:
log.debug("Client %s doesn't support %s controller." log.debug("Client %s doesn't support %s controller. What your trying might not work", self.title, controller)
"What your trying might not work" % (self.title, controller))
proxy = self._proxyThroughServer if proxy is None else proxy proxy = self._proxyThroughServer if proxy is None else proxy
query = self._server.query if proxy else self.query query = self._server.query if proxy else self.query
@ -225,7 +226,7 @@ class PlexClient(PlexObject):
self.sendCommand(ClientTimeline.key, wait=0) self.sendCommand(ClientTimeline.key, wait=0)
params['commandID'] = self._nextCommandId() params['commandID'] = self._nextCommandId()
key = '/player/%s%s' % (command, utils.joinArgs(params)) key = f'/player/{command}{utils.joinArgs(params)}'
try: try:
return query(key, headers=headers) return query(key, headers=headers)
@ -250,8 +251,8 @@ class PlexClient(PlexObject):
raise BadRequest('PlexClient object missing baseurl.') raise BadRequest('PlexClient object missing baseurl.')
if self._token and (includeToken or self._showSecrets): if self._token and (includeToken or self._showSecrets):
delim = '&' if '?' in key else '?' delim = '&' if '?' in key else '?'
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token) return f'{self._baseurl}{key}{delim}X-Plex-Token={self._token}'
return '%s%s' % (self._baseurl, key) return f'{self._baseurl}{key}'
# --------------------- # ---------------------
# Navigation Commands # Navigation Commands
@ -514,7 +515,7 @@ class PlexClient(PlexObject):
'offset': offset, 'offset': offset,
'key': media.key or playqueue.selectedItem.key, 'key': media.key or playqueue.selectedItem.key,
'type': mediatype, 'type': mediatype,
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID, 'containerKey': f'/playQueues/{playqueue.playQueueID}?window=100&own=1',
**params, **params,
} }
token = media._server.createToken() token = media._server.createToken()
@ -620,7 +621,7 @@ class ClientTimeline(PlexObject):
self.protocol = data.attrib.get('protocol') self.protocol = data.attrib.get('protocol')
self.providerIdentifier = data.attrib.get('providerIdentifier') self.providerIdentifier = data.attrib.get('providerIdentifier')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) 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.seekRange = data.attrib.get('seekRange')
self.shuffle = utils.cast(bool, data.attrib.get('shuffle')) self.shuffle = utils.cast(bool, data.attrib.get('shuffle'))
self.state = data.attrib.get('state') 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 import media, utils
from plexapi.base import PlexPartialObject from plexapi.base import PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection from plexapi.library import LibrarySection, ManagedHub
from plexapi.mixins import ( from plexapi.mixins import (
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin, AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin, ArtMixin, PosterMixin, ThemeMixin,
@ -184,16 +184,28 @@ class Collection(
for item in self.items(): for item in self.items():
if item.title.lower() == title.lower(): if item.title.lower() == title.lower():
return item 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): def items(self):
""" Returns a list of all items in the collection. """ """ Returns a list of all items in the collection. """
if self._items is None: if self._items is None:
key = '%s/children' % self.key key = f'{self.key}/children'
items = self.fetchItems(key) items = self.fetchItems(key)
self._items = items self._items = items
return self._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): def get(self, title):
""" Alias to :func:`~plexapi.library.Collection.item`. """ """ Alias to :func:`~plexapi.library.Collection.item`. """
return self.item(title) return self.item(title)
@ -221,8 +233,8 @@ class Collection(
} }
key = user_dict.get(user) key = user_dict.get(user)
if key is None: if key is None:
raise BadRequest('Unknown collection filtering user: %s. Options %s' % (user, list(user_dict))) raise BadRequest(f'Unknown collection filtering user: {user}. Options {list(user_dict)}')
self.editAdvanced(collectionFilterBasedOnUser=key) return self.editAdvanced(collectionFilterBasedOnUser=key)
def modeUpdate(self, mode=None): def modeUpdate(self, mode=None):
""" Update the collection mode advanced setting. """ Update the collection mode advanced setting.
@ -248,8 +260,8 @@ class Collection(
} }
key = mode_dict.get(mode) key = mode_dict.get(mode)
if key is None: if key is None:
raise BadRequest('Unknown collection mode: %s. Options %s' % (mode, list(mode_dict))) raise BadRequest(f'Unknown collection mode: {mode}. Options {list(mode_dict)}')
self.editAdvanced(collectionMode=key) return self.editAdvanced(collectionMode=key)
def sortUpdate(self, sort=None): def sortUpdate(self, sort=None):
""" Update the collection order advanced setting. """ Update the collection order advanced setting.
@ -276,8 +288,8 @@ class Collection(
} }
key = sort_dict.get(sort) key = sort_dict.get(sort)
if key is None: if key is None:
raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) raise BadRequest(f'Unknown sort dir: {sort}. Options: {list(sort_dict)}')
self.editAdvanced(collectionSort=key) return self.editAdvanced(collectionSort=key)
def addItems(self, items): def addItems(self, items):
""" Add items to the collection. """ Add items to the collection.
@ -298,17 +310,16 @@ class Collection(
ratingKeys = [] ratingKeys = []
for item in items: for item in items:
if item.type != self.subtype: # pragma: no cover if item.type != self.subtype: # pragma: no cover
raise BadRequest('Can not mix media types when building a collection: %s and %s' % raise BadRequest(f'Can not mix media types when building a collection: {self.subtype} and {item.type}')
(self.subtype, item.type))
ratingKeys.append(str(item.ratingKey)) ratingKeys.append(str(item.ratingKey))
ratingKeys = ','.join(ratingKeys) 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({ args = {'uri': uri}
'uri': uri key = f"{self.key}/items{utils.joinArgs(args)}"
}))
self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
return self
def removeItems(self, items): def removeItems(self, items):
""" Remove items from the collection. """ Remove items from the collection.
@ -327,17 +338,18 @@ class Collection(
items = [items] items = [items]
for item in 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) self._server.query(key, method=self._server._session.delete)
return self
def moveItem(self, item, after=None): def moveItem(self, item, after=None):
""" Move an item to a new position in the collection. """ Move an item to a new position in the collection.
Parameters: Parameters:
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, item (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to be moved in the collection. or :class:`~plexapi.photo.Photo` object to be moved in the collection.
after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, 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: Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart collection. :class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart collection.
@ -345,12 +357,13 @@ class Collection(
if self.smart: if self.smart:
raise BadRequest('Cannot move items in a smart collection.') 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: if after:
key += '?after=%s' % after.ratingKey key += f'?after={after.ratingKey}'
self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
return self
def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs): def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs):
""" Update the filters for a smart collection. """ Update the filters for a smart collection.
@ -376,12 +389,12 @@ class Collection(
section = self.section() section = self.section()
searchKey = section._buildSearchKey( searchKey = section._buildSearchKey(
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) 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({ args = {'uri': uri}
'uri': uri key = f"{self.key}/items{utils.joinArgs(args)}"
}))
self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
return self
@deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead') @deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead')
def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs): def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs):
@ -438,15 +451,10 @@ class Collection(
ratingKeys.append(str(item.ratingKey)) ratingKeys.append(str(item.ratingKey))
ratingKeys = ','.join(ratingKeys) ratingKeys = ','.join(ratingKeys)
uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys) uri = f'{server._uriRoot()}/library/metadata/{ratingKeys}'
key = '/library/collections%s' % utils.joinArgs({ args = {'uri': uri, 'type': utils.searchType(itemType), 'title': title, 'smart': 0, 'sectionId': section.key}
'uri': uri, key = f"/library/collections{utils.joinArgs(args)}"
'type': utils.searchType(itemType),
'title': title,
'smart': 0,
'sectionId': section.key
})
data = server.query(key, method=server._session.post)[0] data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key) return cls(server, data, initpath=key)
@ -460,15 +468,10 @@ class Collection(
searchKey = section._buildSearchKey( searchKey = section._buildSearchKey(
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) 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({ args = {'uri': uri, 'type': utils.searchType(libtype), 'title': title, 'smart': 1, 'sectionId': section.key}
'uri': uri, key = f"/library/collections{utils.joinArgs(args)}"
'type': utils.searchType(libtype),
'title': title,
'smart': 1,
'sectionId': section.key
})
data = server.query(key, method=server._session.post)[0] data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key) return cls(server, data, initpath=key)
@ -547,9 +550,8 @@ class Collection(
sync_item.metadataType = self.metadataType sync_item.metadataType = self.metadataType
sync_item.machineIdentifier = self._server.machineIdentifier sync_item.machineIdentifier = self._server.machineIdentifier
sync_item.location = 'library:///directory/%s' % quote_plus( key = quote_plus(f'{self.key}/children?excludeAllLeaves=1')
'%s/children?excludeAllLeaves=1' % (self.key) sync_item.location = f'library:///directory/{key}'
)
sync_item.policy = Policy.create(limit, unwatched) sync_item.policy = Policy.create(limit, unwatched)
if self.isVideo: if self.isVideo:

View file

@ -29,7 +29,7 @@ class PlexConfig(ConfigParser):
""" """
try: try:
# First: check environment variable is set # First: check environment variable is set
envkey = 'PLEXAPI_%s' % key.upper().replace('.', '_') envkey = f"PLEXAPI_{key.upper().replace('.', '_')}"
value = os.environ.get(envkey) value = os.environ.get(envkey)
if value is None: if value is None:
# Second: check the config file has attr # Second: check the config file has attr

View file

@ -3,7 +3,7 @@
# Library version # Library version
MAJOR_VERSION = 4 MAJOR_VERSION = 4
MINOR_VERSION = 11 MINOR_VERSION = 13
PATCH_VERSION = 2 PATCH_VERSION = 1
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}"

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.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming'))
self.parts = self.findItems(data, MediaPart) self.parts = self.findItems(data, MediaPart)
self.proxyType = utils.cast(int, data.attrib.get('proxyType')) 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.target = data.attrib.get('target')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.videoCodec = data.attrib.get('videoCodec') self.videoCodec = data.attrib.get('videoCodec')
@ -71,6 +72,7 @@ class Media(PlexObject):
self.videoProfile = data.attrib.get('videoProfile') self.videoProfile = data.attrib.get('videoProfile')
self.videoResolution = data.attrib.get('videoResolution') self.videoResolution = data.attrib.get('videoResolution')
self.width = utils.cast(int, data.attrib.get('width')) self.width = utils.cast(int, data.attrib.get('width'))
self.uuid = data.attrib.get('uuid')
if self._isChildOf(etag='Photo'): if self._isChildOf(etag='Photo'):
self.aperture = data.attrib.get('aperture') self.aperture = data.attrib.get('aperture')
@ -89,12 +91,11 @@ class Media(PlexObject):
return self.proxyType == utils.SEARCHTYPES['optimizedVersion'] return self.proxyType == utils.SEARCHTYPES['optimizedVersion']
def delete(self): def delete(self):
part = '%s/media/%s' % (self._parentKey, self.id) part = f'{self._parentKey}/media/{self.id}'
try: try:
return self._server.query(part, method=self._server._session.delete) return self._server.query(part, method=self._server._session.delete)
except BadRequest: except BadRequest:
log.error("Failed to delete %s. This could be because you haven't allowed " log.error("Failed to delete %s. This could be because you haven't allowed items to be deleted", part)
"items to be deleted" % part)
raise raise
@ -146,7 +147,9 @@ class MediaPart(PlexObject):
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming'))
self.packetLength = utils.cast(int, data.attrib.get('packetLength')) self.packetLength = utils.cast(int, data.attrib.get('packetLength'))
self.protocol = data.attrib.get('protocol')
self.requiredBandwidths = data.attrib.get('requiredBandwidths') 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.size = utils.cast(int, data.attrib.get('size'))
self.streams = self._buildStreams(data) self.streams = self._buildStreams(data)
self.syncItemId = utils.cast(int, data.attrib.get('syncItemId')) 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 stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default
""" """
if isinstance(stream, AudioStream): 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: 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) self._server.query(key, method=self._server._session.put)
return self
def setDefaultSubtitleStream(self, stream): def setDefaultSubtitleStream(self, stream):
""" Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart. """ 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. stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default.
""" """
if isinstance(stream, SubtitleStream): 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: 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) self._server.query(key, method=self._server._session.put)
return self
def resetDefaultSubtitleStream(self): def resetDefaultSubtitleStream(self):
""" Set default subtitle of this MediaPart to 'none'. """ """ 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) self._server.query(key, method=self._server._session.put)
return self
class MediaPartStream(PlexObject): class MediaPartStream(PlexObject):
@ -225,6 +231,7 @@ class MediaPartStream(PlexObject):
index (int): The index of the stream. index (int): The index of the stream.
language (str): The language of the stream (ex: English, ไทย). language (str): The language of the stream (ex: English, ไทย).
languageCode (str): The ASCII language code of the stream (ex: eng, tha). 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. requiredBandwidths (str): The required bandwidths to stream the file.
selected (bool): True if this stream is selected. selected (bool): True if this stream is selected.
streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`, streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`,
@ -238,14 +245,17 @@ class MediaPartStream(PlexObject):
self._data = data self._data = data
self.bitrate = utils.cast(int, data.attrib.get('bitrate')) self.bitrate = utils.cast(int, data.attrib.get('bitrate'))
self.codec = data.attrib.get('codec') self.codec = data.attrib.get('codec')
self.decision = data.attrib.get('decision')
self.default = utils.cast(bool, data.attrib.get('default')) self.default = utils.cast(bool, data.attrib.get('default'))
self.displayTitle = data.attrib.get('displayTitle') self.displayTitle = data.attrib.get('displayTitle')
self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle') self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
self.key = data.attrib.get('key')
self.id = utils.cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
self.index = utils.cast(int, data.attrib.get('index', '-1')) self.index = utils.cast(int, data.attrib.get('index', '-1'))
self.key = data.attrib.get('key')
self.language = data.attrib.get('language') self.language = data.attrib.get('language')
self.languageCode = data.attrib.get('languageCode') 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.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.selected = utils.cast(bool, data.attrib.get('selected', '0')) self.selected = utils.cast(bool, data.attrib.get('selected', '0'))
self.streamType = utils.cast(int, data.attrib.get('streamType')) 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 """ Returns a list of all :class:`~plexapi.media.Video` objects
in this optimized item. in this optimized item.
""" """
key = '%s/%s/items' % (self._initpath, self.id) key = f'{self._initpath}/{self.id}/items'
return self.fetchItems(key) return self.fetchItems(key)
def remove(self): def remove(self):
""" Remove an Optimized item""" """ 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) self._server.query(key, method=self._server._session.delete)
def rename(self, title): def rename(self, title):
""" Rename an Optimized item""" """ 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) self._server.query(key, method=self._server._session.put)
def reprocess(self, ratingKey): def reprocess(self, ratingKey):
""" Reprocess a removed Conversion item that is still a listed Optimize item""" """ 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) self._server.query(key, method=self._server._session.put)
@ -631,7 +641,7 @@ class Conversion(PlexObject):
def remove(self): def remove(self):
""" Remove Conversion from queue """ """ 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) self._server.query(key, method=self._server._session.put)
def move(self, after): def move(self, after):
@ -646,7 +656,7 @@ class Conversion(PlexObject):
conversions[3].move(conversions[1].playQueueItemID) 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) 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. 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): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data self._data = data
@ -682,14 +696,12 @@ class MediaTag(PlexObject):
self._parentType = parent.TYPE self._parentType = parent.TYPE
if self._librarySectionKey and self.filter: if self._librarySectionKey and self.filter:
self.key = '%s/all?%s&type=%s' % ( self.key = f'{self._librarySectionKey}/all?{self.filter}&type={utils.searchType(self._parentType)}'
self._librarySectionKey, self.filter, utils.searchType(self._parentType))
def items(self): def items(self):
""" Return the list of items within this tag. """ """ Return the list of items within this tag. """
if not self.key: if not self.key:
raise BadRequest('Key is not defined for this tag: %s. ' raise BadRequest(f'Key is not defined for this tag: {self.tag}. Reload the parent object.')
'Reload the parent object.' % self.tag)
return self.fetchItems(self.key) return self.fetchItems(self.key)
@ -707,7 +719,7 @@ class Collection(MediaTag):
def collection(self): def collection(self):
""" Return the :class:`~plexapi.collection.Collection` object for this collection tag. """ 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) 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 """ Base class for guid tags used only for Guids, as they contain only a string identifier
Attributes: 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): def _loadData(self, data):
@ -938,7 +950,7 @@ class BaseResource(PlexObject):
def select(self): def select(self):
key = self._initpath[:-1] key = self._initpath[:-1]
data = '%s?url=%s' % (key, quote_plus(self.ratingKey)) data = f'{key}?url={quote_plus(self.ratingKey)}'
try: try:
self._server.query(data, method=self._server._session.put) self._server.query(data, method=self._server._session.put)
except xml.etree.ElementTree.ParseError: except xml.etree.ElementTree.ParseError:
@ -967,7 +979,7 @@ class Theme(BaseResource):
@utils.registerPlexObject @utils.registerPlexObject
class Chapter(PlexObject): class Chapter(PlexObject):
""" Represents a single Writer media tag. """ Represents a single Chapter media tag.
Attributes: Attributes:
TAG (str): 'Chapter' TAG (str): 'Chapter'
@ -998,8 +1010,8 @@ class Marker(PlexObject):
name = self._clean(self.firstAttr('type')) name = self._clean(self.firstAttr('type'))
start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start'))) start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start')))
end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end'))) end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end')))
offsets = '%s-%s' % (start, end) offsets = f'{start}-{end}'
return '<%s>' % ':'.join([self.__class__.__name__, name, offsets]) return f"<{':'.join([self.__class__.__name__, name, offsets])}>"
def _loadData(self, data): def _loadData(self, data):
self._data = data self._data = data
@ -1036,7 +1048,7 @@ class SearchResult(PlexObject):
def __repr__(self): def __repr__(self):
name = self._clean(self.firstAttr('name')) name = self._clean(self.firstAttr('name'))
score = self._clean(self.firstAttr('score')) 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): def _loadData(self, data):
self._data = data self._data = data
@ -1058,7 +1070,7 @@ class Agent(PlexObject):
def __repr__(self): def __repr__(self):
uid = self._clean(self.firstAttr('shortIdentifier')) 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): def _loadData(self, data):
self._data = data self._data = data
@ -1082,7 +1094,7 @@ class Agent(PlexObject):
return self.languageCodes return self.languageCodes
def settings(self): def settings(self):
key = '/:/plugins/%s/prefs' % self.identifier key = f'/:/plugins/{self.identifier}/prefs'
data = self._server.query(key) data = self._server.query(key)
return self.findItems(data, cls=settings.Setting) return self.findItems(data, cls=settings.Setting)
@ -1101,7 +1113,7 @@ class AgentMediaType(Agent):
def __repr__(self): def __repr__(self):
uid = self._clean(self.firstAttr('name')) 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): def _loadData(self, data):
self.languageCodes = self.listAttrs(data, 'code', etag='Language') 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) return next(p for p in prefs if p.id == pref)
except StopIteration: except StopIteration:
availablePrefs = [p.id for p in prefs] availablePrefs = [p.id for p in prefs]
raise NotFound('Unknown preference "%s" for %s. ' raise NotFound(f'Unknown preference "{pref}" for {self.TYPE}. '
'Available preferences: %s' f'Available preferences: {availablePrefs}') from None
% (pref, self.TYPE, availablePrefs)) from None
def editAdvanced(self, **kwargs): def editAdvanced(self, **kwargs):
""" Edit a Plex object's advanced settings. """ """ Edit a Plex object's advanced settings. """
data = {} data = {}
key = '%s/prefs?' % self.key key = f'{self.key}/prefs?'
preferences = {pref.id: pref for pref in self.preferences() if pref.enumValues} preferences = {pref.id: pref for pref in self.preferences() if pref.enumValues}
for settingID, value in kwargs.items(): for settingID, value in kwargs.items():
try: try:
pref = preferences[settingID] pref = preferences[settingID]
except KeyError: 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 enumValues = pref.enumValues
if enumValues.get(value, enumValues.get(str(value))): if enumValues.get(value, enumValues.get(str(value))):
data[settingID] = value data[settingID] = value
else: else:
raise NotFound('%s not found in %s' % (value, list(enumValues))) raise NotFound(f'{value} not found in {list(enumValues)}')
url = key + urlencode(data) url = key + urlencode(data)
self._server.query(url, method=self._server._session.put) self._server.query(url, method=self._server._session.put)
return self
def defaultAdvanced(self): def defaultAdvanced(self):
""" Edit all of a Plex object's advanced settings to default. """ """ Edit all of a Plex object's advanced settings to default. """
data = {} data = {}
key = '%s/prefs?' % self.key key = f'{self.key}/prefs?'
for preference in self.preferences(): for preference in self.preferences():
data[preference.id] = preference.default data[preference.id] = preference.default
url = key + urlencode(data) url = key + urlencode(data)
self._server.query(url, method=self._server._session.put) self._server.query(url, method=self._server._session.put)
return self
class SmartFilterMixin: class SmartFilterMixin:
@ -125,8 +126,9 @@ class SplitMergeMixin:
def split(self): def split(self):
""" Split duplicated Plex object into separate objects. """ """ Split duplicated Plex object into separate objects. """
key = '/library/metadata/%s/split' % self.ratingKey key = f'{self.key}/split'
return self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
return self
def merge(self, ratingKeys): def merge(self, ratingKeys):
""" Merge other Plex objects into the current object. """ Merge other Plex objects into the current object.
@ -137,8 +139,9 @@ class SplitMergeMixin:
if not isinstance(ratingKeys, list): if not isinstance(ratingKeys, list):
ratingKeys = str(ratingKeys).split(',') ratingKeys = str(ratingKeys).split(',')
key = '%s/merge?ids=%s' % (self.key, ','.join([str(r) for r in ratingKeys])) key = f"{self.key}/merge?ids={','.join([str(r) for r in ratingKeys])}"
return self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
return self
class UnmatchMatchMixin: class UnmatchMatchMixin:
@ -146,7 +149,7 @@ class UnmatchMatchMixin:
def unmatch(self): def unmatch(self):
""" Unmatches metadata match from object. """ """ 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) self._server.query(key, method=self._server._session.put)
def matches(self, agent=None, title=None, year=None, language=None): 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 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} params = {'manual': 1}
if agent and not any([title, year, language]): if agent and not any([title, year, language]):
@ -191,7 +194,7 @@ class UnmatchMatchMixin:
params['title'] = title params['title'] = title
if year is None: if year is None:
params['year'] = self.year params['year'] = getattr(self, 'year', '')
else: else:
params['year'] = year params['year'] = year
@ -216,13 +219,13 @@ class UnmatchMatchMixin:
~plexapi.base.matches() ~plexapi.base.matches()
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) 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: if auto:
autoMatch = self.matches(agent=agent) autoMatch = self.matches(agent=agent)
if autoMatch: if autoMatch:
searchResult = autoMatch[0] searchResult = autoMatch[0]
else: 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: elif not searchResult:
raise NotFound('fixMatch() requires either auto=True or ' raise NotFound('fixMatch() requires either auto=True or '
'searchResult=:class:`~plexapi.media.SearchResult`.') 'searchResult=:class:`~plexapi.media.SearchResult`.')
@ -232,6 +235,7 @@ class UnmatchMatchMixin:
data = key + '?' + urlencode(params) data = key + '?' + urlencode(params)
self._server.query(data, method=self._server._session.put) self._server.query(data, method=self._server._session.put)
return self
class ExtrasMixin: class ExtrasMixin:
@ -250,8 +254,48 @@ class HubsMixin:
def hubs(self): def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """ """ Returns a list of :class:`~plexapi.library.Hub` objects. """
from plexapi.library import Hub from plexapi.library import Hub
data = self._server.query(self._details_key) key = f'{self.key}/related'
return self.findItems(data, Hub, rtag='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: class RatingMixin:
@ -270,8 +314,9 @@ class RatingMixin:
rating = -1 rating = -1
elif not isinstance(rating, (int, float)) or rating < 0 or rating > 10: elif not isinstance(rating, (int, float)) or rating < 0 or rating > 10:
raise BadRequest('Rating must be between 0 to 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) self._server.query(key, method=self._server._session.put)
return self
class ArtUrlMixin: class ArtUrlMixin:
@ -289,7 +334,7 @@ class ArtMixin(ArtUrlMixin):
def arts(self): def arts(self):
""" Returns list of available :class:`~plexapi.media.Art` objects. """ """ 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): def uploadArt(self, url=None, filepath=None):
""" Upload a background artwork from a url or filepath. """ 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. filepath (str): The full file path the the image to upload.
""" """
if url: 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) self._server.query(key, method=self._server._session.post)
elif filepath: elif filepath:
key = '/library/metadata/%s/arts?' % self.ratingKey key = f'/library/metadata/{self.ratingKey}/arts'
data = open(filepath, 'rb').read() data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data) self._server.query(key, method=self._server._session.post, data=data)
return self
def setArt(self, art): def setArt(self, art):
""" Set the background artwork for a Plex object. """ 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 (:class:`~plexapi.media.Art`): The art object to select.
""" """
art.select() art.select()
return self
def lockArt(self): def lockArt(self):
""" Lock the background artwork for a Plex object. """ """ Lock the background artwork for a Plex object. """
@ -338,7 +385,7 @@ class BannerMixin(BannerUrlMixin):
def banners(self): def banners(self):
""" Returns list of available :class:`~plexapi.media.Banner` objects. """ """ 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): def uploadBanner(self, url=None, filepath=None):
""" Upload a banner from a url or filepath. """ 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. filepath (str): The full file path the the image to upload.
""" """
if url: 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) self._server.query(key, method=self._server._session.post)
elif filepath: elif filepath:
key = '/library/metadata/%s/banners?' % self.ratingKey key = f'/library/metadata/{self.ratingKey}/banners'
data = open(filepath, 'rb').read() data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data) self._server.query(key, method=self._server._session.post, data=data)
return self
def setBanner(self, banner): def setBanner(self, banner):
""" Set the banner for a Plex object. """ 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 (:class:`~plexapi.media.Banner`): The banner object to select.
""" """
banner.select() banner.select()
return self
def lockBanner(self): def lockBanner(self):
""" Lock the banner for a Plex object. """ """ Lock the banner for a Plex object. """
@ -392,7 +441,7 @@ class PosterMixin(PosterUrlMixin):
def posters(self): def posters(self):
""" Returns list of available :class:`~plexapi.media.Poster` objects. """ """ 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): def uploadPoster(self, url=None, filepath=None):
""" Upload a poster from a url or filepath. """ 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. filepath (str): The full file path the the image to upload.
""" """
if url: 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) self._server.query(key, method=self._server._session.post)
elif filepath: elif filepath:
key = '/library/metadata/%s/posters?' % self.ratingKey key = f'/library/metadata/{self.ratingKey}/posters'
data = open(filepath, 'rb').read() data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data) self._server.query(key, method=self._server._session.post, data=data)
return self
def setPoster(self, poster): def setPoster(self, poster):
""" Set the poster for a Plex object. """ 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 (:class:`~plexapi.media.Poster`): The poster object to select.
""" """
poster.select() poster.select()
return self
def lockPoster(self): def lockPoster(self):
""" Lock the poster for a Plex object. """ """ Lock the poster for a Plex object. """
@ -441,7 +492,7 @@ class ThemeMixin(ThemeUrlMixin):
def themes(self): def themes(self):
""" Returns list of available :class:`~plexapi.media.Theme` objects. """ """ 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): def uploadTheme(self, url=None, filepath=None):
""" Upload a theme from url or filepath. """ 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. filepath (str): The full file path to the theme to upload.
""" """
if url: 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) self._server.query(key, method=self._server._session.post)
elif filepath: elif filepath:
key = '/library/metadata/%s/themes?' % self.ratingKey key = f'/library/metadata/{self.ratingKey}/themes'
data = open(filepath, 'rb').read() data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data) self._server.query(key, method=self._server._session.post, data=data)
return self
def setTheme(self, theme): def setTheme(self, theme):
raise NotImplementedError( raise NotImplementedError(
@ -468,11 +520,11 @@ class ThemeMixin(ThemeUrlMixin):
def lockTheme(self): def lockTheme(self):
""" Lock the theme for a Plex object. """ """ Lock the theme for a Plex object. """
self._edit(**{'theme.locked': 1}) return self._edit(**{'theme.locked': 1})
def unlockTheme(self): def unlockTheme(self):
""" Unlock the theme for a Plex object. """ """ Unlock the theme for a Plex object. """
self._edit(**{'theme.locked': 0}) return self._edit(**{'theme.locked': 0})
class EditFieldMixin: class EditFieldMixin:
@ -496,8 +548,8 @@ class EditFieldMixin:
""" """
edits = { edits = {
'%s.value' % field: value or '', f'{field}.value': value or '',
'%s.locked' % field: 1 if locked else 0 f'{field}.locked': 1 if locked else 0
} }
edits.update(kwargs) edits.update(kwargs)
return self._edit(**edits) return self._edit(**edits)
@ -516,6 +568,19 @@ class ContentRatingMixin(EditFieldMixin):
return self.editField('contentRating', contentRating, locked=locked) 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): class OriginallyAvailableMixin(EditFieldMixin):
""" Mixin for Plex objects that can have an originally available date. """ """ Mixin for Plex objects that can have an originally available date. """
@ -680,7 +745,7 @@ class EditTagsMixin:
Parameters: Parameters:
tag (str): Name of the tag to edit. 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. locked (bool): True (default) to lock the tags, False to unlock the tags.
remove (bool): True to remove the tags in items. remove (bool): True to remove the tags in items.
@ -695,9 +760,11 @@ class EditTagsMixin:
if not isinstance(items, list): if not isinstance(items, list):
items = [items] items = [items]
value = getattr(self, self._tagPlural(tag)) if not remove:
existing_tags = [t.tag for t in value if t and remove is False] tags = getattr(self, self._tagPlural(tag))
edits = self._tagHelper(self._tagSingular(tag), existing_tags + items, locked, remove) items = tags + items
edits = self._tagHelper(self._tagSingular(tag), items, locked, remove)
edits.update(kwargs) edits.update(kwargs)
return self._edit(**edits) return self._edit(**edits)
@ -730,15 +797,15 @@ class EditTagsMixin:
items = [items] items = [items]
data = { data = {
'%s.locked' % tag: 1 if locked else 0 f'{tag}.locked': 1 if locked else 0
} }
if remove: if remove:
tagname = '%s[].tag.tag-' % tag tagname = f'{tag}[].tag.tag-'
data[tagname] = ','.join(items) data[tagname] = ','.join([str(t) for t in items])
else: else:
for i, item in enumerate(items): for i, item in enumerate(items):
tagname = '%s[%s].tag.tag' % (tag, i) tagname = f'{str(tag)}[{i}].tag.tag'
data[tagname] = item data[tagname] = item
return data return data
@ -751,7 +818,7 @@ class CollectionMixin(EditTagsMixin):
""" Add a collection tag(s). """ Add a collection tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('collection', collections, locked=locked) return self.editTags('collection', collections, locked=locked)
@ -760,7 +827,7 @@ class CollectionMixin(EditTagsMixin):
""" Remove a collection tag(s). """ Remove a collection tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('collection', collections, locked=locked, remove=True) return self.editTags('collection', collections, locked=locked, remove=True)
@ -773,7 +840,7 @@ class CountryMixin(EditTagsMixin):
""" Add a country tag(s). """ Add a country tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('country', countries, locked=locked) return self.editTags('country', countries, locked=locked)
@ -782,7 +849,7 @@ class CountryMixin(EditTagsMixin):
""" Remove a country tag(s). """ Remove a country tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('country', countries, locked=locked, remove=True) return self.editTags('country', countries, locked=locked, remove=True)
@ -795,7 +862,7 @@ class DirectorMixin(EditTagsMixin):
""" Add a director tag(s). """ Add a director tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('director', directors, locked=locked) return self.editTags('director', directors, locked=locked)
@ -804,7 +871,7 @@ class DirectorMixin(EditTagsMixin):
""" Remove a director tag(s). """ Remove a director tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('director', directors, locked=locked, remove=True) return self.editTags('director', directors, locked=locked, remove=True)
@ -817,7 +884,7 @@ class GenreMixin(EditTagsMixin):
""" Add a genre tag(s). """ Add a genre tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('genre', genres, locked=locked) return self.editTags('genre', genres, locked=locked)
@ -826,7 +893,7 @@ class GenreMixin(EditTagsMixin):
""" Remove a genre tag(s). """ Remove a genre tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('genre', genres, locked=locked, remove=True) return self.editTags('genre', genres, locked=locked, remove=True)
@ -839,7 +906,7 @@ class LabelMixin(EditTagsMixin):
""" Add a label tag(s). """ Add a label tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('label', labels, locked=locked) return self.editTags('label', labels, locked=locked)
@ -848,7 +915,7 @@ class LabelMixin(EditTagsMixin):
""" Remove a label tag(s). """ Remove a label tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('label', labels, locked=locked, remove=True) return self.editTags('label', labels, locked=locked, remove=True)
@ -861,7 +928,7 @@ class MoodMixin(EditTagsMixin):
""" Add a mood tag(s). """ Add a mood tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('mood', moods, locked=locked) return self.editTags('mood', moods, locked=locked)
@ -870,7 +937,7 @@ class MoodMixin(EditTagsMixin):
""" Remove a mood tag(s). """ Remove a mood tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('mood', moods, locked=locked, remove=True) return self.editTags('mood', moods, locked=locked, remove=True)
@ -883,7 +950,7 @@ class ProducerMixin(EditTagsMixin):
""" Add a producer tag(s). """ Add a producer tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('producer', producers, locked=locked) return self.editTags('producer', producers, locked=locked)
@ -892,7 +959,7 @@ class ProducerMixin(EditTagsMixin):
""" Remove a producer tag(s). """ Remove a producer tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('producer', producers, locked=locked, remove=True) return self.editTags('producer', producers, locked=locked, remove=True)
@ -905,7 +972,7 @@ class SimilarArtistMixin(EditTagsMixin):
""" Add a similar artist tag(s). """ Add a similar artist tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('similar', artists, locked=locked) return self.editTags('similar', artists, locked=locked)
@ -914,7 +981,7 @@ class SimilarArtistMixin(EditTagsMixin):
""" Remove a similar artist tag(s). """ Remove a similar artist tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('similar', artists, locked=locked, remove=True) return self.editTags('similar', artists, locked=locked, remove=True)
@ -927,7 +994,7 @@ class StyleMixin(EditTagsMixin):
""" Add a style tag(s). """ Add a style tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('style', styles, locked=locked) return self.editTags('style', styles, locked=locked)
@ -936,7 +1003,7 @@ class StyleMixin(EditTagsMixin):
""" Remove a style tag(s). """ Remove a style tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('style', styles, locked=locked, remove=True) return self.editTags('style', styles, locked=locked, remove=True)
@ -949,7 +1016,7 @@ class TagMixin(EditTagsMixin):
""" Add a tag(s). """ Add a tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('tag', tags, locked=locked) return self.editTags('tag', tags, locked=locked)
@ -958,7 +1025,7 @@ class TagMixin(EditTagsMixin):
""" Remove a tag(s). """ Remove a tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('tag', tags, locked=locked, remove=True) return self.editTags('tag', tags, locked=locked, remove=True)
@ -971,7 +1038,7 @@ class WriterMixin(EditTagsMixin):
""" Add a writer tag(s). """ Add a writer tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('writer', writers, locked=locked) return self.editTags('writer', writers, locked=locked)
@ -980,7 +1047,7 @@ class WriterMixin(EditTagsMixin):
""" Remove a writer tag(s). """ Remove a writer tag(s).
Parameters: 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. locked (bool): True (default) to lock the field, False to unlock the field.
""" """
return self.editTags('writer', writers, locked=locked, remove=True) return self.editTags('writer', writers, locked=locked, remove=True)
@ -1016,6 +1083,7 @@ class WatchlistMixin:
except AttributeError: except AttributeError:
account = self._server account = self._server
account.addToWatchlist(self) account.addToWatchlist(self)
return self
def removeFromWatchlist(self, account=None): def removeFromWatchlist(self, account=None):
""" Remove this item from the specified user's watchlist. """ Remove this item from the specified user's watchlist.
@ -1030,6 +1098,7 @@ class WatchlistMixin:
except AttributeError: except AttributeError:
account = self._server account = self._server
account.removeFromWatchlist(self) account.removeFromWatchlist(self)
return self
def streamingServices(self, account=None): def streamingServices(self, account=None):
""" Return a list of :class:`~plexapi.media.Availability` """ Return a list of :class:`~plexapi.media.Availability`

View file

@ -3,11 +3,12 @@ import copy
import html import html
import threading import threading
import time import time
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from xml.etree import ElementTree from xml.etree import ElementTree
import requests import requests
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE,
X_PLEX_IDENTIFIER, log, logfilter, utils) X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, log, logfilter, utils)
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.client import PlexClient from plexapi.client import PlexClient
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
@ -47,6 +48,7 @@ class MyPlexAccount(PlexObject):
locale (str): Your Plex locale locale (str): Your Plex locale
mailing_list_status (str): Your current mailing list status. mailing_list_status (str): Your current mailing list status.
maxHomeSize (int): Unknown. maxHomeSize (int): Unknown.
pin (str): The hashed Plex Home PIN.
queueEmail (str): Email address to add items to your `Watch Later` queue. queueEmail (str): Email address to add items to your `Watch Later` queue.
queueUid (str): Unknown. queueUid (str): Unknown.
restricted (bool): Unknown. restricted (bool): Unknown.
@ -65,16 +67,19 @@ class MyPlexAccount(PlexObject):
_session (obj): Requests session object used to access this client. _session (obj): Requests session object used to access this client.
""" """
FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data 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 HOMEUSERCREATE = 'https://plex.tv/api/home/users?title={title}' # post with data
EXISTINGUSER = 'https://plex.tv/api/home/users?invitedEmail={username}' # 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 FRIENDSERVERS = 'https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}' # put with data
PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete 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 SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get
LINK = 'https://plex.tv/api/v2/pins/link' # put LINK = 'https://plex.tv/api/v2/pins/link' # put
VIEWSTATESYNC = 'https://plex.tv/api/v2/user/view_state_sync' # put
# Hub sections # Hub sections
VOD = 'https://vod.provider.plex.tv' # get VOD = 'https://vod.provider.plex.tv' # get
MUSIC = 'https://music.provider.plex.tv' # get MUSIC = 'https://music.provider.plex.tv' # get
@ -115,6 +120,7 @@ class MyPlexAccount(PlexObject):
self.locale = data.attrib.get('locale') self.locale = data.attrib.get('locale')
self.mailing_list_status = data.attrib.get('mailing_list_status') self.mailing_list_status = data.attrib.get('mailing_list_status')
self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize')) self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
self.pin = data.attrib.get('pin')
self.queueEmail = data.attrib.get('queueEmail') self.queueEmail = data.attrib.get('queueEmail')
self.queueUid = data.attrib.get('queueUid') self.queueUid = data.attrib.get('queueUid')
self.restricted = utils.cast(bool, data.attrib.get('restricted')) self.restricted = utils.cast(bool, data.attrib.get('restricted'))
@ -150,7 +156,7 @@ class MyPlexAccount(PlexObject):
for device in self.devices(): for device in self.devices():
if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId): if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId):
return device return device
raise NotFound('Unable to find device %s' % name) raise NotFound(f'Unable to find device {name}')
def devices(self): def devices(self):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """ """ 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 if response.status_code not in (200, 201, 204): # pragma: no cover
codename = codes.get(response.status_code)[0] codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ') 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: if response.status_code == 401:
raise Unauthorized(message) raise Unauthorized(message)
elif response.status_code == 404: elif response.status_code == 404:
@ -195,7 +201,7 @@ class MyPlexAccount(PlexObject):
for resource in self.resources(): for resource in self.resources():
if resource.name.lower() == name.lower(): if resource.name.lower() == name.lower():
return resource return resource
raise NotFound('Unable to find resource %s' % name) raise NotFound(f'Unable to find resource {name}')
def resources(self): def resources(self):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """ """ 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. """ Remove the specified user from your friends.
Parameters: 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) user = user if isinstance(user, MyPlexUser) else self.user(user)
url = self.FRIENDUPDATE.format(userId=user.id) url = self.FRIENDUPDATE.format(userId=user.id)
@ -376,17 +383,89 @@ class MyPlexAccount(PlexObject):
""" Remove the specified user from your home users. """ Remove the specified user from your home users.
Parameters: 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) 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) return self.query(url, self._session.delete)
def acceptInvite(self, user): def switchHomeUser(self, user):
""" Accept a pending firend invite from the specified user. """ Returns a new :class:`~plexapi.myplex.MyPlexAccount` object switched to the given home user.
Parameters: 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) invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeSent=False)
params = { params = {
@ -394,14 +473,15 @@ class MyPlexAccount(PlexObject):
'home': int(invite.home), 'home': int(invite.home),
'server': int(invite.server) '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) return self.query(url, self._session.put)
def cancelInvite(self, user): def cancelInvite(self, user):
""" Cancel a pending firend invite for the specified user. """ Cancel a pending firend invite for the specified user.
Parameters: 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) invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeReceived=False)
params = { params = {
@ -409,7 +489,7 @@ class MyPlexAccount(PlexObject):
'home': int(invite.home), 'home': int(invite.home),
'server': int(invite.server) '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) return self.query(url, self._session.delete)
def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None, 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))): (user.username.lower(), user.email.lower(), str(user.id))):
return user return user
raise NotFound('Unable to find user %s' % username) raise NotFound(f'Unable to find user {username}')
def users(self): def users(self):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account. """ 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))): (invite.username.lower(), invite.email.lower(), str(invite.id))):
return invite 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): def pendingInvites(self, includeSent=True, includeReceived=True):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexInvite` objects connected to your account. """ 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. # Get a list of all section ids for looking up each section.
allSectionIds = {} allSectionIds = {}
machineIdentifier = server.machineIdentifier if isinstance(server, PlexServer) else server 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) data = self.query(url, self._session.get)
for elem in data[0]: for elem in data[0]:
_id = utils.cast(int, elem.attrib.get('id')) _id = utils.cast(int, elem.attrib.get('id'))
@ -567,8 +647,8 @@ class MyPlexAccount(PlexObject):
values = [] values = []
for key, vals in filterDict.items(): for key, vals in filterDict.items():
if key not in ('contentRating', 'label', 'contentRating!', 'label!'): if key not in ('contentRating', 'label', 'contentRating!', 'label!'):
raise BadRequest('Unknown filter key: %s', key) raise BadRequest(f'Unknown filter key: {key}')
values.append('%s=%s' % (key, '%2C'.join(vals))) values.append(f"{key}={'%2C'.join(vals)}")
return '|'.join(values) return '|'.join(values)
def addWebhook(self, url): def addWebhook(self, url):
@ -579,12 +659,12 @@ class MyPlexAccount(PlexObject):
def deleteWebhook(self, url): def deleteWebhook(self, url):
urls = copy.copy(self._webhooks) urls = copy.copy(self._webhooks)
if url not in urls: if url not in urls:
raise BadRequest('Webhook does not exist: %s' % url) raise BadRequest(f'Webhook does not exist: {url}')
urls.remove(url) urls.remove(url)
return self.setWebhooks(urls) return self.setWebhooks(urls)
def setWebhooks(self, 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 = {'urls[]': urls} if len(urls) else {'urls': ''}
data = self.query(self.WEBHOOKS, self._session.post, data=data) data = self.query(self.WEBHOOKS, self._session.post, data=data)
self._webhooks = self.listAttrs(data, 'url', etag='webhook') self._webhooks = self.listAttrs(data, 'url', etag='webhook')
@ -655,7 +735,7 @@ class MyPlexAccount(PlexObject):
break break
if not client: 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: if 'sync-target' not in client.provides:
raise BadRequest("Received client doesn't provides sync-target") 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 if response.status_code not in (200, 201, 204): # pragma: no cover
codename = codes.get(response.status_code)[0] codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ') 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'] return response.json()['token']
def history(self, maxresults=9999999, mindate=None): def history(self, maxresults=9999999, mindate=None):
@ -730,7 +810,7 @@ class MyPlexAccount(PlexObject):
data = self.query(f'{self.MUSIC}/hubs') data = self.query(f'{self.MUSIC}/hubs')
return self.findItems(data) 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. """ 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, 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. search for the media using the guid.
@ -742,6 +822,7 @@ class MyPlexAccount(PlexObject):
``titleSort`` (Title), ``originallyAvailableAt`` (Release Date), or ``rating`` (Critic Rating). ``titleSort`` (Title), ``originallyAvailableAt`` (Release Date), or ``rating`` (Critic Rating).
``dir`` can be ``asc`` or ``desc``. ``dir`` can be ``asc`` or ``desc``.
libtype (str, optional): 'movie' or 'show' to only return movies or shows, otherwise return all items. 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. **kwargs (dict): Additional custom filters to apply to the search results.
@ -769,9 +850,18 @@ class MyPlexAccount(PlexObject):
if libtype: if libtype:
params['type'] = utils.searchType(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) params.update(kwargs)
data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params)
return self.findItems(data) results, subresults = [], '_init'
while subresults and maxresults > len(results):
data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params)
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): def onWatchlist(self, item):
""" Returns True if the item is on the user's watchlist. """ 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 item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to check
if it is on the user's watchlist. if it is on the user's watchlist.
""" """
ratingKey = item.guid.rsplit('/', 1)[-1] return bool(self.userState(item).watchlistedAt)
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
return bool(data.find('UserState').attrib.get('watchlistedAt'))
def addToWatchlist(self, items): def addToWatchlist(self, items):
""" Add media items to the user's watchlist """ Add media items to the user's watchlist
@ -800,9 +888,10 @@ class MyPlexAccount(PlexObject):
for item in items: for item in items:
if self.onWatchlist(item): 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] ratingKey = item.guid.rsplit('/', 1)[-1]
self.query(f'{self.METADATA}/actions/addToWatchlist?ratingKey={ratingKey}', method=self._session.put) self.query(f'{self.METADATA}/actions/addToWatchlist?ratingKey={ratingKey}', method=self._session.put)
return self
def removeFromWatchlist(self, items): def removeFromWatchlist(self, items):
""" Remove media items from the user's watchlist """ Remove media items from the user's watchlist
@ -820,33 +909,49 @@ class MyPlexAccount(PlexObject):
for item in items: for item in items:
if not self.onWatchlist(item): 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] ratingKey = item.guid.rsplit('/', 1)[-1]
self.query(f'{self.METADATA}/actions/removeFromWatchlist?ratingKey={ratingKey}', method=self._session.put) 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. """ Search for movies and TV shows in Discover.
Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects. Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects.
Parameters: Parameters:
query (str): Search query. query (str): Search query.
limit (int, optional): Limit to the specified number of results. Default 30. 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 = { headers = {
'Accept': 'application/json' 'Accept': 'application/json'
} }
params = { params = {
'query': query, 'query': query,
'limit ': limit, 'limit ': limit,
'searchTypes': 'movies,tv', 'searchTypes': libtype,
'includeMetadata': 1 'includeMetadata': 1
} }
data = self.query(f'{self.METADATA}/library/search', headers=headers, params=params) 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 = [] results = []
for result in searchResults: for result in searchResult:
metadata = result['Metadata'] metadata = result['Metadata']
type = metadata['type'] type = metadata['type']
if type == 'movie': if type == 'movie':
@ -859,7 +964,33 @@ class MyPlexAccount(PlexObject):
xml = f'<{tag} {attrs}/>' xml = f'<{tag} {attrs}/>'
results.append(self._manuallyLoadXML(xml)) 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): def link(self, pin):
""" Link a device to the account using a pin code. """ Link a device to the account using a pin code.
@ -874,6 +1005,26 @@ class MyPlexAccount(PlexObject):
data = {'code': pin} data = {'code': pin}
self.query(self.LINK, self._session.put, headers=headers, data=data) 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): class MyPlexUser(PlexObject):
""" This object represents non-signed in users such as friends and linked """ 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: if utils.cast(int, item.attrib.get('userID')) == self.id:
return item.attrib.get('accessToken') return item.attrib.get('accessToken')
except Exception: 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): def server(self, name):
""" Returns the :class:`~plexapi.myplex.MyPlexServerShare` that matches the name specified. """ Returns the :class:`~plexapi.myplex.MyPlexServerShare` that matches the name specified.
@ -949,7 +1100,7 @@ class MyPlexUser(PlexObject):
if name.lower() == server.name.lower(): if name.lower() == server.name.lower():
return server 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): def history(self, maxresults=9999999, mindate=None):
""" Get all Play History for a user in all shared servers. """ Get all Play History for a user in all shared servers.
@ -1077,7 +1228,7 @@ class MyPlexServerShare(PlexObject):
if name.lower() == section.title.lower(): if name.lower() == section.title.lower():
return section return section
raise NotFound('Unable to find section %s' % name) raise NotFound(f'Unable to find section {name}')
def sections(self): def sections(self):
""" Returns a list of all :class:`~plexapi.myplex.Section` objects shared with this user. """ Returns a list of all :class:`~plexapi.myplex.Section` objects shared with this user.
@ -1158,7 +1309,6 @@ class MyPlexResource(PlexObject):
def preferred_connections( def preferred_connections(
self, self,
ssl=None, ssl=None,
timeout=None,
locations=DEFAULT_LOCATION_ORDER, locations=DEFAULT_LOCATION_ORDER,
schemes=DEFAULT_SCHEME_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 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 only connect to HTTP connections. Set None (default) to connect to any
HTTP or HTTPS connection. 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} connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations}
for connection in self.connections: for connection in self.connections:
@ -1212,7 +1361,7 @@ class MyPlexResource(PlexObject):
Raises: Raises:
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. :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 # Try connecting to all known resource connections in parallel, but
# only return the first server (in order) that provides a response. # only return the first server (in order) that provides a response.
cls = PlexServer if 'server' in self.provides else PlexClient 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.port = utils.cast(int, data.attrib.get('port'))
self.uri = data.attrib.get('uri') self.uri = data.attrib.get('uri')
self.local = utils.cast(bool, data.attrib.get('local')) 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')) self.relay = utils.cast(bool, data.attrib.get('relay'))
@ -1318,7 +1467,7 @@ class MyPlexDevice(PlexObject):
def delete(self): def delete(self):
""" Remove this device from your account. """ """ 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) self._server.query(key, self._server._session.delete)
def syncItems(self): def syncItems(self):
@ -1355,11 +1504,12 @@ class MyPlexPinLogin:
session (requests.Session, optional): Use your own session object if you want to session (requests.Session, optional): Use your own session object if you want to
cache the http responses from PMS cache the http responses from PMS
requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT). 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: Attributes:
PINS (str): 'https://plex.tv/api/v2/pins' PINS (str): 'https://plex.tv/api/v2/pins'
CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}' CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}'
LINK (str): 'https://plex.tv/api/v2/pins/link'
POLLINTERVAL (int): 1 POLLINTERVAL (int): 1
finished (bool): Whether the pin login has finished or not. finished (bool): Whether the pin login has finished or not.
expired (bool): Whether the pin login has expired 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 CHECKPINS = 'https://plex.tv/api/v2/pins/{pinid}' # get
POLLINTERVAL = 1 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__() super(MyPlexPinLogin, self).__init__()
self._session = session or requests.Session() self._session = session or requests.Session()
self._requestTimeout = requestTimeout or TIMEOUT self._requestTimeout = requestTimeout or TIMEOUT
self.headers = headers self.headers = headers
self._oauth = oauth
self._loginTimeout = None self._loginTimeout = None
self._callback = None self._callback = None
self._thread = None self._thread = None
@ -1390,8 +1541,36 @@ class MyPlexPinLogin:
@property @property
def pin(self): def pin(self):
""" Return the 4 character PIN used for linking a device at https://plex.tv/link. """
if self._oauth:
raise BadRequest('Cannot use PIN for Plex OAuth login')
return self._code 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): def run(self, callback=None, timeout=None):
""" Starts the thread which monitors the PIN login state. """ Starts the thread which monitors the PIN login state.
Parameters: Parameters:
@ -1455,7 +1634,13 @@ class MyPlexPinLogin:
def _getCode(self): def _getCode(self):
url = self.PINS 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: if not response:
return None return None
@ -1520,7 +1705,7 @@ class MyPlexPinLogin:
if not response.ok: # pragma: no cover if not response.ok: # pragma: no cover
codename = codes.get(response.status_code)[0] codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ') 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') data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None return ElementTree.fromstring(data) if data.strip() else None
@ -1564,7 +1749,7 @@ def _chooseConnection(ctype, name, results):
if results: if results:
log.debug('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token) log.debug('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token)
return results[0] 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): class AccountOptOut(PlexObject):
@ -1593,8 +1778,8 @@ class AccountOptOut(PlexObject):
:exc:`~plexapi.exceptions.NotFound`: ``option`` str not found in CHOICES. :exc:`~plexapi.exceptions.NotFound`: ``option`` str not found in CHOICES.
""" """
if option not in self.CHOICES: if option not in self.CHOICES:
raise NotFound('%s not found in available choices: %s' % (option, self.CHOICES)) raise NotFound(f'{option} not found in available choices: {self.CHOICES}')
url = self._server.OPTOUTS % {'userUUID': self._server.uuid} url = self._server.OPTOUTS.format(userUUID=self._server.uuid)
params = {'key': self.key, 'value': option} params = {'key': self.key, 'value': option}
self._server.query(url, method=self._server._session.post, params=params) self._server.query(url, method=self._server._session.post, params=params)
self.value = option # assume query successful and set the value to option 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. :exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music.
""" """
if self.key == 'tv.plex.provider.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') 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 urllib.parse import quote_plus
from plexapi import media, utils, video 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.exceptions import BadRequest
from plexapi.mixins import ( from plexapi.mixins import (
RatingMixin, RatingMixin,
@ -79,12 +79,12 @@ class Photoalbum(
Parameters: Parameters:
title (str): Title of the photo album to return. 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) return self.fetchItem(key, Photoalbum, title__iexact=title)
def albums(self, **kwargs): def albums(self, **kwargs):
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in the album. """ """ 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) return self.fetchItems(key, Photoalbum, **kwargs)
def photo(self, title): def photo(self, title):
@ -93,12 +93,12 @@ class Photoalbum(
Parameters: Parameters:
title (str): Title of the photo to return. 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) return self.fetchItem(key, Photo, title__iexact=title)
def photos(self, **kwargs): def photos(self, **kwargs):
""" Returns a list of :class:`~plexapi.photo.Photo` objects in the album. """ """ 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) return self.fetchItems(key, Photo, **kwargs)
def clip(self, title): def clip(self, title):
@ -107,12 +107,12 @@ class Photoalbum(
Parameters: Parameters:
title (str): Title of the clip to return. 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) return self.fetchItem(key, video.Clip, title__iexact=title)
def clips(self, **kwargs): def clips(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Clip` objects in the album. """ """ 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) return self.fetchItems(key, video.Clip, **kwargs)
def get(self, title): def get(self, title):
@ -226,7 +226,7 @@ class Photo(
def _prettyfilename(self): def _prettyfilename(self):
""" Returns a filename for use in download. """ """ Returns a filename for use in download. """
if self.parentTitle: if self.parentTitle:
return '%s - %s' % (self.parentTitle, self.title) return f'{self.parentTitle} - {self.title}'
return self.title return self.title
def photoalbum(self): def photoalbum(self):
@ -282,7 +282,7 @@ class Photo(
section = self.section() 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.policy = Policy.create(limit)
sync_item.mediaSettings = MediaSettings.createPhoto(resolution) sync_item.mediaSettings = MediaSettings.createPhoto(resolution)
@ -291,3 +291,16 @@ class Photo(
def _getWebURL(self, base=None): def _getWebURL(self, base=None):
""" Get the Plex Web URL with the correct parameters. """ """ Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1) 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(): for _item in self.items():
if _item.ratingKey == item.ratingKey: if _item.ratingKey == item.ratingKey:
return _item.playlistItemID 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): def filters(self):
""" Returns the search filter dict for smart playlist. """ Returns the search filter dict for smart playlist.
@ -177,14 +177,14 @@ class Playlist(
for item in self.items(): for item in self.items():
if item.title.lower() == title.lower(): if item.title.lower() == title.lower():
return item 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): def items(self):
""" Returns a list of all items in the playlist. """ """ Returns a list of all items in the playlist. """
if self.radio: if self.radio:
return [] return []
if self._items is None: if self._items is None:
key = '%s/items' % self.key key = f'{self.key}/items'
items = self.fetchItems(key) items = self.fetchItems(key)
self._items = items self._items = items
return self._items return self._items
@ -212,17 +212,17 @@ class Playlist(
ratingKeys = [] ratingKeys = []
for item in items: for item in items:
if item.listType != self.playlistType: # pragma: no cover if item.listType != self.playlistType: # pragma: no cover
raise BadRequest('Can not mix media types when building a playlist: %s and %s' % raise BadRequest(f'Can not mix media types when building a playlist: '
(self.playlistType, item.listType)) f'{self.playlistType} and {item.listType}')
ratingKeys.append(str(item.ratingKey)) ratingKeys.append(str(item.ratingKey))
ratingKeys = ','.join(ratingKeys) 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({ args = {'uri': uri}
'uri': uri key = f"{self.key}/items{utils.joinArgs(args)}"
}))
self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
return self
@deprecated('use "removeItems" instead', stacklevel=3) @deprecated('use "removeItems" instead', stacklevel=3)
def removeItem(self, item): def removeItem(self, item):
@ -247,8 +247,9 @@ class Playlist(
for item in items: for item in items:
playlistItemID = self._getPlaylistItemID(item) 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) self._server.query(key, method=self._server._session.delete)
return self
def moveItem(self, item, after=None): def moveItem(self, item, after=None):
""" Move an item to a new position in the playlist. """ 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.') raise BadRequest('Cannot move items in a smart playlist.')
playlistItemID = self._getPlaylistItemID(item) playlistItemID = self._getPlaylistItemID(item)
key = '%s/items/%s/move' % (self.key, playlistItemID) key = f'{self.key}/items/{playlistItemID}/move'
if after: if after:
afterPlaylistItemID = self._getPlaylistItemID(after) afterPlaylistItemID = self._getPlaylistItemID(after)
key += '?after=%s' % afterPlaylistItemID key += f'?after={afterPlaylistItemID}'
self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
return self
def updateFilters(self, limit=None, sort=None, filters=None, **kwargs): def updateFilters(self, limit=None, sort=None, filters=None, **kwargs):
""" Update the filters for a smart playlist. """ Update the filters for a smart playlist.
@ -297,17 +299,18 @@ class Playlist(
section = self.section() section = self.section()
searchKey = section._buildSearchKey( searchKey = section._buildSearchKey(
sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs) 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({ args = {'uri': uri}
'uri': uri key = f"{self.key}/items{utils.joinArgs(args)}"
}))
self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
return self
def _edit(self, **kwargs): def _edit(self, **kwargs):
""" Actually edit the playlist. """ """ 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) self._server.query(key, method=self._server._session.put)
return self
def edit(self, title=None, summary=None): def edit(self, title=None, summary=None):
""" Edit the playlist. """ Edit the playlist.
@ -321,7 +324,7 @@ class Playlist(
args['title'] = title args['title'] = title
if summary: if summary:
args['summary'] = summary args['summary'] = summary
self._edit(**args) return self._edit(**args)
def delete(self): def delete(self):
""" Delete the playlist. """ """ Delete the playlist. """
@ -348,14 +351,10 @@ class Playlist(
ratingKeys.append(str(item.ratingKey)) ratingKeys.append(str(item.ratingKey))
ratingKeys = ','.join(ratingKeys) ratingKeys = ','.join(ratingKeys)
uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys) uri = f'{server._uriRoot()}/library/metadata/{ratingKeys}'
key = '/playlists%s' % utils.joinArgs({ args = {'uri': uri, 'type': listType, 'title': title, 'smart': 0}
'uri': uri, key = f"/playlists{utils.joinArgs(args)}"
'type': listType,
'title': title,
'smart': 0
})
data = server.query(key, method=server._session.post)[0] data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key) return cls(server, data, initpath=key)
@ -369,14 +368,10 @@ class Playlist(
searchKey = section._buildSearchKey( searchKey = section._buildSearchKey(
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) 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({ args = {'uri': uri, 'type': section.CONTENT_TYPE, 'title': title, 'smart': 1}
'uri': uri, key = f"/playlists{utils.joinArgs(args)}"
'type': section.CONTENT_TYPE,
'title': title,
'smart': 1,
})
data = server.query(key, method=server._session.post)[0] data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key) return cls(server, data, initpath=key)
@ -465,7 +460,7 @@ class Playlist(
sync_item.metadataType = self.metadataType sync_item.metadataType = self.metadataType
sync_item.machineIdentifier = self._server.machineIdentifier 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) sync_item.policy = Policy.create(limit, unwatched)
if self.isVideo: if self.isVideo:

View file

@ -3,7 +3,7 @@ from urllib.parse import quote_plus
from plexapi import utils from plexapi import utils
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest, Unsupported from plexapi.exceptions import BadRequest
class PlayQueue(PlexObject): class PlayQueue(PlexObject):
@ -13,7 +13,7 @@ class PlayQueue(PlexObject):
TAG (str): 'PlayQueue' TAG (str): 'PlayQueue'
TYPE (str): 'playqueue' TYPE (str): 'playqueue'
identifier (str): com.plexapp.plugins.library 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/ mediaTagPrefix (str): Fx /system/bundle/media/flags/
mediaTagVersion (int): Fx 1485957738 mediaTagVersion (int): Fx 1485957738
playQueueID (int): ID of the PlayQueue. playQueueID (int): ID of the PlayQueue.
@ -27,7 +27,7 @@ class PlayQueue(PlexObject):
playQueueSourceURI (str): Original URI used to create the PlayQueue. playQueueSourceURI (str): Original URI used to create the PlayQueue.
playQueueTotalCount (int): How many items in 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. 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. _server (:class:`~plexapi.server.PlexServer`): PlexServer associated with the PlayQueue.
size (int): Alias for playQueueTotalCount. size (int): Alias for playQueueTotalCount.
""" """
@ -90,10 +90,10 @@ class PlayQueue(PlexObject):
return matches[0] return matches[0]
elif len(matches) > 1: elif len(matches) > 1:
raise BadRequest( 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: else:
raise BadRequest("{item} not valid for this PlayQueue".format(item=item)) raise BadRequest(f"{item} not valid for this PlayQueue")
@classmethod @classmethod
def get( def get(
@ -128,7 +128,7 @@ class PlayQueue(PlexObject):
if center: if center:
args["center"] = 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) data = server.query(path, method=server._session.get)
c = cls(server, data, initpath=path) c = cls(server, data, initpath=path)
c._server = server c._server = server
@ -150,9 +150,9 @@ class PlayQueue(PlexObject):
Parameters: Parameters:
server (:class:`~plexapi.server.PlexServer`): Server you are connected to. 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. 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. Media item in the PlayQueue where playback should begin.
shuffle (int, optional): Start the playqueue shuffled. shuffle (int, optional): Start the playqueue shuffled.
repeat (int, optional): Start the playqueue shuffled. repeat (int, optional): Start the playqueue shuffled.
@ -171,8 +171,8 @@ class PlayQueue(PlexObject):
if isinstance(items, list): if isinstance(items, list):
item_keys = ",".join([str(x.ratingKey) for x in items]) item_keys = ",".join([str(x.ratingKey) for x in items])
uri_args = quote_plus("/library/metadata/{item_keys}".format(item_keys=item_keys)) uri_args = quote_plus(f"/library/metadata/{item_keys}")
args["uri"] = "library:///directory/{uri_args}".format(uri_args=uri_args) args["uri"] = f"library:///directory/{uri_args}"
args["type"] = items[0].listType args["type"] = items[0].listType
elif items.type == "playlist": elif items.type == "playlist":
args["type"] = items.playlistType args["type"] = items.playlistType
@ -183,15 +183,14 @@ class PlayQueue(PlexObject):
else: else:
uuid = items.section().uuid uuid = items.section().uuid
args["type"] = items.listType 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: if startItem:
args["key"] = startItem.key 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) data = server.query(path, method=server._session.post)
c = cls(server, data, initpath=path) c = cls(server, data, initpath=path)
c.playQueueType = args["type"]
c._server = server c._server = server
return c return c
@ -227,7 +226,6 @@ class PlayQueue(PlexObject):
path = f"/playQueues{utils.joinArgs(args)}" path = f"/playQueues{utils.joinArgs(args)}"
data = server.query(path, method=server._session.post) data = server.query(path, method=server._session.post)
c = cls(server, data, initpath=path) c = cls(server, data, initpath=path)
c.playQueueType = args["type"]
c._server = server c._server = server
return c return c
@ -237,7 +235,7 @@ class PlayQueue(PlexObject):
Items can only be added to the section immediately following the current playing item. Items can only be added to the section immediately following the current playing item.
Parameters: 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. 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. 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. Only has an effect if an item has already been added to the "Up Next" section.
@ -250,21 +248,17 @@ class PlayQueue(PlexObject):
args = {} args = {}
if item.type == "playlist": if item.type == "playlist":
args["playlistID"] = item.ratingKey args["playlistID"] = item.ratingKey
itemType = item.playlistType
else: else:
uuid = item.section().uuid uuid = item.section().uuid
itemType = item.listType args["uri"] = f"library://{uuid}/item{item.key}"
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")
if playNext: if playNext:
args["next"] = 1 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) data = self._server.query(path, method=self._server._session.put)
self._loadData(data) self._loadData(data)
return self
def moveItem(self, item, after=None, refresh=True): def moveItem(self, item, after=None, refresh=True):
""" """
@ -290,11 +284,10 @@ class PlayQueue(PlexObject):
after = self.getQueueItem(after) after = self.getQueueItem(after)
args["after"] = after.playQueueItemID args["after"] = after.playQueueItemID
path = "/playQueues/{playQueueID}/items/{playQueueItemID}/move{args}".format( path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}/move{utils.joinArgs(args)}"
playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID, args=utils.joinArgs(args)
)
data = self._server.query(path, method=self._server._session.put) data = self._server.query(path, method=self._server._session.put)
self._loadData(data) self._loadData(data)
return self
def removeItem(self, item, refresh=True): def removeItem(self, item, refresh=True):
"""Remove an item from the PlayQueue. """Remove an item from the PlayQueue.
@ -309,20 +302,21 @@ class PlayQueue(PlexObject):
if item not in self: if item not in self:
item = self.getQueueItem(item) item = self.getQueueItem(item)
path = "/playQueues/{playQueueID}/items/{playQueueItemID}".format( path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}"
playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID
)
data = self._server.query(path, method=self._server._session.delete) data = self._server.query(path, method=self._server._session.delete)
self._loadData(data) self._loadData(data)
return self
def clear(self): def clear(self):
"""Remove all items from the PlayQueue.""" """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) data = self._server.query(path, method=self._server._session.delete)
self._loadData(data) self._loadData(data)
return self
def refresh(self): def refresh(self):
"""Refresh the PlayQueue from the Plex server.""" """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) data = self._server.query(path, method=self._server._session.get)
self._loadData(data) self._loadData(data)
return self

View file

@ -171,7 +171,7 @@ class PlexServer(PlexObject):
return headers return headers
def _uriRoot(self): def _uriRoot(self):
return 'server://%s/com.plexapp.plugins.library' % self.machineIdentifier return f'server://{self.machineIdentifier}/com.plexapp.plugins.library'
@property @property
def library(self): def library(self):
@ -232,7 +232,7 @@ class PlexServer(PlexObject):
""" Returns a list of :class:`~plexapi.media.Agent` objects this server has available. """ """ Returns a list of :class:`~plexapi.media.Agent` objects this server has available. """
key = '/system/agents' key = '/system/agents'
if mediaType: if mediaType:
key += '?mediaType=%s' % utils.searchType(mediaType) key += f'?mediaType={utils.searchType(mediaType)}'
return self.fetchItems(key) return self.fetchItems(key)
def createToken(self, type='delegation', scope='all'): def createToken(self, type='delegation', scope='all'):
@ -240,7 +240,7 @@ class PlexServer(PlexObject):
if not self._token: if not self._token:
# Handle unclaimed servers # Handle unclaimed servers
return None 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') return q.attrib.get('token')
def switchUser(self, username, session=None, timeout=None): def switchUser(self, username, session=None, timeout=None):
@ -291,7 +291,7 @@ class PlexServer(PlexObject):
try: try:
return next(account for account in self.systemAccounts() if account.id == accountID) return next(account for account in self.systemAccounts() if account.id == accountID)
except StopIteration: 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): def systemDevices(self):
""" Returns a list of :class:`~plexapi.server.SystemDevice` objects this server contains. """ """ Returns a list of :class:`~plexapi.server.SystemDevice` objects this server contains. """
@ -309,7 +309,7 @@ class PlexServer(PlexObject):
try: try:
return next(device for device in self.systemDevices() if device.id == deviceID) return next(device for device in self.systemDevices() if device.id == deviceID)
except StopIteration: 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): def myPlexAccount(self):
""" Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same """ Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same
@ -351,7 +351,7 @@ class PlexServer(PlexObject):
key = path.key key = path.key
elif path is not None: elif path is not None:
base64path = utils.base64str(path) base64path = utils.base64str(path)
key = '/services/browse/%s' % base64path key = f'/services/browse/{base64path}'
else: else:
key = '/services/browse' key = '/services/browse'
if includeFiles: if includeFiles:
@ -406,7 +406,7 @@ class PlexServer(PlexObject):
log.warning('%s did not advertise a port, checking plex.tv.', elem.attrib.get('name')) log.warning('%s did not advertise a port, checking plex.tv.', elem.attrib.get('name'))
ports = self._myPlexClientPorts() if ports is None else ports ports = self._myPlexClientPorts() if ports is None else ports
port = ports.get(elem.attrib.get('machineIdentifier')) 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, items.append(PlexClient(baseurl=baseurl, server=self,
token=self._token, data=elem, connect=False)) token=self._token, data=elem, connect=False))
@ -425,7 +425,7 @@ class PlexServer(PlexObject):
if client and client.title == name: if client and client.title == name:
return client 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, def createCollection(self, title, section, items=None, smart=False, limit=None,
libtype=None, sort=None, filters=None, **kwargs): libtype=None, sort=None, filters=None, **kwargs):
@ -547,6 +547,7 @@ class PlexServer(PlexObject):
f'Invalid butler task: {task}. Available tasks are: {validTasks}' f'Invalid butler task: {task}. Available tasks are: {validTasks}'
) )
self.query(f'/butler/{task}', method=self._session.post) self.query(f'/butler/{task}', method=self._session.post)
return self
@deprecated('use "checkForUpdate" instead') @deprecated('use "checkForUpdate" instead')
def check_for_update(self, force=True, download=False): 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 force (bool): Force server to check for new releases
download (bool): Download if a update is available. 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: if force:
self.query(part, method=self._session.put) self.query(part, method=self._session.put)
releases = self.fetchItems('/updater/status') releases = self.fetchItems('/updater/status')
@ -608,7 +609,7 @@ class PlexServer(PlexObject):
args['X-Plex-Container-Start'] = 0 args['X-Plex-Container-Start'] = 0
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults) args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
while subresults and maxresults > len(results): 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) subresults = self.fetchItems(key)
results += subresults[:maxresults - len(results)] results += subresults[:maxresults - len(results)]
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] 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() # TODO: Automatically retrieve and validate sort field similar to LibrarySection.search()
args['sort'] = sort args['sort'] = sort
key = '/playlists%s' % utils.joinArgs(args) key = f'/playlists{utils.joinArgs(args)}'
return self.fetchItems(key, **kwargs) return self.fetchItems(key, **kwargs)
def playlist(self, title): def playlist(self, title):
@ -650,7 +651,7 @@ class PlexServer(PlexObject):
try: try:
return self.playlists(title=title, title__iexact=title)[0] return self.playlists(title=title, title__iexact=title)[0]
except IndexError: 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): def optimizedItems(self, removeAll=None):
""" Returns list of all :class:`~plexapi.media.Optimized` objects connected to server. """ """ 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) self.query(key, method=self._server._session.delete)
else: else:
backgroundProcessing = self.fetchItem('/playlists?type=42') 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') @deprecated('use "plexapi.media.Optimized.items()" instead')
def optimizedItem(self, optimizedID): def optimizedItem(self, optimizedID):
@ -668,7 +669,7 @@ class PlexServer(PlexObject):
""" """
backgroundProcessing = self.fetchItem('/playlists?type=42') 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): def conversions(self, pause=None):
""" Returns list of all :class:`~plexapi.media.Conversion` objects connected to server. """ """ 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): if response.status_code not in (200, 201, 204):
codename = codes.get(response.status_code)[0] codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ') 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: if response.status_code == 401:
raise Unauthorized(message) raise Unauthorized(message)
elif response.status_code == 404: elif response.status_code == 404:
@ -736,7 +737,7 @@ class PlexServer(PlexObject):
params['limit'] = limit params['limit'] = limit
if sectionId: if sectionId:
params['sectionId'] = sectionId params['sectionId'] = sectionId
key = '/hubs/search?%s' % urlencode(params) key = f'/hubs/search?{urlencode(params)}'
for hub in self.fetchItems(key, Hub): for hub in self.fetchItems(key, Hub):
if mediatype: if mediatype:
if hub.type == mediatype: if hub.type == mediatype:
@ -753,21 +754,27 @@ class PlexServer(PlexObject):
""" Returns a list of all active :class:`~plexapi.media.TranscodeSession` objects. """ """ Returns a list of all active :class:`~plexapi.media.TranscodeSession` objects. """
return self.fetchItems('/transcode/sessions') 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 """ Creates a websocket connection to the Plex Server to optionally receive
notifications. These often include messages from Plex about media scans notifications. These often include messages from Plex about media scans
as well as updates to currently running Transcode Sessions. 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.
>> pip install websocket-client
Note: ``websocket-client`` must be installed in order to use this feature.
.. code-block:: python
>> pip install websocket-client
Parameters: Parameters:
callback (func): Callback function to call on received messages. callback (func): Callback function to call on received messages.
callbackError (func): Callback function to call on errors.
Raises: Raises:
:exc:`~plexapi.exception.Unsupported`: Websocket-client not installed. :exc:`~plexapi.exception.Unsupported`: Websocket-client not installed.
""" """
notifier = AlertListener(self, callback) notifier = AlertListener(self, callback, callbackError)
notifier.start() notifier.start()
return notifier return notifier
@ -809,7 +816,7 @@ class PlexServer(PlexObject):
if imageFormat is not None: if imageFormat is not None:
params['format'] = imageFormat.lower() params['format'] = imageFormat.lower()
key = '/photo/:/transcode%s' % utils.joinArgs(params) key = f'/photo/:/transcode{utils.joinArgs(params)}'
return self.url(key, includeToken=True) return self.url(key, includeToken=True)
def url(self, key, includeToken=None): def url(self, key, includeToken=None):
@ -818,8 +825,8 @@ class PlexServer(PlexObject):
""" """
if self._token and (includeToken or self._showSecrets): if self._token and (includeToken or self._showSecrets):
delim = '&' if '?' in key else '?' delim = '&' if '?' in key else '?'
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token) return f'{self._baseurl}{key}{delim}X-Plex-Token={self._token}'
return '%s%s' % (self._baseurl, key) return f'{self._baseurl}{key}'
def refreshSynclist(self): def refreshSynclist(self):
""" Force PMS to download new SyncList from Plex.tv. """ """ 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.') 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.') raise BadRequest('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.')
value = 1 if toggle is True else 0 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): def bandwidth(self, timespan=None, **kwargs):
""" Returns a list of :class:`~plexapi.server.StatisticsBandwidth` objects """ Returns a list of :class:`~plexapi.server.StatisticsBandwidth` objects
@ -904,8 +911,7 @@ class PlexServer(PlexObject):
gigabytes = round(bandwidth.bytes / 1024**3, 3) gigabytes = round(bandwidth.bytes / 1024**3, 3)
local = 'local' if bandwidth.lan else 'remote' local = 'local' if bandwidth.lan else 'remote'
date = bandwidth.at.strftime('%Y-%m-%d') date = bandwidth.at.strftime('%Y-%m-%d')
print('%s used %s GB of %s bandwidth on %s from %s' print(f'{account.name} used {gigabytes} GB of {local} bandwidth on {date} from {device.name}')
% (account.name, gigabytes, local, date, device.name))
""" """
params = {} params = {}
@ -923,19 +929,19 @@ class PlexServer(PlexObject):
try: try:
params['timespan'] = timespans[timespan] params['timespan'] = timespans[timespan]
except KeyError: except KeyError:
raise BadRequest('Invalid timespan specified: %s. ' raise BadRequest(f"Invalid timespan specified: {timespan}. "
'Available timespans: %s' % (timespan, ', '.join(timespans.keys()))) f"Available timespans: {', '.join(timespans.keys())}")
filters = {'accountID', 'at', 'at<', 'at>', 'bytes', 'bytes<', 'bytes>', 'deviceID', 'lan'} filters = {'accountID', 'at', 'at<', 'at>', 'bytes', 'bytes<', 'bytes>', 'deviceID', 'lan'}
for key, value in kwargs.items(): for key, value in kwargs.items():
if key not in filters: if key not in filters:
raise BadRequest('Unknown filter: %s=%s' % (key, value)) raise BadRequest(f'Unknown filter: {key}={value}')
if key.startswith('at'): if key.startswith('at'):
try: try:
value = utils.cast(int, value.timestamp()) value = utils.cast(int, value.timestamp())
except AttributeError: 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': elif key.startswith('bytes') or key == 'lan':
value = utils.cast(int, value) value = utils.cast(int, value)
elif key == 'accountID': elif key == 'accountID':
@ -943,7 +949,7 @@ class PlexServer(PlexObject):
value = 1 # The admin account is accountID=1 value = 1 # The admin account is accountID=1
params[key] = value params[key] = value
key = '/statistics/bandwidth?%s' % urlencode(params) key = f'/statistics/bandwidth?{urlencode(params)}'
return self.fetchItems(key, StatisticsBandwidth) return self.fetchItems(key, StatisticsBandwidth)
def resources(self): def resources(self):
@ -966,13 +972,9 @@ class PlexServer(PlexObject):
base = 'https://app.plex.tv/desktop/' base = 'https://app.plex.tv/desktop/'
if endpoint: if endpoint:
return '%s#!/server/%s/%s%s' % ( return f'{base}#!/server/{self.machineIdentifier}/{endpoint}{utils.joinArgs(kwargs)}'
base, self.machineIdentifier, endpoint, utils.joinArgs(kwargs)
)
else: else:
return '%s#!/media/%s/com.plexapp.plugins.library%s' % ( return f'{base}#!/media/{self.machineIdentifier}/com.plexapp.plugins.library{utils.joinArgs(kwargs)}'
base, self.machineIdentifier, utils.joinArgs(kwargs)
)
def getWebURL(self, base=None, playlistTab=None): def getWebURL(self, base=None, playlistTab=None):
""" Returns the Plex Web URL for the server. """ 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. playlistTab (str): The playlist tab (audio, video, photo). Only used for the playlist URL.
""" """
if playlistTab is not None: if playlistTab is not None:
params = {'source': 'playlists', 'pivot': 'playlists.%s' % playlistTab} params = {'source': 'playlists', 'pivot': f'playlists.{playlistTab}'}
else: else:
params = {'key': '/hubs', 'pageType': 'hub'} params = {'key': '/hubs', 'pageType': 'hub'}
return self._buildWebURL(base=base, **params) return self._buildWebURL(base=base, **params)
@ -1115,7 +1117,7 @@ class SystemDevice(PlexObject):
self.clientIdentifier = data.attrib.get('clientIdentifier') self.clientIdentifier = data.attrib.get('clientIdentifier')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.id = utils.cast(int, data.attrib.get('id')) 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.name = data.attrib.get('name')
self.platform = data.attrib.get('platform') self.platform = data.attrib.get('platform')
@ -1146,12 +1148,14 @@ class StatisticsBandwidth(PlexObject):
self.timespan = utils.cast(int, data.attrib.get('timespan')) self.timespan = utils.cast(int, data.attrib.get('timespan'))
def __repr__(self): def __repr__(self):
return '<%s>' % ':'.join([p for p in [ return '<{}>'.format(
self.__class__.__name__, ':'.join([p for p in [
self._clean(self.accountID), self.__class__.__name__,
self._clean(self.deviceID), self._clean(self.accountID),
self._clean(int(self.at.timestamp())) self._clean(self.deviceID),
] if p]) self._clean(int(self.at.timestamp()))
] if p])
)
def account(self): def account(self):
""" Returns the :class:`~plexapi.server.SystemAccount` associated with the bandwidth data. """ """ 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')) self.timespan = utils.cast(int, data.attrib.get('timespan'))
def __repr__(self): def __repr__(self):
return '<%s>' % ':'.join([p for p in [ return f"<{':'.join([p for p in [self.__class__.__name__, self._clean(int(self.at.timestamp()))] if p])}>"
self.__class__.__name__,
self._clean(int(self.at.timestamp()))
] if p])
@utils.registerPlexObject @utils.registerPlexObject

View file

@ -51,7 +51,7 @@ class Settings(PlexObject):
id = utils.lowerFirst(id) id = utils.lowerFirst(id)
if id in self._settings: if id in self._settings:
return self._settings[id] return self._settings[id]
raise NotFound('Invalid setting id: %s' % id) raise NotFound(f'Invalid setting id: {id}')
def groups(self): def groups(self):
""" Returns a dict of lists for all :class:`~plexapi.settings.Setting` """ Returns a dict of lists for all :class:`~plexapi.settings.Setting`
@ -77,12 +77,12 @@ class Settings(PlexObject):
params = {} params = {}
for setting in self.all(): for setting in self.all():
if setting._setValue: 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) params[setting.id] = quote(setting._setValue)
if not params: if not params:
raise BadRequest('No setting have been modified.') raise BadRequest('No setting have been modified.')
querystr = '&'.join(['%s=%s' % (k, v) for k, v in params.items()]) querystr = '&'.join([f'{k}={v}' for k, v in params.items()])
url = '%s?%s' % (self.key, querystr) url = f'{self.key}?{querystr}'
self._server.query(url, self._server._session.put) self._server.query(url, self._server._session.put)
self.reload() self.reload()
@ -149,16 +149,16 @@ class Setting(PlexObject):
# check a few things up front # check a few things up front
if not isinstance(value, self.TYPES[self.type]['type']): if not isinstance(value, self.TYPES[self.type]['type']):
badtype = type(value).__name__ 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: 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() # store value off to the side until we call settings.save()
tostr = self.TYPES[self.type]['tostr'] tostr = self.TYPES[self.type]['tostr']
self._setValue = tostr(value) self._setValue = tostr(value)
def toUrl(self): def toUrl(self):
"""Helper for urls""" """Helper for urls"""
return '%s=%s' % (self.id, self._value or self.value) return f'{self.id}={self._value or self.value}'
@utils.registerPlexObject @utils.registerPlexObject
@ -174,6 +174,6 @@ class Preferences(Setting):
def _default(self): def _default(self):
""" Set the default value for this setting.""" """ Set the default value for this setting."""
key = '%s/prefs?' % self._initpath key = f'{self._initpath}/prefs?'
url = key + '%s=%s' % (self.id, self.default) url = key + f'{self.id}={self.default}'
self._server.query(url, method=self._server._session.put) self._server.query(url, method=self._server._session.put)

View file

@ -96,9 +96,7 @@ class PlexSonosClient(PlexClient):
{ {
"type": "music", "type": "music",
"providerIdentifier": "com.plexapp.plugins.library", "providerIdentifier": "com.plexapp.plugins.library",
"containerKey": "/playQueues/{}?own=1".format( "containerKey": f"/playQueues/{playqueue.playQueueID}?own=1",
playqueue.playQueueID
),
"key": media.key, "key": media.key,
"offset": offset, "offset": offset,
"machineIdentifier": media._server.machineIdentifier, "machineIdentifier": media._server.machineIdentifier,

View file

@ -81,13 +81,13 @@ class SyncItem(PlexObject):
""" Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. """ """ Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. """
server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier] server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier]
if len(server) == 0: 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] return server[0]
def getMedia(self): def getMedia(self):
""" Returns list of :class:`~plexapi.base.Playable` which belong to this sync item. """ """ Returns list of :class:`~plexapi.base.Playable` which belong to this sync item. """
server = self.server().connect() server = self.server().connect()
key = '/sync/items/%s' % self.id key = f'/sync/items/{self.id}'
return server.fetchItems(key) return server.fetchItems(key)
def markDownloaded(self, media): def markDownloaded(self, media):
@ -97,7 +97,7 @@ class SyncItem(PlexObject):
Parameters: Parameters:
media (base.Playable): the media to be marked as downloaded. 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) media._server.query(url, method=requests.put)
def delete(self): def delete(self):
@ -159,13 +159,14 @@ class Status:
self.itemsCount = plexapi.utils.cast(int, itemsCount) self.itemsCount = plexapi.utils.cast(int, itemsCount)
def __repr__(self): def __repr__(self):
return '<%s>:%s' % (self.__class__.__name__, dict( d = dict(
itemsCount=self.itemsCount, itemsCount=self.itemsCount,
itemsCompleteCount=self.itemsCompleteCount, itemsCompleteCount=self.itemsCompleteCount,
itemsDownloadedCount=self.itemsDownloadedCount, itemsDownloadedCount=self.itemsDownloadedCount,
itemsReadyCount=self.itemsReadyCount, itemsReadyCount=self.itemsReadyCount,
itemsSuccessfulCount=self.itemsSuccessfulCount itemsSuccessfulCount=self.itemsSuccessfulCount
)) )
return f'<{self.__class__.__name__}>:{d}'
class MediaSettings: class MediaSettings:

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import base64 import base64
import functools import functools
import json
import logging import logging
import os import os
import re import re
@ -26,10 +27,66 @@ except ImportError:
log = logging.getLogger('plexapi') log = logging.getLogger('plexapi')
# Search Types - Plex uses these to filter specific media types when searching. # Search Types - Plex uses these to filter specific media types when searching.
# Library Types - Populated at runtime SEARCHTYPES = {
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7, 'movie': 1,
'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14, 'show': 2,
'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'optimizedVersion': 42, 'userPlaylistItem': 1001} '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 = {} PLEXOBJECTS = {}
@ -60,10 +117,12 @@ def registerPlexObject(cls):
buildItem() below for an example. buildItem() below for an example.
""" """
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE)) 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: if ehash in PLEXOBJECTS:
raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' % raise Exception(f'Ambiguous PlexObject definition {cls.__name__}(tag={cls.TAG}, type={etype}) '
(cls.__name__, cls.TAG, etype, PLEXOBJECTS[ehash].__name__)) f'with {PLEXOBJECTS[ehash].__name__}')
PLEXOBJECTS[ehash] = cls PLEXOBJECTS[ehash] = cls
return cls return cls
@ -106,8 +165,8 @@ def joinArgs(args):
arglist = [] arglist = []
for key in sorted(args, key=lambda x: x.lower()): for key in sorted(args, key=lambda x: x.lower()):
value = str(args[key]) value = str(args[key])
arglist.append('%s=%s' % (key, quote(value, safe=''))) arglist.append(f"{key}={quote(value, safe='')}")
return '?%s' % '&'.join(arglist) return f"?{'&'.join(arglist)}"
def lowerFirst(s): def lowerFirst(s):
@ -149,8 +208,7 @@ def searchType(libtype):
""" Returns the integer value of the library string type. """ Returns the integer value of the library string type.
Parameters: Parameters:
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track, libtype (str): LibType to lookup (See :data:`~plexapi.utils.SEARCHTYPES`)
collection)
Raises: Raises:
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype :exc:`~plexapi.exceptions.NotFound`: Unknown libtype
@ -160,7 +218,7 @@ def searchType(libtype):
return libtype return libtype
if SEARCHTYPES.get(libtype) is not None: if SEARCHTYPES.get(libtype) is not None:
return SEARCHTYPES[libtype] return SEARCHTYPES[libtype]
raise NotFound('Unknown libtype: %s' % libtype) raise NotFound(f'Unknown libtype: {libtype}')
def reverseSearchType(libtype): def reverseSearchType(libtype):
@ -178,7 +236,42 @@ def reverseSearchType(libtype):
for k, v in SEARCHTYPES.items(): for k, v in SEARCHTYPES.items():
if libtype == v: if libtype == v:
return k 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): def threaded(callback, listargs):
@ -255,7 +348,7 @@ def toList(value, itemcast=None, delim=','):
def cleanFilename(filename, replace='_'): 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 = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename) cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename)
return cleaned_filename return cleaned_filename
@ -283,11 +376,11 @@ def downloadSessionImages(server, filename=None, height=150, width=150,
if media.thumb: if media.thumb:
url = media.thumb url = media.thumb
if part.indexes: # always use bif images if available. 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 url:
if filename is None: if filename is None:
prettyname = media._prettyfilename() 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) url = server.transcodeImage(url, height, width, opacity, saturation)
filepath = download(url, filename=filename) filepath = download(url, filename=filename)
info['username'] = {'filepath': filepath, 'url': url} info['username'] = {'filepath': filepath, 'url': url}
@ -374,13 +467,13 @@ def getMyPlexAccount(opts=None): # pragma: no cover
from plexapi.myplex import MyPlexAccount from plexapi.myplex import MyPlexAccount
# 1. Check command-line options # 1. Check command-line options
if opts and opts.username and opts.password: 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) return MyPlexAccount(opts.username, opts.password)
# 2. Check Plexconfig (environment variables and config.ini) # 2. Check Plexconfig (environment variables and config.ini)
config_username = CONFIG.get('auth.myplex_username') config_username = CONFIG.get('auth.myplex_username')
config_password = CONFIG.get('auth.myplex_password') config_password = CONFIG.get('auth.myplex_password')
if config_username and config_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) return MyPlexAccount(config_username, config_password)
config_token = CONFIG.get('auth.server_token') config_token = CONFIG.get('auth.server_token')
if config_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 # 3. Prompt for username and password on the command line
username = input('What is your plex.tv username: ') username = input('What is your plex.tv username: ')
password = getpass('What is your plex.tv password: ') 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) return MyPlexAccount(username, password)
def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover 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: Parameters:
headers (dict): Provide the X-Plex- headers for the new device. 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) 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 def choose(msg, items, attr): # pragma: no cover
""" Command line helper to display a list of choices, asking the """ Command line helper to display a list of choices, asking the
user to choose one of the options. user to choose one of the options.
@ -428,12 +548,12 @@ def choose(msg, items, attr): # pragma: no cover
print() print()
for index, i in enumerate(items): for index, i in enumerate(items):
name = attr(i) if callable(attr) else getattr(i, attr) name = attr(i) if callable(attr) else getattr(i, attr)
print(' %s: %s' % (index, name)) print(f' {index}: {name}')
print() print()
# Request choice from the user # Request choice from the user
while True: while True:
try: try:
inp = input('%s: ' % msg) inp = input(f'{msg}: ')
if any(s in inp for s in (':', '::', '-')): if any(s in inp for s in (':', '::', '-')):
idx = slice(*map(lambda x: int(x.strip()) if x.strip() else None, inp.split(':'))) idx = slice(*map(lambda x: int(x.strip()) if x.strip() else None, inp.split(':')))
return items[idx] return items[idx]
@ -452,8 +572,7 @@ def getAgentIdentifier(section, agent):
if agent in identifiers: if agent in identifiers:
return ag.identifier return ag.identifier
agents += identifiers agents += identifiers
raise NotFound('Could not find "%s" in agents list (%s)' % raise NotFound(f"Could not find \"{agent}\" in agents list ({', '.join(agents)})")
(agent, ', '.join(agents)))
def base64str(text): def base64str(text):
@ -467,7 +586,7 @@ def deprecated(message, stacklevel=2):
when the function is used.""" when the function is used."""
@functools.wraps(func) @functools.wraps(func)
def wrapper(*args, **kwargs): 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) warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel)
log.warning(msg) log.warning(msg)
return func(*args, **kwargs) return func(*args, **kwargs)
@ -485,3 +604,17 @@ def iterXMLBFS(root, tag=None):
if tag is None or node.tag == tag: if tag is None or node.tag == tag:
yield node yield node
queue.extend(list(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 -*- # -*- coding: utf-8 -*-
import os import os
from urllib.parse import quote_plus, urlencode from urllib.parse import quote_plus
from plexapi import media, utils 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.exceptions import BadRequest
from plexapi.mixins import ( from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin, SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin, CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
WatchlistMixin WatchlistMixin
) )
class Video(PlexPartialObject): class Video(PlexPartialObject, PlayedUnplayedMixin):
""" Base class for all video objects including :class:`~plexapi.video.Movie`, """ Base class for all video objects including :class:`~plexapi.video.Movie`,
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`, :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`. :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.userRating = utils.cast(float, data.attrib.get('userRating'))
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) 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): def url(self, part):
""" Returns the full url for something. Typically used for getting a specific image. """ """ Returns the full url for something. Typically used for getting a specific image. """
return self._server.url(part, includeToken=True) if part else None 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): def augmentation(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """ Returns a list of :class:`~plexapi.library.Hub` objects.
Augmentation returns hub items relating to online media sources Augmentation returns hub items relating to online media sources
@ -132,7 +117,7 @@ class Video(PlexPartialObject):
def uploadSubtitles(self, filepath): def uploadSubtitles(self, filepath):
""" Upload Subtitle file for video. """ """ Upload Subtitle file for video. """
url = '%s/subtitles' % self.key url = f'{self.key}/subtitles'
filename = os.path.basename(filepath) filename = os.path.basename(filepath)
subFormat = os.path.splitext(filepath)[1][1:] subFormat = os.path.splitext(filepath)[1][1:]
with open(filepath, 'rb') as subfile: with open(filepath, 'rb') as subfile:
@ -141,6 +126,7 @@ class Video(PlexPartialObject):
} }
headers = {'Accept': 'text/plain, */*'} headers = {'Accept': 'text/plain, */*'}
self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers) self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers)
return self
def removeSubtitles(self, streamID=None, streamTitle=None): def removeSubtitles(self, streamID=None, streamTitle=None):
""" Remove Subtitle from movie's subtitles listing. """ Remove Subtitle from movie's subtitles listing.
@ -151,74 +137,103 @@ class Video(PlexPartialObject):
for stream in self.subtitleStreams(): for stream in self.subtitleStreams():
if streamID == stream.id or streamTitle == stream.title: if streamID == stream.id or streamTitle == stream.title:
self._server.query(stream.key, self._server._session.delete) self._server.query(stream.key, self._server._session.delete)
return self
def optimize(self, title=None, target="", targetTagID=None, locationID=-1, policyScope='all', def optimize(self, title='', target='', deviceProfile='', videoQuality=None,
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None): locationID=-1, limit=None, unwatched=False):
""" Optimize item """ Create an optimized version of the video.
locationID (int): -1 in folder with original items Parameters:
2 library path id title (str, optional): Title of the optimized video.
library path id is found in library.locations[i].id 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. Raises:
if none provided use "Custom: {deviceProfile}" :exc:`~plexapi.exceptions.BadRequest`: Unknown quality profile target
or missing deviceProfile and videoQuality.
targetTagID (int): Default quality settings :exc:`~plexapi.exceptions.BadRequest`: Unknown location ID.
1 Mobile
2 TV
3 Original Quality
deviceProfile (str): Android, IOS, Universal TV, Universal Mobile, Windows Phone,
Windows, Xbox One
Example: 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] from plexapi.library import Location
tagKeys = ["Mobile", "TV", "Original Quality"] from plexapi.sync import Policy, MediaSettings
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
backgroundProcessing = self.fetchItem('/playlists?type=42') 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 = { params = {
'Item[type]': 42, 'Item[type]': 42,
'Item[title]': title or self._defaultSyncTitle(),
'Item[target]': target, 'Item[target]': target,
'Item[targetTagID]': targetTagID if targetTagID else '', 'Item[targetTagID]': targetTagID,
'Item[locationID]': locationID, 'Item[locationID]': locationID,
'Item[Policy][scope]': policyScope, 'Item[Location][uri]': uri,
'Item[Policy][value]': policyValue, 'Item[Policy][scope]': policy.scope,
'Item[Policy][unwatched]': policyUnwatched 'Item[Policy][value]': str(policy.value),
'Item[Policy][unwatched]': str(int(policy.unwatched)),
} }
if deviceProfile: if deviceProfile:
params['Item[Device][profile]'] = deviceProfile params['Item[Device][profile]'] = deviceProfile
if videoQuality: if videoQuality:
from plexapi.sync import MediaSettings
mediaSettings = MediaSettings.createVideo(videoQuality) mediaSettings = MediaSettings.createVideo(videoQuality)
params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality
params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution
@ -227,14 +242,11 @@ class Video(PlexPartialObject):
params['Item[MediaSettings][subtitleSize]'] = '' params['Item[MediaSettings][subtitleSize]'] = ''
params['Item[MediaSettings][musicBitrate]'] = '' params['Item[MediaSettings][musicBitrate]'] = ''
params['Item[MediaSettings][photoQuality]'] = '' params['Item[MediaSettings][photoQuality]'] = ''
params['Item[MediaSettings][photoResolution]'] = ''
titleParam = {'Item[title]': title} url = key + utils.joinArgs(params)
section = self._server.library.sectionByID(self.librarySectionID) self._server.query(url, method=self._server._session.put)
params['Item[Location][uri]'] = 'library://' + section.uuid + '/item/' + \ return self
quote_plus(self.key + '?includeExternalMedia=1')
data = key + urlencode(params) + '&' + urlencode(titleParam)
return self._server.query(data, method=self._server._session.put)
def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None): 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. """ 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) 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.policy = Policy.create(limit, unwatched)
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
@ -279,7 +291,7 @@ class Movie(
Video, Playable, Video, Playable,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin, ArtMixin, PosterMixin, ThemeMixin,
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin, SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin, CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
WatchlistMixin WatchlistMixin
@ -298,6 +310,7 @@ class Movie(
countries (List<:class:`~plexapi.media.Country`>): List of countries objects. countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
directors (List<:class:`~plexapi.media.Director`>): List of director objects. directors (List<:class:`~plexapi.media.Director`>): List of director objects.
duration (int): Duration of the movie in milliseconds. 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. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
labels (List<:class:`~plexapi.media.Label`>): List of label 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.countries = self.findItems(data, media.Country)
self.directors = self.findItems(data, media.Director) self.directors = self.findItems(data, media.Director)
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.editionTitle = data.attrib.get('editionTitle')
self.genres = self.findItems(data, media.Genre) self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid) self.guids = self.findItems(data, media.Guid)
self.labels = self.findItems(data, media.Label) self.labels = self.findItems(data, media.Label)
@ -381,13 +395,23 @@ class Movie(
def _prettyfilename(self): def _prettyfilename(self):
""" Returns a filename for use in download. """ """ Returns a filename for use in download. """
return '%s (%s)' % (self.title, self.year) return f'{self.title} ({self.year})'
def reviews(self): def reviews(self):
""" Returns a list of :class:`~plexapi.media.Review` objects. """ """ Returns a list of :class:`~plexapi.media.Review` objects. """
data = self._server.query(self._details_key) data = self._server.query(self._details_key)
return self.findItems(data, media.Review, rtag='Video') 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 @utils.registerPlexObject
class Show( class Show(
@ -499,8 +523,8 @@ class Show(
return self.roles return self.roles
@property @property
def isWatched(self): def isPlayed(self):
""" Returns True if the show is fully watched. """ """ Returns True if the show is fully played. """
return bool(self.viewedLeafCount == self.leafCount) return bool(self.viewedLeafCount == self.leafCount)
def onDeck(self): def onDeck(self):
@ -520,7 +544,7 @@ class Show(
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. :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): if title is not None and not isinstance(title, int):
return self.fetchItem(key, Season, title__iexact=title) return self.fetchItem(key, Season, title__iexact=title)
elif season is not None or isinstance(title, int): elif season is not None or isinstance(title, int):
@ -533,8 +557,8 @@ class Show(
def seasons(self, **kwargs): def seasons(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Season` objects in the show. """ """ Returns a list of :class:`~plexapi.video.Season` objects in the show. """
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey key = f'{self.key}/children?excludeAllLeaves=1'
return self.fetchItems(key, Season, **kwargs) return self.fetchItems(key, Season, container_size=self.childCount, **kwargs)
def episode(self, title=None, season=None, episode=None): def episode(self, title=None, season=None, episode=None):
""" Find a episode using a title or season and episode. """ Find a episode using a title or season and episode.
@ -547,7 +571,7 @@ class Show(
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing. :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: if title is not None:
return self.fetchItem(key, Episode, title__iexact=title) return self.fetchItem(key, Episode, title__iexact=title)
elif season is not None and episode is not None: elif season is not None and episode is not None:
@ -556,7 +580,7 @@ class Show(
def episodes(self, **kwargs): def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects in the show. """ """ 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) return self.fetchItems(key, Episode, **kwargs)
def get(self, title=None, season=None, episode=None): def get(self, title=None, season=None, episode=None):
@ -583,7 +607,7 @@ class Show(
""" """
filepaths = [] filepaths = []
for episode in self.episodes(): 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) filepaths += episode.download(_savepath, keep_original_name, **kwargs)
return filepaths return filepaths
@ -647,15 +671,17 @@ class Season(
yield episode yield episode
def __repr__(self): def __repr__(self):
return '<%s>' % ':'.join([p for p in [ return '<{}>'.format(
self.__class__.__name__, ':'.join([p for p in [
self.key.replace('/library/metadata/', '').replace('/children', ''), self.__class__.__name__,
'%s-s%s' % (self.parentTitle.replace(' ', '-')[:20], self.seasonNumber), self.key.replace('/library/metadata/', '').replace('/children', ''),
] if p]) f"{self.parentTitle.replace(' ', '-')[:20]}-{self.seasonNumber}",
] if p])
)
@property @property
def isWatched(self): def isPlayed(self):
""" Returns True if the season is fully watched. """ """ Returns True if the season is fully played. """
return bool(self.viewedLeafCount == self.leafCount) return bool(self.viewedLeafCount == self.leafCount)
@property @property
@ -665,7 +691,7 @@ class Season(
def episodes(self, **kwargs): def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """ """ 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) return self.fetchItems(key, Episode, **kwargs)
def episode(self, title=None, episode=None): def episode(self, title=None, episode=None):
@ -678,7 +704,7 @@ class Season(
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. :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): if title is not None and not isinstance(title, int):
return self.fetchItem(key, Episode, title__iexact=title) return self.fetchItem(key, Episode, title__iexact=title)
elif episode is not None or isinstance(title, int): elif episode is not None or isinstance(title, int):
@ -728,7 +754,7 @@ class Season(
def _defaultSyncTitle(self): def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """ """ Returns str, default title for a new syncItem. """
return '%s - %s' % (self.parentTitle, self.title) return f'{self.parentTitle} - {self.title}'
@utils.registerPlexObject @utils.registerPlexObject
@ -835,18 +861,20 @@ class Episode(
if not self.parentRatingKey and self.grandparentRatingKey: if not self.parentRatingKey and self.grandparentRatingKey:
self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey
if self.parentRatingKey: if self.parentRatingKey:
self.parentKey = '/library/metadata/%s' % self.parentRatingKey self.parentKey = f'/library/metadata/{self.parentRatingKey}'
def __repr__(self): def __repr__(self):
return '<%s>' % ':'.join([p for p in [ return '<{}>'.format(
self.__class__.__name__, ':'.join([p for p in [
self.key.replace('/library/metadata/', '').replace('/children', ''), self.__class__.__name__,
'%s-%s' % (self.grandparentTitle.replace(' ', '-')[:20], self.seasonEpisode), self.key.replace('/library/metadata/', '').replace('/children', ''),
] if p]) f"{self.grandparentTitle.replace(' ', '-')[:20]}-{self.seasonEpisode}",
] if p])
)
def _prettyfilename(self): def _prettyfilename(self):
""" Returns a filename for use in download. """ """ 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 @property
def actors(self): def actors(self):
@ -878,7 +906,7 @@ class Episode(
@property @property
def seasonEpisode(self): def seasonEpisode(self):
""" Returns the s00e00 string containing the season and episode numbers. """ """ 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 @property
def hasCommercialMarker(self): def hasCommercialMarker(self):
@ -905,7 +933,7 @@ class Episode(
def _defaultSyncTitle(self): def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """ """ 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 @utils.registerPlexObject
@ -979,4 +1007,43 @@ class Extra(Clip):
def _prettyfilename(self): def _prettyfilename(self):
""" Returns a filename for use in download. """ """ 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 musicbrainzngs==0.7.1
packaging==21.3 packaging==21.3
paho-mqtt==1.6.1 paho-mqtt==1.6.1
plexapi==4.12.1 plexapi==4.13.1
portend==3.1.0 portend==3.1.0
profilehooks==1.12.0 profilehooks==1.12.0
PyJWT==2.4.0 PyJWT==2.4.0