mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-12 16:22:57 -07:00
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:
parent
2c42150799
commit
b2c16eba07
19 changed files with 988 additions and 534 deletions
|
@ -3,19 +3,17 @@ import os
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from plexapi import media, utils
|
from plexapi import media, utils
|
||||||
from plexapi.base import Playable, PlexPartialObject, PlexSession
|
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
from plexapi.exceptions import BadRequest
|
||||||
from plexapi.mixins import (
|
from plexapi.mixins import (
|
||||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
||||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
|
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
|
||||||
AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
|
ArtistEditMixins, AlbumEditMixins, TrackEditMixins
|
||||||
TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin,
|
|
||||||
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
|
|
||||||
)
|
)
|
||||||
from plexapi.playlist import Playlist
|
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`,
|
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
|
||||||
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
|
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
|
||||||
|
|
||||||
|
@ -132,8 +130,7 @@ class Artist(
|
||||||
Audio,
|
Audio,
|
||||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeMixin,
|
ArtMixin, PosterMixin, ThemeMixin,
|
||||||
SortTitleMixin, SummaryMixin, TitleMixin,
|
ArtistEditMixins
|
||||||
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Artist.
|
""" Represents a single Artist.
|
||||||
|
|
||||||
|
@ -181,14 +178,19 @@ class Artist(
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str): Title of the album to return.
|
title (str): Title of the album to return.
|
||||||
"""
|
"""
|
||||||
try:
|
return self.section().get(
|
||||||
return self.section().search(title, libtype='album', filters={'artist.id': self.ratingKey})[0]
|
title=title,
|
||||||
except IndexError:
|
libtype='album',
|
||||||
raise NotFound(f"Unable to find album '{title}'") from None
|
filters={'artist.id': self.ratingKey}
|
||||||
|
)
|
||||||
|
|
||||||
def albums(self, **kwargs):
|
def albums(self, **kwargs):
|
||||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
|
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
|
||||||
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):
|
def track(self, title=None, album=None, track=None):
|
||||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||||
|
@ -244,8 +246,7 @@ class Album(
|
||||||
Audio,
|
Audio,
|
||||||
UnmatchMatchMixin, RatingMixin,
|
UnmatchMatchMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||||
OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
|
AlbumEditMixins
|
||||||
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Album.
|
""" Represents a single Album.
|
||||||
|
|
||||||
|
@ -364,14 +365,14 @@ class Track(
|
||||||
Audio, Playable,
|
Audio, Playable,
|
||||||
ExtrasMixin, RatingMixin,
|
ExtrasMixin, RatingMixin,
|
||||||
ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin,
|
ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin,
|
||||||
TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin,
|
TrackEditMixins
|
||||||
CollectionMixin, LabelMixin, MoodMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Track.
|
""" Represents a single Track.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Directory'
|
TAG (str): 'Directory'
|
||||||
TYPE (str): 'track'
|
TYPE (str): 'track'
|
||||||
|
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
||||||
chapterSource (str): Unknown
|
chapterSource (str): Unknown
|
||||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||||
duration (int): Length of the track in milliseconds.
|
duration (int): Length of the track in milliseconds.
|
||||||
|
@ -407,6 +408,7 @@ class Track(
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
Audio._loadData(self, data)
|
Audio._loadData(self, data)
|
||||||
Playable._loadData(self, data)
|
Playable._loadData(self, data)
|
||||||
|
self.chapters = self.findItems(data, media.Chapter)
|
||||||
self.chapterSource = data.attrib.get('chapterSource')
|
self.chapterSource = data.attrib.get('chapterSource')
|
||||||
self.collections = self.findItems(data, media.Collection)
|
self.collections = self.findItems(data, media.Collection)
|
||||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
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.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
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
|
@property
|
||||||
def locations(self):
|
def locations(self):
|
||||||
""" This does not exist in plex xml response but is added to have a common
|
""" 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. """
|
""" Returns the track number. """
|
||||||
return self.index
|
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):
|
def _defaultSyncTitle(self):
|
||||||
""" Returns str, default title for a new syncItem. """
|
""" Returns str, default title for a new syncItem. """
|
||||||
return f'{self.grandparentTitle} - {self.parentTitle} - {self.title}'
|
return f'{self.grandparentTitle} - {self.parentTitle} - {self.title}'
|
||||||
|
@ -480,3 +482,16 @@ class TrackSession(PlexSession, Track):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
Track._loadData(self, data)
|
Track._loadData(self, data)
|
||||||
PlexSession._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)
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import re
|
import re
|
||||||
import weakref
|
import weakref
|
||||||
|
from functools import cached_property
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from xml.etree import ElementTree
|
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.exceptions import BadRequest, NotFound, UnknownType, Unsupported
|
||||||
from plexapi.utils import cached_property
|
|
||||||
|
|
||||||
USER_DONT_RELOAD_FOR_KEYS = set()
|
USER_DONT_RELOAD_FOR_KEYS = set()
|
||||||
_DONT_RELOAD_FOR_KEYS = {'key'}
|
_DONT_RELOAD_FOR_KEYS = {'key'}
|
||||||
|
@ -50,9 +50,14 @@ class PlexObject:
|
||||||
self._initpath = initpath or self.key
|
self._initpath = initpath or self.key
|
||||||
self._parent = weakref.ref(parent) if parent is not None else None
|
self._parent = weakref.ref(parent) if parent is not None else None
|
||||||
self._details_key = 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
|
# Allow overwriting previous attribute values with `None` when manually reloading
|
||||||
self._edits = None # Save batch edits for a single API call
|
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:
|
if data is not None:
|
||||||
self._loadData(data)
|
self._loadData(data)
|
||||||
self._details_key = self._buildDetailsKey()
|
self._details_key = self._buildDetailsKey()
|
||||||
|
@ -87,7 +92,9 @@ class PlexObject:
|
||||||
etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type')))
|
etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type')))
|
||||||
ehash = f'{elem.tag}.{etype}' if etype else elem.tag
|
ehash = f'{elem.tag}.{etype}' if etype else elem.tag
|
||||||
if initpath == '/status/sessions':
|
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))
|
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
|
||||||
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
|
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
|
||||||
if ecls is not None:
|
if ecls is not None:
|
||||||
|
@ -147,47 +154,14 @@ class PlexObject:
|
||||||
elem = ElementTree.fromstring(xml)
|
elem = ElementTree.fromstring(xml)
|
||||||
return self._buildItemOrNone(elem, cls)
|
return self._buildItemOrNone(elem, cls)
|
||||||
|
|
||||||
def fetchItem(self, ekey, cls=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 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):
|
|
||||||
""" Load the specified key to find and build all items with the specified tag
|
""" Load the specified key to find and build all items with the specified tag
|
||||||
and attrs.
|
and attrs.
|
||||||
|
|
||||||
Parameters:
|
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
|
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
||||||
items to be fetched, passing this in will help the parser ensure
|
items to be fetched, passing this in will help the parser ensure
|
||||||
it only returns those items. By default we convert the xml elements
|
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.
|
etag (str): Only fetch items with the specified tag.
|
||||||
container_start (None, int): offset to get a subset of the data
|
container_start (None, int): offset to get a subset of the data
|
||||||
container_size (None, int): How many items in 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.
|
**kwargs (dict): Optionally add XML attribute to filter the items.
|
||||||
See the details below for more info.
|
See the details below for more info.
|
||||||
|
|
||||||
|
@ -259,39 +234,80 @@ class PlexObject:
|
||||||
if ekey is None:
|
if ekey is None:
|
||||||
raise BadRequest('ekey was not provided')
|
raise BadRequest('ekey was not provided')
|
||||||
|
|
||||||
params = {}
|
if isinstance(ekey, list) and all(isinstance(key, int) for key in ekey):
|
||||||
if container_start is not None:
|
ekey = f'/library/metadata/{",".join(str(key) for key in ekey)}'
|
||||||
params["X-Plex-Container-Start"] = container_start
|
|
||||||
if container_size is not None:
|
|
||||||
params["X-Plex-Container-Size"] = container_size
|
|
||||||
|
|
||||||
data = self._server.query(ekey, params=params)
|
container_start = container_start or 0
|
||||||
items = self.findItems(data, cls, ekey, **kwargs)
|
container_size = container_size or X_PLEX_CONTAINER_SIZE
|
||||||
|
offset = container_start
|
||||||
|
|
||||||
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
if maxresults is not None:
|
||||||
if librarySectionID:
|
container_size = min(container_size, maxresults)
|
||||||
for item in items:
|
|
||||||
item.librarySectionID = librarySectionID
|
|
||||||
return items
|
|
||||||
|
|
||||||
def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
results = []
|
||||||
""" Load the specified data to find and build the first items with the specified tag
|
subresults = []
|
||||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
headers = {}
|
||||||
on how this is used.
|
|
||||||
|
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 subresults:
|
||||||
|
item.librarySectionID = librarySectionID
|
||||||
|
|
||||||
|
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 isinstance(ekey, int):
|
||||||
if cls and cls.TAG and 'tag' not in kwargs:
|
ekey = f'/library/metadata/{ekey}'
|
||||||
kwargs['etag'] = cls.TAG
|
|
||||||
if cls and cls.TYPE and 'type' not in kwargs:
|
try:
|
||||||
kwargs['type'] = cls.TYPE
|
return self.fetchItems(ekey, cls, **kwargs)[0]
|
||||||
# rtag to iter on a specific root tag
|
except IndexError:
|
||||||
if rtag:
|
clsname = cls.__name__ if cls else 'None'
|
||||||
data = next(data.iter(rtag), [])
|
raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}') from None
|
||||||
# loop through all data elements to find matches
|
|
||||||
for elem in data:
|
|
||||||
if self._checkAttrs(elem, **kwargs):
|
|
||||||
item = self._buildItemOrNone(elem, cls, initpath)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
||||||
""" Load the specified data to find and build all items with the specified tag
|
""" Load the specified data to find and build all items with the specified tag
|
||||||
|
@ -315,6 +331,16 @@ class PlexObject:
|
||||||
items.append(item)
|
items.append(item)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
||||||
|
""" Load the specified data to find and build the first items with the specified tag
|
||||||
|
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||||
|
on how this is used.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.findItems(data, cls, initpath, rtag, **kwargs)[0]
|
||||||
|
except IndexError:
|
||||||
|
return None
|
||||||
|
|
||||||
def firstAttr(self, *attrs):
|
def firstAttr(self, *attrs):
|
||||||
""" Return the first attribute in attrs that is not None. """
|
""" Return the first attribute in attrs that is not None. """
|
||||||
for attr in attrs:
|
for attr in attrs:
|
||||||
|
@ -475,7 +501,9 @@ class PlexPartialObject(PlexObject):
|
||||||
}
|
}
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
return other not in [None, []] and self.key == other.key
|
if isinstance(other, PlexPartialObject):
|
||||||
|
return other not in [None, []] and self.key == other.key
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
return hash(repr(self))
|
return hash(repr(self))
|
||||||
|
@ -492,7 +520,7 @@ class PlexPartialObject(PlexObject):
|
||||||
if attr.startswith('_'): return value
|
if attr.startswith('_'): return value
|
||||||
if value not in (None, []): return value
|
if value not in (None, []): return value
|
||||||
if self.isFullObject(): return value
|
if self.isFullObject(): return value
|
||||||
if isinstance(self, PlexSession): return value
|
if isinstance(self, (PlexSession, PlexHistory)): return value
|
||||||
if self._autoReload is False: return value
|
if self._autoReload is False: return value
|
||||||
# Log the reload.
|
# Log the reload.
|
||||||
clsname = self.__class__.__name__
|
clsname = self.__class__.__name__
|
||||||
|
@ -543,13 +571,10 @@ class PlexPartialObject(PlexObject):
|
||||||
self._edits.update(kwargs)
|
self._edits.update(kwargs)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
if 'id' not in kwargs:
|
|
||||||
kwargs['id'] = self.ratingKey
|
|
||||||
if 'type' not in kwargs:
|
if 'type' not in kwargs:
|
||||||
kwargs['type'] = utils.searchType(self._searchType)
|
kwargs['type'] = utils.searchType(self._searchType)
|
||||||
|
|
||||||
part = f'/library/sections/{self.librarySectionID}/all{utils.joinArgs(kwargs)}'
|
self.section()._edit(items=self, **kwargs)
|
||||||
self._server.query(part, method=self._server._session.put)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def edit(self, **kwargs):
|
def edit(self, **kwargs):
|
||||||
|
@ -643,7 +668,7 @@ class PlexPartialObject(PlexObject):
|
||||||
'have not allowed items to be deleted', self.key)
|
'have not allowed items to be deleted', self.key)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def history(self, maxresults=9999999, mindate=None):
|
def history(self, maxresults=None, mindate=None):
|
||||||
""" Get Play History for a media item.
|
""" Get Play History for a media item.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
@ -681,17 +706,11 @@ class Playable:
|
||||||
Albums which are all not playable.
|
Albums which are all not playable.
|
||||||
|
|
||||||
Attributes:
|
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).
|
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).
|
playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _loadData(self, data):
|
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.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
|
||||||
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue
|
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}'
|
key = f'/:/progress?key={self.ratingKey}&identifier=com.plexapp.plugins.library&time={time}&state={state}'
|
||||||
self._server.query(key)
|
self._server.query(key)
|
||||||
self._reload(_overwriteNone=False)
|
return self
|
||||||
|
|
||||||
def updateTimeline(self, time, state='stopped', duration=None):
|
def updateTimeline(self, time, state='stopped', duration=None):
|
||||||
""" Set the timeline progress for this video.
|
""" Set the timeline progress for this video.
|
||||||
|
@ -830,7 +849,7 @@ class Playable:
|
||||||
key = (f'/:/timeline?ratingKey={self.ratingKey}&key={self.key}&'
|
key = (f'/:/timeline?ratingKey={self.ratingKey}&key={self.key}&'
|
||||||
f'identifier=com.plexapp.plugins.library&time={int(time)}&state={state}{durationStr}')
|
f'identifier=com.plexapp.plugins.library&time={int(time)}&state={state}{durationStr}')
|
||||||
self._server.query(key)
|
self._server.query(key)
|
||||||
self._reload(_overwriteNone=False)
|
return self
|
||||||
|
|
||||||
|
|
||||||
class PlexSession(object):
|
class PlexSession(object):
|
||||||
|
@ -912,6 +931,35 @@ class PlexSession(object):
|
||||||
return self._server.query(key, params=params)
|
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):
|
class MediaContainer(PlexObject):
|
||||||
""" Represents a single MediaContainer.
|
""" Represents a single MediaContainer.
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import time
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
|
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
|
||||||
|
|
|
@ -8,8 +8,7 @@ from plexapi.library import LibrarySection, ManagedHub
|
||||||
from plexapi.mixins import (
|
from plexapi.mixins import (
|
||||||
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeMixin,
|
ArtMixin, PosterMixin, ThemeMixin,
|
||||||
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
CollectionEditMixins
|
||||||
LabelMixin
|
|
||||||
)
|
)
|
||||||
from plexapi.utils import deprecated
|
from plexapi.utils import deprecated
|
||||||
|
|
||||||
|
@ -19,8 +18,7 @@ class Collection(
|
||||||
PlexPartialObject,
|
PlexPartialObject,
|
||||||
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeMixin,
|
ArtMixin, PosterMixin, ThemeMixin,
|
||||||
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
CollectionEditMixins
|
||||||
LabelMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Collection.
|
""" Represents a single Collection.
|
||||||
|
|
||||||
|
@ -222,6 +220,7 @@ class Collection(
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
collection.updateMode(user="user")
|
collection.updateMode(user="user")
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not self.smart:
|
if not self.smart:
|
||||||
raise BadRequest('Cannot change collection filtering user for a non-smart collection.')
|
raise BadRequest('Cannot change collection filtering user for a non-smart collection.')
|
||||||
|
@ -250,6 +249,7 @@ class Collection(
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
collection.updateMode(mode="hide")
|
collection.updateMode(mode="hide")
|
||||||
|
|
||||||
"""
|
"""
|
||||||
mode_dict = {
|
mode_dict = {
|
||||||
'default': -1,
|
'default': -1,
|
||||||
|
@ -276,6 +276,7 @@ class Collection(
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
collection.updateSort(mode="alpha")
|
collection.updateSort(mode="alpha")
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if self.smart:
|
if self.smart:
|
||||||
raise BadRequest('Cannot change collection order for a smart collection.')
|
raise BadRequest('Cannot change collection order for a smart collection.')
|
||||||
|
|
|
@ -3,6 +3,8 @@ import os
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
|
|
||||||
|
from plexapi import utils
|
||||||
|
|
||||||
|
|
||||||
class PlexConfig(ConfigParser):
|
class PlexConfig(ConfigParser):
|
||||||
""" PlexAPI configuration object. Settings are stored in an INI file within the
|
""" 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
|
# Second: check the config file has attr
|
||||||
section, name = key.lower().split('.')
|
section, name = key.lower().split('.')
|
||||||
value = self.data.get(section, {}).get(name, default)
|
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
|
except: # noqa: E722
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
# Library version
|
# Library version
|
||||||
MAJOR_VERSION = 4
|
MAJOR_VERSION = 4
|
||||||
MINOR_VERSION = 13
|
MINOR_VERSION = 15
|
||||||
PATCH_VERSION = 4
|
PATCH_VERSION = 0
|
||||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||||
|
|
|
@ -1,13 +1,18 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
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.base import OPERATORS, PlexObject
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
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.settings import Setting
|
||||||
from plexapi.utils import cached_property, deprecated
|
from plexapi.utils import deprecated
|
||||||
|
|
||||||
|
|
||||||
class Library(PlexObject):
|
class Library(PlexObject):
|
||||||
|
@ -352,7 +357,7 @@ class Library(PlexObject):
|
||||||
part += urlencode(kwargs)
|
part += urlencode(kwargs)
|
||||||
return self._server.query(part, method=self._server._session.post)
|
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.
|
""" Get Play History for all library Sections for the owner.
|
||||||
Parameters:
|
Parameters:
|
||||||
maxresults (int): Only return the specified number of results (optional).
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
|
@ -421,40 +426,6 @@ class LibrarySection(PlexObject):
|
||||||
self._totalDuration = None
|
self._totalDuration = None
|
||||||
self._totalStorage = 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
|
@cached_property
|
||||||
def totalSize(self):
|
def totalSize(self):
|
||||||
""" Returns the total number of items in the library for the default library type. """
|
""" Returns the total number of items in the library for the default library type. """
|
||||||
|
@ -474,6 +445,20 @@ class LibrarySection(PlexObject):
|
||||||
self._getTotalDurationStorage()
|
self._getTotalDurationStorage()
|
||||||
return self._totalStorage
|
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):
|
def _getTotalDurationStorage(self):
|
||||||
""" Queries the Plex server for the total library duration and storage and caches the values. """
|
""" Queries the Plex server for the total library duration and storage and caches the values. """
|
||||||
data = self._server.query('/media/providers?includeStorage=1')
|
data = self._server.query('/media/providers?includeStorage=1')
|
||||||
|
@ -565,8 +550,9 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
LibrarySection.addLocations('/path/1')
|
LibrarySection.addLocations('/path/1')
|
||||||
LibrarySection.addLocations(['/path/1', 'path/2', '/path/3'])
|
LibrarySection.addLocations(['/path/1', 'path/2', '/path/3'])
|
||||||
|
|
||||||
"""
|
"""
|
||||||
locations = self.locations
|
locations = self.locations
|
||||||
if isinstance(location, str):
|
if isinstance(location, str):
|
||||||
|
@ -587,8 +573,9 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
LibrarySection.removeLocations('/path/1')
|
LibrarySection.removeLocations('/path/1')
|
||||||
LibrarySection.removeLocations(['/path/1', 'path/2', '/path/3'])
|
LibrarySection.removeLocations(['/path/1', 'path/2', '/path/3'])
|
||||||
|
|
||||||
"""
|
"""
|
||||||
locations = self.locations
|
locations = self.locations
|
||||||
if isinstance(location, str):
|
if isinstance(location, str):
|
||||||
|
@ -602,19 +589,24 @@ class LibrarySection(PlexObject):
|
||||||
raise BadRequest('You are unable to remove all locations from a library.')
|
raise BadRequest('You are unable to remove all locations from a library.')
|
||||||
return self.edit(location=locations)
|
return self.edit(location=locations)
|
||||||
|
|
||||||
def get(self, title):
|
def get(self, title, **kwargs):
|
||||||
""" Returns the media item with the specified title.
|
""" Returns the media item with the specified title and kwargs.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str): Title of the item to return.
|
title (str): Title of the item to return.
|
||||||
|
kwargs (dict): Additional search parameters.
|
||||||
|
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`~plexapi.exceptions.NotFound`: The title is not found in the library.
|
:exc:`~plexapi.exceptions.NotFound`: The title is not found in the library.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return self.search(title)[0]
|
return self.search(title, limit=1, **kwargs)[0]
|
||||||
except IndexError:
|
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):
|
def getGuid(self, guid):
|
||||||
""" Returns the media item with the specified external Plex, IMDB, TMDB, or TVDB ID.
|
""" 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'
|
key = f'/library/sections/{self.key}/onDeck'
|
||||||
return self.fetchItems(key)
|
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):
|
def recentlyAdded(self, maxresults=50, libtype=None):
|
||||||
""" Returns a list of media items recently added from this library section.
|
""" 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)
|
return self._server.search(query, mediatype, limit, sectionId=self.key)
|
||||||
|
|
||||||
def search(self, title=None, sort=None, maxresults=None, libtype=None,
|
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
|
""" 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
|
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.
|
over all results on the server.
|
||||||
|
@ -1517,43 +1514,8 @@ class LibrarySection(PlexObject):
|
||||||
"""
|
"""
|
||||||
key, kwargs = self._buildSearchKey(
|
key, kwargs = self._buildSearchKey(
|
||||||
title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs)
|
title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs)
|
||||||
return self._search(key, maxresults, container_start, container_size, **kwargs)
|
return self.fetchItems(
|
||||||
|
key, container_start=container_start, container_size=container_size, maxresults=maxresults, **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
|
|
||||||
|
|
||||||
def _locations(self):
|
def _locations(self):
|
||||||
""" Returns a list of :class:`~plexapi.library.Location` objects
|
""" 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)
|
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.
|
""" Get Play History for this library Section for the owner.
|
||||||
Parameters:
|
Parameters:
|
||||||
maxresults (int): Only return the specified number of results (optional).
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
|
@ -1720,8 +1682,101 @@ class LibrarySection(PlexObject):
|
||||||
params['pageType'] = 'list'
|
params['pageType'] = 'list'
|
||||||
return self._server._buildWebURL(base=base, **params)
|
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.')
|
||||||
|
|
||||||
|
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}')
|
||||||
|
|
||||||
class MovieSection(LibrarySection):
|
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.
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -1781,7 +1836,7 @@ class MovieSection(LibrarySection):
|
||||||
return super(MovieSection, self).sync(**kwargs)
|
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.
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -1865,7 +1920,7 @@ class ShowSection(LibrarySection):
|
||||||
return super(ShowSection, self).sync(**kwargs)
|
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.
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing music artists.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -1957,7 +2012,7 @@ class MusicSection(LibrarySection):
|
||||||
return super(MusicSection, self).sync(**kwargs)
|
return super(MusicSection, self).sync(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class PhotoSection(LibrarySection):
|
class PhotoSection(LibrarySection, PhotoalbumEditMixins, PhotoEditMixins):
|
||||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -1979,13 +2034,13 @@ class PhotoSection(LibrarySection):
|
||||||
def collections(self, **kwargs):
|
def collections(self, **kwargs):
|
||||||
raise NotImplementedError('Collections are not available for a Photo library.')
|
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. """
|
""" 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. """
|
""" 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):
|
def recentlyAddedAlbums(self, maxresults=50):
|
||||||
""" Returns a list of recently added photo albums from this library section.
|
""" 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.
|
reason (str): The reason for the search result.
|
||||||
reasonID (int): The reason ID for the search result.
|
reasonID (int): The reason ID for the search result.
|
||||||
reasonTitle (str): The reason title 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).
|
type (str): The type of search result (tag).
|
||||||
tag (str): The title of the 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.
|
tagType (int): The type ID of the tag.
|
||||||
tagValue (int): The value of the tag.
|
tagValue (int): The value of the tag.
|
||||||
thumb (str): The URL for the thumbnail of the tag (if available).
|
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.reason = data.attrib.get('reason')
|
||||||
self.reasonID = utils.cast(int, data.attrib.get('reasonID'))
|
self.reasonID = utils.cast(int, data.attrib.get('reasonID'))
|
||||||
self.reasonTitle = data.attrib.get('reasonTitle')
|
self.reasonTitle = data.attrib.get('reasonTitle')
|
||||||
|
self.score = utils.cast(float, data.attrib.get('score'))
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
self.tag = data.attrib.get('tag')
|
self.tag = data.attrib.get('tag')
|
||||||
|
self.tagKey = data.attrib.get('tagKey')
|
||||||
self.tagType = utils.cast(int, data.attrib.get('tagType'))
|
self.tagType = utils.cast(int, data.attrib.get('tagType'))
|
||||||
self.tagValue = utils.cast(int, data.attrib.get('tagValue'))
|
self.tagValue = utils.cast(int, data.attrib.get('tagValue'))
|
||||||
self.thumb = data.attrib.get('thumb')
|
self.thumb = data.attrib.get('thumb')
|
||||||
|
@ -2222,16 +2281,6 @@ class Autotag(LibraryMediaTag):
|
||||||
TAGTYPE = 207
|
TAGTYPE = 207
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
|
||||||
class Banner(LibraryMediaTag):
|
|
||||||
""" Represents a single Banner library media tag.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
TAGTYPE (int): 311
|
|
||||||
"""
|
|
||||||
TAGTYPE = 311
|
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Chapter(LibraryMediaTag):
|
class Chapter(LibraryMediaTag):
|
||||||
""" Represents a single Chapter library media tag.
|
""" Represents a single Chapter library media tag.
|
||||||
|
@ -2958,6 +3007,7 @@ class ManagedHub(PlexObject):
|
||||||
managedHub.updateVisibility(recommended=True, home=True, shared=False).reload()
|
managedHub.updateVisibility(recommended=True, home=True, shared=False).reload()
|
||||||
# or using chained methods
|
# or using chained methods
|
||||||
managedHub.promoteRecommended().promoteHome().demoteShared().reload()
|
managedHub.promoteRecommended().promoteHome().demoteShared().reload()
|
||||||
|
|
||||||
"""
|
"""
|
||||||
params = {
|
params = {
|
||||||
'promotedToRecommended': int(self.promotedToRecommended),
|
'promotedToRecommended': int(self.promotedToRecommended),
|
||||||
|
@ -3066,7 +3116,6 @@ class Path(PlexObject):
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Path'
|
TAG (str): 'Path'
|
||||||
|
|
||||||
home (bool): True if the path is the home directory
|
home (bool): True if the path is the home directory
|
||||||
key (str): API URL (/services/browse/<base64path>)
|
key (str): API URL (/services/browse/<base64path>)
|
||||||
network (bool): True if path is a network location
|
network (bool): True if path is a network location
|
||||||
|
@ -3098,7 +3147,6 @@ class File(PlexObject):
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'File'
|
TAG (str): 'File'
|
||||||
|
|
||||||
key (str): API URL (/services/browse/<base64path>)
|
key (str): API URL (/services/browse/<base64path>)
|
||||||
path (str): Full path to file
|
path (str): Full path to file
|
||||||
title (str): File name
|
title (str): File name
|
||||||
|
@ -3109,3 +3157,105 @@ class File(PlexObject):
|
||||||
self.key = data.attrib.get('key')
|
self.key = data.attrib.get('key')
|
||||||
self.path = data.attrib.get('path')
|
self.path = data.attrib.get('path')
|
||||||
self.title = data.attrib.get('title')
|
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)
|
||||||
|
|
|
@ -415,7 +415,11 @@ class SubtitleStream(MediaPartStream):
|
||||||
forced (bool): True if this is a forced subtitle.
|
forced (bool): True if this is a forced subtitle.
|
||||||
format (str): The format of the subtitle stream (ex: srt).
|
format (str): The format of the subtitle stream (ex: srt).
|
||||||
headerCompression (str): The header compression of the subtitle stream.
|
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.
|
transient (str): Unknown.
|
||||||
|
userID (int): The user id of the user that downloaded the on-demand subtitle.
|
||||||
"""
|
"""
|
||||||
TAG = 'Stream'
|
TAG = 'Stream'
|
||||||
STREAMTYPE = 3
|
STREAMTYPE = 3
|
||||||
|
@ -427,7 +431,11 @@ class SubtitleStream(MediaPartStream):
|
||||||
self.forced = utils.cast(bool, data.attrib.get('forced', '0'))
|
self.forced = utils.cast(bool, data.attrib.get('forced', '0'))
|
||||||
self.format = data.attrib.get('format')
|
self.format = data.attrib.get('format')
|
||||||
self.headerCompression = data.attrib.get('headerCompression')
|
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.transient = data.attrib.get('transient')
|
||||||
|
self.userID = utils.cast(int, data.attrib.get('userID'))
|
||||||
|
|
||||||
def setDefault(self):
|
def setDefault(self):
|
||||||
""" Sets this subtitle stream as the default subtitle stream. """
|
""" Sets this subtitle stream as the default subtitle stream. """
|
||||||
|
@ -955,7 +963,7 @@ class Review(PlexObject):
|
||||||
|
|
||||||
|
|
||||||
class BaseResource(PlexObject):
|
class BaseResource(PlexObject):
|
||||||
""" Base class for all Art, Banner, Poster, and Theme objects.
|
""" Base class for all Art, Poster, and Theme objects.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Photo' or 'Track'
|
TAG (str): 'Photo' or 'Track'
|
||||||
|
@ -987,11 +995,6 @@ class Art(BaseResource):
|
||||||
TAG = 'Photo'
|
TAG = 'Photo'
|
||||||
|
|
||||||
|
|
||||||
class Banner(BaseResource):
|
|
||||||
""" Represents a single Banner object. """
|
|
||||||
TAG = 'Photo'
|
|
||||||
|
|
||||||
|
|
||||||
class Poster(BaseResource):
|
class Poster(BaseResource):
|
||||||
""" Represents a single Poster object. """
|
""" Represents a single Poster object. """
|
||||||
TAG = 'Photo'
|
TAG = 'Photo'
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit
|
from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit
|
||||||
|
|
||||||
from plexapi import media, settings, utils
|
from plexapi import media, settings, utils
|
||||||
|
@ -139,7 +138,7 @@ class SplitMergeMixin:
|
||||||
if not isinstance(ratingKeys, list):
|
if not isinstance(ratingKeys, list):
|
||||||
ratingKeys = str(ratingKeys).split(',')
|
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)
|
self._server.query(key, method=self._server._session.put)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@ -329,7 +328,19 @@ class ArtUrlMixin:
|
||||||
return self._server.url(art, includeToken=True) if art else None
|
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. """
|
""" Mixin for Plex objects that can have background artwork. """
|
||||||
|
|
||||||
def arts(self):
|
def arts(self):
|
||||||
|
@ -361,65 +372,6 @@ class ArtMixin(ArtUrlMixin):
|
||||||
art.select()
|
art.select()
|
||||||
return self
|
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:
|
class PosterUrlMixin:
|
||||||
""" Mixin for Plex objects that can have a poster url. """
|
""" Mixin for Plex objects that can have a poster url. """
|
||||||
|
@ -436,7 +388,19 @@ class PosterUrlMixin:
|
||||||
return self.thumbUrl
|
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. """
|
""" Mixin for Plex objects that can have posters. """
|
||||||
|
|
||||||
def posters(self):
|
def posters(self):
|
||||||
|
@ -468,14 +432,6 @@ class PosterMixin(PosterUrlMixin):
|
||||||
poster.select()
|
poster.select()
|
||||||
return self
|
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:
|
class ThemeUrlMixin:
|
||||||
""" Mixin for Plex objects that can have a theme url. """
|
""" 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
|
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. """
|
""" Mixin for Plex objects that can have themes. """
|
||||||
|
|
||||||
def themes(self):
|
def themes(self):
|
||||||
|
@ -520,14 +488,6 @@ class ThemeMixin(ThemeUrlMixin):
|
||||||
'Re-upload the theme using "uploadTheme" to set it.'
|
'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:
|
class EditFieldMixin:
|
||||||
""" Mixin for editing Plex object fields. """
|
""" Mixin for editing Plex object fields. """
|
||||||
|
@ -752,6 +712,19 @@ class PhotoCapturedTimeMixin(EditFieldMixin):
|
||||||
return self.editField('originallyAvailableAt', capturedTime, locked=locked)
|
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:
|
class EditTagsMixin:
|
||||||
""" Mixin for editing Plex object tags. """
|
""" Mixin for editing Plex object tags. """
|
||||||
|
|
||||||
|
@ -781,7 +754,7 @@ class EditTagsMixin:
|
||||||
items = [items]
|
items = [items]
|
||||||
|
|
||||||
if not remove:
|
if not remove:
|
||||||
tags = getattr(self, self._tagPlural(tag))
|
tags = getattr(self, self._tagPlural(tag), [])
|
||||||
items = tags + items
|
items = tags + items
|
||||||
|
|
||||||
edits = self._tagHelper(self._tagSingular(tag), items, locked, remove)
|
edits = self._tagHelper(self._tagSingular(tag), items, locked, remove)
|
||||||
|
@ -822,7 +795,7 @@ class EditTagsMixin:
|
||||||
|
|
||||||
if remove:
|
if remove:
|
||||||
tagname = f'{tag}[].tag.tag-'
|
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:
|
else:
|
||||||
for i, item in enumerate(items):
|
for i, item in enumerate(items):
|
||||||
tagname = f'{str(tag)}[{i}].tag.tag'
|
tagname = f'{str(tag)}[{i}].tag.tag'
|
||||||
|
@ -1135,3 +1108,84 @@ class WatchlistMixin:
|
||||||
ratingKey = self.guid.rsplit('/', 1)[-1]
|
ratingKey = self.guid.rsplit('/', 1)[-1]
|
||||||
data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities")
|
data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities")
|
||||||
return self.findItems(data)
|
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
|
||||||
|
|
|
@ -7,8 +7,9 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_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.base import PlexObject
|
||||||
from plexapi.client import PlexClient
|
from plexapi.client import PlexClient
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||||
|
@ -21,51 +22,76 @@ from requests.status_codes import _codes as codes
|
||||||
|
|
||||||
class MyPlexAccount(PlexObject):
|
class MyPlexAccount(PlexObject):
|
||||||
""" MyPlex account and profile information. This object represents the data found Account on
|
""" 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
|
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
|
method provided at :class:`~plexapi.server.PlexServer.myPlexAccount()` which will create
|
||||||
and return this object.
|
and return this object.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
username (str): Your MyPlex username.
|
username (str): Plex login username if not using a token.
|
||||||
password (str): Your MyPlex password.
|
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
|
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).
|
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:
|
Attributes:
|
||||||
SIGNIN (str): 'https://plex.tv/users/sign_in.xml'
|
key (str): 'https://plex.tv/api/v2/user'
|
||||||
key (str): 'https://plex.tv/users/account'
|
adsConsent (str): Unknown.
|
||||||
authenticationToken (str): Unknown.
|
adsConsentReminderAt (str): Unknown.
|
||||||
certificateVersion (str): Unknown.
|
adsConsentSetAt (str): Unknown.
|
||||||
cloudSyncDevice (str): Unknown.
|
anonymous (str): Unknown.
|
||||||
email (str): Your current Plex email address.
|
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.
|
entitlements (List<str>): List of devices your allowed to use with this account.
|
||||||
guest (bool): Unknown.
|
guest (bool): If the account is a Plex Home guest user.
|
||||||
home (bool): Unknown.
|
hasPassword (bool): If the account has a password.
|
||||||
homeSize (int): Unknown.
|
home (bool): If the account is a Plex Home user.
|
||||||
id (int): Your Plex account ID.
|
homeAdmin (bool): If the account is the Plex Home admin.
|
||||||
locale (str): Your Plex locale
|
homeSize (int): The number of accounts in the Plex Home.
|
||||||
mailing_list_status (str): Your current mailing list status.
|
id (int): The Plex account ID.
|
||||||
maxHomeSize (int): Unknown.
|
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.
|
pin (str): The hashed Plex Home PIN.
|
||||||
queueEmail (str): Email address to add items to your `Watch Later` queue.
|
profileAutoSelectAudio (bool): If the account has automatically select audio and subtitle tracks enabled.
|
||||||
queueUid (str): Unknown.
|
profileDefaultAudioLanguage (str): The preferred audio language for the account.
|
||||||
restricted (bool): Unknown.
|
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.
|
roles: (List<str>) Lit of account roles. Plexpass membership listed here.
|
||||||
scrobbleTypes (str): Description
|
scrobbleTypes (List<int>): Unknown.
|
||||||
secure (bool): Description
|
subscriptionActive (bool): If the account's Plex Pass subscription is active.
|
||||||
subscriptionActive (bool): True if your subscription is active.
|
subscriptionDescription (str): Description of the Plex Pass subscription.
|
||||||
subscriptionFeatures: (List<str>) List of features allowed on your subscription.
|
subscriptionFeatures: (List<str>) List of features allowed on your Plex Pass subscription.
|
||||||
subscriptionPlan (str): Name of subscription plan.
|
subscriptionPaymentService (str): Payment service used for your Plex Pass subscription.
|
||||||
subscriptionStatus (str): String representation of `subscriptionActive`.
|
subscriptionPlan (str): Name of Plex Pass subscription plan.
|
||||||
thumb (str): URL of your account thumbnail.
|
subscriptionStatus (str): String representation of ``subscriptionActive``.
|
||||||
title (str): Unknown. - Looks like an alias for `username`.
|
subscriptionSubscribedAt (datetime): Date the account subscribed to Plex Pass.
|
||||||
username (str): Your account username.
|
thumb (str): URL of the account thumbnail.
|
||||||
uuid (str): Unknown.
|
title (str): The title of the account (username or friendly name).
|
||||||
_token (str): Token used to access this client.
|
twoFactorEnabled (bool): If two-factor authentication is enabled.
|
||||||
_session (obj): Requests session object used to access this client.
|
username (str): The account username.
|
||||||
|
uuid (str): The account UUID.
|
||||||
"""
|
"""
|
||||||
FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data
|
FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data
|
||||||
HOMEUSERS = 'https://plex.tv/api/home/users'
|
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
|
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete
|
||||||
HOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete, put
|
HOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete, put
|
||||||
MANAGEDHOMEUSER = 'https://plex.tv/api/v2/home/users/restricted/{userId}' # 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
|
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
||||||
OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get
|
OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get
|
||||||
LINK = 'https://plex.tv/api/v2/pins/link' # put
|
LINK = 'https://plex.tv/api/v2/pins/link' # put
|
||||||
|
@ -85,86 +112,106 @@ class MyPlexAccount(PlexObject):
|
||||||
VOD = 'https://vod.provider.plex.tv' # get
|
VOD = 'https://vod.provider.plex.tv' # get
|
||||||
MUSIC = 'https://music.provider.plex.tv' # get
|
MUSIC = 'https://music.provider.plex.tv' # get
|
||||||
METADATA = 'https://metadata.provider.plex.tv'
|
METADATA = 'https://metadata.provider.plex.tv'
|
||||||
# Key may someday switch to the following url. For now the current value works.
|
key = 'https://plex.tv/api/v2/user'
|
||||||
# https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId}
|
|
||||||
key = 'https://plex.tv/users/account'
|
|
||||||
|
|
||||||
def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None):
|
def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None, remember=True):
|
||||||
self._token = token or CONFIG.get('auth.server_token')
|
self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
|
||||||
self._session = session or requests.Session()
|
self._session = session or requests.Session()
|
||||||
self._sonos_cache = []
|
self._sonos_cache = []
|
||||||
self._sonos_cache_timestamp = 0
|
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)
|
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:
|
if self._token:
|
||||||
return self.query(self.key), self.key
|
return self.query(self.key), self.key
|
||||||
username = username or CONFIG.get('auth.myplex_username')
|
payload = {
|
||||||
password = password or CONFIG.get('auth.myplex_password')
|
'login': username or CONFIG.get('auth.myplex_username'),
|
||||||
|
'password': password or CONFIG.get('auth.myplex_password'),
|
||||||
|
'rememberMe': remember
|
||||||
|
}
|
||||||
if code:
|
if code:
|
||||||
password += code
|
payload['verificationCode'] = code
|
||||||
data = self.query(self.SIGNIN, method=self._session.post, auth=(username, password), timeout=timeout)
|
data = self.query(self.SIGNIN, method=self._session.post, data=payload, timeout=timeout)
|
||||||
return data, self.SIGNIN
|
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):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
self._data = data
|
self._data = data
|
||||||
self._token = logfilter.add_secret(data.attrib.get('authenticationToken'))
|
self._token = logfilter.add_secret(data.attrib.get('authToken'))
|
||||||
self._webhooks = []
|
self._webhooks = []
|
||||||
self.authenticationToken = self._token
|
|
||||||
self.certificateVersion = data.attrib.get('certificateVersion')
|
self.adsConsent = data.attrib.get('adsConsent')
|
||||||
self.cloudSyncDevice = data.attrib.get('cloudSyncDevice')
|
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.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.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.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.homeSize = utils.cast(int, data.attrib.get('homeSize'))
|
||||||
self.id = utils.cast(int, data.attrib.get('id'))
|
self.id = utils.cast(int, data.attrib.get('id'))
|
||||||
|
self.joinedAt = utils.toDatetime(data.attrib.get('joinedAt'))
|
||||||
self.locale = data.attrib.get('locale')
|
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.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
|
||||||
self.pin = data.attrib.get('pin')
|
self.pin = data.attrib.get('pin')
|
||||||
self.queueEmail = data.attrib.get('queueEmail')
|
self.protected = utils.cast(bool, data.attrib.get('protected'))
|
||||||
self.queueUid = data.attrib.get('queueUid')
|
self.rememberExpiresAt = utils.toDatetime(data.attrib.get('rememberExpiresAt'))
|
||||||
self.restricted = utils.cast(bool, data.attrib.get('restricted'))
|
self.restricted = utils.cast(bool, data.attrib.get('restricted'))
|
||||||
self.scrobbleTypes = data.attrib.get('scrobbleTypes')
|
self.scrobbleTypes = [utils.cast(int, x) for x in data.attrib.get('scrobbleTypes').split(',')]
|
||||||
self.secure = utils.cast(bool, data.attrib.get('secure'))
|
|
||||||
self.thumb = data.attrib.get('thumb')
|
self.thumb = data.attrib.get('thumb')
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
|
self.twoFactorEnabled = utils.cast(bool, data.attrib.get('twoFactorEnabled'))
|
||||||
self.username = data.attrib.get('username')
|
self.username = data.attrib.get('username')
|
||||||
self.uuid = data.attrib.get('uuid')
|
self.uuid = data.attrib.get('uuid')
|
||||||
|
|
||||||
subscription = data.find('subscription')
|
subscription = data.find('subscription')
|
||||||
self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active'))
|
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.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.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement')
|
||||||
|
self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role')
|
||||||
|
|
||||||
# TODO: Fetch missing MyPlexAccount attributes
|
# TODO: Fetch missing MyPlexAccount services
|
||||||
self.profile_settings = None
|
|
||||||
self.services = None
|
self.services = None
|
||||||
self.joined_at = None
|
|
||||||
|
|
||||||
def device(self, name=None, clientId=None):
|
@property
|
||||||
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
|
def authenticationToken(self):
|
||||||
|
""" Returns the authentication token for the account. Alias for ``authToken``. """
|
||||||
|
return self.authToken
|
||||||
|
|
||||||
Parameters:
|
def _reload(self, key=None, **kwargs):
|
||||||
name (str): Name to match against.
|
""" Perform the actual reload. """
|
||||||
clientId (str): clientIdentifier to match against.
|
data = self.query(self.key)
|
||||||
"""
|
self._loadData(data)
|
||||||
for device in self.devices():
|
return self
|
||||||
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 _headers(self, **kwargs):
|
def _headers(self, **kwargs):
|
||||||
""" Returns dict containing base headers for all requests to the server. """
|
""" Returns dict containing base headers for all requests to the server. """
|
||||||
|
@ -188,6 +235,8 @@ class MyPlexAccount(PlexObject):
|
||||||
raise Unauthorized(message)
|
raise Unauthorized(message)
|
||||||
elif response.status_code == 404:
|
elif response.status_code == 404:
|
||||||
raise NotFound(message)
|
raise NotFound(message)
|
||||||
|
elif response.status_code == 422 and "Invalid token" in response.text:
|
||||||
|
raise Unauthorized(message)
|
||||||
else:
|
else:
|
||||||
raise BadRequest(message)
|
raise BadRequest(message)
|
||||||
if headers.get('Accept') == 'application/json':
|
if headers.get('Accept') == 'application/json':
|
||||||
|
@ -195,6 +244,23 @@ class MyPlexAccount(PlexObject):
|
||||||
data = response.text.encode('utf8')
|
data = response.text.encode('utf8')
|
||||||
return ElementTree.fromstring(data) if data.strip() else None
|
return ElementTree.fromstring(data) if data.strip() else None
|
||||||
|
|
||||||
|
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):
|
def resource(self, name):
|
||||||
""" Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified.
|
""" 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}')
|
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
|
||||||
return response.json()['token']
|
return response.json()['token']
|
||||||
|
|
||||||
def history(self, maxresults=9999999, mindate=None):
|
def history(self, maxresults=None, mindate=None):
|
||||||
""" Get Play History for all library sections on all servers for the owner.
|
""" Get Play History for all library sections on all servers for the owner.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
@ -817,7 +883,7 @@ class MyPlexAccount(PlexObject):
|
||||||
data = self.query(f'{self.MUSIC}/hubs')
|
data = self.query(f'{self.MUSIC}/hubs')
|
||||||
return self.findItems(data)
|
return self.findItems(data)
|
||||||
|
|
||||||
def watchlist(self, filter=None, sort=None, libtype=None, 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.
|
""" Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` items in the user's watchlist.
|
||||||
Note: The objects returned are from Plex's online metadata. To get the matching item on a Plex server,
|
Note: The objects returned are from Plex's online metadata. To get the matching item on a Plex server,
|
||||||
search for the media using the guid.
|
search for the media using the guid.
|
||||||
|
@ -857,23 +923,10 @@ class MyPlexAccount(PlexObject):
|
||||||
if libtype:
|
if libtype:
|
||||||
params['type'] = utils.searchType(libtype)
|
params['type'] = utils.searchType(libtype)
|
||||||
|
|
||||||
params['X-Plex-Container-Start'] = 0
|
|
||||||
params['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
|
||||||
params.update(kwargs)
|
params.update(kwargs)
|
||||||
|
|
||||||
results, subresults = [], '_init'
|
key = f'{self.METADATA}/library/sections/watchlist/{filter}{utils.joinArgs(params)}'
|
||||||
while subresults and maxresults > len(results):
|
return self._toOnlineMetadata(self.fetchItems(key, maxresults=maxresults), **kwargs)
|
||||||
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)
|
|
||||||
|
|
||||||
def onWatchlist(self, item):
|
def onWatchlist(self, item):
|
||||||
""" Returns True if the item is on the user's watchlist.
|
""" Returns True if the item is on the user's watchlist.
|
||||||
|
@ -936,6 +989,48 @@ class MyPlexAccount(PlexObject):
|
||||||
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
|
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
|
||||||
return self.findItem(data, cls=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):
|
def searchDiscover(self, query, limit=30, libtype=None):
|
||||||
""" Search for movies and TV shows in Discover.
|
""" Search for movies and TV shows in Discover.
|
||||||
Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects.
|
Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects.
|
||||||
|
@ -1117,7 +1212,7 @@ class MyPlexUser(PlexObject):
|
||||||
|
|
||||||
raise NotFound(f'Unable to find server {name}')
|
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.
|
""" Get all Play History for a user in all shared servers.
|
||||||
Parameters:
|
Parameters:
|
||||||
maxresults (int): Only return the specified number of results (optional).
|
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.sectionId = self.id # For backwards compatibility
|
||||||
self.sectionKey = self.key # 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.
|
""" Get all Play History for a user for this section in this shared server.
|
||||||
Parameters:
|
Parameters:
|
||||||
maxresults (int): Only return the specified number of results (optional).
|
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
|
""" 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
|
content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml
|
||||||
for the data presented here can be found at:
|
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:
|
Attributes:
|
||||||
TAG (str): 'Device'
|
TAG (str): 'Device'
|
||||||
key (str): 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
key (str): 'https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1'
|
||||||
accessToken (str): This resources accesstoken.
|
accessToken (str): This resource's Plex access token.
|
||||||
clientIdentifier (str): Unique ID for this resource.
|
clientIdentifier (str): Unique ID for this resource.
|
||||||
connections (list): List of :class:`~plexapi.myplex.ResourceConnection` objects
|
connections (list): List of :class:`~plexapi.myplex.ResourceConnection` objects
|
||||||
for this resource.
|
for this resource.
|
||||||
createdAt (datetime): Timestamp this resource first connected to your server.
|
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).
|
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
|
home (bool): Unknown
|
||||||
|
httpsRequired (bool): True if the resource requires https.
|
||||||
lastSeenAt (datetime): Timestamp this resource last connected.
|
lastSeenAt (datetime): Timestamp this resource last connected.
|
||||||
name (str): Descriptive name of this resource.
|
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).
|
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.)
|
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
|
||||||
platformVersion (str): Version of the platform.
|
platformVersion (str): Version of the platform.
|
||||||
presence (bool): True if the resource is online
|
presence (bool): True if the resource is online
|
||||||
|
@ -1288,10 +1387,13 @@ class MyPlexResource(PlexObject):
|
||||||
productVersion (str): Version of the product.
|
productVersion (str): Version of the product.
|
||||||
provides (str): List of services this resource provides (client, server,
|
provides (str): List of services this resource provides (client, server,
|
||||||
player, pubsub-player, etc.)
|
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?)
|
synced (bool): Unknown (possibly True if the resource has synced content?)
|
||||||
"""
|
"""
|
||||||
TAG = 'Device'
|
TAG = 'resource'
|
||||||
key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
key = 'https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1'
|
||||||
|
|
||||||
# Default order to prioritize available resource connections
|
# Default order to prioritize available resource connections
|
||||||
DEFAULT_LOCATION_ORDER = ['local', 'remote', 'relay']
|
DEFAULT_LOCATION_ORDER = ['local', 'remote', 'relay']
|
||||||
|
@ -1299,33 +1401,35 @@ class MyPlexResource(PlexObject):
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
self._data = data
|
self._data = data
|
||||||
self.name = data.attrib.get('name')
|
|
||||||
self.accessToken = logfilter.add_secret(data.attrib.get('accessToken'))
|
self.accessToken = logfilter.add_secret(data.attrib.get('accessToken'))
|
||||||
self.product = data.attrib.get('product')
|
self.clientIdentifier = data.attrib.get('clientIdentifier')
|
||||||
self.productVersion = data.attrib.get('productVersion')
|
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.platform = data.attrib.get('platform')
|
||||||
self.platformVersion = data.attrib.get('platformVersion')
|
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.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'))
|
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.relay = utils.cast(bool, data.attrib.get('relay'))
|
||||||
self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired'))
|
self.sourceTitle = data.attrib.get('sourceTitle')
|
||||||
self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0))
|
self.synced = utils.cast(bool, data.attrib.get('synced'))
|
||||||
self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username.
|
|
||||||
|
|
||||||
def preferred_connections(
|
def preferred_connections(
|
||||||
self,
|
self,
|
||||||
ssl=None,
|
ssl=None,
|
||||||
locations=DEFAULT_LOCATION_ORDER,
|
locations=None,
|
||||||
schemes=DEFAULT_SCHEME_ORDER,
|
schemes=None,
|
||||||
):
|
):
|
||||||
""" Returns a sorted list of the available connection addresses for this resource.
|
""" 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.
|
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
|
only connect to HTTP connections. Set None (default) to connect to any
|
||||||
HTTP or HTTPS connection.
|
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}
|
connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations}
|
||||||
for connection in self.connections:
|
for connection in self.connections:
|
||||||
# Only check non-local connections unless we own the resource
|
# Only check non-local connections unless we own the resource
|
||||||
|
@ -1359,8 +1468,8 @@ class MyPlexResource(PlexObject):
|
||||||
self,
|
self,
|
||||||
ssl=None,
|
ssl=None,
|
||||||
timeout=None,
|
timeout=None,
|
||||||
locations=DEFAULT_LOCATION_ORDER,
|
locations=None,
|
||||||
schemes=DEFAULT_SCHEME_ORDER,
|
schemes=None,
|
||||||
):
|
):
|
||||||
""" Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object.
|
""" 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.
|
Uses `MyPlexResource.preferred_connections()` to generate the priority order of connection addresses.
|
||||||
|
@ -1376,11 +1485,16 @@ class MyPlexResource(PlexObject):
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||||
"""
|
"""
|
||||||
|
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)
|
connections = self.preferred_connections(ssl, locations, schemes)
|
||||||
# Try connecting to all known resource connections in parallel, but
|
# Try connecting to all known resource connections in parallel, but
|
||||||
# only return the first server (in order) that provides a response.
|
# only return the first server (in order) that provides a response.
|
||||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||||
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))
|
log.debug('Testing %s resource connections..', len(listargs))
|
||||||
results = utils.threaded(_connect, listargs)
|
results = utils.threaded(_connect, listargs)
|
||||||
return _chooseConnection('Resource', self.name, results)
|
return _chooseConnection('Resource', self.name, results)
|
||||||
|
@ -1392,24 +1506,27 @@ class ResourceConnection(PlexObject):
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Connection'
|
TAG (str): 'Connection'
|
||||||
address (str): Local IP address
|
address (str): The connection IP address
|
||||||
httpuri (str): Full local address
|
httpuri (str): Full HTTP URL
|
||||||
local (bool): True if local
|
ipv6 (bool): True if the address is IPv6
|
||||||
port (int): 32400
|
local (bool): True if the address is local
|
||||||
|
port (int): The connection port
|
||||||
protocol (str): HTTP or HTTPS
|
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):
|
def _loadData(self, data):
|
||||||
self._data = data
|
self._data = data
|
||||||
self.protocol = data.attrib.get('protocol')
|
|
||||||
self.address = data.attrib.get('address')
|
self.address = data.attrib.get('address')
|
||||||
self.port = utils.cast(int, data.attrib.get('port'))
|
self.ipv6 = utils.cast(bool, data.attrib.get('IPv6'))
|
||||||
self.uri = data.attrib.get('uri')
|
|
||||||
self.local = utils.cast(bool, data.attrib.get('local'))
|
self.local = utils.cast(bool, data.attrib.get('local'))
|
||||||
self.httpuri = 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.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):
|
class MyPlexDevice(PlexObject):
|
||||||
|
@ -1475,7 +1592,7 @@ class MyPlexDevice(PlexObject):
|
||||||
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
|
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
|
||||||
"""
|
"""
|
||||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
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))
|
log.debug('Testing %s device connections..', len(listargs))
|
||||||
results = utils.threaded(_connect, listargs)
|
results = utils.threaded(_connect, listargs)
|
||||||
return _chooseConnection('Device', self.name, results)
|
return _chooseConnection('Device', self.name, results)
|
||||||
|
@ -1725,7 +1842,7 @@ class MyPlexPinLogin:
|
||||||
return ElementTree.fromstring(data) if data.strip() else None
|
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
|
""" Connects to the specified cls with url and token. Stores the connection
|
||||||
information to results[i] in a threadsafe way.
|
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
|
cls: the class which is responsible for establishing connection, basically it's
|
||||||
:class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer`
|
:class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer`
|
||||||
url (str): url which should be passed as `baseurl` argument to cls.__init__()
|
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__()
|
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__()
|
timeout (int): timeout which should be passed as `baseurl` argument to cls.__init__()
|
||||||
results (list): pre-filled list for results
|
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()
|
starttime = time.time()
|
||||||
try:
|
try:
|
||||||
device = cls(baseurl=url, token=token, timeout=timeout)
|
device = cls(baseurl=url, token=token, session=session, timeout=timeout)
|
||||||
runtime = int(time.time() - starttime)
|
runtime = int(time.time() - starttime)
|
||||||
results[i] = (url, token, device, runtime)
|
results[i] = (url, token, device, runtime)
|
||||||
if X_PLEX_ENABLE_FAST_CONNECT and job_is_done_event:
|
if X_PLEX_ENABLE_FAST_CONNECT and job_is_done_event:
|
||||||
|
|
|
@ -8,8 +8,7 @@ from plexapi.exceptions import BadRequest
|
||||||
from plexapi.mixins import (
|
from plexapi.mixins import (
|
||||||
RatingMixin,
|
RatingMixin,
|
||||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin,
|
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin,
|
||||||
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, PhotoCapturedTimeMixin,
|
PhotoalbumEditMixins, PhotoEditMixins
|
||||||
TagMixin
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,7 +17,7 @@ class Photoalbum(
|
||||||
PlexPartialObject,
|
PlexPartialObject,
|
||||||
RatingMixin,
|
RatingMixin,
|
||||||
ArtMixin, PosterMixin,
|
ArtMixin, PosterMixin,
|
||||||
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin
|
PhotoalbumEditMixins
|
||||||
):
|
):
|
||||||
""" Represents a single Photoalbum (collection of photos).
|
""" Represents a single Photoalbum (collection of photos).
|
||||||
|
|
||||||
|
@ -146,8 +145,7 @@ class Photo(
|
||||||
PlexPartialObject, Playable,
|
PlexPartialObject, Playable,
|
||||||
RatingMixin,
|
RatingMixin,
|
||||||
ArtUrlMixin, PosterUrlMixin,
|
ArtUrlMixin, PosterUrlMixin,
|
||||||
AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
PhotoEditMixins
|
||||||
TagMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Photo.
|
""" Represents a single Photo.
|
||||||
|
|
||||||
|
|
|
@ -433,7 +433,8 @@ class Playlist(
|
||||||
""" Copy playlist to another user account.
|
""" Copy playlist to another user account.
|
||||||
|
|
||||||
Parameters:
|
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)
|
userServer = self._server.switchUser(user)
|
||||||
return self.create(server=userServer, title=self.title, items=self.items())
|
return self.create(server=userServer, title=self.title, items=self.items())
|
||||||
|
|
|
@ -170,7 +170,7 @@ class PlayQueue(PlexObject):
|
||||||
}
|
}
|
||||||
|
|
||||||
if isinstance(items, list):
|
if isinstance(items, list):
|
||||||
item_keys = ",".join([str(x.ratingKey) for x in items])
|
item_keys = ",".join(str(x.ratingKey) for x in items)
|
||||||
uri_args = quote_plus(f"/library/metadata/{item_keys}")
|
uri_args = quote_plus(f"/library/metadata/{item_keys}")
|
||||||
args["uri"] = f"library:///directory/{uri_args}"
|
args["uri"] = f"library:///directory/{uri_args}"
|
||||||
args["type"] = items[0].listType
|
args["type"] = items[0].listType
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
import os
|
||||||
|
from functools import cached_property
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import os
|
|
||||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log,
|
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter
|
||||||
logfilter)
|
|
||||||
from plexapi import utils
|
from plexapi import utils
|
||||||
from plexapi.alert import AlertListener
|
from plexapi.alert import AlertListener
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
|
@ -17,7 +18,7 @@ from plexapi.media import Conversion, Optimized
|
||||||
from plexapi.playlist import Playlist
|
from plexapi.playlist import Playlist
|
||||||
from plexapi.playqueue import PlayQueue
|
from plexapi.playqueue import PlayQueue
|
||||||
from plexapi.settings import Settings
|
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
|
from requests.status_codes import _codes as codes
|
||||||
|
|
||||||
# Need these imports to populate utils.PLEXOBJECTS
|
# Need these imports to populate utils.PLEXOBJECTS
|
||||||
|
@ -236,12 +237,13 @@ class PlexServer(PlexObject):
|
||||||
q = self.query(f'/security/token?type={type}&scope={scope}')
|
q = self.query(f'/security/token?type={type}&scope={scope}')
|
||||||
return q.attrib.get('token')
|
return q.attrib.get('token')
|
||||||
|
|
||||||
def switchUser(self, username, session=None, timeout=None):
|
def switchUser(self, user, session=None, timeout=None):
|
||||||
""" Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username.
|
""" Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username.
|
||||||
Note: Only the admin account can switch to other users.
|
Note: Only the admin account can switch to other users.
|
||||||
|
|
||||||
Parameters:
|
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
|
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
|
cache the http responses from the server. This will default to the same
|
||||||
session as the admin account if no new session is provided.
|
session as the admin account if no new session is provided.
|
||||||
|
@ -260,7 +262,8 @@ class PlexServer(PlexObject):
|
||||||
userPlex = plex.switchUser("Username")
|
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)
|
userToken = user.get_token(self.machineIdentifier)
|
||||||
if session is None:
|
if session is None:
|
||||||
session = self._session
|
session = self._session
|
||||||
|
@ -470,6 +473,7 @@ class PlexServer(PlexObject):
|
||||||
sort="episode.originallyAvailableAt:desc",
|
sort="episode.originallyAvailableAt:desc",
|
||||||
filters={"episode.originallyAvailableAt>>": "4w", "genre": "comedy"}
|
filters={"episode.originallyAvailableAt>>": "4w", "genre": "comedy"}
|
||||||
)
|
)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return Collection.create(
|
return Collection.create(
|
||||||
self, title, section, items=items, smart=smart, limit=limit,
|
self, title, section, items=items, smart=smart, limit=limit,
|
||||||
|
@ -535,6 +539,7 @@ class PlexServer(PlexObject):
|
||||||
section="Music",
|
section="Music",
|
||||||
m3ufilepath="/path/to/playlist.m3u"
|
m3ufilepath="/path/to/playlist.m3u"
|
||||||
)
|
)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return Playlist.create(
|
return Playlist.create(
|
||||||
self, title, section=section, items=items, smart=smart, limit=limit,
|
self, title, section=section, items=items, smart=smart, limit=limit,
|
||||||
|
@ -549,26 +554,28 @@ class PlexServer(PlexObject):
|
||||||
"""
|
"""
|
||||||
return PlayQueue.create(self, item, **kwargs)
|
return PlayQueue.create(self, item, **kwargs)
|
||||||
|
|
||||||
def downloadDatabases(self, savepath=None, unpack=False):
|
def downloadDatabases(self, savepath=None, unpack=False, showstatus=False):
|
||||||
""" Download databases.
|
""" Download databases.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
savepath (str): Defaults to current working dir.
|
savepath (str): Defaults to current working dir.
|
||||||
unpack (bool): Unpack the zip file.
|
unpack (bool): Unpack the zip file.
|
||||||
|
showstatus(bool): Display a progressbar.
|
||||||
"""
|
"""
|
||||||
url = self.url('/diagnostics/databases')
|
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
|
return filepath
|
||||||
|
|
||||||
def downloadLogs(self, savepath=None, unpack=False):
|
def downloadLogs(self, savepath=None, unpack=False, showstatus=False):
|
||||||
""" Download server logs.
|
""" Download server logs.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
savepath (str): Defaults to current working dir.
|
savepath (str): Defaults to current working dir.
|
||||||
unpack (bool): Unpack the zip file.
|
unpack (bool): Unpack the zip file.
|
||||||
|
showstatus(bool): Display a progressbar.
|
||||||
"""
|
"""
|
||||||
url = self.url('/diagnostics/logs')
|
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
|
return filepath
|
||||||
|
|
||||||
def butlerTasks(self):
|
def butlerTasks(self):
|
||||||
|
@ -588,6 +595,7 @@ class PlexServer(PlexObject):
|
||||||
|
|
||||||
availableTasks = [task.name for task in plex.butlerTasks()]
|
availableTasks = [task.name for task in plex.butlerTasks()]
|
||||||
print("Available butler tasks:", availableTasks)
|
print("Available butler tasks:", availableTasks)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
validTasks = [task.name for task in self.butlerTasks()]
|
validTasks = [task.name for task in self.butlerTasks()]
|
||||||
if task not in validTasks:
|
if task not in validTasks:
|
||||||
|
@ -630,7 +638,7 @@ class PlexServer(PlexObject):
|
||||||
# figure out what method this is..
|
# figure out what method this is..
|
||||||
return self.query(part, method=self._session.put)
|
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
|
""" 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
|
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
|
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.
|
accountID (int/str) Request history for a specific account ID.
|
||||||
librarySectionID (int/str) Request history for a specific library section ID.
|
librarySectionID (int/str) Request history for a specific library section ID.
|
||||||
"""
|
"""
|
||||||
results, subresults = [], '_init'
|
|
||||||
args = {'sort': 'viewedAt:desc'}
|
args = {'sort': 'viewedAt:desc'}
|
||||||
if ratingKey:
|
if ratingKey:
|
||||||
args['metadataItemID'] = ratingKey
|
args['metadataItemID'] = ratingKey
|
||||||
|
@ -654,14 +661,9 @@ class PlexServer(PlexObject):
|
||||||
args['librarySectionID'] = librarySectionID
|
args['librarySectionID'] = librarySectionID
|
||||||
if mindate:
|
if mindate:
|
||||||
args['viewedAt>'] = int(mindate.timestamp())
|
args['viewedAt>'] = int(mindate.timestamp())
|
||||||
args['X-Plex-Container-Start'] = 0
|
|
||||||
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
key = f'/status/sessions/history/all{utils.joinArgs(args)}'
|
||||||
while subresults and maxresults > len(results):
|
return self.fetchItems(key, maxresults=maxresults)
|
||||||
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
|
|
||||||
|
|
||||||
def playlists(self, playlistType=None, sectionId=None, title=None, sort=None, **kwargs):
|
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.
|
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server.
|
||||||
|
@ -794,6 +796,10 @@ class PlexServer(PlexObject):
|
||||||
results += hub.items
|
results += hub.items
|
||||||
return results
|
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):
|
def sessions(self):
|
||||||
""" Returns a list of all active session (currently playing) media objects. """
|
""" Returns a list of all active session (currently playing) media objects. """
|
||||||
return self.fetchItems('/status/sessions')
|
return self.fetchItems('/status/sessions')
|
||||||
|
|
|
@ -81,7 +81,7 @@ class Settings(PlexObject):
|
||||||
params[setting.id] = quote(setting._setValue)
|
params[setting.id] = quote(setting._setValue)
|
||||||
if not params:
|
if not params:
|
||||||
raise BadRequest('No setting have been modified.')
|
raise BadRequest('No setting have been modified.')
|
||||||
querystr = '&'.join([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}'
|
url = f'{self.key}?{querystr}'
|
||||||
self._server.query(url, self._server._session.put)
|
self._server.query(url, self._server._session.put)
|
||||||
self.reload()
|
self.reload()
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from plexapi import CONFIG, X_PLEX_IDENTIFIER
|
from plexapi import CONFIG, X_PLEX_IDENTIFIER
|
||||||
from plexapi.client import PlexClient
|
from plexapi.client import PlexClient
|
||||||
from plexapi.exceptions import BadRequest
|
from plexapi.exceptions import BadRequest
|
||||||
|
|
|
@ -15,20 +15,17 @@ from datetime import datetime
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
from requests.status_codes import _codes as codes
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
|
||||||
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
except ImportError:
|
except ImportError:
|
||||||
tqdm = None
|
tqdm = None
|
||||||
|
|
||||||
try:
|
|
||||||
from functools import cached_property
|
|
||||||
except ImportError:
|
|
||||||
from backports.cached_property import cached_property # noqa: F401
|
|
||||||
|
|
||||||
log = logging.getLogger('plexapi')
|
log = logging.getLogger('plexapi')
|
||||||
|
|
||||||
# Search Types - Plex uses these to filter specific media types when searching.
|
# Search Types - Plex uses these to filter specific media types when searching.
|
||||||
|
@ -106,7 +103,7 @@ class SecretsFilter(logging.Filter):
|
||||||
self.secrets = secrets or set()
|
self.secrets = secrets or set()
|
||||||
|
|
||||||
def add_secret(self, secret):
|
def add_secret(self, secret):
|
||||||
if secret is not None:
|
if secret is not None and secret != '':
|
||||||
self.secrets.add(secret)
|
self.secrets.add(secret)
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
|
@ -128,7 +125,9 @@ def registerPlexObject(cls):
|
||||||
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
|
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
|
||||||
ehash = f'{cls.TAG}.{etype}' if etype else cls.TAG
|
ehash = f'{cls.TAG}.{etype}' if etype else cls.TAG
|
||||||
if getattr(cls, '_SESSIONTYPE', None):
|
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:
|
if ehash in PLEXOBJECTS:
|
||||||
raise Exception(f'Ambiguous PlexObject definition {cls.__name__}(tag={cls.TAG}, type={etype}) '
|
raise Exception(f'Ambiguous PlexObject definition {cls.__name__}(tag={cls.TAG}, type={etype}) '
|
||||||
f'with {PLEXOBJECTS[ehash].__name__}')
|
f'with {PLEXOBJECTS[ehash].__name__}')
|
||||||
|
@ -391,12 +390,12 @@ def downloadSessionImages(server, filename=None, height=150, width=150,
|
||||||
prettyname = media._prettyfilename()
|
prettyname = media._prettyfilename()
|
||||||
filename = f'session_transcode_{media.usernames[0]}_{prettyname}_{int(time.time())}'
|
filename = f'session_transcode_{media.usernames[0]}_{prettyname}_{int(time.time())}'
|
||||||
url = server.transcodeImage(url, height, width, opacity, saturation)
|
url = server.transcodeImage(url, height, width, opacity, saturation)
|
||||||
filepath = download(url, filename=filename)
|
filepath = download(url, server._token, filename=filename)
|
||||||
info['username'] = {'filepath': filepath, 'url': url}
|
info['username'] = {'filepath': filepath, 'url': url}
|
||||||
return info
|
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):
|
unpack=False, mocked=False, showstatus=False):
|
||||||
""" Helper to download a thumb, videofile or other media item. Returns the local
|
""" Helper to download a thumb, videofile or other media item. Returns the local
|
||||||
path to the downloaded file.
|
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()
|
session = session or requests.Session()
|
||||||
headers = {'X-Plex-Token': token}
|
headers = {'X-Plex-Token': token}
|
||||||
response = session.get(url, headers=headers, stream=True)
|
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
|
# make sure the savepath directory exists
|
||||||
savepath = savepath or os.getcwd()
|
savepath = savepath or os.getcwd()
|
||||||
os.makedirs(savepath, exist_ok=True)
|
os.makedirs(savepath, exist_ok=True)
|
||||||
|
|
|
@ -3,19 +3,17 @@ import os
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from plexapi import media, utils
|
from plexapi import media, utils
|
||||||
from plexapi.base import Playable, PlexPartialObject, PlexSession
|
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession
|
||||||
from plexapi.exceptions import BadRequest
|
from plexapi.exceptions import BadRequest
|
||||||
from plexapi.mixins import (
|
from plexapi.mixins import (
|
||||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
||||||
ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
|
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
|
||||||
AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin,
|
MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins,
|
||||||
StudioMixin, SummaryMixin, TaglineMixin, TitleMixin,
|
|
||||||
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
|
|
||||||
WatchlistMixin
|
WatchlistMixin
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Video(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin):
|
class Video(PlexPartialObject, PlayedUnplayedMixin):
|
||||||
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
||||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
||||||
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
|
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
|
||||||
|
@ -186,20 +184,20 @@ class Video(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin):
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
# Optimize for mobile using defaults
|
# Optimize for mobile using defaults
|
||||||
video.optimize(target="mobile")
|
video.optimize(target="mobile")
|
||||||
|
|
||||||
# Optimize for Android at 10 Mbps 1080p
|
# Optimize for Android at 10 Mbps 1080p
|
||||||
from plexapi.sync import VIDEO_QUALITY_10_MBPS_1080p
|
from plexapi.sync import VIDEO_QUALITY_10_MBPS_1080p
|
||||||
video.optimize(deviceProfile="Android", videoQuality=sync.VIDEO_QUALITY_10_MBPS_1080p)
|
video.optimize(deviceProfile="Android", videoQuality=sync.VIDEO_QUALITY_10_MBPS_1080p)
|
||||||
|
|
||||||
# Optimize for iOS at original quality in library location
|
# Optimize for iOS at original quality in library location
|
||||||
from plexapi.sync import VIDEO_QUALITY_ORIGINAL
|
from plexapi.sync import VIDEO_QUALITY_ORIGINAL
|
||||||
locations = plex.library.section("Movies")._locations()
|
locations = plex.library.section("Movies")._locations()
|
||||||
video.optimize(deviceProfile="iOS", videoQuality=VIDEO_QUALITY_ORIGINAL, locationID=locations[0])
|
video.optimize(deviceProfile="iOS", videoQuality=VIDEO_QUALITY_ORIGINAL, locationID=locations[0])
|
||||||
|
|
||||||
# Optimize for tv the next 5 unwatched episodes
|
# Optimize for tv the next 5 unwatched episodes
|
||||||
show.optimize(target="tv", limit=5, unwatched=True)
|
show.optimize(target="tv", limit=5, unwatched=True)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from plexapi.library import Location
|
from plexapi.library import Location
|
||||||
|
@ -309,9 +307,7 @@ class Movie(
|
||||||
Video, Playable,
|
Video, Playable,
|
||||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeMixin,
|
ArtMixin, PosterMixin, ThemeMixin,
|
||||||
ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
MovieEditMixins,
|
||||||
SummaryMixin, TaglineMixin, TitleMixin,
|
|
||||||
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
|
|
||||||
WatchlistMixin
|
WatchlistMixin
|
||||||
):
|
):
|
||||||
""" Represents a single Movie.
|
""" Represents a single Movie.
|
||||||
|
@ -330,6 +326,7 @@ class Movie(
|
||||||
duration (int): Duration of the movie in milliseconds.
|
duration (int): Duration of the movie in milliseconds.
|
||||||
editionTitle (str): The edition title of the movie (e.g. Director's Cut, Extended Edition, etc.).
|
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.
|
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.
|
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||||
|
@ -441,15 +438,20 @@ class Movie(
|
||||||
}
|
}
|
||||||
return self.section().search(filters=filters)
|
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
|
@utils.registerPlexObject
|
||||||
class Show(
|
class Show(
|
||||||
Video,
|
Video,
|
||||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||||
ArtMixin, BannerMixin, PosterMixin, ThemeMixin,
|
ArtMixin, PosterMixin, ThemeMixin,
|
||||||
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
ShowEditMixins,
|
||||||
SummaryMixin, TaglineMixin, TitleMixin,
|
|
||||||
CollectionMixin, GenreMixin, LabelMixin,
|
|
||||||
WatchlistMixin
|
WatchlistMixin
|
||||||
):
|
):
|
||||||
""" Represents a single Show (including all seasons and episodes).
|
""" Represents a single Show (including all seasons and episodes).
|
||||||
|
@ -467,12 +469,12 @@ class Show(
|
||||||
autoDeletionItemPolicyWatchedLibrary (int): Setting that indicates if episodes are deleted
|
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,
|
after being watched for the show (0 = Never, 1 = After a day, 7 = After a week,
|
||||||
100 = On next refresh).
|
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.
|
childCount (int): Number of seasons (including Specials) in the show.
|
||||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||||
duration (int): Typical duration of the show episodes in milliseconds.
|
duration (int): Typical duration of the show episodes in milliseconds.
|
||||||
enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled.
|
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
|
episodeSort (int): Setting that indicates how episodes are sorted for the show
|
||||||
(-1 = Library default, 0 = Oldest first, 1 = Newest first).
|
(-1 = Library default, 0 = Oldest first, 1 = Newest first).
|
||||||
flattenSeasons (int): Setting that indicates if seasons are set to hidden for the show
|
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.
|
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||||
seasonCount (int): Number of seasons (excluding Specials) in the show.
|
seasonCount (int): Number of seasons (excluding Specials) in the show.
|
||||||
showOrdering (str): Setting that indicates the episode ordering for 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.
|
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
||||||
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
|
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||||
subtitleLanguage (str): Setting that indicates the preferred subtitle language.
|
subtitleLanguage (str): Setting that indicates the preferred subtitle language.
|
||||||
|
@ -521,7 +524,6 @@ class Show(
|
||||||
int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0'))
|
int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0'))
|
||||||
self.autoDeletionItemPolicyWatchedLibrary = utils.cast(
|
self.autoDeletionItemPolicyWatchedLibrary = utils.cast(
|
||||||
int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0'))
|
int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0'))
|
||||||
self.banner = data.attrib.get('banner')
|
|
||||||
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
||||||
self.collections = self.findItems(data, media.Collection)
|
self.collections = self.findItems(data, media.Collection)
|
||||||
self.contentRating = data.attrib.get('contentRating')
|
self.contentRating = data.attrib.get('contentRating')
|
||||||
|
@ -659,8 +661,7 @@ class Season(
|
||||||
Video,
|
Video,
|
||||||
AdvancedSettingsMixin, ExtrasMixin, RatingMixin,
|
AdvancedSettingsMixin, ExtrasMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||||
SummaryMixin, TitleMixin,
|
SeasonEditMixins
|
||||||
CollectionMixin, LabelMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Show Season (including all episodes).
|
""" Represents a single Show Season (including all episodes).
|
||||||
|
|
||||||
|
@ -740,10 +741,12 @@ class Season(
|
||||||
""" Returns the season number. """
|
""" Returns the season number. """
|
||||||
return self.index
|
return self.index
|
||||||
|
|
||||||
def episodes(self, **kwargs):
|
def onDeck(self):
|
||||||
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """
|
""" Returns season's On Deck :class:`~plexapi.video.Video` object or `None`.
|
||||||
key = f'{self.key}/children'
|
Will only return a match if the show's On Deck episode is in this season.
|
||||||
return self.fetchItems(key, Episode, **kwargs)
|
"""
|
||||||
|
data = self._server.query(self._details_key)
|
||||||
|
return next(iter(self.findItems(data, rtag='OnDeck')), None)
|
||||||
|
|
||||||
def episode(self, title=None, episode=None):
|
def episode(self, title=None, episode=None):
|
||||||
""" Returns the episode with the given title or number.
|
""" 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)
|
return self.fetchItem(key, Episode, parentIndex=self.index, index=index)
|
||||||
raise BadRequest('Missing argument: title or episode is required')
|
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):
|
def get(self, title=None, episode=None):
|
||||||
""" Alias to :func:`~plexapi.video.Season.episode`. """
|
""" Alias to :func:`~plexapi.video.Season.episode`. """
|
||||||
return self.episode(title, 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):
|
def show(self):
|
||||||
""" Return the season's :class:`~plexapi.video.Show`. """
|
""" Return the season's :class:`~plexapi.video.Show`. """
|
||||||
return self.fetchItem(self.parentKey)
|
return self.fetchItem(self.parentKey)
|
||||||
|
@ -813,8 +814,7 @@ class Episode(
|
||||||
Video, Playable,
|
Video, Playable,
|
||||||
ExtrasMixin, RatingMixin,
|
ExtrasMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||||
ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
EpisodeEditMixins
|
||||||
CollectionMixin, DirectorMixin, LabelMixin, WriterMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Shows Episode.
|
""" Represents a single Shows Episode.
|
||||||
|
|
||||||
|
@ -906,7 +906,7 @@ class Episode(
|
||||||
|
|
||||||
# If seasons are hidden, parentKey and parentRatingKey are missing from the XML response.
|
# 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
|
# 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
|
# Parse the parentRatingKey from the parentThumb
|
||||||
if self.parentThumb and self.parentThumb.startswith('/library/metadata/'):
|
if self.parentThumb and self.parentThumb.startswith('/library/metadata/'):
|
||||||
self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3])
|
self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3])
|
||||||
|
@ -993,6 +993,13 @@ class Episode(
|
||||||
""" Returns str, default title for a new syncItem. """
|
""" Returns str, default title for a new syncItem. """
|
||||||
return f'{self.grandparentTitle} - {self.parentTitle} - ({self.seasonEpisode}) {self.title}'
|
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
|
@utils.registerPlexObject
|
||||||
class Clip(
|
class Clip(
|
||||||
|
@ -1105,3 +1112,42 @@ class ClipSession(PlexSession, Clip):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
Clip._loadData(self, data)
|
Clip._loadData(self, data)
|
||||||
PlexSession._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)
|
||||||
|
|
|
@ -28,7 +28,7 @@ MarkupSafe==2.1.3
|
||||||
musicbrainzngs==0.7.1
|
musicbrainzngs==0.7.1
|
||||||
packaging==23.1
|
packaging==23.1
|
||||||
paho-mqtt==1.6.1
|
paho-mqtt==1.6.1
|
||||||
plexapi==4.13.4
|
plexapi==4.15.0
|
||||||
portend==3.2.0
|
portend==3.2.0
|
||||||
profilehooks==1.12.0
|
profilehooks==1.12.0
|
||||||
PyJWT==2.8.0
|
PyJWT==2.8.0
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue