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 plexapi import media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject, PlexSession
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.mixins import (
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
|
||||
AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
|
||||
TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin,
|
||||
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
|
||||
ArtistEditMixins, AlbumEditMixins, TrackEditMixins
|
||||
)
|
||||
from plexapi.playlist import Playlist
|
||||
|
||||
|
||||
class Audio(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin):
|
||||
class Audio(PlexPartialObject, PlayedUnplayedMixin):
|
||||
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
|
||||
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
|
||||
|
||||
|
@ -132,8 +130,7 @@ class Artist(
|
|||
Audio,
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
SortTitleMixin, SummaryMixin, TitleMixin,
|
||||
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
|
||||
ArtistEditMixins
|
||||
):
|
||||
""" Represents a single Artist.
|
||||
|
||||
|
@ -181,14 +178,19 @@ class Artist(
|
|||
Parameters:
|
||||
title (str): Title of the album to return.
|
||||
"""
|
||||
try:
|
||||
return self.section().search(title, libtype='album', filters={'artist.id': self.ratingKey})[0]
|
||||
except IndexError:
|
||||
raise NotFound(f"Unable to find album '{title}'") from None
|
||||
return self.section().get(
|
||||
title=title,
|
||||
libtype='album',
|
||||
filters={'artist.id': self.ratingKey}
|
||||
)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
|
||||
return self.section().search(libtype='album', filters={'artist.id': self.ratingKey}, **kwargs)
|
||||
return self.section().search(
|
||||
libtype='album',
|
||||
filters={'artist.id': self.ratingKey},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def track(self, title=None, album=None, track=None):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
@ -244,8 +246,7 @@ class Album(
|
|||
Audio,
|
||||
UnmatchMatchMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||
OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
|
||||
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin
|
||||
AlbumEditMixins
|
||||
):
|
||||
""" Represents a single Album.
|
||||
|
||||
|
@ -364,14 +365,14 @@ class Track(
|
|||
Audio, Playable,
|
||||
ExtrasMixin, RatingMixin,
|
||||
ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin,
|
||||
TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin,
|
||||
CollectionMixin, LabelMixin, MoodMixin
|
||||
TrackEditMixins
|
||||
):
|
||||
""" Represents a single Track.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'track'
|
||||
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
||||
chapterSource (str): Unknown
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
duration (int): Length of the track in milliseconds.
|
||||
|
@ -407,6 +408,7 @@ class Track(
|
|||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
|
@ -433,18 +435,6 @@ class Track(
|
|||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}'
|
||||
|
||||
def album(self):
|
||||
""" Return the track's :class:`~plexapi.audio.Album`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def artist(self):
|
||||
""" Return the track's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.grandparentKey)
|
||||
|
||||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
|
@ -460,6 +450,18 @@ class Track(
|
|||
""" Returns the track number. """
|
||||
return self.index
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}'
|
||||
|
||||
def album(self):
|
||||
""" Return the track's :class:`~plexapi.audio.Album`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def artist(self):
|
||||
""" Return the track's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.grandparentKey)
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - {self.title}'
|
||||
|
@ -480,3 +482,16 @@ class TrackSession(PlexSession, Track):
|
|||
""" Load attribute values from Plex XML response. """
|
||||
Track._loadData(self, data)
|
||||
PlexSession._loadData(self, data)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class TrackHistory(PlexHistory, Track):
|
||||
""" Represents a single Track history entry
|
||||
loaded from :func:`~plexapi.server.PlexServer.history`.
|
||||
"""
|
||||
_HISTORYTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Track._loadData(self, data)
|
||||
PlexHistory._loadData(self, data)
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import weakref
|
||||
from functools import cached_property
|
||||
from urllib.parse import urlencode
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from plexapi import log, utils
|
||||
from plexapi import CONFIG, X_PLEX_CONTAINER_SIZE, log, utils
|
||||
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
|
||||
from plexapi.utils import cached_property
|
||||
|
||||
USER_DONT_RELOAD_FOR_KEYS = set()
|
||||
_DONT_RELOAD_FOR_KEYS = {'key'}
|
||||
|
@ -50,9 +50,14 @@ class PlexObject:
|
|||
self._initpath = initpath or self.key
|
||||
self._parent = weakref.ref(parent) if parent is not None else None
|
||||
self._details_key = None
|
||||
self._overwriteNone = True # Allow overwriting previous attribute values with `None` when manually reloading
|
||||
self._autoReload = True # Automatically reload the object when accessing a missing attribute
|
||||
self._edits = None # Save batch edits for a single API call
|
||||
|
||||
# Allow overwriting previous attribute values with `None` when manually reloading
|
||||
self._overwriteNone = True
|
||||
# Automatically reload the object when accessing a missing attribute
|
||||
self._autoReload = CONFIG.get('plexapi.autoreload', True, bool)
|
||||
# Attribute to save batch edits for a single API call
|
||||
self._edits = None
|
||||
|
||||
if data is not None:
|
||||
self._loadData(data)
|
||||
self._details_key = self._buildDetailsKey()
|
||||
|
@ -87,7 +92,9 @@ class PlexObject:
|
|||
etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type')))
|
||||
ehash = f'{elem.tag}.{etype}' if etype else elem.tag
|
||||
if initpath == '/status/sessions':
|
||||
ehash = f"{ehash}.{'session'}"
|
||||
ehash = f"{ehash}.session"
|
||||
elif initpath.startswith('/status/sessions/history'):
|
||||
ehash = f"{ehash}.history"
|
||||
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
|
||||
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
|
||||
if ecls is not None:
|
||||
|
@ -147,47 +154,14 @@ class PlexObject:
|
|||
elem = ElementTree.fromstring(xml)
|
||||
return self._buildItemOrNone(elem, cls)
|
||||
|
||||
def fetchItem(self, ekey, cls=None, **kwargs):
|
||||
""" Load the specified key to find and build the first item with the
|
||||
specified tag and attrs. If no tag or attrs are specified then
|
||||
the first item in the result set is returned.
|
||||
|
||||
Parameters:
|
||||
ekey (str or int): Path in Plex to fetch items from. If an int is passed
|
||||
in, the key will be translated to /library/metadata/<key>. This allows
|
||||
fetching an item only knowing its key-id.
|
||||
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
||||
items to be fetched, passing this in will help the parser ensure
|
||||
it only returns those items. By default we convert the xml elements
|
||||
with the best guess PlexObjects based on tag and type attrs.
|
||||
etag (str): Only fetch items with the specified tag.
|
||||
**kwargs (dict): Optionally add XML attribute to filter the items.
|
||||
See :func:`~plexapi.base.PlexObject.fetchItems` for more details
|
||||
on how this is used.
|
||||
"""
|
||||
if ekey is None:
|
||||
raise BadRequest('ekey was not provided')
|
||||
if isinstance(ekey, int):
|
||||
ekey = f'/library/metadata/{ekey}'
|
||||
|
||||
data = self._server.query(ekey)
|
||||
item = self.findItem(data, cls, ekey, **kwargs)
|
||||
|
||||
if item:
|
||||
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
if librarySectionID:
|
||||
item.librarySectionID = librarySectionID
|
||||
return item
|
||||
|
||||
clsname = cls.__name__ if cls else 'None'
|
||||
raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}')
|
||||
|
||||
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
|
||||
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, maxresults=None, **kwargs):
|
||||
""" Load the specified key to find and build all items with the specified tag
|
||||
and attrs.
|
||||
|
||||
Parameters:
|
||||
ekey (str): API URL path in Plex to fetch items from.
|
||||
ekey (str or List<int>): API URL path in Plex to fetch items from. If a list of ints is passed
|
||||
in, the key will be translated to /library/metadata/<key1,key2,key3>. This allows
|
||||
fetching multiple items only knowing their key-ids.
|
||||
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
||||
items to be fetched, passing this in will help the parser ensure
|
||||
it only returns those items. By default we convert the xml elements
|
||||
|
@ -195,6 +169,7 @@ class PlexObject:
|
|||
etag (str): Only fetch items with the specified tag.
|
||||
container_start (None, int): offset to get a subset of the data
|
||||
container_size (None, int): How many items in data
|
||||
maxresults (int, optional): Only return the specified number of results.
|
||||
**kwargs (dict): Optionally add XML attribute to filter the items.
|
||||
See the details below for more info.
|
||||
|
||||
|
@ -259,39 +234,80 @@ class PlexObject:
|
|||
if ekey is None:
|
||||
raise BadRequest('ekey was not provided')
|
||||
|
||||
params = {}
|
||||
if container_start is not None:
|
||||
params["X-Plex-Container-Start"] = container_start
|
||||
if container_size is not None:
|
||||
params["X-Plex-Container-Size"] = container_size
|
||||
if isinstance(ekey, list) and all(isinstance(key, int) for key in ekey):
|
||||
ekey = f'/library/metadata/{",".join(str(key) for key in ekey)}'
|
||||
|
||||
data = self._server.query(ekey, params=params)
|
||||
items = self.findItems(data, cls, ekey, **kwargs)
|
||||
container_start = container_start or 0
|
||||
container_size = container_size or X_PLEX_CONTAINER_SIZE
|
||||
offset = container_start
|
||||
|
||||
if maxresults is not None:
|
||||
container_size = min(container_size, maxresults)
|
||||
|
||||
results = []
|
||||
subresults = []
|
||||
headers = {}
|
||||
|
||||
while True:
|
||||
headers['X-Plex-Container-Start'] = str(container_start)
|
||||
headers['X-Plex-Container-Size'] = str(container_size)
|
||||
|
||||
data = self._server.query(ekey, headers=headers)
|
||||
subresults = self.findItems(data, cls, ekey, **kwargs)
|
||||
total_size = utils.cast(int, data.attrib.get('totalSize') or data.attrib.get('size')) or len(subresults)
|
||||
|
||||
if not subresults:
|
||||
if offset > total_size:
|
||||
log.info('container_start is greater than the number of items')
|
||||
|
||||
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
if librarySectionID:
|
||||
for item in items:
|
||||
for item in subresults:
|
||||
item.librarySectionID = librarySectionID
|
||||
return items
|
||||
|
||||
def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
||||
""" Load the specified data to find and build the first items with the specified tag
|
||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||
results.extend(subresults)
|
||||
|
||||
wanted_number_of_items = total_size - offset
|
||||
if maxresults is not None:
|
||||
wanted_number_of_items = min(maxresults, wanted_number_of_items)
|
||||
container_size = min(container_size, wanted_number_of_items - len(results))
|
||||
|
||||
if wanted_number_of_items <= len(results):
|
||||
break
|
||||
|
||||
container_start += container_size
|
||||
|
||||
if container_start > total_size:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
def fetchItem(self, ekey, cls=None, **kwargs):
|
||||
""" Load the specified key to find and build the first item with the
|
||||
specified tag and attrs. If no tag or attrs are specified then
|
||||
the first item in the result set is returned.
|
||||
|
||||
Parameters:
|
||||
ekey (str or int): Path in Plex to fetch items from. If an int is passed
|
||||
in, the key will be translated to /library/metadata/<key>. This allows
|
||||
fetching an item only knowing its key-id.
|
||||
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
||||
items to be fetched, passing this in will help the parser ensure
|
||||
it only returns those items. By default we convert the xml elements
|
||||
with the best guess PlexObjects based on tag and type attrs.
|
||||
etag (str): Only fetch items with the specified tag.
|
||||
**kwargs (dict): Optionally add XML attribute to filter the items.
|
||||
See :func:`~plexapi.base.PlexObject.fetchItems` for more details
|
||||
on how this is used.
|
||||
"""
|
||||
# filter on cls attrs if specified
|
||||
if cls and cls.TAG and 'tag' not in kwargs:
|
||||
kwargs['etag'] = cls.TAG
|
||||
if cls and cls.TYPE and 'type' not in kwargs:
|
||||
kwargs['type'] = cls.TYPE
|
||||
# rtag to iter on a specific root tag
|
||||
if rtag:
|
||||
data = next(data.iter(rtag), [])
|
||||
# loop through all data elements to find matches
|
||||
for elem in data:
|
||||
if self._checkAttrs(elem, **kwargs):
|
||||
item = self._buildItemOrNone(elem, cls, initpath)
|
||||
return item
|
||||
if isinstance(ekey, int):
|
||||
ekey = f'/library/metadata/{ekey}'
|
||||
|
||||
try:
|
||||
return self.fetchItems(ekey, cls, **kwargs)[0]
|
||||
except IndexError:
|
||||
clsname = cls.__name__ if cls else 'None'
|
||||
raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}') from None
|
||||
|
||||
def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
||||
""" Load the specified data to find and build all items with the specified tag
|
||||
|
@ -315,6 +331,16 @@ class PlexObject:
|
|||
items.append(item)
|
||||
return items
|
||||
|
||||
def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
||||
""" Load the specified data to find and build the first items with the specified tag
|
||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||
on how this is used.
|
||||
"""
|
||||
try:
|
||||
return self.findItems(data, cls, initpath, rtag, **kwargs)[0]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def firstAttr(self, *attrs):
|
||||
""" Return the first attribute in attrs that is not None. """
|
||||
for attr in attrs:
|
||||
|
@ -475,7 +501,9 @@ class PlexPartialObject(PlexObject):
|
|||
}
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, PlexPartialObject):
|
||||
return other not in [None, []] and self.key == other.key
|
||||
return NotImplemented
|
||||
|
||||
def __hash__(self):
|
||||
return hash(repr(self))
|
||||
|
@ -492,7 +520,7 @@ class PlexPartialObject(PlexObject):
|
|||
if attr.startswith('_'): return value
|
||||
if value not in (None, []): return value
|
||||
if self.isFullObject(): return value
|
||||
if isinstance(self, PlexSession): return value
|
||||
if isinstance(self, (PlexSession, PlexHistory)): return value
|
||||
if self._autoReload is False: return value
|
||||
# Log the reload.
|
||||
clsname = self.__class__.__name__
|
||||
|
@ -543,13 +571,10 @@ class PlexPartialObject(PlexObject):
|
|||
self._edits.update(kwargs)
|
||||
return self
|
||||
|
||||
if 'id' not in kwargs:
|
||||
kwargs['id'] = self.ratingKey
|
||||
if 'type' not in kwargs:
|
||||
kwargs['type'] = utils.searchType(self._searchType)
|
||||
|
||||
part = f'/library/sections/{self.librarySectionID}/all{utils.joinArgs(kwargs)}'
|
||||
self._server.query(part, method=self._server._session.put)
|
||||
self.section()._edit(items=self, **kwargs)
|
||||
return self
|
||||
|
||||
def edit(self, **kwargs):
|
||||
|
@ -643,7 +668,7 @@ class PlexPartialObject(PlexObject):
|
|||
'have not allowed items to be deleted', self.key)
|
||||
raise
|
||||
|
||||
def history(self, maxresults=9999999, mindate=None):
|
||||
def history(self, maxresults=None, mindate=None):
|
||||
""" Get Play History for a media item.
|
||||
|
||||
Parameters:
|
||||
|
@ -681,17 +706,11 @@ class Playable:
|
|||
Albums which are all not playable.
|
||||
|
||||
Attributes:
|
||||
viewedAt (datetime): Datetime item was last viewed (history).
|
||||
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
|
||||
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
|
||||
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
|
||||
playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items).
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
|
||||
self.accountID = utils.cast(int, data.attrib.get('accountID')) # history
|
||||
self.deviceID = utils.cast(int, data.attrib.get('deviceID')) # history
|
||||
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
|
||||
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue
|
||||
|
||||
|
@ -812,7 +831,7 @@ class Playable:
|
|||
"""
|
||||
key = f'/:/progress?key={self.ratingKey}&identifier=com.plexapp.plugins.library&time={time}&state={state}'
|
||||
self._server.query(key)
|
||||
self._reload(_overwriteNone=False)
|
||||
return self
|
||||
|
||||
def updateTimeline(self, time, state='stopped', duration=None):
|
||||
""" Set the timeline progress for this video.
|
||||
|
@ -830,7 +849,7 @@ class Playable:
|
|||
key = (f'/:/timeline?ratingKey={self.ratingKey}&key={self.key}&'
|
||||
f'identifier=com.plexapp.plugins.library&time={int(time)}&state={state}{durationStr}')
|
||||
self._server.query(key)
|
||||
self._reload(_overwriteNone=False)
|
||||
return self
|
||||
|
||||
|
||||
class PlexSession(object):
|
||||
|
@ -912,6 +931,35 @@ class PlexSession(object):
|
|||
return self._server.query(key, params=params)
|
||||
|
||||
|
||||
class PlexHistory(object):
|
||||
""" This is a general place to store functions specific to media that is a Plex history item.
|
||||
|
||||
Attributes:
|
||||
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
|
||||
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
|
||||
historyKey (str): API URL (/status/sessions/history/<historyID>).
|
||||
viewedAt (datetime): Datetime item was last watched.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
self.accountID = utils.cast(int, data.attrib.get('accountID'))
|
||||
self.deviceID = utils.cast(int, data.attrib.get('deviceID'))
|
||||
self.historyKey = data.attrib.get('historyKey')
|
||||
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt'))
|
||||
|
||||
def _reload(self, **kwargs):
|
||||
""" Reload the data for the history entry. """
|
||||
raise NotImplementedError('History objects cannot be reloaded. Use source() to get the source media item.')
|
||||
|
||||
def source(self):
|
||||
""" Return the source media object for the history entry. """
|
||||
return self.fetchItem(self._details_key)
|
||||
|
||||
def delete(self):
|
||||
""" Delete the history entry. """
|
||||
return self._server.query(self.historyKey, method=self._server._session.delete)
|
||||
|
||||
|
||||
class MediaContainer(PlexObject):
|
||||
""" Represents a single MediaContainer.
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import time
|
|||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
|
||||
|
|
|
@ -8,8 +8,7 @@ from plexapi.library import LibrarySection, ManagedHub
|
|||
from plexapi.mixins import (
|
||||
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
||||
LabelMixin
|
||||
CollectionEditMixins
|
||||
)
|
||||
from plexapi.utils import deprecated
|
||||
|
||||
|
@ -19,8 +18,7 @@ class Collection(
|
|||
PlexPartialObject,
|
||||
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
||||
LabelMixin
|
||||
CollectionEditMixins
|
||||
):
|
||||
""" Represents a single Collection.
|
||||
|
||||
|
@ -222,6 +220,7 @@ class Collection(
|
|||
.. code-block:: python
|
||||
|
||||
collection.updateMode(user="user")
|
||||
|
||||
"""
|
||||
if not self.smart:
|
||||
raise BadRequest('Cannot change collection filtering user for a non-smart collection.')
|
||||
|
@ -250,6 +249,7 @@ class Collection(
|
|||
.. code-block:: python
|
||||
|
||||
collection.updateMode(mode="hide")
|
||||
|
||||
"""
|
||||
mode_dict = {
|
||||
'default': -1,
|
||||
|
@ -276,6 +276,7 @@ class Collection(
|
|||
.. code-block:: python
|
||||
|
||||
collection.updateSort(mode="alpha")
|
||||
|
||||
"""
|
||||
if self.smart:
|
||||
raise BadRequest('Cannot change collection order for a smart collection.')
|
||||
|
|
|
@ -3,6 +3,8 @@ import os
|
|||
from collections import defaultdict
|
||||
from configparser import ConfigParser
|
||||
|
||||
from plexapi import utils
|
||||
|
||||
|
||||
class PlexConfig(ConfigParser):
|
||||
""" PlexAPI configuration object. Settings are stored in an INI file within the
|
||||
|
@ -35,7 +37,7 @@ class PlexConfig(ConfigParser):
|
|||
# Second: check the config file has attr
|
||||
section, name = key.lower().split('.')
|
||||
value = self.data.get(section, {}).get(name, default)
|
||||
return cast(value) if cast else value
|
||||
return utils.cast(cast, value) if cast else value
|
||||
except: # noqa: E722
|
||||
return default
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
# Library version
|
||||
MAJOR_VERSION = 4
|
||||
MINOR_VERSION = 13
|
||||
PATCH_VERSION = 4
|
||||
MINOR_VERSION = 15
|
||||
PATCH_VERSION = 0
|
||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
from functools import cached_property
|
||||
from urllib.parse import parse_qs, quote_plus, urlencode, urlparse
|
||||
|
||||
from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils
|
||||
from plexapi import log, media, utils
|
||||
from plexapi.base import OPERATORS, PlexObject
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.mixins import (
|
||||
MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins,
|
||||
ArtistEditMixins, AlbumEditMixins, TrackEditMixins, PhotoalbumEditMixins, PhotoEditMixins
|
||||
)
|
||||
from plexapi.settings import Setting
|
||||
from plexapi.utils import cached_property, deprecated
|
||||
from plexapi.utils import deprecated
|
||||
|
||||
|
||||
class Library(PlexObject):
|
||||
|
@ -352,7 +357,7 @@ class Library(PlexObject):
|
|||
part += urlencode(kwargs)
|
||||
return self._server.query(part, method=self._server._session.post)
|
||||
|
||||
def history(self, maxresults=9999999, mindate=None):
|
||||
def history(self, maxresults=None, mindate=None):
|
||||
""" Get Play History for all library Sections for the owner.
|
||||
Parameters:
|
||||
maxresults (int): Only return the specified number of results (optional).
|
||||
|
@ -421,40 +426,6 @@ class LibrarySection(PlexObject):
|
|||
self._totalDuration = None
|
||||
self._totalStorage = None
|
||||
|
||||
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
|
||||
""" Load the specified key to find and build all items with the specified tag
|
||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||
on how this is used.
|
||||
|
||||
Parameters:
|
||||
container_start (None, int): offset to get a subset of the data
|
||||
container_size (None, int): How many items in data
|
||||
|
||||
"""
|
||||
url_kw = {}
|
||||
if container_start is not None:
|
||||
url_kw["X-Plex-Container-Start"] = container_start
|
||||
if container_size is not None:
|
||||
url_kw["X-Plex-Container-Size"] = container_size
|
||||
|
||||
if ekey is None:
|
||||
raise BadRequest('ekey was not provided')
|
||||
data = self._server.query(ekey, params=url_kw)
|
||||
|
||||
if '/all' in ekey:
|
||||
# totalSize is only included in the xml response
|
||||
# if container size is used.
|
||||
total_size = data.attrib.get("totalSize") or data.attrib.get("size")
|
||||
self._totalViewSize = utils.cast(int, total_size)
|
||||
|
||||
items = self.findItems(data, cls, ekey, **kwargs)
|
||||
|
||||
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
if librarySectionID:
|
||||
for item in items:
|
||||
item.librarySectionID = librarySectionID
|
||||
return items
|
||||
|
||||
@cached_property
|
||||
def totalSize(self):
|
||||
""" Returns the total number of items in the library for the default library type. """
|
||||
|
@ -474,6 +445,20 @@ class LibrarySection(PlexObject):
|
|||
self._getTotalDurationStorage()
|
||||
return self._totalStorage
|
||||
|
||||
def __getattribute__(self, attr):
|
||||
# Intercept to call EditFieldMixin and EditTagMixin methods
|
||||
# based on the item type being batch multi-edited
|
||||
value = super().__getattribute__(attr)
|
||||
if attr.startswith('_'): return value
|
||||
if callable(value) and 'Mixin' in value.__qualname__:
|
||||
if not isinstance(self._edits, dict):
|
||||
raise AttributeError("Must enable batchMultiEdit() to use this method")
|
||||
elif not hasattr(self._edits['items'][0], attr):
|
||||
raise AttributeError(
|
||||
f"Batch multi-editing '{self._edits['items'][0].__class__.__name__}' object has no attribute '{attr}'"
|
||||
)
|
||||
return value
|
||||
|
||||
def _getTotalDurationStorage(self):
|
||||
""" Queries the Plex server for the total library duration and storage and caches the values. """
|
||||
data = self._server.query('/media/providers?includeStorage=1')
|
||||
|
@ -567,6 +552,7 @@ class LibrarySection(PlexObject):
|
|||
|
||||
LibrarySection.addLocations('/path/1')
|
||||
LibrarySection.addLocations(['/path/1', 'path/2', '/path/3'])
|
||||
|
||||
"""
|
||||
locations = self.locations
|
||||
if isinstance(location, str):
|
||||
|
@ -589,6 +575,7 @@ class LibrarySection(PlexObject):
|
|||
|
||||
LibrarySection.removeLocations('/path/1')
|
||||
LibrarySection.removeLocations(['/path/1', 'path/2', '/path/3'])
|
||||
|
||||
"""
|
||||
locations = self.locations
|
||||
if isinstance(location, str):
|
||||
|
@ -602,19 +589,24 @@ class LibrarySection(PlexObject):
|
|||
raise BadRequest('You are unable to remove all locations from a library.')
|
||||
return self.edit(location=locations)
|
||||
|
||||
def get(self, title):
|
||||
""" Returns the media item with the specified title.
|
||||
def get(self, title, **kwargs):
|
||||
""" Returns the media item with the specified title and kwargs.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the item to return.
|
||||
kwargs (dict): Additional search parameters.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: The title is not found in the library.
|
||||
"""
|
||||
try:
|
||||
return self.search(title)[0]
|
||||
return self.search(title, limit=1, **kwargs)[0]
|
||||
except IndexError:
|
||||
raise NotFound(f"Unable to find item '{title}'") from None
|
||||
msg = f"Unable to find item with title '{title}'"
|
||||
if kwargs:
|
||||
msg += f" and kwargs {kwargs}"
|
||||
raise NotFound(msg) from None
|
||||
|
||||
def getGuid(self, guid):
|
||||
""" Returns the media item with the specified external Plex, IMDB, TMDB, or TVDB ID.
|
||||
|
@ -781,6 +773,11 @@ class LibrarySection(PlexObject):
|
|||
key = f'/library/sections/{self.key}/onDeck'
|
||||
return self.fetchItems(key)
|
||||
|
||||
def continueWatching(self):
|
||||
""" Return a list of media items in the library's Continue Watching hub. """
|
||||
key = f'/hubs/sections/{self.key}/continueWatching/items'
|
||||
return self.fetchItems(key)
|
||||
|
||||
def recentlyAdded(self, maxresults=50, libtype=None):
|
||||
""" Returns a list of media items recently added from this library section.
|
||||
|
||||
|
@ -1261,7 +1258,7 @@ class LibrarySection(PlexObject):
|
|||
return self._server.search(query, mediatype, limit, sectionId=self.key)
|
||||
|
||||
def search(self, title=None, sort=None, maxresults=None, libtype=None,
|
||||
container_start=0, container_size=X_PLEX_CONTAINER_SIZE, limit=None, filters=None, **kwargs):
|
||||
container_start=None, container_size=None, limit=None, filters=None, **kwargs):
|
||||
""" Search the library. The http requests will be batched in container_size. If you are only looking for the
|
||||
first <num> results, it would be wise to set the maxresults option to that amount so the search doesn't iterate
|
||||
over all results on the server.
|
||||
|
@ -1517,43 +1514,8 @@ class LibrarySection(PlexObject):
|
|||
"""
|
||||
key, kwargs = self._buildSearchKey(
|
||||
title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs)
|
||||
return self._search(key, maxresults, container_start, container_size, **kwargs)
|
||||
|
||||
def _search(self, key, maxresults, container_start, container_size, **kwargs):
|
||||
""" Perform the actual library search and return the results. """
|
||||
results = []
|
||||
subresults = []
|
||||
offset = container_start
|
||||
|
||||
if maxresults is not None:
|
||||
container_size = min(container_size, maxresults)
|
||||
|
||||
while True:
|
||||
subresults = self.fetchItems(key, container_start=container_start,
|
||||
container_size=container_size, **kwargs)
|
||||
if not len(subresults):
|
||||
if offset > self._totalViewSize:
|
||||
log.info("container_start is higher than the number of items in the library")
|
||||
|
||||
results.extend(subresults)
|
||||
|
||||
# self._totalViewSize is not used as a condition in the while loop as
|
||||
# this require a additional http request.
|
||||
# self._totalViewSize is updated from self.fetchItems
|
||||
wanted_number_of_items = self._totalViewSize - offset
|
||||
if maxresults is not None:
|
||||
wanted_number_of_items = min(maxresults, wanted_number_of_items)
|
||||
container_size = min(container_size, maxresults - len(results))
|
||||
|
||||
if wanted_number_of_items <= len(results):
|
||||
break
|
||||
|
||||
container_start += container_size
|
||||
|
||||
if container_start > self._totalViewSize:
|
||||
break
|
||||
|
||||
return results
|
||||
return self.fetchItems(
|
||||
key, container_start=container_start, container_size=container_size, maxresults=maxresults, **kwargs)
|
||||
|
||||
def _locations(self):
|
||||
""" Returns a list of :class:`~plexapi.library.Location` objects
|
||||
|
@ -1630,7 +1592,7 @@ class LibrarySection(PlexObject):
|
|||
|
||||
return myplex.sync(client=client, clientId=clientId, sync_item=sync_item)
|
||||
|
||||
def history(self, maxresults=9999999, mindate=None):
|
||||
def history(self, maxresults=None, mindate=None):
|
||||
""" Get Play History for this library Section for the owner.
|
||||
Parameters:
|
||||
maxresults (int): Only return the specified number of results (optional).
|
||||
|
@ -1720,8 +1682,101 @@ class LibrarySection(PlexObject):
|
|||
params['pageType'] = 'list'
|
||||
return self._server._buildWebURL(base=base, **params)
|
||||
|
||||
def _validateItems(self, items):
|
||||
""" Validates the specified items are from this library and of the same type. """
|
||||
if not items:
|
||||
raise BadRequest('No items specified.')
|
||||
|
||||
class MovieSection(LibrarySection):
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
|
||||
itemType = items[0].type
|
||||
for item in items:
|
||||
if item.librarySectionID != self.key:
|
||||
raise BadRequest(f'{item.title} is not from this library.')
|
||||
elif item.type != itemType:
|
||||
raise BadRequest(f'Cannot mix items of different type: {itemType} and {item.type}')
|
||||
|
||||
return items
|
||||
|
||||
def common(self, items):
|
||||
""" Returns a :class:`~plexapi.library.Common` object for the specified items. """
|
||||
params = {
|
||||
'id': ','.join(str(item.ratingKey) for item in self._validateItems(items)),
|
||||
'type': utils.searchType(items[0].type)
|
||||
}
|
||||
part = f'/library/sections/{self.key}/common{utils.joinArgs(params)}'
|
||||
return self.fetchItem(part, cls=Common)
|
||||
|
||||
def _edit(self, items=None, **kwargs):
|
||||
""" Actually edit multiple objects. """
|
||||
if isinstance(self._edits, dict):
|
||||
self._edits.update(kwargs)
|
||||
return self
|
||||
|
||||
kwargs['id'] = ','.join(str(item.ratingKey) for item in self._validateItems(items))
|
||||
if 'type' not in kwargs:
|
||||
kwargs['type'] = utils.searchType(items[0].type)
|
||||
|
||||
part = f'/library/sections/{self.key}/all{utils.joinArgs(kwargs)}'
|
||||
self._server.query(part, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def multiEdit(self, items, **kwargs):
|
||||
""" Edit multiple objects at once.
|
||||
Note: This is a low level method and you need to know all the field/tag keys.
|
||||
See :class:`~plexapi.LibrarySection.batchMultiEdits` instead.
|
||||
|
||||
Parameters:
|
||||
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
:class:`~plexapi.photo.Photo`, or :class:`~plexapi.collection.Collection`
|
||||
objects to be edited.
|
||||
kwargs (dict): Dict of settings to edit.
|
||||
"""
|
||||
return self._edit(items, **kwargs)
|
||||
|
||||
def batchMultiEdits(self, items):
|
||||
""" Enable batch multi-editing mode to save API calls.
|
||||
Must call :func:`~plexapi.library.LibrarySection.saveMultiEdits` at the end to save all the edits.
|
||||
See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin`
|
||||
for individual field and tag editing methods.
|
||||
|
||||
Parameters:
|
||||
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
:class:`~plexapi.photo.Photo`, or :class:`~plexapi.collection.Collection`
|
||||
objects to be edited.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
movies = MovieSection.all()
|
||||
items = [movies[0], movies[3], movies[5]]
|
||||
|
||||
# Batch multi-editing multiple fields and tags in a single API call
|
||||
MovieSection.batchMultiEdits(items)
|
||||
MovieSection.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline') \\
|
||||
.addCollection('New Collection').removeGenre('Action').addLabel('Favorite')
|
||||
MovieSection.saveMultiEdits()
|
||||
|
||||
"""
|
||||
self._edits = {'items': self._validateItems(items)}
|
||||
return self
|
||||
|
||||
def saveMultiEdits(self):
|
||||
""" Save all the batch multi-edits.
|
||||
See :func:`~plexapi.library.LibrarySection.batchMultiEdits` for details.
|
||||
"""
|
||||
if not isinstance(self._edits, dict):
|
||||
raise BadRequest('Batch multi-editing mode not enabled. Must call `batchMultiEdits()` first.')
|
||||
|
||||
edits = self._edits
|
||||
self._edits = None
|
||||
self._edit(items=edits.pop('items'), **edits)
|
||||
return self
|
||||
|
||||
|
||||
class MovieSection(LibrarySection, MovieEditMixins):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
||||
|
||||
Attributes:
|
||||
|
@ -1781,7 +1836,7 @@ class MovieSection(LibrarySection):
|
|||
return super(MovieSection, self).sync(**kwargs)
|
||||
|
||||
|
||||
class ShowSection(LibrarySection):
|
||||
class ShowSection(LibrarySection, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows.
|
||||
|
||||
Attributes:
|
||||
|
@ -1865,7 +1920,7 @@ class ShowSection(LibrarySection):
|
|||
return super(ShowSection, self).sync(**kwargs)
|
||||
|
||||
|
||||
class MusicSection(LibrarySection):
|
||||
class MusicSection(LibrarySection, ArtistEditMixins, AlbumEditMixins, TrackEditMixins):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing music artists.
|
||||
|
||||
Attributes:
|
||||
|
@ -1957,7 +2012,7 @@ class MusicSection(LibrarySection):
|
|||
return super(MusicSection, self).sync(**kwargs)
|
||||
|
||||
|
||||
class PhotoSection(LibrarySection):
|
||||
class PhotoSection(LibrarySection, PhotoalbumEditMixins, PhotoEditMixins):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
|
||||
|
||||
Attributes:
|
||||
|
@ -1979,13 +2034,13 @@ class PhotoSection(LibrarySection):
|
|||
def collections(self, **kwargs):
|
||||
raise NotImplementedError('Collections are not available for a Photo library.')
|
||||
|
||||
def searchAlbums(self, title, **kwargs):
|
||||
def searchAlbums(self, **kwargs):
|
||||
""" Search for a photo album. See :func:`~plexapi.library.LibrarySection.search` for usage. """
|
||||
return self.search(libtype='photoalbum', title=title, **kwargs)
|
||||
return self.search(libtype='photoalbum', **kwargs)
|
||||
|
||||
def searchPhotos(self, title, **kwargs):
|
||||
def searchPhotos(self, **kwargs):
|
||||
""" Search for a photo. See :func:`~plexapi.library.LibrarySection.search` for usage. """
|
||||
return self.search(libtype='photo', title=title, **kwargs)
|
||||
return self.search(libtype='photo', **kwargs)
|
||||
|
||||
def recentlyAddedAlbums(self, maxresults=50):
|
||||
""" Returns a list of recently added photo albums from this library section.
|
||||
|
@ -2157,8 +2212,10 @@ class LibraryMediaTag(PlexObject):
|
|||
reason (str): The reason for the search result.
|
||||
reasonID (int): The reason ID for the search result.
|
||||
reasonTitle (str): The reason title for the search result.
|
||||
score (float): The score for the search result.
|
||||
type (str): The type of search result (tag).
|
||||
tag (str): The title of the tag.
|
||||
tagKey (str): The Plex Discover ratingKey (guid) for people.
|
||||
tagType (int): The type ID of the tag.
|
||||
tagValue (int): The value of the tag.
|
||||
thumb (str): The URL for the thumbnail of the tag (if available).
|
||||
|
@ -2179,8 +2236,10 @@ class LibraryMediaTag(PlexObject):
|
|||
self.reason = data.attrib.get('reason')
|
||||
self.reasonID = utils.cast(int, data.attrib.get('reasonID'))
|
||||
self.reasonTitle = data.attrib.get('reasonTitle')
|
||||
self.score = utils.cast(float, data.attrib.get('score'))
|
||||
self.type = data.attrib.get('type')
|
||||
self.tag = data.attrib.get('tag')
|
||||
self.tagKey = data.attrib.get('tagKey')
|
||||
self.tagType = utils.cast(int, data.attrib.get('tagType'))
|
||||
self.tagValue = utils.cast(int, data.attrib.get('tagValue'))
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
|
@ -2222,16 +2281,6 @@ class Autotag(LibraryMediaTag):
|
|||
TAGTYPE = 207
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Banner(LibraryMediaTag):
|
||||
""" Represents a single Banner library media tag.
|
||||
|
||||
Attributes:
|
||||
TAGTYPE (int): 311
|
||||
"""
|
||||
TAGTYPE = 311
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Chapter(LibraryMediaTag):
|
||||
""" Represents a single Chapter library media tag.
|
||||
|
@ -2958,6 +3007,7 @@ class ManagedHub(PlexObject):
|
|||
managedHub.updateVisibility(recommended=True, home=True, shared=False).reload()
|
||||
# or using chained methods
|
||||
managedHub.promoteRecommended().promoteHome().demoteShared().reload()
|
||||
|
||||
"""
|
||||
params = {
|
||||
'promotedToRecommended': int(self.promotedToRecommended),
|
||||
|
@ -3066,7 +3116,6 @@ class Path(PlexObject):
|
|||
|
||||
Attributes:
|
||||
TAG (str): 'Path'
|
||||
|
||||
home (bool): True if the path is the home directory
|
||||
key (str): API URL (/services/browse/<base64path>)
|
||||
network (bool): True if path is a network location
|
||||
|
@ -3098,7 +3147,6 @@ class File(PlexObject):
|
|||
|
||||
Attributes:
|
||||
TAG (str): 'File'
|
||||
|
||||
key (str): API URL (/services/browse/<base64path>)
|
||||
path (str): Full path to file
|
||||
title (str): File name
|
||||
|
@ -3109,3 +3157,105 @@ class File(PlexObject):
|
|||
self.key = data.attrib.get('key')
|
||||
self.path = data.attrib.get('path')
|
||||
self.title = data.attrib.get('title')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Common(PlexObject):
|
||||
""" Represents a Common element from a library. This object lists common fields between multiple objects.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Common'
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
contentRating (str): Content rating of the items.
|
||||
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
|
||||
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
|
||||
editionTitle (str): Edition title of the items.
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
grandparentRatingKey (int): Grandparent rating key of the items.
|
||||
grandparentTitle (str): Grandparent title of the items.
|
||||
guid (str): Plex GUID of the items.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
index (int): Index of the items.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
mixedFields (List<str>): List of mixed fields.
|
||||
moods (List<:class:`~plexapi.media.Mood`>): List of mood objects.
|
||||
originallyAvailableAt (datetime): Datetime of the release date of the items.
|
||||
parentRatingKey (int): Parent rating key of the items.
|
||||
parentTitle (str): Parent title of the items.
|
||||
producers (List<:class:`~plexapi.media.Producer`>): List of producer objects.
|
||||
ratingKey (int): Rating key of the items.
|
||||
ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects.
|
||||
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||
studio (str): Studio name of the items.
|
||||
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||
summary (str): Summary of the items.
|
||||
tagline (str): Tagline of the items.
|
||||
tags (List<:class:`~plexapi.media.Tag`>): List of tag objects.
|
||||
title (str): Title of the items.
|
||||
titleSort (str): Title to use when sorting of the items.
|
||||
type (str): Type of the media (common).
|
||||
writers (List<:class:`~plexapi.media.Writer`>): List of writer objects.
|
||||
year (int): Year of the items.
|
||||
"""
|
||||
TAG = 'Common'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.directors = self.findItems(data, media.Director)
|
||||
self.editionTitle = data.attrib.get('editionTitle')
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
|
||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key')
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.mixedFields = data.attrib.get('mixedFields').split(',')
|
||||
self.moods = self.findItems(data, media.Mood)
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'))
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.producers = self.findItems(data, media.Producer)
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.ratings = self.findItems(data, media.Rating)
|
||||
self.roles = self.findItems(data, media.Role)
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.styles = self.findItems(data, media.Style)
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.tagline = data.attrib.get('tagline')
|
||||
self.tags = self.findItems(data, media.Tag)
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort')
|
||||
self.type = data.attrib.get('type')
|
||||
self.writers = self.findItems(data, media.Writer)
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s:%s:%s>' % (
|
||||
self.__class__.__name__,
|
||||
self.commonType,
|
||||
','.join(str(key) for key in self.ratingKeys)
|
||||
)
|
||||
|
||||
@property
|
||||
def commonType(self):
|
||||
""" Returns the media type of the common items. """
|
||||
parsed_query = parse_qs(urlparse(self._initpath).query)
|
||||
return utils.reverseSearchType(parsed_query['type'][0])
|
||||
|
||||
@property
|
||||
def ratingKeys(self):
|
||||
""" Returns a list of rating keys for the common items. """
|
||||
parsed_query = parse_qs(urlparse(self._initpath).query)
|
||||
return [int(value.strip()) for value in parsed_query['id'][0].split(',')]
|
||||
|
||||
def items(self):
|
||||
""" Returns a list of the common items. """
|
||||
return self._server.fetchItems(self.ratingKeys)
|
||||
|
|
|
@ -415,7 +415,11 @@ class SubtitleStream(MediaPartStream):
|
|||
forced (bool): True if this is a forced subtitle.
|
||||
format (str): The format of the subtitle stream (ex: srt).
|
||||
headerCompression (str): The header compression of the subtitle stream.
|
||||
providerTitle (str): The provider title where the on-demand subtitle is downloaded from.
|
||||
score (int): The match score of the on-demand subtitle.
|
||||
sourceKey (str): The source key of the on-demand subtitle.
|
||||
transient (str): Unknown.
|
||||
userID (int): The user id of the user that downloaded the on-demand subtitle.
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 3
|
||||
|
@ -427,7 +431,11 @@ class SubtitleStream(MediaPartStream):
|
|||
self.forced = utils.cast(bool, data.attrib.get('forced', '0'))
|
||||
self.format = data.attrib.get('format')
|
||||
self.headerCompression = data.attrib.get('headerCompression')
|
||||
self.providerTitle = data.attrib.get('providerTitle')
|
||||
self.score = utils.cast(int, data.attrib.get('score'))
|
||||
self.sourceKey = data.attrib.get('sourceKey')
|
||||
self.transient = data.attrib.get('transient')
|
||||
self.userID = utils.cast(int, data.attrib.get('userID'))
|
||||
|
||||
def setDefault(self):
|
||||
""" Sets this subtitle stream as the default subtitle stream. """
|
||||
|
@ -955,7 +963,7 @@ class Review(PlexObject):
|
|||
|
||||
|
||||
class BaseResource(PlexObject):
|
||||
""" Base class for all Art, Banner, Poster, and Theme objects.
|
||||
""" Base class for all Art, Poster, and Theme objects.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Photo' or 'Track'
|
||||
|
@ -987,11 +995,6 @@ class Art(BaseResource):
|
|||
TAG = 'Photo'
|
||||
|
||||
|
||||
class Banner(BaseResource):
|
||||
""" Represents a single Banner object. """
|
||||
TAG = 'Photo'
|
||||
|
||||
|
||||
class Poster(BaseResource):
|
||||
""" Represents a single Poster object. """
|
||||
TAG = 'Photo'
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit
|
||||
|
||||
from plexapi import media, settings, utils
|
||||
|
@ -139,7 +138,7 @@ class SplitMergeMixin:
|
|||
if not isinstance(ratingKeys, list):
|
||||
ratingKeys = str(ratingKeys).split(',')
|
||||
|
||||
key = f"{self.key}/merge?ids={','.join([str(r) for r in ratingKeys])}"
|
||||
key = f"{self.key}/merge?ids={','.join(str(r) for r in ratingKeys)}"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
|
@ -329,7 +328,19 @@ class ArtUrlMixin:
|
|||
return self._server.url(art, includeToken=True) if art else None
|
||||
|
||||
|
||||
class ArtMixin(ArtUrlMixin):
|
||||
class ArtLockMixin:
|
||||
""" Mixin for Plex objects that can have a locked background artwork. """
|
||||
|
||||
def lockArt(self):
|
||||
""" Lock the background artwork for a Plex object. """
|
||||
return self._edit(**{'art.locked': 1})
|
||||
|
||||
def unlockArt(self):
|
||||
""" Unlock the background artwork for a Plex object. """
|
||||
return self._edit(**{'art.locked': 0})
|
||||
|
||||
|
||||
class ArtMixin(ArtUrlMixin, ArtLockMixin):
|
||||
""" Mixin for Plex objects that can have background artwork. """
|
||||
|
||||
def arts(self):
|
||||
|
@ -361,65 +372,6 @@ class ArtMixin(ArtUrlMixin):
|
|||
art.select()
|
||||
return self
|
||||
|
||||
def lockArt(self):
|
||||
""" Lock the background artwork for a Plex object. """
|
||||
return self._edit(**{'art.locked': 1})
|
||||
|
||||
def unlockArt(self):
|
||||
""" Unlock the background artwork for a Plex object. """
|
||||
return self._edit(**{'art.locked': 0})
|
||||
|
||||
|
||||
class BannerUrlMixin:
|
||||
""" Mixin for Plex objects that can have a banner url. """
|
||||
|
||||
@property
|
||||
def bannerUrl(self):
|
||||
""" Return the banner url for the Plex object. """
|
||||
banner = self.firstAttr('banner')
|
||||
return self._server.url(banner, includeToken=True) if banner else None
|
||||
|
||||
|
||||
class BannerMixin(BannerUrlMixin):
|
||||
""" Mixin for Plex objects that can have banners. """
|
||||
|
||||
def banners(self):
|
||||
""" Returns list of available :class:`~plexapi.media.Banner` objects. """
|
||||
return self.fetchItems(f'/library/metadata/{self.ratingKey}/banners', cls=media.Banner)
|
||||
|
||||
def uploadBanner(self, url=None, filepath=None):
|
||||
""" Upload a banner from a url or filepath.
|
||||
|
||||
Parameters:
|
||||
url (str): The full URL to the image to upload.
|
||||
filepath (str): The full file path the the image to upload or file-like object.
|
||||
"""
|
||||
if url:
|
||||
key = f'/library/metadata/{self.ratingKey}/banners?url={quote_plus(url)}'
|
||||
self._server.query(key, method=self._server._session.post)
|
||||
elif filepath:
|
||||
key = f'/library/metadata/{self.ratingKey}/banners'
|
||||
data = openOrRead(filepath)
|
||||
self._server.query(key, method=self._server._session.post, data=data)
|
||||
return self
|
||||
|
||||
def setBanner(self, banner):
|
||||
""" Set the banner for a Plex object.
|
||||
|
||||
Parameters:
|
||||
banner (:class:`~plexapi.media.Banner`): The banner object to select.
|
||||
"""
|
||||
banner.select()
|
||||
return self
|
||||
|
||||
def lockBanner(self):
|
||||
""" Lock the banner for a Plex object. """
|
||||
return self._edit(**{'banner.locked': 1})
|
||||
|
||||
def unlockBanner(self):
|
||||
""" Unlock the banner for a Plex object. """
|
||||
return self._edit(**{'banner.locked': 0})
|
||||
|
||||
|
||||
class PosterUrlMixin:
|
||||
""" Mixin for Plex objects that can have a poster url. """
|
||||
|
@ -436,7 +388,19 @@ class PosterUrlMixin:
|
|||
return self.thumbUrl
|
||||
|
||||
|
||||
class PosterMixin(PosterUrlMixin):
|
||||
class PosterLockMixin:
|
||||
""" Mixin for Plex objects that can have a locked poster. """
|
||||
|
||||
def lockPoster(self):
|
||||
""" Lock the poster for a Plex object. """
|
||||
return self._edit(**{'thumb.locked': 1})
|
||||
|
||||
def unlockPoster(self):
|
||||
""" Unlock the poster for a Plex object. """
|
||||
return self._edit(**{'thumb.locked': 0})
|
||||
|
||||
|
||||
class PosterMixin(PosterUrlMixin, PosterLockMixin):
|
||||
""" Mixin for Plex objects that can have posters. """
|
||||
|
||||
def posters(self):
|
||||
|
@ -468,14 +432,6 @@ class PosterMixin(PosterUrlMixin):
|
|||
poster.select()
|
||||
return self
|
||||
|
||||
def lockPoster(self):
|
||||
""" Lock the poster for a Plex object. """
|
||||
return self._edit(**{'thumb.locked': 1})
|
||||
|
||||
def unlockPoster(self):
|
||||
""" Unlock the poster for a Plex object. """
|
||||
return self._edit(**{'thumb.locked': 0})
|
||||
|
||||
|
||||
class ThemeUrlMixin:
|
||||
""" Mixin for Plex objects that can have a theme url. """
|
||||
|
@ -487,7 +443,19 @@ class ThemeUrlMixin:
|
|||
return self._server.url(theme, includeToken=True) if theme else None
|
||||
|
||||
|
||||
class ThemeMixin(ThemeUrlMixin):
|
||||
class ThemeLockMixin:
|
||||
""" Mixin for Plex objects that can have a locked theme. """
|
||||
|
||||
def lockTheme(self):
|
||||
""" Lock the theme for a Plex object. """
|
||||
return self._edit(**{'theme.locked': 1})
|
||||
|
||||
def unlockTheme(self):
|
||||
""" Unlock the theme for a Plex object. """
|
||||
return self._edit(**{'theme.locked': 0})
|
||||
|
||||
|
||||
class ThemeMixin(ThemeUrlMixin, ThemeLockMixin):
|
||||
""" Mixin for Plex objects that can have themes. """
|
||||
|
||||
def themes(self):
|
||||
|
@ -520,14 +488,6 @@ class ThemeMixin(ThemeUrlMixin):
|
|||
'Re-upload the theme using "uploadTheme" to set it.'
|
||||
)
|
||||
|
||||
def lockTheme(self):
|
||||
""" Lock the theme for a Plex object. """
|
||||
return self._edit(**{'theme.locked': 1})
|
||||
|
||||
def unlockTheme(self):
|
||||
""" Unlock the theme for a Plex object. """
|
||||
return self._edit(**{'theme.locked': 0})
|
||||
|
||||
|
||||
class EditFieldMixin:
|
||||
""" Mixin for editing Plex object fields. """
|
||||
|
@ -752,6 +712,19 @@ class PhotoCapturedTimeMixin(EditFieldMixin):
|
|||
return self.editField('originallyAvailableAt', capturedTime, locked=locked)
|
||||
|
||||
|
||||
class UserRatingMixin(EditFieldMixin):
|
||||
""" Mixin for Plex objects that can have a user rating. """
|
||||
|
||||
def editUserRating(self, userRating, locked=True):
|
||||
""" Edit the user rating.
|
||||
|
||||
Parameters:
|
||||
userRating (int): The new value.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
return self.editField('userRating', userRating, locked=locked)
|
||||
|
||||
|
||||
class EditTagsMixin:
|
||||
""" Mixin for editing Plex object tags. """
|
||||
|
||||
|
@ -781,7 +754,7 @@ class EditTagsMixin:
|
|||
items = [items]
|
||||
|
||||
if not remove:
|
||||
tags = getattr(self, self._tagPlural(tag))
|
||||
tags = getattr(self, self._tagPlural(tag), [])
|
||||
items = tags + items
|
||||
|
||||
edits = self._tagHelper(self._tagSingular(tag), items, locked, remove)
|
||||
|
@ -822,7 +795,7 @@ class EditTagsMixin:
|
|||
|
||||
if remove:
|
||||
tagname = f'{tag}[].tag.tag-'
|
||||
data[tagname] = ','.join([quote(str(t)) for t in items])
|
||||
data[tagname] = ','.join(quote(str(t)) for t in items)
|
||||
else:
|
||||
for i, item in enumerate(items):
|
||||
tagname = f'{str(tag)}[{i}].tag.tag'
|
||||
|
@ -1135,3 +1108,84 @@ class WatchlistMixin:
|
|||
ratingKey = self.guid.rsplit('/', 1)[-1]
|
||||
data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities")
|
||||
return self.findItems(data)
|
||||
|
||||
|
||||
class MovieEditMixins(
|
||||
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||
AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin,
|
||||
StudioMixin, SummaryMixin, TaglineMixin, TitleMixin, UserRatingMixin,
|
||||
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class ShowEditMixins(
|
||||
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||
AddedAtMixin, ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
||||
SummaryMixin, TaglineMixin, TitleMixin, UserRatingMixin,
|
||||
CollectionMixin, GenreMixin, LabelMixin,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class SeasonEditMixins(
|
||||
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||
AddedAtMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||
CollectionMixin, LabelMixin
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class EpisodeEditMixins(
|
||||
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||
AddedAtMixin, ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||
CollectionMixin, DirectorMixin, LabelMixin, WriterMixin
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class ArtistEditMixins(
|
||||
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class AlbumEditMixins(
|
||||
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||
AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class TrackEditMixins(
|
||||
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||
AddedAtMixin, TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin, UserRatingMixin,
|
||||
CollectionMixin, LabelMixin, MoodMixin
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class PhotoalbumEditMixins(
|
||||
ArtLockMixin, PosterLockMixin,
|
||||
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class PhotoEditMixins(
|
||||
ArtLockMixin, PosterLockMixin,
|
||||
AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||
TagMixin
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class CollectionEditMixins(
|
||||
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||
LabelMixin
|
||||
):
|
||||
pass
|
||||
|
|
|
@ -7,8 +7,9 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE,
|
||||
X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, log, logfilter, utils)
|
||||
|
||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER,
|
||||
log, logfilter, utils)
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||
|
@ -21,51 +22,76 @@ from requests.status_codes import _codes as codes
|
|||
|
||||
class MyPlexAccount(PlexObject):
|
||||
""" MyPlex account and profile information. This object represents the data found Account on
|
||||
the myplex.tv servers at the url https://plex.tv/users/account. You may create this object
|
||||
the myplex.tv servers at the url https://plex.tv/api/v2/user. You may create this object
|
||||
directly by passing in your username & password (or token). There is also a convenience
|
||||
method provided at :class:`~plexapi.server.PlexServer.myPlexAccount()` which will create
|
||||
and return this object.
|
||||
|
||||
Parameters:
|
||||
username (str): Your MyPlex username.
|
||||
password (str): Your MyPlex password.
|
||||
username (str): Plex login username if not using a token.
|
||||
password (str): Plex login password if not using a token.
|
||||
token (str): Plex authentication token instead of username and password.
|
||||
session (requests.Session, optional): Use your own session object if you want to
|
||||
cache the http responses from PMS
|
||||
cache the http responses from PMS.
|
||||
timeout (int): timeout in seconds on initial connect to myplex (default config.TIMEOUT).
|
||||
code (str): Two-factor authentication code to use when logging in.
|
||||
code (str): Two-factor authentication code to use when logging in with username and password.
|
||||
remember (bool): Remember the account token for 14 days (Default True).
|
||||
|
||||
Attributes:
|
||||
SIGNIN (str): 'https://plex.tv/users/sign_in.xml'
|
||||
key (str): 'https://plex.tv/users/account'
|
||||
authenticationToken (str): Unknown.
|
||||
certificateVersion (str): Unknown.
|
||||
cloudSyncDevice (str): Unknown.
|
||||
email (str): Your current Plex email address.
|
||||
key (str): 'https://plex.tv/api/v2/user'
|
||||
adsConsent (str): Unknown.
|
||||
adsConsentReminderAt (str): Unknown.
|
||||
adsConsentSetAt (str): Unknown.
|
||||
anonymous (str): Unknown.
|
||||
authToken (str): The account token.
|
||||
backupCodesCreated (bool): If the two-factor authentication backup codes have been created.
|
||||
confirmed (bool): If the account has been confirmed.
|
||||
country (str): The account country.
|
||||
email (str): The account email address.
|
||||
emailOnlyAuth (bool): If login with email only is enabled.
|
||||
experimentalFeatures (bool): If experimental features are enabled.
|
||||
friendlyName (str): Your account full name.
|
||||
entitlements (List<str>): List of devices your allowed to use with this account.
|
||||
guest (bool): Unknown.
|
||||
home (bool): Unknown.
|
||||
homeSize (int): Unknown.
|
||||
id (int): Your Plex account ID.
|
||||
locale (str): Your Plex locale
|
||||
mailing_list_status (str): Your current mailing list status.
|
||||
maxHomeSize (int): Unknown.
|
||||
guest (bool): If the account is a Plex Home guest user.
|
||||
hasPassword (bool): If the account has a password.
|
||||
home (bool): If the account is a Plex Home user.
|
||||
homeAdmin (bool): If the account is the Plex Home admin.
|
||||
homeSize (int): The number of accounts in the Plex Home.
|
||||
id (int): The Plex account ID.
|
||||
joinedAt (datetime): Date the account joined Plex.
|
||||
locale (str): the account locale
|
||||
mailingListActive (bool): If you are subscribed to the Plex newsletter.
|
||||
mailingListStatus (str): Your current mailing list status.
|
||||
maxHomeSize (int): The maximum number of accounts allowed in the Plex Home.
|
||||
pin (str): The hashed Plex Home PIN.
|
||||
queueEmail (str): Email address to add items to your `Watch Later` queue.
|
||||
queueUid (str): Unknown.
|
||||
restricted (bool): Unknown.
|
||||
profileAutoSelectAudio (bool): If the account has automatically select audio and subtitle tracks enabled.
|
||||
profileDefaultAudioLanguage (str): The preferred audio language for the account.
|
||||
profileDefaultSubtitleLanguage (str): The preferred subtitle language for the account.
|
||||
profileAutoSelectSubtitle (int): The auto-select subtitle mode
|
||||
(0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled).
|
||||
profileDefaultSubtitleAccessibility (int): The subtitles for the deaf or hard-of-hearing (SDH) searches mode
|
||||
(0 = Prefer non-SDH subtitles, 1 = Prefer SDH subtitles, 2 = Only show SDH subtitles,
|
||||
3 = Only shown non-SDH subtitles).
|
||||
profileDefaultSubtitleForced (int): The forced subtitles searches mode
|
||||
(0 = Prefer non-forced subtitles, 1 = Prefer forced subtitles, 2 = Only show forced subtitles,
|
||||
3 = Only show non-forced subtitles).
|
||||
protected (bool): If the account has a Plex Home PIN enabled.
|
||||
rememberExpiresAt (datetime): Date the token expires.
|
||||
restricted (bool): If the account is a Plex Home managed user.
|
||||
roles: (List<str>) Lit of account roles. Plexpass membership listed here.
|
||||
scrobbleTypes (str): Description
|
||||
secure (bool): Description
|
||||
subscriptionActive (bool): True if your subscription is active.
|
||||
subscriptionFeatures: (List<str>) List of features allowed on your subscription.
|
||||
subscriptionPlan (str): Name of subscription plan.
|
||||
subscriptionStatus (str): String representation of `subscriptionActive`.
|
||||
thumb (str): URL of your account thumbnail.
|
||||
title (str): Unknown. - Looks like an alias for `username`.
|
||||
username (str): Your account username.
|
||||
uuid (str): Unknown.
|
||||
_token (str): Token used to access this client.
|
||||
_session (obj): Requests session object used to access this client.
|
||||
scrobbleTypes (List<int>): Unknown.
|
||||
subscriptionActive (bool): If the account's Plex Pass subscription is active.
|
||||
subscriptionDescription (str): Description of the Plex Pass subscription.
|
||||
subscriptionFeatures: (List<str>) List of features allowed on your Plex Pass subscription.
|
||||
subscriptionPaymentService (str): Payment service used for your Plex Pass subscription.
|
||||
subscriptionPlan (str): Name of Plex Pass subscription plan.
|
||||
subscriptionStatus (str): String representation of ``subscriptionActive``.
|
||||
subscriptionSubscribedAt (datetime): Date the account subscribed to Plex Pass.
|
||||
thumb (str): URL of the account thumbnail.
|
||||
title (str): The title of the account (username or friendly name).
|
||||
twoFactorEnabled (bool): If two-factor authentication is enabled.
|
||||
username (str): The account username.
|
||||
uuid (str): The account UUID.
|
||||
"""
|
||||
FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data
|
||||
HOMEUSERS = 'https://plex.tv/api/home/users'
|
||||
|
@ -76,7 +102,8 @@ class MyPlexAccount(PlexObject):
|
|||
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete
|
||||
HOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete, put
|
||||
MANAGEDHOMEUSER = 'https://plex.tv/api/v2/home/users/restricted/{userId}' # put
|
||||
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
|
||||
SIGNIN = 'https://plex.tv/api/v2/users/signin' # post with auth
|
||||
SIGNOUT = 'https://plex.tv/api/v2/users/signout' # delete
|
||||
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
||||
OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get
|
||||
LINK = 'https://plex.tv/api/v2/pins/link' # put
|
||||
|
@ -85,86 +112,106 @@ class MyPlexAccount(PlexObject):
|
|||
VOD = 'https://vod.provider.plex.tv' # get
|
||||
MUSIC = 'https://music.provider.plex.tv' # get
|
||||
METADATA = 'https://metadata.provider.plex.tv'
|
||||
# Key may someday switch to the following url. For now the current value works.
|
||||
# https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId}
|
||||
key = 'https://plex.tv/users/account'
|
||||
key = 'https://plex.tv/api/v2/user'
|
||||
|
||||
def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None):
|
||||
self._token = token or CONFIG.get('auth.server_token')
|
||||
def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None, remember=True):
|
||||
self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
|
||||
self._session = session or requests.Session()
|
||||
self._sonos_cache = []
|
||||
self._sonos_cache_timestamp = 0
|
||||
data, initpath = self._signin(username, password, code, timeout)
|
||||
data, initpath = self._signin(username, password, code, remember, timeout)
|
||||
super(MyPlexAccount, self).__init__(self, data, initpath)
|
||||
|
||||
def _signin(self, username, password, code, timeout):
|
||||
def _signin(self, username, password, code, remember, timeout):
|
||||
if self._token:
|
||||
return self.query(self.key), self.key
|
||||
username = username or CONFIG.get('auth.myplex_username')
|
||||
password = password or CONFIG.get('auth.myplex_password')
|
||||
payload = {
|
||||
'login': username or CONFIG.get('auth.myplex_username'),
|
||||
'password': password or CONFIG.get('auth.myplex_password'),
|
||||
'rememberMe': remember
|
||||
}
|
||||
if code:
|
||||
password += code
|
||||
data = self.query(self.SIGNIN, method=self._session.post, auth=(username, password), timeout=timeout)
|
||||
payload['verificationCode'] = code
|
||||
data = self.query(self.SIGNIN, method=self._session.post, data=payload, timeout=timeout)
|
||||
return data, self.SIGNIN
|
||||
|
||||
def signout(self):
|
||||
""" Sign out of the Plex account. Invalidates the authentication token. """
|
||||
return self.query(self.SIGNOUT, method=self._session.delete)
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self._token = logfilter.add_secret(data.attrib.get('authenticationToken'))
|
||||
self._token = logfilter.add_secret(data.attrib.get('authToken'))
|
||||
self._webhooks = []
|
||||
self.authenticationToken = self._token
|
||||
self.certificateVersion = data.attrib.get('certificateVersion')
|
||||
self.cloudSyncDevice = data.attrib.get('cloudSyncDevice')
|
||||
|
||||
self.adsConsent = data.attrib.get('adsConsent')
|
||||
self.adsConsentReminderAt = data.attrib.get('adsConsentReminderAt')
|
||||
self.adsConsentSetAt = data.attrib.get('adsConsentSetAt')
|
||||
self.anonymous = data.attrib.get('anonymous')
|
||||
self.authToken = self._token
|
||||
self.backupCodesCreated = utils.cast(bool, data.attrib.get('backupCodesCreated'))
|
||||
self.confirmed = utils.cast(bool, data.attrib.get('confirmed'))
|
||||
self.country = data.attrib.get('country')
|
||||
self.email = data.attrib.get('email')
|
||||
self.emailOnlyAuth = utils.cast(bool, data.attrib.get('emailOnlyAuth'))
|
||||
self.experimentalFeatures = utils.cast(bool, data.attrib.get('experimentalFeatures'))
|
||||
self.friendlyName = data.attrib.get('friendlyName')
|
||||
self.guest = utils.cast(bool, data.attrib.get('guest'))
|
||||
self.hasPassword = utils.cast(bool, data.attrib.get('hasPassword'))
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.homeAdmin = utils.cast(bool, data.attrib.get('homeAdmin'))
|
||||
self.homeSize = utils.cast(int, data.attrib.get('homeSize'))
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.joinedAt = utils.toDatetime(data.attrib.get('joinedAt'))
|
||||
self.locale = data.attrib.get('locale')
|
||||
self.mailing_list_status = data.attrib.get('mailing_list_status')
|
||||
self.mailingListActive = utils.cast(bool, data.attrib.get('mailingListActive'))
|
||||
self.mailingListStatus = data.attrib.get('mailingListStatus')
|
||||
self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
|
||||
self.pin = data.attrib.get('pin')
|
||||
self.queueEmail = data.attrib.get('queueEmail')
|
||||
self.queueUid = data.attrib.get('queueUid')
|
||||
self.protected = utils.cast(bool, data.attrib.get('protected'))
|
||||
self.rememberExpiresAt = utils.toDatetime(data.attrib.get('rememberExpiresAt'))
|
||||
self.restricted = utils.cast(bool, data.attrib.get('restricted'))
|
||||
self.scrobbleTypes = data.attrib.get('scrobbleTypes')
|
||||
self.secure = utils.cast(bool, data.attrib.get('secure'))
|
||||
self.scrobbleTypes = [utils.cast(int, x) for x in data.attrib.get('scrobbleTypes').split(',')]
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.twoFactorEnabled = utils.cast(bool, data.attrib.get('twoFactorEnabled'))
|
||||
self.username = data.attrib.get('username')
|
||||
self.uuid = data.attrib.get('uuid')
|
||||
|
||||
subscription = data.find('subscription')
|
||||
self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active'))
|
||||
self.subscriptionStatus = subscription.attrib.get('status')
|
||||
self.subscriptionDescription = data.attrib.get('subscriptionDescription')
|
||||
self.subscriptionFeatures = self.listAttrs(subscription, 'id', rtag='features', etag='feature')
|
||||
self.subscriptionPaymentService = subscription.attrib.get('paymentService')
|
||||
self.subscriptionPlan = subscription.attrib.get('plan')
|
||||
self.subscriptionFeatures = self.listAttrs(subscription, 'id', etag='feature')
|
||||
self.subscriptionStatus = subscription.attrib.get('status')
|
||||
self.subscriptionSubscribedAt = utils.toDatetime(subscription.attrib.get('subscribedAt'), '%Y-%m-%d %H:%M:%S %Z')
|
||||
|
||||
self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role')
|
||||
profile = data.find('profile')
|
||||
self.profileAutoSelectAudio = utils.cast(bool, profile.attrib.get('autoSelectAudio'))
|
||||
self.profileDefaultAudioLanguage = profile.attrib.get('defaultAudioLanguage')
|
||||
self.profileDefaultSubtitleLanguage = profile.attrib.get('defaultSubtitleLanguage')
|
||||
self.profileAutoSelectSubtitle = utils.cast(int, profile.attrib.get('autoSelectSubtitle'))
|
||||
self.profileDefaultSubtitleAccessibility = utils.cast(int, profile.attrib.get('defaultSubtitleAccessibility'))
|
||||
self.profileDefaultSubtitleForces = utils.cast(int, profile.attrib.get('defaultSubtitleForces'))
|
||||
|
||||
self.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement')
|
||||
self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role')
|
||||
|
||||
# TODO: Fetch missing MyPlexAccount attributes
|
||||
self.profile_settings = None
|
||||
# TODO: Fetch missing MyPlexAccount services
|
||||
self.services = None
|
||||
self.joined_at = None
|
||||
|
||||
def device(self, name=None, clientId=None):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
|
||||
@property
|
||||
def authenticationToken(self):
|
||||
""" Returns the authentication token for the account. Alias for ``authToken``. """
|
||||
return self.authToken
|
||||
|
||||
Parameters:
|
||||
name (str): Name to match against.
|
||||
clientId (str): clientIdentifier to match against.
|
||||
"""
|
||||
for device in self.devices():
|
||||
if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId):
|
||||
return device
|
||||
raise NotFound(f'Unable to find device {name}')
|
||||
|
||||
def devices(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """
|
||||
data = self.query(MyPlexDevice.key)
|
||||
return [MyPlexDevice(self, elem) for elem in data]
|
||||
def _reload(self, key=None, **kwargs):
|
||||
""" Perform the actual reload. """
|
||||
data = self.query(self.key)
|
||||
self._loadData(data)
|
||||
return self
|
||||
|
||||
def _headers(self, **kwargs):
|
||||
""" Returns dict containing base headers for all requests to the server. """
|
||||
|
@ -188,6 +235,8 @@ class MyPlexAccount(PlexObject):
|
|||
raise Unauthorized(message)
|
||||
elif response.status_code == 404:
|
||||
raise NotFound(message)
|
||||
elif response.status_code == 422 and "Invalid token" in response.text:
|
||||
raise Unauthorized(message)
|
||||
else:
|
||||
raise BadRequest(message)
|
||||
if headers.get('Accept') == 'application/json':
|
||||
|
@ -195,6 +244,23 @@ class MyPlexAccount(PlexObject):
|
|||
data = response.text.encode('utf8')
|
||||
return ElementTree.fromstring(data) if data.strip() else None
|
||||
|
||||
def device(self, name=None, clientId=None):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
|
||||
|
||||
Parameters:
|
||||
name (str): Name to match against.
|
||||
clientId (str): clientIdentifier to match against.
|
||||
"""
|
||||
for device in self.devices():
|
||||
if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId):
|
||||
return device
|
||||
raise NotFound(f'Unable to find device {name}')
|
||||
|
||||
def devices(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """
|
||||
data = self.query(MyPlexDevice.key)
|
||||
return [MyPlexDevice(self, elem) for elem in data]
|
||||
|
||||
def resource(self, name):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified.
|
||||
|
||||
|
@ -784,7 +850,7 @@ class MyPlexAccount(PlexObject):
|
|||
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
|
||||
return response.json()['token']
|
||||
|
||||
def history(self, maxresults=9999999, mindate=None):
|
||||
def history(self, maxresults=None, mindate=None):
|
||||
""" Get Play History for all library sections on all servers for the owner.
|
||||
|
||||
Parameters:
|
||||
|
@ -817,7 +883,7 @@ class MyPlexAccount(PlexObject):
|
|||
data = self.query(f'{self.MUSIC}/hubs')
|
||||
return self.findItems(data)
|
||||
|
||||
def watchlist(self, filter=None, sort=None, libtype=None, maxresults=9999999, **kwargs):
|
||||
def watchlist(self, filter=None, sort=None, libtype=None, maxresults=None, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` items in the user's watchlist.
|
||||
Note: The objects returned are from Plex's online metadata. To get the matching item on a Plex server,
|
||||
search for the media using the guid.
|
||||
|
@ -857,23 +923,10 @@ class MyPlexAccount(PlexObject):
|
|||
if libtype:
|
||||
params['type'] = utils.searchType(libtype)
|
||||
|
||||
params['X-Plex-Container-Start'] = 0
|
||||
params['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
||||
params.update(kwargs)
|
||||
|
||||
results, subresults = [], '_init'
|
||||
while subresults and maxresults > len(results):
|
||||
data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params)
|
||||
subresults = self.findItems(data)
|
||||
results += subresults[:maxresults - len(results)]
|
||||
params['X-Plex-Container-Start'] += params['X-Plex-Container-Size']
|
||||
|
||||
# totalSize is available in first response, update maxresults from it
|
||||
totalSize = utils.cast(int, data.attrib.get('totalSize'))
|
||||
if maxresults > totalSize:
|
||||
maxresults = totalSize
|
||||
|
||||
return self._toOnlineMetadata(results, **kwargs)
|
||||
key = f'{self.METADATA}/library/sections/watchlist/{filter}{utils.joinArgs(params)}'
|
||||
return self._toOnlineMetadata(self.fetchItems(key, maxresults=maxresults), **kwargs)
|
||||
|
||||
def onWatchlist(self, item):
|
||||
""" Returns True if the item is on the user's watchlist.
|
||||
|
@ -936,6 +989,48 @@ class MyPlexAccount(PlexObject):
|
|||
data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState")
|
||||
return self.findItem(data, cls=UserState)
|
||||
|
||||
def isPlayed(self, item):
|
||||
""" Return True if the item is played on Discover.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.video.Movie`,
|
||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or
|
||||
:class:`~plexapi.video.Episode`): Object from searchDiscover().
|
||||
Can be also result from Plex Movie or Plex TV Series agent.
|
||||
"""
|
||||
userState = self.userState(item)
|
||||
return bool(userState.viewCount > 0) if userState.viewCount else False
|
||||
|
||||
def markPlayed(self, item):
|
||||
""" Mark the Plex object as played on Discover.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.video.Movie`,
|
||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or
|
||||
:class:`~plexapi.video.Episode`): Object from searchDiscover().
|
||||
Can be also result from Plex Movie or Plex TV Series agent.
|
||||
"""
|
||||
key = f'{self.METADATA}/actions/scrobble'
|
||||
ratingKey = item.guid.rsplit('/', 1)[-1]
|
||||
params = {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}
|
||||
self.query(key, params=params)
|
||||
return self
|
||||
|
||||
def markUnplayed(self, item):
|
||||
""" Mark the Plex object as unplayed on Discover.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.video.Movie`,
|
||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or
|
||||
:class:`~plexapi.video.Episode`): Object from searchDiscover().
|
||||
Can be also result from Plex Movie or Plex TV Series agent.
|
||||
"""
|
||||
key = f'{self.METADATA}/actions/unscrobble'
|
||||
ratingKey = item.guid.rsplit('/', 1)[-1]
|
||||
params = {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}
|
||||
self.query(key, params=params)
|
||||
return self
|
||||
|
||||
def searchDiscover(self, query, limit=30, libtype=None):
|
||||
""" Search for movies and TV shows in Discover.
|
||||
Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects.
|
||||
|
@ -1117,7 +1212,7 @@ class MyPlexUser(PlexObject):
|
|||
|
||||
raise NotFound(f'Unable to find server {name}')
|
||||
|
||||
def history(self, maxresults=9999999, mindate=None):
|
||||
def history(self, maxresults=None, mindate=None):
|
||||
""" Get all Play History for a user in all shared servers.
|
||||
Parameters:
|
||||
maxresults (int): Only return the specified number of results (optional).
|
||||
|
@ -1191,7 +1286,7 @@ class Section(PlexObject):
|
|||
self.sectionId = self.id # For backwards compatibility
|
||||
self.sectionKey = self.key # For backwards compatibility
|
||||
|
||||
def history(self, maxresults=9999999, mindate=None):
|
||||
def history(self, maxresults=None, mindate=None):
|
||||
""" Get all Play History for a user for this section in this shared server.
|
||||
Parameters:
|
||||
maxresults (int): Only return the specified number of results (optional).
|
||||
|
@ -1266,21 +1361,25 @@ class MyPlexResource(PlexObject):
|
|||
""" This object represents resources connected to your Plex server that can provide
|
||||
content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml
|
||||
for the data presented here can be found at:
|
||||
https://plex.tv/api/resources?includeHttps=1&includeRelay=1
|
||||
https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Device'
|
||||
key (str): 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
||||
accessToken (str): This resources accesstoken.
|
||||
key (str): 'https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1'
|
||||
accessToken (str): This resource's Plex access token.
|
||||
clientIdentifier (str): Unique ID for this resource.
|
||||
connections (list): List of :class:`~plexapi.myplex.ResourceConnection` objects
|
||||
for this resource.
|
||||
createdAt (datetime): Timestamp this resource first connected to your server.
|
||||
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
|
||||
dnsRebindingProtection (bool): True if the server had DNS rebinding protection.
|
||||
home (bool): Unknown
|
||||
httpsRequired (bool): True if the resource requires https.
|
||||
lastSeenAt (datetime): Timestamp this resource last connected.
|
||||
name (str): Descriptive name of this resource.
|
||||
natLoopbackSupported (bool): True if the resource supports NAT loopback.
|
||||
owned (bool): True if this resource is one of your own (you logged into it).
|
||||
ownerId (int): ID of the user that owns this resource (shared resources only).
|
||||
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
|
||||
platformVersion (str): Version of the platform.
|
||||
presence (bool): True if the resource is online
|
||||
|
@ -1288,10 +1387,13 @@ class MyPlexResource(PlexObject):
|
|||
productVersion (str): Version of the product.
|
||||
provides (str): List of services this resource provides (client, server,
|
||||
player, pubsub-player, etc.)
|
||||
publicAddressMatches (bool): True if the public IP address matches the client's public IP address.
|
||||
relay (bool): True if this resource has the Plex Relay enabled.
|
||||
sourceTitle (str): Username of the user that owns this resource (shared resources only).
|
||||
synced (bool): Unknown (possibly True if the resource has synced content?)
|
||||
"""
|
||||
TAG = 'Device'
|
||||
key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
||||
TAG = 'resource'
|
||||
key = 'https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1'
|
||||
|
||||
# Default order to prioritize available resource connections
|
||||
DEFAULT_LOCATION_ORDER = ['local', 'remote', 'relay']
|
||||
|
@ -1299,33 +1401,35 @@ class MyPlexResource(PlexObject):
|
|||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.name = data.attrib.get('name')
|
||||
self.accessToken = logfilter.add_secret(data.attrib.get('accessToken'))
|
||||
self.product = data.attrib.get('product')
|
||||
self.productVersion = data.attrib.get('productVersion')
|
||||
self.clientIdentifier = data.attrib.get('clientIdentifier')
|
||||
self.connections = self.findItems(data, ResourceConnection, rtag='connections')
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'), "%Y-%m-%dT%H:%M:%SZ")
|
||||
self.device = data.attrib.get('device')
|
||||
self.dnsRebindingProtection = utils.cast(bool, data.attrib.get('dnsRebindingProtection'))
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired'))
|
||||
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'), "%Y-%m-%dT%H:%M:%SZ")
|
||||
self.name = data.attrib.get('name')
|
||||
self.natLoopbackSupported = utils.cast(bool, data.attrib.get('natLoopbackSupported'))
|
||||
self.owned = utils.cast(bool, data.attrib.get('owned'))
|
||||
self.ownerId = utils.cast(int, data.attrib.get('ownerId', 0))
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.platformVersion = data.attrib.get('platformVersion')
|
||||
self.device = data.attrib.get('device')
|
||||
self.clientIdentifier = data.attrib.get('clientIdentifier')
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
||||
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'))
|
||||
self.provides = data.attrib.get('provides')
|
||||
self.owned = utils.cast(bool, data.attrib.get('owned'))
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.synced = utils.cast(bool, data.attrib.get('synced'))
|
||||
self.presence = utils.cast(bool, data.attrib.get('presence'))
|
||||
self.connections = self.findItems(data, ResourceConnection)
|
||||
self.product = data.attrib.get('product')
|
||||
self.productVersion = data.attrib.get('productVersion')
|
||||
self.provides = data.attrib.get('provides')
|
||||
self.publicAddressMatches = utils.cast(bool, data.attrib.get('publicAddressMatches'))
|
||||
# This seems to only be available if its not your device (say are shared server)
|
||||
self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired'))
|
||||
self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0))
|
||||
self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username.
|
||||
self.relay = utils.cast(bool, data.attrib.get('relay'))
|
||||
self.sourceTitle = data.attrib.get('sourceTitle')
|
||||
self.synced = utils.cast(bool, data.attrib.get('synced'))
|
||||
|
||||
def preferred_connections(
|
||||
self,
|
||||
ssl=None,
|
||||
locations=DEFAULT_LOCATION_ORDER,
|
||||
schemes=DEFAULT_SCHEME_ORDER,
|
||||
locations=None,
|
||||
schemes=None,
|
||||
):
|
||||
""" Returns a sorted list of the available connection addresses for this resource.
|
||||
Often times there is more than one address specified for a server or client.
|
||||
|
@ -1336,6 +1440,11 @@ class MyPlexResource(PlexObject):
|
|||
only connect to HTTP connections. Set None (default) to connect to any
|
||||
HTTP or HTTPS connection.
|
||||
"""
|
||||
if locations is None:
|
||||
locations = self.DEFAULT_LOCATION_ORDER[:]
|
||||
if schemes is None:
|
||||
schemes = self.DEFAULT_SCHEME_ORDER[:]
|
||||
|
||||
connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations}
|
||||
for connection in self.connections:
|
||||
# Only check non-local connections unless we own the resource
|
||||
|
@ -1359,8 +1468,8 @@ class MyPlexResource(PlexObject):
|
|||
self,
|
||||
ssl=None,
|
||||
timeout=None,
|
||||
locations=DEFAULT_LOCATION_ORDER,
|
||||
schemes=DEFAULT_SCHEME_ORDER,
|
||||
locations=None,
|
||||
schemes=None,
|
||||
):
|
||||
""" Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object.
|
||||
Uses `MyPlexResource.preferred_connections()` to generate the priority order of connection addresses.
|
||||
|
@ -1376,11 +1485,16 @@ class MyPlexResource(PlexObject):
|
|||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||
"""
|
||||
if locations is None:
|
||||
locations = self.DEFAULT_LOCATION_ORDER[:]
|
||||
if schemes is None:
|
||||
schemes = self.DEFAULT_SCHEME_ORDER[:]
|
||||
|
||||
connections = self.preferred_connections(ssl, locations, schemes)
|
||||
# Try connecting to all known resource connections in parallel, but
|
||||
# only return the first server (in order) that provides a response.
|
||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||
listargs = [[cls, url, self.accessToken, timeout] for url in connections]
|
||||
listargs = [[cls, url, self.accessToken, self._server._session, timeout] for url in connections]
|
||||
log.debug('Testing %s resource connections..', len(listargs))
|
||||
results = utils.threaded(_connect, listargs)
|
||||
return _chooseConnection('Resource', self.name, results)
|
||||
|
@ -1392,24 +1506,27 @@ class ResourceConnection(PlexObject):
|
|||
|
||||
Attributes:
|
||||
TAG (str): 'Connection'
|
||||
address (str): Local IP address
|
||||
httpuri (str): Full local address
|
||||
local (bool): True if local
|
||||
port (int): 32400
|
||||
address (str): The connection IP address
|
||||
httpuri (str): Full HTTP URL
|
||||
ipv6 (bool): True if the address is IPv6
|
||||
local (bool): True if the address is local
|
||||
port (int): The connection port
|
||||
protocol (str): HTTP or HTTPS
|
||||
uri (str): External address
|
||||
relay (bool): True if the address uses the Plex Relay
|
||||
uri (str): Full connetion URL
|
||||
"""
|
||||
TAG = 'Connection'
|
||||
TAG = 'connection'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.address = data.attrib.get('address')
|
||||
self.port = utils.cast(int, data.attrib.get('port'))
|
||||
self.uri = data.attrib.get('uri')
|
||||
self.ipv6 = utils.cast(bool, data.attrib.get('IPv6'))
|
||||
self.local = utils.cast(bool, data.attrib.get('local'))
|
||||
self.httpuri = f'http://{self.address}:{self.port}'
|
||||
self.port = utils.cast(int, data.attrib.get('port'))
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.relay = utils.cast(bool, data.attrib.get('relay'))
|
||||
self.uri = data.attrib.get('uri')
|
||||
self.httpuri = f'http://{self.address}:{self.port}'
|
||||
|
||||
|
||||
class MyPlexDevice(PlexObject):
|
||||
|
@ -1475,7 +1592,7 @@ class MyPlexDevice(PlexObject):
|
|||
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
|
||||
"""
|
||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||
listargs = [[cls, url, self.token, timeout] for url in self.connections]
|
||||
listargs = [[cls, url, self.token, self._server._session, timeout] for url in self.connections]
|
||||
log.debug('Testing %s device connections..', len(listargs))
|
||||
results = utils.threaded(_connect, listargs)
|
||||
return _chooseConnection('Device', self.name, results)
|
||||
|
@ -1725,7 +1842,7 @@ class MyPlexPinLogin:
|
|||
return ElementTree.fromstring(data) if data.strip() else None
|
||||
|
||||
|
||||
def _connect(cls, url, token, timeout, results, i, job_is_done_event=None):
|
||||
def _connect(cls, url, token, session, timeout, results, i, job_is_done_event=None):
|
||||
""" Connects to the specified cls with url and token. Stores the connection
|
||||
information to results[i] in a threadsafe way.
|
||||
|
||||
|
@ -1733,6 +1850,7 @@ def _connect(cls, url, token, timeout, results, i, job_is_done_event=None):
|
|||
cls: the class which is responsible for establishing connection, basically it's
|
||||
:class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer`
|
||||
url (str): url which should be passed as `baseurl` argument to cls.__init__()
|
||||
session (requests.Session): session which sould be passed as `session` argument to cls.__init()
|
||||
token (str): authentication token which should be passed as `baseurl` argument to cls.__init__()
|
||||
timeout (int): timeout which should be passed as `baseurl` argument to cls.__init__()
|
||||
results (list): pre-filled list for results
|
||||
|
@ -1742,7 +1860,7 @@ def _connect(cls, url, token, timeout, results, i, job_is_done_event=None):
|
|||
"""
|
||||
starttime = time.time()
|
||||
try:
|
||||
device = cls(baseurl=url, token=token, timeout=timeout)
|
||||
device = cls(baseurl=url, token=token, session=session, timeout=timeout)
|
||||
runtime = int(time.time() - starttime)
|
||||
results[i] = (url, token, device, runtime)
|
||||
if X_PLEX_ENABLE_FAST_CONNECT and job_is_done_event:
|
||||
|
|
|
@ -8,8 +8,7 @@ from plexapi.exceptions import BadRequest
|
|||
from plexapi.mixins import (
|
||||
RatingMixin,
|
||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin,
|
||||
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, PhotoCapturedTimeMixin,
|
||||
TagMixin
|
||||
PhotoalbumEditMixins, PhotoEditMixins
|
||||
)
|
||||
|
||||
|
||||
|
@ -18,7 +17,7 @@ class Photoalbum(
|
|||
PlexPartialObject,
|
||||
RatingMixin,
|
||||
ArtMixin, PosterMixin,
|
||||
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin
|
||||
PhotoalbumEditMixins
|
||||
):
|
||||
""" Represents a single Photoalbum (collection of photos).
|
||||
|
||||
|
@ -146,8 +145,7 @@ class Photo(
|
|||
PlexPartialObject, Playable,
|
||||
RatingMixin,
|
||||
ArtUrlMixin, PosterUrlMixin,
|
||||
AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
||||
TagMixin
|
||||
PhotoEditMixins
|
||||
):
|
||||
""" Represents a single Photo.
|
||||
|
||||
|
|
|
@ -433,7 +433,8 @@ class Playlist(
|
|||
""" Copy playlist to another user account.
|
||||
|
||||
Parameters:
|
||||
user (str): Username, email or user id of the user to copy the playlist to.
|
||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
|
||||
email, or user id of the user to copy the playlist to.
|
||||
"""
|
||||
userServer = self._server.switchUser(user)
|
||||
return self.create(server=userServer, title=self.title, items=self.items())
|
||||
|
|
|
@ -170,7 +170,7 @@ class PlayQueue(PlexObject):
|
|||
}
|
||||
|
||||
if isinstance(items, list):
|
||||
item_keys = ",".join([str(x.ratingKey) for x in items])
|
||||
item_keys = ",".join(str(x.ratingKey) for x in items)
|
||||
uri_args = quote_plus(f"/library/metadata/{item_keys}")
|
||||
args["uri"] = f"library:///directory/{uri_args}"
|
||||
args["type"] = items[0].listType
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from functools import cached_property
|
||||
from urllib.parse import urlencode
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
import os
|
||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log,
|
||||
logfilter)
|
||||
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter
|
||||
from plexapi import utils
|
||||
from plexapi.alert import AlertListener
|
||||
from plexapi.base import PlexObject
|
||||
|
@ -17,7 +18,7 @@ from plexapi.media import Conversion, Optimized
|
|||
from plexapi.playlist import Playlist
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from plexapi.settings import Settings
|
||||
from plexapi.utils import cached_property, deprecated
|
||||
from plexapi.utils import deprecated
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
# Need these imports to populate utils.PLEXOBJECTS
|
||||
|
@ -236,12 +237,13 @@ class PlexServer(PlexObject):
|
|||
q = self.query(f'/security/token?type={type}&scope={scope}')
|
||||
return q.attrib.get('token')
|
||||
|
||||
def switchUser(self, username, session=None, timeout=None):
|
||||
def switchUser(self, user, session=None, timeout=None):
|
||||
""" Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username.
|
||||
Note: Only the admin account can switch to other users.
|
||||
|
||||
Parameters:
|
||||
username (str): Username, email or user id of the user to log in to the server.
|
||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
|
||||
email, or user id of the user to log in to the server.
|
||||
session (requests.Session, optional): Use your own session object if you want to
|
||||
cache the http responses from the server. This will default to the same
|
||||
session as the admin account if no new session is provided.
|
||||
|
@ -260,7 +262,8 @@ class PlexServer(PlexObject):
|
|||
userPlex = plex.switchUser("Username")
|
||||
|
||||
"""
|
||||
user = self.myPlexAccount().user(username)
|
||||
from plexapi.myplex import MyPlexUser
|
||||
user = user if isinstance(user, MyPlexUser) else self.myPlexAccount().user(user)
|
||||
userToken = user.get_token(self.machineIdentifier)
|
||||
if session is None:
|
||||
session = self._session
|
||||
|
@ -470,6 +473,7 @@ class PlexServer(PlexObject):
|
|||
sort="episode.originallyAvailableAt:desc",
|
||||
filters={"episode.originallyAvailableAt>>": "4w", "genre": "comedy"}
|
||||
)
|
||||
|
||||
"""
|
||||
return Collection.create(
|
||||
self, title, section, items=items, smart=smart, limit=limit,
|
||||
|
@ -535,6 +539,7 @@ class PlexServer(PlexObject):
|
|||
section="Music",
|
||||
m3ufilepath="/path/to/playlist.m3u"
|
||||
)
|
||||
|
||||
"""
|
||||
return Playlist.create(
|
||||
self, title, section=section, items=items, smart=smart, limit=limit,
|
||||
|
@ -549,26 +554,28 @@ class PlexServer(PlexObject):
|
|||
"""
|
||||
return PlayQueue.create(self, item, **kwargs)
|
||||
|
||||
def downloadDatabases(self, savepath=None, unpack=False):
|
||||
def downloadDatabases(self, savepath=None, unpack=False, showstatus=False):
|
||||
""" Download databases.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
unpack (bool): Unpack the zip file.
|
||||
showstatus(bool): Display a progressbar.
|
||||
"""
|
||||
url = self.url('/diagnostics/databases')
|
||||
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack)
|
||||
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack, showstatus=showstatus)
|
||||
return filepath
|
||||
|
||||
def downloadLogs(self, savepath=None, unpack=False):
|
||||
def downloadLogs(self, savepath=None, unpack=False, showstatus=False):
|
||||
""" Download server logs.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
unpack (bool): Unpack the zip file.
|
||||
showstatus(bool): Display a progressbar.
|
||||
"""
|
||||
url = self.url('/diagnostics/logs')
|
||||
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack)
|
||||
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack, showstatus=showstatus)
|
||||
return filepath
|
||||
|
||||
def butlerTasks(self):
|
||||
|
@ -588,6 +595,7 @@ class PlexServer(PlexObject):
|
|||
|
||||
availableTasks = [task.name for task in plex.butlerTasks()]
|
||||
print("Available butler tasks:", availableTasks)
|
||||
|
||||
"""
|
||||
validTasks = [task.name for task in self.butlerTasks()]
|
||||
if task not in validTasks:
|
||||
|
@ -630,7 +638,7 @@ class PlexServer(PlexObject):
|
|||
# figure out what method this is..
|
||||
return self.query(part, method=self._session.put)
|
||||
|
||||
def history(self, maxresults=9999999, mindate=None, ratingKey=None, accountID=None, librarySectionID=None):
|
||||
def history(self, maxresults=None, mindate=None, ratingKey=None, accountID=None, librarySectionID=None):
|
||||
""" Returns a list of media items from watched history. If there are many results, they will
|
||||
be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only
|
||||
looking for the first <num> results, it would be wise to set the maxresults option to that
|
||||
|
@ -644,7 +652,6 @@ class PlexServer(PlexObject):
|
|||
accountID (int/str) Request history for a specific account ID.
|
||||
librarySectionID (int/str) Request history for a specific library section ID.
|
||||
"""
|
||||
results, subresults = [], '_init'
|
||||
args = {'sort': 'viewedAt:desc'}
|
||||
if ratingKey:
|
||||
args['metadataItemID'] = ratingKey
|
||||
|
@ -654,14 +661,9 @@ class PlexServer(PlexObject):
|
|||
args['librarySectionID'] = librarySectionID
|
||||
if mindate:
|
||||
args['viewedAt>'] = int(mindate.timestamp())
|
||||
args['X-Plex-Container-Start'] = 0
|
||||
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
||||
while subresults and maxresults > len(results):
|
||||
|
||||
key = f'/status/sessions/history/all{utils.joinArgs(args)}'
|
||||
subresults = self.fetchItems(key)
|
||||
results += subresults[:maxresults - len(results)]
|
||||
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
|
||||
return results
|
||||
return self.fetchItems(key, maxresults=maxresults)
|
||||
|
||||
def playlists(self, playlistType=None, sectionId=None, title=None, sort=None, **kwargs):
|
||||
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server.
|
||||
|
@ -794,6 +796,10 @@ class PlexServer(PlexObject):
|
|||
results += hub.items
|
||||
return results
|
||||
|
||||
def continueWatching(self):
|
||||
""" Return a list of all items in the Continue Watching hub. """
|
||||
return self.fetchItems('/hubs/continueWatching/items')
|
||||
|
||||
def sessions(self):
|
||||
""" Returns a list of all active session (currently playing) media objects. """
|
||||
return self.fetchItems('/status/sessions')
|
||||
|
|
|
@ -81,7 +81,7 @@ class Settings(PlexObject):
|
|||
params[setting.id] = quote(setting._setValue)
|
||||
if not params:
|
||||
raise BadRequest('No setting have been modified.')
|
||||
querystr = '&'.join([f'{k}={v}' for k, v in params.items()])
|
||||
querystr = '&'.join(f'{k}={v}' for k, v in params.items())
|
||||
url = f'{self.key}?{querystr}'
|
||||
self._server.query(url, self._server._session.put)
|
||||
self.reload()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import requests
|
||||
|
||||
from plexapi import CONFIG, X_PLEX_IDENTIFIER
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.exceptions import BadRequest
|
||||
|
|
|
@ -15,20 +15,17 @@ from datetime import datetime
|
|||
from getpass import getpass
|
||||
from threading import Event, Thread
|
||||
from urllib.parse import quote
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
import requests
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
except ImportError:
|
||||
tqdm = None
|
||||
|
||||
try:
|
||||
from functools import cached_property
|
||||
except ImportError:
|
||||
from backports.cached_property import cached_property # noqa: F401
|
||||
|
||||
log = logging.getLogger('plexapi')
|
||||
|
||||
# Search Types - Plex uses these to filter specific media types when searching.
|
||||
|
@ -106,7 +103,7 @@ class SecretsFilter(logging.Filter):
|
|||
self.secrets = secrets or set()
|
||||
|
||||
def add_secret(self, secret):
|
||||
if secret is not None:
|
||||
if secret is not None and secret != '':
|
||||
self.secrets.add(secret)
|
||||
return secret
|
||||
|
||||
|
@ -128,7 +125,9 @@ def registerPlexObject(cls):
|
|||
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
|
||||
ehash = f'{cls.TAG}.{etype}' if etype else cls.TAG
|
||||
if getattr(cls, '_SESSIONTYPE', None):
|
||||
ehash = f"{ehash}.{'session'}"
|
||||
ehash = f"{ehash}.session"
|
||||
elif getattr(cls, '_HISTORYTYPE', None):
|
||||
ehash = f"{ehash}.history"
|
||||
if ehash in PLEXOBJECTS:
|
||||
raise Exception(f'Ambiguous PlexObject definition {cls.__name__}(tag={cls.TAG}, type={etype}) '
|
||||
f'with {PLEXOBJECTS[ehash].__name__}')
|
||||
|
@ -391,12 +390,12 @@ def downloadSessionImages(server, filename=None, height=150, width=150,
|
|||
prettyname = media._prettyfilename()
|
||||
filename = f'session_transcode_{media.usernames[0]}_{prettyname}_{int(time.time())}'
|
||||
url = server.transcodeImage(url, height, width, opacity, saturation)
|
||||
filepath = download(url, filename=filename)
|
||||
filepath = download(url, server._token, filename=filename)
|
||||
info['username'] = {'filepath': filepath, 'url': url}
|
||||
return info
|
||||
|
||||
|
||||
def download(url, token, filename=None, savepath=None, session=None, chunksize=4024,
|
||||
def download(url, token, filename=None, savepath=None, session=None, chunksize=4024, # noqa: C901
|
||||
unpack=False, mocked=False, showstatus=False):
|
||||
""" Helper to download a thumb, videofile or other media item. Returns the local
|
||||
path to the downloaded file.
|
||||
|
@ -419,6 +418,17 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4
|
|||
session = session or requests.Session()
|
||||
headers = {'X-Plex-Token': token}
|
||||
response = session.get(url, headers=headers, stream=True)
|
||||
if response.status_code not in (200, 201, 204):
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
message = f'({response.status_code}) {codename}; {response.url} {errtext}'
|
||||
if response.status_code == 401:
|
||||
raise Unauthorized(message)
|
||||
elif response.status_code == 404:
|
||||
raise NotFound(message)
|
||||
else:
|
||||
raise BadRequest(message)
|
||||
|
||||
# make sure the savepath directory exists
|
||||
savepath = savepath or os.getcwd()
|
||||
os.makedirs(savepath, exist_ok=True)
|
||||
|
|
|
@ -3,19 +3,17 @@ import os
|
|||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject, PlexSession
|
||||
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.mixins import (
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
||||
ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
|
||||
AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin,
|
||||
StudioMixin, SummaryMixin, TaglineMixin, TitleMixin,
|
||||
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
|
||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
|
||||
MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins,
|
||||
WatchlistMixin
|
||||
)
|
||||
|
||||
|
||||
class Video(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin):
|
||||
class Video(PlexPartialObject, PlayedUnplayedMixin):
|
||||
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
||||
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
|
||||
|
@ -309,9 +307,7 @@ class Movie(
|
|||
Video, Playable,
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
||||
SummaryMixin, TaglineMixin, TitleMixin,
|
||||
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
|
||||
MovieEditMixins,
|
||||
WatchlistMixin
|
||||
):
|
||||
""" Represents a single Movie.
|
||||
|
@ -330,6 +326,7 @@ class Movie(
|
|||
duration (int): Duration of the movie in milliseconds.
|
||||
editionTitle (str): The edition title of the movie (e.g. Director's Cut, Extended Edition, etc.).
|
||||
enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled.
|
||||
(-1 = Library default, 0 = Disabled)
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
|
@ -441,15 +438,20 @@ class Movie(
|
|||
}
|
||||
return self.section().search(filters=filters)
|
||||
|
||||
def removeFromContinueWatching(self):
|
||||
""" Remove the movie from continue watching. """
|
||||
key = '/actions/removeFromContinueWatching'
|
||||
params = {'ratingKey': self.ratingKey}
|
||||
self._server.query(key, params=params, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Show(
|
||||
Video,
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, BannerMixin, PosterMixin, ThemeMixin,
|
||||
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
||||
SummaryMixin, TaglineMixin, TitleMixin,
|
||||
CollectionMixin, GenreMixin, LabelMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
ShowEditMixins,
|
||||
WatchlistMixin
|
||||
):
|
||||
""" Represents a single Show (including all seasons and episodes).
|
||||
|
@ -467,12 +469,12 @@ class Show(
|
|||
autoDeletionItemPolicyWatchedLibrary (int): Setting that indicates if episodes are deleted
|
||||
after being watched for the show (0 = Never, 1 = After a day, 7 = After a week,
|
||||
100 = On next refresh).
|
||||
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/banner/<bannerid>).
|
||||
childCount (int): Number of seasons (including Specials) in the show.
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
duration (int): Typical duration of the show episodes in milliseconds.
|
||||
enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled.
|
||||
(-1 = Library default, 0 = Disabled).
|
||||
episodeSort (int): Setting that indicates how episodes are sorted for the show
|
||||
(-1 = Library default, 0 = Oldest first, 1 = Newest first).
|
||||
flattenSeasons (int): Setting that indicates if seasons are set to hidden for the show
|
||||
|
@ -494,7 +496,8 @@ class Show(
|
|||
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||
seasonCount (int): Number of seasons (excluding Specials) in the show.
|
||||
showOrdering (str): Setting that indicates the episode ordering for the show
|
||||
(None = Library default).
|
||||
(None = Library default, tmdbAiring = The Movie Database (Aired),
|
||||
aired = TheTVDB (Aired), dvd = TheTVDB (DVD), absolute = TheTVDB (Absolute)).
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
||||
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||
subtitleLanguage (str): Setting that indicates the preferred subtitle language.
|
||||
|
@ -521,7 +524,6 @@ class Show(
|
|||
int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0'))
|
||||
self.autoDeletionItemPolicyWatchedLibrary = utils.cast(
|
||||
int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0'))
|
||||
self.banner = data.attrib.get('banner')
|
||||
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
|
@ -659,8 +661,7 @@ class Season(
|
|||
Video,
|
||||
AdvancedSettingsMixin, ExtrasMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||
SummaryMixin, TitleMixin,
|
||||
CollectionMixin, LabelMixin
|
||||
SeasonEditMixins
|
||||
):
|
||||
""" Represents a single Show Season (including all episodes).
|
||||
|
||||
|
@ -740,10 +741,12 @@ class Season(
|
|||
""" Returns the season number. """
|
||||
return self.index
|
||||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Episode, **kwargs)
|
||||
def onDeck(self):
|
||||
""" Returns season's On Deck :class:`~plexapi.video.Video` object or `None`.
|
||||
Will only return a match if the show's On Deck episode is in this season.
|
||||
"""
|
||||
data = self._server.query(self._details_key)
|
||||
return next(iter(self.findItems(data, rtag='OnDeck')), None)
|
||||
|
||||
def episode(self, title=None, episode=None):
|
||||
""" Returns the episode with the given title or number.
|
||||
|
@ -766,17 +769,15 @@ class Season(
|
|||
return self.fetchItem(key, Episode, parentIndex=self.index, index=index)
|
||||
raise BadRequest('Missing argument: title or episode is required')
|
||||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Episode, **kwargs)
|
||||
|
||||
def get(self, title=None, episode=None):
|
||||
""" Alias to :func:`~plexapi.video.Season.episode`. """
|
||||
return self.episode(title, episode)
|
||||
|
||||
def onDeck(self):
|
||||
""" Returns season's On Deck :class:`~plexapi.video.Video` object or `None`.
|
||||
Will only return a match if the show's On Deck episode is in this season.
|
||||
"""
|
||||
data = self._server.query(self._details_key)
|
||||
return next(iter(self.findItems(data, rtag='OnDeck')), None)
|
||||
|
||||
def show(self):
|
||||
""" Return the season's :class:`~plexapi.video.Show`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
@ -813,8 +814,7 @@ class Episode(
|
|||
Video, Playable,
|
||||
ExtrasMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||
ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
||||
CollectionMixin, DirectorMixin, LabelMixin, WriterMixin
|
||||
EpisodeEditMixins
|
||||
):
|
||||
""" Represents a single Shows Episode.
|
||||
|
||||
|
@ -906,7 +906,7 @@ class Episode(
|
|||
|
||||
# If seasons are hidden, parentKey and parentRatingKey are missing from the XML response.
|
||||
# https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553
|
||||
if self.skipParent and not self.parentRatingKey:
|
||||
if self.skipParent and data.attrib.get('parentRatingKey') is None:
|
||||
# Parse the parentRatingKey from the parentThumb
|
||||
if self.parentThumb and self.parentThumb.startswith('/library/metadata/'):
|
||||
self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3])
|
||||
|
@ -993,6 +993,13 @@ class Episode(
|
|||
""" Returns str, default title for a new syncItem. """
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - ({self.seasonEpisode}) {self.title}'
|
||||
|
||||
def removeFromContinueWatching(self):
|
||||
""" Remove the movie from continue watching. """
|
||||
key = '/actions/removeFromContinueWatching'
|
||||
params = {'ratingKey': self.ratingKey}
|
||||
self._server.query(key, params=params, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Clip(
|
||||
|
@ -1105,3 +1112,42 @@ class ClipSession(PlexSession, Clip):
|
|||
""" Load attribute values from Plex XML response. """
|
||||
Clip._loadData(self, data)
|
||||
PlexSession._loadData(self, data)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class MovieHistory(PlexHistory, Movie):
|
||||
""" Represents a single Movie history entry
|
||||
loaded from :func:`~plexapi.server.PlexServer.history`.
|
||||
"""
|
||||
_HISTORYTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Movie._loadData(self, data)
|
||||
PlexHistory._loadData(self, data)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class EpisodeHistory(PlexHistory, Episode):
|
||||
""" Represents a single Episode history entry
|
||||
loaded from :func:`~plexapi.server.PlexServer.history`.
|
||||
"""
|
||||
_HISTORYTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Episode._loadData(self, data)
|
||||
PlexHistory._loadData(self, data)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class ClipHistory(PlexHistory, Clip):
|
||||
""" Represents a single Clip history entry
|
||||
loaded from :func:`~plexapi.server.PlexServer.history`.
|
||||
"""
|
||||
_HISTORYTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Clip._loadData(self, data)
|
||||
PlexHistory._loadData(self, data)
|
||||
|
|
|
@ -28,7 +28,7 @@ MarkupSafe==2.1.3
|
|||
musicbrainzngs==0.7.1
|
||||
packaging==23.1
|
||||
paho-mqtt==1.6.1
|
||||
plexapi==4.13.4
|
||||
plexapi==4.15.0
|
||||
portend==3.2.0
|
||||
profilehooks==1.12.0
|
||||
PyJWT==2.8.0
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue