Bump plexapi from 4.13.4 to 4.15.0 (#2132)

* Bump plexapi from 4.13.4 to 4.15.0

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

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

---------

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

[skip ci]
This commit is contained in:
dependabot[bot] 2023-08-24 12:10:56 -07:00 committed by GitHub
parent 2c42150799
commit b2c16eba07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 988 additions and 534 deletions

View file

@ -3,19 +3,17 @@ import os
from urllib.parse import quote_plus
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject, PlexSession
from plexapi.exceptions import BadRequest, NotFound
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession
from plexapi.exceptions import BadRequest
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin,
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
ArtistEditMixins, AlbumEditMixins, TrackEditMixins
)
from plexapi.playlist import Playlist
class Audio(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin):
class Audio(PlexPartialObject, PlayedUnplayedMixin):
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
@ -132,8 +130,7 @@ class Artist(
Audio,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
SortTitleMixin, SummaryMixin, TitleMixin,
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
ArtistEditMixins
):
""" Represents a single Artist.
@ -181,14 +178,19 @@ class Artist(
Parameters:
title (str): Title of the album to return.
"""
try:
return self.section().search(title, libtype='album', filters={'artist.id': self.ratingKey})[0]
except IndexError:
raise NotFound(f"Unable to find album '{title}'") from None
return self.section().get(
title=title,
libtype='album',
filters={'artist.id': self.ratingKey}
)
def albums(self, **kwargs):
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
return self.section().search(libtype='album', filters={'artist.id': self.ratingKey}, **kwargs)
return self.section().search(
libtype='album',
filters={'artist.id': self.ratingKey},
**kwargs
)
def track(self, title=None, album=None, track=None):
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
@ -244,8 +246,7 @@ class Album(
Audio,
UnmatchMatchMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeUrlMixin,
OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin
AlbumEditMixins
):
""" Represents a single Album.
@ -364,14 +365,14 @@ class Track(
Audio, Playable,
ExtrasMixin, RatingMixin,
ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin,
TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin,
CollectionMixin, LabelMixin, MoodMixin
TrackEditMixins
):
""" Represents a single Track.
Attributes:
TAG (str): 'Directory'
TYPE (str): 'track'
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
chapterSource (str): Unknown
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
duration (int): Length of the track in milliseconds.
@ -407,6 +408,7 @@ class Track(
""" Load attribute values from Plex XML response. """
Audio._loadData(self, data)
Playable._loadData(self, data)
self.chapters = self.findItems(data, media.Chapter)
self.chapterSource = data.attrib.get('chapterSource')
self.collections = self.findItems(data, media.Collection)
self.duration = utils.cast(int, data.attrib.get('duration'))
@ -433,18 +435,6 @@ class Track(
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
def _prettyfilename(self):
""" Returns a filename for use in download. """
return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}'
def album(self):
""" Return the track's :class:`~plexapi.audio.Album`. """
return self.fetchItem(self.parentKey)
def artist(self):
""" Return the track's :class:`~plexapi.audio.Artist`. """
return self.fetchItem(self.grandparentKey)
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
@ -460,6 +450,18 @@ class Track(
""" Returns the track number. """
return self.index
def _prettyfilename(self):
""" Returns a filename for use in download. """
return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}'
def album(self):
""" Return the track's :class:`~plexapi.audio.Album`. """
return self.fetchItem(self.parentKey)
def artist(self):
""" Return the track's :class:`~plexapi.audio.Artist`. """
return self.fetchItem(self.grandparentKey)
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return f'{self.grandparentTitle} - {self.parentTitle} - {self.title}'
@ -480,3 +482,16 @@ class TrackSession(PlexSession, Track):
""" Load attribute values from Plex XML response. """
Track._loadData(self, data)
PlexSession._loadData(self, data)
@utils.registerPlexObject
class TrackHistory(PlexHistory, Track):
""" Represents a single Track history entry
loaded from :func:`~plexapi.server.PlexServer.history`.
"""
_HISTORYTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Track._loadData(self, data)
PlexHistory._loadData(self, data)

View file

@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
import re
import weakref
from functools import cached_property
from urllib.parse import urlencode
from xml.etree import ElementTree
from plexapi import log, utils
from plexapi import CONFIG, X_PLEX_CONTAINER_SIZE, log, utils
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
from plexapi.utils import cached_property
USER_DONT_RELOAD_FOR_KEYS = set()
_DONT_RELOAD_FOR_KEYS = {'key'}
@ -50,9 +50,14 @@ class PlexObject:
self._initpath = initpath or self.key
self._parent = weakref.ref(parent) if parent is not None else None
self._details_key = None
self._overwriteNone = True # Allow overwriting previous attribute values with `None` when manually reloading
self._autoReload = True # Automatically reload the object when accessing a missing attribute
self._edits = None # Save batch edits for a single API call
# Allow overwriting previous attribute values with `None` when manually reloading
self._overwriteNone = True
# Automatically reload the object when accessing a missing attribute
self._autoReload = CONFIG.get('plexapi.autoreload', True, bool)
# Attribute to save batch edits for a single API call
self._edits = None
if data is not None:
self._loadData(data)
self._details_key = self._buildDetailsKey()
@ -87,7 +92,9 @@ class PlexObject:
etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type')))
ehash = f'{elem.tag}.{etype}' if etype else elem.tag
if initpath == '/status/sessions':
ehash = f"{ehash}.{'session'}"
ehash = f"{ehash}.session"
elif initpath.startswith('/status/sessions/history'):
ehash = f"{ehash}.history"
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
if ecls is not None:
@ -147,47 +154,14 @@ class PlexObject:
elem = ElementTree.fromstring(xml)
return self._buildItemOrNone(elem, cls)
def fetchItem(self, ekey, cls=None, **kwargs):
""" Load the specified key to find and build the first item with the
specified tag and attrs. If no tag or attrs are specified then
the first item in the result set is returned.
Parameters:
ekey (str or int): Path in Plex to fetch items from. If an int is passed
in, the key will be translated to /library/metadata/<key>. This allows
fetching an item only knowing its key-id.
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
items to be fetched, passing this in will help the parser ensure
it only returns those items. By default we convert the xml elements
with the best guess PlexObjects based on tag and type attrs.
etag (str): Only fetch items with the specified tag.
**kwargs (dict): Optionally add XML attribute to filter the items.
See :func:`~plexapi.base.PlexObject.fetchItems` for more details
on how this is used.
"""
if ekey is None:
raise BadRequest('ekey was not provided')
if isinstance(ekey, int):
ekey = f'/library/metadata/{ekey}'
data = self._server.query(ekey)
item = self.findItem(data, cls, ekey, **kwargs)
if item:
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
if librarySectionID:
item.librarySectionID = librarySectionID
return item
clsname = cls.__name__ if cls else 'None'
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, maxresults=None, **kwargs):
""" Load the specified key to find and build all items with the specified tag
and attrs.
Parameters:
ekey (str): API URL path in Plex to fetch items from.
ekey (str or List<int>): API URL path in Plex to fetch items from. If a list of ints is passed
in, the key will be translated to /library/metadata/<key1,key2,key3>. This allows
fetching multiple items only knowing their key-ids.
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
items to be fetched, passing this in will help the parser ensure
it only returns those items. By default we convert the xml elements
@ -195,6 +169,7 @@ class PlexObject:
etag (str): Only fetch items with the specified tag.
container_start (None, int): offset to get a subset of the data
container_size (None, int): How many items in data
maxresults (int, optional): Only return the specified number of results.
**kwargs (dict): Optionally add XML attribute to filter the items.
See the details below for more info.
@ -259,39 +234,80 @@ class PlexObject:
if ekey is None:
raise BadRequest('ekey was not provided')
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
if isinstance(ekey, list) and all(isinstance(key, int) for key in ekey):
ekey = f'/library/metadata/{",".join(str(key) for key in ekey)}'
data = self._server.query(ekey, params=params)
items = self.findItems(data, cls, ekey, **kwargs)
container_start = container_start or 0
container_size = container_size or X_PLEX_CONTAINER_SIZE
offset = container_start
if maxresults is not None:
container_size = min(container_size, maxresults)
results = []
subresults = []
headers = {}
while True:
headers['X-Plex-Container-Start'] = str(container_start)
headers['X-Plex-Container-Size'] = str(container_size)
data = self._server.query(ekey, headers=headers)
subresults = self.findItems(data, cls, ekey, **kwargs)
total_size = utils.cast(int, data.attrib.get('totalSize') or data.attrib.get('size')) or len(subresults)
if not subresults:
if offset > total_size:
log.info('container_start is greater than the number of items')
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
if librarySectionID:
for item in items:
for item in subresults:
item.librarySectionID = librarySectionID
return items
def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs):
""" Load the specified data to find and build the first items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
results.extend(subresults)
wanted_number_of_items = total_size - offset
if maxresults is not None:
wanted_number_of_items = min(maxresults, wanted_number_of_items)
container_size = min(container_size, wanted_number_of_items - len(results))
if wanted_number_of_items <= len(results):
break
container_start += container_size
if container_start > total_size:
break
return results
def fetchItem(self, ekey, cls=None, **kwargs):
""" Load the specified key to find and build the first item with the
specified tag and attrs. If no tag or attrs are specified then
the first item in the result set is returned.
Parameters:
ekey (str or int): Path in Plex to fetch items from. If an int is passed
in, the key will be translated to /library/metadata/<key>. This allows
fetching an item only knowing its key-id.
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
items to be fetched, passing this in will help the parser ensure
it only returns those items. By default we convert the xml elements
with the best guess PlexObjects based on tag and type attrs.
etag (str): Only fetch items with the specified tag.
**kwargs (dict): Optionally add XML attribute to filter the items.
See :func:`~plexapi.base.PlexObject.fetchItems` 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
if isinstance(ekey, int):
ekey = f'/library/metadata/{ekey}'
try:
return self.fetchItems(ekey, cls, **kwargs)[0]
except IndexError:
clsname = cls.__name__ if cls else 'None'
raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}') from None
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
@ -315,6 +331,16 @@ class PlexObject:
items.append(item)
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.
"""
try:
return self.findItems(data, cls, initpath, rtag, **kwargs)[0]
except IndexError:
return None
def firstAttr(self, *attrs):
""" Return the first attribute in attrs that is not None. """
for attr in attrs:
@ -475,7 +501,9 @@ class PlexPartialObject(PlexObject):
}
def __eq__(self, other):
if isinstance(other, PlexPartialObject):
return other not in [None, []] and self.key == other.key
return NotImplemented
def __hash__(self):
return hash(repr(self))
@ -492,7 +520,7 @@ class PlexPartialObject(PlexObject):
if attr.startswith('_'): return value
if value not in (None, []): return value
if self.isFullObject(): return value
if isinstance(self, PlexSession): return value
if isinstance(self, (PlexSession, PlexHistory)): return value
if self._autoReload is False: return value
# Log the reload.
clsname = self.__class__.__name__
@ -543,13 +571,10 @@ class PlexPartialObject(PlexObject):
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._searchType)
part = f'/library/sections/{self.librarySectionID}/all{utils.joinArgs(kwargs)}'
self._server.query(part, method=self._server._session.put)
self.section()._edit(items=self, **kwargs)
return self
def edit(self, **kwargs):
@ -643,7 +668,7 @@ class PlexPartialObject(PlexObject):
'have not allowed items to be deleted', self.key)
raise
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get Play History for a media item.
Parameters:
@ -681,17 +706,11 @@ class Playable:
Albums which are all not playable.
Attributes:
viewedAt (datetime): Datetime item was last viewed (history).
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items).
"""
def _loadData(self, data):
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
self.accountID = utils.cast(int, data.attrib.get('accountID')) # history
self.deviceID = utils.cast(int, data.attrib.get('deviceID')) # history
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue
@ -812,7 +831,7 @@ class Playable:
"""
key = f'/:/progress?key={self.ratingKey}&identifier=com.plexapp.plugins.library&time={time}&state={state}'
self._server.query(key)
self._reload(_overwriteNone=False)
return self
def updateTimeline(self, time, state='stopped', duration=None):
""" Set the timeline progress for this video.
@ -830,7 +849,7 @@ class Playable:
key = (f'/:/timeline?ratingKey={self.ratingKey}&key={self.key}&'
f'identifier=com.plexapp.plugins.library&time={int(time)}&state={state}{durationStr}')
self._server.query(key)
self._reload(_overwriteNone=False)
return self
class PlexSession(object):
@ -912,6 +931,35 @@ class PlexSession(object):
return self._server.query(key, params=params)
class PlexHistory(object):
""" This is a general place to store functions specific to media that is a Plex history item.
Attributes:
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
historyKey (str): API URL (/status/sessions/history/<historyID>).
viewedAt (datetime): Datetime item was last watched.
"""
def _loadData(self, data):
self.accountID = utils.cast(int, data.attrib.get('accountID'))
self.deviceID = utils.cast(int, data.attrib.get('deviceID'))
self.historyKey = data.attrib.get('historyKey')
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt'))
def _reload(self, **kwargs):
""" Reload the data for the history entry. """
raise NotImplementedError('History objects cannot be reloaded. Use source() to get the source media item.')
def source(self):
""" Return the source media object for the history entry. """
return self.fetchItem(self._details_key)
def delete(self):
""" Delete the history entry. """
return self._server.query(self.historyKey, method=self._server._session.delete)
class MediaContainer(PlexObject):
""" Represents a single MediaContainer.

View file

@ -3,6 +3,7 @@ import time
from xml.etree import ElementTree
import requests
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported

View file

@ -8,8 +8,7 @@ from plexapi.library import LibrarySection, ManagedHub
from plexapi.mixins import (
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
LabelMixin
CollectionEditMixins
)
from plexapi.utils import deprecated
@ -19,8 +18,7 @@ class Collection(
PlexPartialObject,
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
LabelMixin
CollectionEditMixins
):
""" Represents a single Collection.
@ -222,6 +220,7 @@ class Collection(
.. code-block:: python
collection.updateMode(user="user")
"""
if not self.smart:
raise BadRequest('Cannot change collection filtering user for a non-smart collection.')
@ -250,6 +249,7 @@ class Collection(
.. code-block:: python
collection.updateMode(mode="hide")
"""
mode_dict = {
'default': -1,
@ -276,6 +276,7 @@ class Collection(
.. code-block:: python
collection.updateSort(mode="alpha")
"""
if self.smart:
raise BadRequest('Cannot change collection order for a smart collection.')

View file

@ -3,6 +3,8 @@ import os
from collections import defaultdict
from configparser import ConfigParser
from plexapi import utils
class PlexConfig(ConfigParser):
""" PlexAPI configuration object. Settings are stored in an INI file within the
@ -35,7 +37,7 @@ class PlexConfig(ConfigParser):
# Second: check the config file has attr
section, name = key.lower().split('.')
value = self.data.get(section, {}).get(name, default)
return cast(value) if cast else value
return utils.cast(cast, value) if cast else value
except: # noqa: E722
return default

View file

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

View file

@ -1,13 +1,18 @@
# -*- coding: utf-8 -*-
import re
from datetime import datetime
from urllib.parse import quote_plus, urlencode
from functools import cached_property
from urllib.parse import parse_qs, quote_plus, urlencode, urlparse
from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils
from plexapi import log, media, utils
from plexapi.base import OPERATORS, PlexObject
from plexapi.exceptions import BadRequest, NotFound
from plexapi.mixins import (
MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins,
ArtistEditMixins, AlbumEditMixins, TrackEditMixins, PhotoalbumEditMixins, PhotoEditMixins
)
from plexapi.settings import Setting
from plexapi.utils import cached_property, deprecated
from plexapi.utils import deprecated
class Library(PlexObject):
@ -352,7 +357,7 @@ class Library(PlexObject):
part += urlencode(kwargs)
return self._server.query(part, method=self._server._session.post)
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get Play History for all library Sections for the owner.
Parameters:
maxresults (int): Only return the specified number of results (optional).
@ -421,40 +426,6 @@ class LibrarySection(PlexObject):
self._totalDuration = None
self._totalStorage = None
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
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
Parameters:
container_start (None, int): offset to get a subset of the data
container_size (None, int): How many items in data
"""
url_kw = {}
if container_start is not None:
url_kw["X-Plex-Container-Start"] = container_start
if container_size is not None:
url_kw["X-Plex-Container-Size"] = container_size
if ekey is None:
raise BadRequest('ekey was not provided')
data = self._server.query(ekey, params=url_kw)
if '/all' in ekey:
# totalSize is only included in the xml response
# if container size is used.
total_size = data.attrib.get("totalSize") or data.attrib.get("size")
self._totalViewSize = utils.cast(int, total_size)
items = self.findItems(data, cls, ekey, **kwargs)
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
if librarySectionID:
for item in items:
item.librarySectionID = librarySectionID
return items
@cached_property
def totalSize(self):
""" Returns the total number of items in the library for the default library type. """
@ -474,6 +445,20 @@ class LibrarySection(PlexObject):
self._getTotalDurationStorage()
return self._totalStorage
def __getattribute__(self, attr):
# Intercept to call EditFieldMixin and EditTagMixin methods
# based on the item type being batch multi-edited
value = super().__getattribute__(attr)
if attr.startswith('_'): return value
if callable(value) and 'Mixin' in value.__qualname__:
if not isinstance(self._edits, dict):
raise AttributeError("Must enable batchMultiEdit() to use this method")
elif not hasattr(self._edits['items'][0], attr):
raise AttributeError(
f"Batch multi-editing '{self._edits['items'][0].__class__.__name__}' object has no attribute '{attr}'"
)
return value
def _getTotalDurationStorage(self):
""" Queries the Plex server for the total library duration and storage and caches the values. """
data = self._server.query('/media/providers?includeStorage=1')
@ -567,6 +552,7 @@ class LibrarySection(PlexObject):
LibrarySection.addLocations('/path/1')
LibrarySection.addLocations(['/path/1', 'path/2', '/path/3'])
"""
locations = self.locations
if isinstance(location, str):
@ -589,6 +575,7 @@ class LibrarySection(PlexObject):
LibrarySection.removeLocations('/path/1')
LibrarySection.removeLocations(['/path/1', 'path/2', '/path/3'])
"""
locations = self.locations
if isinstance(location, str):
@ -602,19 +589,24 @@ class LibrarySection(PlexObject):
raise BadRequest('You are unable to remove all locations from a library.')
return self.edit(location=locations)
def get(self, title):
""" Returns the media item with the specified title.
def get(self, title, **kwargs):
""" Returns the media item with the specified title and kwargs.
Parameters:
title (str): Title of the item to return.
kwargs (dict): Additional search parameters.
See :func:`~plexapi.library.LibrarySection.search` for more info.
Raises:
:exc:`~plexapi.exceptions.NotFound`: The title is not found in the library.
"""
try:
return self.search(title)[0]
return self.search(title, limit=1, **kwargs)[0]
except IndexError:
raise NotFound(f"Unable to find item '{title}'") from None
msg = f"Unable to find item with title '{title}'"
if kwargs:
msg += f" and kwargs {kwargs}"
raise NotFound(msg) from None
def getGuid(self, guid):
""" Returns the media item with the specified external Plex, IMDB, TMDB, or TVDB ID.
@ -781,6 +773,11 @@ class LibrarySection(PlexObject):
key = f'/library/sections/{self.key}/onDeck'
return self.fetchItems(key)
def continueWatching(self):
""" Return a list of media items in the library's Continue Watching hub. """
key = f'/hubs/sections/{self.key}/continueWatching/items'
return self.fetchItems(key)
def recentlyAdded(self, maxresults=50, libtype=None):
""" Returns a list of media items recently added from this library section.
@ -1261,7 +1258,7 @@ class LibrarySection(PlexObject):
return self._server.search(query, mediatype, limit, sectionId=self.key)
def search(self, title=None, sort=None, maxresults=None, libtype=None,
container_start=0, container_size=X_PLEX_CONTAINER_SIZE, limit=None, filters=None, **kwargs):
container_start=None, container_size=None, limit=None, filters=None, **kwargs):
""" Search the library. The http requests will be batched in container_size. If you are only looking for the
first <num> results, it would be wise to set the maxresults option to that amount so the search doesn't iterate
over all results on the server.
@ -1517,43 +1514,8 @@ class LibrarySection(PlexObject):
"""
key, kwargs = self._buildSearchKey(
title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs)
return self._search(key, maxresults, container_start, container_size, **kwargs)
def _search(self, key, maxresults, container_start, container_size, **kwargs):
""" Perform the actual library search and return the results. """
results = []
subresults = []
offset = container_start
if maxresults is not None:
container_size = min(container_size, maxresults)
while True:
subresults = self.fetchItems(key, container_start=container_start,
container_size=container_size, **kwargs)
if not len(subresults):
if offset > self._totalViewSize:
log.info("container_start is higher than the number of items in the library")
results.extend(subresults)
# self._totalViewSize is not used as a condition in the while loop as
# this require a additional http request.
# self._totalViewSize is updated from self.fetchItems
wanted_number_of_items = self._totalViewSize - offset
if maxresults is not None:
wanted_number_of_items = min(maxresults, wanted_number_of_items)
container_size = min(container_size, maxresults - len(results))
if wanted_number_of_items <= len(results):
break
container_start += container_size
if container_start > self._totalViewSize:
break
return results
return self.fetchItems(
key, container_start=container_start, container_size=container_size, maxresults=maxresults, **kwargs)
def _locations(self):
""" Returns a list of :class:`~plexapi.library.Location` objects
@ -1630,7 +1592,7 @@ class LibrarySection(PlexObject):
return myplex.sync(client=client, clientId=clientId, sync_item=sync_item)
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get Play History for this library Section for the owner.
Parameters:
maxresults (int): Only return the specified number of results (optional).
@ -1720,8 +1682,101 @@ class LibrarySection(PlexObject):
params['pageType'] = 'list'
return self._server._buildWebURL(base=base, **params)
def _validateItems(self, items):
""" Validates the specified items are from this library and of the same type. """
if not items:
raise BadRequest('No items specified.')
class MovieSection(LibrarySection):
if not isinstance(items, list):
items = [items]
itemType = items[0].type
for item in items:
if item.librarySectionID != self.key:
raise BadRequest(f'{item.title} is not from this library.')
elif item.type != itemType:
raise BadRequest(f'Cannot mix items of different type: {itemType} and {item.type}')
return items
def common(self, items):
""" Returns a :class:`~plexapi.library.Common` object for the specified items. """
params = {
'id': ','.join(str(item.ratingKey) for item in self._validateItems(items)),
'type': utils.searchType(items[0].type)
}
part = f'/library/sections/{self.key}/common{utils.joinArgs(params)}'
return self.fetchItem(part, cls=Common)
def _edit(self, items=None, **kwargs):
""" Actually edit multiple objects. """
if isinstance(self._edits, dict):
self._edits.update(kwargs)
return self
kwargs['id'] = ','.join(str(item.ratingKey) for item in self._validateItems(items))
if 'type' not in kwargs:
kwargs['type'] = utils.searchType(items[0].type)
part = f'/library/sections/{self.key}/all{utils.joinArgs(kwargs)}'
self._server.query(part, method=self._server._session.put)
return self
def multiEdit(self, items, **kwargs):
""" Edit multiple objects at once.
Note: This is a low level method and you need to know all the field/tag keys.
See :class:`~plexapi.LibrarySection.batchMultiEdits` instead.
Parameters:
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
:class:`~plexapi.photo.Photo`, or :class:`~plexapi.collection.Collection`
objects to be edited.
kwargs (dict): Dict of settings to edit.
"""
return self._edit(items, **kwargs)
def batchMultiEdits(self, items):
""" Enable batch multi-editing mode to save API calls.
Must call :func:`~plexapi.library.LibrarySection.saveMultiEdits` 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.
Parameters:
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
:class:`~plexapi.photo.Photo`, or :class:`~plexapi.collection.Collection`
objects to be edited.
Example:
.. code-block:: python
movies = MovieSection.all()
items = [movies[0], movies[3], movies[5]]
# Batch multi-editing multiple fields and tags in a single API call
MovieSection.batchMultiEdits(items)
MovieSection.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline') \\
.addCollection('New Collection').removeGenre('Action').addLabel('Favorite')
MovieSection.saveMultiEdits()
"""
self._edits = {'items': self._validateItems(items)}
return self
def saveMultiEdits(self):
""" Save all the batch multi-edits.
See :func:`~plexapi.library.LibrarySection.batchMultiEdits` for details.
"""
if not isinstance(self._edits, dict):
raise BadRequest('Batch multi-editing mode not enabled. Must call `batchMultiEdits()` first.')
edits = self._edits
self._edits = None
self._edit(items=edits.pop('items'), **edits)
return self
class MovieSection(LibrarySection, MovieEditMixins):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
Attributes:
@ -1781,7 +1836,7 @@ class MovieSection(LibrarySection):
return super(MovieSection, self).sync(**kwargs)
class ShowSection(LibrarySection):
class ShowSection(LibrarySection, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows.
Attributes:
@ -1865,7 +1920,7 @@ class ShowSection(LibrarySection):
return super(ShowSection, self).sync(**kwargs)
class MusicSection(LibrarySection):
class MusicSection(LibrarySection, ArtistEditMixins, AlbumEditMixins, TrackEditMixins):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing music artists.
Attributes:
@ -1957,7 +2012,7 @@ class MusicSection(LibrarySection):
return super(MusicSection, self).sync(**kwargs)
class PhotoSection(LibrarySection):
class PhotoSection(LibrarySection, PhotoalbumEditMixins, PhotoEditMixins):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
Attributes:
@ -1979,13 +2034,13 @@ class PhotoSection(LibrarySection):
def collections(self, **kwargs):
raise NotImplementedError('Collections are not available for a Photo library.')
def searchAlbums(self, title, **kwargs):
def searchAlbums(self, **kwargs):
""" Search for a photo album. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='photoalbum', title=title, **kwargs)
return self.search(libtype='photoalbum', **kwargs)
def searchPhotos(self, title, **kwargs):
def searchPhotos(self, **kwargs):
""" Search for a photo. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='photo', title=title, **kwargs)
return self.search(libtype='photo', **kwargs)
def recentlyAddedAlbums(self, maxresults=50):
""" Returns a list of recently added photo albums from this library section.
@ -2157,8 +2212,10 @@ class LibraryMediaTag(PlexObject):
reason (str): The reason for the search result.
reasonID (int): The reason ID for the search result.
reasonTitle (str): The reason title for the search result.
score (float): The score for the search result.
type (str): The type of search result (tag).
tag (str): The title of the tag.
tagKey (str): The Plex Discover ratingKey (guid) for people.
tagType (int): The type ID of the tag.
tagValue (int): The value of the tag.
thumb (str): The URL for the thumbnail of the tag (if available).
@ -2179,8 +2236,10 @@ class LibraryMediaTag(PlexObject):
self.reason = data.attrib.get('reason')
self.reasonID = utils.cast(int, data.attrib.get('reasonID'))
self.reasonTitle = data.attrib.get('reasonTitle')
self.score = utils.cast(float, data.attrib.get('score'))
self.type = data.attrib.get('type')
self.tag = data.attrib.get('tag')
self.tagKey = data.attrib.get('tagKey')
self.tagType = utils.cast(int, data.attrib.get('tagType'))
self.tagValue = utils.cast(int, data.attrib.get('tagValue'))
self.thumb = data.attrib.get('thumb')
@ -2222,16 +2281,6 @@ class Autotag(LibraryMediaTag):
TAGTYPE = 207
@utils.registerPlexObject
class Banner(LibraryMediaTag):
""" Represents a single Banner library media tag.
Attributes:
TAGTYPE (int): 311
"""
TAGTYPE = 311
@utils.registerPlexObject
class Chapter(LibraryMediaTag):
""" Represents a single Chapter library media tag.
@ -2958,6 +3007,7 @@ class ManagedHub(PlexObject):
managedHub.updateVisibility(recommended=True, home=True, shared=False).reload()
# or using chained methods
managedHub.promoteRecommended().promoteHome().demoteShared().reload()
"""
params = {
'promotedToRecommended': int(self.promotedToRecommended),
@ -3066,7 +3116,6 @@ class Path(PlexObject):
Attributes:
TAG (str): 'Path'
home (bool): True if the path is the home directory
key (str): API URL (/services/browse/<base64path>)
network (bool): True if path is a network location
@ -3098,7 +3147,6 @@ class File(PlexObject):
Attributes:
TAG (str): 'File'
key (str): API URL (/services/browse/<base64path>)
path (str): Full path to file
title (str): File name
@ -3109,3 +3157,105 @@ class File(PlexObject):
self.key = data.attrib.get('key')
self.path = data.attrib.get('path')
self.title = data.attrib.get('title')
@utils.registerPlexObject
class Common(PlexObject):
""" Represents a Common element from a library. This object lists common fields between multiple objects.
Attributes:
TAG (str): 'Common'
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
contentRating (str): Content rating of the items.
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
editionTitle (str): Edition title of the items.
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
grandparentRatingKey (int): Grandparent rating key of the items.
grandparentTitle (str): Grandparent title of the items.
guid (str): Plex GUID of the items.
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
index (int): Index of the items.
key (str): API URL (/library/metadata/<ratingkey>).
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
mixedFields (List<str>): List of mixed fields.
moods (List<:class:`~plexapi.media.Mood`>): List of mood objects.
originallyAvailableAt (datetime): Datetime of the release date of the items.
parentRatingKey (int): Parent rating key of the items.
parentTitle (str): Parent title of the items.
producers (List<:class:`~plexapi.media.Producer`>): List of producer objects.
ratingKey (int): Rating key of the items.
ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects.
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
studio (str): Studio name of the items.
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
summary (str): Summary of the items.
tagline (str): Tagline of the items.
tags (List<:class:`~plexapi.media.Tag`>): List of tag objects.
title (str): Title of the items.
titleSort (str): Title to use when sorting of the items.
type (str): Type of the media (common).
writers (List<:class:`~plexapi.media.Writer`>): List of writer objects.
year (int): Year of the items.
"""
TAG = 'Common'
def _loadData(self, data):
self._data = data
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating')
self.countries = self.findItems(data, media.Country)
self.directors = self.findItems(data, media.Director)
self.editionTitle = data.attrib.get('editionTitle')
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guid = data.attrib.get('guid')
self.guids = self.findItems(data, media.Guid)
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key')
self.labels = self.findItems(data, media.Label)
self.mixedFields = data.attrib.get('mixedFields').split(',')
self.moods = self.findItems(data, media.Mood)
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'))
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentTitle = data.attrib.get('parentTitle')
self.producers = self.findItems(data, media.Producer)
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.ratings = self.findItems(data, media.Rating)
self.roles = self.findItems(data, media.Role)
self.studio = data.attrib.get('studio')
self.styles = self.findItems(data, media.Style)
self.summary = data.attrib.get('summary')
self.tagline = data.attrib.get('tagline')
self.tags = self.findItems(data, media.Tag)
self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort')
self.type = data.attrib.get('type')
self.writers = self.findItems(data, media.Writer)
self.year = utils.cast(int, data.attrib.get('year'))
def __repr__(self):
return '<%s:%s:%s>' % (
self.__class__.__name__,
self.commonType,
','.join(str(key) for key in self.ratingKeys)
)
@property
def commonType(self):
""" Returns the media type of the common items. """
parsed_query = parse_qs(urlparse(self._initpath).query)
return utils.reverseSearchType(parsed_query['type'][0])
@property
def ratingKeys(self):
""" Returns a list of rating keys for the common items. """
parsed_query = parse_qs(urlparse(self._initpath).query)
return [int(value.strip()) for value in parsed_query['id'][0].split(',')]
def items(self):
""" Returns a list of the common items. """
return self._server.fetchItems(self.ratingKeys)

View file

@ -415,7 +415,11 @@ class SubtitleStream(MediaPartStream):
forced (bool): True if this is a forced subtitle.
format (str): The format of the subtitle stream (ex: srt).
headerCompression (str): The header compression of the subtitle stream.
providerTitle (str): The provider title where the on-demand subtitle is downloaded from.
score (int): The match score of the on-demand subtitle.
sourceKey (str): The source key of the on-demand subtitle.
transient (str): Unknown.
userID (int): The user id of the user that downloaded the on-demand subtitle.
"""
TAG = 'Stream'
STREAMTYPE = 3
@ -427,7 +431,11 @@ class SubtitleStream(MediaPartStream):
self.forced = utils.cast(bool, data.attrib.get('forced', '0'))
self.format = data.attrib.get('format')
self.headerCompression = data.attrib.get('headerCompression')
self.providerTitle = data.attrib.get('providerTitle')
self.score = utils.cast(int, data.attrib.get('score'))
self.sourceKey = data.attrib.get('sourceKey')
self.transient = data.attrib.get('transient')
self.userID = utils.cast(int, data.attrib.get('userID'))
def setDefault(self):
""" Sets this subtitle stream as the default subtitle stream. """
@ -955,7 +963,7 @@ class Review(PlexObject):
class BaseResource(PlexObject):
""" Base class for all Art, Banner, Poster, and Theme objects.
""" Base class for all Art, Poster, and Theme objects.
Attributes:
TAG (str): 'Photo' or 'Track'
@ -987,11 +995,6 @@ class Art(BaseResource):
TAG = 'Photo'
class Banner(BaseResource):
""" Represents a single Banner object. """
TAG = 'Photo'
class Poster(BaseResource):
""" Represents a single Poster object. """
TAG = 'Photo'

View file

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit
from plexapi import media, settings, utils
@ -139,7 +138,7 @@ class SplitMergeMixin:
if not isinstance(ratingKeys, list):
ratingKeys = str(ratingKeys).split(',')
key = f"{self.key}/merge?ids={','.join([str(r) for r in ratingKeys])}"
key = f"{self.key}/merge?ids={','.join(str(r) for r in ratingKeys)}"
self._server.query(key, method=self._server._session.put)
return self
@ -329,7 +328,19 @@ class ArtUrlMixin:
return self._server.url(art, includeToken=True) if art else None
class ArtMixin(ArtUrlMixin):
class ArtLockMixin:
""" Mixin for Plex objects that can have a locked background artwork. """
def lockArt(self):
""" Lock the background artwork for a Plex object. """
return self._edit(**{'art.locked': 1})
def unlockArt(self):
""" Unlock the background artwork for a Plex object. """
return self._edit(**{'art.locked': 0})
class ArtMixin(ArtUrlMixin, ArtLockMixin):
""" Mixin for Plex objects that can have background artwork. """
def arts(self):
@ -361,65 +372,6 @@ class ArtMixin(ArtUrlMixin):
art.select()
return self
def lockArt(self):
""" Lock the background artwork for a Plex object. """
return self._edit(**{'art.locked': 1})
def unlockArt(self):
""" Unlock the background artwork for a Plex object. """
return self._edit(**{'art.locked': 0})
class BannerUrlMixin:
""" Mixin for Plex objects that can have a banner url. """
@property
def bannerUrl(self):
""" Return the banner url for the Plex object. """
banner = self.firstAttr('banner')
return self._server.url(banner, includeToken=True) if banner else None
class BannerMixin(BannerUrlMixin):
""" Mixin for Plex objects that can have banners. """
def banners(self):
""" Returns list of available :class:`~plexapi.media.Banner` objects. """
return self.fetchItems(f'/library/metadata/{self.ratingKey}/banners', cls=media.Banner)
def uploadBanner(self, url=None, filepath=None):
""" Upload a banner from a url or filepath.
Parameters:
url (str): The full URL to the image to upload.
filepath (str): The full file path the the image to upload or file-like object.
"""
if url:
key = f'/library/metadata/{self.ratingKey}/banners?url={quote_plus(url)}'
self._server.query(key, method=self._server._session.post)
elif filepath:
key = f'/library/metadata/{self.ratingKey}/banners'
data = openOrRead(filepath)
self._server.query(key, method=self._server._session.post, data=data)
return self
def setBanner(self, banner):
""" Set the banner for a Plex object.
Parameters:
banner (:class:`~plexapi.media.Banner`): The banner object to select.
"""
banner.select()
return self
def lockBanner(self):
""" Lock the banner for a Plex object. """
return self._edit(**{'banner.locked': 1})
def unlockBanner(self):
""" Unlock the banner for a Plex object. """
return self._edit(**{'banner.locked': 0})
class PosterUrlMixin:
""" Mixin for Plex objects that can have a poster url. """
@ -436,7 +388,19 @@ class PosterUrlMixin:
return self.thumbUrl
class PosterMixin(PosterUrlMixin):
class PosterLockMixin:
""" Mixin for Plex objects that can have a locked poster. """
def lockPoster(self):
""" Lock the poster for a Plex object. """
return self._edit(**{'thumb.locked': 1})
def unlockPoster(self):
""" Unlock the poster for a Plex object. """
return self._edit(**{'thumb.locked': 0})
class PosterMixin(PosterUrlMixin, PosterLockMixin):
""" Mixin for Plex objects that can have posters. """
def posters(self):
@ -468,14 +432,6 @@ class PosterMixin(PosterUrlMixin):
poster.select()
return self
def lockPoster(self):
""" Lock the poster for a Plex object. """
return self._edit(**{'thumb.locked': 1})
def unlockPoster(self):
""" Unlock the poster for a Plex object. """
return self._edit(**{'thumb.locked': 0})
class ThemeUrlMixin:
""" Mixin for Plex objects that can have a theme url. """
@ -487,7 +443,19 @@ class ThemeUrlMixin:
return self._server.url(theme, includeToken=True) if theme else None
class ThemeMixin(ThemeUrlMixin):
class ThemeLockMixin:
""" Mixin for Plex objects that can have a locked theme. """
def lockTheme(self):
""" Lock the theme for a Plex object. """
return self._edit(**{'theme.locked': 1})
def unlockTheme(self):
""" Unlock the theme for a Plex object. """
return self._edit(**{'theme.locked': 0})
class ThemeMixin(ThemeUrlMixin, ThemeLockMixin):
""" Mixin for Plex objects that can have themes. """
def themes(self):
@ -520,14 +488,6 @@ class ThemeMixin(ThemeUrlMixin):
'Re-upload the theme using "uploadTheme" to set it.'
)
def lockTheme(self):
""" Lock the theme for a Plex object. """
return self._edit(**{'theme.locked': 1})
def unlockTheme(self):
""" Unlock the theme for a Plex object. """
return self._edit(**{'theme.locked': 0})
class EditFieldMixin:
""" Mixin for editing Plex object fields. """
@ -752,6 +712,19 @@ class PhotoCapturedTimeMixin(EditFieldMixin):
return self.editField('originallyAvailableAt', capturedTime, locked=locked)
class UserRatingMixin(EditFieldMixin):
""" Mixin for Plex objects that can have a user rating. """
def editUserRating(self, userRating, locked=True):
""" Edit the user rating.
Parameters:
userRating (int): The new value.
locked (bool): True (default) to lock the field, False to unlock the field.
"""
return self.editField('userRating', userRating, locked=locked)
class EditTagsMixin:
""" Mixin for editing Plex object tags. """
@ -781,7 +754,7 @@ class EditTagsMixin:
items = [items]
if not remove:
tags = getattr(self, self._tagPlural(tag))
tags = getattr(self, self._tagPlural(tag), [])
items = tags + items
edits = self._tagHelper(self._tagSingular(tag), items, locked, remove)
@ -822,7 +795,7 @@ class EditTagsMixin:
if remove:
tagname = f'{tag}[].tag.tag-'
data[tagname] = ','.join([quote(str(t)) for t in items])
data[tagname] = ','.join(quote(str(t)) for t in items)
else:
for i, item in enumerate(items):
tagname = f'{str(tag)}[{i}].tag.tag'
@ -1135,3 +1108,84 @@ class WatchlistMixin:
ratingKey = self.guid.rsplit('/', 1)[-1]
data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities")
return self.findItems(data)
class MovieEditMixins(
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin,
StudioMixin, SummaryMixin, TaglineMixin, TitleMixin, UserRatingMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin
):
pass
class ShowEditMixins(
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
AddedAtMixin, ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin, UserRatingMixin,
CollectionMixin, GenreMixin, LabelMixin,
):
pass
class SeasonEditMixins(
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
AddedAtMixin, SummaryMixin, TitleMixin, UserRatingMixin,
CollectionMixin, LabelMixin
):
pass
class EpisodeEditMixins(
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
AddedAtMixin, ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
CollectionMixin, DirectorMixin, LabelMixin, WriterMixin
):
pass
class ArtistEditMixins(
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
):
pass
class AlbumEditMixins(
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, UserRatingMixin,
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin
):
pass
class TrackEditMixins(
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
AddedAtMixin, TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin, UserRatingMixin,
CollectionMixin, LabelMixin, MoodMixin
):
pass
class PhotoalbumEditMixins(
ArtLockMixin, PosterLockMixin,
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin
):
pass
class PhotoEditMixins(
ArtLockMixin, PosterLockMixin,
AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
TagMixin
):
pass
class CollectionEditMixins(
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
LabelMixin
):
pass

View file

@ -7,8 +7,9 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from xml.etree import ElementTree
import requests
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE,
X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, log, logfilter, utils)
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER,
log, logfilter, utils)
from plexapi.base import PlexObject
from plexapi.client import PlexClient
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
@ -21,51 +22,76 @@ from requests.status_codes import _codes as codes
class MyPlexAccount(PlexObject):
""" MyPlex account and profile information. This object represents the data found Account on
the myplex.tv servers at the url https://plex.tv/users/account. You may create this object
the myplex.tv servers at the url https://plex.tv/api/v2/user. You may create this object
directly by passing in your username & password (or token). There is also a convenience
method provided at :class:`~plexapi.server.PlexServer.myPlexAccount()` which will create
and return this object.
Parameters:
username (str): Your MyPlex username.
password (str): Your MyPlex password.
username (str): Plex login username if not using a token.
password (str): Plex login password if not using a token.
token (str): Plex authentication token instead of username and password.
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.
timeout (int): timeout in seconds on initial connect to myplex (default config.TIMEOUT).
code (str): Two-factor authentication code to use when logging in.
code (str): Two-factor authentication code to use when logging in with username and password.
remember (bool): Remember the account token for 14 days (Default True).
Attributes:
SIGNIN (str): 'https://plex.tv/users/sign_in.xml'
key (str): 'https://plex.tv/users/account'
authenticationToken (str): Unknown.
certificateVersion (str): Unknown.
cloudSyncDevice (str): Unknown.
email (str): Your current Plex email address.
key (str): 'https://plex.tv/api/v2/user'
adsConsent (str): Unknown.
adsConsentReminderAt (str): Unknown.
adsConsentSetAt (str): Unknown.
anonymous (str): Unknown.
authToken (str): The account token.
backupCodesCreated (bool): If the two-factor authentication backup codes have been created.
confirmed (bool): If the account has been confirmed.
country (str): The account country.
email (str): The account email address.
emailOnlyAuth (bool): If login with email only is enabled.
experimentalFeatures (bool): If experimental features are enabled.
friendlyName (str): Your account full name.
entitlements (List<str>): List of devices your allowed to use with this account.
guest (bool): Unknown.
home (bool): Unknown.
homeSize (int): Unknown.
id (int): Your Plex account ID.
locale (str): Your Plex locale
mailing_list_status (str): Your current mailing list status.
maxHomeSize (int): Unknown.
guest (bool): If the account is a Plex Home guest user.
hasPassword (bool): If the account has a password.
home (bool): If the account is a Plex Home user.
homeAdmin (bool): If the account is the Plex Home admin.
homeSize (int): The number of accounts in the Plex Home.
id (int): The Plex account ID.
joinedAt (datetime): Date the account joined Plex.
locale (str): the account locale
mailingListActive (bool): If you are subscribed to the Plex newsletter.
mailingListStatus (str): Your current mailing list status.
maxHomeSize (int): The maximum number of accounts allowed in the Plex Home.
pin (str): The hashed Plex Home PIN.
queueEmail (str): Email address to add items to your `Watch Later` queue.
queueUid (str): Unknown.
restricted (bool): Unknown.
profileAutoSelectAudio (bool): If the account has automatically select audio and subtitle tracks enabled.
profileDefaultAudioLanguage (str): The preferred audio language for the account.
profileDefaultSubtitleLanguage (str): The preferred subtitle language for the account.
profileAutoSelectSubtitle (int): The auto-select subtitle mode
(0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled).
profileDefaultSubtitleAccessibility (int): The subtitles for the deaf or hard-of-hearing (SDH) searches mode
(0 = Prefer non-SDH subtitles, 1 = Prefer SDH subtitles, 2 = Only show SDH subtitles,
3 = Only shown non-SDH subtitles).
profileDefaultSubtitleForced (int): The forced subtitles searches mode
(0 = Prefer non-forced subtitles, 1 = Prefer forced subtitles, 2 = Only show forced subtitles,
3 = Only show non-forced subtitles).
protected (bool): If the account has a Plex Home PIN enabled.
rememberExpiresAt (datetime): Date the token expires.
restricted (bool): If the account is a Plex Home managed user.
roles: (List<str>) Lit of account roles. Plexpass membership listed here.
scrobbleTypes (str): Description
secure (bool): Description
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`.
thumb (str): URL of your account thumbnail.
title (str): Unknown. - Looks like an alias for `username`.
username (str): Your account username.
uuid (str): Unknown.
_token (str): Token used to access this client.
_session (obj): Requests session object used to access this client.
scrobbleTypes (List<int>): Unknown.
subscriptionActive (bool): If the account's Plex Pass subscription is active.
subscriptionDescription (str): Description of the Plex Pass subscription.
subscriptionFeatures: (List<str>) List of features allowed on your Plex Pass subscription.
subscriptionPaymentService (str): Payment service used for your Plex Pass subscription.
subscriptionPlan (str): Name of Plex Pass subscription plan.
subscriptionStatus (str): String representation of ``subscriptionActive``.
subscriptionSubscribedAt (datetime): Date the account subscribed to Plex Pass.
thumb (str): URL of the account thumbnail.
title (str): The title of the account (username or friendly name).
twoFactorEnabled (bool): If two-factor authentication is enabled.
username (str): The account username.
uuid (str): The account UUID.
"""
FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data
HOMEUSERS = 'https://plex.tv/api/home/users'
@ -76,7 +102,8 @@ class MyPlexAccount(PlexObject):
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, 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/api/v2/users/signin' # post with auth
SIGNOUT = 'https://plex.tv/api/v2/users/signout' # delete
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get
LINK = 'https://plex.tv/api/v2/pins/link' # put
@ -85,86 +112,106 @@ class MyPlexAccount(PlexObject):
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'
key = 'https://plex.tv/api/v2/user'
def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None):
self._token = token or CONFIG.get('auth.server_token')
def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None, remember=True):
self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
self._session = session or requests.Session()
self._sonos_cache = []
self._sonos_cache_timestamp = 0
data, initpath = self._signin(username, password, code, timeout)
data, initpath = self._signin(username, password, code, remember, timeout)
super(MyPlexAccount, self).__init__(self, data, initpath)
def _signin(self, username, password, code, timeout):
def _signin(self, username, password, code, remember, timeout):
if self._token:
return self.query(self.key), self.key
username = username or CONFIG.get('auth.myplex_username')
password = password or CONFIG.get('auth.myplex_password')
payload = {
'login': username or CONFIG.get('auth.myplex_username'),
'password': password or CONFIG.get('auth.myplex_password'),
'rememberMe': remember
}
if code:
password += code
data = self.query(self.SIGNIN, method=self._session.post, auth=(username, password), timeout=timeout)
payload['verificationCode'] = code
data = self.query(self.SIGNIN, method=self._session.post, data=payload, timeout=timeout)
return data, self.SIGNIN
def signout(self):
""" Sign out of the Plex account. Invalidates the authentication token. """
return self.query(self.SIGNOUT, method=self._session.delete)
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self._token = logfilter.add_secret(data.attrib.get('authenticationToken'))
self._token = logfilter.add_secret(data.attrib.get('authToken'))
self._webhooks = []
self.authenticationToken = self._token
self.certificateVersion = data.attrib.get('certificateVersion')
self.cloudSyncDevice = data.attrib.get('cloudSyncDevice')
self.adsConsent = data.attrib.get('adsConsent')
self.adsConsentReminderAt = data.attrib.get('adsConsentReminderAt')
self.adsConsentSetAt = data.attrib.get('adsConsentSetAt')
self.anonymous = data.attrib.get('anonymous')
self.authToken = self._token
self.backupCodesCreated = utils.cast(bool, data.attrib.get('backupCodesCreated'))
self.confirmed = utils.cast(bool, data.attrib.get('confirmed'))
self.country = data.attrib.get('country')
self.email = data.attrib.get('email')
self.emailOnlyAuth = utils.cast(bool, data.attrib.get('emailOnlyAuth'))
self.experimentalFeatures = utils.cast(bool, data.attrib.get('experimentalFeatures'))
self.friendlyName = data.attrib.get('friendlyName')
self.guest = utils.cast(bool, data.attrib.get('guest'))
self.hasPassword = utils.cast(bool, data.attrib.get('hasPassword'))
self.home = utils.cast(bool, data.attrib.get('home'))
self.homeAdmin = utils.cast(bool, data.attrib.get('homeAdmin'))
self.homeSize = utils.cast(int, data.attrib.get('homeSize'))
self.id = utils.cast(int, data.attrib.get('id'))
self.joinedAt = utils.toDatetime(data.attrib.get('joinedAt'))
self.locale = data.attrib.get('locale')
self.mailing_list_status = data.attrib.get('mailing_list_status')
self.mailingListActive = utils.cast(bool, data.attrib.get('mailingListActive'))
self.mailingListStatus = data.attrib.get('mailingListStatus')
self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
self.pin = data.attrib.get('pin')
self.queueEmail = data.attrib.get('queueEmail')
self.queueUid = data.attrib.get('queueUid')
self.protected = utils.cast(bool, data.attrib.get('protected'))
self.rememberExpiresAt = utils.toDatetime(data.attrib.get('rememberExpiresAt'))
self.restricted = utils.cast(bool, data.attrib.get('restricted'))
self.scrobbleTypes = data.attrib.get('scrobbleTypes')
self.secure = utils.cast(bool, data.attrib.get('secure'))
self.scrobbleTypes = [utils.cast(int, x) for x in data.attrib.get('scrobbleTypes').split(',')]
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title')
self.twoFactorEnabled = utils.cast(bool, data.attrib.get('twoFactorEnabled'))
self.username = data.attrib.get('username')
self.uuid = data.attrib.get('uuid')
subscription = data.find('subscription')
self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active'))
self.subscriptionStatus = subscription.attrib.get('status')
self.subscriptionDescription = data.attrib.get('subscriptionDescription')
self.subscriptionFeatures = self.listAttrs(subscription, 'id', rtag='features', etag='feature')
self.subscriptionPaymentService = subscription.attrib.get('paymentService')
self.subscriptionPlan = subscription.attrib.get('plan')
self.subscriptionFeatures = self.listAttrs(subscription, 'id', etag='feature')
self.subscriptionStatus = subscription.attrib.get('status')
self.subscriptionSubscribedAt = utils.toDatetime(subscription.attrib.get('subscribedAt'), '%Y-%m-%d %H:%M:%S %Z')
self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role')
profile = data.find('profile')
self.profileAutoSelectAudio = utils.cast(bool, profile.attrib.get('autoSelectAudio'))
self.profileDefaultAudioLanguage = profile.attrib.get('defaultAudioLanguage')
self.profileDefaultSubtitleLanguage = profile.attrib.get('defaultSubtitleLanguage')
self.profileAutoSelectSubtitle = utils.cast(int, profile.attrib.get('autoSelectSubtitle'))
self.profileDefaultSubtitleAccessibility = utils.cast(int, profile.attrib.get('defaultSubtitleAccessibility'))
self.profileDefaultSubtitleForces = utils.cast(int, profile.attrib.get('defaultSubtitleForces'))
self.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement')
self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role')
# TODO: Fetch missing MyPlexAccount attributes
self.profile_settings = None
# TODO: Fetch missing MyPlexAccount services
self.services = None
self.joined_at = None
def device(self, name=None, clientId=None):
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
@property
def authenticationToken(self):
""" Returns the authentication token for the account. Alias for ``authToken``. """
return self.authToken
Parameters:
name (str): Name to match against.
clientId (str): clientIdentifier to match against.
"""
for device in self.devices():
if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId):
return device
raise NotFound(f'Unable to find device {name}')
def devices(self):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """
data = self.query(MyPlexDevice.key)
return [MyPlexDevice(self, elem) for elem in data]
def _reload(self, key=None, **kwargs):
""" Perform the actual reload. """
data = self.query(self.key)
self._loadData(data)
return self
def _headers(self, **kwargs):
""" Returns dict containing base headers for all requests to the server. """
@ -188,6 +235,8 @@ class MyPlexAccount(PlexObject):
raise Unauthorized(message)
elif response.status_code == 404:
raise NotFound(message)
elif response.status_code == 422 and "Invalid token" in response.text:
raise Unauthorized(message)
else:
raise BadRequest(message)
if headers.get('Accept') == 'application/json':
@ -195,6 +244,23 @@ class MyPlexAccount(PlexObject):
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
def device(self, name=None, clientId=None):
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
Parameters:
name (str): Name to match against.
clientId (str): clientIdentifier to match against.
"""
for device in self.devices():
if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId):
return device
raise NotFound(f'Unable to find device {name}')
def devices(self):
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """
data = self.query(MyPlexDevice.key)
return [MyPlexDevice(self, elem) for elem in data]
def resource(self, name):
""" Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified.
@ -784,7 +850,7 @@ class MyPlexAccount(PlexObject):
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
return response.json()['token']
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get Play History for all library sections on all servers for the owner.
Parameters:
@ -817,7 +883,7 @@ class MyPlexAccount(PlexObject):
data = self.query(f'{self.MUSIC}/hubs')
return self.findItems(data)
def watchlist(self, filter=None, sort=None, libtype=None, maxresults=9999999, **kwargs):
def watchlist(self, filter=None, sort=None, libtype=None, maxresults=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.
@ -857,23 +923,10 @@ class MyPlexAccount(PlexObject):
if libtype:
params['type'] = utils.searchType(libtype)
params['X-Plex-Container-Start'] = 0
params['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
params.update(kwargs)
results, subresults = [], '_init'
while subresults and maxresults > len(results):
data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params)
subresults = self.findItems(data)
results += subresults[:maxresults - len(results)]
params['X-Plex-Container-Start'] += params['X-Plex-Container-Size']
# totalSize is available in first response, update maxresults from it
totalSize = utils.cast(int, data.attrib.get('totalSize'))
if maxresults > totalSize:
maxresults = totalSize
return self._toOnlineMetadata(results, **kwargs)
key = f'{self.METADATA}/library/sections/watchlist/{filter}{utils.joinArgs(params)}'
return self._toOnlineMetadata(self.fetchItems(key, maxresults=maxresults), **kwargs)
def onWatchlist(self, item):
""" Returns True if the item is on the user's watchlist.
@ -936,6 +989,48 @@ class MyPlexAccount(PlexObject):
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
return self.findItem(data, cls=UserState)
def isPlayed(self, item):
""" Return True if the item is played on Discover.
Parameters:
item (:class:`~plexapi.video.Movie`,
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or
:class:`~plexapi.video.Episode`): Object from searchDiscover().
Can be also result from Plex Movie or Plex TV Series agent.
"""
userState = self.userState(item)
return bool(userState.viewCount > 0) if userState.viewCount else False
def markPlayed(self, item):
""" Mark the Plex object as played on Discover.
Parameters:
item (:class:`~plexapi.video.Movie`,
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or
:class:`~plexapi.video.Episode`): Object from searchDiscover().
Can be also result from Plex Movie or Plex TV Series agent.
"""
key = f'{self.METADATA}/actions/scrobble'
ratingKey = item.guid.rsplit('/', 1)[-1]
params = {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}
self.query(key, params=params)
return self
def markUnplayed(self, item):
""" Mark the Plex object as unplayed on Discover.
Parameters:
item (:class:`~plexapi.video.Movie`,
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or
:class:`~plexapi.video.Episode`): Object from searchDiscover().
Can be also result from Plex Movie or Plex TV Series agent.
"""
key = f'{self.METADATA}/actions/unscrobble'
ratingKey = item.guid.rsplit('/', 1)[-1]
params = {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}
self.query(key, params=params)
return self
def searchDiscover(self, query, limit=30, libtype=None):
""" Search for movies and TV shows in Discover.
Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects.
@ -1117,7 +1212,7 @@ class MyPlexUser(PlexObject):
raise NotFound(f'Unable to find server {name}')
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get all Play History for a user in all shared servers.
Parameters:
maxresults (int): Only return the specified number of results (optional).
@ -1191,7 +1286,7 @@ class Section(PlexObject):
self.sectionId = self.id # For backwards compatibility
self.sectionKey = self.key # For backwards compatibility
def history(self, maxresults=9999999, mindate=None):
def history(self, maxresults=None, mindate=None):
""" Get all Play History for a user for this section in this shared server.
Parameters:
maxresults (int): Only return the specified number of results (optional).
@ -1266,21 +1361,25 @@ class MyPlexResource(PlexObject):
""" This object represents resources connected to your Plex server that can provide
content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml
for the data presented here can be found at:
https://plex.tv/api/resources?includeHttps=1&includeRelay=1
https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1
Attributes:
TAG (str): 'Device'
key (str): 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
accessToken (str): This resources accesstoken.
key (str): 'https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1'
accessToken (str): This resource's Plex access token.
clientIdentifier (str): Unique ID for this resource.
connections (list): List of :class:`~plexapi.myplex.ResourceConnection` objects
for this resource.
createdAt (datetime): Timestamp this resource first connected to your server.
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
dnsRebindingProtection (bool): True if the server had DNS rebinding protection.
home (bool): Unknown
httpsRequired (bool): True if the resource requires https.
lastSeenAt (datetime): Timestamp this resource last connected.
name (str): Descriptive name of this resource.
natLoopbackSupported (bool): True if the resource supports NAT loopback.
owned (bool): True if this resource is one of your own (you logged into it).
ownerId (int): ID of the user that owns this resource (shared resources only).
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
platformVersion (str): Version of the platform.
presence (bool): True if the resource is online
@ -1288,10 +1387,13 @@ class MyPlexResource(PlexObject):
productVersion (str): Version of the product.
provides (str): List of services this resource provides (client, server,
player, pubsub-player, etc.)
publicAddressMatches (bool): True if the public IP address matches the client's public IP address.
relay (bool): True if this resource has the Plex Relay enabled.
sourceTitle (str): Username of the user that owns this resource (shared resources only).
synced (bool): Unknown (possibly True if the resource has synced content?)
"""
TAG = 'Device'
key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
TAG = 'resource'
key = 'https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1'
# Default order to prioritize available resource connections
DEFAULT_LOCATION_ORDER = ['local', 'remote', 'relay']
@ -1299,33 +1401,35 @@ class MyPlexResource(PlexObject):
def _loadData(self, data):
self._data = data
self.name = data.attrib.get('name')
self.accessToken = logfilter.add_secret(data.attrib.get('accessToken'))
self.product = data.attrib.get('product')
self.productVersion = data.attrib.get('productVersion')
self.clientIdentifier = data.attrib.get('clientIdentifier')
self.connections = self.findItems(data, ResourceConnection, rtag='connections')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'), "%Y-%m-%dT%H:%M:%SZ")
self.device = data.attrib.get('device')
self.dnsRebindingProtection = utils.cast(bool, data.attrib.get('dnsRebindingProtection'))
self.home = utils.cast(bool, data.attrib.get('home'))
self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired'))
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'), "%Y-%m-%dT%H:%M:%SZ")
self.name = data.attrib.get('name')
self.natLoopbackSupported = utils.cast(bool, data.attrib.get('natLoopbackSupported'))
self.owned = utils.cast(bool, data.attrib.get('owned'))
self.ownerId = utils.cast(int, data.attrib.get('ownerId', 0))
self.platform = data.attrib.get('platform')
self.platformVersion = data.attrib.get('platformVersion')
self.device = data.attrib.get('device')
self.clientIdentifier = data.attrib.get('clientIdentifier')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'))
self.provides = data.attrib.get('provides')
self.owned = utils.cast(bool, data.attrib.get('owned'))
self.home = utils.cast(bool, data.attrib.get('home'))
self.synced = utils.cast(bool, data.attrib.get('synced'))
self.presence = utils.cast(bool, data.attrib.get('presence'))
self.connections = self.findItems(data, ResourceConnection)
self.product = data.attrib.get('product')
self.productVersion = data.attrib.get('productVersion')
self.provides = data.attrib.get('provides')
self.publicAddressMatches = utils.cast(bool, data.attrib.get('publicAddressMatches'))
# This seems to only be available if its not your device (say are shared server)
self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired'))
self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0))
self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username.
self.relay = utils.cast(bool, data.attrib.get('relay'))
self.sourceTitle = data.attrib.get('sourceTitle')
self.synced = utils.cast(bool, data.attrib.get('synced'))
def preferred_connections(
self,
ssl=None,
locations=DEFAULT_LOCATION_ORDER,
schemes=DEFAULT_SCHEME_ORDER,
locations=None,
schemes=None,
):
""" Returns a sorted list of the available connection addresses for this resource.
Often times there is more than one address specified for a server or client.
@ -1336,6 +1440,11 @@ class MyPlexResource(PlexObject):
only connect to HTTP connections. Set None (default) to connect to any
HTTP or HTTPS connection.
"""
if locations is None:
locations = self.DEFAULT_LOCATION_ORDER[:]
if schemes is None:
schemes = self.DEFAULT_SCHEME_ORDER[:]
connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations}
for connection in self.connections:
# Only check non-local connections unless we own the resource
@ -1359,8 +1468,8 @@ class MyPlexResource(PlexObject):
self,
ssl=None,
timeout=None,
locations=DEFAULT_LOCATION_ORDER,
schemes=DEFAULT_SCHEME_ORDER,
locations=None,
schemes=None,
):
""" Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object.
Uses `MyPlexResource.preferred_connections()` to generate the priority order of connection addresses.
@ -1376,11 +1485,16 @@ class MyPlexResource(PlexObject):
Raises:
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
"""
if locations is None:
locations = self.DEFAULT_LOCATION_ORDER[:]
if schemes is None:
schemes = self.DEFAULT_SCHEME_ORDER[:]
connections = self.preferred_connections(ssl, locations, schemes)
# Try connecting to all known resource connections in parallel, but
# only return the first server (in order) that provides a response.
cls = PlexServer if 'server' in self.provides else PlexClient
listargs = [[cls, url, self.accessToken, timeout] for url in connections]
listargs = [[cls, url, self.accessToken, self._server._session, timeout] for url in connections]
log.debug('Testing %s resource connections..', len(listargs))
results = utils.threaded(_connect, listargs)
return _chooseConnection('Resource', self.name, results)
@ -1392,24 +1506,27 @@ class ResourceConnection(PlexObject):
Attributes:
TAG (str): 'Connection'
address (str): Local IP address
httpuri (str): Full local address
local (bool): True if local
port (int): 32400
address (str): The connection IP address
httpuri (str): Full HTTP URL
ipv6 (bool): True if the address is IPv6
local (bool): True if the address is local
port (int): The connection port
protocol (str): HTTP or HTTPS
uri (str): External address
relay (bool): True if the address uses the Plex Relay
uri (str): Full connetion URL
"""
TAG = 'Connection'
TAG = 'connection'
def _loadData(self, data):
self._data = data
self.protocol = data.attrib.get('protocol')
self.address = data.attrib.get('address')
self.port = utils.cast(int, data.attrib.get('port'))
self.uri = data.attrib.get('uri')
self.ipv6 = utils.cast(bool, data.attrib.get('IPv6'))
self.local = utils.cast(bool, data.attrib.get('local'))
self.httpuri = f'http://{self.address}:{self.port}'
self.port = utils.cast(int, data.attrib.get('port'))
self.protocol = data.attrib.get('protocol')
self.relay = utils.cast(bool, data.attrib.get('relay'))
self.uri = data.attrib.get('uri')
self.httpuri = f'http://{self.address}:{self.port}'
class MyPlexDevice(PlexObject):
@ -1475,7 +1592,7 @@ class MyPlexDevice(PlexObject):
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
"""
cls = PlexServer if 'server' in self.provides else PlexClient
listargs = [[cls, url, self.token, timeout] for url in self.connections]
listargs = [[cls, url, self.token, self._server._session, timeout] for url in self.connections]
log.debug('Testing %s device connections..', len(listargs))
results = utils.threaded(_connect, listargs)
return _chooseConnection('Device', self.name, results)
@ -1725,7 +1842,7 @@ class MyPlexPinLogin:
return ElementTree.fromstring(data) if data.strip() else None
def _connect(cls, url, token, timeout, results, i, job_is_done_event=None):
def _connect(cls, url, token, session, timeout, results, i, job_is_done_event=None):
""" Connects to the specified cls with url and token. Stores the connection
information to results[i] in a threadsafe way.
@ -1733,6 +1850,7 @@ def _connect(cls, url, token, timeout, results, i, job_is_done_event=None):
cls: the class which is responsible for establishing connection, basically it's
:class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer`
url (str): url which should be passed as `baseurl` argument to cls.__init__()
session (requests.Session): session which sould be passed as `session` argument to cls.__init()
token (str): authentication token which should be passed as `baseurl` argument to cls.__init__()
timeout (int): timeout which should be passed as `baseurl` argument to cls.__init__()
results (list): pre-filled list for results
@ -1742,7 +1860,7 @@ def _connect(cls, url, token, timeout, results, i, job_is_done_event=None):
"""
starttime = time.time()
try:
device = cls(baseurl=url, token=token, timeout=timeout)
device = cls(baseurl=url, token=token, session=session, timeout=timeout)
runtime = int(time.time() - starttime)
results[i] = (url, token, device, runtime)
if X_PLEX_ENABLE_FAST_CONNECT and job_is_done_event:

View file

@ -8,8 +8,7 @@ from plexapi.exceptions import BadRequest
from plexapi.mixins import (
RatingMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin,
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, PhotoCapturedTimeMixin,
TagMixin
PhotoalbumEditMixins, PhotoEditMixins
)
@ -18,7 +17,7 @@ class Photoalbum(
PlexPartialObject,
RatingMixin,
ArtMixin, PosterMixin,
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin
PhotoalbumEditMixins
):
""" Represents a single Photoalbum (collection of photos).
@ -146,8 +145,7 @@ class Photo(
PlexPartialObject, Playable,
RatingMixin,
ArtUrlMixin, PosterUrlMixin,
AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin,
TagMixin
PhotoEditMixins
):
""" Represents a single Photo.

View file

@ -433,7 +433,8 @@ class Playlist(
""" Copy playlist to another user account.
Parameters:
user (str): Username, email or user id of the user to copy the playlist to.
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
email, or user id of the user to copy the playlist to.
"""
userServer = self._server.switchUser(user)
return self.create(server=userServer, title=self.title, items=self.items())

View file

@ -170,7 +170,7 @@ class PlayQueue(PlexObject):
}
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(f"/library/metadata/{item_keys}")
args["uri"] = f"library:///directory/{uri_args}"
args["type"] = items[0].listType

View file

@ -1,11 +1,12 @@
# -*- coding: utf-8 -*-
import os
from functools import cached_property
from urllib.parse import urlencode
from xml.etree import ElementTree
import requests
import os
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log,
logfilter)
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter
from plexapi import utils
from plexapi.alert import AlertListener
from plexapi.base import PlexObject
@ -17,7 +18,7 @@ from plexapi.media import Conversion, Optimized
from plexapi.playlist import Playlist
from plexapi.playqueue import PlayQueue
from plexapi.settings import Settings
from plexapi.utils import cached_property, deprecated
from plexapi.utils import deprecated
from requests.status_codes import _codes as codes
# Need these imports to populate utils.PLEXOBJECTS
@ -236,12 +237,13 @@ class PlexServer(PlexObject):
q = self.query(f'/security/token?type={type}&scope={scope}')
return q.attrib.get('token')
def switchUser(self, username, session=None, timeout=None):
def switchUser(self, user, session=None, timeout=None):
""" Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username.
Note: Only the admin account can switch to other users.
Parameters:
username (str): Username, email or user id of the user to log in to the server.
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
email, or user id of the user to log in to the server.
session (requests.Session, optional): Use your own session object if you want to
cache the http responses from the server. This will default to the same
session as the admin account if no new session is provided.
@ -260,7 +262,8 @@ class PlexServer(PlexObject):
userPlex = plex.switchUser("Username")
"""
user = self.myPlexAccount().user(username)
from plexapi.myplex import MyPlexUser
user = user if isinstance(user, MyPlexUser) else self.myPlexAccount().user(user)
userToken = user.get_token(self.machineIdentifier)
if session is None:
session = self._session
@ -470,6 +473,7 @@ class PlexServer(PlexObject):
sort="episode.originallyAvailableAt:desc",
filters={"episode.originallyAvailableAt>>": "4w", "genre": "comedy"}
)
"""
return Collection.create(
self, title, section, items=items, smart=smart, limit=limit,
@ -535,6 +539,7 @@ class PlexServer(PlexObject):
section="Music",
m3ufilepath="/path/to/playlist.m3u"
)
"""
return Playlist.create(
self, title, section=section, items=items, smart=smart, limit=limit,
@ -549,26 +554,28 @@ class PlexServer(PlexObject):
"""
return PlayQueue.create(self, item, **kwargs)
def downloadDatabases(self, savepath=None, unpack=False):
def downloadDatabases(self, savepath=None, unpack=False, showstatus=False):
""" Download databases.
Parameters:
savepath (str): Defaults to current working dir.
unpack (bool): Unpack the zip file.
showstatus(bool): Display a progressbar.
"""
url = self.url('/diagnostics/databases')
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack)
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack, showstatus=showstatus)
return filepath
def downloadLogs(self, savepath=None, unpack=False):
def downloadLogs(self, savepath=None, unpack=False, showstatus=False):
""" Download server logs.
Parameters:
savepath (str): Defaults to current working dir.
unpack (bool): Unpack the zip file.
showstatus(bool): Display a progressbar.
"""
url = self.url('/diagnostics/logs')
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack)
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack, showstatus=showstatus)
return filepath
def butlerTasks(self):
@ -588,6 +595,7 @@ class PlexServer(PlexObject):
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:
@ -630,7 +638,7 @@ class PlexServer(PlexObject):
# figure out what method this is..
return self.query(part, method=self._session.put)
def history(self, maxresults=9999999, mindate=None, ratingKey=None, accountID=None, librarySectionID=None):
def history(self, maxresults=None, mindate=None, ratingKey=None, accountID=None, librarySectionID=None):
""" Returns a list of media items from watched history. If there are many results, they will
be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only
looking for the first <num> results, it would be wise to set the maxresults option to that
@ -644,7 +652,6 @@ class PlexServer(PlexObject):
accountID (int/str) Request history for a specific account ID.
librarySectionID (int/str) Request history for a specific library section ID.
"""
results, subresults = [], '_init'
args = {'sort': 'viewedAt:desc'}
if ratingKey:
args['metadataItemID'] = ratingKey
@ -654,14 +661,9 @@ class PlexServer(PlexObject):
args['librarySectionID'] = librarySectionID
if mindate:
args['viewedAt>'] = int(mindate.timestamp())
args['X-Plex-Container-Start'] = 0
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
while subresults and maxresults > len(results):
key = f'/status/sessions/history/all{utils.joinArgs(args)}'
subresults = self.fetchItems(key)
results += subresults[:maxresults - len(results)]
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
return results
return self.fetchItems(key, maxresults=maxresults)
def playlists(self, playlistType=None, sectionId=None, title=None, sort=None, **kwargs):
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server.
@ -794,6 +796,10 @@ class PlexServer(PlexObject):
results += hub.items
return results
def continueWatching(self):
""" Return a list of all items in the Continue Watching hub. """
return self.fetchItems('/hubs/continueWatching/items')
def sessions(self):
""" Returns a list of all active session (currently playing) media objects. """
return self.fetchItems('/status/sessions')

View file

@ -81,7 +81,7 @@ class Settings(PlexObject):
params[setting.id] = quote(setting._setValue)
if not params:
raise BadRequest('No setting have been modified.')
querystr = '&'.join([f'{k}={v}' for k, v in params.items()])
querystr = '&'.join(f'{k}={v}' for k, v in params.items())
url = f'{self.key}?{querystr}'
self._server.query(url, self._server._session.put)
self.reload()

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import requests
from plexapi import CONFIG, X_PLEX_IDENTIFIER
from plexapi.client import PlexClient
from plexapi.exceptions import BadRequest

View file

@ -15,20 +15,17 @@ from datetime import datetime
from getpass import getpass
from threading import Event, Thread
from urllib.parse import quote
from requests.status_codes import _codes as codes
import requests
from plexapi.exceptions import BadRequest, NotFound
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
try:
from tqdm import tqdm
except ImportError:
tqdm = None
try:
from functools import cached_property
except ImportError:
from backports.cached_property import cached_property # noqa: F401
log = logging.getLogger('plexapi')
# Search Types - Plex uses these to filter specific media types when searching.
@ -106,7 +103,7 @@ class SecretsFilter(logging.Filter):
self.secrets = secrets or set()
def add_secret(self, secret):
if secret is not None:
if secret is not None and secret != '':
self.secrets.add(secret)
return secret
@ -128,7 +125,9 @@ def registerPlexObject(cls):
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
ehash = f'{cls.TAG}.{etype}' if etype else cls.TAG
if getattr(cls, '_SESSIONTYPE', None):
ehash = f"{ehash}.{'session'}"
ehash = f"{ehash}.session"
elif getattr(cls, '_HISTORYTYPE', None):
ehash = f"{ehash}.history"
if ehash in PLEXOBJECTS:
raise Exception(f'Ambiguous PlexObject definition {cls.__name__}(tag={cls.TAG}, type={etype}) '
f'with {PLEXOBJECTS[ehash].__name__}')
@ -391,12 +390,12 @@ def downloadSessionImages(server, filename=None, height=150, width=150,
prettyname = media._prettyfilename()
filename = f'session_transcode_{media.usernames[0]}_{prettyname}_{int(time.time())}'
url = server.transcodeImage(url, height, width, opacity, saturation)
filepath = download(url, filename=filename)
filepath = download(url, server._token, filename=filename)
info['username'] = {'filepath': filepath, 'url': url}
return info
def download(url, token, filename=None, savepath=None, session=None, chunksize=4024,
def download(url, token, filename=None, savepath=None, session=None, chunksize=4024, # noqa: C901
unpack=False, mocked=False, showstatus=False):
""" Helper to download a thumb, videofile or other media item. Returns the local
path to the downloaded file.
@ -419,6 +418,17 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4
session = session or requests.Session()
headers = {'X-Plex-Token': token}
response = session.get(url, headers=headers, stream=True)
if response.status_code not in (200, 201, 204):
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
message = f'({response.status_code}) {codename}; {response.url} {errtext}'
if response.status_code == 401:
raise Unauthorized(message)
elif response.status_code == 404:
raise NotFound(message)
else:
raise BadRequest(message)
# make sure the savepath directory exists
savepath = savepath or os.getcwd()
os.makedirs(savepath, exist_ok=True)

View file

@ -3,19 +3,17 @@ import os
from urllib.parse import quote_plus
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject, PlexSession
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession
from plexapi.exceptions import BadRequest
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin,
StudioMixin, SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins,
WatchlistMixin
)
class Video(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin):
class Video(PlexPartialObject, PlayedUnplayedMixin):
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
@ -309,9 +307,7 @@ class Movie(
Video, Playable,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
MovieEditMixins,
WatchlistMixin
):
""" Represents a single Movie.
@ -330,6 +326,7 @@ class Movie(
duration (int): Duration of the movie in milliseconds.
editionTitle (str): The edition title of the movie (e.g. Director's Cut, Extended Edition, etc.).
enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled.
(-1 = Library default, 0 = Disabled)
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.
@ -441,15 +438,20 @@ class Movie(
}
return self.section().search(filters=filters)
def removeFromContinueWatching(self):
""" Remove the movie from continue watching. """
key = '/actions/removeFromContinueWatching'
params = {'ratingKey': self.ratingKey}
self._server.query(key, params=params, method=self._server._session.put)
return self
@utils.registerPlexObject
class Show(
Video,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, BannerMixin, PosterMixin, ThemeMixin,
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, GenreMixin, LabelMixin,
ArtMixin, PosterMixin, ThemeMixin,
ShowEditMixins,
WatchlistMixin
):
""" Represents a single Show (including all seasons and episodes).
@ -467,12 +469,12 @@ class Show(
autoDeletionItemPolicyWatchedLibrary (int): Setting that indicates if episodes are deleted
after being watched for the show (0 = Never, 1 = After a day, 7 = After a week,
100 = On next refresh).
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/banner/<bannerid>).
childCount (int): Number of seasons (including Specials) in the show.
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
contentRating (str) Content rating (PG-13; NR; TV-G).
duration (int): Typical duration of the show episodes in milliseconds.
enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled.
(-1 = Library default, 0 = Disabled).
episodeSort (int): Setting that indicates how episodes are sorted for the show
(-1 = Library default, 0 = Oldest first, 1 = Newest first).
flattenSeasons (int): Setting that indicates if seasons are set to hidden for the show
@ -494,7 +496,8 @@ class Show(
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
seasonCount (int): Number of seasons (excluding Specials) in the show.
showOrdering (str): Setting that indicates the episode ordering for the show
(None = Library default).
(None = Library default, tmdbAiring = The Movie Database (Aired),
aired = TheTVDB (Aired), dvd = TheTVDB (DVD), absolute = TheTVDB (Absolute)).
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
subtitleLanguage (str): Setting that indicates the preferred subtitle language.
@ -521,7 +524,6 @@ class Show(
int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0'))
self.autoDeletionItemPolicyWatchedLibrary = utils.cast(
int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0'))
self.banner = data.attrib.get('banner')
self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating')
@ -659,8 +661,7 @@ class Season(
Video,
AdvancedSettingsMixin, ExtrasMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeUrlMixin,
SummaryMixin, TitleMixin,
CollectionMixin, LabelMixin
SeasonEditMixins
):
""" Represents a single Show Season (including all episodes).
@ -740,10 +741,12 @@ class Season(
""" Returns the season number. """
return self.index
def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """
key = f'{self.key}/children'
return self.fetchItems(key, Episode, **kwargs)
def onDeck(self):
""" Returns season's On Deck :class:`~plexapi.video.Video` object or `None`.
Will only return a match if the show's On Deck episode is in this season.
"""
data = self._server.query(self._details_key)
return next(iter(self.findItems(data, rtag='OnDeck')), None)
def episode(self, title=None, episode=None):
""" Returns the episode with the given title or number.
@ -766,17 +769,15 @@ class Season(
return self.fetchItem(key, Episode, parentIndex=self.index, index=index)
raise BadRequest('Missing argument: title or episode is required')
def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """
key = f'{self.key}/children'
return self.fetchItems(key, Episode, **kwargs)
def get(self, title=None, episode=None):
""" Alias to :func:`~plexapi.video.Season.episode`. """
return self.episode(title, episode)
def onDeck(self):
""" Returns season's On Deck :class:`~plexapi.video.Video` object or `None`.
Will only return a match if the show's On Deck episode is in this season.
"""
data = self._server.query(self._details_key)
return next(iter(self.findItems(data, rtag='OnDeck')), None)
def show(self):
""" Return the season's :class:`~plexapi.video.Show`. """
return self.fetchItem(self.parentKey)
@ -813,8 +814,7 @@ class Episode(
Video, Playable,
ExtrasMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeUrlMixin,
ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin,
CollectionMixin, DirectorMixin, LabelMixin, WriterMixin
EpisodeEditMixins
):
""" Represents a single Shows Episode.
@ -906,7 +906,7 @@ class Episode(
# If seasons are hidden, parentKey and parentRatingKey are missing from the XML response.
# https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553
if self.skipParent and not self.parentRatingKey:
if self.skipParent and data.attrib.get('parentRatingKey') is None:
# Parse the parentRatingKey from the parentThumb
if self.parentThumb and self.parentThumb.startswith('/library/metadata/'):
self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3])
@ -993,6 +993,13 @@ class Episode(
""" Returns str, default title for a new syncItem. """
return f'{self.grandparentTitle} - {self.parentTitle} - ({self.seasonEpisode}) {self.title}'
def removeFromContinueWatching(self):
""" Remove the movie from continue watching. """
key = '/actions/removeFromContinueWatching'
params = {'ratingKey': self.ratingKey}
self._server.query(key, params=params, method=self._server._session.put)
return self
@utils.registerPlexObject
class Clip(
@ -1105,3 +1112,42 @@ class ClipSession(PlexSession, Clip):
""" Load attribute values from Plex XML response. """
Clip._loadData(self, data)
PlexSession._loadData(self, data)
@utils.registerPlexObject
class MovieHistory(PlexHistory, Movie):
""" Represents a single Movie history entry
loaded from :func:`~plexapi.server.PlexServer.history`.
"""
_HISTORYTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Movie._loadData(self, data)
PlexHistory._loadData(self, data)
@utils.registerPlexObject
class EpisodeHistory(PlexHistory, Episode):
""" Represents a single Episode history entry
loaded from :func:`~plexapi.server.PlexServer.history`.
"""
_HISTORYTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Episode._loadData(self, data)
PlexHistory._loadData(self, data)
@utils.registerPlexObject
class ClipHistory(PlexHistory, Clip):
""" Represents a single Clip history entry
loaded from :func:`~plexapi.server.PlexServer.history`.
"""
_HISTORYTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Clip._loadData(self, data)
PlexHistory._loadData(self, data)

View file

@ -28,7 +28,7 @@ MarkupSafe==2.1.3
musicbrainzngs==0.7.1
packaging==23.1
paho-mqtt==1.6.1
plexapi==4.13.4
plexapi==4.15.0
portend==3.2.0
profilehooks==1.12.0
PyJWT==2.8.0