Bump plexapi from 4.13.4 to 4.15.0 (#2132)

* Bump plexapi from 4.13.4 to 4.15.0

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

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

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

* Update plexapi==4.15.0

---------

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -170,7 +170,7 @@ class PlayQueue(PlexObject):
} }
if isinstance(items, list): if isinstance(items, list):
item_keys = ",".join([str(x.ratingKey) for x in items]) item_keys = ",".join(str(x.ratingKey) for x in items)
uri_args = quote_plus(f"/library/metadata/{item_keys}") uri_args = quote_plus(f"/library/metadata/{item_keys}")
args["uri"] = f"library:///directory/{uri_args}" args["uri"] = f"library:///directory/{uri_args}"
args["type"] = items[0].listType args["type"] = items[0].listType

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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