Update plexapi to 4.3.0

This commit is contained in:
JonnyWong16 2021-01-24 19:53:39 -08:00
parent 6b013da697
commit f497c11d73
No known key found for this signature in database
GPG key ID: B1F1F9807184697A
18 changed files with 2365 additions and 1061 deletions

View file

@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH)
# PlexAPI Settings
PROJECT = 'PlexAPI'
VERSION = '3.6.0'
VERSION = '4.3.0'
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)

View file

@ -1,31 +1,39 @@
# -*- coding: utf-8 -*-
from plexapi import media, utils
from urllib.parse import quote_plus
from plexapi import library, media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.compat import quote_plus
from plexapi.exceptions import BadRequest
class Audio(PlexPartialObject):
""" Base class for audio :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`
and :class:`~plexapi.audio.Track` objects.
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
Attributes:
addedAt (datetime): Datetime this item was added to the library.
art (str): URL to artwork image.
addedAt (datetime): Datetime the item was added to the library.
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
artBlurHash (str): BlurHash string for artwork image.
index (sting): Index Number (often the track number).
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c).
index (int): Plex index number (often the track number).
key (str): API URL (/library/metadata/<ratingkey>).
lastViewedAt (datetime): Datetime item was last accessed.
lastViewedAt (datetime): Datetime the item was last played.
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
listType (str): Hardcoded as 'audio' (useful for search filters).
ratingKey (int): Unique key identifying this item.
summary (str): Summary of the artist, track, or album.
thumb (str): URL to thumbnail image.
moods (List<:class:`~plexapi.media.Mood`>): List of mood objects.
ratingKey (int): Unique key identifying the item.
summary (str): Summary of the artist, album, or track.
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
thumbBlurHash (str): BlurHash string for thumbnail image.
title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.)
title (str): Name of the artist, album, or track (Jason Mraz, We Sing, Lucky, etc.).
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'artist', 'album', or 'track'.
updatedAt (datatime): Datetime this item was updated.
viewCount (int): Count of times this item was accessed.
updatedAt (datatime): Datetime the item was updated.
userRating (float): Rating of the track (0.0 - 10.0) equaling (0 stars - 5 stars).
viewCount (int): Count of times the item was played.
"""
METADATA_TYPE = 'track'
@ -33,16 +41,19 @@ class Audio(PlexPartialObject):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.listType = 'audio'
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash')
self.index = data.attrib.get('index')
self.key = data.attrib.get('key')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '')
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'audio'
self.moods = self.findItems(data, media.Mood)
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
@ -51,6 +62,7 @@ class Audio(PlexPartialObject):
self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.userRating = utils.cast(float, data.attrib.get('userRating', 0))
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
@property
@ -66,7 +78,7 @@ class Audio(PlexPartialObject):
return self._server.url(art, includeToken=True) if art else None
def url(self, part):
""" Returns the full URL for this audio item. Typically used for getting a specific track. """
""" Returns the full URL for the audio item. Typically used for getting a specific track. """
return self._server.url(part, includeToken=True) if part else None
def _defaultSyncTitle(self):
@ -112,17 +124,18 @@ class Audio(PlexPartialObject):
@utils.registerPlexObject
class Artist(Audio):
""" Represents a single audio artist.
""" Represents a single Artist.
Attributes:
TAG (str): 'Directory'
TYPE (str): 'artist'
countries (list): List of :class:`~plexapi.media.Country` objects this artist respresents.
genres (list): List of :class:`~plexapi.media.Genre` objects this artist respresents.
guid (str): Unknown (unique ID; com.plexapp.agents.plexmusic://gracenote/artist/05517B8701668D28?lang=en)
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
countries (List<:class:`~plexapi.media.Country`>): List country objects.
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
key (str): API URL (/library/metadata/<ratingkey>).
location (str): Filepath this artist is found on disk.
similar (list): List of :class:`~plexapi.media.Similar` artists.
locations (List<str>): List of folder paths where the artist is found on disk.
similar (List<:class:`~plexapi.media.Similar`>): List of similar objects.
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
"""
TAG = 'Directory'
TYPE = 'artist'
@ -130,55 +143,70 @@ class Artist(Audio):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Audio._loadData(self, data)
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.guid = data.attrib.get('guid')
self.locations = self.listAttrs(data, 'path', etag='Location')
self.countries = self.findItems(data, media.Country)
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.similar = self.findItems(data, media.Similar)
self.collections = self.findItems(data, media.Collection)
self.moods = self.findItems(data, media.Mood)
self.countries = self.findItems(data, media.Country)
self.genres = self.findItems(data, media.Genre)
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.locations = self.listAttrs(data, 'path', etag='Location')
self.similar = self.findItems(data, media.Similar)
self.styles = self.findItems(data, media.Style)
def __iter__(self):
for album in self.albums():
yield album
def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
data = self._server.query(self._details_key)
directory = data.find('Directory')
if directory:
related = directory.find('Related')
if related:
return self.findItems(related, library.Hub)
def album(self, title):
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
Parameters:
title (str): Title of the album to return.
"""
key = '%s/children' % self.key
return self.fetchItem(key, title__iexact=title)
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItem(key, Album, title__iexact=title)
def albums(self, **kwargs):
""" Returns a list of :class:`~plexapi.audio.Album` objects by this artist. """
key = '%s/children' % self.key
return self.fetchItems(key, **kwargs)
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, Album, **kwargs)
def track(self, title):
def track(self, title=None, album=None, track=None):
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
Parameters:
title (str): Title of the track to return.
album (str): Album name (default: None; required if title not specified).
track (int): Track number (default: None; required if title not specified).
Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing.
"""
key = '%s/allLeaves' % self.key
return self.fetchItem(key, title__iexact=title)
key = '/library/metadata/%s/allLeaves' % self.ratingKey
if title is not None:
return self.fetchItem(key, Track, title__iexact=title)
elif album is not None and track is not None:
return self.fetchItem(key, Track, parentTitle__iexact=album, index=track)
raise BadRequest('Missing argument: title or album and track are required')
def tracks(self, **kwargs):
""" Returns a list of :class:`~plexapi.audio.Track` objects by this artist. """
key = '%s/allLeaves' % self.key
return self.fetchItems(key, **kwargs)
""" Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """
key = '/library/metadata/%s/allLeaves' % self.ratingKey
return self.fetchItems(key, Track, **kwargs)
def get(self, title):
def get(self, title=None, album=None, track=None):
""" Alias of :func:`~plexapi.audio.Artist.track`. """
return self.track(title)
return self.track(title, album, track)
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads all tracks for this artist to the specified location.
""" Downloads all tracks for the artist to the specified location.
Parameters:
savepath (str): Title of the track to return.
@ -199,76 +227,89 @@ class Artist(Audio):
@utils.registerPlexObject
class Album(Audio):
""" Represents a single audio album.
""" Represents a single Album.
Attributes:
TAG (str): 'Directory'
TYPE (str): 'album'
genres (list): List of :class:`~plexapi.media.Genre` objects this album respresents.
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
key (str): API URL (/library/metadata/<ratingkey>).
originallyAvailableAt (datetime): Datetime this album was released.
parentKey (str): API URL of this artist.
parentRatingKey (int): Unique key identifying artist.
parentThumb (str): URL to artist thumbnail image.
parentTitle (str): Name of the artist for this album.
studio (str): Studio that released this album.
year (int): Year this album was released.
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
leafCount (int): Number of items in the album view.
loudnessAnalysisVersion (int): The Plex loudness analysis version level.
originallyAvailableAt (datetime): Datetime the album was released.
parentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
parentKey (str): API URL of the album artist (/library/metadata/<parentRatingKey>).
parentRatingKey (int): Unique key identifying the album artist.
parentThumb (str): URL to album artist thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the album artist.
rating (float): Album rating (7.9; 9.8; 8.1).
studio (str): Studio that released the album.
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
viewedLeafCount (int): Number of items marked as played in the album view.
year (int): Year the album was released.
"""
TAG = 'Directory'
TYPE = 'album'
def __iter__(self):
for track in self.tracks:
yield track
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Audio._loadData(self, data)
self.guid = data.attrib.get('guid')
self.collections = self.findItems(data, media.Collection)
self.genres = self.findItems(data, media.Genre)
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.rating = utils.cast(float, data.attrib.get('rating'))
self.studio = data.attrib.get('studio')
self.styles = self.findItems(data, media.Style)
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year'))
self.collections = self.findItems(data, media.Collection)
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.labels = self.findItems(data, media.Label)
self.moods = self.findItems(data, media.Mood)
self.styles = self.findItems(data, media.Style)
def track(self, title):
def __iter__(self):
for track in self.tracks():
yield track
def track(self, title=None, track=None):
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
Parameters:
title (str): Title of the track to return.
track (int): Track number (default: None; required if title not specified).
Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing.
"""
key = '%s/children' % self.key
return self.fetchItem(key, title__iexact=title)
key = '/library/metadata/%s/children' % self.ratingKey
if title is not None:
return self.fetchItem(key, Track, title__iexact=title)
elif track is not None:
return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=track)
raise BadRequest('Missing argument: title or track is required')
def tracks(self, **kwargs):
""" Returns a list of :class:`~plexapi.audio.Track` objects in this album. """
key = '%s/children' % self.key
return self.fetchItems(key, **kwargs)
""" Returns a list of :class:`~plexapi.audio.Track` objects in the album. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, Track, **kwargs)
def get(self, title):
def get(self, title=None, track=None):
""" Alias of :func:`~plexapi.audio.Album.track`. """
return self.track(title)
return self.track(title, track)
def artist(self):
""" Return :func:`~plexapi.audio.Artist` of this album. """
""" Return the album's :class:`~plexapi.audio.Artist`. """
return self.fetchItem(self.parentKey)
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads all tracks for this artist to the specified location.
""" Downloads all tracks for the artist to the specified location.
Parameters:
savepath (str): Title of the track to return.
@ -292,37 +333,32 @@ class Album(Audio):
@utils.registerPlexObject
class Track(Audio, Playable):
""" Represents a single audio track.
""" Represents a single Track.
Attributes:
TAG (str): 'Directory'
TYPE (str): 'track'
chapterSource (TYPE): Unknown
duration (int): Length of this album in seconds.
grandparentArt (str): Album artist artwork.
grandparentKey (str): Album artist API URL.
grandparentRatingKey (str): Unique key identifying album artist.
grandparentThumb (str): URL to album artist thumbnail image.
grandparentTitle (str): Name of the album artist for this track.
guid (str): Unknown (unique ID).
media (list): List of :class:`~plexapi.media.Media` objects for this track.
moods (list): List of :class:`~plexapi.media.Mood` objects for this track.
originalTitle (str): Track artist.
chapterSource (str): Unknown
duration (int): Length of the track in milliseconds.
grandparentArt (str): URL to album artist artwork (/library/metadata/<grandparentRatingKey>/art/<artid>).
grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
grandparentKey (str): API URL of the album artist (/library/metadata/<grandparentRatingKey>).
grandparentRatingKey (int): Unique key identifying the album artist.
grandparentThumb (str): URL to album artist thumbnail image
(/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
grandparentTitle (str): Name of the album artist for the track.
media (List<:class:`~plexapi.media.Media`>): List of media objects.
originalTitle (str): The original title of the track (eg. a different language).
parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9).
parentIndex (int): Album index.
parentKey (str): Album API URL.
parentRatingKey (int): Unique key identifying album.
parentThumb (str): URL to album thumbnail image.
parentTitle (str): Name of the album for this track.
primaryExtraKey (str): Unknown
ratingCount (int): Unknown
userRating (float): Rating of this track (0.0 - 10.0) equaling (0 stars - 5 stars)
viewOffset (int): Unknown
year (int): Year this track was released.
sessionKey (int): Session Key (active sessions only).
usernames (str): Username of person playing this track (active sessions only).
player (str): :class:`~plexapi.client.PlexClient` for playing track (active sessions only).
transcodeSessions (None): :class:`~plexapi.media.TranscodeSession` for playing
track (active sessions only).
parentKey (str): API URL of the album (/library/metadata/<parentRatingKey>).
parentRatingKey (int): Unique key identifying the album.
parentThumb (str): URL to album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the album for the track.
primaryExtraKey (str) API URL for the primary extra for the track.
ratingCount (int): Number of ratings contributing to the rating score.
viewOffset (int): View offset in milliseconds.
year (int): Year the track was released.
"""
TAG = 'Track'
TYPE = 'track'
@ -336,42 +372,41 @@ class Track(Audio, Playable):
self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey')
self.grandparentRatingKey = data.attrib.get('grandparentRatingKey')
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guid = data.attrib.get('guid')
self.media = self.findItems(data, media.Media)
self.originalTitle = data.attrib.get('originalTitle')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
self.userRating = utils.cast(float, data.attrib.get('userRating', 0))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
self.media = self.findItems(data, media.Media)
self.moods = self.findItems(data, media.Mood)
self.fields = self.findItems(data, media.Field)
def _prettyfilename(self):
""" Returns a filename for use in download. """
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
def album(self):
""" Return this track's :class:`~plexapi.audio.Album`. """
""" Return the track's :class:`~plexapi.audio.Album`. """
return self.fetchItem(self.parentKey)
def artist(self):
""" Return this track's :class:`~plexapi.audio.Artist`. """
""" Return the track's :class:`~plexapi.audio.Artist`. """
return self.fetchItem(self.grandparentKey)
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Track
interface to get the locations of the track.
Retruns:
List<str> of file paths where the track is found on disk.
"""
return [part.file for part in self.iterParts() if part]

View file

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
import re
import weakref
from urllib.parse import quote_plus, urlencode
from plexapi import log, utils
from plexapi.compat import quote_plus, urlencode
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
from plexapi.utils import tag_helper
@ -35,15 +36,17 @@ class PlexObject(object):
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
data (ElementTree): Response from PlexServer used to build this object (optional).
initpath (str): Relative path requested when retrieving specified `data` (optional).
parent (:class:`~plexapi.base.PlexObject`): The parent object that this object is built from (optional).
"""
TAG = None # xml element tag
TYPE = None # xml element type
key = None # plex relative url
def __init__(self, server, data, initpath=None):
def __init__(self, server, data, initpath=None, parent=None):
self._server = server
self._data = data
self._initpath = initpath or self.key
self._parent = weakref.ref(parent) if parent else None
if data is not None:
self._loadData(data)
self._details_key = self._buildDetailsKey()
@ -54,8 +57,8 @@ class PlexObject(object):
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p])
def __setattr__(self, attr, value):
# dont overwrite an attr with None unless its a private variable
if value is not None or attr.startswith('_') or attr not in self.__dict__:
# Don't overwrite an attr with None or [] unless it's a private variable
if value not in [None, []] or attr.startswith('_') or attr not in self.__dict__:
self.__dict__[attr] = value
def _clean(self, value):
@ -63,6 +66,8 @@ class PlexObject(object):
if value:
value = str(value).replace('/library/metadata/', '')
value = value.replace('/children', '')
value = value.replace('/accounts/', '')
value = value.replace('/devices/', '')
return value.replace(' ', '-')[:20]
def _buildItem(self, elem, cls=None, initpath=None):
@ -70,9 +75,9 @@ class PlexObject(object):
# cls is specified, build the object and return
initpath = initpath or self._initpath
if cls is not None:
return cls(self._server, elem, initpath)
return cls(self._server, elem, initpath, parent=self)
# cls is not specified, try looking it up in PLEXOBJECTS
etype = elem.attrib.get('type', elem.attrib.get('streamType'))
etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type')))
ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
@ -95,7 +100,7 @@ class PlexObject(object):
or disable each parameter individually by setting it to False or 0.
"""
details_key = self.key
if hasattr(self, '_INCLUDES'):
if details_key and hasattr(self, '_INCLUDES'):
includes = {}
for k, v in self._INCLUDES.items():
value = kwargs.get(k, v)
@ -105,6 +110,21 @@ class PlexObject(object):
details_key += '?' + urlencode(sorted(includes.items()))
return details_key
def _isChildOf(self, **kwargs):
""" Returns True if this object is a child of the given attributes.
This will search the parent objects all the way to the top.
Parameters:
**kwargs (dict): The attributes and values to search for in the parent objects.
See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`.
"""
obj = self
while obj._parent is not None:
obj = obj._parent()
if obj._checkAttrs(obj._data, **kwargs):
return True
return False
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
@ -212,6 +232,7 @@ class PlexObject(object):
return value
def listAttrs(self, data, attr, **kwargs):
""" Return a list of values from matching attribute. """
results = []
for elem in data:
kwargs['%s__exists' % attr] = True
@ -350,7 +371,7 @@ class PlexPartialObject(PlexObject):
}
def __eq__(self, other):
return other is not None and self.key == other.key
return other not in [None, []] and self.key == other.key
def __hash__(self):
return hash(repr(self))
@ -391,6 +412,8 @@ class PlexPartialObject(PlexObject):
Playing screen to show a graphical representation of where playback
is. Video preview thumbnails creation is a CPU-intensive process akin
to transcoding the file.
* Generate intro video markers: Detects show intros, exposing the
'Skip Intro' button in clients.
"""
key = '/%s/analyze' % self.key.lstrip('/')
self._server.query(key, method=self._server._session.put)
@ -663,6 +686,7 @@ class Playable(object):
if item is being transcoded (None otherwise).
viewedAt (datetime): Datetime item was last viewed (history).
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items).
"""
def _loadData(self, data):
@ -674,6 +698,7 @@ class Playable(object):
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
self.accountID = utils.cast(int, data.attrib.get('accountID')) # history
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue
def getStreamURL(self, **params):
""" Returns a stream url that may be used by external applications such as VLC.
@ -684,7 +709,7 @@ class Playable(object):
offset, copyts, protocol, mediaIndex, platform.
Raises:
:exc:`plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
:exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
"""
if self.TYPE not in ('movie', 'episode', 'track'):
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
@ -698,7 +723,7 @@ class Playable(object):
'mediaIndex': params.get('mediaIndex', 0),
'X-Plex-Platform': params.get('platform', 'Chrome'),
'maxVideoBitrate': max(mvb, 64) if mvb else None,
'videoResolution': vr if re.match('^\d+x\d+$', vr) else None
'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None
}
# remove None values
params = {k: v for k, v in params.items() if v is not None}

View file

@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
import time
from xml.etree import ElementTree
import requests
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
from plexapi.base import PlexObject
from plexapi.compat import ElementTree
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
from plexapi.playqueue import PlayQueue
from requests.status_codes import _codes as codes
@ -69,7 +69,9 @@ class PlexClient(PlexObject):
self._proxyThroughServer = False
self._commandId = 0
self._last_call = 0
if not any([data, initpath, baseurl, token]):
self._timeline_cache = []
self._timeline_cache_timestamp = 0
if not any([data is not None, initpath, baseurl, token]):
self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433')
self._token = logfilter.add_secret(CONFIG.get('auth.client_token'))
if connect and self._baseurl:
@ -138,7 +140,7 @@ class PlexClient(PlexObject):
value (bool): Enable or disable proxying (optional, default True).
Raises:
:exc:`plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
:exc:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
"""
if server:
self._server = server
@ -181,7 +183,7 @@ class PlexClient(PlexObject):
**params (dict): Additional GET parameters to include with the command.
Raises:
:exc:`plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability.
:exc:`~plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability.
"""
command = command.strip('/')
controller = command.split('/')[0]
@ -195,10 +197,11 @@ class PlexClient(PlexObject):
# Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244
t = time.time()
if t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'):
url = '/player/timeline/poll?wait=0&commandID=%s' % self._nextCommandId()
query(url, headers=headers)
if command == 'timeline/poll':
self._last_call = t
elif t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'):
self._last_call = t
self.sendCommand(ClientTimeline.key, wait=0)
params['commandID'] = self._nextCommandId()
key = '/player/%s%s' % (command, utils.joinArgs(params))
@ -296,7 +299,7 @@ class PlexClient(PlexObject):
**params (dict): Additional GET parameters to include with the command.
Raises:
:exc:`plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
:exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
"""
if not self._server:
raise Unsupported('A server must be specified before using this command.')
@ -466,7 +469,7 @@ class PlexClient(PlexObject):
also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands
Raises:
:exc:`plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
:exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
"""
if not self._server:
raise Unsupported('A server must be specified before using this command.')
@ -485,15 +488,6 @@ class PlexClient(PlexObject):
if mediatype == "audio":
mediatype = "music"
if self.product != 'OpenPHT':
try:
self.sendCommand('timeline/subscribe', port=server_port, protocol='http')
except: # noqa: E722
# some clients dont need or like this and raises http 400.
# We want to include the exception in the log,
# but it might still work so we swallow it.
log.exception('%s failed to subscribe ' % self.title)
playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media)
self.sendCommand('playback/playMedia', **dict({
'machineIdentifier': self._server.machineIdentifier,
@ -548,20 +542,68 @@ class PlexClient(PlexObject):
# -------------------
# Timeline Commands
def timeline(self, wait=1):
""" Poll the current timeline and return the XML response. """
return self.sendCommand('timeline/poll', wait=wait)
def timelines(self, wait=0):
"""Poll the client's timelines, create, and return timeline objects.
Some clients may not always respond to timeline requests, believe this
to be a Plex bug.
"""
t = time.time()
if t - self._timeline_cache_timestamp > 1:
self._timeline_cache_timestamp = t
timelines = self.sendCommand(ClientTimeline.key, wait=wait) or []
self._timeline_cache = [ClientTimeline(self, data) for data in timelines]
def isPlayingMedia(self, includePaused=False):
return self._timeline_cache
@property
def timeline(self):
"""Returns the active timeline object."""
return next((x for x in self.timelines() if x.state != 'stopped'), None)
def isPlayingMedia(self, includePaused=True):
"""Returns True if any media is currently playing.
Parameters:
includePaused (bool): Set True to treat currently paused items
as playing (optional; default True).
"""
for mediatype in self.timeline(wait=0):
if mediatype.get('state') == 'playing':
return True
if includePaused and mediatype.get('state') == 'paused':
return True
return False
state = getattr(self.timeline, "state", None)
return bool(state == 'playing' or (includePaused and state == 'paused'))
class ClientTimeline(PlexObject):
"""Get the timeline's attributes."""
key = 'timeline/poll'
def _loadData(self, data):
self._data = data
self.address = data.attrib.get('address')
self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId'))
self.autoPlay = utils.cast(bool, data.attrib.get('autoPlay'))
self.containerKey = data.attrib.get('containerKey')
self.controllable = data.attrib.get('controllable')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.itemType = data.attrib.get('itemType')
self.key = data.attrib.get('key')
self.location = data.attrib.get('location')
self.machineIdentifier = data.attrib.get('machineIdentifier')
self.partCount = utils.cast(int, data.attrib.get('partCount'))
self.partIndex = utils.cast(int, data.attrib.get('partIndex'))
self.playQueueID = utils.cast(int, data.attrib.get('playQueueID'))
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID'))
self.playQueueVersion = utils.cast(int, data.attrib.get('playQueueVersion'))
self.port = utils.cast(int, data.attrib.get('port'))
self.protocol = data.attrib.get('protocol')
self.providerIdentifier = data.attrib.get('providerIdentifier')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.repeat = utils.cast(bool, data.attrib.get('repeat'))
self.seekRange = data.attrib.get('seekRange')
self.shuffle = utils.cast(bool, data.attrib.get('shuffle'))
self.state = data.attrib.get('state')
self.subtitleColor = data.attrib.get('subtitleColor')
self.subtitlePosition = data.attrib.get('subtitlePosition')
self.subtitleSize = utils.cast(int, data.attrib.get('subtitleSize'))
self.time = utils.cast(int, data.attrib.get('time'))
self.type = data.attrib.get('type')
self.volume = utils.cast(int, data.attrib.get('volume'))

View file

@ -1,118 +0,0 @@
# -*- coding: utf-8 -*-
# Python 2/3 compatability
# Always try Py3 first
import os
import sys
from sys import version_info
ustr = str
if version_info < (3,):
ustr = unicode
try:
string_type = basestring
except NameError:
string_type = str
try:
from urllib.parse import urlencode
except ImportError:
from urllib import urlencode
try:
from urllib.parse import quote
except ImportError:
from urllib import quote
try:
from urllib.parse import quote_plus, quote
except ImportError:
from urllib import quote_plus, quote
try:
from urllib.parse import unquote
except ImportError:
from urllib import unquote
try:
from configparser import ConfigParser
except ImportError:
from ConfigParser import ConfigParser
try:
from xml.etree import cElementTree as ElementTree
except ImportError:
from xml.etree import ElementTree
def makedirs(name, mode=0o777, exist_ok=False):
""" Mimicks os.makedirs() from Python 3. """
try:
os.makedirs(name, mode)
except OSError:
if not os.path.isdir(name) or not exist_ok:
raise
def which(cmd, mode=os.F_OK | os.X_OK, path=None):
"""Given a command, mode, and a PATH string, return the path which
conforms to the given mode on the PATH, or None if there is no such
file.
`mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result
of os.environ.get("PATH"), or can be overridden with a custom search
path.
Copied from https://hg.python.org/cpython/file/default/Lib/shutil.py
"""
# Check that a given file can be accessed with the correct mode.
# Additionally check that `file` is not a directory, as on Windows
# directories pass the os.access check.
def _access_check(fn, mode):
return (os.path.exists(fn) and os.access(fn, mode)
and not os.path.isdir(fn))
# If we're given a path with a directory part, look it up directly rather
# than referring to PATH directories. This includes checking relative to the
# current directory, e.g. ./script
if os.path.dirname(cmd):
if _access_check(cmd, mode):
return cmd
return None
if path is None:
path = os.environ.get("PATH", os.defpath)
if not path:
return None
path = path.split(os.pathsep)
if sys.platform == "win32":
# The current directory takes precedence on Windows.
if not os.curdir in path:
path.insert(0, os.curdir)
# PATHEXT is necessary to check on Windows.
pathext = os.environ.get("PATHEXT", "").split(os.pathsep)
# See if the given file matches any of the expected path extensions.
# This will allow us to short circuit when given "python.exe".
# If it does match, only test that one, otherwise we have to try
# others.
if any(cmd.lower().endswith(ext.lower()) for ext in pathext):
files = [cmd]
else:
files = [cmd + ext for ext in pathext]
else:
# On other platforms you don't have things like PATHEXT to tell you
# what file suffixes are executable, so just pass on cmd as-is.
files = [cmd]
seen = set()
for dir in path:
normdir = os.path.normcase(dir)
if not normdir in seen:
seen.add(normdir)
for thefile in files:
name = os.path.join(dir, thefile)
if _access_check(name, mode):
return name
return None

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
import os
from collections import defaultdict
from plexapi.compat import ConfigParser
from configparser import ConfigParser
class PlexConfig(ConfigParser):
@ -13,6 +13,7 @@ class PlexConfig(ConfigParser):
Parameters:
path (str): Path of the configuration file to load.
"""
def __init__(self, path):
ConfigParser.__init__(self)
self.read(path)

View file

@ -23,12 +23,12 @@ class GDM:
"""Scan the network."""
self.update(scan_for_clients)
def all(self):
def all(self, scan_for_clients=False):
"""Return all found entries.
Will scan for entries if not scanned recently.
"""
self.scan()
self.scan(scan_for_clients)
return list(self.entries)
def find_by_content_type(self, value):

File diff suppressed because it is too large Load diff

View file

@ -1,41 +1,50 @@
# -*- coding: utf-8 -*-
import xml
from urllib.parse import quote_plus
from plexapi import compat, log, settings, utils
from plexapi import log, settings, utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest
from plexapi.utils import cast, SEARCHTYPES
from plexapi.utils import cast
@utils.registerPlexObject
class Media(PlexObject):
""" Container object for all MediaPart objects. Provides useful data about the
video this media belong to such as video framerate, resolution, etc.
video or audio this media belong to such as video framerate, resolution, etc.
Attributes:
TAG (str): 'Media'
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
initpath (str): Relative path requested when retrieving specified data.
video (str): Video this media belongs to.
aspectRatio (float): Aspect ratio of the video (ex: 2.35).
audioChannels (int): Number of audio channels for this video (ex: 6).
audioCodec (str): Audio codec used within the video (ex: ac3).
bitrate (int): Bitrate of the video (ex: 1624)
container (str): Container this video is in (ex: avi).
duration (int): Length of the video in milliseconds (ex: 6990483).
height (int): Height of the video in pixels (ex: 256).
id (int): Plex ID of this media item (ex: 46184).
has64bitOffsets (bool): True if video has 64 bit offsets (?).
aspectRatio (float): The aspect ratio of the media (ex: 2.35).
audioChannels (int): The number of audio channels of the media (ex: 6).
audioCodec (str): The audio codec of the media (ex: ac3).
audioProfile (str): The audio profile of the media (ex: dts).
bitrate (int): The bitrate of the media (ex: 1624).
container (str): The container of the media (ex: avi).
duration (int): The duration of the media in milliseconds (ex: 6990483).
height (int): The height of the media in pixels (ex: 256).
id (int): The unique ID for this media on the server.
has64bitOffsets (bool): True if video has 64 bit offsets.
optimizedForStreaming (bool): True if video is optimized for streaming.
target (str): Media version target name.
title (str): Media version title.
videoCodec (str): Video codec used within the video (ex: ac3).
videoFrameRate (str): Video frame rate (ex: 24p).
videoResolution (str): Video resolution (ex: sd).
videoProfile (str): Video profile (ex: high).
width (int): Width of the video in pixels (ex: 608).
parts (list<:class:`~plexapi.media.MediaPart`>): List of MediaParts in this video.
parts (List<:class:`~plexapi.media.MediaPart`>): List of media part objects.
proxyType (int): Equals 42 for optimized versions.
target (str): The media version target name.
title (str): The title of the media.
videoCodec (str): The video codec of the media (ex: ac3).
videoFrameRate (str): The video frame rate of the media (ex: 24p).
videoProfile (str): The video profile of the media (ex: high).
videoResolution (str): The video resolution of the media (ex: sd).
width (int): The width of the video in pixels (ex: 608).
<Photo_only_attributes>: The following attributes are only available for photos.
* aperture (str): The apeture used to take the photo.
* exposure (str): The exposure used to take the photo.
* iso (int): The iso used to take the photo.
* lens (str): The lens used to take the photo.
* make (str): The make of the camera used to take the photo.
* model (str): The model of the camera used to take the photo.
"""
TAG = 'Media'
@ -53,6 +62,8 @@ class Media(PlexObject):
self.id = cast(int, data.attrib.get('id'))
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
self.parts = self.findItems(data, MediaPart)
self.proxyType = cast(int, data.attrib.get('proxyType'))
self.target = data.attrib.get('target')
self.title = data.attrib.get('title')
self.videoCodec = data.attrib.get('videoCodec')
@ -60,11 +71,8 @@ class Media(PlexObject):
self.videoProfile = data.attrib.get('videoProfile')
self.videoResolution = data.attrib.get('videoResolution')
self.width = cast(int, data.attrib.get('width'))
self.parts = self.findItems(data, MediaPart)
self.proxyType = cast(int, data.attrib.get('proxyType'))
self.optimizedVersion = self.proxyType == SEARCHTYPES['optimizedVersion']
# For Photo only
if self._isChildOf(etag='Photo'):
self.aperture = data.attrib.get('aperture')
self.exposure = data.attrib.get('exposure')
self.iso = cast(int, data.attrib.get('iso'))
@ -72,6 +80,11 @@ class Media(PlexObject):
self.make = data.attrib.get('make')
self.model = data.attrib.get('model')
@property
def isOptimizedVersion(self):
""" Returns True if the media is a Plex optimized version. """
return self.proxyType == utils.SEARCHTYPES['optimizedVersion']
def delete(self):
part = self._initpath + '/media/%s' % self.id
try:
@ -88,73 +101,77 @@ class MediaPart(PlexObject):
Attributes:
TAG (str): 'Part'
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
initpath (str): Relative path requested when retrieving specified data.
media (:class:`~plexapi.media.Media`): Media object this part belongs to.
container (str): Container type of this media part (ex: avi).
duration (int): Length of this media part in milliseconds.
file (str): Path to this file on disk (ex: /media/Movies/Cars.(2006)/Cars.cd2.avi)
id (int): Unique ID of this media part.
indexes (str, None): None or SD.
key (str): Key used to access this media part (ex: /library/parts/46618/1389985872/file.avi).
size (int): Size of this file in bytes (ex: 733884416).
streams (list<:class:`~plexapi.media.MediaPartStream`>): List of streams in this media part.
exists (bool): Determine if file exists
accessible (bool): Determine if file is accessible
accessible (bool): True if the file is accessible.
audioProfile (str): The audio profile of the file.
container (str): The container type of the file (ex: avi).
decision (str): Unknown.
deepAnalysisVersion (int): The Plex deep analysis version for the file.
duration (int): The duration of the file in milliseconds.
exists (bool): True if the file exists.
file (str): The path to this file on disk (ex: /media/Movies/Cars (2006)/Cars (2006).mkv)
has64bitOffsets (bool): True if the file has 64 bit offsets.
hasThumbnail (bool): True if the file (track) has an embedded thumbnail.
id (int): The unique ID for this media part on the server.
indexes (str, None): sd if the file has generated BIF thumbnails.
key (str): API URL (ex: /library/parts/46618/1389985872/file.mkv).
optimizedForStreaming (bool): True if the file is optimized for streaming.
packetLength (int): The packet length of the file.
requiredBandwidths (str): The required bandwidths to stream the file.
size (int): The size of the file in bytes (ex: 733884416).
streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects.
syncItemId (int): The unique ID for this media part if it is synced.
syncState (str): The sync state for this media part.
videoProfile (str): The video profile of the file.
"""
TAG = 'Part'
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.accessible = cast(bool, data.attrib.get('accessible'))
self.audioProfile = data.attrib.get('audioProfile')
self.container = data.attrib.get('container')
self.decision = data.attrib.get('decision')
self.deepAnalysisVersion = cast(int, data.attrib.get('deepAnalysisVersion'))
self.duration = cast(int, data.attrib.get('duration'))
self.exists = cast(bool, data.attrib.get('exists'))
self.file = data.attrib.get('file')
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
self.hasThumbnail = cast(bool, data.attrib.get('hasThumbnail'))
self.id = cast(int, data.attrib.get('id'))
self.indexes = data.attrib.get('indexes')
self.key = data.attrib.get('key')
self.size = cast(int, data.attrib.get('size'))
self.decision = data.attrib.get('decision')
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
self.packetLength = cast(int, data.attrib.get('packetLength'))
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.size = cast(int, data.attrib.get('size'))
self.streams = self._buildStreams(data)
self.syncItemId = cast(int, data.attrib.get('syncItemId'))
self.syncState = data.attrib.get('syncState')
self.videoProfile = data.attrib.get('videoProfile')
self.streams = self._buildStreams(data)
self.exists = cast(bool, data.attrib.get('exists'))
self.accessible = cast(bool, data.attrib.get('accessible'))
# For Photo only
self.orientation = cast(int, data.attrib.get('orientation'))
def _buildStreams(self, data):
streams = []
for elem in data:
for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream):
if elem.attrib.get('streamType') == str(cls.STREAMTYPE):
streams.append(cls(self._server, elem, self._initpath))
items = self.findItems(data, cls, streamType=cls.STREAMTYPE)
streams.extend(items)
return streams
def videoStreams(self):
""" Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == VideoStream.STREAMTYPE]
return [stream for stream in self.streams if isinstance(stream, VideoStream)]
def audioStreams(self):
""" Returns a list of :class:`~plexapi.media.AudioStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == AudioStream.STREAMTYPE]
return [stream for stream in self.streams if isinstance(stream, AudioStream)]
def subtitleStreams(self):
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE]
return [stream for stream in self.streams if isinstance(stream, SubtitleStream)]
def lyricStreams(self):
""" Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == LyricStream.STREAMTYPE]
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
return [stream for stream in self.streams if isinstance(stream, LyricStream)]
def setDefaultAudioStream(self, stream):
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
@ -187,73 +204,87 @@ class MediaPart(PlexObject):
class MediaPartStream(PlexObject):
""" Base class for media streams. These consist of video, audio and subtitles.
""" Base class for media streams. These consist of video, audio, subtitles, and lyrics.
Attributes:
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
initpath (str): Relative path requested when retrieving specified data.
part (:class:`~plexapi.media.MediaPart`): Media part this stream belongs to.
codec (str): Codec of this stream (ex: srt, ac3, mpeg4).
codecID (str): Codec ID (ex: XVID).
id (int): Unique stream ID on this server.
index (int): Unknown
language (str): Stream language (ex: English, ไทย).
languageCode (str): Ascii code for language (ex: eng, tha).
bitrate (int): The bitrate of the stream.
codec (str): The codec of the stream (ex: srt, ac3, mpeg4).
default (bool): True if this is the default stream.
displayTitle (str): The display title of the stream.
extendedDisplayTitle (str): The extended display title of the stream.
key (str): API URL (/library/streams/<id>)
id (int): The unique ID for this stream on the server.
index (int): The index of the stream.
language (str): The language of the stream (ex: English, ไทย).
languageCode (str): The Ascii language code of the stream (ex: eng, tha).
requiredBandwidths (str): The required bandwidths to stream the file.
selected (bool): True if this stream is selected.
streamType (int): Stream type (1=:class:`~plexapi.media.VideoStream`,
2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`,
4=:class:`~plexapi.media.LyricStream`).
streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`,
2= :class:`~plexapi.media.AudioStream`, 3= :class:`~plexapi.media.SubtitleStream`).
title (str): The title of the stream.
type (int): Alias for streamType.
"""
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.bitrate = cast(int, data.attrib.get('bitrate'))
self.codec = data.attrib.get('codec')
self.default = cast(bool, data.attrib.get('selected', '0'))
self.default = cast(bool, data.attrib.get('default'))
self.displayTitle = data.attrib.get('displayTitle')
self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
self.key = data.attrib.get('key')
self.id = cast(int, data.attrib.get('id'))
self.index = cast(int, data.attrib.get('index', '-1'))
self.language = data.attrib.get('language')
self.languageCode = data.attrib.get('languageCode')
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.selected = cast(bool, data.attrib.get('selected', '0'))
self.streamType = cast(int, data.attrib.get('streamType'))
self.title = data.attrib.get('title')
self.type = cast(int, data.attrib.get('streamType'))
@staticmethod
def parse(server, data, initpath): # pragma: no cover seems to be dead code.
""" Factory method returns a new MediaPartStream from xml data. """
STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream, 4: LyricStream}
stype = cast(int, data.attrib.get('streamType'))
cls = STREAMCLS.get(stype, MediaPartStream)
return cls(server, data, initpath)
@utils.registerPlexObject
class VideoStream(MediaPartStream):
""" Respresents a video stream within a :class:`~plexapi.media.MediaPart`.
""" Represents a video stream within a :class:`~plexapi.media.MediaPart`.
Attributes:
TAG (str): 'Stream'
STREAMTYPE (int): 1
bitDepth (int): Bit depth (ex: 8).
bitrate (int): Bitrate (ex: 1169)
cabac (int): Unknown
chromaSubsampling (str): Chroma Subsampling (ex: 4:2:0).
colorSpace (str): Unknown
duration (int): Duration of video stream in milliseconds.
frameRate (float): Frame rate (ex: 23.976)
frameRateMode (str): Unknown
anamorphic (str): If the video is anamorphic.
bitDepth (int): The bit depth of the video stream (ex: 8).
cabac (int): The context-adaptive binary arithmetic coding.
chromaLocation (str): The chroma location of the video stream.
chromaSubsampling (str): The chroma subsampling of the video stream (ex: 4:2:0).
codecID (str): The codec ID (ex: XVID).
codedHeight (int): The coded height of the video stream in pixels.
codedWidth (int): The coded width of the video stream in pixels.
colorPrimaries (str): The color primaries of the video stream.
colorRange (str): The color range of the video stream.
colorSpace (str): The color space of the video stream (ex: bt2020).
colorTrc (str): The color trc of the video stream.
DOVIBLCompatID (int): Dolby Vision base layer compatibility ID.
DOVIBLPresent (bool): True if Dolby Vision base layer is present.
DOVIELPresent (bool): True if Dolby Vision enhancement layer is present.
DOVILevel (int): Dolby Vision level.
DOVIPresent (bool): True if Dolby Vision is present.
DOVIProfile (int): Dolby Vision profile.
DOVIRPUPresent (bool): True if Dolby Vision reference processing unit is present.
DOVIVersion (float): The Dolby Vision version.
duration (int): The duration of video stream in milliseconds.
frameRate (float): The frame rate of the video stream (ex: 23.976).
frameRateMode (str): The frame rate mode of the video stream.
hasScallingMatrix (bool): True if video stream has a scaling matrix.
height (int): Height of video stream.
level (int): Videl stream level (?).
profile (str): Video stream profile (ex: asp).
refFrames (int): Unknown
scanType (str): Video stream scan type (ex: progressive).
title (str): Title of this video stream.
width (int): Width of video stream.
height (int): The hight of the video stream in pixels (ex: 1080).
level (int): The codec encoding level of the video stream (ex: 41).
profile (str): The profile of the video stream (ex: asp).
pixelAspectRatio (str): The pixel aspect ratio of the video stream.
pixelFormat (str): The pixel format of the video stream.
refFrames (int): The number of reference frames of the video stream.
scanType (str): The scan type of the video stream (ex: progressive).
streamIdentifier(int): The stream identifier of the video stream.
width (int): The width of the video stream in pixels (ex: 1920).
"""
TAG = 'Stream'
STREAMTYPE = 1
@ -263,13 +294,12 @@ class VideoStream(MediaPartStream):
super(VideoStream, self)._loadData(data)
self.anamorphic = data.attrib.get('anamorphic')
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
self.bitrate = cast(int, data.attrib.get('bitrate'))
self.cabac = cast(int, data.attrib.get('cabac'))
self.chromaLocation = data.attrib.get('chromaLocation')
self.chromaSubsampling = data.attrib.get('chromaSubsampling')
self.codecID = data.attrib.get('codecID')
self.codedHeight = data.attrib.get('codedHeight')
self.codedWidth = data.attrib.get('codedWidth')
self.codedHeight = cast(int, data.attrib.get('codedHeight'))
self.codedWidth = cast(int, data.attrib.get('codedWidth'))
self.colorPrimaries = data.attrib.get('colorPrimaries')
self.colorRange = data.attrib.get('colorRange')
self.colorSpace = data.attrib.get('colorSpace')
@ -285,14 +315,13 @@ class VideoStream(MediaPartStream):
self.duration = cast(int, data.attrib.get('duration'))
self.frameRate = cast(float, data.attrib.get('frameRate'))
self.frameRateMode = data.attrib.get('frameRateMode')
self.hasScalingMatrix = cast(bool, data.attrib.get('hasScalingMatrix'))
self.hasScallingMatrix = cast(bool, data.attrib.get('hasScallingMatrix'))
self.height = cast(int, data.attrib.get('height'))
self.level = cast(int, data.attrib.get('level'))
self.profile = data.attrib.get('profile')
self.refFrames = cast(int, data.attrib.get('refFrames'))
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.pixelAspectRatio = data.attrib.get('pixelAspectRatio')
self.pixelFormat = data.attrib.get('pixelFormat')
self.refFrames = cast(int, data.attrib.get('refFrames'))
self.scanType = data.attrib.get('scanType')
self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier'))
self.width = cast(int, data.attrib.get('width'))
@ -300,20 +329,31 @@ class VideoStream(MediaPartStream):
@utils.registerPlexObject
class AudioStream(MediaPartStream):
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
""" Represents a audio stream within a :class:`~plexapi.media.MediaPart`.
Attributes:
TAG (str): 'Stream'
STREAMTYPE (int): 2
audioChannelLayout (str): Audio channel layout (ex: 5.1(side)).
bitDepth (int): Bit depth (ex: 16).
bitrate (int): Audio bitrate (ex: 448).
bitrateMode (str): Bitrate mode (ex: cbr).
channels (int): number of channels in this stream (ex: 6).
dialogNorm (int): Unknown (ex: -27).
duration (int): Duration of audio stream in milliseconds.
samplingRate (int): Sampling rate (ex: xxx)
title (str): Title of this audio stream.
audioChannelLayout (str): The audio channel layout of the audio stream (ex: 5.1(side)).
bitDepth (int): The bit depth of the audio stream (ex: 16).
bitrateMode (str): The bitrate mode of the audio stream (ex: cbr).
channels (int): The number of audio channels of the audio stream (ex: 6).
duration (int): The duration of audio stream in milliseconds.
profile (str): The profile of the audio stream.
samplingRate (int): The sampling rate of the audio stream (ex: xxx)
streamIdentifier (int): The stream identifier of the audio stream.
<Track_only_attributes>: The following attributes are only available for tracks.
* albumGain (float): The gain for the album.
* albumPeak (float): The peak for the album.
* albumRange (float): The range for the album.
* endRamp (str): The end ramp for the track.
* gain (float): The gain for the track.
* loudness (float): The loudness for the track.
* lra (float): The lra for the track.
* peak (float): The peak for the track.
* startRamp (str): The start ramp for the track.
"""
TAG = 'Stream'
STREAMTYPE = 2
@ -323,16 +363,14 @@ class AudioStream(MediaPartStream):
super(AudioStream, self)._loadData(data)
self.audioChannelLayout = data.attrib.get('audioChannelLayout')
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
self.bitrate = cast(int, data.attrib.get('bitrate'))
self.bitrateMode = data.attrib.get('bitrateMode')
self.channels = cast(int, data.attrib.get('channels'))
self.duration = cast(int, data.attrib.get('duration'))
self.profile = data.attrib.get('profile')
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.samplingRate = cast(int, data.attrib.get('samplingRate'))
self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier'))
# For Track only
if self._isChildOf(etag='Track'):
self.albumGain = cast(float, data.attrib.get('albumGain'))
self.albumPeak = cast(float, data.attrib.get('albumPeak'))
self.albumRange = cast(float, data.attrib.get('albumRange'))
@ -346,15 +384,16 @@ class AudioStream(MediaPartStream):
@utils.registerPlexObject
class SubtitleStream(MediaPartStream):
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
""" Represents a audio stream within a :class:`~plexapi.media.MediaPart`.
Attributes:
TAG (str): 'Stream'
STREAMTYPE (int): 3
forced (bool): True if this is a forced subtitle
format (str): Subtitle format (ex: srt).
key (str): Key of this subtitle stream (ex: /library/streams/212284).
title (str): Title of this subtitle stream.
container (str): The container of the subtitle stream.
forced (bool): True if this is a forced subtitle.
format (str): The format of the subtitle stream (ex: srt).
headerCommpression (str): The header compression of the subtitle stream.
transient (str): Unknown.
"""
TAG = 'Stream'
STREAMTYPE = 3
@ -366,21 +405,19 @@ class SubtitleStream(MediaPartStream):
self.forced = cast(bool, data.attrib.get('forced', '0'))
self.format = data.attrib.get('format')
self.headerCompression = data.attrib.get('headerCompression')
self.key = data.attrib.get('key')
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.transient = data.attrib.get('transient')
@utils.registerPlexObject
class LyricStream(MediaPartStream):
""" Respresents a lyric stream within a :class:`~plexapi.media.MediaPart`.
""" Represents a lyric stream within a :class:`~plexapi.media.MediaPart`.
Attributes:
TAG (str): 'Stream'
STREAMTYPE (int): 4
format (str): Lyric format (ex: lrc).
key (str): Key of this subtitle stream (ex: /library/streams/212284).
title (str): Title of this lyric stream.
format (str): The format of the lyric stream (ex: lrc).
minLines (int): The minimum number of lines in the (timed) lyric stream.
provider (str): The provider of the lyric stream (ex: com.plexapp.agents.lyricfind).
timed (bool): True if the lyrics are timed to the track.
"""
TAG = 'Stream'
STREAMTYPE = 4
@ -389,7 +426,6 @@ class LyricStream(MediaPartStream):
""" Load attribute values from Plex XML response. """
super(LyricStream, self)._loadData(data)
self.format = data.attrib.get('format')
self.key = data.attrib.get('key')
self.minLines = cast(int, data.attrib.get('minLines'))
self.provider = data.attrib.get('provider')
self.timed = cast(bool, data.attrib.get('timed', '0'))
@ -397,7 +433,14 @@ class LyricStream(MediaPartStream):
@utils.registerPlexObject
class Session(PlexObject):
""" Represents a current session. """
""" Represents a current session.
Attributes:
TAG (str): 'Session'
id (str): The unique identifier for the session.
bandwidth (int): The Plex streaming brain reserved bandwidth for the session.
location (str): The location of the session (lan, wan, or cellular)
"""
TAG = 'Session'
def _loadData(self, data):
@ -412,7 +455,36 @@ class TranscodeSession(PlexObject):
Attributes:
TAG (str): 'TranscodeSession'
TODO: Document this.
audioChannels (int): The number of audio channels of the transcoded media.
audioCodec (str): The audio codec of the transcoded media.
audioDecision (str): The transcode decision for the audio stream.
complete (bool): True if the transcode is complete.
container (str): The container of the transcoded media.
context (str): The context for the transcode sesson.
duration (int): The duration of the transcoded media in milliseconds.
height (int): The height of the transcoded media in pixels.
key (str): API URL (ex: /transcode/sessions/<id>).
maxOffsetAvailable (float): Unknown.
minOffsetAvailable (float): Unknown.
progress (float): The progress percentage of the transcode.
protocol (str): The protocol of the transcode.
remaining (int): Unknown.
size (int): The size of the transcoded media in bytes.
sourceAudioCodec (str): The audio codec of the source media.
sourceVideoCodec (str): The video codec of the source media.
speed (float): The speed of the transcode.
subtitleDecision (str): The transcode decision for the subtitle stream
throttled (bool): True if the transcode is throttled.
timestamp (int): The epoch timestamp when the transcode started.
transcodeHwDecoding (str): The hardware transcoding decoder engine.
transcodeHwDecodingTitle (str): The title of the hardware transcoding decoder engine.
transcodeHwEncoding (str): The hardware transcoding encoder engine.
transcodeHwEncodingTitle (str): The title of the hardware transcoding encoder engine.
transcodeHwFullPipeline (str): True if hardware decoding and encoding is being used for the transcode.
transcodeHwRequested (str): True if hardware transcoding was requested for the transcode.
videoCodec (str): The video codec of the transcoded media.
videoDecision (str): The transcode decision for the video stream.
width (str): The width of the transcoded media in pixels.
"""
TAG = 'TranscodeSession'
@ -422,17 +494,30 @@ class TranscodeSession(PlexObject):
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
self.audioCodec = data.attrib.get('audioCodec')
self.audioDecision = data.attrib.get('audioDecision')
self.complete = cast(bool, data.attrib.get('complete', '0'))
self.container = data.attrib.get('container')
self.context = data.attrib.get('context')
self.duration = cast(int, data.attrib.get('duration'))
self.height = cast(int, data.attrib.get('height'))
self.key = data.attrib.get('key')
self.maxOffsetAvailable = cast(float, data.attrib.get('maxOffsetAvailable'))
self.minOffsetAvailable = cast(float, data.attrib.get('minOffsetAvailable'))
self.progress = cast(float, data.attrib.get('progress'))
self.protocol = data.attrib.get('protocol')
self.remaining = cast(int, data.attrib.get('remaining'))
self.speed = cast(int, data.attrib.get('speed'))
self.throttled = cast(int, data.attrib.get('throttled'))
self.size = cast(int, data.attrib.get('size'))
self.sourceAudioCodec = data.attrib.get('sourceAudioCodec')
self.sourceVideoCodec = data.attrib.get('sourceVideoCodec')
self.speed = cast(float, data.attrib.get('speed'))
self.subtitleDecision = data.attrib.get('subtitleDecision')
self.throttled = cast(bool, data.attrib.get('throttled', '0'))
self.timestamp = cast(float, data.attrib.get('timeStamp'))
self.transcodeHwDecoding = data.attrib.get('transcodeHwDecoding')
self.transcodeHwDecodingTitle = data.attrib.get('transcodeHwDecodingTitle')
self.transcodeHwEncoding = data.attrib.get('transcodeHwEncoding')
self.transcodeHwEncodingTitle = data.attrib.get('transcodeHwEncodingTitle')
self.transcodeHwFullPipeline = cast(bool, data.attrib.get('transcodeHwFullPipeline', '0'))
self.transcodeHwRequested = cast(bool, data.attrib.get('transcodeHwRequested', '0'))
self.videoCodec = data.attrib.get('videoCodec')
self.videoDecision = data.attrib.get('videoDecision')
self.width = cast(int, data.attrib.get('width'))
@ -442,7 +527,7 @@ class TranscodeSession(PlexObject):
class TranscodeJob(PlexObject):
""" Represents an Optimizing job.
TrancodeJobs are the process for optimizing conversions.
Active or paused optimization items. Usually one item as a time"""
Active or paused optimization items. Usually one item as a time."""
TAG = 'TranscodeJob'
def _loadData(self, data):
@ -598,25 +683,15 @@ class MediaTag(PlexObject):
class GuidTag(PlexObject):
""" Base class for guid tags used only for Guids, as they contain only a string identifier
Attributes:
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
id (id): Tag ID (Used as a unique id, except for Guid's, used for external systems
to plex identifiers, like imdb and tmdb).
id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB).
"""
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.id = data.attrib.get('id')
self.tag = data.attrib.get('tag')
def items(self, *args, **kwargs):
""" Return the list of items within this tag. This function is only applicable
in search results from PlexServer :func:`~plexapi.server.PlexServer.search()`.
"""
if not self.key:
raise BadRequest('Key is not defined for this tag: %s' % self.tag)
return self.fetchItems(self.key)
@utils.registerPlexObject
@ -700,7 +775,11 @@ class Genre(MediaTag):
@utils.registerPlexObject
class Guid(GuidTag):
""" Represents a single Guid media tag. """
""" Represents a single Guid media tag.
Attributes:
TAG (str): 'Guid'
"""
TAG = "Guid"
@ -741,12 +820,12 @@ class Poster(PlexObject):
self._data = data
self.key = data.attrib.get('key')
self.ratingKey = data.attrib.get('ratingKey')
self.selected = cast(bool, data.attrib.get('selected'))
self.selected = data.attrib.get('selected')
self.thumb = data.attrib.get('thumb')
def select(self):
key = self._initpath[:-1]
data = '%s?url=%s' % (key, compat.quote_plus(self.ratingKey))
data = '%s?url=%s' % (key, quote_plus(self.ratingKey))
try:
self._server.query(data, method=self._server._session.put)
except xml.etree.ElementTree.ParseError:
@ -816,7 +895,6 @@ class Chapter(PlexObject):
self.filter = data.attrib.get('filter') # I couldn't filter on it anyways
self.tag = data.attrib.get('tag')
self.title = self.tag
self.thumb = data.attrib.get('thumb')
self.index = cast(int, data.attrib.get('index'))
self.start = cast(int, data.attrib.get('startTimeOffset'))
self.end = cast(int, data.attrib.get('endTimeOffset'))
@ -825,6 +903,7 @@ class Chapter(PlexObject):
@utils.registerPlexObject
class Marker(PlexObject):
""" Represents a single Marker media tag.
Attributes:
TAG (str): 'Marker'
"""

View file

@ -2,14 +2,14 @@
import copy
import threading
import time
from xml.etree import ElementTree
import requests
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.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.client import PlexClient
from plexapi.compat import ElementTree
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.library import LibrarySection
from plexapi.server import PlexServer
from plexapi.sonos import PlexSonosClient
@ -43,7 +43,7 @@ class MyPlexAccount(PlexObject):
guest (bool): Unknown.
home (bool): Unknown.
homeSize (int): Unknown.
id (str): Your Plex account ID.
id (int): Your Plex account ID.
locale (str): Your Plex locale
mailing_list_status (str): Your current mailing list status.
maxHomeSize (int): Unknown.
@ -71,11 +71,12 @@ class MyPlexAccount(PlexObject):
PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete
REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete
REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=0&server=1&home=0' # delete
REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=1&server=1&home=1' # delete
REQUESTED = 'https://plex.tv/api/invites/requested' # get
REQUESTS = 'https://plex.tv/api/invites/requests' # get
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
LINK = 'https://plex.tv/api/v2/pins/link' # put
# Hub sections
VOD = 'https://vod.provider.plex.tv/' # get
WEBSHOWS = 'https://webshows.provider.plex.tv/' # get
@ -87,7 +88,7 @@ class MyPlexAccount(PlexObject):
key = 'https://plex.tv/users/account'
def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
self._token = token
self._token = token or CONFIG.get('auth.server_token')
self._session = session or requests.Session()
self._sonos_cache = []
self._sonos_cache_timestamp = 0
@ -114,7 +115,7 @@ class MyPlexAccount(PlexObject):
self.guest = utils.cast(bool, data.attrib.get('guest'))
self.home = utils.cast(bool, data.attrib.get('home'))
self.homeSize = utils.cast(int, data.attrib.get('homeSize'))
self.id = data.attrib.get('id')
self.id = utils.cast(int, data.attrib.get('id'))
self.locale = data.attrib.get('locale')
self.mailing_list_status = data.attrib.get('mailing_list_status')
self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
@ -139,7 +140,7 @@ class MyPlexAccount(PlexObject):
roles = data.find('roles')
self.roles = []
if roles:
if roles is not None:
for role in roles.iter('role'):
self.roles.append(role.attrib.get('id'))
@ -153,14 +154,15 @@ class MyPlexAccount(PlexObject):
self.services = None
self.joined_at = None
def device(self, name):
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 device.name.lower() == name.lower():
if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId):
return device
raise NotFound('Unable to find device %s' % name)
@ -217,7 +219,7 @@ class MyPlexAccount(PlexObject):
return []
t = time.time()
if t - self._sonos_cache_timestamp > 60:
if t - self._sonos_cache_timestamp > 5:
self._sonos_cache_timestamp = t
data = self.query('https://sonos.plex.tv/resources')
self._sonos_cache = [PlexSonosClient(self, elem) for elem in data]
@ -225,10 +227,10 @@ class MyPlexAccount(PlexObject):
return self._sonos_cache
def sonos_speaker(self, name):
return [x for x in self.sonos_speakers() if x.title == name][0]
return next((x for x in self.sonos_speakers() if x.title.split("+")[0].strip() == name), None)
def sonos_speaker_by_id(self, identifier):
return [x for x in self.sonos_speakers() if x.machineIdentifier == identifier][0]
return next((x for x in self.sonos_speakers() if x.machineIdentifier.startswith(identifier)), None)
def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
@ -578,8 +580,8 @@ class MyPlexAccount(PlexObject):
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
Raises:
:exc:`plexapi.exceptions.BadRequest`: when client with provided clientId wasn`t found.
:exc:`plexapi.exceptions.BadRequest`: provided client doesn`t provides `sync-target`.
:exc:`~plexapi.exceptions.BadRequest`: When client with provided clientId wasn`t found.
:exc:`~plexapi.exceptions.BadRequest`: Provided client doesn`t provides `sync-target`.
"""
if not client and not clientId:
clientId = X_PLEX_IDENTIFIER
@ -683,6 +685,19 @@ class MyPlexAccount(PlexObject):
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)
def link(self, pin):
""" Link a device to the account using a pin code.
Parameters:
pin (str): The 4 digit link pin code.
"""
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Plex-Product': 'Plex SSO'
}
data = {'code': pin}
self.query(self.LINK, self._session.put, headers=headers, data=data)
class MyPlexUser(PlexObject):
""" This object represents non-signed in users such as friends and linked
@ -942,7 +957,7 @@ class MyPlexResource(PlexObject):
HTTP or HTTPS connection.
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.
"""
# Sort connections from (https, local) to (http, remote)
# Only check non-local connections unless we own the resource
@ -958,7 +973,7 @@ class MyPlexResource(PlexObject):
# Try connecting to all known resource connections in parellel, but
# only return the first server (in order) that provides a response.
listargs = [[cls, url, self.accessToken, timeout] for url in connections]
log.info('Testing %s resource connections..', len(listargs))
log.debug('Testing %s resource connections..', len(listargs))
results = utils.threaded(_connect, listargs)
return _chooseConnection('Resource', self.name, results)
@ -1049,11 +1064,11 @@ class MyPlexDevice(PlexObject):
at least one connection was successful, the PlexClient object is built and returned.
Raises:
: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
listargs = [[cls, url, self.token, timeout] for url in self.connections]
log.info('Testing %s device connections..', len(listargs))
log.debug('Testing %s device connections..', len(listargs))
results = utils.threaded(_connect, listargs)
return _chooseConnection('Device', self.name, results)
@ -1066,7 +1081,7 @@ class MyPlexDevice(PlexObject):
""" Returns an instance of :class:`~plexapi.sync.SyncList` for current device.
Raises:
:exc:`plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`.
:exc:`~plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`.
"""
if 'sync-target' not in self.provides:
raise BadRequest('Requested syncList for device which do not provides sync-target')
@ -1098,33 +1113,40 @@ class MyPlexPinLogin(object):
requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT).
Attributes:
PINS (str): 'https://plex.tv/pins.xml'
CHECKPINS (str): 'https://plex.tv/pins/{pinid}.xml'
PINS (str): 'https://plex.tv/api/v2/pins'
CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}'
LINK (str): 'https://plex.tv/api/v2/pins/link'
POLLINTERVAL (int): 1
finished (bool): Whether the pin login has finished or not.
expired (bool): Whether the pin login has expired or not.
token (str): Token retrieved through the pin login.
pin (str): Pin to use for the login on https://plex.tv/link.
"""
PINS = 'https://plex.tv/pins.xml' # get
CHECKPINS = 'https://plex.tv/pins/{pinid}.xml' # get
PINS = 'https://plex.tv/api/v2/pins' # get
CHECKPINS = 'https://plex.tv/api/v2/pins/{pinid}' # get
POLLINTERVAL = 1
def __init__(self, session=None, requestTimeout=None):
def __init__(self, session=None, requestTimeout=None, headers=None):
super(MyPlexPinLogin, self).__init__()
self._session = session or requests.Session()
self._requestTimeout = requestTimeout or TIMEOUT
self.headers = headers
self._loginTimeout = None
self._callback = None
self._thread = None
self._abort = False
self._id = None
self._code = None
self._getCode()
self.finished = False
self.expired = False
self.token = None
self.pin = self._getPin()
@property
def pin(self):
return self._code
def run(self, callback=None, timeout=None):
""" Starts the thread which monitors the PIN login state.
@ -1133,8 +1155,8 @@ class MyPlexPinLogin(object):
timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional).
Raises:
:class:`RuntimeError`: if the thread is already running.
:class:`RuntimeError`: if the PIN login for the current PIN has expired.
:class:`RuntimeError`: If the thread is already running.
:class:`RuntimeError`: If the PIN login for the current PIN has expired.
"""
if self._thread and not self._abort:
raise RuntimeError('MyPlexPinLogin thread is already running')
@ -1187,19 +1209,16 @@ class MyPlexPinLogin(object):
return False
def _getPin(self):
if self.pin:
return self.pin
def _getCode(self):
url = self.PINS
response = self._query(url, self._session.post)
if not response:
return None
self._id = response.find('id').text
self.pin = response.find('code').text
self._id = response.attrib.get('id')
self._code = response.attrib.get('code')
return self.pin
return self._code
def _checkLogin(self):
if not self._id:
@ -1213,7 +1232,7 @@ class MyPlexPinLogin(object):
if not response:
return False
token = response.find('auth_token').text
token = response.attrib.get('authToken')
if not token:
return False
@ -1241,11 +1260,19 @@ class MyPlexPinLogin(object):
finally:
self.finished = True
def _query(self, url, method=None):
def _headers(self, **kwargs):
""" Returns dict containing base headers for all requests for pin login. """
headers = BASE_HEADERS.copy()
if self.headers:
headers.update(self.headers)
headers.update(kwargs)
return headers
def _query(self, url, method=None, headers=None, **kwargs):
method = method or self._session.get
log.debug('%s %s', method.__name__.upper(), url)
headers = BASE_HEADERS.copy()
response = method(url, headers=headers, timeout=self._requestTimeout)
headers = headers or self._headers()
response = method(url, headers=headers, timeout=self._requestTimeout, **kwargs)
if not response.ok: # pragma: no cover
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
@ -1288,9 +1315,9 @@ def _chooseConnection(ctype, name, results):
# or (url, token, None, runtime) in the case a connection could not be established.
for url, token, result, runtime in results:
okerr = 'OK' if result else 'ERR'
log.info('%s connection %s (%ss): %s?X-Plex-Token=%s', ctype, okerr, runtime, url, token)
log.debug('%s connection %s (%ss): %s?X-Plex-Token=%s', ctype, okerr, runtime, url, token)
results = [r[2] for r in results if r and r[2] is not None]
if results:
log.info('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token)
log.debug('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token)
return results[0]
raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name))

View file

@ -1,157 +1,224 @@
# -*- coding: utf-8 -*-
from plexapi import media, utils
from plexapi.base import PlexPartialObject
from plexapi.exceptions import NotFound, BadRequest
from plexapi.compat import quote_plus
from urllib.parse import quote_plus
from plexapi import media, utils, video
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest
@utils.registerPlexObject
class Photoalbum(PlexPartialObject):
""" Represents a photoalbum (collection of photos).
""" Represents a single Photoalbum (collection of photos).
Attributes:
TAG (str): 'Directory'
TYPE (str): 'photo'
addedAt (datetime): Datetime this item was added to the library.
art (str): Photo art (/library/metadata/<ratingkey>/art/<artid>)
composite (str): Unknown
guid (str): Unknown (unique ID)
index (sting): Index number of this album.
addedAt (datetime): Datetime the photo album was added to the library.
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
composite (str): URL to composite image (/library/metadata/<ratingKey>/composite/<compositeid>)
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
guid (str): Plex GUID for the photo album (local://229674).
index (sting): Plex index number for the photo album.
key (str): API URL (/library/metadata/<ratingkey>).
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
listType (str): Hardcoded as 'photo' (useful for search filters).
ratingKey (int): Unique key identifying this item.
ratingKey (int): Unique key identifying the photo album.
summary (str): Summary of the photoalbum.
thumb (str): URL to thumbnail image.
title (str): Photoalbum title. (Trip to Disney World)
type (str): Unknown
updatedAt (datatime): Datetime this item was updated.
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
title (str): Name of the photo album. (Trip to Disney World)
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'photo'
updatedAt (datatime): Datetime the photo album was updated.
userRating (float): Rating of the photoalbum (0.0 - 10.0) equaling (0 stars - 5 stars).
"""
TAG = 'Directory'
TYPE = 'photo'
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.listType = 'photo'
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art')
self.composite = data.attrib.get('composite')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.key = data.attrib.get('key', '')
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.ratingKey = data.attrib.get('ratingKey')
self.listType = 'photo'
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort')
self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.fields = self.findItems(data, media.Field)
def albums(self, **kwargs):
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in this album. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, etag='Directory', **kwargs)
self.userRating = utils.cast(float, data.attrib.get('userRating', 0))
def album(self, title):
""" Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. """
for album in self.albums():
if album.title.lower() == title.lower():
return album
raise NotFound('Unable to find album: %s' % title)
""" Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title.
def photos(self, **kwargs):
""" Returns a list of :class:`~plexapi.photo.Photo` objects in this album. """
Parameters:
title (str): Title of the photo album to return.
"""
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, etag='Photo', **kwargs)
return self.fetchItem(key, Photoalbum, title__iexact=title)
def albums(self, **kwargs):
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in the album. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, Photoalbum, **kwargs)
def photo(self, title):
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title. """
for photo in self.photos():
if photo.title.lower() == title.lower():
return photo
raise NotFound('Unable to find photo: %s' % title)
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title.
Parameters:
title (str): Title of the photo to return.
"""
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItem(key, Photo, title__iexact=title)
def photos(self, **kwargs):
""" Returns a list of :class:`~plexapi.photo.Photo` objects in the album. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, Photo, **kwargs)
def clip(self, title):
""" Returns the :class:`~plexapi.video.Clip` that matches the specified title.
Parameters:
title (str): Title of the clip to return.
"""
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItem(key, video.Clip, title__iexact=title)
def clips(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Clip` objects in this album. """
""" Returns a list of :class:`~plexapi.video.Clip` objects in the album. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, etag='Video', **kwargs)
return self.fetchItems(key, video.Clip, **kwargs)
def get(self, title):
""" Alias to :func:`~plexapi.photo.Photoalbum.photo`. """
return self.episode(title)
def iterParts(self):
""" Iterates over the parts of the media item. """
for album in self.albums():
for photo in album.photos():
for part in photo.iterParts():
yield part
def download(self, savepath=None, keep_original_name=False, showstatus=False):
""" Download photo files to specified directory.
Parameters:
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
showstatus(bool): Display a progressbar.
"""
filepaths = []
locations = [i for i in self.iterParts() if i]
for location in locations:
name = location.file
if not keep_original_name:
title = self.title.replace(' ', '.')
name = '%s.%s' % (title, location.container)
url = self._server.url('%s?download=1' % location.key)
filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus,
savepath=savepath, session=self._server._session)
if filepath:
filepaths.append(filepath)
return filepaths
@utils.registerPlexObject
class Photo(PlexPartialObject):
""" Represents a single photo.
class Photo(PlexPartialObject, Playable):
""" Represents a single Photo.
Attributes:
TAG (str): 'Photo'
TYPE (str): 'photo'
addedAt (datetime): Datetime this item was added to the library.
index (sting): Index number of this photo.
addedAt (datetime): Datetime the photo was added to the library.
createdAtAccuracy (str): Unknown (local).
createdAtTZOffset (int): Unknown (-25200).
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
guid (str): Plex GUID for the photo (com.plexapp.agents.none://231714?lang=xn).
index (sting): Plex index number for the photo.
key (str): API URL (/library/metadata/<ratingkey>).
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
listType (str): Hardcoded as 'photo' (useful for search filters).
media (TYPE): Unknown
originallyAvailableAt (datetime): Datetime this photo was added to Plex.
parentKey (str): Photoalbum API URL.
media (List<:class:`~plexapi.media.Media`>): List of media objects.
originallyAvailableAt (datetime): Datetime the photo was added to Plex.
parentGuid (str): Plex GUID for the photo album (local://229674).
parentIndex (int): Plex index number for the photo album.
parentKey (str): API URL of the photo album (/library/metadata/<parentRatingKey>).
parentRatingKey (int): Unique key identifying the photo album.
ratingKey (int): Unique key identifying this item.
parentThumb (str): URL to photo album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the photo album for the photo.
ratingKey (int): Unique key identifying the photo.
summary (str): Summary of the photo.
thumb (str): URL to thumbnail image.
title (str): Photo title.
type (str): Unknown
updatedAt (datatime): Datetime this item was updated.
year (int): Year this photo was taken.
tag (List<:class:`~plexapi.media.Tag`>): List of tag objects.
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
title (str): Name of the photo.
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'photo'
updatedAt (datatime): Datetime the photo was updated.
year (int): Year the photo was taken.
"""
TAG = 'Photo'
TYPE = 'photo'
METADATA_TYPE = 'photo'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeMarkers=1&includeConcerts=1&includePreferences=1'
'&includeBandwidths=1&includeLoudnessRamps=1')
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.key = data.attrib.get('key')
self._details_key = self.key + self._include
self.listType = 'photo'
Playable._loadData(self, data)
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.createdAtAccuracy = data.attrib.get('createdAtAccuracy')
self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset'))
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '')
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.originallyAvailableAt = utils.toDatetime(
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.listType = 'photo'
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.ratingKey = data.attrib.get('ratingKey')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary')
self.tag = self.findItems(data, media.Tag)
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort')
self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.year = utils.cast(int, data.attrib.get('year'))
self.media = self.findItems(data, media.Media)
self.tag = self.findItems(data, media.Tag)
self.fields = self.findItems(data, media.Field)
@property
def thumbUrl(self):
"""Return URL for the thumbnail image."""
key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb')
return self._server.url(key, includeToken=True) if key else None
def photoalbum(self):
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
""" Return the photo's :class:`~plexapi.photo.Photoalbum`. """
return self.fetchItem(self.parentKey)
def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
""" Returns the :class:`~plexapi.library.LibrarySection` the item belongs to. """
if hasattr(self, 'librarySectionID'):
return self._server.library.sectionByID(self.librarySectionID)
elif self.parentKey:
@ -162,10 +229,19 @@ class Photo(PlexPartialObject):
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Photo
interface to get the locations of the photo.
Retruns:
List<str> of file paths where the photo is found on disk.
"""
return [part.file for item in self.media for part in item.parts if part]
def iterParts(self):
""" Iterates over the parts of the media item. """
for item in self.media:
for part in item.parts:
yield part
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
""" Add current photo as sync item for specified device.
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
@ -201,3 +277,26 @@ class Photo(PlexPartialObject):
sync_item.mediaSettings = MediaSettings.createPhoto(resolution)
return myplex.sync(sync_item, client=client, clientId=clientId)
def download(self, savepath=None, keep_original_name=False, showstatus=False):
""" Download photo files to specified directory.
Parameters:
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
showstatus(bool): Display a progressbar.
"""
filepaths = []
locations = [i for i in self.iterParts() if i]
for location in locations:
name = location.file
if not keep_original_name:
title = self.title.replace(' ', '.')
name = '%s.%s' % (title, location.container)
url = self._server.url('%s?download=1' % location.key)
filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus,
savepath=savepath, session=self._server._session)
if filepath:
filepaths.append(filepath)
return filepaths

View file

@ -1,17 +1,36 @@
# -*- coding: utf-8 -*-
from urllib.parse import quote_plus
from plexapi import utils
from plexapi.base import PlexPartialObject, Playable
from plexapi.exceptions import BadRequest, Unsupported
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection
from plexapi.playqueue import PlayQueue
from plexapi.utils import cast, toDatetime
from plexapi.compat import quote_plus
@utils.registerPlexObject
class Playlist(PlexPartialObject, Playable):
""" Represents a single Playlist object.
# TODO: Document attributes
""" Represents a single Playlist.
Attributes:
TAG (str): 'Playlist'
TYPE (str): 'playlist'
addedAt (datetime): Datetime the playlist was added to the server.
allowSync (bool): True if you allow syncing playlists.
composite (str): URL to composite image (/playlist/<ratingKey>/composite/<compositeid>)
duration (int): Duration of the playlist in milliseconds.
durationInSeconds (int): Duration of the playlist in seconds.
guid (str): Plex GUID for the playlist (com.plexapp.agents.none://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX).
key (str): API URL (/playlist/<ratingkey>).
leafCount (int): Number of items in the playlist view.
playlistType (str): 'audio', 'video', or 'photo'
ratingKey (int): Unique key identifying the playlist.
smart (bool): True if the playlist is a smart playlist.
summary (str): Summary of the playlist.
title (str): Name of the playlist.
type (str): 'playlist'
updatedAt (datatime): Datetime the playlist was updated.
"""
TAG = 'Playlist'
TYPE = 'playlist'
@ -20,12 +39,12 @@ class Playlist(PlexPartialObject, Playable):
""" Load attribute values from Plex XML response. """
Playable._loadData(self, data)
self.addedAt = toDatetime(data.attrib.get('addedAt'))
self.allowSync = cast(bool, data.attrib.get('allowSync'))
self.composite = data.attrib.get('composite') # url to thumbnail
self.duration = cast(int, data.attrib.get('duration'))
self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds'))
self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key')
self.key = self.key.replace('/items', '') if self.key else self.key # FIX_BUG_50
self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50
self.leafCount = cast(int, data.attrib.get('leafCount'))
self.playlistType = data.attrib.get('playlistType')
self.ratingKey = cast(int, data.attrib.get('ratingKey'))
@ -34,12 +53,15 @@ class Playlist(PlexPartialObject, Playable):
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
self.allowSync = cast(bool, data.attrib.get('allowSync'))
self._items = None # cache for self.items
def __len__(self): # pragma: no cover
return len(self.items())
def __iter__(self): # pragma: no cover
for item in self.items():
yield item
@property
def metadataType(self):
if self.isVideo:
@ -69,14 +91,29 @@ class Playlist(PlexPartialObject, Playable):
def __getitem__(self, key): # pragma: no cover
return self.items()[key]
def item(self, title):
""" Returns the item in the playlist that matches the specified title.
Parameters:
title (str): Title of the item to return.
"""
for item in self.items():
if item.title.lower() == title.lower():
return item
raise NotFound('Item with title "%s" not found in the playlist' % title)
def items(self):
""" Returns a list of all items in the playlist. """
if self._items is None:
key = '%s/items' % self.key
key = '/playlists/%s/items' % self.ratingKey
items = self.fetchItems(key)
self._items = items
return self._items
def get(self, title):
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """
return self.item(title)
def addItems(self, items):
""" Add items to a playlist. """
if not isinstance(items, (list, tuple)):
@ -130,6 +167,9 @@ class Playlist(PlexPartialObject, Playable):
@classmethod
def _create(cls, server, title, items):
""" Create a playlist. """
if not items:
raise BadRequest('Must include items to add when creating new playlist')
if items and not isinstance(items, (list, tuple)):
items = [items]
ratingKeys = []
@ -162,6 +202,9 @@ class Playlist(PlexPartialObject, Playable):
**kwargs (dict): is passed to the filters. For a example see the search method.
Raises:
:class:`plexapi.exceptions.BadRequest`: when no items are included in create request.
Returns:
:class:`~plexapi.playlist.Playlist`: an instance of created Playlist.
"""
@ -235,8 +278,8 @@ class Playlist(PlexPartialObject, Playable):
generated from metadata of current photo.
Raises:
:exc:`plexapi.exceptions.BadRequest`: when playlist is not allowed to sync.
:exc:`plexapi.exceptions.Unsupported`: when playlist content is unsupported.
:exc:`~plexapi.exceptions.BadRequest`: When playlist is not allowed to sync.
:exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported.
Returns:
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.

View file

@ -1,75 +1,289 @@
# -*- coding: utf-8 -*-
from urllib.parse import quote_plus
from plexapi import utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest, Unsupported
class PlayQueue(PlexObject):
"""Control a PlayQueue.
Attributes:
key (str): This is only added to support playMedia
TAG (str): 'PlayQueue'
TYPE (str): 'playqueue'
identifier (str): com.plexapp.plugins.library
initpath (str): Relative url where data was grabbed from.
items (list): List of :class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist`
items (list): List of :class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`
mediaTagPrefix (str): Fx /system/bundle/media/flags/
mediaTagVersion (str): Fx 1485957738
playQueueID (str): a id for the playqueue
playQueueSelectedItemID (str): playQueueSelectedItemID
playQueueSelectedItemOffset (str): playQueueSelectedItemOffset
playQueueSelectedMetadataItemID (<type 'str'>): 7
playQueueShuffled (bool): True if shuffled
playQueueSourceURI (str): Fx library://150425c9-0d99-4242-821e-e5ab81cd2221/item//library/metadata/7
playQueueTotalCount (str): How many items in the play queue.
playQueueVersion (str): What version the playqueue is.
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
size (str): Seems to be a alias for playQueueTotalCount.
mediaTagVersion (int): Fx 1485957738
playQueueID (int): ID of the PlayQueue.
playQueueLastAddedItemID (int):
Defines where the "Up Next" region starts. Empty unless PlayQueue is modified after creation.
playQueueSelectedItemID (int): The queue item ID of the currently selected item.
playQueueSelectedItemOffset (int):
The offset of the selected item in the PlayQueue, from the beginning of the queue.
playQueueSelectedMetadataItemID (int): ID of the currently selected item, matches ratingKey.
playQueueShuffled (bool): True if shuffled.
playQueueSourceURI (str): Original URI used to create the PlayQueue.
playQueueTotalCount (int): How many items in the PlayQueue.
playQueueVersion (int): Version of the PlayQueue. Increments every time a change is made to the PlayQueue.
selectedItem (:class:`~plexapi.media.Media`): Media object for the currently selected item.
_server (:class:`~plexapi.server.PlexServer`): PlexServer associated with the PlayQueue.
size (int): Alias for playQueueTotalCount.
"""
TAG = "PlayQueue"
TYPE = "playqueue"
def _loadData(self, data):
self._data = data
self.identifier = data.attrib.get('identifier')
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
self.playQueueID = data.attrib.get('playQueueID')
self.playQueueSelectedItemID = data.attrib.get('playQueueSelectedItemID')
self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset')
self.playQueueSelectedMetadataItemID = data.attrib.get('playQueueSelectedMetadataItemID')
self.playQueueShuffled = utils.cast(bool, data.attrib.get('playQueueShuffled', 0))
self.playQueueSourceURI = data.attrib.get('playQueueSourceURI')
self.playQueueTotalCount = data.attrib.get('playQueueTotalCount')
self.playQueueVersion = data.attrib.get('playQueueVersion')
self.size = utils.cast(int, data.attrib.get('size', 0))
self.identifier = data.attrib.get("identifier")
self.mediaTagPrefix = data.attrib.get("mediaTagPrefix")
self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion"))
self.playQueueID = utils.cast(int, data.attrib.get("playQueueID"))
self.playQueueLastAddedItemID = utils.cast(
int, data.attrib.get("playQueueLastAddedItemID")
)
self.playQueueSelectedItemID = utils.cast(
int, data.attrib.get("playQueueSelectedItemID")
)
self.playQueueSelectedItemOffset = utils.cast(
int, data.attrib.get("playQueueSelectedItemOffset")
)
self.playQueueSelectedMetadataItemID = utils.cast(
int, data.attrib.get("playQueueSelectedMetadataItemID")
)
self.playQueueShuffled = utils.cast(
bool, data.attrib.get("playQueueShuffled", 0)
)
self.playQueueSourceURI = data.attrib.get("playQueueSourceURI")
self.playQueueTotalCount = utils.cast(
int, data.attrib.get("playQueueTotalCount")
)
self.playQueueVersion = utils.cast(int, data.attrib.get("playQueueVersion"))
self.size = utils.cast(int, data.attrib.get("size", 0))
self.items = self.findItems(data)
self.selectedItem = self[self.playQueueSelectedItemOffset]
def __getitem__(self, key):
if not self.items:
return None
return self.items[key]
def __len__(self):
return self.playQueueTotalCount
def __iter__(self):
yield from self.items
def __contains__(self, media):
"""Returns True if the PlayQueue contains the provided media item."""
return any(x.playQueueItemID == media.playQueueItemID for x in self.items)
def getQueueItem(self, item):
"""
Accepts a media item and returns a similar object from this PlayQueue.
Useful for looking up playQueueItemIDs using items obtained from the Library.
"""
matches = [x for x in self.items if x == item]
if len(matches) == 1:
return matches[0]
elif len(matches) > 1:
raise BadRequest(
"{item} occurs multiple times in this PlayQueue, provide exact item".format(item=item)
)
else:
raise BadRequest("{item} not valid for this PlayQueue".format(item=item))
@classmethod
def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1):
""" Create and returns a new :class:`~plexapi.playqueue.PlayQueue`.
def get(
cls,
server,
playQueueID,
own=False,
center=None,
window=50,
includeBefore=True,
includeAfter=True,
):
"""Retrieve an existing :class:`~plexapi.playqueue.PlayQueue` by identifier.
Paramaters:
Parameters:
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
item (:class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist`): A media or Playlist.
playQueueID (int): Identifier of an existing PlayQueue.
own (bool, optional): If server should transfer ownership.
center (int, optional): The playQueueItemID of the center of the window. Does not change selectedItem.
window (int, optional): Number of items to return from each side of the center item.
includeBefore (bool, optional):
Include items before the center, defaults True. Does not include center if False.
includeAfter (bool, optional):
Include items after the center, defaults True. Does not include center if False.
"""
args = {
"own": utils.cast(int, own),
"window": window,
"includeBefore": utils.cast(int, includeBefore),
"includeAfter": utils.cast(int, includeAfter),
}
if center:
args["center"] = center
path = "/playQueues/{playQueueID}{args}".format(playQueueID=playQueueID, args=utils.joinArgs(args))
data = server.query(path, method=server._session.get)
c = cls(server, data, initpath=path)
c._server = server
return c
@classmethod
def create(
cls,
server,
items,
startItem=None,
shuffle=0,
repeat=0,
includeChapters=1,
includeRelated=1,
continuous=0,
):
"""Create and return a new :class:`~plexapi.playqueue.PlayQueue`.
Parameters:
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
items (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`):
A media item, list of media items, or Playlist.
startItem (:class:`~plexapi.media.Media`, optional):
Media item in the PlayQueue where playback should begin.
shuffle (int, optional): Start the playqueue shuffled.
repeat (int, optional): Start the playqueue shuffled.
includeChapters (int, optional): include Chapters.
includeRelated (int, optional): include Related.
continuous (int, optional): include additional items after the initial item.
For a show this would be the next episodes, for a movie it does nothing.
"""
args = {}
args['includeChapters'] = includeChapters
args['includeRelated'] = includeRelated
args['repeat'] = repeat
args['shuffle'] = shuffle
if item.type == 'playlist':
args['playlistID'] = item.ratingKey
args['type'] = item.playlistType
args = {
"includeChapters": includeChapters,
"includeRelated": includeRelated,
"repeat": repeat,
"shuffle": shuffle,
"continuous": continuous,
}
if isinstance(items, list):
item_keys = ",".join([str(x.ratingKey) for x in items])
uri_args = quote_plus("/library/metadata/{item_keys}".format(item_keys=item_keys))
args["uri"] = "library:///directory/{uri_args}".format(uri_args=uri_args)
args["type"] = items[0].listType
elif items.type == "playlist":
args["playlistID"] = items.ratingKey
args["type"] = items.playlistType
else:
uuid = item.section().uuid
args['key'] = item.key
args['type'] = item.listType
args['uri'] = 'library://%s/item/%s' % (uuid, item.key)
path = '/playQueues%s' % utils.joinArgs(args)
uuid = items.section().uuid
args["type"] = items.listType
args["uri"] = "library://{uuid}/item/{key}".format(uuid=uuid, key=items.key)
if startItem:
args["key"] = startItem.key
path = "/playQueues{args}".format(args=utils.joinArgs(args))
data = server.query(path, method=server._session.post)
c = cls(server, data, initpath=path)
# we manually add a key so we can pass this to playMedia
# since the data, does not contain a key.
c.key = item.key
c.playQueueType = args["type"]
c._server = server
return c
def addItem(self, item, playNext=False, refresh=True):
"""
Append the provided item to the "Up Next" section of the PlayQueue.
Items can only be added to the section immediately following the current playing item.
Parameters:
item (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist.
playNext (bool, optional): If True, add this item to the front of the "Up Next" section.
If False, the item will be appended to the end of the "Up Next" section.
Only has an effect if an item has already been added to the "Up Next" section.
See https://support.plex.tv/articles/202188298-play-queues/ for more details.
refresh (bool, optional): Refresh the PlayQueue from the server before updating.
"""
if refresh:
self.refresh()
args = {}
if item.type == "playlist":
args["playlistID"] = item.ratingKey
itemType = item.playlistType
else:
uuid = item.section().uuid
itemType = item.listType
args["uri"] = "library://{uuid}/item{key}".format(uuid=uuid, key=item.key)
if itemType != self.playQueueType:
raise Unsupported("Item type does not match PlayQueue type")
if playNext:
args["next"] = 1
path = "/playQueues/{playQueueID}{args}".format(playQueueID=self.playQueueID, args=utils.joinArgs(args))
data = self._server.query(path, method=self._server._session.put)
self._loadData(data)
def moveItem(self, item, after=None, refresh=True):
"""
Moves an item to the beginning of the PlayQueue. If `after` is provided,
the item will be placed immediately after the specified item.
Parameters:
item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move.
afterItemID (:class:`~plexapi.base.Playable`, optional): A different item in the PlayQueue.
If provided, `item` will be placed in the PlayQueue after this item.
refresh (bool, optional): Refresh the PlayQueue from the server before updating.
"""
args = {}
if refresh:
self.refresh()
if item not in self:
item = self.getQueueItem(item)
if after:
if after not in self:
after = self.getQueueItem(after)
args["after"] = after.playQueueItemID
path = "/playQueues/{playQueueID}/items/{playQueueItemID}/move{args}".format(
playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID, args=utils.joinArgs(args)
)
data = self._server.query(path, method=self._server._session.put)
self._loadData(data)
def removeItem(self, item, refresh=True):
"""Remove an item from the PlayQueue.
Parameters:
item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move.
refresh (bool, optional): Refresh the PlayQueue from the server before updating.
"""
if refresh:
self.refresh()
if item not in self:
item = self.getQueueItem(item)
path = "/playQueues/{playQueueID}/items/{playQueueItemID}".format(
playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID
)
data = self._server.query(path, method=self._server._session.delete)
self._loadData(data)
def clear(self):
"""Remove all items from the PlayQueue."""
path = "/playQueues/{playQueueID}/items".format(playQueueID=self.playQueueID)
data = self._server.query(path, method=self._server._session.delete)
self._loadData(data)
def refresh(self):
"""Refresh the PlayQueue from the Plex server."""
path = "/playQueues/{playQueueID}".format(playQueueID=self.playQueueID)
data = self._server.query(path, method=self._server._session.get)
self._loadData(data)

View file

@ -1,23 +1,29 @@
# -*- coding: utf-8 -*-
from urllib.parse import urlencode
from xml.etree import ElementTree
import requests
from requests.status_codes import _codes as codes
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE
from plexapi import log, logfilter, utils
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log,
logfilter)
from plexapi import utils
from plexapi.alert import AlertListener
from plexapi.base import PlexObject
from plexapi.client import PlexClient
from plexapi.compat import ElementTree, urlencode
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.library import Hub, Library, Path, File
from plexapi.settings import Settings
from plexapi.media import Conversion, Optimized
from plexapi.playlist import Playlist
from plexapi.playqueue import PlayQueue
from plexapi.settings import Settings
from plexapi.utils import cast
from plexapi.media import Optimized, Conversion
from requests.status_codes import _codes as codes
# Need these imports to populate utils.PLEXOBJECTS
from plexapi import (audio as _audio, video as _video, # noqa: F401
photo as _photo, media as _media, playlist as _playlist) # noqa: F401
from plexapi import audio as _audio # noqa: F401; noqa: F401
from plexapi import media as _media # noqa: F401
from plexapi import photo as _photo # noqa: F401
from plexapi import playlist as _playlist # noqa: F401
from plexapi import video as _video # noqa: F401
class PlexServer(PlexObject):
@ -101,6 +107,8 @@ class PlexServer(PlexObject):
self._library = None # cached library
self._settings = None # cached settings
self._myPlexAccount = None # cached myPlexAccount
self._systemAccounts = None # cached list of SystemAccount
self._systemDevices = None # cached list of SystemDevice
data = self.query(self.key, timeout=timeout)
super(PlexServer, self).__init__(self, data, self.key)
@ -184,6 +192,14 @@ class PlexServer(PlexObject):
data = self.query(Account.key)
return Account(self, data)
@property
def activities(self):
"""Returns all current PMS activities."""
activities = []
for elem in self.query(Activity.key):
activities.append(Activity(self, elem))
return activities
def agents(self, mediaType=None):
""" Returns the :class:`~plexapi.media.Agent` objects this server has available. """
key = '/system/agents'
@ -200,11 +216,18 @@ class PlexServer(PlexObject):
return q.attrib.get('token')
def systemAccounts(self):
""" Returns the :class:`~plexapi.server.SystemAccounts` objects this server contains. """
accounts = []
for elem in self.query('/accounts'):
accounts.append(SystemAccount(self, data=elem))
return accounts
""" Returns a list of :class:`~plexapi.server.SystemAccounts` objects this server contains. """
if self._systemAccounts is None:
key = '/accounts'
self._systemAccounts = self.fetchItems(key, SystemAccount)
return self._systemAccounts
def systemDevices(self):
""" Returns a list of :class:`~plexapi.server.SystemDevices` objects this server contains. """
if self._systemDevices is None:
key = '/devices'
self._systemDevices = self.fetchItems(key, SystemDevice)
return self._systemDevices
def myPlexAccount(self):
""" Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same
@ -303,7 +326,7 @@ class PlexServer(PlexObject):
name (str): Name of the client to return.
Raises:
:exc:`plexapi.exceptions.NotFound`: Unknown client name
:exc:`~plexapi.exceptions.NotFound`: Unknown client name.
"""
for client in self.clients():
if client and client.title == name:
@ -325,7 +348,7 @@ class PlexServer(PlexObject):
Parameters:
item (Media or Playlist): Media or playlist to add to PlayQueue.
kwargs (dict): See `~plexapi.playerque.PlayQueue.create`.
kwargs (dict): See `~plexapi.playqueue.PlayQueue.create`.
"""
return PlayQueue.create(self, item, **kwargs)
@ -413,11 +436,11 @@ class PlexServer(PlexObject):
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
return results
def playlists(self, **kwargs):
def playlists(self):
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """
# TODO: Add sort and type options?
# /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0
return self.fetchItems('/playlists', **kwargs)
return self.fetchItems('/playlists')
def playlist(self, title):
""" Returns the :class:`~plexapi.client.Playlist` that matches the specified title.
@ -426,7 +449,7 @@ class PlexServer(PlexObject):
title (str): Title of the playlist to return.
Raises:
:exc:`plexapi.exceptions.NotFound`: Invalid playlist title
:exc:`~plexapi.exceptions.NotFound`: Invalid playlist title.
"""
return self.fetchItem('/playlists', title=title)
@ -471,7 +494,7 @@ class PlexServer(PlexObject):
log.debug('%s %s', method.__name__.upper(), url)
headers = self._headers(**headers or {})
response = method(url, headers=headers, timeout=timeout, **kwargs)
if response.status_code not in (200, 201):
if response.status_code not in (200, 201, 204):
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
@ -499,16 +522,23 @@ class PlexServer(PlexObject):
Parameters:
query (str): Query to use when searching your library.
mediatype (str): Optionally limit your search to the specified media type.
actor, album, artist, autotag, collection, director, episode, game, genre,
movie, photo, photoalbum, place, playlist, shared, show, tag, track
limit (int): Optionally limit to the specified number of results per Hub.
"""
results = []
params = {'query': query}
if mediatype:
params['section'] = utils.SEARCHTYPES[mediatype]
params = {
'query': query,
'includeCollections': 1,
'includeExternalMedia': 1}
if limit:
params['limit'] = limit
key = '/hubs/search?%s' % urlencode(params)
for hub in self.fetchItems(key, Hub):
if mediatype:
if hub.type == mediatype:
return hub.items
else:
results += hub.items
return results
@ -516,6 +546,10 @@ class PlexServer(PlexObject):
""" Returns a list of all active session (currently playing) media objects. """
return self.fetchItems('/status/sessions')
def transcodeSessions(self):
""" Returns a list of all active :class:`~plexapi.media.TranscodeSession` objects. """
return self.fetchItems('/transcode/sessions')
def startAlertListener(self, callback=None):
""" Creates a websocket connection to the Plex Server to optionally recieve
notifications. These often include messages from Plex about media scans
@ -528,7 +562,7 @@ class PlexServer(PlexObject):
callback (func): Callback function to call on recieved messages.
Raises:
:exc:`plexapi.exception.Unsupported`: Websocket-client not installed.
:exc:`~plexapi.exception.Unsupported`: Websocket-client not installed.
"""
notifier = AlertListener(self, callback)
notifier.start()
@ -593,6 +627,103 @@ class PlexServer(PlexObject):
value = 1 if toggle is True else 0
return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put)
def bandwidth(self, timespan=None, **kwargs):
""" Returns a list of :class:`~plexapi.server.StatisticsBandwidth` objects
with the Plex server dashboard bandwidth data.
Parameters:
timespan (str, optional): The timespan to bin the bandwidth data. Default is seconds.
Available timespans: seconds, hours, days, weeks, months.
**kwargs (dict, optional): Any of the available filters that can be applied to the bandwidth data.
The time frame (at) and bytes can also be filtered using less than or greater than (see examples below).
* accountID (int): The :class:`~plexapi.server.SystemAccount` ID to filter.
* at (datetime): The time frame to filter (inclusive). The time frame can be either:
1. An exact time frame (e.g. Only December 1st 2020 `at=datetime(2020, 12, 1)`).
2. Before a specific time (e.g. Before and including December 2020 `at<=datetime(2020, 12, 1)`).
3. After a specific time (e.g. After and including January 2021 `at>=datetime(2021, 1, 1)`).
* bytes (int): The amount of bytes to filter (inclusive). The bytes can be either:
1. An exact number of bytes (not very useful) (e.g. `bytes=1024**3`).
2. Less than or equal number of bytes (e.g. `bytes<=1024**3`).
3. Greater than or equal number of bytes (e.g. `bytes>=1024**3`).
* deviceID (int): The :class:`~plexapi.server.SystemDevice` ID to filter.
* lan (bool): True to only retrieve local bandwidth, False to only retrieve remote bandwidth.
Default returns all local and remote bandwidth.
Raises:
:exc:`~plexapi.exceptions.BadRequest`: When applying an invalid timespan or unknown filter.
Example:
.. code-block:: python
from plexapi.server import PlexServer
plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx')
# Filter bandwidth data for December 2020 and later, and more than 1 GB used.
filters = {
'at>': datetime(2020, 12, 1),
'bytes>': 1024**3
}
# Retrieve bandwidth data in one day timespans.
bandwidthData = plex.bandwidth(timespan='days', **filters)
# Print out bandwidth usage for each account and device combination.
for bandwidth in sorted(bandwidthData, key=lambda x: x.at):
account = bandwidth.account()
device = bandwidth.device()
gigabytes = round(bandwidth.bytes / 1024**3, 3)
local = 'local' if bandwidth.lan else 'remote'
date = bandwidth.at.strftime('%Y-%m-%d')
print('%s used %s GB of %s bandwidth on %s from %s'
% (account.name, gigabytes, local, date, device.name))
"""
params = {}
if timespan is None:
params['timespan'] = 6 # Default to seconds
else:
timespans = {
'seconds': 6,
'hours': 4,
'days': 3,
'weeks': 2,
'months': 1
}
try:
params['timespan'] = timespans[timespan]
except KeyError:
raise BadRequest('Invalid timespan specified: %s. '
'Available timespans: %s' % (timespan, ', '.join(timespans.keys())))
filters = {'accountID', 'at', 'at<', 'at>', 'bytes', 'bytes<', 'bytes>', 'deviceID', 'lan'}
for key, value in kwargs.items():
if key not in filters:
raise BadRequest('Unknown filter: %s=%s' % (key, value))
if key.startswith('at'):
try:
value = cast(int, value.timestamp())
except AttributeError:
raise BadRequest('Time frame filter must be a datetime object: %s=%s' % (key, value))
elif key.startswith('bytes') or key == 'lan':
value = cast(int, value)
elif key == 'accountID':
if value == self.myPlexAccount().id:
value = 1 # The admin account is accountID=1
params[key] = value
key = '/statistics/bandwidth?%s' % urlencode(params)
return self.fetchItems(key, StatisticsBandwidth)
def resources(self):
""" Returns a list of :class:`~plexapi.server.StatisticsResources` objects
with the Plex server dashboard resources data. """
key = '/statistics/resources?timespan=6'
return self.fetchItems(key, StatisticsResources)
class Account(PlexObject):
""" Contains the locally cached MyPlex account information. The properties provided don't
@ -642,12 +773,148 @@ class Account(PlexObject):
self.subscriptionState = data.attrib.get('subscriptionState')
class SystemAccount(PlexObject):
""" Minimal api to list system accounts. """
key = '/accounts'
class Activity(PlexObject):
"""A currently running activity on the PlexServer."""
key = '/activities'
def _loadData(self, data):
self._data = data
self.accountID = cast(int, data.attrib.get('id'))
self.accountKey = data.attrib.get('key')
self.cancellable = cast(bool, data.attrib.get('cancellable'))
self.progress = cast(int, data.attrib.get('progress'))
self.title = data.attrib.get('title')
self.subtitle = data.attrib.get('subtitle')
self.type = data.attrib.get('type')
self.uuid = data.attrib.get('uuid')
class SystemAccount(PlexObject):
""" Represents a single system account.
Attributes:
TAG (str): 'Account'
autoSelectAudio (bool): True or False if the account has automatic audio language enabled.
defaultAudioLanguage (str): The default audio language code for the account.
defaultSubtitleLanguage (str): The default subtitle language code for the account.
id (int): The Plex account ID.
key (str): API URL (/accounts/<id>)
name (str): The username of the account.
subtitleMode (bool): The subtitle mode for the account.
thumb (str): URL for the account thumbnail.
"""
TAG = 'Account'
def _loadData(self, data):
self._data = data
self.autoSelectAudio = cast(bool, data.attrib.get('autoSelectAudio'))
self.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage')
self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage')
self.id = cast(int, data.attrib.get('id'))
self.key = data.attrib.get('key')
self.name = data.attrib.get('name')
self.subtitleMode = cast(int, data.attrib.get('subtitleMode'))
self.thumb = data.attrib.get('thumb')
# For backwards compatibility
self.accountID = self.id
self.accountKey = self.key
class SystemDevice(PlexObject):
""" Represents a single system device.
Attributes:
TAG (str): 'Device'
createdAt (datatime): Datetime the device was created.
id (int): The ID of the device (not the same as :class:`~plexapi.myplex.MyPlexDevice` ID).
key (str): API URL (/devices/<id>)
name (str): The name of the device.
platform (str): OS the device is running (Linux, Windows, Chrome, etc.)
"""
TAG = 'Device'
def _loadData(self, data):
self._data = data
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.id = cast(int, data.attrib.get('id'))
self.key = '/devices/%s' % self.id
self.name = data.attrib.get('name')
self.platform = data.attrib.get('platform')
class StatisticsBandwidth(PlexObject):
""" Represents a single statistics bandwidth data.
Attributes:
TAG (str): 'StatisticsBandwidth'
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
at (datatime): Datetime of the bandwidth data.
bytes (int): The total number of bytes for the specified timespan.
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
lan (bool): True or False wheter the bandwidth is local or remote.
timespan (int): The timespan for the bandwidth data.
1: months, 2: weeks, 3: days, 4: hours, 6: seconds.
"""
TAG = 'StatisticsBandwidth'
def _loadData(self, data):
self._data = data
self.accountID = cast(int, data.attrib.get('accountID'))
self.at = utils.toDatetime(data.attrib.get('at'))
self.bytes = cast(int, data.attrib.get('bytes'))
self.deviceID = cast(int, data.attrib.get('deviceID'))
self.lan = cast(bool, data.attrib.get('lan'))
self.timespan = cast(int, data.attrib.get('timespan'))
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
self.__class__.__name__,
self._clean(self.accountID),
self._clean(self.deviceID),
self._clean(int(self.at.timestamp()))
] if p])
def account(self):
""" Returns the :class:`~plexapi.server.SystemAccount` associated with the bandwidth data. """
accounts = self._server.systemAccounts()
try:
return next(account for account in accounts if account.id == self.accountID)
except StopIteration:
raise NotFound('Unknown account for this bandwidth data: accountID=%s' % self.accountID)
def device(self):
""" Returns the :class:`~plexapi.server.SystemDevice` associated with the bandwidth data. """
devices = self._server.systemDevices()
try:
return next(device for device in devices if device.id == self.deviceID)
except StopIteration:
raise NotFound('Unknown device for this bandwidth data: deviceID=%s' % self.deviceID)
class StatisticsResources(PlexObject):
""" Represents a single statistics resources data.
Attributes:
TAG (str): 'StatisticsResources'
at (datatime): Datetime of the resource data.
hostCpuUtilization (float): The system CPU usage %.
hostMemoryUtilization (float): The Plex Media Server CPU usage %.
processCpuUtilization (float): The system RAM usage %.
processMemoryUtilization (float): The Plex Media Server RAM usage %.
timespan (int): The timespan for the resource data (6: seconds).
"""
TAG = 'StatisticsResources'
def _loadData(self, data):
self._data = data
self.at = utils.toDatetime(data.attrib.get('at'))
self.hostCpuUtilization = cast(float, data.attrib.get('hostCpuUtilization'))
self.hostMemoryUtilization = cast(float, data.attrib.get('hostMemoryUtilization'))
self.processCpuUtilization = cast(float, data.attrib.get('processCpuUtilization'))
self.processMemoryUtilization = cast(float, data.attrib.get('processMemoryUtilization'))
self.timespan = cast(int, data.attrib.get('timespan'))
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
self.__class__.__name__,
self._clean(int(self.at.timestamp()))
] if p])

View file

@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
from collections import defaultdict
from urllib.parse import quote
from plexapi import log, utils
from plexapi.base import PlexObject
from plexapi.compat import quote, string_type
from plexapi.exceptions import BadRequest, NotFound
@ -104,12 +104,11 @@ class Setting(PlexObject):
"""
_bool_cast = lambda x: True if x == 'true' or x == '1' else False
_bool_str = lambda x: str(x).lower()
_str = lambda x: str(x).encode('utf-8')
TYPES = {
'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str},
'double': {'type': float, 'cast': float, 'tostr': _str},
'int': {'type': int, 'cast': int, 'tostr': _str},
'text': {'type': string_type, 'cast': _str, 'tostr': _str},
'double': {'type': float, 'cast': float, 'tostr': str},
'int': {'type': int, 'cast': int, 'tostr': str},
'text': {'type': str, 'cast': str, 'tostr': str},
}
def _loadData(self, data):
@ -158,3 +157,21 @@ class Setting(PlexObject):
def toUrl(self):
"""Helper for urls"""
return '%s=%s' % (self.id, self._value or self.value)
@utils.registerPlexObject
class Preferences(Setting):
""" Represents a single Preferences.
Attributes:
TAG (str): 'Preferences'
FILTER (str): 'preferences'
"""
TAG = 'Preferences'
FILTER = 'preferences'
def _default(self):
""" Set the default value for this setting."""
key = '%s/prefs?' % self._initpath
url = key + '%s=%s' % (self.id, self.default)
self._server.query(url, method=self._server._session.put)

View file

@ -201,7 +201,7 @@ class MediaSettings(object):
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module.
Raises:
:exc:`plexapi.exceptions.BadRequest`: when provided unknown video quality.
:exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality.
"""
if videoQuality == VIDEO_QUALITY_ORIGINAL:
return MediaSettings('', '', '')
@ -231,7 +231,7 @@ class MediaSettings(object):
module.
Raises:
:exc:`plexapi.exceptions.BadRequest` when provided unknown video quality.
:exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality.
"""
if resolution in PHOTO_QUALITIES:
return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution)

View file

@ -1,17 +1,19 @@
# -*- coding: utf-8 -*-
import base64
import functools
import logging
import os
import re
import time
import warnings
import zipfile
from datetime import datetime
from getpass import getpass
from threading import Event, Thread
from urllib.parse import quote
import requests
from plexapi import compat
from plexapi.exceptions import NotFound
from plexapi.exceptions import BadRequest, NotFound
try:
from tqdm import tqdm
@ -19,13 +21,13 @@ except ImportError:
tqdm = None
log = logging.getLogger('plexapi')
warnings.simplefilter('default', category=DeprecationWarning)
# Search Types - Plex uses these to filter specific media types when searching.
# Library Types - Populated at runtime
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7,
'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14,
'playlist': 15, 'playlistFolder': 16, 'collection': 18,
'optimizedVersion': 42, 'userPlaylistItem': 1001}
'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'optimizedVersion': 42, 'userPlaylistItem': 1001}
PLEXOBJECTS = {}
@ -43,7 +45,7 @@ class SecretsFilter(logging.Filter):
def filter(self, record):
cleanargs = list(record.args)
for i in range(len(cleanargs)):
if isinstance(cleanargs[i], compat.string_type):
if isinstance(cleanargs[i], str):
for secret in self.secrets:
cleanargs[i] = cleanargs[i].replace(secret, '<hidden>')
record.args = tuple(cleanargs)
@ -55,7 +57,7 @@ def registerPlexObject(cls):
define a few helper functions to dynamically convery the XML into objects. See
buildItem() below for an example.
"""
etype = getattr(cls, 'STREAMTYPE', cls.TYPE)
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG
if ehash in PLEXOBJECTS:
raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' %
@ -101,8 +103,8 @@ def joinArgs(args):
return ''
arglist = []
for key in sorted(args, key=lambda x: x.lower()):
value = compat.ustr(args[key])
arglist.append('%s=%s' % (key, compat.quote(value, safe='')))
value = str(args[key])
arglist.append('%s=%s' % (key, quote(value, safe='')))
return '?%s' % '&'.join(arglist)
@ -112,7 +114,7 @@ def lowerFirst(s):
def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover
""" Returns the value at the specified attrstr location within a nexted tree of
dicts, lists, tuples, functions, classes, etc. The lookup is done recursivley
dicts, lists, tuples, functions, classes, etc. The lookup is done recursively
for each key in attrstr (split by by the delimiter) This function is heavily
influenced by the lookups used in Django templates.
@ -148,10 +150,10 @@ def searchType(libtype):
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
collection)
Raises:
:exc:`plexapi.exceptions.NotFound`: Unknown libtype
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
"""
libtype = compat.ustr(libtype)
if libtype in [compat.ustr(v) for v in SEARCHTYPES.values()]:
libtype = str(libtype)
if libtype in [str(v) for v in SEARCHTYPES.values()]:
return libtype
if SEARCHTYPES.get(libtype) is not None:
return SEARCHTYPES[libtype]
@ -159,12 +161,12 @@ def searchType(libtype):
def threaded(callback, listargs):
""" Returns the result of <callback> for each set of \*args in listargs. Each call
""" Returns the result of <callback> for each set of `*args` in listargs. Each call
to <callback> is called concurrently in their own separate threads.
Parameters:
callback (func): Callback function to apply to each set of \*args.
listargs (list): List of lists; \*args to pass each thread.
callback (func): Callback function to apply to each set of `*args`.
listargs (list): List of lists; `*args` to pass each thread.
"""
threads, results = [], []
job_is_done_event = Event()
@ -206,6 +208,19 @@ def toDatetime(value, format=None):
return value
def millisecondToHumanstr(milliseconds):
""" Returns human readable time duration from milliseconds.
HH:MM:SS:MMMM
Parameters:
milliseconds (str,int): time duration in milliseconds.
"""
milliseconds = int(milliseconds)
r = datetime.utcfromtimestamp(milliseconds / 1000)
f = r.strftime("%H:%M:%S.%f")
return f[:-2]
def toList(value, itemcast=None, delim=','):
""" Returns a list of strings from the specified value.
@ -277,7 +292,7 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4
response = session.get(url, headers=headers, stream=True)
# make sure the savepath directory exists
savepath = savepath or os.getcwd()
compat.makedirs(savepath, exist_ok=True)
os.makedirs(savepath, exist_ok=True)
# try getting filename from header if not specified in arguments (used for logs, db)
if not filename and response.headers.get('Content-Disposition'):
@ -356,6 +371,10 @@ def getMyPlexAccount(opts=None): # pragma: no cover
if config_username and config_password:
print('Authenticating with Plex.tv as %s..' % config_username)
return MyPlexAccount(config_username, config_password)
config_token = CONFIG.get('auth.server_token')
if config_token:
print('Authenticating with Plex.tv with token')
return MyPlexAccount(token=config_token)
# 3. Prompt for username and password on the command line
username = input('What is your plex.tv username: ')
password = getpass('What is your plex.tv password: ')
@ -363,6 +382,30 @@ def getMyPlexAccount(opts=None): # pragma: no cover
return MyPlexAccount(username, password)
def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover
""" Helper function to create a new MyPlexDevice.
Parameters:
headers (dict): Provide the X-Plex- headers for the new device.
A unique X-Plex-Client-Identifier is required.
account (MyPlexAccount): The Plex account to create the device on.
timeout (int): Timeout in seconds to wait for device login.
"""
from plexapi.myplex import MyPlexPinLogin
if 'X-Plex-Client-Identifier' not in headers:
raise BadRequest('The X-Plex-Client-Identifier header is required.')
clientIdentifier = headers['X-Plex-Client-Identifier']
pinlogin = MyPlexPinLogin(headers=headers)
pinlogin.run(timeout=timeout)
account.link(pinlogin.pin)
pinlogin.waitForLogin()
return account.device(clientId=clientIdentifier)
def choose(msg, items, attr): # pragma: no cover
""" Command line helper to display a list of choices, asking the
user to choose one of the options.
@ -404,3 +447,18 @@ def getAgentIdentifier(section, agent):
def base64str(text):
return base64.b64encode(text.encode('utf-8')).decode('utf-8')
def deprecated(message):
def decorator(func):
"""This is a decorator which can be used to mark functions
as deprecated. It will result in a warning being emitted
when the function is used."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message)
warnings.warn(msg, category=DeprecationWarning, stacklevel=3)
log.warning(msg)
return func(*args, **kwargs)
return wrapper
return decorator

View file

@ -1,47 +1,54 @@
# -*- coding: utf-8 -*-
from plexapi import media, utils
from plexapi.exceptions import BadRequest, NotFound
from plexapi.base import Playable, PlexPartialObject
from plexapi.compat import quote_plus, urlencode
import os
from urllib.parse import quote_plus, urlencode
from plexapi import library, media, settings, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound
class Video(PlexPartialObject):
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
:class:`~plexapi.video.Episode`.
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
Attributes:
addedAt (datetime): Datetime this item was added to the library.
art (str): URL to artwork image.
addedAt (datetime): Datetime the item was added to the library.
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
artBlurHash (str): BlurHash string for artwork image.
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
guid (str): Plex GUID for the movie, show, season, episode, or clip (plex://movie/5d776b59ad5437001f79c6f8).
key (str): API URL (/library/metadata/<ratingkey>).
lastViewedAt (datetime): Datetime item was last accessed.
lastViewedAt (datetime): Datetime the item was last played.
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
listType (str): Hardcoded as 'audio' (useful for search filters).
ratingKey (int): Unique key identifying this item.
summary (str): Summary of the artist, track, or album.
thumb (str): URL to thumbnail image.
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
listType (str): Hardcoded as 'video' (useful for search filters).
ratingKey (int): Unique key identifying the item.
summary (str): Summary of the movie, show, season, episode, or clip.
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
thumbBlurHash (str): BlurHash string for thumbnail image.
title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.)
title (str): Name of the movie, show, season, episode, or clip.
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'artist', 'album', or 'track'.
updatedAt (datatime): Datetime this item was updated.
viewCount (int): Count of times this item was accessed.
type (str): 'movie', 'show', 'season', 'episode', or 'clip'.
updatedAt (datatime): Datetime the item was updated.
viewCount (int): Count of times the item was played.
"""
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.listType = 'video'
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key', '')
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'video'
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
@ -133,8 +140,9 @@ class Video(PlexPartialObject):
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None):
""" Optimize item
locationID (int): -1 in folder with orginal items
2 library path
locationID (int): -1 in folder with original items
2 library path id
library path id is found in library.locations[i].id
target (str): custom quality name.
if none provided use "Custom: {deviceProfile}"
@ -164,6 +172,13 @@ class Video(PlexPartialObject):
if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None):
raise BadRequest('Unexpected or missing quality profile.')
libraryLocationIDs = [location.id for location in self.section()._locations()]
libraryLocationIDs.append(-1)
if locationID not in libraryLocationIDs:
raise BadRequest('Unexpected library path ID. %s not in %s' %
(locationID, libraryLocationIDs))
if isinstance(targetTagID, str):
tagIndex = tagKeys.index(targetTagID)
targetTagID = tagValues[tagIndex]
@ -250,35 +265,33 @@ class Movie(Playable, Video):
Attributes:
TAG (str): 'Video'
TYPE (str): 'movie'
art (str): Key to movie artwork (/library/metadata/<ratingkey>/art/<artid>)
audienceRating (float): Audience rating (usually from Rotten Tomatoes).
audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled)
audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled).
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
chapterSource (str): Chapter source (agent; media; mixed).
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
contentRating (str) Content rating (PG-13; NR; TV-G).
duration (int): Duration of movie in milliseconds.
guid: Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
duration (int): Duration of the movie in milliseconds.
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
media (List<:class:`~plexapi.media.Media`>): List of media objects.
originallyAvailableAt (datetime): Datetime the movie was released.
originalTitle (str): Original title, often the foreign title (転々; 엽기적인 그녀).
originallyAvailableAt (datetime): Datetime movie was released.
primaryExtraKey (str) Primary extra key (/library/metadata/66351).
rating (float): Movie rating (7.9; 9.8; 8.1).
ratingImage (str): Key to rating image (rottentomatoes://image.rating.rotten).
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
rating (float): Movie critic rating (7.9; 9.8; 8.1).
ratingImage (str): Key to critic rating image (rottentomatoes://image.rating.rotten).
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment).
tagline (str): Movie tag line (Back 2 Work; Who says men can't change?).
userRating (float): User rating (2.0; 8.0).
viewOffset (int): View offset in milliseconds.
year (int): Year movie was released.
collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs.
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
media (List<:class:`~plexapi.media.Media`>): List of media objects.
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
year (int): Year movie was released.
"""
TAG = 'Video'
TYPE = 'movie'
@ -288,38 +301,33 @@ class Movie(Playable, Video):
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
Playable._loadData(self, data)
self.art = data.attrib.get('art')
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
self.chapters = self.findItems(data, media.Chapter)
self.chapterSource = data.attrib.get('chapterSource')
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.duration = utils.cast(int, data.attrib.get('duration'))
self.guid = data.attrib.get('guid')
self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.labels = self.findItems(data, media.Label)
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.originalTitle = data.attrib.get('originalTitle')
self.originallyAvailableAt = utils.toDatetime(
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
self.producers = self.findItems(data, media.Producer)
self.rating = utils.cast(float, data.attrib.get('rating'))
self.ratingImage = data.attrib.get('ratingImage')
self.roles = self.findItems(data, media.Role)
self.similar = self.findItems(data, media.Similar)
self.studio = data.attrib.get('studio')
self.tagline = data.attrib.get('tagline')
self.userRating = utils.cast(float, data.attrib.get('userRating'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
self.collections = self.findItems(data, media.Collection)
self.countries = self.findItems(data, media.Country)
self.directors = self.findItems(data, media.Director)
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.media = self.findItems(data, media.Media)
self.producers = self.findItems(data, media.Producer)
self.roles = self.findItems(data, media.Role)
self.writers = self.findItems(data, media.Writer)
self.labels = self.findItems(data, media.Label)
self.chapters = self.findItems(data, media.Chapter)
self.similar = self.findItems(data, media.Similar)
self.year = utils.cast(int, data.attrib.get('year'))
@property
def actors(self):
@ -329,7 +337,10 @@ class Movie(Playable, Video):
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Movie
interface to get the locations of the movie.
Retruns:
List<str> of file paths where the movie is found on disk.
"""
return [part.file for part in self.iterParts() if part]
@ -337,6 +348,15 @@ class Movie(Playable, Video):
# This is just for compat.
return self.title
def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
data = self._server.query(self._details_key)
video = data.find('Video')
if video:
related = video.find('Related')
if related:
return self.findItems(related, library.Hub)
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
@ -371,61 +391,56 @@ class Show(Video):
Attributes:
TAG (str): 'Directory'
TYPE (str): 'show'
art (str): Key to show artwork (/library/metadata/<ratingkey>/art/<artid>)
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/art/<artid>)
childCount (int): Unknown.
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/banner/<bannerid>).
childCount (int): Number of seasons in the show.
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
contentRating (str) Content rating (PG-13; NR; TV-G).
collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs.
duration (int): Duration of show in milliseconds.
guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
index (int): Plex index (?)
leafCount (int): Unknown.
locations (list<str>): List of locations paths.
originallyAvailableAt (datetime): Datetime show was released.
rating (float): Show rating (7.9; 9.8; 8.1).
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
theme (str): Key to theme resource (/library/metadata/<ratingkey>/theme/<themeid>)
viewedLeafCount (int): Unknown.
year (int): Year the show was released.
duration (int): Typical duration of the show episodes in milliseconds.
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
index (int): Plex index number for the show.
key (str): API URL (/library/metadata/<ratingkey>).
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
leafCount (int): Number of items in the show view.
locations (List<str>): List of folder paths where the show is found on disk.
originallyAvailableAt (datetime): Datetime the show was released.
rating (float): Show rating (7.9; 9.8; 8.1).
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
viewedLeafCount (int): Number of items marked as played in the show view.
year (int): Year the show was released.
"""
TAG = 'Directory'
TYPE = 'show'
METADATA_TYPE = 'episode'
def __iter__(self):
for season in self.seasons():
yield season
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
# fix key if loaded from search
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.art = data.attrib.get('art')
self.banner = data.attrib.get('banner')
self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.contentRating = data.attrib.get('contentRating')
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.guid = data.attrib.get('guid')
self.index = data.attrib.get('index')
self.genres = self.findItems(data, media.Genre)
self.index = utils.cast(int, data.attrib.get('index'))
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.locations = self.listAttrs(data, 'path', etag='Location')
self.originallyAvailableAt = utils.toDatetime(
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.rating = utils.cast(float, data.attrib.get('rating'))
self.roles = self.findItems(data, media.Role)
self.similar = self.findItems(data, media.Similar)
self.studio = data.attrib.get('studio')
self.theme = data.attrib.get('theme')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year'))
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.roles = self.findItems(data, media.Role)
self.labels = self.findItems(data, media.Label)
self.similar = self.findItems(data, media.Similar)
def __iter__(self):
for season in self.seasons():
yield season
@property
def actors(self):
@ -434,29 +449,88 @@ class Show(Video):
@property
def isWatched(self):
""" Returns True if this show is fully watched. """
""" Returns True if the show is fully watched. """
return bool(self.viewedLeafCount == self.leafCount)
def seasons(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Season` objects. """
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
return self.fetchItems(key, **kwargs)
def preferences(self):
""" Returns a list of :class:`~plexapi.settings.Preferences` objects. """
items = []
data = self._server.query(self._details_key)
for item in data.iter('Preferences'):
for elem in item:
setting = settings.Preferences(data=elem, server=self._server)
setting._initpath = self.key
items.append(setting)
def season(self, title=None):
return items
def editAdvanced(self, **kwargs):
""" Edit a show's advanced settings. """
data = {}
key = '%s/prefs?' % self.key
preferences = {pref.id: list(pref.enumValues.keys()) for pref in self.preferences()}
for settingID, value in kwargs.items():
enumValues = preferences.get(settingID)
if value in enumValues:
data[settingID] = value
else:
raise NotFound('%s not found in %s' % (value, enumValues))
url = key + urlencode(data)
self._server.query(url, method=self._server._session.put)
def defaultAdvanced(self):
""" Edit all of show's advanced settings to default. """
data = {}
key = '%s/prefs?' % self.key
for preference in self.preferences():
data[preference.id] = preference.default
url = key + urlencode(data)
self._server.query(url, method=self._server._session.put)
def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
data = self._server.query(self._details_key)
directory = data.find('Directory')
if directory:
related = directory.find('Related')
if related:
return self.findItems(related, library.Hub)
def onDeck(self):
""" Returns show's On Deck :class:`~plexapi.video.Video` object or `None`.
If show is unwatched, return will likely be the first episode.
"""
data = self._server.query(self._details_key)
episode = next(data.iter('OnDeck'), None)
if episode:
return self.findItems(episode)[0]
return None
def season(self, title=None, season=None):
""" Returns the season with the specified title or number.
Parameters:
title (str or int): Title or Number of the season to return.
title (str): Title of the season to return.
season (int): Season number (default: None; required if title not specified).
Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing.
"""
key = '/library/metadata/%s/children' % self.ratingKey
if title is not None and not isinstance(title, int):
return self.fetchItem(key, Season, title__iexact=title)
elif season is not None or isinstance(title, int):
if isinstance(title, int):
return self.fetchItem(key, etag='Directory', index__iexact=str(title))
return self.fetchItem(key, etag='Directory', title__iexact=title)
index = title
else:
index = season
return self.fetchItem(key, Season, index=index)
raise BadRequest('Missing argument: title or season is required')
def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
key = '/library/metadata/%s/allLeaves' % self.ratingKey
return self.fetchItems(key, **kwargs)
def seasons(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Season` objects in the show. """
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
return self.fetchItems(key, Season, **kwargs)
def episode(self, title=None, season=None, episode=None):
""" Find a episode using a title or season and episode.
@ -467,19 +541,24 @@ class Show(Video):
episode (int): Episode number (default: None; required if title not specified).
Raises:
:exc:`plexapi.exceptions.BadRequest`: If season and episode is missing.
:exc:`plexapi.exceptions.NotFound`: If the episode is missing.
:exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing.
"""
if title:
key = '/library/metadata/%s/allLeaves' % self.ratingKey
return self.fetchItem(key, title__iexact=title)
elif season is not None and episode:
results = [i for i in self.episodes() if i.seasonNumber == season and i.index == episode]
if results:
return results[0]
raise NotFound('Couldnt find %s S%s E%s' % (self.title, season, episode))
if title is not None:
return self.fetchItem(key, Episode, title__iexact=title)
elif season is not None and episode is not None:
return self.fetchItem(key, Episode, parentIndex=season, index=episode)
raise BadRequest('Missing argument: title or season and episode are required')
def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects in the show. """
key = '/library/metadata/%s/allLeaves' % self.ratingKey
return self.fetchItems(key, Episode, **kwargs)
def get(self, title=None, season=None, episode=None):
""" Alias to :func:`~plexapi.video.Show.episode`. """
return self.episode(title, season, episode)
def watched(self):
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
return self.episodes(viewCount__gt=0)
@ -488,10 +567,6 @@ class Show(Video):
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
return self.episodes(viewCount=0)
def get(self, title=None, season=None, episode=None):
""" Alias to :func:`~plexapi.video.Show.episode`. """
return self.episode(title, season, episode)
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
@ -514,31 +589,28 @@ class Season(Video):
Attributes:
TAG (str): 'Directory'
TYPE (str): 'season'
leafCount (int): Number of episodes in season.
index (int): Season number.
parentKey (str): Key to this seasons :class:`~plexapi.video.Show`.
parentRatingKey (int): Unique key for this seasons :class:`~plexapi.video.Show`.
parentTitle (str): Title of this seasons :class:`~plexapi.video.Show`.
viewedLeafCount (int): Number of watched episodes in season.
key (str): API URL (/library/metadata/<ratingkey>).
leafCount (int): Number of items in the season view.
parentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6).
parentIndex (int): Plex index number for the show.
parentKey (str): API URL of the show (/library/metadata/<parentRatingKey>).
parentRatingKey (int): Unique key identifying the show.
parentTheme (str): URL to show theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>).
parentThumb (str): URL to show thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the show for the season.
viewedLeafCount (int): Number of items marked as played in the season view.
"""
TAG = 'Directory'
TYPE = 'season'
METADATA_TYPE = 'episode'
def __iter__(self):
for episode in self.episodes():
yield episode
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
# fix key if loaded from search
self.key = self.key.replace('/children', '')
art = data.attrib.get('art')
self.art = art if art and str(self.ratingKey) in art else None
self.guid = data.attrib.get('guid')
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.index = utils.cast(int, data.attrib.get('index'))
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey')
@ -547,7 +619,10 @@ class Season(Video):
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.fields = self.findItems(data, media.Field)
def __iter__(self):
for episode in self.episodes():
yield episode
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
@ -558,7 +633,7 @@ class Season(Video):
@property
def isWatched(self):
""" Returns True if this season is fully watched. """
""" Returns True if the season is fully watched. """
return bool(self.viewedLeafCount == self.leafCount)
@property
@ -567,9 +642,9 @@ class Season(Video):
return self.index
def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, **kwargs)
return self.fetchItems(key, Episode, **kwargs)
def episode(self, title=None, episode=None):
""" Returns the episode with the given title or number.
@ -577,21 +652,34 @@ class Season(Video):
Parameters:
title (str): Title of the episode to return.
episode (int): Episode number (default: None; required if title not specified).
Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing.
"""
if not title and not episode:
raise BadRequest('Missing argument, you need to use title or episode.')
key = '/library/metadata/%s/children' % self.ratingKey
if title:
return self.fetchItem(key, title=title)
return self.fetchItem(key, parentIndex=self.index, index=episode)
if title is not None:
return self.fetchItem(key, Episode, title__iexact=title)
elif episode is not None:
return self.fetchItem(key, Episode, parentIndex=self.index, index=episode)
raise BadRequest('Missing argument: title or episode is required')
def get(self, title=None, episode=None):
""" Alias to :func:`~plexapi.video.Season.episode`. """
return self.episode(title, episode)
def onDeck(self):
""" Returns season's On Deck :class:`~plexapi.video.Video` object or `None`.
Will only return a match if the show's On Deck episode is in this season.
"""
data = self._server.query(self._details_key)
episode = next(data.iter('OnDeck'), None)
if episode:
return self.findItems(episode)[0]
return None
def show(self):
""" Return this seasons :func:`~plexapi.video.Show`.. """
return self.fetchItem(int(self.parentRatingKey))
""" Return the season's :class:`~plexapi.video.Show`. """
return self.fetchItem(self.parentRatingKey)
def watched(self):
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
@ -627,31 +715,32 @@ class Episode(Playable, Video):
Attributes:
TAG (str): 'Video'
TYPE (str): 'episode'
art (str): Key to episode artwork (/library/metadata/<ratingkey>/art/<artid>)
chapterSource (str): Unknown (media).
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
chapterSource (str): Chapter source (agent; media; mixed).
contentRating (str) Content rating (PG-13; NR; TV-G).
duration (int): Duration of episode in milliseconds.
grandparentArt (str): Key to this episodes :class:`~plexapi.video.Show` artwork.
grandparentKey (str): Key to this episodes :class:`~plexapi.video.Show`.
grandparentRatingKey (str): Unique key for this episodes :class:`~plexapi.video.Show`.
grandparentTheme (str): Key to this episodes :class:`~plexapi.video.Show` theme.
grandparentThumb (str): Key to this episodes :class:`~plexapi.video.Show` thumb.
grandparentTitle (str): Title of this episodes :class:`~plexapi.video.Show`.
guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
index (int): Episode number.
originallyAvailableAt (datetime): Datetime episode was released.
parentIndex (str): Season number of episode.
parentKey (str): Key to this episodes :class:`~plexapi.video.Season`.
parentRatingKey (int): Unique key for this episodes :class:`~plexapi.video.Season`.
parentThumb (str): Key to this episodes thumbnail.
parentTitle (str): Name of this episode's season
title (str): Name of this Episode
rating (float): Movie rating (7.9; 9.8; 8.1).
viewOffset (int): View offset in milliseconds.
year (int): Year episode was released.
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
duration (int): Duration of the episode in milliseconds.
grandparentArt (str): URL to show artwork (/library/metadata/<grandparentRatingKey>/art/<artid>).
grandparentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6).
grandparentKey (str): API URL of the show (/library/metadata/<grandparentRatingKey>).
grandparentRatingKey (int): Unique key identifying the show.
grandparentTheme (str): URL to show theme resource (/library/metadata/<grandparentRatingkey>/theme/<themeid>).
grandparentThumb (str): URL to show thumbnail image (/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
grandparentTitle (str): Name of the show for the episode.
index (int): Episode number.
markers (List<:class:`~plexapi.media.Marker`>): List of marker objects.
media (List<:class:`~plexapi.media.Media`>): List of media objects.
originallyAvailableAt (datetime): Datetime the episode was released.
parentGuid (str): Plex GUID for the season (plex://season/5d9c09e42df347001e3c2a72).
parentIndex (int): Season number of episode.
parentKey (str): API URL of the season (/library/metadata/<parentRatingKey>).
parentRatingKey (int): Unique key identifying the season.
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the season for the episode.
rating (float): Episode rating (7.9; 9.8; 8.1).
viewOffset (int): View offset in milliseconds.
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
year (int): Year episode was released.
"""
TAG = 'Video'
TYPE = 'episode'
@ -662,10 +751,10 @@ class Episode(Playable, Video):
Video._loadData(self, data)
Playable._loadData(self, data)
self._seasonNumber = None # cached season number
art = data.attrib.get('art')
self.art = art if art and str(self.ratingKey) in art else None
self.chapters = self.findItems(data, media.Chapter)
self.chapterSource = data.attrib.get('chapterSource')
self.contentRating = data.attrib.get('contentRating')
self.directors = self.findItems(data, media.Director)
self.duration = utils.cast(int, data.attrib.get('duration'))
self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid')
@ -674,27 +763,20 @@ class Episode(Playable, Video):
self.grandparentTheme = data.attrib.get('grandparentTheme')
self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.markers = self.findItems(data, media.Marker)
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.title = data.attrib.get('title')
self.rating = utils.cast(float, data.attrib.get('rating'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
self.directors = self.findItems(data, media.Director)
self.fields = self.findItems(data, media.Field)
self.media = self.findItems(data, media.Media)
self.writers = self.findItems(data, media.Writer)
self.labels = self.findItems(data, media.Label)
self.collections = self.findItems(data, media.Collection)
self.chapters = self.findItems(data, media.Chapter)
self.markers = self.findItems(data, media.Marker)
self.year = utils.cast(int, data.attrib.get('year'))
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
@ -710,13 +792,16 @@ class Episode(Playable, Video):
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Episode
interface to get the locations of the episode.
Retruns:
List<str> of file paths where the episode is found on disk.
"""
return [part.file for part in self.iterParts() if part]
@property
def seasonNumber(self):
""" Returns this episodes season number. """
""" Returns the episodes season number. """
if self._seasonNumber is None:
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
return utils.cast(int, self._seasonNumber)
@ -728,18 +813,18 @@ class Episode(Playable, Video):
@property
def hasIntroMarker(self):
""" Returns True if this episode has an intro marker in the xml. """
""" Returns True if the episode has an intro marker in the xml. """
if not self.isFullObject():
self.reload()
return any(marker.type == 'intro' for marker in self.markers)
def season(self):
"""" Return this episodes :func:`~plexapi.video.Season`.. """
"""" Return the episode's :class:`~plexapi.video.Season`. """
return self.fetchItem(self.parentKey)
def show(self):
"""" Return this episodes :func:`~plexapi.video.Show`.. """
return self.fetchItem(int(self.grandparentRatingKey))
"""" Return the episode's :class:`~plexapi.video.Show`. """
return self.fetchItem(self.grandparentRatingKey)
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
@ -748,7 +833,22 @@ class Episode(Playable, Video):
@utils.registerPlexObject
class Clip(Playable, Video):
""" Represents a single Clip."""
"""Represents a single Clip.
Attributes:
TAG (str): 'Video'
TYPE (str): 'clip'
duration (int): Duration of the clip in milliseconds.
extraType (int): Unknown.
index (int): Plex index number for the clip.
media (List<:class:`~plexapi.media.Media`>): List of media objects.
originallyAvailableAt (datetime): Datetime the clip was released.
skipDetails (int): Unknown.
subtype (str): Type of clip (trailer, behindTheScenes, sceneOrSample, etc.).
thumbAspectRatio (str): Aspect ratio of the thumbnail image.
viewOffset (int): View offset in milliseconds.
year (int): Year clip was released.
"""
TAG = 'Video'
TYPE = 'clip'
@ -759,24 +859,23 @@ class Clip(Playable, Video):
Video._loadData(self, data)
Playable._loadData(self, data)
self._data = data
self.addedAt = data.attrib.get('addedAt')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key')
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.ratingKey = data.attrib.get('ratingKey')
self.extraType = utils.cast(int, data.attrib.get('extraType'))
self.index = utils.cast(int, data.attrib.get('index'))
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = data.attrib.get('originallyAvailableAt')
self.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))
self.subtype = data.attrib.get('subtype')
self.thumb = data.attrib.get('thumb')
self.thumbAspectRatio = data.attrib.get('thumbAspectRatio')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
self.media = self.findItems(data, media.Media)
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Clip
interface to get the locations of the clip.
Retruns:
List<str> of file paths where the clip is found on disk.
"""
return [part.file for part in self.iterParts() if part]