Bump plexapi from 4.9.2 to 4.11.0 (#1690)

* Bump plexapi from 4.9.2 to 4.10.1

Bumps [plexapi](https://github.com/pkkid/python-plexapi) from 4.9.2 to 4.10.1.
- [Release notes](https://github.com/pkkid/python-plexapi/releases)
- [Commits](https://github.com/pkkid/python-plexapi/compare/4.9.2...4.10.1)

---
updated-dependencies:
- dependency-name: plexapi
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update plexapi==4.11.0

* Update requirements.txt

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>

[skip ci]
This commit is contained in:
dependabot[bot] 2022-05-18 11:24:15 -07:00 committed by GitHub
parent f1b95f5837
commit 399fd6ff91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1421 additions and 589 deletions

View file

@ -314,7 +314,7 @@ class SuperWeirdWordPlugin(MessDetectorPlugin):
self._buffer = ""
self._buffer_accent_count = 0
elif (
character not in {"<", ">", "-", "="}
character not in {"<", ">", "-", "=", "~", "|", "_"}
and character.isdigit() is False
and is_symbol(character)
):

View file

@ -2,5 +2,5 @@
Expose version
"""
__version__ = "2.0.11"
__version__ = "2.0.12"
VERSION = __version__.split(".")

View file

@ -21,9 +21,9 @@ TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)
# Plex Header Configuation
# Plex Header Configuration
X_PLEX_PROVIDES = CONFIG.get('header.provides', 'controller')
X_PLEX_PLATFORM = CONFIG.get('header.platform', CONFIG.get('header.platorm', uname()[0]))
X_PLEX_PLATFORM = CONFIG.get('header.platform', CONFIG.get('header.platform', uname()[0]))
X_PLEX_PLATFORM_VERSION = CONFIG.get('header.platform_version', uname()[2])
X_PLEX_PRODUCT = CONFIG.get('header.product', PROJECT)
X_PLEX_VERSION = CONFIG.get('header.version', VERSION)

View file

@ -2,12 +2,16 @@
import os
from urllib.parse import quote_plus
from plexapi import library, media, utils
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest
from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin
from plexapi.mixins import RatingMixin, SplitMergeMixin, UnmatchMatchMixin
from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin,
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
)
from plexapi.playlist import Playlist
@ -38,7 +42,7 @@ class Audio(PlexPartialObject):
title (str): Name of the artist, album, or track (Jason Mraz, We Sing, Lucky, etc.).
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'artist', 'album', or 'track'.
updatedAt (datatime): Datetime the item was updated.
updatedAt (datetime): Datetime the item was updated.
userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars).
viewCount (int): Count of times the item was played.
"""
@ -125,8 +129,13 @@ class Audio(PlexPartialObject):
@utils.registerPlexObject
class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin,
CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin):
class Artist(
Audio,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
SortTitleMixin, SummaryMixin, TitleMixin,
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
):
""" Represents a single Artist.
Attributes:
@ -138,9 +147,11 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, S
countries (List<:class:`~plexapi.media.Country`>): List country objects.
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
key (str): API URL (/library/metadata/<ratingkey>).
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
locations (List<str>): List of folder paths where the artist is found on disk.
similar (List<:class:`~plexapi.media.Similar`>): List of similar objects.
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
"""
TAG = 'Directory'
TYPE = 'artist'
@ -153,26 +164,23 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, S
self.countries = self.findItems(data, media.Country)
self.genres = self.findItems(data, media.Genre)
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.locations = self.listAttrs(data, 'path', etag='Location')
self.similar = self.findItems(data, media.Similar)
self.styles = self.findItems(data, media.Style)
self.theme = data.attrib.get('theme')
def __iter__(self):
for album in self.albums():
yield album
def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
data = self._server.query(self._details_key)
return self.findItems(data, library.Hub, rtag='Related')
def album(self, title):
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
Parameters:
title (str): Title of the album to return.
"""
key = '/library/metadata/%s/children' % self.ratingKey
key = f"/library/sections/{self.librarySectionID}/all?artist.id={self.ratingKey}&type=9"
return self.fetchItem(key, Album, title__iexact=title)
def albums(self, **kwargs):
@ -230,8 +238,13 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, S
@utils.registerPlexObject
class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin,
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin):
class Album(
Audio,
UnmatchMatchMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeUrlMixin,
OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin
):
""" Represents a single Album.
Attributes:
@ -248,6 +261,7 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin,
parentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
parentKey (str): API URL of the album artist (/library/metadata/<parentRatingKey>).
parentRatingKey (int): Unique key identifying the album artist.
parentTheme (str): URL to artist theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>).
parentThumb (str): URL to album artist thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the album artist.
rating (float): Album rating (7.9; 9.8; 8.1).
@ -274,6 +288,7 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin,
self.parentGuid = data.attrib.get('parentGuid')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentTheme = data.attrib.get('parentTheme')
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.rating = utils.cast(float, data.attrib.get('rating'))
@ -298,10 +313,14 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin,
:exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing.
"""
key = '/library/metadata/%s/children' % self.ratingKey
if title is not None:
if title is not None and not isinstance(title, int):
return self.fetchItem(key, Track, title__iexact=title)
elif track is not None:
return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=track)
elif track is not None or isinstance(title, int):
if isinstance(title, int):
index = title
else:
index = track
return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=index)
raise BadRequest('Missing argument: title or track is required')
def tracks(self, **kwargs):
@ -337,8 +356,13 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin,
@utils.registerPlexObject
class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin,
CollectionMixin, MoodMixin):
class Track(
Audio, Playable,
ExtrasMixin, RatingMixin,
ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin,
TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin,
CollectionMixin, LabelMixin, MoodMixin
):
""" Represents a single Track.
Attributes:
@ -351,19 +375,23 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin,
grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
grandparentKey (str): API URL of the album artist (/library/metadata/<grandparentRatingKey>).
grandparentRatingKey (int): Unique key identifying the album artist.
grandparentTheme (str): URL to artist theme resource (/library/metadata/<grandparentRatingkey>/theme/<themeid>).
(/library/metadata/<grandparentRatingkey>/theme/<themeid>).
grandparentThumb (str): URL to album artist thumbnail image
(/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
grandparentTitle (str): Name of the album artist for the track.
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
media (List<:class:`~plexapi.media.Media`>): List of media objects.
originalTitle (str): The artist for the track.
parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9).
parentIndex (int): Album index.
parentIndex (int): Disc number of the track.
parentKey (str): API URL of the album (/library/metadata/<parentRatingKey>).
parentRatingKey (int): Unique key identifying the album.
parentThumb (str): URL to album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the album for the track.
primaryExtraKey (str) API URL for the primary extra for the track.
ratingCount (int): Number of ratings contributing to the rating score.
skipCount (int): Number of times the track has been skipped.
viewOffset (int): View offset in milliseconds.
year (int): Year the track was released.
"""
@ -381,18 +409,21 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin,
self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey')
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
self.grandparentTheme = data.attrib.get('grandparentTheme')
self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle')
self.labels = self.findItems(data, media.Label)
self.media = self.findItems(data, media.Media)
self.originalTitle = data.attrib.get('originalTitle')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
self.skipCount = utils.cast(int, data.attrib.get('skipCount'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))

View file

@ -53,7 +53,9 @@ class PlexObject(object):
if data is not None:
self._loadData(data)
self._details_key = self._buildDetailsKey()
self._autoReload = False
self._overwriteNone = True
self._edits = None # Save batch edits for a single API call
self._autoReload = True # Automatically reload the object when accessing a missing attribute
def __repr__(self):
uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri'))
@ -65,9 +67,9 @@ class PlexObject(object):
if attr in _DONT_OVERWRITE_SESSION_KEYS and value == []:
value = getattr(self, attr, [])
autoReload = self.__dict__.get('_autoReload')
# Don't overwrite an attr with None unless it's a private variable or not auto reload
if value is not None or attr.startswith('_') or attr not in self.__dict__ or not autoReload:
overwriteNone = self.__dict__.get('_overwriteNone')
# Don't overwrite an attr with None unless it's a private variable or overwrite None is True
if value is not None or attr.startswith('_') or attr not in self.__dict__ or overwriteNone:
self.__dict__[attr] = value
def _clean(self, value):
@ -169,9 +171,14 @@ class PlexObject(object):
raise BadRequest('ekey was not provided')
if isinstance(ekey, int):
ekey = '/library/metadata/%s' % ekey
for elem in self._server.query(ekey):
data = self._server.query(ekey)
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
for elem in data:
if self._checkAttrs(elem, **kwargs):
return self._buildItem(elem, cls, ekey)
item = self._buildItem(elem, cls, ekey)
if librarySectionID:
item.librarySectionID = librarySectionID
return item
clsname = cls.__name__ if cls else 'None'
raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs))
@ -196,7 +203,7 @@ class PlexObject(object):
Any XML attribute can be filtered when fetching results. Filtering is done before
the Python objects are built to help keep things speedy. For example, passing in
``viewCount=0`` will only return matching items where the view count is ``0``.
Note that case matters when specifying attributes. Attributes futher down in the XML
Note that case matters when specifying attributes. Attributes further down in the XML
tree can be filtered by *prepending* the attribute with each element tag ``Tag__``.
Examples:
@ -228,12 +235,12 @@ class PlexObject(object):
* ``__exists`` (*bool*): Value is or is not present in the attrs.
* ``__gt``: Value is greater than specified arg.
* ``__gte``: Value is greater than or equal to specified arg.
* ``__icontains``: Case insensative value contains specified arg.
* ``__iendswith``: Case insensative value ends with specified arg.
* ``__iexact``: Case insensative value matches specified arg.
* ``__icontains``: Case insensitive value contains specified arg.
* ``__iendswith``: Case insensitive value ends with specified arg.
* ``__iexact``: Case insensitive value matches specified arg.
* ``__in``: Value is in a specified list or tuple.
* ``__iregex``: Case insensative value matches the specified regular expression.
* ``__istartswith``: Case insensative value starts with specified arg.
* ``__iregex``: Case insensitive value matches the specified regular expression.
* ``__istartswith``: Case insensitive value starts with specified arg.
* ``__lt``: Value is less than specified arg.
* ``__lte``: Value is less than or equal to specified arg.
* ``__regex``: Value matches the specified regular expression.
@ -276,9 +283,9 @@ class PlexObject(object):
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
# rtag to iter on a specific root tag using breadth-first search
if rtag:
data = next(data.iter(rtag), [])
data = next(utils.iterXMLBFS(data, rtag), [])
# loop through all data elements to find matches
items = []
for elem in data:
@ -298,9 +305,9 @@ class PlexObject(object):
def listAttrs(self, data, attr, rtag=None, **kwargs):
""" Return a list of values from matching attribute. """
results = []
# rtag to iter on a specific root tag
# rtag to iter on a specific root tag using breadth-first search
if rtag:
data = next(data.iter(rtag), [])
data = next(utils.iterXMLBFS(data, rtag), [])
for elem in data:
kwargs['%s__exists' % attr] = True
if self._checkAttrs(elem, **kwargs):
@ -340,7 +347,7 @@ class PlexObject(object):
"""
return self._reload(key=key, **kwargs)
def _reload(self, key=None, _autoReload=False, **kwargs):
def _reload(self, key=None, _overwriteNone=True, **kwargs):
""" Perform the actual reload. """
details_key = self._buildDetailsKey(**kwargs) if kwargs else self._details_key
key = key or details_key or self.key
@ -348,9 +355,9 @@ class PlexObject(object):
raise Unsupported('Cannot reload an object not built from a URL.')
self._initpath = key
data = self._server.query(key)
self._autoReload = _autoReload
self._overwriteNone = _overwriteNone
self._loadData(data[0])
self._autoReload = False
self._overwriteNone = True
return self
def _checkAttrs(self, elem, **kwargs):
@ -392,7 +399,7 @@ class PlexObject(object):
# check were looking for the tag
if attr.lower() == 'etag':
return [elem.tag]
# loop through attrs so we can perform case-insensative match
# loop through attrs so we can perform case-insensitive match
for _attr, value in elem.attrib.items():
if attr.lower() == _attr.lower():
return [value]
@ -414,6 +421,10 @@ class PlexObject(object):
def _loadData(self, data):
raise NotImplementedError('Abstract method not implemented.')
@property
def _searchType(self):
return self.TYPE
class PlexPartialObject(PlexObject):
""" Not all objects in the Plex listings return the complete list of elements
@ -455,20 +466,21 @@ class PlexPartialObject(PlexObject):
def __getattribute__(self, attr):
# Dragons inside.. :-/
value = super(PlexPartialObject, self).__getattribute__(attr)
# Check a few cases where we dont 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_OVERWRITE_SESSION_KEYS: return value
if attr in USER_DONT_RELOAD_FOR_KEYS: return value
if attr.startswith('_'): return value
if value not in (None, []): return value
if self.isFullObject(): return value
if self._autoReload is False: return value
# Log the reload.
clsname = self.__class__.__name__
title = self.__dict__.get('title', self.__dict__.get('name'))
objname = "%s '%s'" % (clsname, title) if title else clsname
log.debug("Reloading %s for attr '%s'", objname, attr)
# Reload and return the value
self._reload(_autoReload=True)
self._reload()
return super(PlexPartialObject, self).__getattribute__(attr)
def analyze(self):
@ -507,44 +519,79 @@ class PlexPartialObject(PlexObject):
def _edit(self, **kwargs):
""" Actually edit an object. """
if isinstance(self._edits, dict):
self._edits.update(kwargs)
return self
if 'id' not in kwargs:
kwargs['id'] = self.ratingKey
if 'type' not in kwargs:
kwargs['type'] = utils.searchType(self.type)
kwargs['type'] = utils.searchType(self._searchType)
part = '/library/sections/%s/all?%s' % (self.librarySectionID,
urlencode(kwargs))
part = '/library/sections/%s/all%s' % (self.librarySectionID,
utils.joinArgs(kwargs))
self._server.query(part, method=self._server._session.put)
return self
def edit(self, **kwargs):
""" Edit an object.
Note: This is a low level method and you need to know all the field/tag keys.
See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin`
for individual field and tag editing methods.
Parameters:
kwargs (dict): Dict of settings to edit.
Example:
{'type': 1,
.. code-block:: python
edits = {
'type': 1,
'id': movie.ratingKey,
'collection[0].tag.tag': 'Super',
'collection.locked': 0}
"""
self._edit(**kwargs)
'title.value': 'A new title',
'title.locked': 1,
'summary.value': 'This is a summary.',
'summary.locked': 1,
'collection[0].tag.tag': 'A tag',
'collection.locked': 1}
}
movie.edit(**edits)
def _edit_tags(self, tag, items, locked=True, remove=False):
""" Helper to edit tags.
Parameters:
tag (str): Tag name.
items (list): List of tags to add.
locked (bool): True to lock the field.
remove (bool): True to remove the tags in items.
"""
if not isinstance(items, list):
items = [items]
value = getattr(self, utils.tag_plural(tag))
existing_tags = [t.tag for t in value if t and remove is False]
tag_edits = utils.tag_helper(tag, existing_tags + items, locked, remove)
self.edit(**tag_edits)
return self._edit(**kwargs)
def batchEdits(self):
""" Enable batch editing mode to save API calls.
Must call :func:`~plexapi.base.PlexPartialObject.saveEdits` at the end to save all the edits.
See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin`
for individual field and tag editing methods.
Example:
.. code-block:: python
# Batch editing multiple fields and tags in a single API call
Movie.batchEdits()
Movie.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline') \\
.addCollection('New Collection').removeGenre('Action').addLabel('Favorite')
Movie.saveEdits()
"""
self._edits = {}
return self
def saveEdits(self):
""" Save all the batch edits and automatically reload the object.
See :func:`~plexapi.base.PlexPartialObject.batchEdits` for details.
"""
if not isinstance(self._edits, dict):
raise BadRequest('Batch editing mode not enabled. Must call `batchEdits()` first.')
edits = self._edits
self._edits = None
self._edit(**edits)
return self.reload()
def refresh(self):
""" Refreshing a Library or individual item causes the metadata for the item to be
@ -746,7 +793,7 @@ class Playable(object):
key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey,
time, state)
self._server.query(key)
self._reload(_autoReload=True)
self._reload(_overwriteNone=False)
def updateTimeline(self, time, state='stopped', duration=None):
""" Set the timeline progress for this video.
@ -764,7 +811,7 @@ class Playable(object):
key = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s'
key %= (self.ratingKey, self.key, time, state, durationStr)
self._server.query(key)
self._reload(_autoReload=True)
self._reload(_overwriteNone=False)
class MediaContainer(PlexObject):

View file

@ -23,10 +23,10 @@ class PlexClient(PlexObject):
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional).
data (ElementTree): Response from PlexServer used to build this object (optional).
initpath (str): Path used to generate data.
baseurl (str): HTTP URL to connect dirrectly to this client.
baseurl (str): HTTP URL to connect directly to this client.
identifier (str): The resource/machine identifier for the desired client.
May be necessary when connecting to a specific proxied client (optional).
token (str): X-Plex-Token used for authenication (optional).
token (str): X-Plex-Token used for authentication (optional).
session (:class:`~requests.Session`): requests.Session object if you want more control (optional).
timeout (int): timeout in seconds on initial connect to client (default config.TIMEOUT).
@ -48,7 +48,7 @@ class PlexClient(PlexObject):
session (:class:`~requests.Session`): Session object used for connection.
state (str): Unknown
title (str): Name of this client (Johns iPhone, etc).
token (str): X-Plex-Token used for authenication
token (str): X-Plex-Token used for authentication
vendor (str): Unknown
version (str): Device version (4.6.1, etc).
_baseurl (str): HTTP address of the client.
@ -131,7 +131,7 @@ class PlexClient(PlexObject):
self.platformVersion = data.attrib.get('platformVersion')
self.title = data.attrib.get('title') or data.attrib.get('name')
# Active session details
# Since protocolCapabilities is missing from /sessions we cant really control this player without
# Since protocolCapabilities is missing from /sessions we can't really control this player without
# creating a client manually.
# Add this in next breaking release.
# if self._initpath == 'status/sessions':
@ -210,8 +210,8 @@ class PlexClient(PlexObject):
controller = command.split('/')[0]
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
if controller not in self.protocolCapabilities:
log.debug('Client %s doesnt support %s controller.'
'What your trying might not work' % (self.title, controller))
log.debug("Client %s doesn't support %s controller."
"What your trying might not work" % (self.title, controller))
proxy = self._proxyThroughServer if proxy is None else proxy
query = self._server.query if proxy else self.query
@ -318,21 +318,21 @@ class PlexClient(PlexObject):
Parameters:
media (:class:`~plexapi.media.Media`): Media object to navigate to.
**params (dict): Additional GET parameters to include with the command.
Raises:
:exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
"""
if not self._server:
raise Unsupported('A server must be specified before using this command.')
server_url = media._server._baseurl.split(':')
self.sendCommand('mirror/details', **dict({
'machineIdentifier': self._server.machineIdentifier,
command = {
'machineIdentifier': media._server.machineIdentifier,
'address': server_url[1].strip('/'),
'port': server_url[-1],
'key': media.key,
'protocol': server_url[0],
'token': media._server.createToken()
}, **params))
**params,
}
token = media._server.createToken()
if token:
command["token"] = token
self.sendCommand("mirror/details", **command)
# -------------------
# Playback Commands
@ -488,12 +488,7 @@ class PlexClient(PlexObject):
representing the beginning (default 0).
**params (dict): Optional additional parameters to include in the playback request. See
also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands
Raises:
:exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
"""
if not self._server:
raise Unsupported('A server must be specified before using this command.')
server_url = media._server._baseurl.split(':')
server_port = server_url[-1].strip('/')
@ -509,19 +504,24 @@ class PlexClient(PlexObject):
if mediatype == "audio":
mediatype = "music"
playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media)
self.sendCommand('playback/playMedia', **dict({
playqueue = media if isinstance(media, PlayQueue) else media._server.createPlayQueue(media)
command = {
'providerIdentifier': 'com.plexapp.plugins.library',
'machineIdentifier': self._server.machineIdentifier,
'machineIdentifier': media._server.machineIdentifier,
'protocol': server_url[0],
'address': server_url[1].strip('/'),
'port': server_port,
'offset': offset,
'key': media.key or playqueue.selectedItem.key,
'token': media._server.createToken(),
'type': mediatype,
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
}, **params))
**params,
}
token = media._server.createToken()
if token:
command["token"] = token
self.sendCommand("playback/playMedia", **command)
def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=DEFAULT_MTYPE):
""" Set multiple playback parameters at once.

View file

@ -5,14 +5,24 @@ from plexapi import media, utils
from plexapi.base import PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection
from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin
from plexapi.mixins import LabelMixin, SmartFilterMixin
from plexapi.mixins import (
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
LabelMixin
)
from plexapi.playqueue import PlayQueue
from plexapi.utils import deprecated
@utils.registerPlexObject
class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin, SmartFilterMixin):
class Collection(
PlexPartialObject,
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
LabelMixin
):
""" Represents a single Collection.
Attributes:
@ -22,9 +32,10 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
artBlurHash (str): BlurHash string for artwork image.
childCount (int): Number of items in the collection.
collectionMode (str): How the items in the collection are displayed.
collectionFilterBasedOnUser (int): Which user's activity is used for the collection filtering.
collectionMode (int): How the items in the collection are displayed.
collectionPublished (bool): True if the collection is published to the Plex homepage.
collectionSort (str): How to sort the items in the collection.
collectionSort (int): How to sort the items in the collection.
content (str): The filter URI string for smart collections.
contentRating (str) Content rating (PG-13; NR; TV-G).
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
@ -43,12 +54,13 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
smart (bool): True if the collection is a smart collection.
subtype (str): Media type of the items in the collection (movie, show, artist, or album).
summary (str): Summary of the collection.
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
thumbBlurHash (str): BlurHash string for thumbnail image.
title (str): Name of the collection.
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'collection'
updatedAt (datatime): Datetime the collection was updated.
updatedAt (datetime): Datetime the collection was updated.
userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars).
"""
TAG = 'Directory'
@ -60,6 +72,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash')
self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.collectionFilterBasedOnUser = utils.cast(int, data.attrib.get('collectionFilterBasedOnUser', '0'))
self.collectionMode = utils.cast(int, data.attrib.get('collectionMode', '-1'))
self.collectionPublished = utils.cast(bool, data.attrib.get('collectionPublished', '0'))
self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0'))
@ -81,6 +94,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
self.smart = utils.cast(bool, data.attrib.get('smart', '0'))
self.subtype = data.attrib.get('subtype')
self.summary = data.attrib.get('summary')
self.theme = data.attrib.get('theme')
self.thumb = data.attrib.get('thumb')
self.thumbBlurHash = data.attrib.get('thumbBlurHash')
self.title = data.attrib.get('title')
@ -184,6 +198,32 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
""" Alias to :func:`~plexapi.library.Collection.item`. """
return self.item(title)
def filterUserUpdate(self, user=None):
""" Update the collection filtering user advanced setting.
Parameters:
user (str): One of the following values:
"admin" (Always the server admin user),
"user" (User currently viewing the content)
Example:
.. code-block:: python
collection.updateMode(user="user")
"""
if not self.smart:
raise BadRequest('Cannot change collection filtering user for a non-smart collection.')
user_dict = {
'admin': 0,
'user': 1
}
key = user_dict.get(user)
if key is None:
raise BadRequest('Unknown collection filtering user: %s. Options %s' % (user, list(user_dict)))
self.editAdvanced(collectionFilterBasedOnUser=key)
def modeUpdate(self, mode=None):
""" Update the collection mode advanced setting.
@ -216,7 +256,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
Parameters:
sort (str): One of the following values:
"realease" (Order Collection by realease dates),
"release" (Order Collection by release dates),
"alpha" (Order Collection alphabetically),
"custom" (Custom collection order)
@ -226,6 +266,9 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
collection.updateSort(mode="alpha")
"""
if self.smart:
raise BadRequest('Cannot change collection order for a smart collection.')
sort_dict = {
'release': 0,
'alpha': 1,
@ -340,6 +383,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
}))
self._server.query(key, method=self._server._session.put)
@deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead')
def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs):
""" Edit the collection.
@ -364,7 +408,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
args['summary.locked'] = 1
args.update(kwargs)
super(Collection, self).edit(**args)
self._edit(**args)
def delete(self):
""" Delete the collection. """

View file

@ -62,4 +62,5 @@ def reset_base_headers():
'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME,
'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER,
'X-Plex-Sync-Version': '2',
'X-Plex-Features': 'external-media',
}

View file

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

View file

@ -15,7 +15,7 @@ import struct
class GDM:
"""Base class to discover GDM services.
Atrributes:
Attributes:
entries (List<dict>): List of server and/or client data discovered.
"""

View file

@ -43,7 +43,7 @@ class Library(PlexObject):
if elem.attrib.get('type') == cls.TYPE:
section = cls(self._server, elem, key)
self._sectionsByID[section.key] = section
self._sectionsByTitle[section.title.lower()] = section
self._sectionsByTitle[section.title.lower().strip()] = section
def sections(self):
""" Returns a list of all media sections in this library. Library sections may be any of
@ -59,10 +59,11 @@ class Library(PlexObject):
Parameters:
title (str): Title of the section to return.
"""
if not self._sectionsByTitle or title not in self._sectionsByTitle:
normalized_title = title.lower().strip()
if not self._sectionsByTitle or normalized_title not in self._sectionsByTitle:
self._loadSections()
try:
return self._sectionsByTitle[title.lower()]
return self._sectionsByTitle[normalized_title]
except KeyError:
raise NotFound('Invalid library section: %s' % title) from None
@ -125,7 +126,7 @@ class Library(PlexObject):
def search(self, title=None, libtype=None, **kwargs):
""" Searching within a library section is much more powerful. It seems certain
attributes on the media objects can be targeted to filter this search down
a bit, but I havent found the documentation for it.
a bit, but I haven't found the documentation for it.
Example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items
such as actor=<id> seem to work, but require you already know the id of the actor.
@ -396,7 +397,7 @@ class LibrarySection(PlexObject):
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.uuid = data.attrib.get('uuid')
# Private attrs as we dont want a reload.
# Private attrs as we don't want a reload.
self._filterTypes = None
self._fieldTypes = None
self._totalViewSize = None
@ -599,12 +600,13 @@ class LibrarySection(PlexObject):
return self.fetchItem(key, title__iexact=title)
def getGuid(self, guid):
""" Returns the media item with the specified external IMDB, TMDB, or TVDB ID.
""" Returns the media item with the specified external Plex, IMDB, TMDB, or TVDB ID.
Note: Only available for the Plex Movie and Plex TV Series agents.
Parameters:
guid (str): The external guid of the item to return.
Examples: IMDB ``imdb://tt0944947``, TMDB ``tmdb://1399``, TVDB ``tvdb://121361``.
Examples: Plex ``plex://show/5d9c086c46115600200aa2fe``
IMDB ``imdb://tt0944947``, TMDB ``tmdb://1399``, TVDB ``tvdb://121361``.
Raises:
:exc:`~plexapi.exceptions.NotFound`: The guid is not found in the library.
@ -613,18 +615,29 @@ class LibrarySection(PlexObject):
.. code-block:: python
result1 = library.getGuid('imdb://tt0944947')
result2 = library.getGuid('tmdb://1399')
result3 = library.getGuid('tvdb://121361')
result1 = library.getGuid('plex://show/5d9c086c46115600200aa2fe')
result2 = library.getGuid('imdb://tt0944947')
result3 = library.getGuid('tmdb://1399')
result4 = library.getGuid('tvdb://121361')
# Alternatively, create your own guid lookup dictionary for faster performance
guidLookup = {guid.id: item for item in library.all() for guid in item.guids}
result1 = guidLookup['imdb://tt0944947']
result2 = guidLookup['tmdb://1399']
result3 = guidLookup['tvdb://121361']
guidLookup = {}
for item in library.all():
guidLookup[item.guid] = item
guidLookup.update({guid.id for guid in item.guids}}
result1 = guidLookup['plex://show/5d9c086c46115600200aa2fe']
result2 = guidLookup['imdb://tt0944947']
result4 = guidLookup['tmdb://1399']
result5 = guidLookup['tvdb://121361']
"""
try:
if guid.startswith('plex://'):
result = self.search(guid=guid)[0]
return result
else:
dummy = self.search(maxresults=1)[0]
match = dummy.matches(agent=self.agent, title=guid.replace('://', '-'))
return self.search(guid=match[0].guid)[0]
@ -1271,7 +1284,7 @@ class LibrarySection(PlexObject):
* See :func:`~plexapi.library.LibrarySection.listOperators` to get a list of all available operators.
* See :func:`~plexapi.library.LibrarySection.listFilterChoices` to get a list of all available filter values.
The following filter fields are just some examples of the possible filters. The list is not exaustive,
The following filter fields are just some examples of the possible filters. The list is not exhaustive,
and not all filters apply to all library types.
* **actor** (:class:`~plexapi.media.MediaTag`): Search for the name of an actor.
@ -1334,7 +1347,7 @@ class LibrarySection(PlexObject):
Some filters may be prefixed by the ``libtype`` separated by a ``.`` (e.g. ``show.collection``,
``episode.title``, ``artist.style``, ``album.genre``, ``track.userRating``, etc.). This should not be
confused with the ``libtype`` parameter. If no ``libtype`` prefix is provided, then the default library
type is assumed. For example, in a TV show library ``viewCout`` is assumed to be ``show.viewCount``.
type is assumed. For example, in a TV show library ``viewCount`` is assumed to be ``show.viewCount``.
If you want to filter using episode view count then you must specify ``episode.viewCount`` explicitly.
In addition, if the filter does not exist for the default library type it will fallback to the most
specific ``libtype`` available. For example, ``show.unwatched`` does not exists so it will fallback to
@ -2236,16 +2249,61 @@ class FilteringType(PlexObject):
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
# Add additional manual sorts and fields which are available
self._librarySectionID = self._parent().key
# Add additional manual filters, sorts, and fields which are available
# but not exposed on the Plex server
self.filters += self._manualFilters()
self.sorts += self._manualSorts()
self.fields += self._manualFields()
def _manualFilters(self):
""" Manually add additional filters which are available
but not exposed on the Plex server.
"""
# Filters: (filter, type, title)
additionalFilters = [
]
if self.type == 'season':
additionalFilters.extend([
('label', 'string', 'Labels')
])
elif self.type == 'episode':
additionalFilters.extend([
('label', 'string', 'Labels')
])
elif self.type == 'artist':
additionalFilters.extend([
('label', 'string', 'Labels')
])
elif self.type == 'track':
additionalFilters.extend([
('label', 'string', 'Labels')
])
elif self.type == 'collection':
additionalFilters.extend([
('label', 'string', 'Labels')
])
manualFilters = []
for filterTag, filterType, filterTitle in additionalFilters:
filterKey = '/library/sections/%s/%s?type=%s' % (
self._librarySectionID, filterTag, utils.searchType(self.type)
)
filterXML = (
'<Filter filter="%s" filterType="%s" key="%s" title="%s" type="filter" />'
% (filterTag, filterType, filterKey, filterTitle)
)
manualFilters.append(self._manuallyLoadXML(filterXML, FilteringFilter))
return manualFilters
def _manualSorts(self):
""" Manually add additional sorts which are available
but not exposed on the Plex server.
"""
# Sorts: key, dir, title
# Sorts: (key, dir, title)
additionalSorts = [
('guid', 'asc', 'Guid'),
('id', 'asc', 'Rating Key'),
@ -2275,8 +2333,10 @@ class FilteringType(PlexObject):
manualSorts = []
for sortField, sortDir, sortTitle in additionalSorts:
sortXML = ('<Sort defaultDirection="%s" descKey="%s:desc" key="%s" title="%s" />'
% (sortDir, sortField, sortField, sortTitle))
sortXML = (
'<Sort defaultDirection="%s" descKey="%s:desc" key="%s" title="%s" />'
% (sortDir, sortField, sortField, sortTitle)
)
manualSorts.append(self._manuallyLoadXML(sortXML, FilteringSort))
return manualSorts
@ -2285,7 +2345,7 @@ class FilteringType(PlexObject):
""" Manually add additional fields which are available
but not exposed on the Plex server.
"""
# Fields: key, type, title
# Fields: (key, type, title)
additionalFields = [
('guid', 'string', 'Guid'),
('id', 'integer', 'Rating Key'),
@ -2311,31 +2371,41 @@ class FilteringType(PlexObject):
additionalFields.extend([
('addedAt', 'date', 'Date Season Added'),
('unviewedLeafCount', 'integer', 'Episode Unplayed Count'),
('year', 'integer', 'Season Year')
('year', 'integer', 'Season Year'),
('label', 'tag', 'Label')
])
elif self.type == 'episode':
additionalFields.extend([
('audienceRating', 'integer', 'Audience Rating'),
('duration', 'integer', 'Duration'),
('rating', 'integer', 'Critic Rating'),
('viewOffset', 'integer', 'View Offset')
('viewOffset', 'integer', 'View Offset'),
('label', 'tag', 'Label')
])
elif self.type == 'artist':
additionalFields.extend([
('label', 'tag', 'Label')
])
elif self.type == 'track':
additionalFields.extend([
('duration', 'integer', 'Duration'),
('viewOffset', 'integer', 'View Offset')
('viewOffset', 'integer', 'View Offset'),
('label', 'tag', 'Label')
])
elif self.type == 'collection':
additionalFields.extend([
('addedAt', 'date', 'Date Added')
('addedAt', 'date', 'Date Added'),
('label', 'tag', 'Label')
])
prefix = '' if self.type == 'movie' else self.type + '.'
manualFields = []
for field, fieldType, fieldTitle in additionalFields:
fieldXML = ('<Field key="%s%s" title="%s" type="%s"/>'
% (prefix, field, fieldTitle, fieldType))
fieldXML = (
'<Field key="%s%s" title="%s" type="%s"/>'
% (prefix, field, fieldTitle, fieldType)
)
manualFields.append(self._manuallyLoadXML(fieldXML, FilteringField))
return manualFields

View file

@ -39,7 +39,7 @@ class Media(PlexObject):
<Photo_only_attributes>: The following attributes are only available for photos.
* aperture (str): The apeture used to take the photo.
* aperture (str): The aperture used to take the photo.
* exposure (str): The exposure used to take the photo.
* iso (int): The iso used to take the photo.
* lens (str): The lens used to take the photo.
@ -93,7 +93,7 @@ class Media(PlexObject):
try:
return self._server.query(part, method=self._server._session.delete)
except BadRequest:
log.error("Failed to delete %s. This could be because you havn't allowed "
log.error("Failed to delete %s. This could be because you haven't allowed "
"items to be deleted" % part)
raise
@ -224,7 +224,7 @@ class MediaPartStream(PlexObject):
id (int): The unique ID for this stream on the server.
index (int): The index of the stream.
language (str): The language of the stream (ex: English, ไทย).
languageCode (str): The Ascii language code of the stream (ex: eng, tha).
languageCode (str): The ASCII language code of the stream (ex: eng, tha).
requiredBandwidths (str): The required bandwidths to stream the file.
selected (bool): True if this stream is selected.
streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`,
@ -283,8 +283,8 @@ class VideoStream(MediaPartStream):
duration (int): The duration of video stream in milliseconds.
frameRate (float): The frame rate of the video stream (ex: 23.976).
frameRateMode (str): The frame rate mode of the video stream.
hasScallingMatrix (bool): True if video stream has a scaling matrix.
height (int): The hight of the video stream in pixels (ex: 1080).
hasScalingMatrix (bool): True if video stream has a scaling matrix.
height (int): The height of the video stream in pixels (ex: 1080).
level (int): The codec encoding level of the video stream (ex: 41).
profile (str): The profile of the video stream (ex: asp).
pixelAspectRatio (str): The pixel aspect ratio of the video stream.
@ -323,7 +323,7 @@ class VideoStream(MediaPartStream):
self.duration = utils.cast(int, data.attrib.get('duration'))
self.frameRate = utils.cast(float, data.attrib.get('frameRate'))
self.frameRateMode = data.attrib.get('frameRateMode')
self.hasScallingMatrix = utils.cast(bool, data.attrib.get('hasScallingMatrix'))
self.hasScalingMatrix = utils.cast(bool, data.attrib.get('hasScalingMatrix'))
self.height = utils.cast(int, data.attrib.get('height'))
self.level = utils.cast(int, data.attrib.get('level'))
self.profile = data.attrib.get('profile')
@ -400,7 +400,7 @@ class SubtitleStream(MediaPartStream):
container (str): The container of the subtitle stream.
forced (bool): True if this is a forced subtitle.
format (str): The format of the subtitle stream (ex: srt).
headerCommpression (str): The header compression of the subtitle stream.
headerCompression (str): The header compression of the subtitle stream.
transient (str): Unknown.
"""
TAG = 'Stream'
@ -468,7 +468,7 @@ class TranscodeSession(PlexObject):
audioDecision (str): The transcode decision for the audio stream.
complete (bool): True if the transcode is complete.
container (str): The container of the transcoded media.
context (str): The context for the transcode sesson.
context (str): The context for the transcode session.
duration (int): The duration of the transcoded media in milliseconds.
height (int): The height of the transcoded media in pixels.
key (str): API URL (ex: /transcode/sessions/<id>).
@ -917,19 +917,17 @@ class Review(PlexObject):
self.text = data.attrib.get('text')
class BaseImage(PlexObject):
""" Base class for all Art, Banner, and Poster objects.
class BaseResource(PlexObject):
""" Base class for all Art, Banner, Poster, and Theme objects.
Attributes:
TAG (str): 'Photo'
TAG (str): 'Photo' or 'Track'
key (str): API URL (/library/metadata/<ratingkey>).
provider (str): The source of the poster or art.
ratingKey (str): Unique key identifying the poster or art.
selected (bool): True if the poster or art is currently selected.
thumb (str): The URL to retrieve the poster or art thumbnail.
provider (str): The source of the art or poster, None for Theme objects.
ratingKey (str): Unique key identifying the resource.
selected (bool): True if the resource is currently selected.
thumb (str): The URL to retrieve the resource thumbnail.
"""
TAG = 'Photo'
def _loadData(self, data):
self._data = data
self.key = data.attrib.get('key')
@ -947,16 +945,24 @@ class BaseImage(PlexObject):
pass
class Art(BaseImage):
class Art(BaseResource):
""" Represents a single Art object. """
TAG = 'Photo'
class Banner(BaseImage):
class Banner(BaseResource):
""" Represents a single Banner object. """
TAG = 'Photo'
class Poster(BaseImage):
class Poster(BaseResource):
""" Represents a single Poster object. """
TAG = 'Photo'
class Theme(BaseResource):
""" Represents a single Theme object. """
TAG = 'Track'
@utils.registerPlexObject
@ -1106,3 +1112,41 @@ class AgentMediaType(Agent):
@deprecated('use "languageCodes" instead')
def languageCode(self):
return self.languageCodes
@utils.registerPlexObject
class Availability(PlexObject):
""" Represents a single online streaming service Availability.
Attributes:
TAG (str): 'Availability'
country (str): The streaming service country.
offerType (str): Subscription, buy, or rent from the streaming service.
platform (str): The platform slug for the streaming service.
platformColorThumb (str): Thumbnail icon for the streaming service.
platformInfo (str): The streaming service platform info.
platformUrl (str): The URL to the media on the streaming service.
price (float): The price to buy or rent from the streaming service.
priceDescription (str): The display price to buy or rent from the streaming service.
quality (str): The video quality on the streaming service.
title (str): The title of the streaming service.
url (str): The Plex availability URL.
"""
TAG = 'Availability'
def __repr__(self):
return f'<{self.__class__.__name__}:{self.platform}:{self.offerType}>'
def _loadData(self, data):
self._data = data
self.country = data.attrib.get('country')
self.offerType = data.attrib.get('offerType')
self.platform = data.attrib.get('platform')
self.platformColorThumb = data.attrib.get('platformColorThumb')
self.platformInfo = data.attrib.get('platformInfo')
self.platformUrl = data.attrib.get('platformUrl')
self.price = utils.cast(float, data.attrib.get('price'))
self.priceDescription = data.attrib.get('priceDescription')
self.quality = data.attrib.get('quality')
self.title = data.attrib.get('title')
self.url = data.attrib.get('url')

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import copy
import html
import threading
import time
from xml.etree import ElementTree
@ -52,7 +53,7 @@ class MyPlexAccount(PlexObject):
roles: (List<str>) Lit of account roles. Plexpass membership listed here.
scrobbleTypes (str): Description
secure (bool): Description
subscriptionActive (bool): True if your subsctiption is active.
subscriptionActive (bool): True if your subscription is active.
subscriptionFeatures: (List<str>) List of features allowed on your subscription.
subscriptionPlan (str): Name of subscription plan.
subscriptionStatus (str): String representation of `subscriptionActive`.
@ -72,14 +73,12 @@ class MyPlexAccount(PlexObject):
REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
OPTOUTS = 'https://plex.tv/api/v2/user/%(userUUID)s/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
# Hub sections
VOD = 'https://vod.provider.plex.tv/' # get
WEBSHOWS = 'https://webshows.provider.plex.tv/' # get
NEWS = 'https://news.provider.plex.tv/' # get
PODCASTS = 'https://podcasts.provider.plex.tv/' # get
MUSIC = 'https://music.provider.plex.tv/' # get
VOD = 'https://vod.provider.plex.tv' # get
MUSIC = 'https://music.provider.plex.tv' # get
METADATA = 'https://metadata.provider.plex.tv'
# Key may someday switch to the following url. For now the current value works.
# https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId}
key = 'https://plex.tv/users/account'
@ -182,6 +181,8 @@ class MyPlexAccount(PlexObject):
raise NotFound(message)
else:
raise BadRequest(message)
if headers.get('Accept') == 'application/json':
return response.json()
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
@ -228,7 +229,7 @@ class MyPlexAccount(PlexObject):
of the user to be added.
server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier
containing the library sections to share.
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names
to be shared (default None). `sections` must be defined in order to update shared libraries.
allowSync (Bool): Set True to allow user to sync content.
allowCameraUpload (Bool): Set True to allow user to upload photos.
@ -268,7 +269,7 @@ class MyPlexAccount(PlexObject):
of the user to be added.
server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier
containing the library sections to share.
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names
to be shared (default None). `sections` must be defined in order to update shared libraries.
allowSync (Bool): Set True to allow user to sync content.
allowCameraUpload (Bool): Set True to allow user to upload photos.
@ -317,7 +318,7 @@ class MyPlexAccount(PlexObject):
of the user to be added.
server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier
containing the library sections to share.
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names
to be shared (default None). `sections` must be defined in order to update shared libraries.
allowSync (Bool): Set True to allow user to sync content.
allowCameraUpload (Bool): Set True to allow user to upload photos.
@ -420,7 +421,7 @@ class MyPlexAccount(PlexObject):
of the user to be updated.
server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier
containing the library sections to share.
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names
to be shared (default None). `sections` must be defined in order to update shared libraries.
removeSections (Bool): Set True to remove all shares. Supersedes sections.
allowSync (Bool): Set True to allow user to sync content.
@ -565,7 +566,7 @@ class MyPlexAccount(PlexObject):
""" Converts friend filters to a string representation for transport. """
values = []
for key, vals in filterDict.items():
if key not in ('contentRating', 'label'):
if key not in ('contentRating', 'label', 'contentRating!', 'label!'):
raise BadRequest('Unknown filter key: %s', key)
values.append('%s=%s' % (key, '%2C'.join(vals)))
return '|'.join(values)
@ -614,7 +615,7 @@ class MyPlexAccount(PlexObject):
clientId (str): an identifier of a client to query SyncItems for.
If both `client` and `clientId` provided the client would be preferred.
If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier.
If neither `client` nor `clientId` provided the clientId would be set to current clients's identifier.
"""
if client:
clientId = client.clientIdentifier
@ -635,14 +636,14 @@ class MyPlexAccount(PlexObject):
sync_item (:class:`~plexapi.sync.SyncItem`): prepared SyncItem object with all fields set.
If both `client` and `clientId` provided the client would be preferred.
If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier.
If neither `client` nor `clientId` provided the clientId would be set to current clients's identifier.
Returns:
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
Raises:
:exc:`~plexapi.exceptions.BadRequest`: When client with provided clientId wasn`t found.
:exc:`~plexapi.exceptions.BadRequest`: Provided client doesn`t provides `sync-target`.
:exc:`~plexapi.exceptions.BadRequest`: When client with provided clientId wasn't found.
:exc:`~plexapi.exceptions.BadRequest`: Provided client doesn't provides `sync-target`.
"""
if not client and not clientId:
clientId = X_PLEX_IDENTIFIER
@ -657,7 +658,7 @@ class MyPlexAccount(PlexObject):
raise BadRequest('Unable to find client by clientId=%s', clientId)
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")
params = {
'SyncItem[title]': sync_item.title,
@ -698,6 +699,7 @@ class MyPlexAccount(PlexObject):
def history(self, maxresults=9999999, mindate=None):
""" Get Play History for all library sections on all servers for the owner.
Parameters:
maxresults (int): Only return the specified number of results (optional).
mindate (datetime): Min datetime to return results from.
@ -709,47 +711,155 @@ class MyPlexAccount(PlexObject):
hist.extend(conn.history(maxresults=maxresults, mindate=mindate, accountID=1))
return hist
def onlineMediaSources(self):
""" Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut`
"""
url = self.OPTOUTS.format(userUUID=self.uuid)
elem = self.query(url)
return self.findItems(elem, cls=AccountOptOut, etag='optOut')
def videoOnDemand(self):
""" Returns a list of VOD Hub items :class:`~plexapi.library.Hub`
"""
req = requests.get(self.VOD + 'hubs/', headers={'X-Plex-Token': self._token})
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)
def webShows(self):
""" Returns a list of Webshow Hub items :class:`~plexapi.library.Hub`
"""
req = requests.get(self.WEBSHOWS + 'hubs/', headers={'X-Plex-Token': self._token})
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)
def news(self):
""" Returns a list of News Hub items :class:`~plexapi.library.Hub`
"""
req = requests.get(self.NEWS + 'hubs/sections/all', headers={'X-Plex-Token': self._token})
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)
def podcasts(self):
""" Returns a list of Podcasts Hub items :class:`~plexapi.library.Hub`
"""
req = requests.get(self.PODCASTS + 'hubs/', headers={'X-Plex-Token': self._token})
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)
data = self.query(f'{self.VOD}/hubs')
return self.findItems(data)
def tidal(self):
""" Returns a list of tidal Hub items :class:`~plexapi.library.Hub`
"""
req = requests.get(self.MUSIC + 'hubs/', headers={'X-Plex-Token': self._token})
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)
data = self.query(f'{self.MUSIC}/hubs')
return self.findItems(data)
def watchlist(self, filter=None, sort=None, libtype=None, **kwargs):
""" Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` items in the user's watchlist.
Note: The objects returned are from Plex's online metadata. To get the matching item on a Plex server,
search for the media using the guid.
Parameters:
filter (str, optional): 'available' or 'released' to only return items that are available or released,
otherwise return all items.
sort (str, optional): In the format ``field:dir``. Available fields are ``watchlistedAt`` (Added At),
``titleSort`` (Title), ``originallyAvailableAt`` (Release Date), or ``rating`` (Critic Rating).
``dir`` can be ``asc`` or ``desc``.
libtype (str, optional): 'movie' or 'show' to only return movies or shows, otherwise return all items.
**kwargs (dict): Additional custom filters to apply to the search results.
Example:
.. code-block:: python
# Watchlist for released movies sorted by critic rating in descending order
watchlist = account.watchlist(filter='released', sort='rating:desc', libtype='movie')
item = watchlist[0] # First item in the watchlist
# Search for the item on a Plex server
result = plex.library.search(guid=item.guid, libtype=item.type)
def onlineMediaSources(self):
""" Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut`
"""
url = self.OPTOUTS % {'userUUID': self.uuid}
elem = self.query(url)
return self.findItems(elem, cls=AccountOptOut, etag='optOut')
params = {
'includeCollections': 1,
'includeExternalMedia': 1
}
if not filter:
filter = 'all'
if sort:
params['sort'] = sort
if libtype:
params['type'] = utils.searchType(libtype)
params.update(kwargs)
data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params)
return self.findItems(data)
def onWatchlist(self, item):
""" Returns True if the item is on the user's watchlist.
Parameters:
item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to check
if it is on the user's watchlist.
"""
ratingKey = item.guid.rsplit('/', 1)[-1]
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
return bool(data.find('UserState').attrib.get('watchlistedAt'))
def addToWatchlist(self, items):
""" Add media items to the user's watchlist
Parameters:
items (List): List of :class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`
objects to be added to the watchlist.
Raises:
:exc:`~plexapi.exceptions.BadRequest`: When trying to add invalid or existing
media to the watchlist.
"""
if not isinstance(items, list):
items = [items]
for item in items:
if self.onWatchlist(item):
raise BadRequest('"%s" is already on the watchlist' % item.title)
ratingKey = item.guid.rsplit('/', 1)[-1]
self.query(f'{self.METADATA}/actions/addToWatchlist?ratingKey={ratingKey}', method=self._session.put)
def removeFromWatchlist(self, items):
""" Remove media items from the user's watchlist
Parameters:
items (List): List of :class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`
objects to be added to the watchlist.
Raises:
:exc:`~plexapi.exceptions.BadRequest`: When trying to remove invalid or non-existing
media to the watchlist.
"""
if not isinstance(items, list):
items = [items]
for item in items:
if not self.onWatchlist(item):
raise BadRequest('"%s" is not on the watchlist' % item.title)
ratingKey = item.guid.rsplit('/', 1)[-1]
self.query(f'{self.METADATA}/actions/removeFromWatchlist?ratingKey={ratingKey}', method=self._session.put)
def searchDiscover(self, query, limit=30):
""" Search for movies and TV shows in Discover.
Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects.
Parameters:
query (str): Search query.
limit (int, optional): Limit to the specified number of results. Default 30.
"""
headers = {
'Accept': 'application/json'
}
params = {
'query': query,
'limit ': limit,
'searchTypes': 'movies,tv',
'includeMetadata': 1
}
data = self.query(f'{self.METADATA}/library/search', headers=headers, params=params)
searchResults = data['MediaContainer'].get('SearchResult', [])
results = []
for result in searchResults:
metadata = result['Metadata']
type = metadata['type']
if type == 'movie':
tag = 'Video'
elif type == 'show':
tag = 'Directory'
else:
continue
attrs = ''.join(f'{k}="{html.escape(str(v))}" ' for k, v in metadata.items())
xml = f'<{tag} {attrs}/>'
results.append(self._manuallyLoadXML(xml))
return results
def link(self, pin):
""" Link a device to the account using a pin code.
@ -790,7 +900,7 @@ class MyPlexUser(PlexObject):
restricted (str): Unknown.
servers (List<:class:`~plexapi.myplex.<MyPlexServerShare`>)): Servers shared with the user.
thumb (str): Link to the users avatar.
title (str): Seems to be an aliad for username.
title (str): Seems to be an alias for username.
username (str): User's username.
"""
TAG = 'User'
@ -1103,7 +1213,7 @@ class MyPlexResource(PlexObject):
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
"""
connections = self.preferred_connections(ssl, timeout, locations, schemes)
# Try connecting to all known resource connections in parellel, but
# Try connecting to all known resource connections in parallel, but
# only return the first server (in order) that provides a response.
cls = PlexServer if 'server' in self.provides else PlexClient
listargs = [[cls, url, self.accessToken, timeout] for url in connections]
@ -1215,7 +1325,7 @@ class MyPlexDevice(PlexObject):
""" Returns an instance of :class:`~plexapi.sync.SyncList` for current device.
Raises:
:exc:`~plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`.
:exc:`~plexapi.exceptions.BadRequest`: when the device doesn't provides `sync-target`.
"""
if 'sync-target' not in self.provides:
raise BadRequest('Requested syncList for device which do not provides sync-target')

View file

@ -5,11 +5,21 @@ from urllib.parse import quote_plus
from plexapi import media, utils, video
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest
from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, RatingMixin, TagMixin
from plexapi.mixins import (
RatingMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin,
SortTitleMixin, SummaryMixin, TitleMixin, PhotoCapturedTimeMixin,
TagMixin
)
@utils.registerPlexObject
class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin):
class Photoalbum(
PlexPartialObject,
RatingMixin,
ArtMixin, PosterMixin,
SortTitleMixin, SummaryMixin, TitleMixin
):
""" Represents a single Photoalbum (collection of photos).
Attributes:
@ -33,11 +43,12 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin):
title (str): Name of the photo album. (Trip to Disney World)
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'photo'
updatedAt (datatime): Datetime the photo album was updated.
updatedAt (datetime): Datetime the photo album was updated.
userRating (float): Rating of the photo album (0.0 - 10.0) equaling (0 stars - 5 stars).
"""
TAG = 'Directory'
TYPE = 'photo'
_searchType = 'photoalbum'
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
@ -109,7 +120,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin):
return self.episode(title)
def download(self, savepath=None, keep_original_name=False, subfolders=False):
""" Download all photos and clips from the photo ablum. See :func:`~plexapi.base.Playable.download` for details.
""" Download all photos and clips from the photo album. See :func:`~plexapi.base.Playable.download` for details.
Parameters:
savepath (str): Defaults to current working dir.
@ -131,7 +142,13 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin):
@utils.registerPlexObject
class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, TagMixin):
class Photo(
PlexPartialObject, Playable,
RatingMixin,
ArtUrlMixin, PosterUrlMixin,
PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin,
TagMixin
):
""" Represents a single Photo.
Attributes:
@ -164,7 +181,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi
title (str): Name of the photo.
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'photo'
updatedAt (datatime): Datetime the photo was updated.
updatedAt (datetime): Datetime the photo was updated.
userRating (float): Rating of the photo (0.0 - 10.0) equaling (0 stars - 5 stars).
year (int): Year the photo was taken.
"""
@ -223,7 +240,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi
elif self.parentKey:
return self._server.library.sectionByID(self.photoalbum().librarySectionID)
else:
raise BadRequest('Unable to get section for photo, can`t find librarySectionID')
raise BadRequest("Unable to get section for photo, can't find librarySectionID")
@property
def locations(self):

View file

@ -6,13 +6,17 @@ from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection
from plexapi.mixins import ArtMixin, PosterMixin, SmartFilterMixin
from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin
from plexapi.playqueue import PlayQueue
from plexapi.utils import deprecated
@utils.registerPlexObject
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin):
class Playlist(
PlexPartialObject, Playable,
SmartFilterMixin,
ArtMixin, PosterMixin
):
""" Represents a single Playlist.
Attributes:
@ -39,7 +43,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi
summary (str): Summary of the playlist.
title (str): Name of the playlist.
type (str): 'playlist'
updatedAt (datatime): Datetime the playlist was updated.
updatedAt (datetime): Datetime the playlist was updated.
"""
TAG = 'Playlist'
TYPE = 'playlist'

View file

@ -314,7 +314,7 @@ class PlexServer(PlexObject):
def myPlexAccount(self):
""" Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same
token to access this server. If you are not the owner of this PlexServer
you're likley to recieve an authentication error calling this.
you're likely to receive an authentication error calling this.
"""
if self._myPlexAccount is None:
from plexapi.myplex import MyPlexAccount
@ -323,7 +323,7 @@ class PlexServer(PlexObject):
def _myPlexClientPorts(self):
""" Sometimes the PlexServer does not properly advertise port numbers required
to connect. This attemps to look up device port number from plex.tv.
to connect. This attempts to look up device port number from plex.tv.
See issue #126: Make PlexServer.clients() more user friendly.
https://github.com/pkkid/python-plexapi/issues/126
"""
@ -393,7 +393,6 @@ class PlexServer(PlexObject):
"""
if isinstance(path, Path):
path = path.path
path = os.path.normpath(path)
paths = [p.path for p in self.browse(os.path.dirname(path), includeFiles=False)]
return path in paths
@ -524,9 +523,34 @@ class PlexServer(PlexObject):
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack)
return filepath
def butlerTasks(self):
""" Return a list of :class:`~plexapi.base.ButlerTask` objects. """
return self.fetchItems('/butler')
def runButlerTask(self, task):
""" Manually run a butler task immediately instead of waiting for the scheduled task to run.
Note: The butler task is run asynchronously. Check Plex Web to monitor activity.
Parameters:
task (str): The name of the task to run. (e.g. 'BackupDatabase')
Example:
.. code-block:: python
availableTasks = [task.name for task in plex.butlerTasks()]
print("Available butler tasks:", availableTasks)
"""
validTasks = [task.name for task in self.butlerTasks()]
if task not in validTasks:
raise BadRequest(
f'Invalid butler task: {task}. Available tasks are: {validTasks}'
)
self.query(f'/butler/{task}', method=self._session.post)
@deprecated('use "checkForUpdate" instead')
def check_for_update(self, force=True, download=False):
return self.checkForUpdate()
return self.checkForUpdate(force=force, download=download)
def checkForUpdate(self, force=True, download=False):
""" Returns a :class:`~plexapi.base.Release` object containing release info.
@ -730,7 +754,7 @@ class PlexServer(PlexObject):
return self.fetchItems('/transcode/sessions')
def startAlertListener(self, callback=None):
""" Creates a websocket connection to the Plex Server to optionally recieve
""" Creates a websocket connection to the Plex Server to optionally receive
notifications. These often include messages from Plex about media scans
as well as updates to currently running Transcode Sessions.
@ -738,7 +762,7 @@ class PlexServer(PlexObject):
>> pip install websocket-client
Parameters:
callback (func): Callback function to call on recieved messages.
callback (func): Callback function to call on received messages.
Raises:
:exc:`~plexapi.exception.Unsupported`: Websocket-client not installed.
@ -1078,7 +1102,7 @@ class SystemDevice(PlexObject):
Attributes:
TAG (str): 'Device'
clientIdentifier (str): The unique identifier for the device.
createdAt (datatime): Datetime the device was created.
createdAt (datetime): Datetime the device was created.
id (int): The ID of the device (not the same as :class:`~plexapi.myplex.MyPlexDevice` ID).
key (str): API URL (/devices/<id>)
name (str): The name of the device.
@ -1102,10 +1126,10 @@ class StatisticsBandwidth(PlexObject):
Attributes:
TAG (str): 'StatisticsBandwidth'
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
at (datatime): Datetime of the bandwidth data.
at (datetime): Datetime of the bandwidth data.
bytes (int): The total number of bytes for the specified time span.
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
lan (bool): True or False wheter the bandwidth is local or remote.
lan (bool): True or False whether the bandwidth is local or remote.
timespan (int): The time span for the bandwidth data.
1: months, 2: weeks, 3: days, 4: hours, 6: seconds.
@ -1143,7 +1167,7 @@ class StatisticsResources(PlexObject):
Attributes:
TAG (str): 'StatisticsResources'
at (datatime): Datetime of the resource data.
at (datetime): Datetime of the resource data.
hostCpuUtilization (float): The system CPU usage %.
hostMemoryUtilization (float): The Plex Media Server CPU usage %.
processCpuUtilization (float): The system RAM usage %.
@ -1166,3 +1190,28 @@ class StatisticsResources(PlexObject):
self.__class__.__name__,
self._clean(int(self.at.timestamp()))
] if p])
@utils.registerPlexObject
class ButlerTask(PlexObject):
""" Represents a single scheduled butler task.
Attributes:
TAG (str): 'ButlerTask'
description (str): The description of the task.
enabled (bool): Whether the task is enabled.
interval (int): The interval the task is run in days.
name (str): The name of the task.
scheduleRandomized (bool): Whether the task schedule is randomized.
title (str): The title of the task.
"""
TAG = 'ButlerTask'
def _loadData(self, data):
self._data = data
self.description = data.attrib.get('description')
self.enabled = utils.cast(bool, data.attrib.get('enabled'))
self.interval = utils.cast(int, data.attrib.get('interval'))
self.name = data.attrib.get('name')
self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized'))
self.title = data.attrib.get('title')

View file

@ -71,7 +71,7 @@ class Settings(PlexObject):
return self.groups().get(group, [])
def save(self):
""" Save any outstanding settnig changes to the :class:`~plexapi.server.PlexServer`. This
""" Save any outstanding setting changes to the :class:`~plexapi.server.PlexServer`. This
performs a full reload() of Settings after complete.
"""
params = {}
@ -100,7 +100,7 @@ class Setting(PlexObject):
hidden (bool): True if this is a hidden setting.
advanced (bool): True if this is an advanced setting.
group (str): Group name this setting is categorized as.
enumValues (list,dict): List or dictionary of valis values for this setting.
enumValues (list,dict): List or dictionary of valid values for this setting.
"""
_bool_cast = lambda x: bool(x == 'true' or x == '1')
_bool_str = lambda x: str(x).lower()
@ -143,7 +143,7 @@ class Setting(PlexObject):
return enumstr.split('|')
def set(self, value):
""" Set a new value for this setitng. NOTE: You must call plex.settings.save() for before
""" Set a new value for this setting. NOTE: You must call plex.settings.save() for before
any changes to setting values are persisted to the :class:`~plexapi.server.PlexServer`.
"""
# check a few things up front

View file

@ -14,7 +14,7 @@ class PlexSonosClient(PlexClient):
speakers linked to your Plex account. It also requires remote access to
be working properly.
More details on the Sonos integration are avaialble here:
More details on the Sonos integration are available here:
https://support.plex.tv/articles/218237558-requirements-for-using-plex-for-sonos/
The Sonos API emulates the Plex player control API closely:
@ -38,7 +38,7 @@ class PlexSonosClient(PlexClient):
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
session (:class:`~requests.Session`): Session object used for connection.
title (str): Name of this Sonos speaker.
token (str): X-Plex-Token used for authenication
token (str): X-Plex-Token used for authentication
_baseurl (str): Address of public Plex Sonos API endpoint.
_commandId (int): Counter for commands sent to Plex API.
_token (str): Token associated with linked Plex account.

View file

@ -9,6 +9,7 @@ import time
import unicodedata
import warnings
import zipfile
from collections import deque
from datetime import datetime
from getpass import getpass
from threading import Event, Thread
@ -55,7 +56,7 @@ class SecretsFilter(logging.Filter):
def registerPlexObject(cls):
""" Registry of library types we may come across when parsing XML. This allows us to
define a few helper functions to dynamically convery the XML into objects. See
define a few helper functions to dynamically convert the XML into objects. See
buildItem() below for an example.
"""
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
@ -72,7 +73,7 @@ def cast(func, value):
only support str, int, float, bool. Should be extended if needed.
Parameters:
func (func): Calback function to used cast to type (int, bool, float).
func (func): Callback function to used cast to type (int, bool, float).
value (any): value to be cast and returned.
"""
if value is not None:
@ -114,7 +115,7 @@ def lowerFirst(s):
def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover
""" Returns the value at the specified attrstr location within a nexted tree of
""" Returns the value at the specified attrstr location within a nested tree of
dicts, lists, tuples, functions, classes, etc. The lookup is done recursively
for each key in attrstr (split by by the delimiter) This function is heavily
influenced by the lookups used in Django templates.
@ -194,7 +195,7 @@ def threaded(callback, listargs):
args += [results, len(results)]
results.append(None)
threads.append(Thread(target=callback, args=args, kwargs=dict(job_is_done_event=job_is_done_event)))
threads[-1].setDaemon(True)
threads[-1].daemon = True
threads[-1].start()
while not job_is_done_event.is_set():
if all(not t.is_alive() for t in threads):
@ -304,7 +305,7 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4
filename (str): Filename of the downloaded file, default None.
savepath (str): Defaults to current working dir.
chunksize (int): What chunksize read/write at the time.
mocked (bool): Helper to do evertything except write the file.
mocked (bool): Helper to do everything except write the file.
unpack (bool): Unpack the zip file.
showstatus(bool): Display a progressbar.
@ -361,40 +362,6 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4
return fullpath
def tag_singular(tag):
if tag == 'countries':
return 'country'
elif tag == 'similar':
return 'similar'
else:
return tag[:-1]
def tag_plural(tag):
if tag == 'country':
return 'countries'
elif tag == 'similar':
return 'similar'
else:
return tag + 's'
def tag_helper(tag, items, locked=True, remove=False):
""" Simple tag helper for editing a object. """
if not isinstance(items, list):
items = [items]
data = {}
if not remove:
for i, item in enumerate(items):
tagname = '%s[%s].tag.tag' % (tag, i)
data[tagname] = item
if remove:
tagname = '%s[].tag.tag-' % tag
data[tagname] = ','.join(items)
data['%s.locked' % tag] = 1 if locked else 0
return data
def getMyPlexAccount(opts=None): # pragma: no cover
""" Helper function tries to get a MyPlex Account instance by checking
the the following locations for a username and password. This is
@ -485,7 +452,7 @@ def getAgentIdentifier(section, agent):
if agent in identifiers:
return ag.identifier
agents += identifiers
raise NotFound('Couldnt find "%s" in agents list (%s)' %
raise NotFound('Could not find "%s" in agents list (%s)' %
(agent, ', '.join(agents)))
@ -506,3 +473,15 @@ def deprecated(message, stacklevel=2):
return func(*args, **kwargs)
return wrapper
return decorator
def iterXMLBFS(root, tag=None):
""" Iterate through an XML tree using a breadth-first search.
If tag is specified, only return nodes with that tag.
"""
queue = deque([root])
while queue:
node = queue.popleft()
if tag is None or node.tag == tag:
yield node
queue.extend(list(node))

View file

@ -2,12 +2,17 @@
import os
from urllib.parse import quote_plus, urlencode
from plexapi import library, media, utils
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest
from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin
from plexapi.mixins import RatingMixin, SplitMergeMixin, UnmatchMatchMixin
from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
WatchlistMixin
)
class Video(PlexPartialObject):
@ -35,7 +40,7 @@ class Video(PlexPartialObject):
title (str): Name of the movie, show, season, episode, or clip.
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'movie', 'show', 'season', 'episode', or 'clip'.
updatedAt (datatime): Datetime the item was updated.
updatedAt (datetime): Datetime the item was updated.
userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars).
viewCount (int): Count of times the item was played.
"""
@ -76,7 +81,7 @@ class Video(PlexPartialObject):
return self._server.url(part, includeToken=True) if part else None
def markWatched(self):
""" Mark the video as palyed. """
""" Mark the video as played. """
key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
self._server.query(key)
@ -107,6 +112,15 @@ class Video(PlexPartialObject):
""" Returns str, default title for a new syncItem. """
return self.title
def audioStreams(self):
""" Returns a list of :class:`~plexapi.media.AudioStream` objects for all MediaParts. """
streams = []
parts = self.iterParts()
for part in parts:
streams += part.audioStreams()
return streams
def subtitleStreams(self):
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """
streams = []
@ -261,8 +275,15 @@ class Video(PlexPartialObject):
@utils.registerPlexObject
class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin):
class Movie(
Video, Playable,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
WatchlistMixin
):
""" Represents a single Movie.
Attributes:
@ -280,7 +301,7 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
languageOverride (str): Setting that indicates if a languge is used to override metadata
languageOverride (str): Setting that indicates if a language is used to override metadata
(eg. en-CA, None = Library default).
media (List<:class:`~plexapi.media.Media`>): List of media objects.
originallyAvailableAt (datetime): Datetime the movie was released.
@ -293,6 +314,7 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment).
tagline (str): Movie tag line (Back 2 Work; Who says men can't change?).
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
useOriginalTitle (int): Setting that indicates if the original title is used for the movie
(-1 = Library default, 0 = No, 1 = Yes).
viewOffset (int): View offset in milliseconds.
@ -331,6 +353,7 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin
self.similar = self.findItems(data, media.Similar)
self.studio = data.attrib.get('studio')
self.tagline = data.attrib.get('tagline')
self.theme = data.attrib.get('theme')
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.writers = self.findItems(data, media.Writer)
@ -365,20 +388,17 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin
data = self._server.query(self._details_key)
return self.findItems(data, media.Review, rtag='Video')
def extras(self):
""" Returns a list of :class:`~plexapi.video.Extra` objects. """
data = self._server.query(self._details_key)
return self.findItems(data, Extra, rtag='Extras')
def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
data = self._server.query(self._details_key)
return self.findItems(data, library.Hub, rtag='Related')
@utils.registerPlexObject
class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin,
CollectionMixin, GenreMixin, LabelMixin):
class Show(
Video,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, BannerMixin, PosterMixin, ThemeMixin,
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, GenreMixin, LabelMixin,
WatchlistMixin
):
""" Represents a single Show (including all seasons and episodes).
Attributes:
@ -407,7 +427,7 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Rat
index (int): Plex index number for the show.
key (str): API URL (/library/metadata/<ratingkey>).
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
languageOverride (str): Setting that indicates if a languge is used to override metadata
languageOverride (str): Setting that indicates if a language is used to override metadata
(eg. en-CA, None = Library default).
leafCount (int): Number of items in the show view.
locations (List<str>): List of folder paths where the show is found on disk.
@ -483,11 +503,6 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Rat
""" Returns True if the show is fully watched. """
return bool(self.viewedLeafCount == self.leafCount)
def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
data = self._server.query(self._details_key)
return self.findItems(data, library.Hub, rtag='Related')
def onDeck(self):
""" Returns show's On Deck :class:`~plexapi.video.Video` object or `None`.
If show is unwatched, return will likely be the first episode.
@ -574,7 +589,13 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Rat
@utils.registerPlexObject
class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin):
class Season(
Video,
ExtrasMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeUrlMixin,
SummaryMixin, TitleMixin,
CollectionMixin, LabelMixin
):
""" Represents a single Show Season (including all episodes).
Attributes:
@ -584,6 +605,7 @@ class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin):
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
index (int): Season number.
key (str): API URL (/library/metadata/<ratingkey>).
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
leafCount (int): Number of items in the season view.
parentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6).
parentIndex (int): Plex index number for the show.
@ -607,6 +629,7 @@ class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin):
self.guids = self.findItems(data, media.Guid)
self.index = utils.cast(int, data.attrib.get('index'))
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
@ -709,8 +732,13 @@ class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin):
@utils.registerPlexObject
class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
CollectionMixin, DirectorMixin, WriterMixin):
class Episode(
Video, Playable,
ExtrasMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeUrlMixin,
ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin,
CollectionMixin, DirectorMixin, LabelMixin, WriterMixin
):
""" Represents a single Shows Episode.
Attributes:
@ -733,6 +761,7 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
grandparentTitle (str): Name of the show for the episode.
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
index (int): Episode number.
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
markers (List<:class:`~plexapi.media.Marker`>): List of marker objects.
media (List<:class:`~plexapi.media.Media`>): List of media objects.
originallyAvailableAt (datetime): Datetime the episode was released.
@ -777,6 +806,7 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guids = self.findItems(data, media.Guid)
self.index = utils.cast(int, data.attrib.get('index'))
self.labels = self.findItems(data, media.Label)
self.markers = self.findItems(data, media.Marker)
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
@ -879,7 +909,10 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
@utils.registerPlexObject
class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin):
class Clip(
Video, Playable,
ArtUrlMixin, PosterUrlMixin
):
""" Represents a single Clip.
Attributes:

View file

@ -27,7 +27,7 @@ MarkupSafe==2.1.1
musicbrainzngs==0.7.1
packaging==21.3
paho-mqtt==1.6.1
plexapi==4.9.2
plexapi==4.11.0
portend==3.1.0
profilehooks==1.12.0
PyJWT==2.4.0