mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-07 21:51:14 -07:00
Update plexapi to 4.3.0
This commit is contained in:
parent
6b013da697
commit
f497c11d73
18 changed files with 2365 additions and 1061 deletions
|
@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH)
|
||||||
|
|
||||||
# PlexAPI Settings
|
# PlexAPI Settings
|
||||||
PROJECT = 'PlexAPI'
|
PROJECT = 'PlexAPI'
|
||||||
VERSION = '3.6.0'
|
VERSION = '4.3.0'
|
||||||
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
|
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
|
||||||
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, 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)
|
X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)
|
||||||
|
|
|
@ -1,31 +1,39 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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.base import Playable, PlexPartialObject
|
||||||
from plexapi.compat import quote_plus
|
from plexapi.exceptions import BadRequest
|
||||||
|
|
||||||
|
|
||||||
class Audio(PlexPartialObject):
|
class Audio(PlexPartialObject):
|
||||||
""" Base class for audio :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`
|
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
|
||||||
and :class:`~plexapi.audio.Track` objects.
|
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
addedAt (datetime): Datetime this item was added to the library.
|
addedAt (datetime): Datetime the item was added to the library.
|
||||||
art (str): URL to artwork image.
|
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||||
artBlurHash (str): BlurHash string for artwork image.
|
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>).
|
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.
|
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).
|
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||||
ratingKey (int): Unique key identifying this item.
|
moods (List<:class:`~plexapi.media.Mood`>): List of mood objects.
|
||||||
summary (str): Summary of the artist, track, or album.
|
ratingKey (int): Unique key identifying the item.
|
||||||
thumb (str): URL to thumbnail image.
|
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.
|
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).
|
titleSort (str): Title to use when sorting (defaults to title).
|
||||||
type (str): 'artist', 'album', or 'track'.
|
type (str): 'artist', 'album', or 'track'.
|
||||||
updatedAt (datatime): Datetime this item was updated.
|
updatedAt (datatime): Datetime the item was updated.
|
||||||
viewCount (int): Count of times this item was accessed.
|
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'
|
METADATA_TYPE = 'track'
|
||||||
|
@ -33,16 +41,19 @@ class Audio(PlexPartialObject):
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
self._data = data
|
self._data = data
|
||||||
self.listType = 'audio'
|
|
||||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||||
self.art = data.attrib.get('art')
|
self.art = data.attrib.get('art')
|
||||||
self.artBlurHash = data.attrib.get('artBlurHash')
|
self.artBlurHash = data.attrib.get('artBlurHash')
|
||||||
self.index = data.attrib.get('index')
|
self.fields = self.findItems(data, media.Field)
|
||||||
self.key = data.attrib.get('key')
|
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.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
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.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||||
self.summary = data.attrib.get('summary')
|
self.summary = data.attrib.get('summary')
|
||||||
self.thumb = data.attrib.get('thumb')
|
self.thumb = data.attrib.get('thumb')
|
||||||
|
@ -51,6 +62,7 @@ class Audio(PlexPartialObject):
|
||||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
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))
|
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -66,7 +78,7 @@ class Audio(PlexPartialObject):
|
||||||
return self._server.url(art, includeToken=True) if art else None
|
return self._server.url(art, includeToken=True) if art else None
|
||||||
|
|
||||||
def url(self, part):
|
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
|
return self._server.url(part, includeToken=True) if part else None
|
||||||
|
|
||||||
def _defaultSyncTitle(self):
|
def _defaultSyncTitle(self):
|
||||||
|
@ -112,17 +124,18 @@ class Audio(PlexPartialObject):
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Artist(Audio):
|
class Artist(Audio):
|
||||||
""" Represents a single audio artist.
|
""" Represents a single Artist.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Directory'
|
TAG (str): 'Directory'
|
||||||
TYPE (str): 'artist'
|
TYPE (str): 'artist'
|
||||||
countries (list): List of :class:`~plexapi.media.Country` objects this artist respresents.
|
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||||
genres (list): List of :class:`~plexapi.media.Genre` objects this artist respresents.
|
countries (List<:class:`~plexapi.media.Country`>): List country objects.
|
||||||
guid (str): Unknown (unique ID; com.plexapp.agents.plexmusic://gracenote/artist/05517B8701668D28?lang=en)
|
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||||
key (str): API URL (/library/metadata/<ratingkey>).
|
key (str): API URL (/library/metadata/<ratingkey>).
|
||||||
location (str): Filepath this artist is found on disk.
|
locations (List<str>): List of folder paths where the artist is found on disk.
|
||||||
similar (list): List of :class:`~plexapi.media.Similar` artists.
|
similar (List<:class:`~plexapi.media.Similar`>): List of similar objects.
|
||||||
|
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||||
"""
|
"""
|
||||||
TAG = 'Directory'
|
TAG = 'Directory'
|
||||||
TYPE = 'artist'
|
TYPE = 'artist'
|
||||||
|
@ -130,55 +143,70 @@ class Artist(Audio):
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
Audio._loadData(self, data)
|
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.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)
|
self.styles = self.findItems(data, media.Style)
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
for album in self.albums():
|
for album in self.albums():
|
||||||
yield album
|
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):
|
def album(self, title):
|
||||||
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
|
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str): Title of the album to return.
|
title (str): Title of the album to return.
|
||||||
"""
|
"""
|
||||||
key = '%s/children' % self.key
|
key = '/library/metadata/%s/children' % self.ratingKey
|
||||||
return self.fetchItem(key, title__iexact=title)
|
return self.fetchItem(key, Album, title__iexact=title)
|
||||||
|
|
||||||
def albums(self, **kwargs):
|
def albums(self, **kwargs):
|
||||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by this artist. """
|
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
|
||||||
key = '%s/children' % self.key
|
key = '/library/metadata/%s/children' % self.ratingKey
|
||||||
return self.fetchItems(key, **kwargs)
|
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.
|
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str): Title of the track to return.
|
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
|
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||||
return self.fetchItem(key, title__iexact=title)
|
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):
|
def tracks(self, **kwargs):
|
||||||
""" Returns a list of :class:`~plexapi.audio.Track` objects by this artist. """
|
""" Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """
|
||||||
key = '%s/allLeaves' % self.key
|
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||||
return self.fetchItems(key, **kwargs)
|
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`. """
|
""" 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):
|
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:
|
Parameters:
|
||||||
savepath (str): Title of the track to return.
|
savepath (str): Title of the track to return.
|
||||||
|
@ -199,76 +227,89 @@ class Artist(Audio):
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Album(Audio):
|
class Album(Audio):
|
||||||
""" Represents a single audio album.
|
""" Represents a single Album.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Directory'
|
TAG (str): 'Directory'
|
||||||
TYPE (str): 'album'
|
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>).
|
key (str): API URL (/library/metadata/<ratingkey>).
|
||||||
originallyAvailableAt (datetime): Datetime this album was released.
|
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||||
parentKey (str): API URL of this artist.
|
leafCount (int): Number of items in the album view.
|
||||||
parentRatingKey (int): Unique key identifying artist.
|
loudnessAnalysisVersion (int): The Plex loudness analysis version level.
|
||||||
parentThumb (str): URL to artist thumbnail image.
|
originallyAvailableAt (datetime): Datetime the album was released.
|
||||||
parentTitle (str): Name of the artist for this album.
|
parentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
|
||||||
studio (str): Studio that released this album.
|
parentKey (str): API URL of the album artist (/library/metadata/<parentRatingKey>).
|
||||||
year (int): Year this album was released.
|
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'
|
TAG = 'Directory'
|
||||||
TYPE = 'album'
|
TYPE = 'album'
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
for track in self.tracks:
|
|
||||||
yield track
|
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
Audio._loadData(self, data)
|
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.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||||
self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
|
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.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||||
self.parentGuid = data.attrib.get('parentGuid')
|
self.parentGuid = data.attrib.get('parentGuid')
|
||||||
self.parentKey = data.attrib.get('parentKey')
|
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.parentThumb = data.attrib.get('parentThumb')
|
||||||
self.parentTitle = data.attrib.get('parentTitle')
|
self.parentTitle = data.attrib.get('parentTitle')
|
||||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||||
self.studio = data.attrib.get('studio')
|
self.studio = data.attrib.get('studio')
|
||||||
|
self.styles = self.findItems(data, media.Style)
|
||||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
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.
|
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str): Title of the track to return.
|
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
|
key = '/library/metadata/%s/children' % self.ratingKey
|
||||||
return self.fetchItem(key, title__iexact=title)
|
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):
|
def tracks(self, **kwargs):
|
||||||
""" Returns a list of :class:`~plexapi.audio.Track` objects in this album. """
|
""" Returns a list of :class:`~plexapi.audio.Track` objects in the album. """
|
||||||
key = '%s/children' % self.key
|
key = '/library/metadata/%s/children' % self.ratingKey
|
||||||
return self.fetchItems(key, **kwargs)
|
return self.fetchItems(key, Track, **kwargs)
|
||||||
|
|
||||||
def get(self, title):
|
def get(self, title=None, track=None):
|
||||||
""" Alias of :func:`~plexapi.audio.Album.track`. """
|
""" Alias of :func:`~plexapi.audio.Album.track`. """
|
||||||
return self.track(title)
|
return self.track(title, track)
|
||||||
|
|
||||||
def artist(self):
|
def artist(self):
|
||||||
""" Return :func:`~plexapi.audio.Artist` of this album. """
|
""" Return the album's :class:`~plexapi.audio.Artist`. """
|
||||||
return self.fetchItem(self.parentKey)
|
return self.fetchItem(self.parentKey)
|
||||||
|
|
||||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
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:
|
Parameters:
|
||||||
savepath (str): Title of the track to return.
|
savepath (str): Title of the track to return.
|
||||||
|
@ -292,37 +333,32 @@ class Album(Audio):
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Track(Audio, Playable):
|
class Track(Audio, Playable):
|
||||||
""" Represents a single audio track.
|
""" Represents a single Track.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Directory'
|
TAG (str): 'Directory'
|
||||||
TYPE (str): 'track'
|
TYPE (str): 'track'
|
||||||
chapterSource (TYPE): Unknown
|
chapterSource (str): Unknown
|
||||||
duration (int): Length of this album in seconds.
|
duration (int): Length of the track in milliseconds.
|
||||||
grandparentArt (str): Album artist artwork.
|
grandparentArt (str): URL to album artist artwork (/library/metadata/<grandparentRatingKey>/art/<artid>).
|
||||||
grandparentKey (str): Album artist API URL.
|
grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
|
||||||
grandparentRatingKey (str): Unique key identifying album artist.
|
grandparentKey (str): API URL of the album artist (/library/metadata/<grandparentRatingKey>).
|
||||||
grandparentThumb (str): URL to album artist thumbnail image.
|
grandparentRatingKey (int): Unique key identifying the album artist.
|
||||||
grandparentTitle (str): Name of the album artist for this track.
|
grandparentThumb (str): URL to album artist thumbnail image
|
||||||
guid (str): Unknown (unique ID).
|
(/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
|
||||||
media (list): List of :class:`~plexapi.media.Media` objects for this track.
|
grandparentTitle (str): Name of the album artist for the track.
|
||||||
moods (list): List of :class:`~plexapi.media.Mood` objects for this track.
|
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||||
originalTitle (str): Track artist.
|
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.
|
parentIndex (int): Album index.
|
||||||
parentKey (str): Album API URL.
|
parentKey (str): API URL of the album (/library/metadata/<parentRatingKey>).
|
||||||
parentRatingKey (int): Unique key identifying album.
|
parentRatingKey (int): Unique key identifying the album.
|
||||||
parentThumb (str): URL to album thumbnail image.
|
parentThumb (str): URL to album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||||
parentTitle (str): Name of the album for this track.
|
parentTitle (str): Name of the album for the track.
|
||||||
primaryExtraKey (str): Unknown
|
primaryExtraKey (str) API URL for the primary extra for the track.
|
||||||
ratingCount (int): Unknown
|
ratingCount (int): Number of ratings contributing to the rating score.
|
||||||
userRating (float): Rating of this track (0.0 - 10.0) equaling (0 stars - 5 stars)
|
viewOffset (int): View offset in milliseconds.
|
||||||
viewOffset (int): Unknown
|
year (int): Year the track was released.
|
||||||
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).
|
|
||||||
"""
|
"""
|
||||||
TAG = 'Track'
|
TAG = 'Track'
|
||||||
TYPE = 'track'
|
TYPE = 'track'
|
||||||
|
@ -336,42 +372,41 @@ class Track(Audio, Playable):
|
||||||
self.grandparentArt = data.attrib.get('grandparentArt')
|
self.grandparentArt = data.attrib.get('grandparentArt')
|
||||||
self.grandparentGuid = data.attrib.get('grandparentGuid')
|
self.grandparentGuid = data.attrib.get('grandparentGuid')
|
||||||
self.grandparentKey = data.attrib.get('grandparentKey')
|
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.grandparentThumb = data.attrib.get('grandparentThumb')
|
||||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
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.originalTitle = data.attrib.get('originalTitle')
|
||||||
self.parentGuid = data.attrib.get('parentGuid')
|
self.parentGuid = data.attrib.get('parentGuid')
|
||||||
self.parentIndex = data.attrib.get('parentIndex')
|
self.parentIndex = data.attrib.get('parentIndex')
|
||||||
self.parentKey = data.attrib.get('parentKey')
|
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.parentThumb = data.attrib.get('parentThumb')
|
||||||
self.parentTitle = data.attrib.get('parentTitle')
|
self.parentTitle = data.attrib.get('parentTitle')
|
||||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||||
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
|
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.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
self.year = utils.cast(int, data.attrib.get('year'))
|
||||||
self.media = self.findItems(data, media.Media)
|
|
||||||
self.moods = self.findItems(data, media.Mood)
|
|
||||||
self.fields = self.findItems(data, media.Field)
|
|
||||||
|
|
||||||
def _prettyfilename(self):
|
def _prettyfilename(self):
|
||||||
""" Returns a filename for use in download. """
|
""" Returns a filename for use in download. """
|
||||||
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
|
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
|
||||||
|
|
||||||
def album(self):
|
def album(self):
|
||||||
""" Return this track's :class:`~plexapi.audio.Album`. """
|
""" Return the track's :class:`~plexapi.audio.Album`. """
|
||||||
return self.fetchItem(self.parentKey)
|
return self.fetchItem(self.parentKey)
|
||||||
|
|
||||||
def artist(self):
|
def artist(self):
|
||||||
""" Return this track's :class:`~plexapi.audio.Artist`. """
|
""" Return the track's :class:`~plexapi.audio.Artist`. """
|
||||||
return self.fetchItem(self.grandparentKey)
|
return self.fetchItem(self.grandparentKey)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def locations(self):
|
def locations(self):
|
||||||
""" This does not exist in plex xml response but is added to have a common
|
""" This does not exist in plex xml response but is added to have a common
|
||||||
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]
|
return [part.file for part in self.iterParts() if part]
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import re
|
import re
|
||||||
|
import weakref
|
||||||
|
from urllib.parse import quote_plus, urlencode
|
||||||
|
|
||||||
from plexapi import log, utils
|
from plexapi import log, utils
|
||||||
from plexapi.compat import quote_plus, urlencode
|
|
||||||
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
|
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
|
||||||
from plexapi.utils import tag_helper
|
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)
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||||
initpath (str): Relative path requested when retrieving specified `data` (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
|
TAG = None # xml element tag
|
||||||
TYPE = None # xml element type
|
TYPE = None # xml element type
|
||||||
key = None # plex relative url
|
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._server = server
|
||||||
self._data = data
|
self._data = data
|
||||||
self._initpath = initpath or self.key
|
self._initpath = initpath or self.key
|
||||||
|
self._parent = weakref.ref(parent) if parent else None
|
||||||
if data is not None:
|
if data is not None:
|
||||||
self._loadData(data)
|
self._loadData(data)
|
||||||
self._details_key = self._buildDetailsKey()
|
self._details_key = self._buildDetailsKey()
|
||||||
|
@ -54,8 +57,8 @@ class PlexObject(object):
|
||||||
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p])
|
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p])
|
||||||
|
|
||||||
def __setattr__(self, attr, value):
|
def __setattr__(self, attr, value):
|
||||||
# dont overwrite an attr with None unless its a private variable
|
# Don't overwrite an attr with None or [] unless it's a private variable
|
||||||
if value is not None or attr.startswith('_') or attr not in self.__dict__:
|
if value not in [None, []] or attr.startswith('_') or attr not in self.__dict__:
|
||||||
self.__dict__[attr] = value
|
self.__dict__[attr] = value
|
||||||
|
|
||||||
def _clean(self, value):
|
def _clean(self, value):
|
||||||
|
@ -63,6 +66,8 @@ class PlexObject(object):
|
||||||
if value:
|
if value:
|
||||||
value = str(value).replace('/library/metadata/', '')
|
value = str(value).replace('/library/metadata/', '')
|
||||||
value = value.replace('/children', '')
|
value = value.replace('/children', '')
|
||||||
|
value = value.replace('/accounts/', '')
|
||||||
|
value = value.replace('/devices/', '')
|
||||||
return value.replace(' ', '-')[:20]
|
return value.replace(' ', '-')[:20]
|
||||||
|
|
||||||
def _buildItem(self, elem, cls=None, initpath=None):
|
def _buildItem(self, elem, cls=None, initpath=None):
|
||||||
|
@ -70,9 +75,9 @@ class PlexObject(object):
|
||||||
# cls is specified, build the object and return
|
# cls is specified, build the object and return
|
||||||
initpath = initpath or self._initpath
|
initpath = initpath or self._initpath
|
||||||
if cls is not None:
|
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
|
# 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
|
ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag
|
||||||
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
|
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
|
||||||
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
|
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
|
||||||
|
@ -95,7 +100,7 @@ class PlexObject(object):
|
||||||
or disable each parameter individually by setting it to False or 0.
|
or disable each parameter individually by setting it to False or 0.
|
||||||
"""
|
"""
|
||||||
details_key = self.key
|
details_key = self.key
|
||||||
if hasattr(self, '_INCLUDES'):
|
if details_key and hasattr(self, '_INCLUDES'):
|
||||||
includes = {}
|
includes = {}
|
||||||
for k, v in self._INCLUDES.items():
|
for k, v in self._INCLUDES.items():
|
||||||
value = kwargs.get(k, v)
|
value = kwargs.get(k, v)
|
||||||
|
@ -105,6 +110,21 @@ class PlexObject(object):
|
||||||
details_key += '?' + urlencode(sorted(includes.items()))
|
details_key += '?' + urlencode(sorted(includes.items()))
|
||||||
return details_key
|
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):
|
def fetchItem(self, ekey, cls=None, **kwargs):
|
||||||
""" Load the specified key to find and build the first item with the
|
""" 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
|
specified tag and attrs. If no tag or attrs are specified then
|
||||||
|
@ -212,6 +232,7 @@ class PlexObject(object):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def listAttrs(self, data, attr, **kwargs):
|
def listAttrs(self, data, attr, **kwargs):
|
||||||
|
""" Return a list of values from matching attribute. """
|
||||||
results = []
|
results = []
|
||||||
for elem in data:
|
for elem in data:
|
||||||
kwargs['%s__exists' % attr] = True
|
kwargs['%s__exists' % attr] = True
|
||||||
|
@ -350,7 +371,7 @@ class PlexPartialObject(PlexObject):
|
||||||
}
|
}
|
||||||
|
|
||||||
def __eq__(self, other):
|
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):
|
def __hash__(self):
|
||||||
return hash(repr(self))
|
return hash(repr(self))
|
||||||
|
@ -391,6 +412,8 @@ class PlexPartialObject(PlexObject):
|
||||||
Playing screen to show a graphical representation of where playback
|
Playing screen to show a graphical representation of where playback
|
||||||
is. Video preview thumbnails creation is a CPU-intensive process akin
|
is. Video preview thumbnails creation is a CPU-intensive process akin
|
||||||
to transcoding the file.
|
to transcoding the file.
|
||||||
|
* Generate intro video markers: Detects show intros, exposing the
|
||||||
|
'Skip Intro' button in clients.
|
||||||
"""
|
"""
|
||||||
key = '/%s/analyze' % self.key.lstrip('/')
|
key = '/%s/analyze' % self.key.lstrip('/')
|
||||||
self._server.query(key, method=self._server._session.put)
|
self._server.query(key, method=self._server._session.put)
|
||||||
|
@ -663,6 +686,7 @@ class Playable(object):
|
||||||
if item is being transcoded (None otherwise).
|
if item is being transcoded (None otherwise).
|
||||||
viewedAt (datetime): Datetime item was last viewed (history).
|
viewedAt (datetime): Datetime item was last viewed (history).
|
||||||
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
|
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
|
||||||
|
playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
|
@ -674,6 +698,7 @@ class Playable(object):
|
||||||
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
|
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
|
||||||
self.accountID = utils.cast(int, data.attrib.get('accountID')) # history
|
self.accountID = utils.cast(int, data.attrib.get('accountID')) # history
|
||||||
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
|
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
|
||||||
|
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue
|
||||||
|
|
||||||
def getStreamURL(self, **params):
|
def getStreamURL(self, **params):
|
||||||
""" Returns a stream url that may be used by external applications such as VLC.
|
""" 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.
|
offset, copyts, protocol, mediaIndex, platform.
|
||||||
|
|
||||||
Raises:
|
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'):
|
if self.TYPE not in ('movie', 'episode', 'track'):
|
||||||
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
|
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
|
||||||
|
@ -698,7 +723,7 @@ class Playable(object):
|
||||||
'mediaIndex': params.get('mediaIndex', 0),
|
'mediaIndex': params.get('mediaIndex', 0),
|
||||||
'X-Plex-Platform': params.get('platform', 'Chrome'),
|
'X-Plex-Platform': params.get('platform', 'Chrome'),
|
||||||
'maxVideoBitrate': max(mvb, 64) if mvb else None,
|
'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
|
# remove None values
|
||||||
params = {k: v for k, v in params.items() if v is not None}
|
params = {k: v for k, v in params.items() if v is not None}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import time
|
import time
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
|
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
from plexapi.compat import ElementTree
|
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
|
||||||
from plexapi.playqueue import PlayQueue
|
from plexapi.playqueue import PlayQueue
|
||||||
from requests.status_codes import _codes as codes
|
from requests.status_codes import _codes as codes
|
||||||
|
@ -69,7 +69,9 @@ class PlexClient(PlexObject):
|
||||||
self._proxyThroughServer = False
|
self._proxyThroughServer = False
|
||||||
self._commandId = 0
|
self._commandId = 0
|
||||||
self._last_call = 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._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433')
|
||||||
self._token = logfilter.add_secret(CONFIG.get('auth.client_token'))
|
self._token = logfilter.add_secret(CONFIG.get('auth.client_token'))
|
||||||
if connect and self._baseurl:
|
if connect and self._baseurl:
|
||||||
|
@ -138,7 +140,7 @@ class PlexClient(PlexObject):
|
||||||
value (bool): Enable or disable proxying (optional, default True).
|
value (bool): Enable or disable proxying (optional, default True).
|
||||||
|
|
||||||
Raises:
|
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:
|
if server:
|
||||||
self._server = server
|
self._server = server
|
||||||
|
@ -181,7 +183,7 @@ class PlexClient(PlexObject):
|
||||||
**params (dict): Additional GET parameters to include with the command.
|
**params (dict): Additional GET parameters to include with the command.
|
||||||
|
|
||||||
Raises:
|
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('/')
|
command = command.strip('/')
|
||||||
controller = command.split('/')[0]
|
controller = command.split('/')[0]
|
||||||
|
@ -195,10 +197,11 @@ class PlexClient(PlexObject):
|
||||||
|
|
||||||
# Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244
|
# Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244
|
||||||
t = time.time()
|
t = time.time()
|
||||||
if t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'):
|
if command == 'timeline/poll':
|
||||||
url = '/player/timeline/poll?wait=0&commandID=%s' % self._nextCommandId()
|
|
||||||
query(url, headers=headers)
|
|
||||||
self._last_call = t
|
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()
|
params['commandID'] = self._nextCommandId()
|
||||||
key = '/player/%s%s' % (command, utils.joinArgs(params))
|
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.
|
**params (dict): Additional GET parameters to include with the command.
|
||||||
|
|
||||||
Raises:
|
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:
|
if not self._server:
|
||||||
raise Unsupported('A server must be specified before using this command.')
|
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
|
also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands
|
||||||
|
|
||||||
Raises:
|
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:
|
if not self._server:
|
||||||
raise Unsupported('A server must be specified before using this command.')
|
raise Unsupported('A server must be specified before using this command.')
|
||||||
|
@ -485,15 +488,6 @@ class PlexClient(PlexObject):
|
||||||
if mediatype == "audio":
|
if mediatype == "audio":
|
||||||
mediatype = "music"
|
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)
|
playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media)
|
||||||
self.sendCommand('playback/playMedia', **dict({
|
self.sendCommand('playback/playMedia', **dict({
|
||||||
'machineIdentifier': self._server.machineIdentifier,
|
'machineIdentifier': self._server.machineIdentifier,
|
||||||
|
@ -548,20 +542,68 @@ class PlexClient(PlexObject):
|
||||||
|
|
||||||
# -------------------
|
# -------------------
|
||||||
# Timeline Commands
|
# Timeline Commands
|
||||||
def timeline(self, wait=1):
|
def timelines(self, wait=0):
|
||||||
""" Poll the current timeline and return the XML response. """
|
"""Poll the client's timelines, create, and return timeline objects.
|
||||||
return self.sendCommand('timeline/poll', wait=wait)
|
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
|
||||||
""" Returns True if any media is currently playing.
|
|
||||||
|
@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:
|
Parameters:
|
||||||
includePaused (bool): Set True to treat currently paused items
|
includePaused (bool): Set True to treat currently paused items
|
||||||
as playing (optional; default True).
|
as playing (optional; default True).
|
||||||
"""
|
"""
|
||||||
for mediatype in self.timeline(wait=0):
|
state = getattr(self.timeline, "state", None)
|
||||||
if mediatype.get('state') == 'playing':
|
return bool(state == 'playing' or (includePaused and state == 'paused'))
|
||||||
return True
|
|
||||||
if includePaused and mediatype.get('state') == 'paused':
|
|
||||||
return True
|
class ClientTimeline(PlexObject):
|
||||||
return False
|
"""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'))
|
||||||
|
|
|
@ -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
|
|
|
@ -1,7 +1,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import os
|
import os
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from plexapi.compat import ConfigParser
|
from configparser import ConfigParser
|
||||||
|
|
||||||
|
|
||||||
class PlexConfig(ConfigParser):
|
class PlexConfig(ConfigParser):
|
||||||
|
@ -13,6 +13,7 @@ class PlexConfig(ConfigParser):
|
||||||
Parameters:
|
Parameters:
|
||||||
path (str): Path of the configuration file to load.
|
path (str): Path of the configuration file to load.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, path):
|
def __init__(self, path):
|
||||||
ConfigParser.__init__(self)
|
ConfigParser.__init__(self)
|
||||||
self.read(path)
|
self.read(path)
|
||||||
|
|
|
@ -23,12 +23,12 @@ class GDM:
|
||||||
"""Scan the network."""
|
"""Scan the network."""
|
||||||
self.update(scan_for_clients)
|
self.update(scan_for_clients)
|
||||||
|
|
||||||
def all(self):
|
def all(self, scan_for_clients=False):
|
||||||
"""Return all found entries.
|
"""Return all found entries.
|
||||||
|
|
||||||
Will scan for entries if not scanned recently.
|
Will scan for entries if not scanned recently.
|
||||||
"""
|
"""
|
||||||
self.scan()
|
self.scan(scan_for_clients)
|
||||||
return list(self.entries)
|
return list(self.entries)
|
||||||
|
|
||||||
def find_by_content_type(self, value):
|
def find_by_content_type(self, value):
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,41 +1,50 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import xml
|
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.base import PlexObject
|
||||||
from plexapi.exceptions import BadRequest
|
from plexapi.exceptions import BadRequest
|
||||||
from plexapi.utils import cast, SEARCHTYPES
|
from plexapi.utils import cast
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Media(PlexObject):
|
class Media(PlexObject):
|
||||||
""" Container object for all MediaPart objects. Provides useful data about the
|
""" 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:
|
Attributes:
|
||||||
TAG (str): 'Media'
|
TAG (str): 'Media'
|
||||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
aspectRatio (float): The aspect ratio of the media (ex: 2.35).
|
||||||
initpath (str): Relative path requested when retrieving specified data.
|
audioChannels (int): The number of audio channels of the media (ex: 6).
|
||||||
video (str): Video this media belongs to.
|
audioCodec (str): The audio codec of the media (ex: ac3).
|
||||||
aspectRatio (float): Aspect ratio of the video (ex: 2.35).
|
audioProfile (str): The audio profile of the media (ex: dts).
|
||||||
audioChannels (int): Number of audio channels for this video (ex: 6).
|
bitrate (int): The bitrate of the media (ex: 1624).
|
||||||
audioCodec (str): Audio codec used within the video (ex: ac3).
|
container (str): The container of the media (ex: avi).
|
||||||
bitrate (int): Bitrate of the video (ex: 1624)
|
duration (int): The duration of the media in milliseconds (ex: 6990483).
|
||||||
container (str): Container this video is in (ex: avi).
|
height (int): The height of the media in pixels (ex: 256).
|
||||||
duration (int): Length of the video in milliseconds (ex: 6990483).
|
id (int): The unique ID for this media on the server.
|
||||||
height (int): Height of the video in pixels (ex: 256).
|
has64bitOffsets (bool): True if video has 64 bit offsets.
|
||||||
id (int): Plex ID of this media item (ex: 46184).
|
|
||||||
has64bitOffsets (bool): True if video has 64 bit offsets (?).
|
|
||||||
optimizedForStreaming (bool): True if video is optimized for streaming.
|
optimizedForStreaming (bool): True if video is optimized for streaming.
|
||||||
target (str): Media version target name.
|
parts (List<:class:`~plexapi.media.MediaPart`>): List of media part objects.
|
||||||
title (str): Media version title.
|
proxyType (int): Equals 42 for optimized versions.
|
||||||
videoCodec (str): Video codec used within the video (ex: ac3).
|
target (str): The media version target name.
|
||||||
videoFrameRate (str): Video frame rate (ex: 24p).
|
title (str): The title of the media.
|
||||||
videoResolution (str): Video resolution (ex: sd).
|
videoCodec (str): The video codec of the media (ex: ac3).
|
||||||
videoProfile (str): Video profile (ex: high).
|
videoFrameRate (str): The video frame rate of the media (ex: 24p).
|
||||||
width (int): Width of the video in pixels (ex: 608).
|
videoProfile (str): The video profile of the media (ex: high).
|
||||||
parts (list<:class:`~plexapi.media.MediaPart`>): List of MediaParts in this video.
|
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'
|
TAG = 'Media'
|
||||||
|
|
||||||
|
@ -53,6 +62,8 @@ class Media(PlexObject):
|
||||||
self.id = cast(int, data.attrib.get('id'))
|
self.id = cast(int, data.attrib.get('id'))
|
||||||
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
|
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
|
||||||
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
|
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.target = data.attrib.get('target')
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
self.videoCodec = data.attrib.get('videoCodec')
|
self.videoCodec = data.attrib.get('videoCodec')
|
||||||
|
@ -60,11 +71,8 @@ class Media(PlexObject):
|
||||||
self.videoProfile = data.attrib.get('videoProfile')
|
self.videoProfile = data.attrib.get('videoProfile')
|
||||||
self.videoResolution = data.attrib.get('videoResolution')
|
self.videoResolution = data.attrib.get('videoResolution')
|
||||||
self.width = cast(int, data.attrib.get('width'))
|
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.aperture = data.attrib.get('aperture')
|
||||||
self.exposure = data.attrib.get('exposure')
|
self.exposure = data.attrib.get('exposure')
|
||||||
self.iso = cast(int, data.attrib.get('iso'))
|
self.iso = cast(int, data.attrib.get('iso'))
|
||||||
|
@ -72,6 +80,11 @@ class Media(PlexObject):
|
||||||
self.make = data.attrib.get('make')
|
self.make = data.attrib.get('make')
|
||||||
self.model = data.attrib.get('model')
|
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):
|
def delete(self):
|
||||||
part = self._initpath + '/media/%s' % self.id
|
part = self._initpath + '/media/%s' % self.id
|
||||||
try:
|
try:
|
||||||
|
@ -88,73 +101,77 @@ class MediaPart(PlexObject):
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Part'
|
TAG (str): 'Part'
|
||||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
accessible (bool): True if the file is accessible.
|
||||||
initpath (str): Relative path requested when retrieving specified data.
|
audioProfile (str): The audio profile of the file.
|
||||||
media (:class:`~plexapi.media.Media`): Media object this part belongs to.
|
container (str): The container type of the file (ex: avi).
|
||||||
container (str): Container type of this media part (ex: avi).
|
decision (str): Unknown.
|
||||||
duration (int): Length of this media part in milliseconds.
|
deepAnalysisVersion (int): The Plex deep analysis version for the file.
|
||||||
file (str): Path to this file on disk (ex: /media/Movies/Cars.(2006)/Cars.cd2.avi)
|
duration (int): The duration of the file in milliseconds.
|
||||||
id (int): Unique ID of this media part.
|
exists (bool): True if the file exists.
|
||||||
indexes (str, None): None or SD.
|
file (str): The path to this file on disk (ex: /media/Movies/Cars (2006)/Cars (2006).mkv)
|
||||||
key (str): Key used to access this media part (ex: /library/parts/46618/1389985872/file.avi).
|
has64bitOffsets (bool): True if the file has 64 bit offsets.
|
||||||
size (int): Size of this file in bytes (ex: 733884416).
|
hasThumbnail (bool): True if the file (track) has an embedded thumbnail.
|
||||||
streams (list<:class:`~plexapi.media.MediaPartStream`>): List of streams in this media part.
|
id (int): The unique ID for this media part on the server.
|
||||||
exists (bool): Determine if file exists
|
indexes (str, None): sd if the file has generated BIF thumbnails.
|
||||||
accessible (bool): Determine if file is accessible
|
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'
|
TAG = 'Part'
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
self._data = data
|
self._data = data
|
||||||
|
self.accessible = cast(bool, data.attrib.get('accessible'))
|
||||||
self.audioProfile = data.attrib.get('audioProfile')
|
self.audioProfile = data.attrib.get('audioProfile')
|
||||||
self.container = data.attrib.get('container')
|
self.container = data.attrib.get('container')
|
||||||
|
self.decision = data.attrib.get('decision')
|
||||||
self.deepAnalysisVersion = cast(int, data.attrib.get('deepAnalysisVersion'))
|
self.deepAnalysisVersion = cast(int, data.attrib.get('deepAnalysisVersion'))
|
||||||
self.duration = cast(int, data.attrib.get('duration'))
|
self.duration = cast(int, data.attrib.get('duration'))
|
||||||
|
self.exists = cast(bool, data.attrib.get('exists'))
|
||||||
self.file = data.attrib.get('file')
|
self.file = data.attrib.get('file')
|
||||||
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
|
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
|
||||||
self.hasThumbnail = cast(bool, data.attrib.get('hasThumbnail'))
|
self.hasThumbnail = cast(bool, data.attrib.get('hasThumbnail'))
|
||||||
self.id = cast(int, data.attrib.get('id'))
|
self.id = cast(int, data.attrib.get('id'))
|
||||||
self.indexes = data.attrib.get('indexes')
|
self.indexes = data.attrib.get('indexes')
|
||||||
self.key = data.attrib.get('key')
|
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.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
|
||||||
self.packetLength = cast(int, data.attrib.get('packetLength'))
|
self.packetLength = cast(int, data.attrib.get('packetLength'))
|
||||||
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
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.syncItemId = cast(int, data.attrib.get('syncItemId'))
|
||||||
self.syncState = data.attrib.get('syncState')
|
self.syncState = data.attrib.get('syncState')
|
||||||
self.videoProfile = data.attrib.get('videoProfile')
|
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):
|
def _buildStreams(self, data):
|
||||||
streams = []
|
streams = []
|
||||||
for elem in data:
|
|
||||||
for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream):
|
for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream):
|
||||||
if elem.attrib.get('streamType') == str(cls.STREAMTYPE):
|
items = self.findItems(data, cls, streamType=cls.STREAMTYPE)
|
||||||
streams.append(cls(self._server, elem, self._initpath))
|
streams.extend(items)
|
||||||
return streams
|
return streams
|
||||||
|
|
||||||
def videoStreams(self):
|
def videoStreams(self):
|
||||||
""" Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """
|
""" 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):
|
def audioStreams(self):
|
||||||
""" Returns a list of :class:`~plexapi.media.AudioStream` objects in this MediaPart. """
|
""" 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):
|
def subtitleStreams(self):
|
||||||
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
|
""" 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):
|
def lyricStreams(self):
|
||||||
""" Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """
|
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
|
||||||
return [stream for stream in self.streams if stream.streamType == LyricStream.STREAMTYPE]
|
return [stream for stream in self.streams if isinstance(stream, LyricStream)]
|
||||||
|
|
||||||
def setDefaultAudioStream(self, stream):
|
def setDefaultAudioStream(self, stream):
|
||||||
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
|
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
|
||||||
|
@ -187,73 +204,87 @@ class MediaPart(PlexObject):
|
||||||
|
|
||||||
|
|
||||||
class MediaPartStream(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:
|
Attributes:
|
||||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
bitrate (int): The bitrate of the stream.
|
||||||
initpath (str): Relative path requested when retrieving specified data.
|
codec (str): The codec of the stream (ex: srt, ac3, mpeg4).
|
||||||
part (:class:`~plexapi.media.MediaPart`): Media part this stream belongs to.
|
default (bool): True if this is the default stream.
|
||||||
codec (str): Codec of this stream (ex: srt, ac3, mpeg4).
|
displayTitle (str): The display title of the stream.
|
||||||
codecID (str): Codec ID (ex: XVID).
|
extendedDisplayTitle (str): The extended display title of the stream.
|
||||||
id (int): Unique stream ID on this server.
|
key (str): API URL (/library/streams/<id>)
|
||||||
index (int): Unknown
|
id (int): The unique ID for this stream on the server.
|
||||||
language (str): Stream language (ex: English, ไทย).
|
index (int): The index of the stream.
|
||||||
languageCode (str): Ascii code for language (ex: eng, tha).
|
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.
|
selected (bool): True if this stream is selected.
|
||||||
streamType (int): Stream type (1=:class:`~plexapi.media.VideoStream`,
|
streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`,
|
||||||
2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`,
|
2= :class:`~plexapi.media.AudioStream`, 3= :class:`~plexapi.media.SubtitleStream`).
|
||||||
4=:class:`~plexapi.media.LyricStream`).
|
title (str): The title of the stream.
|
||||||
type (int): Alias for streamType.
|
type (int): Alias for streamType.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
self._data = data
|
self._data = data
|
||||||
|
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
||||||
self.codec = data.attrib.get('codec')
|
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.displayTitle = data.attrib.get('displayTitle')
|
||||||
self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
|
self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
|
||||||
|
self.key = data.attrib.get('key')
|
||||||
self.id = cast(int, data.attrib.get('id'))
|
self.id = cast(int, data.attrib.get('id'))
|
||||||
self.index = cast(int, data.attrib.get('index', '-1'))
|
self.index = cast(int, data.attrib.get('index', '-1'))
|
||||||
self.language = data.attrib.get('language')
|
self.language = data.attrib.get('language')
|
||||||
self.languageCode = data.attrib.get('languageCode')
|
self.languageCode = data.attrib.get('languageCode')
|
||||||
|
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
||||||
self.selected = cast(bool, data.attrib.get('selected', '0'))
|
self.selected = cast(bool, data.attrib.get('selected', '0'))
|
||||||
self.streamType = cast(int, data.attrib.get('streamType'))
|
self.streamType = cast(int, data.attrib.get('streamType'))
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
self.type = cast(int, data.attrib.get('streamType'))
|
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
|
@utils.registerPlexObject
|
||||||
class VideoStream(MediaPartStream):
|
class VideoStream(MediaPartStream):
|
||||||
""" Respresents a video stream within a :class:`~plexapi.media.MediaPart`.
|
""" Represents a video stream within a :class:`~plexapi.media.MediaPart`.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Stream'
|
TAG (str): 'Stream'
|
||||||
STREAMTYPE (int): 1
|
STREAMTYPE (int): 1
|
||||||
bitDepth (int): Bit depth (ex: 8).
|
anamorphic (str): If the video is anamorphic.
|
||||||
bitrate (int): Bitrate (ex: 1169)
|
bitDepth (int): The bit depth of the video stream (ex: 8).
|
||||||
cabac (int): Unknown
|
cabac (int): The context-adaptive binary arithmetic coding.
|
||||||
chromaSubsampling (str): Chroma Subsampling (ex: 4:2:0).
|
chromaLocation (str): The chroma location of the video stream.
|
||||||
colorSpace (str): Unknown
|
chromaSubsampling (str): The chroma subsampling of the video stream (ex: 4:2:0).
|
||||||
duration (int): Duration of video stream in milliseconds.
|
codecID (str): The codec ID (ex: XVID).
|
||||||
frameRate (float): Frame rate (ex: 23.976)
|
codedHeight (int): The coded height of the video stream in pixels.
|
||||||
frameRateMode (str): Unknown
|
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.
|
hasScallingMatrix (bool): True if video stream has a scaling matrix.
|
||||||
height (int): Height of video stream.
|
height (int): The hight of the video stream in pixels (ex: 1080).
|
||||||
level (int): Videl stream level (?).
|
level (int): The codec encoding level of the video stream (ex: 41).
|
||||||
profile (str): Video stream profile (ex: asp).
|
profile (str): The profile of the video stream (ex: asp).
|
||||||
refFrames (int): Unknown
|
pixelAspectRatio (str): The pixel aspect ratio of the video stream.
|
||||||
scanType (str): Video stream scan type (ex: progressive).
|
pixelFormat (str): The pixel format of the video stream.
|
||||||
title (str): Title of this video stream.
|
refFrames (int): The number of reference frames of the video stream.
|
||||||
width (int): Width of 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'
|
TAG = 'Stream'
|
||||||
STREAMTYPE = 1
|
STREAMTYPE = 1
|
||||||
|
@ -263,13 +294,12 @@ class VideoStream(MediaPartStream):
|
||||||
super(VideoStream, self)._loadData(data)
|
super(VideoStream, self)._loadData(data)
|
||||||
self.anamorphic = data.attrib.get('anamorphic')
|
self.anamorphic = data.attrib.get('anamorphic')
|
||||||
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
|
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.cabac = cast(int, data.attrib.get('cabac'))
|
||||||
self.chromaLocation = data.attrib.get('chromaLocation')
|
self.chromaLocation = data.attrib.get('chromaLocation')
|
||||||
self.chromaSubsampling = data.attrib.get('chromaSubsampling')
|
self.chromaSubsampling = data.attrib.get('chromaSubsampling')
|
||||||
self.codecID = data.attrib.get('codecID')
|
self.codecID = data.attrib.get('codecID')
|
||||||
self.codedHeight = data.attrib.get('codedHeight')
|
self.codedHeight = cast(int, data.attrib.get('codedHeight'))
|
||||||
self.codedWidth = data.attrib.get('codedWidth')
|
self.codedWidth = cast(int, data.attrib.get('codedWidth'))
|
||||||
self.colorPrimaries = data.attrib.get('colorPrimaries')
|
self.colorPrimaries = data.attrib.get('colorPrimaries')
|
||||||
self.colorRange = data.attrib.get('colorRange')
|
self.colorRange = data.attrib.get('colorRange')
|
||||||
self.colorSpace = data.attrib.get('colorSpace')
|
self.colorSpace = data.attrib.get('colorSpace')
|
||||||
|
@ -285,14 +315,13 @@ class VideoStream(MediaPartStream):
|
||||||
self.duration = cast(int, data.attrib.get('duration'))
|
self.duration = cast(int, data.attrib.get('duration'))
|
||||||
self.frameRate = cast(float, data.attrib.get('frameRate'))
|
self.frameRate = cast(float, data.attrib.get('frameRate'))
|
||||||
self.frameRateMode = data.attrib.get('frameRateMode')
|
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.height = cast(int, data.attrib.get('height'))
|
||||||
self.level = cast(int, data.attrib.get('level'))
|
self.level = cast(int, data.attrib.get('level'))
|
||||||
self.profile = data.attrib.get('profile')
|
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.pixelAspectRatio = data.attrib.get('pixelAspectRatio')
|
||||||
self.pixelFormat = data.attrib.get('pixelFormat')
|
self.pixelFormat = data.attrib.get('pixelFormat')
|
||||||
|
self.refFrames = cast(int, data.attrib.get('refFrames'))
|
||||||
self.scanType = data.attrib.get('scanType')
|
self.scanType = data.attrib.get('scanType')
|
||||||
self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier'))
|
self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier'))
|
||||||
self.width = cast(int, data.attrib.get('width'))
|
self.width = cast(int, data.attrib.get('width'))
|
||||||
|
@ -300,20 +329,31 @@ class VideoStream(MediaPartStream):
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class AudioStream(MediaPartStream):
|
class AudioStream(MediaPartStream):
|
||||||
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
""" Represents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Stream'
|
TAG (str): 'Stream'
|
||||||
STREAMTYPE (int): 2
|
STREAMTYPE (int): 2
|
||||||
audioChannelLayout (str): Audio channel layout (ex: 5.1(side)).
|
audioChannelLayout (str): The audio channel layout of the audio stream (ex: 5.1(side)).
|
||||||
bitDepth (int): Bit depth (ex: 16).
|
bitDepth (int): The bit depth of the audio stream (ex: 16).
|
||||||
bitrate (int): Audio bitrate (ex: 448).
|
bitrateMode (str): The bitrate mode of the audio stream (ex: cbr).
|
||||||
bitrateMode (str): Bitrate mode (ex: cbr).
|
channels (int): The number of audio channels of the audio stream (ex: 6).
|
||||||
channels (int): number of channels in this stream (ex: 6).
|
duration (int): The duration of audio stream in milliseconds.
|
||||||
dialogNorm (int): Unknown (ex: -27).
|
profile (str): The profile of the audio stream.
|
||||||
duration (int): Duration of audio stream in milliseconds.
|
samplingRate (int): The sampling rate of the audio stream (ex: xxx)
|
||||||
samplingRate (int): Sampling rate (ex: xxx)
|
streamIdentifier (int): The stream identifier of the audio stream.
|
||||||
title (str): Title of this 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'
|
TAG = 'Stream'
|
||||||
STREAMTYPE = 2
|
STREAMTYPE = 2
|
||||||
|
@ -323,16 +363,14 @@ class AudioStream(MediaPartStream):
|
||||||
super(AudioStream, self)._loadData(data)
|
super(AudioStream, self)._loadData(data)
|
||||||
self.audioChannelLayout = data.attrib.get('audioChannelLayout')
|
self.audioChannelLayout = data.attrib.get('audioChannelLayout')
|
||||||
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
|
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
|
||||||
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
|
||||||
self.bitrateMode = data.attrib.get('bitrateMode')
|
self.bitrateMode = data.attrib.get('bitrateMode')
|
||||||
self.channels = cast(int, data.attrib.get('channels'))
|
self.channels = cast(int, data.attrib.get('channels'))
|
||||||
self.duration = cast(int, data.attrib.get('duration'))
|
self.duration = cast(int, data.attrib.get('duration'))
|
||||||
self.profile = data.attrib.get('profile')
|
self.profile = data.attrib.get('profile')
|
||||||
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
|
||||||
self.samplingRate = cast(int, data.attrib.get('samplingRate'))
|
self.samplingRate = cast(int, data.attrib.get('samplingRate'))
|
||||||
self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier'))
|
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.albumGain = cast(float, data.attrib.get('albumGain'))
|
||||||
self.albumPeak = cast(float, data.attrib.get('albumPeak'))
|
self.albumPeak = cast(float, data.attrib.get('albumPeak'))
|
||||||
self.albumRange = cast(float, data.attrib.get('albumRange'))
|
self.albumRange = cast(float, data.attrib.get('albumRange'))
|
||||||
|
@ -346,15 +384,16 @@ class AudioStream(MediaPartStream):
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class SubtitleStream(MediaPartStream):
|
class SubtitleStream(MediaPartStream):
|
||||||
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
""" Represents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Stream'
|
TAG (str): 'Stream'
|
||||||
STREAMTYPE (int): 3
|
STREAMTYPE (int): 3
|
||||||
forced (bool): True if this is a forced subtitle
|
container (str): The container of the subtitle stream.
|
||||||
format (str): Subtitle format (ex: srt).
|
forced (bool): True if this is a forced subtitle.
|
||||||
key (str): Key of this subtitle stream (ex: /library/streams/212284).
|
format (str): The format of the subtitle stream (ex: srt).
|
||||||
title (str): Title of this subtitle stream.
|
headerCommpression (str): The header compression of the subtitle stream.
|
||||||
|
transient (str): Unknown.
|
||||||
"""
|
"""
|
||||||
TAG = 'Stream'
|
TAG = 'Stream'
|
||||||
STREAMTYPE = 3
|
STREAMTYPE = 3
|
||||||
|
@ -366,21 +405,19 @@ class SubtitleStream(MediaPartStream):
|
||||||
self.forced = cast(bool, data.attrib.get('forced', '0'))
|
self.forced = cast(bool, data.attrib.get('forced', '0'))
|
||||||
self.format = data.attrib.get('format')
|
self.format = data.attrib.get('format')
|
||||||
self.headerCompression = data.attrib.get('headerCompression')
|
self.headerCompression = data.attrib.get('headerCompression')
|
||||||
self.key = data.attrib.get('key')
|
|
||||||
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
|
||||||
self.transient = data.attrib.get('transient')
|
self.transient = data.attrib.get('transient')
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
|
||||||
class LyricStream(MediaPartStream):
|
class LyricStream(MediaPartStream):
|
||||||
""" Respresents a lyric stream within a :class:`~plexapi.media.MediaPart`.
|
""" Represents a lyric stream within a :class:`~plexapi.media.MediaPart`.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Stream'
|
TAG (str): 'Stream'
|
||||||
STREAMTYPE (int): 4
|
STREAMTYPE (int): 4
|
||||||
format (str): Lyric format (ex: lrc).
|
format (str): The format of the lyric stream (ex: lrc).
|
||||||
key (str): Key of this subtitle stream (ex: /library/streams/212284).
|
minLines (int): The minimum number of lines in the (timed) lyric stream.
|
||||||
title (str): Title of this 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'
|
TAG = 'Stream'
|
||||||
STREAMTYPE = 4
|
STREAMTYPE = 4
|
||||||
|
@ -389,7 +426,6 @@ class LyricStream(MediaPartStream):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
super(LyricStream, self)._loadData(data)
|
super(LyricStream, self)._loadData(data)
|
||||||
self.format = data.attrib.get('format')
|
self.format = data.attrib.get('format')
|
||||||
self.key = data.attrib.get('key')
|
|
||||||
self.minLines = cast(int, data.attrib.get('minLines'))
|
self.minLines = cast(int, data.attrib.get('minLines'))
|
||||||
self.provider = data.attrib.get('provider')
|
self.provider = data.attrib.get('provider')
|
||||||
self.timed = cast(bool, data.attrib.get('timed', '0'))
|
self.timed = cast(bool, data.attrib.get('timed', '0'))
|
||||||
|
@ -397,7 +433,14 @@ class LyricStream(MediaPartStream):
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Session(PlexObject):
|
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'
|
TAG = 'Session'
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
|
@ -412,7 +455,36 @@ class TranscodeSession(PlexObject):
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'TranscodeSession'
|
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'
|
TAG = 'TranscodeSession'
|
||||||
|
|
||||||
|
@ -422,17 +494,30 @@ class TranscodeSession(PlexObject):
|
||||||
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
|
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
|
||||||
self.audioCodec = data.attrib.get('audioCodec')
|
self.audioCodec = data.attrib.get('audioCodec')
|
||||||
self.audioDecision = data.attrib.get('audioDecision')
|
self.audioDecision = data.attrib.get('audioDecision')
|
||||||
|
self.complete = cast(bool, data.attrib.get('complete', '0'))
|
||||||
self.container = data.attrib.get('container')
|
self.container = data.attrib.get('container')
|
||||||
self.context = data.attrib.get('context')
|
self.context = data.attrib.get('context')
|
||||||
self.duration = cast(int, data.attrib.get('duration'))
|
self.duration = cast(int, data.attrib.get('duration'))
|
||||||
self.height = cast(int, data.attrib.get('height'))
|
self.height = cast(int, data.attrib.get('height'))
|
||||||
self.key = data.attrib.get('key')
|
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.progress = cast(float, data.attrib.get('progress'))
|
||||||
self.protocol = data.attrib.get('protocol')
|
self.protocol = data.attrib.get('protocol')
|
||||||
self.remaining = cast(int, data.attrib.get('remaining'))
|
self.remaining = cast(int, data.attrib.get('remaining'))
|
||||||
self.speed = cast(int, data.attrib.get('speed'))
|
self.size = cast(int, data.attrib.get('size'))
|
||||||
self.throttled = cast(int, data.attrib.get('throttled'))
|
self.sourceAudioCodec = data.attrib.get('sourceAudioCodec')
|
||||||
self.sourceVideoCodec = data.attrib.get('sourceVideoCodec')
|
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.videoCodec = data.attrib.get('videoCodec')
|
||||||
self.videoDecision = data.attrib.get('videoDecision')
|
self.videoDecision = data.attrib.get('videoDecision')
|
||||||
self.width = cast(int, data.attrib.get('width'))
|
self.width = cast(int, data.attrib.get('width'))
|
||||||
|
@ -442,7 +527,7 @@ class TranscodeSession(PlexObject):
|
||||||
class TranscodeJob(PlexObject):
|
class TranscodeJob(PlexObject):
|
||||||
""" Represents an Optimizing job.
|
""" Represents an Optimizing job.
|
||||||
TrancodeJobs are the process for optimizing conversions.
|
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'
|
TAG = 'TranscodeJob'
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
|
@ -598,25 +683,15 @@ class MediaTag(PlexObject):
|
||||||
|
|
||||||
class GuidTag(PlexObject):
|
class GuidTag(PlexObject):
|
||||||
""" Base class for guid tags used only for Guids, as they contain only a string identifier
|
""" Base class for guid tags used only for Guids, as they contain only a string identifier
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB).
|
||||||
id (id): Tag ID (Used as a unique id, except for Guid's, used for external systems
|
|
||||||
to plex identifiers, like imdb and tmdb).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
self._data = data
|
self._data = data
|
||||||
self.id = data.attrib.get('id')
|
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
|
@utils.registerPlexObject
|
||||||
|
@ -700,7 +775,11 @@ class Genre(MediaTag):
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Guid(GuidTag):
|
class Guid(GuidTag):
|
||||||
""" Represents a single Guid media tag. """
|
""" Represents a single Guid media tag.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
TAG (str): 'Guid'
|
||||||
|
"""
|
||||||
TAG = "Guid"
|
TAG = "Guid"
|
||||||
|
|
||||||
|
|
||||||
|
@ -741,12 +820,12 @@ class Poster(PlexObject):
|
||||||
self._data = data
|
self._data = data
|
||||||
self.key = data.attrib.get('key')
|
self.key = data.attrib.get('key')
|
||||||
self.ratingKey = data.attrib.get('ratingKey')
|
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')
|
self.thumb = data.attrib.get('thumb')
|
||||||
|
|
||||||
def select(self):
|
def select(self):
|
||||||
key = self._initpath[:-1]
|
key = self._initpath[:-1]
|
||||||
data = '%s?url=%s' % (key, compat.quote_plus(self.ratingKey))
|
data = '%s?url=%s' % (key, quote_plus(self.ratingKey))
|
||||||
try:
|
try:
|
||||||
self._server.query(data, method=self._server._session.put)
|
self._server.query(data, method=self._server._session.put)
|
||||||
except xml.etree.ElementTree.ParseError:
|
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.filter = data.attrib.get('filter') # I couldn't filter on it anyways
|
||||||
self.tag = data.attrib.get('tag')
|
self.tag = data.attrib.get('tag')
|
||||||
self.title = self.tag
|
self.title = self.tag
|
||||||
self.thumb = data.attrib.get('thumb')
|
|
||||||
self.index = cast(int, data.attrib.get('index'))
|
self.index = cast(int, data.attrib.get('index'))
|
||||||
self.start = cast(int, data.attrib.get('startTimeOffset'))
|
self.start = cast(int, data.attrib.get('startTimeOffset'))
|
||||||
self.end = cast(int, data.attrib.get('endTimeOffset'))
|
self.end = cast(int, data.attrib.get('endTimeOffset'))
|
||||||
|
@ -825,6 +903,7 @@ class Chapter(PlexObject):
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Marker(PlexObject):
|
class Marker(PlexObject):
|
||||||
""" Represents a single Marker media tag.
|
""" Represents a single Marker media tag.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Marker'
|
TAG (str): 'Marker'
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -2,14 +2,14 @@
|
||||||
import copy
|
import copy
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT,
|
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT,
|
||||||
X_PLEX_IDENTIFIER, log, logfilter, utils)
|
X_PLEX_IDENTIFIER, log, logfilter, utils)
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
|
||||||
from plexapi.client import PlexClient
|
from plexapi.client import PlexClient
|
||||||
from plexapi.compat import ElementTree
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||||
from plexapi.library import LibrarySection
|
from plexapi.library import LibrarySection
|
||||||
from plexapi.server import PlexServer
|
from plexapi.server import PlexServer
|
||||||
from plexapi.sonos import PlexSonosClient
|
from plexapi.sonos import PlexSonosClient
|
||||||
|
@ -43,7 +43,7 @@ class MyPlexAccount(PlexObject):
|
||||||
guest (bool): Unknown.
|
guest (bool): Unknown.
|
||||||
home (bool): Unknown.
|
home (bool): Unknown.
|
||||||
homeSize (int): Unknown.
|
homeSize (int): Unknown.
|
||||||
id (str): Your Plex account ID.
|
id (int): Your Plex account ID.
|
||||||
locale (str): Your Plex locale
|
locale (str): Your Plex locale
|
||||||
mailing_list_status (str): Your current mailing list status.
|
mailing_list_status (str): Your current mailing list status.
|
||||||
maxHomeSize (int): Unknown.
|
maxHomeSize (int): Unknown.
|
||||||
|
@ -71,11 +71,12 @@ class MyPlexAccount(PlexObject):
|
||||||
PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get
|
PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get
|
||||||
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete
|
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete
|
||||||
REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # 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
|
REQUESTED = 'https://plex.tv/api/invites/requested' # get
|
||||||
REQUESTS = 'https://plex.tv/api/invites/requests' # get
|
REQUESTS = 'https://plex.tv/api/invites/requests' # get
|
||||||
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
|
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
|
||||||
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
||||||
|
LINK = 'https://plex.tv/api/v2/pins/link' # put
|
||||||
# Hub sections
|
# Hub sections
|
||||||
VOD = 'https://vod.provider.plex.tv/' # get
|
VOD = 'https://vod.provider.plex.tv/' # get
|
||||||
WEBSHOWS = 'https://webshows.provider.plex.tv/' # get
|
WEBSHOWS = 'https://webshows.provider.plex.tv/' # get
|
||||||
|
@ -87,7 +88,7 @@ class MyPlexAccount(PlexObject):
|
||||||
key = 'https://plex.tv/users/account'
|
key = 'https://plex.tv/users/account'
|
||||||
|
|
||||||
def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
|
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._session = session or requests.Session()
|
||||||
self._sonos_cache = []
|
self._sonos_cache = []
|
||||||
self._sonos_cache_timestamp = 0
|
self._sonos_cache_timestamp = 0
|
||||||
|
@ -114,7 +115,7 @@ class MyPlexAccount(PlexObject):
|
||||||
self.guest = utils.cast(bool, data.attrib.get('guest'))
|
self.guest = utils.cast(bool, data.attrib.get('guest'))
|
||||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||||
self.homeSize = utils.cast(int, data.attrib.get('homeSize'))
|
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.locale = data.attrib.get('locale')
|
||||||
self.mailing_list_status = data.attrib.get('mailing_list_status')
|
self.mailing_list_status = data.attrib.get('mailing_list_status')
|
||||||
self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
|
self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
|
||||||
|
@ -139,7 +140,7 @@ class MyPlexAccount(PlexObject):
|
||||||
|
|
||||||
roles = data.find('roles')
|
roles = data.find('roles')
|
||||||
self.roles = []
|
self.roles = []
|
||||||
if roles:
|
if roles is not None:
|
||||||
for role in roles.iter('role'):
|
for role in roles.iter('role'):
|
||||||
self.roles.append(role.attrib.get('id'))
|
self.roles.append(role.attrib.get('id'))
|
||||||
|
|
||||||
|
@ -153,14 +154,15 @@ class MyPlexAccount(PlexObject):
|
||||||
self.services = None
|
self.services = None
|
||||||
self.joined_at = 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.
|
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
name (str): Name to match against.
|
name (str): Name to match against.
|
||||||
|
clientId (str): clientIdentifier to match against.
|
||||||
"""
|
"""
|
||||||
for device in self.devices():
|
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
|
return device
|
||||||
raise NotFound('Unable to find device %s' % name)
|
raise NotFound('Unable to find device %s' % name)
|
||||||
|
|
||||||
|
@ -217,7 +219,7 @@ class MyPlexAccount(PlexObject):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
t = time.time()
|
t = time.time()
|
||||||
if t - self._sonos_cache_timestamp > 60:
|
if t - self._sonos_cache_timestamp > 5:
|
||||||
self._sonos_cache_timestamp = t
|
self._sonos_cache_timestamp = t
|
||||||
data = self.query('https://sonos.plex.tv/resources')
|
data = self.query('https://sonos.plex.tv/resources')
|
||||||
self._sonos_cache = [PlexSonosClient(self, elem) for elem in data]
|
self._sonos_cache = [PlexSonosClient(self, elem) for elem in data]
|
||||||
|
@ -225,10 +227,10 @@ class MyPlexAccount(PlexObject):
|
||||||
return self._sonos_cache
|
return self._sonos_cache
|
||||||
|
|
||||||
def sonos_speaker(self, name):
|
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):
|
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,
|
def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
|
||||||
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
|
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
|
||||||
|
@ -578,8 +580,8 @@ class MyPlexAccount(PlexObject):
|
||||||
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
|
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`plexapi.exceptions.BadRequest`: when client with provided clientId wasn`t found.
|
: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`: Provided client doesn`t provides `sync-target`.
|
||||||
"""
|
"""
|
||||||
if not client and not clientId:
|
if not client and not clientId:
|
||||||
clientId = X_PLEX_IDENTIFIER
|
clientId = X_PLEX_IDENTIFIER
|
||||||
|
@ -683,6 +685,19 @@ class MyPlexAccount(PlexObject):
|
||||||
elem = ElementTree.fromstring(req.text)
|
elem = ElementTree.fromstring(req.text)
|
||||||
return self.findItems(elem)
|
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):
|
class MyPlexUser(PlexObject):
|
||||||
""" This object represents non-signed in users such as friends and linked
|
""" This object represents non-signed in users such as friends and linked
|
||||||
|
@ -942,7 +957,7 @@ class MyPlexResource(PlexObject):
|
||||||
HTTP or HTTPS connection.
|
HTTP or HTTPS connection.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||||
"""
|
"""
|
||||||
# Sort connections from (https, local) to (http, remote)
|
# Sort connections from (https, local) to (http, remote)
|
||||||
# Only check non-local connections unless we own the resource
|
# 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
|
# Try connecting to all known resource connections in parellel, but
|
||||||
# only return the first server (in order) that provides a response.
|
# only return the first server (in order) that provides a response.
|
||||||
listargs = [[cls, url, self.accessToken, timeout] for url in connections]
|
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)
|
results = utils.threaded(_connect, listargs)
|
||||||
return _chooseConnection('Resource', self.name, results)
|
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.
|
at least one connection was successful, the PlexClient object is built and returned.
|
||||||
|
|
||||||
Raises:
|
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
|
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||||
listargs = [[cls, url, self.token, timeout] for url in self.connections]
|
listargs = [[cls, url, self.token, 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)
|
results = utils.threaded(_connect, listargs)
|
||||||
return _chooseConnection('Device', self.name, results)
|
return _chooseConnection('Device', self.name, results)
|
||||||
|
|
||||||
|
@ -1066,7 +1081,7 @@ class MyPlexDevice(PlexObject):
|
||||||
""" Returns an instance of :class:`~plexapi.sync.SyncList` for current device.
|
""" Returns an instance of :class:`~plexapi.sync.SyncList` for current device.
|
||||||
|
|
||||||
Raises:
|
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:
|
if 'sync-target' not in self.provides:
|
||||||
raise BadRequest('Requested syncList for device which do not provides sync-target')
|
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).
|
requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT).
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
PINS (str): 'https://plex.tv/pins.xml'
|
PINS (str): 'https://plex.tv/api/v2/pins'
|
||||||
CHECKPINS (str): 'https://plex.tv/pins/{pinid}.xml'
|
CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}'
|
||||||
|
LINK (str): 'https://plex.tv/api/v2/pins/link'
|
||||||
POLLINTERVAL (int): 1
|
POLLINTERVAL (int): 1
|
||||||
finished (bool): Whether the pin login has finished or not.
|
finished (bool): Whether the pin login has finished or not.
|
||||||
expired (bool): Whether the pin login has expired or not.
|
expired (bool): Whether the pin login has expired or not.
|
||||||
token (str): Token retrieved through the pin login.
|
token (str): Token retrieved through the pin login.
|
||||||
pin (str): Pin to use for the login on https://plex.tv/link.
|
pin (str): Pin to use for the login on https://plex.tv/link.
|
||||||
"""
|
"""
|
||||||
PINS = 'https://plex.tv/pins.xml' # get
|
PINS = 'https://plex.tv/api/v2/pins' # get
|
||||||
CHECKPINS = 'https://plex.tv/pins/{pinid}.xml' # get
|
CHECKPINS = 'https://plex.tv/api/v2/pins/{pinid}' # get
|
||||||
POLLINTERVAL = 1
|
POLLINTERVAL = 1
|
||||||
|
|
||||||
def __init__(self, session=None, requestTimeout=None):
|
def __init__(self, session=None, requestTimeout=None, headers=None):
|
||||||
super(MyPlexPinLogin, self).__init__()
|
super(MyPlexPinLogin, self).__init__()
|
||||||
self._session = session or requests.Session()
|
self._session = session or requests.Session()
|
||||||
self._requestTimeout = requestTimeout or TIMEOUT
|
self._requestTimeout = requestTimeout or TIMEOUT
|
||||||
|
self.headers = headers
|
||||||
|
|
||||||
self._loginTimeout = None
|
self._loginTimeout = None
|
||||||
self._callback = None
|
self._callback = None
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._abort = False
|
self._abort = False
|
||||||
self._id = None
|
self._id = None
|
||||||
|
self._code = None
|
||||||
|
self._getCode()
|
||||||
|
|
||||||
self.finished = False
|
self.finished = False
|
||||||
self.expired = False
|
self.expired = False
|
||||||
self.token = None
|
self.token = None
|
||||||
self.pin = self._getPin()
|
|
||||||
|
@property
|
||||||
|
def pin(self):
|
||||||
|
return self._code
|
||||||
|
|
||||||
def run(self, callback=None, timeout=None):
|
def run(self, callback=None, timeout=None):
|
||||||
""" Starts the thread which monitors the PIN login state.
|
""" 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).
|
timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:class:`RuntimeError`: if the thread is already running.
|
:class:`RuntimeError`: If the thread is already running.
|
||||||
:class:`RuntimeError`: if the PIN login for the current PIN has expired.
|
:class:`RuntimeError`: If the PIN login for the current PIN has expired.
|
||||||
"""
|
"""
|
||||||
if self._thread and not self._abort:
|
if self._thread and not self._abort:
|
||||||
raise RuntimeError('MyPlexPinLogin thread is already running')
|
raise RuntimeError('MyPlexPinLogin thread is already running')
|
||||||
|
@ -1187,19 +1209,16 @@ class MyPlexPinLogin(object):
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _getPin(self):
|
def _getCode(self):
|
||||||
if self.pin:
|
|
||||||
return self.pin
|
|
||||||
|
|
||||||
url = self.PINS
|
url = self.PINS
|
||||||
response = self._query(url, self._session.post)
|
response = self._query(url, self._session.post)
|
||||||
if not response:
|
if not response:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self._id = response.find('id').text
|
self._id = response.attrib.get('id')
|
||||||
self.pin = response.find('code').text
|
self._code = response.attrib.get('code')
|
||||||
|
|
||||||
return self.pin
|
return self._code
|
||||||
|
|
||||||
def _checkLogin(self):
|
def _checkLogin(self):
|
||||||
if not self._id:
|
if not self._id:
|
||||||
|
@ -1213,7 +1232,7 @@ class MyPlexPinLogin(object):
|
||||||
if not response:
|
if not response:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
token = response.find('auth_token').text
|
token = response.attrib.get('authToken')
|
||||||
if not token:
|
if not token:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -1241,11 +1260,19 @@ class MyPlexPinLogin(object):
|
||||||
finally:
|
finally:
|
||||||
self.finished = True
|
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
|
method = method or self._session.get
|
||||||
log.debug('%s %s', method.__name__.upper(), url)
|
log.debug('%s %s', method.__name__.upper(), url)
|
||||||
headers = BASE_HEADERS.copy()
|
headers = headers or self._headers()
|
||||||
response = method(url, headers=headers, timeout=self._requestTimeout)
|
response = method(url, headers=headers, timeout=self._requestTimeout, **kwargs)
|
||||||
if not response.ok: # pragma: no cover
|
if not response.ok: # pragma: no cover
|
||||||
codename = codes.get(response.status_code)[0]
|
codename = codes.get(response.status_code)[0]
|
||||||
errtext = response.text.replace('\n', ' ')
|
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.
|
# or (url, token, None, runtime) in the case a connection could not be established.
|
||||||
for url, token, result, runtime in results:
|
for url, token, result, runtime in results:
|
||||||
okerr = 'OK' if result else 'ERR'
|
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]
|
results = [r[2] for r in results if r and r[2] is not None]
|
||||||
if results:
|
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]
|
return results[0]
|
||||||
raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name))
|
raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name))
|
||||||
|
|
|
@ -1,157 +1,224 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from plexapi import media, utils
|
from urllib.parse import quote_plus
|
||||||
from plexapi.base import PlexPartialObject
|
|
||||||
from plexapi.exceptions import NotFound, BadRequest
|
from plexapi import media, utils, video
|
||||||
from plexapi.compat import quote_plus
|
from plexapi.base import Playable, PlexPartialObject
|
||||||
|
from plexapi.exceptions import BadRequest
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Photoalbum(PlexPartialObject):
|
class Photoalbum(PlexPartialObject):
|
||||||
""" Represents a photoalbum (collection of photos).
|
""" Represents a single Photoalbum (collection of photos).
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Directory'
|
TAG (str): 'Directory'
|
||||||
TYPE (str): 'photo'
|
TYPE (str): 'photo'
|
||||||
addedAt (datetime): Datetime this item was added to the library.
|
addedAt (datetime): Datetime the photo album was added to the library.
|
||||||
art (str): Photo art (/library/metadata/<ratingkey>/art/<artid>)
|
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||||
composite (str): Unknown
|
composite (str): URL to composite image (/library/metadata/<ratingKey>/composite/<compositeid>)
|
||||||
guid (str): Unknown (unique ID)
|
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||||
index (sting): Index number of this album.
|
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>).
|
key (str): API URL (/library/metadata/<ratingkey>).
|
||||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
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).
|
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.
|
summary (str): Summary of the photoalbum.
|
||||||
thumb (str): URL to thumbnail image.
|
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||||
title (str): Photoalbum title. (Trip to Disney World)
|
title (str): Name of the photo album. (Trip to Disney World)
|
||||||
type (str): Unknown
|
titleSort (str): Title to use when sorting (defaults to title).
|
||||||
updatedAt (datatime): Datetime this item was updated.
|
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'
|
TAG = 'Directory'
|
||||||
TYPE = 'photo'
|
TYPE = 'photo'
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
self.listType = 'photo'
|
|
||||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||||
self.art = data.attrib.get('art')
|
self.art = data.attrib.get('art')
|
||||||
self.composite = data.attrib.get('composite')
|
self.composite = data.attrib.get('composite')
|
||||||
|
self.fields = self.findItems(data, media.Field)
|
||||||
self.guid = data.attrib.get('guid')
|
self.guid = data.attrib.get('guid')
|
||||||
self.index = utils.cast(int, data.attrib.get('index'))
|
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.librarySectionID = data.attrib.get('librarySectionID')
|
||||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
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.summary = data.attrib.get('summary')
|
||||||
self.thumb = data.attrib.get('thumb')
|
self.thumb = data.attrib.get('thumb')
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
self.titleSort = data.attrib.get('titleSort')
|
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||||
self.fields = self.findItems(data, media.Field)
|
self.userRating = utils.cast(float, data.attrib.get('userRating', 0))
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
def album(self, title):
|
def album(self, title):
|
||||||
""" Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified 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)
|
|
||||||
|
|
||||||
def photos(self, **kwargs):
|
Parameters:
|
||||||
""" Returns a list of :class:`~plexapi.photo.Photo` objects in this album. """
|
title (str): Title of the photo album to return.
|
||||||
|
"""
|
||||||
key = '/library/metadata/%s/children' % self.ratingKey
|
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):
|
def photo(self, title):
|
||||||
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title. """
|
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title.
|
||||||
for photo in self.photos():
|
|
||||||
if photo.title.lower() == title.lower():
|
Parameters:
|
||||||
return photo
|
title (str): Title of the photo to return.
|
||||||
raise NotFound('Unable to find photo: %s' % title)
|
"""
|
||||||
|
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):
|
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
|
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
|
@utils.registerPlexObject
|
||||||
class Photo(PlexPartialObject):
|
class Photo(PlexPartialObject, Playable):
|
||||||
""" Represents a single photo.
|
""" Represents a single Photo.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Photo'
|
TAG (str): 'Photo'
|
||||||
TYPE (str): 'photo'
|
TYPE (str): 'photo'
|
||||||
addedAt (datetime): Datetime this item was added to the library.
|
addedAt (datetime): Datetime the photo was added to the library.
|
||||||
index (sting): Index number of this photo.
|
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>).
|
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).
|
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||||
media (TYPE): Unknown
|
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||||
originallyAvailableAt (datetime): Datetime this photo was added to Plex.
|
originallyAvailableAt (datetime): Datetime the photo was added to Plex.
|
||||||
parentKey (str): Photoalbum API URL.
|
parentGuid (str): Plex GUID for the photo album (local://229674).
|
||||||
parentRatingKey (int): Unique key identifying the photoalbum.
|
parentIndex (int): Plex index number for the photo album.
|
||||||
ratingKey (int): Unique key identifying this item.
|
parentKey (str): API URL of the photo album (/library/metadata/<parentRatingKey>).
|
||||||
|
parentRatingKey (int): Unique key identifying the photo album.
|
||||||
|
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.
|
summary (str): Summary of the photo.
|
||||||
thumb (str): URL to thumbnail image.
|
tag (List<:class:`~plexapi.media.Tag`>): List of tag objects.
|
||||||
title (str): Photo title.
|
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||||
type (str): Unknown
|
title (str): Name of the photo.
|
||||||
updatedAt (datatime): Datetime this item was updated.
|
titleSort (str): Title to use when sorting (defaults to title).
|
||||||
year (int): Year this photo was taken.
|
type (str): 'photo'
|
||||||
|
updatedAt (datatime): Datetime the photo was updated.
|
||||||
|
year (int): Year the photo was taken.
|
||||||
"""
|
"""
|
||||||
TAG = 'Photo'
|
TAG = 'Photo'
|
||||||
TYPE = 'photo'
|
TYPE = 'photo'
|
||||||
METADATA_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):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
self.key = data.attrib.get('key')
|
Playable._loadData(self, data)
|
||||||
self._details_key = self.key + self._include
|
|
||||||
self.listType = 'photo'
|
|
||||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||||
self.createdAtAccuracy = data.attrib.get('createdAtAccuracy')
|
self.createdAtAccuracy = data.attrib.get('createdAtAccuracy')
|
||||||
self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset'))
|
self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset'))
|
||||||
|
self.fields = self.findItems(data, media.Field)
|
||||||
self.guid = data.attrib.get('guid')
|
self.guid = data.attrib.get('guid')
|
||||||
self.index = utils.cast(int, data.attrib.get('index'))
|
self.index = utils.cast(int, data.attrib.get('index'))
|
||||||
|
self.key = data.attrib.get('key', '')
|
||||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||||
self.originallyAvailableAt = utils.toDatetime(
|
self.listType = 'photo'
|
||||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
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.parentGuid = data.attrib.get('parentGuid')
|
||||||
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
|
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
|
||||||
self.parentKey = data.attrib.get('parentKey')
|
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.parentThumb = data.attrib.get('parentThumb')
|
||||||
self.parentTitle = data.attrib.get('parentTitle')
|
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.summary = data.attrib.get('summary')
|
||||||
|
self.tag = self.findItems(data, media.Tag)
|
||||||
self.thumb = data.attrib.get('thumb')
|
self.thumb = data.attrib.get('thumb')
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
self.titleSort = data.attrib.get('titleSort')
|
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
self.year = utils.cast(int, data.attrib.get('year'))
|
||||||
self.media = self.findItems(data, media.Media)
|
|
||||||
self.tag = self.findItems(data, media.Tag)
|
@property
|
||||||
self.fields = self.findItems(data, media.Field)
|
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):
|
def photoalbum(self):
|
||||||
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
|
""" Return the photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||||
return self.fetchItem(self.parentKey)
|
return self.fetchItem(self.parentKey)
|
||||||
|
|
||||||
def section(self):
|
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'):
|
if hasattr(self, 'librarySectionID'):
|
||||||
return self._server.library.sectionByID(self.librarySectionID)
|
return self._server.library.sectionByID(self.librarySectionID)
|
||||||
elif self.parentKey:
|
elif self.parentKey:
|
||||||
|
@ -162,10 +229,19 @@ class Photo(PlexPartialObject):
|
||||||
@property
|
@property
|
||||||
def locations(self):
|
def locations(self):
|
||||||
""" This does not exist in plex xml response but is added to have a common
|
""" This does not exist in plex xml response but is added to have a common
|
||||||
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]
|
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):
|
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
|
||||||
""" Add current photo as sync item for specified device.
|
""" Add current photo as sync item for specified device.
|
||||||
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
||||||
|
@ -201,3 +277,26 @@ class Photo(PlexPartialObject):
|
||||||
sync_item.mediaSettings = MediaSettings.createPhoto(resolution)
|
sync_item.mediaSettings = MediaSettings.createPhoto(resolution)
|
||||||
|
|
||||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
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
|
||||||
|
|
|
@ -1,17 +1,36 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from plexapi import utils
|
from plexapi import utils
|
||||||
from plexapi.base import PlexPartialObject, Playable
|
from plexapi.base import Playable, PlexPartialObject
|
||||||
from plexapi.exceptions import BadRequest, Unsupported
|
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
||||||
from plexapi.library import LibrarySection
|
from plexapi.library import LibrarySection
|
||||||
from plexapi.playqueue import PlayQueue
|
from plexapi.playqueue import PlayQueue
|
||||||
from plexapi.utils import cast, toDatetime
|
from plexapi.utils import cast, toDatetime
|
||||||
from plexapi.compat import quote_plus
|
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Playlist(PlexPartialObject, Playable):
|
class Playlist(PlexPartialObject, Playable):
|
||||||
""" Represents a single Playlist object.
|
""" Represents a single Playlist.
|
||||||
# TODO: Document attributes
|
|
||||||
|
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'
|
TAG = 'Playlist'
|
||||||
TYPE = 'playlist'
|
TYPE = 'playlist'
|
||||||
|
@ -20,12 +39,12 @@ class Playlist(PlexPartialObject, Playable):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
Playable._loadData(self, data)
|
Playable._loadData(self, data)
|
||||||
self.addedAt = toDatetime(data.attrib.get('addedAt'))
|
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.composite = data.attrib.get('composite') # url to thumbnail
|
||||||
self.duration = cast(int, data.attrib.get('duration'))
|
self.duration = cast(int, data.attrib.get('duration'))
|
||||||
self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds'))
|
self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds'))
|
||||||
self.guid = data.attrib.get('guid')
|
self.guid = data.attrib.get('guid')
|
||||||
self.key = data.attrib.get('key')
|
self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50
|
||||||
self.key = self.key.replace('/items', '') if self.key else self.key # FIX_BUG_50
|
|
||||||
self.leafCount = cast(int, data.attrib.get('leafCount'))
|
self.leafCount = cast(int, data.attrib.get('leafCount'))
|
||||||
self.playlistType = data.attrib.get('playlistType')
|
self.playlistType = data.attrib.get('playlistType')
|
||||||
self.ratingKey = cast(int, data.attrib.get('ratingKey'))
|
self.ratingKey = cast(int, data.attrib.get('ratingKey'))
|
||||||
|
@ -34,12 +53,15 @@ class Playlist(PlexPartialObject, Playable):
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
|
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
|
||||||
self.allowSync = cast(bool, data.attrib.get('allowSync'))
|
|
||||||
self._items = None # cache for self.items
|
self._items = None # cache for self.items
|
||||||
|
|
||||||
def __len__(self): # pragma: no cover
|
def __len__(self): # pragma: no cover
|
||||||
return len(self.items())
|
return len(self.items())
|
||||||
|
|
||||||
|
def __iter__(self): # pragma: no cover
|
||||||
|
for item in self.items():
|
||||||
|
yield item
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def metadataType(self):
|
def metadataType(self):
|
||||||
if self.isVideo:
|
if self.isVideo:
|
||||||
|
@ -69,14 +91,29 @@ class Playlist(PlexPartialObject, Playable):
|
||||||
def __getitem__(self, key): # pragma: no cover
|
def __getitem__(self, key): # pragma: no cover
|
||||||
return self.items()[key]
|
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):
|
def items(self):
|
||||||
""" Returns a list of all items in the playlist. """
|
""" Returns a list of all items in the playlist. """
|
||||||
if self._items is None:
|
if self._items is None:
|
||||||
key = '%s/items' % self.key
|
key = '/playlists/%s/items' % self.ratingKey
|
||||||
items = self.fetchItems(key)
|
items = self.fetchItems(key)
|
||||||
self._items = items
|
self._items = items
|
||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
|
def get(self, title):
|
||||||
|
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """
|
||||||
|
return self.item(title)
|
||||||
|
|
||||||
def addItems(self, items):
|
def addItems(self, items):
|
||||||
""" Add items to a playlist. """
|
""" Add items to a playlist. """
|
||||||
if not isinstance(items, (list, tuple)):
|
if not isinstance(items, (list, tuple)):
|
||||||
|
@ -130,6 +167,9 @@ class Playlist(PlexPartialObject, Playable):
|
||||||
@classmethod
|
@classmethod
|
||||||
def _create(cls, server, title, items):
|
def _create(cls, server, title, items):
|
||||||
""" Create a playlist. """
|
""" 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)):
|
if items and not isinstance(items, (list, tuple)):
|
||||||
items = [items]
|
items = [items]
|
||||||
ratingKeys = []
|
ratingKeys = []
|
||||||
|
@ -162,6 +202,9 @@ class Playlist(PlexPartialObject, Playable):
|
||||||
|
|
||||||
**kwargs (dict): is passed to the filters. For a example see the search method.
|
**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:
|
Returns:
|
||||||
:class:`~plexapi.playlist.Playlist`: an instance of created Playlist.
|
:class:`~plexapi.playlist.Playlist`: an instance of created Playlist.
|
||||||
"""
|
"""
|
||||||
|
@ -235,8 +278,8 @@ class Playlist(PlexPartialObject, Playable):
|
||||||
generated from metadata of current photo.
|
generated from metadata of current photo.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`plexapi.exceptions.BadRequest`: when playlist is not allowed to sync.
|
:exc:`~plexapi.exceptions.BadRequest`: When playlist is not allowed to sync.
|
||||||
:exc:`plexapi.exceptions.Unsupported`: when playlist content is unsupported.
|
:exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
|
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||||
|
|
|
@ -1,75 +1,289 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from plexapi import utils
|
from plexapi import utils
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
|
from plexapi.exceptions import BadRequest, Unsupported
|
||||||
|
|
||||||
|
|
||||||
class PlayQueue(PlexObject):
|
class PlayQueue(PlexObject):
|
||||||
""" Control a PlayQueue.
|
"""Control a PlayQueue.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
key (str): This is only added to support playMedia
|
TAG (str): 'PlayQueue'
|
||||||
|
TYPE (str): 'playqueue'
|
||||||
identifier (str): com.plexapp.plugins.library
|
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/
|
mediaTagPrefix (str): Fx /system/bundle/media/flags/
|
||||||
mediaTagVersion (str): Fx 1485957738
|
mediaTagVersion (int): Fx 1485957738
|
||||||
playQueueID (str): a id for the playqueue
|
playQueueID (int): ID of the PlayQueue.
|
||||||
playQueueSelectedItemID (str): playQueueSelectedItemID
|
playQueueLastAddedItemID (int):
|
||||||
playQueueSelectedItemOffset (str): playQueueSelectedItemOffset
|
Defines where the "Up Next" region starts. Empty unless PlayQueue is modified after creation.
|
||||||
playQueueSelectedMetadataItemID (<type 'str'>): 7
|
playQueueSelectedItemID (int): The queue item ID of the currently selected item.
|
||||||
playQueueShuffled (bool): True if shuffled
|
playQueueSelectedItemOffset (int):
|
||||||
playQueueSourceURI (str): Fx library://150425c9-0d99-4242-821e-e5ab81cd2221/item//library/metadata/7
|
The offset of the selected item in the PlayQueue, from the beginning of the queue.
|
||||||
playQueueTotalCount (str): How many items in the play queue.
|
playQueueSelectedMetadataItemID (int): ID of the currently selected item, matches ratingKey.
|
||||||
playQueueVersion (str): What version the playqueue is.
|
playQueueShuffled (bool): True if shuffled.
|
||||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
playQueueSourceURI (str): Original URI used to create the PlayQueue.
|
||||||
size (str): Seems to be a alias for playQueueTotalCount.
|
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):
|
def _loadData(self, data):
|
||||||
self._data = data
|
self._data = data
|
||||||
self.identifier = data.attrib.get('identifier')
|
self.identifier = data.attrib.get("identifier")
|
||||||
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
|
self.mediaTagPrefix = data.attrib.get("mediaTagPrefix")
|
||||||
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion"))
|
||||||
self.playQueueID = data.attrib.get('playQueueID')
|
self.playQueueID = utils.cast(int, data.attrib.get("playQueueID"))
|
||||||
self.playQueueSelectedItemID = data.attrib.get('playQueueSelectedItemID')
|
self.playQueueLastAddedItemID = utils.cast(
|
||||||
self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset')
|
int, data.attrib.get("playQueueLastAddedItemID")
|
||||||
self.playQueueSelectedMetadataItemID = data.attrib.get('playQueueSelectedMetadataItemID')
|
)
|
||||||
self.playQueueShuffled = utils.cast(bool, data.attrib.get('playQueueShuffled', 0))
|
self.playQueueSelectedItemID = utils.cast(
|
||||||
self.playQueueSourceURI = data.attrib.get('playQueueSourceURI')
|
int, data.attrib.get("playQueueSelectedItemID")
|
||||||
self.playQueueTotalCount = data.attrib.get('playQueueTotalCount')
|
)
|
||||||
self.playQueueVersion = data.attrib.get('playQueueVersion')
|
self.playQueueSelectedItemOffset = utils.cast(
|
||||||
self.size = utils.cast(int, data.attrib.get('size', 0))
|
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.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
|
@classmethod
|
||||||
def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1):
|
def get(
|
||||||
""" Create and returns a new :class:`~plexapi.playqueue.PlayQueue`.
|
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.
|
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.
|
shuffle (int, optional): Start the playqueue shuffled.
|
||||||
repeat (int, optional): Start the playqueue shuffled.
|
repeat (int, optional): Start the playqueue shuffled.
|
||||||
includeChapters (int, optional): include Chapters.
|
includeChapters (int, optional): include Chapters.
|
||||||
includeRelated (int, optional): include Related.
|
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 = {
|
||||||
args['includeChapters'] = includeChapters
|
"includeChapters": includeChapters,
|
||||||
args['includeRelated'] = includeRelated
|
"includeRelated": includeRelated,
|
||||||
args['repeat'] = repeat
|
"repeat": repeat,
|
||||||
args['shuffle'] = shuffle
|
"shuffle": shuffle,
|
||||||
if item.type == 'playlist':
|
"continuous": continuous,
|
||||||
args['playlistID'] = item.ratingKey
|
}
|
||||||
args['type'] = item.playlistType
|
|
||||||
|
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:
|
else:
|
||||||
uuid = item.section().uuid
|
uuid = items.section().uuid
|
||||||
args['key'] = item.key
|
args["type"] = items.listType
|
||||||
args['type'] = item.listType
|
args["uri"] = "library://{uuid}/item/{key}".format(uuid=uuid, key=items.key)
|
||||||
args['uri'] = 'library://%s/item/%s' % (uuid, item.key)
|
|
||||||
path = '/playQueues%s' % utils.joinArgs(args)
|
if startItem:
|
||||||
|
args["key"] = startItem.key
|
||||||
|
|
||||||
|
path = "/playQueues{args}".format(args=utils.joinArgs(args))
|
||||||
data = server.query(path, method=server._session.post)
|
data = server.query(path, method=server._session.post)
|
||||||
c = cls(server, data, initpath=path)
|
c = cls(server, data, initpath=path)
|
||||||
# we manually add a key so we can pass this to playMedia
|
c.playQueueType = args["type"]
|
||||||
# since the data, does not contain a key.
|
c._server = server
|
||||||
c.key = item.key
|
|
||||||
return c
|
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)
|
||||||
|
|
|
@ -1,23 +1,29 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.status_codes import _codes as codes
|
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log,
|
||||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE
|
logfilter)
|
||||||
from plexapi import log, logfilter, utils
|
from plexapi import utils
|
||||||
from plexapi.alert import AlertListener
|
from plexapi.alert import AlertListener
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
from plexapi.client import PlexClient
|
from plexapi.client import PlexClient
|
||||||
from plexapi.compat import ElementTree, urlencode
|
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||||
from plexapi.library import Hub, Library, Path, File
|
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.playlist import Playlist
|
||||||
from plexapi.playqueue import PlayQueue
|
from plexapi.playqueue import PlayQueue
|
||||||
|
from plexapi.settings import Settings
|
||||||
from plexapi.utils import cast
|
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
|
# Need these imports to populate utils.PLEXOBJECTS
|
||||||
from plexapi import (audio as _audio, video as _video, # noqa: F401
|
from plexapi import audio as _audio # noqa: F401; noqa: F401
|
||||||
photo as _photo, media as _media, playlist as _playlist) # 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):
|
class PlexServer(PlexObject):
|
||||||
|
@ -101,6 +107,8 @@ class PlexServer(PlexObject):
|
||||||
self._library = None # cached library
|
self._library = None # cached library
|
||||||
self._settings = None # cached settings
|
self._settings = None # cached settings
|
||||||
self._myPlexAccount = None # cached myPlexAccount
|
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)
|
data = self.query(self.key, timeout=timeout)
|
||||||
super(PlexServer, self).__init__(self, data, self.key)
|
super(PlexServer, self).__init__(self, data, self.key)
|
||||||
|
|
||||||
|
@ -184,6 +192,14 @@ class PlexServer(PlexObject):
|
||||||
data = self.query(Account.key)
|
data = self.query(Account.key)
|
||||||
return Account(self, data)
|
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):
|
def agents(self, mediaType=None):
|
||||||
""" Returns the :class:`~plexapi.media.Agent` objects this server has available. """
|
""" Returns the :class:`~plexapi.media.Agent` objects this server has available. """
|
||||||
key = '/system/agents'
|
key = '/system/agents'
|
||||||
|
@ -200,11 +216,18 @@ class PlexServer(PlexObject):
|
||||||
return q.attrib.get('token')
|
return q.attrib.get('token')
|
||||||
|
|
||||||
def systemAccounts(self):
|
def systemAccounts(self):
|
||||||
""" Returns the :class:`~plexapi.server.SystemAccounts` objects this server contains. """
|
""" Returns a list of :class:`~plexapi.server.SystemAccounts` objects this server contains. """
|
||||||
accounts = []
|
if self._systemAccounts is None:
|
||||||
for elem in self.query('/accounts'):
|
key = '/accounts'
|
||||||
accounts.append(SystemAccount(self, data=elem))
|
self._systemAccounts = self.fetchItems(key, SystemAccount)
|
||||||
return accounts
|
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):
|
def myPlexAccount(self):
|
||||||
""" Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same
|
""" 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.
|
name (str): Name of the client to return.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`plexapi.exceptions.NotFound`: Unknown client name
|
:exc:`~plexapi.exceptions.NotFound`: Unknown client name.
|
||||||
"""
|
"""
|
||||||
for client in self.clients():
|
for client in self.clients():
|
||||||
if client and client.title == name:
|
if client and client.title == name:
|
||||||
|
@ -325,7 +348,7 @@ class PlexServer(PlexObject):
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
item (Media or Playlist): Media or playlist to add to PlayQueue.
|
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)
|
return PlayQueue.create(self, item, **kwargs)
|
||||||
|
|
||||||
|
@ -413,11 +436,11 @@ class PlexServer(PlexObject):
|
||||||
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
|
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def playlists(self, **kwargs):
|
def playlists(self):
|
||||||
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """
|
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """
|
||||||
# TODO: Add sort and type options?
|
# TODO: Add sort and type options?
|
||||||
# /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0
|
# /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0
|
||||||
return self.fetchItems('/playlists', **kwargs)
|
return self.fetchItems('/playlists')
|
||||||
|
|
||||||
def playlist(self, title):
|
def playlist(self, title):
|
||||||
""" Returns the :class:`~plexapi.client.Playlist` that matches the specified 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.
|
title (str): Title of the playlist to return.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`plexapi.exceptions.NotFound`: Invalid playlist title
|
:exc:`~plexapi.exceptions.NotFound`: Invalid playlist title.
|
||||||
"""
|
"""
|
||||||
return self.fetchItem('/playlists', title=title)
|
return self.fetchItem('/playlists', title=title)
|
||||||
|
|
||||||
|
@ -471,7 +494,7 @@ class PlexServer(PlexObject):
|
||||||
log.debug('%s %s', method.__name__.upper(), url)
|
log.debug('%s %s', method.__name__.upper(), url)
|
||||||
headers = self._headers(**headers or {})
|
headers = self._headers(**headers or {})
|
||||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
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]
|
codename = codes.get(response.status_code)[0]
|
||||||
errtext = response.text.replace('\n', ' ')
|
errtext = response.text.replace('\n', ' ')
|
||||||
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
||||||
|
@ -499,16 +522,23 @@ class PlexServer(PlexObject):
|
||||||
Parameters:
|
Parameters:
|
||||||
query (str): Query to use when searching your library.
|
query (str): Query to use when searching your library.
|
||||||
mediatype (str): Optionally limit your search to the specified media type.
|
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.
|
limit (int): Optionally limit to the specified number of results per Hub.
|
||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
params = {'query': query}
|
params = {
|
||||||
if mediatype:
|
'query': query,
|
||||||
params['section'] = utils.SEARCHTYPES[mediatype]
|
'includeCollections': 1,
|
||||||
|
'includeExternalMedia': 1}
|
||||||
if limit:
|
if limit:
|
||||||
params['limit'] = limit
|
params['limit'] = limit
|
||||||
key = '/hubs/search?%s' % urlencode(params)
|
key = '/hubs/search?%s' % urlencode(params)
|
||||||
for hub in self.fetchItems(key, Hub):
|
for hub in self.fetchItems(key, Hub):
|
||||||
|
if mediatype:
|
||||||
|
if hub.type == mediatype:
|
||||||
|
return hub.items
|
||||||
|
else:
|
||||||
results += hub.items
|
results += hub.items
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
@ -516,6 +546,10 @@ class PlexServer(PlexObject):
|
||||||
""" Returns a list of all active session (currently playing) media objects. """
|
""" Returns a list of all active session (currently playing) media objects. """
|
||||||
return self.fetchItems('/status/sessions')
|
return self.fetchItems('/status/sessions')
|
||||||
|
|
||||||
|
def transcodeSessions(self):
|
||||||
|
""" Returns a list of all active :class:`~plexapi.media.TranscodeSession` objects. """
|
||||||
|
return self.fetchItems('/transcode/sessions')
|
||||||
|
|
||||||
def startAlertListener(self, callback=None):
|
def startAlertListener(self, callback=None):
|
||||||
""" Creates a websocket connection to the Plex Server to optionally recieve
|
""" Creates a websocket connection to the Plex Server to optionally recieve
|
||||||
notifications. These often include messages from Plex about media scans
|
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.
|
callback (func): Callback function to call on recieved messages.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`plexapi.exception.Unsupported`: Websocket-client not installed.
|
:exc:`~plexapi.exception.Unsupported`: Websocket-client not installed.
|
||||||
"""
|
"""
|
||||||
notifier = AlertListener(self, callback)
|
notifier = AlertListener(self, callback)
|
||||||
notifier.start()
|
notifier.start()
|
||||||
|
@ -593,6 +627,103 @@ class PlexServer(PlexObject):
|
||||||
value = 1 if toggle is True else 0
|
value = 1 if toggle is True else 0
|
||||||
return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put)
|
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):
|
class Account(PlexObject):
|
||||||
""" Contains the locally cached MyPlex account information. The properties provided don't
|
""" 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')
|
self.subscriptionState = data.attrib.get('subscriptionState')
|
||||||
|
|
||||||
|
|
||||||
class SystemAccount(PlexObject):
|
class Activity(PlexObject):
|
||||||
""" Minimal api to list system accounts. """
|
"""A currently running activity on the PlexServer."""
|
||||||
key = '/accounts'
|
key = '/activities'
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
self._data = data
|
self._data = data
|
||||||
self.accountID = cast(int, data.attrib.get('id'))
|
self.cancellable = cast(bool, data.attrib.get('cancellable'))
|
||||||
self.accountKey = data.attrib.get('key')
|
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.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])
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from plexapi import log, utils
|
from plexapi import log, utils
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
from plexapi.compat import quote, string_type
|
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
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_cast = lambda x: True if x == 'true' or x == '1' else False
|
||||||
_bool_str = lambda x: str(x).lower()
|
_bool_str = lambda x: str(x).lower()
|
||||||
_str = lambda x: str(x).encode('utf-8')
|
|
||||||
TYPES = {
|
TYPES = {
|
||||||
'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str},
|
'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str},
|
||||||
'double': {'type': float, 'cast': float, 'tostr': _str},
|
'double': {'type': float, 'cast': float, 'tostr': str},
|
||||||
'int': {'type': int, 'cast': int, 'tostr': _str},
|
'int': {'type': int, 'cast': int, 'tostr': str},
|
||||||
'text': {'type': string_type, 'cast': _str, 'tostr': _str},
|
'text': {'type': str, 'cast': str, 'tostr': str},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
|
@ -158,3 +157,21 @@ class Setting(PlexObject):
|
||||||
def toUrl(self):
|
def toUrl(self):
|
||||||
"""Helper for urls"""
|
"""Helper for urls"""
|
||||||
return '%s=%s' % (self.id, self._value or self.value)
|
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)
|
||||||
|
|
|
@ -201,7 +201,7 @@ class MediaSettings(object):
|
||||||
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module.
|
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`plexapi.exceptions.BadRequest`: when provided unknown video quality.
|
:exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality.
|
||||||
"""
|
"""
|
||||||
if videoQuality == VIDEO_QUALITY_ORIGINAL:
|
if videoQuality == VIDEO_QUALITY_ORIGINAL:
|
||||||
return MediaSettings('', '', '')
|
return MediaSettings('', '', '')
|
||||||
|
@ -231,7 +231,7 @@ class MediaSettings(object):
|
||||||
module.
|
module.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`plexapi.exceptions.BadRequest` when provided unknown video quality.
|
:exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality.
|
||||||
"""
|
"""
|
||||||
if resolution in PHOTO_QUALITIES:
|
if resolution in PHOTO_QUALITIES:
|
||||||
return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution)
|
return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution)
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import base64
|
import base64
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
import warnings
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from plexapi import compat
|
from plexapi.exceptions import BadRequest, NotFound
|
||||||
from plexapi.exceptions import NotFound
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
@ -19,13 +21,13 @@ except ImportError:
|
||||||
tqdm = None
|
tqdm = None
|
||||||
|
|
||||||
log = logging.getLogger('plexapi')
|
log = logging.getLogger('plexapi')
|
||||||
|
warnings.simplefilter('default', category=DeprecationWarning)
|
||||||
|
|
||||||
# Search Types - Plex uses these to filter specific media types when searching.
|
# Search Types - Plex uses these to filter specific media types when searching.
|
||||||
# Library Types - Populated at runtime
|
# Library Types - Populated at runtime
|
||||||
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7,
|
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,
|
'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14,
|
||||||
'playlist': 15, 'playlistFolder': 16, 'collection': 18,
|
'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'optimizedVersion': 42, 'userPlaylistItem': 1001}
|
||||||
'optimizedVersion': 42, 'userPlaylistItem': 1001}
|
|
||||||
PLEXOBJECTS = {}
|
PLEXOBJECTS = {}
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,7 +45,7 @@ class SecretsFilter(logging.Filter):
|
||||||
def filter(self, record):
|
def filter(self, record):
|
||||||
cleanargs = list(record.args)
|
cleanargs = list(record.args)
|
||||||
for i in range(len(cleanargs)):
|
for i in range(len(cleanargs)):
|
||||||
if isinstance(cleanargs[i], compat.string_type):
|
if isinstance(cleanargs[i], str):
|
||||||
for secret in self.secrets:
|
for secret in self.secrets:
|
||||||
cleanargs[i] = cleanargs[i].replace(secret, '<hidden>')
|
cleanargs[i] = cleanargs[i].replace(secret, '<hidden>')
|
||||||
record.args = tuple(cleanargs)
|
record.args = tuple(cleanargs)
|
||||||
|
@ -55,7 +57,7 @@ def registerPlexObject(cls):
|
||||||
define a few helper functions to dynamically convery the XML into objects. See
|
define a few helper functions to dynamically convery the XML into objects. See
|
||||||
buildItem() below for an example.
|
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
|
ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG
|
||||||
if ehash in PLEXOBJECTS:
|
if ehash in PLEXOBJECTS:
|
||||||
raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' %
|
raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' %
|
||||||
|
@ -101,8 +103,8 @@ def joinArgs(args):
|
||||||
return ''
|
return ''
|
||||||
arglist = []
|
arglist = []
|
||||||
for key in sorted(args, key=lambda x: x.lower()):
|
for key in sorted(args, key=lambda x: x.lower()):
|
||||||
value = compat.ustr(args[key])
|
value = str(args[key])
|
||||||
arglist.append('%s=%s' % (key, compat.quote(value, safe='')))
|
arglist.append('%s=%s' % (key, quote(value, safe='')))
|
||||||
return '?%s' % '&'.join(arglist)
|
return '?%s' % '&'.join(arglist)
|
||||||
|
|
||||||
|
|
||||||
|
@ -112,7 +114,7 @@ def lowerFirst(s):
|
||||||
|
|
||||||
def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover
|
def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover
|
||||||
""" Returns the value at the specified attrstr location within a nexted tree of
|
""" 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
|
for each key in attrstr (split by by the delimiter) This function is heavily
|
||||||
influenced by the lookups used in Django templates.
|
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,
|
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
|
||||||
collection)
|
collection)
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`plexapi.exceptions.NotFound`: Unknown libtype
|
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
||||||
"""
|
"""
|
||||||
libtype = compat.ustr(libtype)
|
libtype = str(libtype)
|
||||||
if libtype in [compat.ustr(v) for v in SEARCHTYPES.values()]:
|
if libtype in [str(v) for v in SEARCHTYPES.values()]:
|
||||||
return libtype
|
return libtype
|
||||||
if SEARCHTYPES.get(libtype) is not None:
|
if SEARCHTYPES.get(libtype) is not None:
|
||||||
return SEARCHTYPES[libtype]
|
return SEARCHTYPES[libtype]
|
||||||
|
@ -159,12 +161,12 @@ def searchType(libtype):
|
||||||
|
|
||||||
|
|
||||||
def threaded(callback, listargs):
|
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.
|
to <callback> is called concurrently in their own separate threads.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
callback (func): Callback function to apply to each set of \*args.
|
callback (func): Callback function to apply to each set of `*args`.
|
||||||
listargs (list): List of lists; \*args to pass each thread.
|
listargs (list): List of lists; `*args` to pass each thread.
|
||||||
"""
|
"""
|
||||||
threads, results = [], []
|
threads, results = [], []
|
||||||
job_is_done_event = Event()
|
job_is_done_event = Event()
|
||||||
|
@ -206,6 +208,19 @@ def toDatetime(value, format=None):
|
||||||
return value
|
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=','):
|
def toList(value, itemcast=None, delim=','):
|
||||||
""" Returns a list of strings from the specified value.
|
""" 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)
|
response = session.get(url, headers=headers, stream=True)
|
||||||
# make sure the savepath directory exists
|
# make sure the savepath directory exists
|
||||||
savepath = savepath or os.getcwd()
|
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)
|
# try getting filename from header if not specified in arguments (used for logs, db)
|
||||||
if not filename and response.headers.get('Content-Disposition'):
|
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:
|
if config_username and config_password:
|
||||||
print('Authenticating with Plex.tv as %s..' % config_username)
|
print('Authenticating with Plex.tv as %s..' % config_username)
|
||||||
return MyPlexAccount(config_username, config_password)
|
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
|
# 3. Prompt for username and password on the command line
|
||||||
username = input('What is your plex.tv username: ')
|
username = input('What is your plex.tv username: ')
|
||||||
password = getpass('What is your plex.tv password: ')
|
password = getpass('What is your plex.tv password: ')
|
||||||
|
@ -363,6 +382,30 @@ def getMyPlexAccount(opts=None): # pragma: no cover
|
||||||
return MyPlexAccount(username, password)
|
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
|
def choose(msg, items, attr): # pragma: no cover
|
||||||
""" Command line helper to display a list of choices, asking the
|
""" Command line helper to display a list of choices, asking the
|
||||||
user to choose one of the options.
|
user to choose one of the options.
|
||||||
|
@ -404,3 +447,18 @@ def getAgentIdentifier(section, agent):
|
||||||
|
|
||||||
def base64str(text):
|
def base64str(text):
|
||||||
return base64.b64encode(text.encode('utf-8')).decode('utf-8')
|
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
|
||||||
|
|
|
@ -1,47 +1,54 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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
|
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):
|
class Video(PlexPartialObject):
|
||||||
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
||||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
||||||
:class:`~plexapi.video.Episode`.
|
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
addedAt (datetime): Datetime this item was added to the library.
|
addedAt (datetime): Datetime the item was added to the library.
|
||||||
art (str): URL to artwork image.
|
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||||
artBlurHash (str): BlurHash string for artwork image.
|
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>).
|
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.
|
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||||
listType (str): Hardcoded as 'audio' (useful for search filters).
|
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||||
ratingKey (int): Unique key identifying this item.
|
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||||
summary (str): Summary of the artist, track, or album.
|
listType (str): Hardcoded as 'video' (useful for search filters).
|
||||||
thumb (str): URL to thumbnail image.
|
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.
|
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).
|
titleSort (str): Title to use when sorting (defaults to title).
|
||||||
type (str): 'artist', 'album', or 'track'.
|
type (str): 'movie', 'show', 'season', 'episode', or 'clip'.
|
||||||
updatedAt (datatime): Datetime this item was updated.
|
updatedAt (datatime): Datetime the item was updated.
|
||||||
viewCount (int): Count of times this item was accessed.
|
viewCount (int): Count of times the item was played.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
self._data = data
|
self._data = data
|
||||||
self.listType = 'video'
|
|
||||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||||
self.art = data.attrib.get('art')
|
self.art = data.attrib.get('art')
|
||||||
self.artBlurHash = data.attrib.get('artBlurHash')
|
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.key = data.attrib.get('key', '')
|
||||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||||
|
self.listType = 'video'
|
||||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||||
self.summary = data.attrib.get('summary')
|
self.summary = data.attrib.get('summary')
|
||||||
self.thumb = data.attrib.get('thumb')
|
self.thumb = data.attrib.get('thumb')
|
||||||
|
@ -133,8 +140,9 @@ class Video(PlexPartialObject):
|
||||||
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None):
|
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None):
|
||||||
""" Optimize item
|
""" Optimize item
|
||||||
|
|
||||||
locationID (int): -1 in folder with orginal items
|
locationID (int): -1 in folder with original items
|
||||||
2 library path
|
2 library path id
|
||||||
|
library path id is found in library.locations[i].id
|
||||||
|
|
||||||
target (str): custom quality name.
|
target (str): custom quality name.
|
||||||
if none provided use "Custom: {deviceProfile}"
|
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):
|
if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None):
|
||||||
raise BadRequest('Unexpected or missing quality profile.')
|
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):
|
if isinstance(targetTagID, str):
|
||||||
tagIndex = tagKeys.index(targetTagID)
|
tagIndex = tagKeys.index(targetTagID)
|
||||||
targetTagID = tagValues[tagIndex]
|
targetTagID = tagValues[tagIndex]
|
||||||
|
@ -250,35 +265,33 @@ class Movie(Playable, Video):
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Video'
|
TAG (str): 'Video'
|
||||||
TYPE (str): 'movie'
|
TYPE (str): 'movie'
|
||||||
art (str): Key to movie artwork (/library/metadata/<ratingkey>/art/<artid>)
|
|
||||||
audienceRating (float): Audience rating (usually from Rotten Tomatoes).
|
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).
|
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).
|
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||||
duration (int): Duration of movie in milliseconds.
|
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
|
||||||
guid: Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
|
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 (転々; 엽기적인 그녀).
|
originalTitle (str): Original title, often the foreign title (転々; 엽기적인 그녀).
|
||||||
originallyAvailableAt (datetime): Datetime movie was released.
|
|
||||||
primaryExtraKey (str) Primary extra key (/library/metadata/66351).
|
primaryExtraKey (str) Primary extra key (/library/metadata/66351).
|
||||||
rating (float): Movie rating (7.9; 9.8; 8.1).
|
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
|
||||||
ratingImage (str): Key to rating image (rottentomatoes://image.rating.rotten).
|
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).
|
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?).
|
tagline (str): Movie tag line (Back 2 Work; Who says men can't change?).
|
||||||
userRating (float): User rating (2.0; 8.0).
|
userRating (float): User rating (2.0; 8.0).
|
||||||
viewOffset (int): View offset in milliseconds.
|
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.
|
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||||
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
year (int): Year movie was released.
|
||||||
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
|
||||||
"""
|
"""
|
||||||
TAG = 'Video'
|
TAG = 'Video'
|
||||||
TYPE = 'movie'
|
TYPE = 'movie'
|
||||||
|
@ -288,38 +301,33 @@ class Movie(Playable, Video):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
Video._loadData(self, data)
|
Video._loadData(self, data)
|
||||||
Playable._loadData(self, data)
|
Playable._loadData(self, data)
|
||||||
|
|
||||||
self.art = data.attrib.get('art')
|
|
||||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||||
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
|
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
|
||||||
|
self.chapters = self.findItems(data, media.Chapter)
|
||||||
self.chapterSource = data.attrib.get('chapterSource')
|
self.chapterSource = data.attrib.get('chapterSource')
|
||||||
|
self.collections = self.findItems(data, media.Collection)
|
||||||
self.contentRating = data.attrib.get('contentRating')
|
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.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.originalTitle = data.attrib.get('originalTitle')
|
||||||
self.originallyAvailableAt = utils.toDatetime(
|
|
||||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
|
||||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||||
|
self.producers = self.findItems(data, media.Producer)
|
||||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||||
self.ratingImage = data.attrib.get('ratingImage')
|
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.studio = data.attrib.get('studio')
|
||||||
self.tagline = data.attrib.get('tagline')
|
self.tagline = data.attrib.get('tagline')
|
||||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
|
||||||
self.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.writers = self.findItems(data, media.Writer)
|
||||||
self.labels = self.findItems(data, media.Label)
|
self.year = utils.cast(int, data.attrib.get('year'))
|
||||||
self.chapters = self.findItems(data, media.Chapter)
|
|
||||||
self.similar = self.findItems(data, media.Similar)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def actors(self):
|
def actors(self):
|
||||||
|
@ -329,7 +337,10 @@ class Movie(Playable, Video):
|
||||||
@property
|
@property
|
||||||
def locations(self):
|
def locations(self):
|
||||||
""" This does not exist in plex xml response but is added to have a common
|
""" This does not exist in plex xml response but is added to have a common
|
||||||
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]
|
return [part.file for part in self.iterParts() if part]
|
||||||
|
|
||||||
|
@ -337,6 +348,15 @@ class Movie(Playable, Video):
|
||||||
# This is just for compat.
|
# This is just for compat.
|
||||||
return self.title
|
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):
|
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||||
""" Download video files to specified directory.
|
""" Download video files to specified directory.
|
||||||
|
|
||||||
|
@ -371,61 +391,56 @@ class Show(Video):
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Directory'
|
TAG (str): 'Directory'
|
||||||
TYPE (str): 'show'
|
TYPE (str): 'show'
|
||||||
art (str): Key to show artwork (/library/metadata/<ratingkey>/art/<artid>)
|
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/banner/<bannerid>).
|
||||||
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/art/<artid>)
|
childCount (int): Number of seasons in the show.
|
||||||
childCount (int): Unknown.
|
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||||
collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs.
|
duration (int): Typical duration of the show episodes in milliseconds.
|
||||||
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.
|
|
||||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
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.
|
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||||
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
||||||
|
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||||
|
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'
|
TAG = 'Directory'
|
||||||
TYPE = 'show'
|
TYPE = 'show'
|
||||||
METADATA_TYPE = 'episode'
|
METADATA_TYPE = 'episode'
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
for season in self.seasons():
|
|
||||||
yield season
|
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
Video._loadData(self, data)
|
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.banner = data.attrib.get('banner')
|
||||||
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
||||||
self.contentRating = data.attrib.get('contentRating')
|
|
||||||
self.collections = self.findItems(data, media.Collection)
|
self.collections = self.findItems(data, media.Collection)
|
||||||
|
self.contentRating = data.attrib.get('contentRating')
|
||||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||||
self.guid = data.attrib.get('guid')
|
self.genres = self.findItems(data, media.Genre)
|
||||||
self.index = data.attrib.get('index')
|
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.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||||
self.originallyAvailableAt = utils.toDatetime(
|
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
|
||||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
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.studio = data.attrib.get('studio')
|
||||||
self.theme = data.attrib.get('theme')
|
self.theme = data.attrib.get('theme')
|
||||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
self.year = utils.cast(int, data.attrib.get('year'))
|
||||||
self.fields = self.findItems(data, media.Field)
|
|
||||||
self.genres = self.findItems(data, media.Genre)
|
def __iter__(self):
|
||||||
self.roles = self.findItems(data, media.Role)
|
for season in self.seasons():
|
||||||
self.labels = self.findItems(data, media.Label)
|
yield season
|
||||||
self.similar = self.findItems(data, media.Similar)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def actors(self):
|
def actors(self):
|
||||||
|
@ -434,52 +449,116 @@ class Show(Video):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isWatched(self):
|
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)
|
return bool(self.viewedLeafCount == self.leafCount)
|
||||||
|
|
||||||
def seasons(self, **kwargs):
|
def preferences(self):
|
||||||
""" Returns a list of :class:`~plexapi.video.Season` objects. """
|
""" Returns a list of :class:`~plexapi.settings.Preferences` objects. """
|
||||||
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
|
items = []
|
||||||
return self.fetchItems(key, **kwargs)
|
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.
|
""" Returns the season with the specified title or number.
|
||||||
|
|
||||||
Parameters:
|
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
|
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):
|
if isinstance(title, int):
|
||||||
return self.fetchItem(key, etag='Directory', index__iexact=str(title))
|
index = title
|
||||||
return self.fetchItem(key, etag='Directory', title__iexact=title)
|
else:
|
||||||
|
index = season
|
||||||
|
return self.fetchItem(key, Season, index=index)
|
||||||
|
raise BadRequest('Missing argument: title or season is required')
|
||||||
|
|
||||||
def episodes(self, **kwargs):
|
def seasons(self, **kwargs):
|
||||||
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
|
""" Returns a list of :class:`~plexapi.video.Season` objects in the show. """
|
||||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
|
||||||
return self.fetchItems(key, **kwargs)
|
return self.fetchItems(key, Season, **kwargs)
|
||||||
|
|
||||||
def episode(self, title=None, season=None, episode=None):
|
def episode(self, title=None, season=None, episode=None):
|
||||||
""" Find a episode using a title or season and episode.
|
""" Find a episode using a title or season and episode.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str): Title of the episode to return
|
title (str): Title of the episode to return
|
||||||
season (int): Season number (default:None; required if title not specified).
|
season (int): Season number (default: None; required if title not specified).
|
||||||
episode (int): Episode number (default:None; required if title not specified).
|
episode (int): Episode number (default: None; required if title not specified).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`plexapi.exceptions.BadRequest`: If season and episode is missing.
|
:exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing.
|
||||||
:exc:`plexapi.exceptions.NotFound`: If the episode is missing.
|
|
||||||
"""
|
"""
|
||||||
if title:
|
|
||||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||||
return self.fetchItem(key, title__iexact=title)
|
if title is not None:
|
||||||
elif season is not None and episode:
|
return self.fetchItem(key, Episode, title__iexact=title)
|
||||||
results = [i for i in self.episodes() if i.seasonNumber == season and i.index == episode]
|
elif season is not None and episode is not None:
|
||||||
if results:
|
return self.fetchItem(key, Episode, parentIndex=season, index=episode)
|
||||||
return results[0]
|
|
||||||
raise NotFound('Couldnt find %s S%s E%s' % (self.title, season, episode))
|
|
||||||
raise BadRequest('Missing argument: title or season and episode are required')
|
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):
|
def watched(self):
|
||||||
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
||||||
return self.episodes(viewCount__gt=0)
|
return self.episodes(viewCount__gt=0)
|
||||||
|
@ -488,10 +567,6 @@ class Show(Video):
|
||||||
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
|
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
|
||||||
return self.episodes(viewCount=0)
|
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):
|
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||||
""" Download video files to specified directory.
|
""" Download video files to specified directory.
|
||||||
|
|
||||||
|
@ -514,31 +589,28 @@ class Season(Video):
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Directory'
|
TAG (str): 'Directory'
|
||||||
TYPE (str): 'season'
|
TYPE (str): 'season'
|
||||||
leafCount (int): Number of episodes in season.
|
|
||||||
index (int): Season number.
|
index (int): Season number.
|
||||||
parentKey (str): Key to this seasons :class:`~plexapi.video.Show`.
|
key (str): API URL (/library/metadata/<ratingkey>).
|
||||||
parentRatingKey (int): Unique key for this seasons :class:`~plexapi.video.Show`.
|
leafCount (int): Number of items in the season view.
|
||||||
parentTitle (str): Title of this seasons :class:`~plexapi.video.Show`.
|
parentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6).
|
||||||
viewedLeafCount (int): Number of watched episodes in season.
|
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'
|
TAG = 'Directory'
|
||||||
TYPE = 'season'
|
TYPE = 'season'
|
||||||
METADATA_TYPE = 'episode'
|
METADATA_TYPE = 'episode'
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
for episode in self.episodes():
|
|
||||||
yield episode
|
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
Video._loadData(self, data)
|
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.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.parentGuid = data.attrib.get('parentGuid')
|
||||||
self.parentIndex = data.attrib.get('parentIndex')
|
self.parentIndex = data.attrib.get('parentIndex')
|
||||||
self.parentKey = data.attrib.get('parentKey')
|
self.parentKey = data.attrib.get('parentKey')
|
||||||
|
@ -547,7 +619,10 @@ class Season(Video):
|
||||||
self.parentThumb = data.attrib.get('parentThumb')
|
self.parentThumb = data.attrib.get('parentThumb')
|
||||||
self.parentTitle = data.attrib.get('parentTitle')
|
self.parentTitle = data.attrib.get('parentTitle')
|
||||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
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):
|
def __repr__(self):
|
||||||
return '<%s>' % ':'.join([p for p in [
|
return '<%s>' % ':'.join([p for p in [
|
||||||
|
@ -558,7 +633,7 @@ class Season(Video):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isWatched(self):
|
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)
|
return bool(self.viewedLeafCount == self.leafCount)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -567,31 +642,44 @@ class Season(Video):
|
||||||
return self.index
|
return self.index
|
||||||
|
|
||||||
def episodes(self, **kwargs):
|
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
|
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):
|
def episode(self, title=None, episode=None):
|
||||||
""" Returns the episode with the given title or number.
|
""" Returns the episode with the given title or number.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str): Title of the episode to return.
|
title (str): Title of the episode to return.
|
||||||
episode (int): Episode number (default:None; required if title not specified).
|
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
|
key = '/library/metadata/%s/children' % self.ratingKey
|
||||||
if title:
|
if title is not None:
|
||||||
return self.fetchItem(key, title=title)
|
return self.fetchItem(key, Episode, title__iexact=title)
|
||||||
return self.fetchItem(key, parentIndex=self.index, index=episode)
|
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):
|
def get(self, title=None, episode=None):
|
||||||
""" Alias to :func:`~plexapi.video.Season.episode`. """
|
""" Alias to :func:`~plexapi.video.Season.episode`. """
|
||||||
return self.episode(title, episode)
|
return self.episode(title, episode)
|
||||||
|
|
||||||
|
def onDeck(self):
|
||||||
|
""" Returns season's On Deck :class:`~plexapi.video.Video` object or `None`.
|
||||||
|
Will only return a match if the show's On Deck episode is in this season.
|
||||||
|
"""
|
||||||
|
data = self._server.query(self._details_key)
|
||||||
|
episode = next(data.iter('OnDeck'), None)
|
||||||
|
if episode:
|
||||||
|
return self.findItems(episode)[0]
|
||||||
|
return None
|
||||||
|
|
||||||
def show(self):
|
def show(self):
|
||||||
""" Return this seasons :func:`~plexapi.video.Show`.. """
|
""" Return the season's :class:`~plexapi.video.Show`. """
|
||||||
return self.fetchItem(int(self.parentRatingKey))
|
return self.fetchItem(self.parentRatingKey)
|
||||||
|
|
||||||
def watched(self):
|
def watched(self):
|
||||||
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
||||||
|
@ -627,31 +715,32 @@ class Episode(Playable, Video):
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Video'
|
TAG (str): 'Video'
|
||||||
TYPE (str): 'episode'
|
TYPE (str): 'episode'
|
||||||
art (str): Key to episode artwork (/library/metadata/<ratingkey>/art/<artid>)
|
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
||||||
chapterSource (str): Unknown (media).
|
chapterSource (str): Chapter source (agent; media; mixed).
|
||||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
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.
|
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.
|
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.
|
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||||
|
year (int): Year episode was released.
|
||||||
"""
|
"""
|
||||||
TAG = 'Video'
|
TAG = 'Video'
|
||||||
TYPE = 'episode'
|
TYPE = 'episode'
|
||||||
|
@ -662,10 +751,10 @@ class Episode(Playable, Video):
|
||||||
Video._loadData(self, data)
|
Video._loadData(self, data)
|
||||||
Playable._loadData(self, data)
|
Playable._loadData(self, data)
|
||||||
self._seasonNumber = None # cached season number
|
self._seasonNumber = None # cached season number
|
||||||
art = data.attrib.get('art')
|
self.chapters = self.findItems(data, media.Chapter)
|
||||||
self.art = art if art and str(self.ratingKey) in art else None
|
|
||||||
self.chapterSource = data.attrib.get('chapterSource')
|
self.chapterSource = data.attrib.get('chapterSource')
|
||||||
self.contentRating = data.attrib.get('contentRating')
|
self.contentRating = data.attrib.get('contentRating')
|
||||||
|
self.directors = self.findItems(data, media.Director)
|
||||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||||
self.grandparentArt = data.attrib.get('grandparentArt')
|
self.grandparentArt = data.attrib.get('grandparentArt')
|
||||||
self.grandparentGuid = data.attrib.get('grandparentGuid')
|
self.grandparentGuid = data.attrib.get('grandparentGuid')
|
||||||
|
@ -674,27 +763,20 @@ class Episode(Playable, Video):
|
||||||
self.grandparentTheme = data.attrib.get('grandparentTheme')
|
self.grandparentTheme = data.attrib.get('grandparentTheme')
|
||||||
self.grandparentThumb = data.attrib.get('grandparentThumb')
|
self.grandparentThumb = data.attrib.get('grandparentThumb')
|
||||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||||
self.guid = data.attrib.get('guid')
|
|
||||||
self.index = utils.cast(int, data.attrib.get('index'))
|
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.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||||
self.parentGuid = data.attrib.get('parentGuid')
|
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.parentKey = data.attrib.get('parentKey')
|
||||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||||
self.parentThumb = data.attrib.get('parentThumb')
|
self.parentThumb = data.attrib.get('parentThumb')
|
||||||
self.parentTitle = data.attrib.get('parentTitle')
|
self.parentTitle = data.attrib.get('parentTitle')
|
||||||
self.title = data.attrib.get('title')
|
|
||||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
|
||||||
self.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.writers = self.findItems(data, media.Writer)
|
||||||
self.labels = self.findItems(data, media.Label)
|
self.year = utils.cast(int, data.attrib.get('year'))
|
||||||
self.collections = self.findItems(data, media.Collection)
|
|
||||||
self.chapters = self.findItems(data, media.Chapter)
|
|
||||||
self.markers = self.findItems(data, media.Marker)
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<%s>' % ':'.join([p for p in [
|
return '<%s>' % ':'.join([p for p in [
|
||||||
|
@ -710,13 +792,16 @@ class Episode(Playable, Video):
|
||||||
@property
|
@property
|
||||||
def locations(self):
|
def locations(self):
|
||||||
""" This does not exist in plex xml response but is added to have a common
|
""" This does not exist in plex xml response but is added to have a common
|
||||||
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]
|
return [part.file for part in self.iterParts() if part]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def seasonNumber(self):
|
def seasonNumber(self):
|
||||||
""" Returns this episodes season number. """
|
""" Returns the episodes season number. """
|
||||||
if self._seasonNumber is None:
|
if self._seasonNumber is None:
|
||||||
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
|
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
|
||||||
return utils.cast(int, self._seasonNumber)
|
return utils.cast(int, self._seasonNumber)
|
||||||
|
@ -728,18 +813,18 @@ class Episode(Playable, Video):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hasIntroMarker(self):
|
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():
|
if not self.isFullObject():
|
||||||
self.reload()
|
self.reload()
|
||||||
return any(marker.type == 'intro' for marker in self.markers)
|
return any(marker.type == 'intro' for marker in self.markers)
|
||||||
|
|
||||||
def season(self):
|
def season(self):
|
||||||
"""" Return this episodes :func:`~plexapi.video.Season`.. """
|
"""" Return the episode's :class:`~plexapi.video.Season`. """
|
||||||
return self.fetchItem(self.parentKey)
|
return self.fetchItem(self.parentKey)
|
||||||
|
|
||||||
def show(self):
|
def show(self):
|
||||||
"""" Return this episodes :func:`~plexapi.video.Show`.. """
|
"""" Return the episode's :class:`~plexapi.video.Show`. """
|
||||||
return self.fetchItem(int(self.grandparentRatingKey))
|
return self.fetchItem(self.grandparentRatingKey)
|
||||||
|
|
||||||
def _defaultSyncTitle(self):
|
def _defaultSyncTitle(self):
|
||||||
""" Returns str, default title for a new syncItem. """
|
""" Returns str, default title for a new syncItem. """
|
||||||
|
@ -748,35 +833,49 @@ class Episode(Playable, Video):
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Clip(Playable, Video):
|
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'
|
TAG = 'Video'
|
||||||
TYPE = 'clip'
|
TYPE = 'clip'
|
||||||
METADATA_TYPE = 'clip'
|
METADATA_TYPE = 'clip'
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
""" Load attribute values from Plex XML response. """
|
"""Load attribute values from Plex XML response."""
|
||||||
Video._loadData(self, data)
|
Video._loadData(self, data)
|
||||||
Playable._loadData(self, data)
|
Playable._loadData(self, data)
|
||||||
self._data = data
|
self._data = data
|
||||||
self.addedAt = data.attrib.get('addedAt')
|
|
||||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||||
self.guid = data.attrib.get('guid')
|
self.extraType = utils.cast(int, data.attrib.get('extraType'))
|
||||||
self.key = data.attrib.get('key')
|
self.index = utils.cast(int, data.attrib.get('index'))
|
||||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
self.media = self.findItems(data, media.Media)
|
||||||
self.ratingKey = data.attrib.get('ratingKey')
|
self.originallyAvailableAt = data.attrib.get('originallyAvailableAt')
|
||||||
self.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))
|
self.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))
|
||||||
self.subtype = data.attrib.get('subtype')
|
self.subtype = data.attrib.get('subtype')
|
||||||
self.thumb = data.attrib.get('thumb')
|
|
||||||
self.thumbAspectRatio = data.attrib.get('thumbAspectRatio')
|
self.thumbAspectRatio = data.attrib.get('thumbAspectRatio')
|
||||||
self.title = data.attrib.get('title')
|
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.type = data.attrib.get('type')
|
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
self.year = utils.cast(int, data.attrib.get('year'))
|
||||||
self.media = self.findItems(data, media.Media)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def locations(self):
|
def locations(self):
|
||||||
""" This does not exist in plex xml response but is added to have a common
|
""" This does not exist in plex xml response but is added to have a common
|
||||||
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]
|
return [part.file for part in self.iterParts() if part]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue