mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-08 06:00:51 -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
|
||||
PROJECT = 'PlexAPI'
|
||||
VERSION = '3.6.0'
|
||||
VERSION = '4.3.0'
|
||||
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
|
||||
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
|
||||
X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)
|
||||
|
|
|
@ -1,31 +1,39 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import library, media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.compat import quote_plus
|
||||
from plexapi.exceptions import BadRequest
|
||||
|
||||
|
||||
class Audio(PlexPartialObject):
|
||||
""" Base class for audio :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`
|
||||
and :class:`~plexapi.audio.Track` objects.
|
||||
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
|
||||
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
art (str): URL to artwork image.
|
||||
addedAt (datetime): Datetime the item was added to the library.
|
||||
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||
artBlurHash (str): BlurHash string for artwork image.
|
||||
index (sting): Index Number (often the track number).
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c).
|
||||
index (int): Plex index number (often the track number).
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastViewedAt (datetime): Datetime item was last accessed.
|
||||
lastViewedAt (datetime): Datetime the item was last played.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the artist, track, or album.
|
||||
thumb (str): URL to thumbnail image.
|
||||
moods (List<:class:`~plexapi.media.Mood`>): List of mood objects.
|
||||
ratingKey (int): Unique key identifying the item.
|
||||
summary (str): Summary of the artist, album, or track.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
thumbBlurHash (str): BlurHash string for thumbnail image.
|
||||
title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.)
|
||||
title (str): Name of the artist, album, or track (Jason Mraz, We Sing, Lucky, etc.).
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'artist', 'album', or 'track'.
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
viewCount (int): Count of times this item was accessed.
|
||||
updatedAt (datatime): Datetime the item was updated.
|
||||
userRating (float): Rating of the track (0.0 - 10.0) equaling (0 stars - 5 stars).
|
||||
viewCount (int): Count of times the item was played.
|
||||
"""
|
||||
|
||||
METADATA_TYPE = 'track'
|
||||
|
@ -33,16 +41,19 @@ class Audio(PlexPartialObject):
|
|||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.listType = 'audio'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.artBlurHash = data.attrib.get('artBlurHash')
|
||||
self.index = data.attrib.get('index')
|
||||
self.key = data.attrib.get('key')
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '')
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.listType = 'audio'
|
||||
self.moods = self.findItems(data, media.Mood)
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
|
@ -51,6 +62,7 @@ class Audio(PlexPartialObject):
|
|||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating', 0))
|
||||
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
|
||||
@property
|
||||
|
@ -66,7 +78,7 @@ class Audio(PlexPartialObject):
|
|||
return self._server.url(art, includeToken=True) if art else None
|
||||
|
||||
def url(self, part):
|
||||
""" Returns the full URL for this audio item. Typically used for getting a specific track. """
|
||||
""" Returns the full URL for the audio item. Typically used for getting a specific track. """
|
||||
return self._server.url(part, includeToken=True) if part else None
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
|
@ -112,17 +124,18 @@ class Audio(PlexPartialObject):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class Artist(Audio):
|
||||
""" Represents a single audio artist.
|
||||
""" Represents a single Artist.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'artist'
|
||||
countries (list): List of :class:`~plexapi.media.Country` objects this artist respresents.
|
||||
genres (list): List of :class:`~plexapi.media.Genre` objects this artist respresents.
|
||||
guid (str): Unknown (unique ID; com.plexapp.agents.plexmusic://gracenote/artist/05517B8701668D28?lang=en)
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
countries (List<:class:`~plexapi.media.Country`>): List country objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
location (str): Filepath this artist is found on disk.
|
||||
similar (list): List of :class:`~plexapi.media.Similar` artists.
|
||||
locations (List<str>): List of folder paths where the artist is found on disk.
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of similar objects.
|
||||
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'artist'
|
||||
|
@ -130,55 +143,70 @@ class Artist(Audio):
|
|||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.moods = self.findItems(data, media.Mood)
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
self.styles = self.findItems(data, media.Style)
|
||||
|
||||
def __iter__(self):
|
||||
for album in self.albums():
|
||||
yield album
|
||||
|
||||
def hubs(self):
|
||||
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
|
||||
data = self._server.query(self._details_key)
|
||||
directory = data.find('Directory')
|
||||
if directory:
|
||||
related = directory.find('Related')
|
||||
if related:
|
||||
return self.findItems(related, library.Hub)
|
||||
|
||||
def album(self, title):
|
||||
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the album to return.
|
||||
"""
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItem(key, Album, title__iexact=title)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by this artist. """
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItems(key, **kwargs)
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, Album, **kwargs)
|
||||
|
||||
def track(self, title):
|
||||
def track(self, title=None, album=None, track=None):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
album (str): Album name (default: None; required if title not specified).
|
||||
track (int): Track number (default: None; required if title not specified).
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing.
|
||||
"""
|
||||
key = '%s/allLeaves' % self.key
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
if title is not None:
|
||||
return self.fetchItem(key, Track, title__iexact=title)
|
||||
elif album is not None and track is not None:
|
||||
return self.fetchItem(key, Track, parentTitle__iexact=album, index=track)
|
||||
raise BadRequest('Missing argument: title or album and track are required')
|
||||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects by this artist. """
|
||||
key = '%s/allLeaves' % self.key
|
||||
return self.fetchItems(key, **kwargs)
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return self.fetchItems(key, Track, **kwargs)
|
||||
|
||||
def get(self, title):
|
||||
def get(self, title=None, album=None, track=None):
|
||||
""" Alias of :func:`~plexapi.audio.Artist.track`. """
|
||||
return self.track(title)
|
||||
return self.track(title, album, track)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Downloads all tracks for this artist to the specified location.
|
||||
""" Downloads all tracks for the artist to the specified location.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Title of the track to return.
|
||||
|
@ -199,76 +227,89 @@ class Artist(Audio):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class Album(Audio):
|
||||
""" Represents a single audio album.
|
||||
""" Represents a single Album.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'album'
|
||||
genres (list): List of :class:`~plexapi.media.Genre` objects this album respresents.
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
originallyAvailableAt (datetime): Datetime this album was released.
|
||||
parentKey (str): API URL of this artist.
|
||||
parentRatingKey (int): Unique key identifying artist.
|
||||
parentThumb (str): URL to artist thumbnail image.
|
||||
parentTitle (str): Name of the artist for this album.
|
||||
studio (str): Studio that released this album.
|
||||
year (int): Year this album was released.
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
leafCount (int): Number of items in the album view.
|
||||
loudnessAnalysisVersion (int): The Plex loudness analysis version level.
|
||||
originallyAvailableAt (datetime): Datetime the album was released.
|
||||
parentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
|
||||
parentKey (str): API URL of the album artist (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the album artist.
|
||||
parentThumb (str): URL to album artist thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the album artist.
|
||||
rating (float): Album rating (7.9; 9.8; 8.1).
|
||||
studio (str): Studio that released the album.
|
||||
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||
viewedLeafCount (int): Number of items marked as played in the album view.
|
||||
year (int): Year the album was released.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'album'
|
||||
|
||||
def __iter__(self):
|
||||
for track in self.tracks:
|
||||
yield track
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = data.attrib.get('parentRatingKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.styles = self.findItems(data, media.Style)
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.moods = self.findItems(data, media.Mood)
|
||||
self.styles = self.findItems(data, media.Style)
|
||||
|
||||
def track(self, title):
|
||||
def __iter__(self):
|
||||
for track in self.tracks():
|
||||
yield track
|
||||
|
||||
def track(self, title=None, track=None):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
track (int): Track number (default: None; required if title not specified).
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing.
|
||||
"""
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
if title is not None:
|
||||
return self.fetchItem(key, Track, title__iexact=title)
|
||||
elif track is not None:
|
||||
return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=track)
|
||||
raise BadRequest('Missing argument: title or track is required')
|
||||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects in this album. """
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItems(key, **kwargs)
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, Track, **kwargs)
|
||||
|
||||
def get(self, title):
|
||||
def get(self, title=None, track=None):
|
||||
""" Alias of :func:`~plexapi.audio.Album.track`. """
|
||||
return self.track(title)
|
||||
return self.track(title, track)
|
||||
|
||||
def artist(self):
|
||||
""" Return :func:`~plexapi.audio.Artist` of this album. """
|
||||
""" Return the album's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Downloads all tracks for this artist to the specified location.
|
||||
""" Downloads all tracks for the artist to the specified location.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Title of the track to return.
|
||||
|
@ -292,37 +333,32 @@ class Album(Audio):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class Track(Audio, Playable):
|
||||
""" Represents a single audio track.
|
||||
""" Represents a single Track.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'track'
|
||||
chapterSource (TYPE): Unknown
|
||||
duration (int): Length of this album in seconds.
|
||||
grandparentArt (str): Album artist artwork.
|
||||
grandparentKey (str): Album artist API URL.
|
||||
grandparentRatingKey (str): Unique key identifying album artist.
|
||||
grandparentThumb (str): URL to album artist thumbnail image.
|
||||
grandparentTitle (str): Name of the album artist for this track.
|
||||
guid (str): Unknown (unique ID).
|
||||
media (list): List of :class:`~plexapi.media.Media` objects for this track.
|
||||
moods (list): List of :class:`~plexapi.media.Mood` objects for this track.
|
||||
originalTitle (str): Track artist.
|
||||
chapterSource (str): Unknown
|
||||
duration (int): Length of the track in milliseconds.
|
||||
grandparentArt (str): URL to album artist artwork (/library/metadata/<grandparentRatingKey>/art/<artid>).
|
||||
grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
|
||||
grandparentKey (str): API URL of the album artist (/library/metadata/<grandparentRatingKey>).
|
||||
grandparentRatingKey (int): Unique key identifying the album artist.
|
||||
grandparentThumb (str): URL to album artist thumbnail image
|
||||
(/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
|
||||
grandparentTitle (str): Name of the album artist for the track.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
originalTitle (str): The original title of the track (eg. a different language).
|
||||
parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9).
|
||||
parentIndex (int): Album index.
|
||||
parentKey (str): Album API URL.
|
||||
parentRatingKey (int): Unique key identifying album.
|
||||
parentThumb (str): URL to album thumbnail image.
|
||||
parentTitle (str): Name of the album for this track.
|
||||
primaryExtraKey (str): Unknown
|
||||
ratingCount (int): Unknown
|
||||
userRating (float): Rating of this track (0.0 - 10.0) equaling (0 stars - 5 stars)
|
||||
viewOffset (int): Unknown
|
||||
year (int): Year this track was released.
|
||||
sessionKey (int): Session Key (active sessions only).
|
||||
usernames (str): Username of person playing this track (active sessions only).
|
||||
player (str): :class:`~plexapi.client.PlexClient` for playing track (active sessions only).
|
||||
transcodeSessions (None): :class:`~plexapi.media.TranscodeSession` for playing
|
||||
track (active sessions only).
|
||||
parentKey (str): API URL of the album (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the album.
|
||||
parentThumb (str): URL to album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the album for the track.
|
||||
primaryExtraKey (str) API URL for the primary extra for the track.
|
||||
ratingCount (int): Number of ratings contributing to the rating score.
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year the track was released.
|
||||
"""
|
||||
TAG = 'Track'
|
||||
TYPE = 'track'
|
||||
|
@ -336,42 +372,41 @@ class Track(Audio, Playable):
|
|||
self.grandparentArt = data.attrib.get('grandparentArt')
|
||||
self.grandparentGuid = data.attrib.get('grandparentGuid')
|
||||
self.grandparentKey = data.attrib.get('grandparentKey')
|
||||
self.grandparentRatingKey = data.attrib.get('grandparentRatingKey')
|
||||
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
|
||||
self.grandparentThumb = data.attrib.get('grandparentThumb')
|
||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.originalTitle = data.attrib.get('originalTitle')
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentIndex = data.attrib.get('parentIndex')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = data.attrib.get('parentRatingKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating', 0))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.moods = self.findItems(data, media.Mood)
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
|
||||
|
||||
def album(self):
|
||||
""" Return this track's :class:`~plexapi.audio.Album`. """
|
||||
""" Return the track's :class:`~plexapi.audio.Album`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def artist(self):
|
||||
""" Return this track's :class:`~plexapi.audio.Artist`. """
|
||||
""" Return the track's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.grandparentKey)
|
||||
|
||||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Track
|
||||
interface to get the locations of the track.
|
||||
|
||||
Retruns:
|
||||
List<str> of file paths where the track is found on disk.
|
||||
"""
|
||||
return [part.file for part in self.iterParts() if part]
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import weakref
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
|
||||
from plexapi import log, utils
|
||||
from plexapi.compat import quote_plus, urlencode
|
||||
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
|
||||
from plexapi.utils import tag_helper
|
||||
|
||||
|
@ -35,15 +36,17 @@ class PlexObject(object):
|
|||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
parent (:class:`~plexapi.base.PlexObject`): The parent object that this object is built from (optional).
|
||||
"""
|
||||
TAG = None # xml element tag
|
||||
TYPE = None # xml element type
|
||||
key = None # plex relative url
|
||||
|
||||
def __init__(self, server, data, initpath=None):
|
||||
def __init__(self, server, data, initpath=None, parent=None):
|
||||
self._server = server
|
||||
self._data = data
|
||||
self._initpath = initpath or self.key
|
||||
self._parent = weakref.ref(parent) if parent else None
|
||||
if data is not None:
|
||||
self._loadData(data)
|
||||
self._details_key = self._buildDetailsKey()
|
||||
|
@ -54,8 +57,8 @@ class PlexObject(object):
|
|||
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p])
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
# dont overwrite an attr with None unless its a private variable
|
||||
if value is not None or attr.startswith('_') or attr not in self.__dict__:
|
||||
# Don't overwrite an attr with None or [] unless it's a private variable
|
||||
if value not in [None, []] or attr.startswith('_') or attr not in self.__dict__:
|
||||
self.__dict__[attr] = value
|
||||
|
||||
def _clean(self, value):
|
||||
|
@ -63,6 +66,8 @@ class PlexObject(object):
|
|||
if value:
|
||||
value = str(value).replace('/library/metadata/', '')
|
||||
value = value.replace('/children', '')
|
||||
value = value.replace('/accounts/', '')
|
||||
value = value.replace('/devices/', '')
|
||||
return value.replace(' ', '-')[:20]
|
||||
|
||||
def _buildItem(self, elem, cls=None, initpath=None):
|
||||
|
@ -70,9 +75,9 @@ class PlexObject(object):
|
|||
# cls is specified, build the object and return
|
||||
initpath = initpath or self._initpath
|
||||
if cls is not None:
|
||||
return cls(self._server, elem, initpath)
|
||||
return cls(self._server, elem, initpath, parent=self)
|
||||
# cls is not specified, try looking it up in PLEXOBJECTS
|
||||
etype = elem.attrib.get('type', elem.attrib.get('streamType'))
|
||||
etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type')))
|
||||
ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag
|
||||
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
|
||||
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
|
||||
|
@ -95,7 +100,7 @@ class PlexObject(object):
|
|||
or disable each parameter individually by setting it to False or 0.
|
||||
"""
|
||||
details_key = self.key
|
||||
if hasattr(self, '_INCLUDES'):
|
||||
if details_key and hasattr(self, '_INCLUDES'):
|
||||
includes = {}
|
||||
for k, v in self._INCLUDES.items():
|
||||
value = kwargs.get(k, v)
|
||||
|
@ -105,6 +110,21 @@ class PlexObject(object):
|
|||
details_key += '?' + urlencode(sorted(includes.items()))
|
||||
return details_key
|
||||
|
||||
def _isChildOf(self, **kwargs):
|
||||
""" Returns True if this object is a child of the given attributes.
|
||||
This will search the parent objects all the way to the top.
|
||||
|
||||
Parameters:
|
||||
**kwargs (dict): The attributes and values to search for in the parent objects.
|
||||
See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`.
|
||||
"""
|
||||
obj = self
|
||||
while obj._parent is not None:
|
||||
obj = obj._parent()
|
||||
if obj._checkAttrs(obj._data, **kwargs):
|
||||
return True
|
||||
return False
|
||||
|
||||
def fetchItem(self, ekey, cls=None, **kwargs):
|
||||
""" Load the specified key to find and build the first item with the
|
||||
specified tag and attrs. If no tag or attrs are specified then
|
||||
|
@ -212,6 +232,7 @@ class PlexObject(object):
|
|||
return value
|
||||
|
||||
def listAttrs(self, data, attr, **kwargs):
|
||||
""" Return a list of values from matching attribute. """
|
||||
results = []
|
||||
for elem in data:
|
||||
kwargs['%s__exists' % attr] = True
|
||||
|
@ -350,7 +371,7 @@ class PlexPartialObject(PlexObject):
|
|||
}
|
||||
|
||||
def __eq__(self, other):
|
||||
return other is not None and self.key == other.key
|
||||
return other not in [None, []] and self.key == other.key
|
||||
|
||||
def __hash__(self):
|
||||
return hash(repr(self))
|
||||
|
@ -391,6 +412,8 @@ class PlexPartialObject(PlexObject):
|
|||
Playing screen to show a graphical representation of where playback
|
||||
is. Video preview thumbnails creation is a CPU-intensive process akin
|
||||
to transcoding the file.
|
||||
* Generate intro video markers: Detects show intros, exposing the
|
||||
'Skip Intro' button in clients.
|
||||
"""
|
||||
key = '/%s/analyze' % self.key.lstrip('/')
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
@ -663,6 +686,7 @@ class Playable(object):
|
|||
if item is being transcoded (None otherwise).
|
||||
viewedAt (datetime): Datetime item was last viewed (history).
|
||||
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
|
||||
playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items).
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
|
@ -674,6 +698,7 @@ class Playable(object):
|
|||
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
|
||||
self.accountID = utils.cast(int, data.attrib.get('accountID')) # history
|
||||
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
|
||||
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue
|
||||
|
||||
def getStreamURL(self, **params):
|
||||
""" Returns a stream url that may be used by external applications such as VLC.
|
||||
|
@ -684,7 +709,7 @@ class Playable(object):
|
|||
offset, copyts, protocol, mediaIndex, platform.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
|
||||
:exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
|
||||
"""
|
||||
if self.TYPE not in ('movie', 'episode', 'track'):
|
||||
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
|
||||
|
@ -698,7 +723,7 @@ class Playable(object):
|
|||
'mediaIndex': params.get('mediaIndex', 0),
|
||||
'X-Plex-Platform': params.get('platform', 'Chrome'),
|
||||
'maxVideoBitrate': max(mvb, 64) if mvb else None,
|
||||
'videoResolution': vr if re.match('^\d+x\d+$', vr) else None
|
||||
'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None
|
||||
}
|
||||
# remove None values
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import time
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.compat import ElementTree
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from requests.status_codes import _codes as codes
|
||||
|
@ -69,7 +69,9 @@ class PlexClient(PlexObject):
|
|||
self._proxyThroughServer = False
|
||||
self._commandId = 0
|
||||
self._last_call = 0
|
||||
if not any([data, initpath, baseurl, token]):
|
||||
self._timeline_cache = []
|
||||
self._timeline_cache_timestamp = 0
|
||||
if not any([data is not None, initpath, baseurl, token]):
|
||||
self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433')
|
||||
self._token = logfilter.add_secret(CONFIG.get('auth.client_token'))
|
||||
if connect and self._baseurl:
|
||||
|
@ -138,7 +140,7 @@ class PlexClient(PlexObject):
|
|||
value (bool): Enable or disable proxying (optional, default True).
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
|
||||
:exc:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
|
||||
"""
|
||||
if server:
|
||||
self._server = server
|
||||
|
@ -181,7 +183,7 @@ class PlexClient(PlexObject):
|
|||
**params (dict): Additional GET parameters to include with the command.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability.
|
||||
:exc:`~plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability.
|
||||
"""
|
||||
command = command.strip('/')
|
||||
controller = command.split('/')[0]
|
||||
|
@ -195,10 +197,11 @@ class PlexClient(PlexObject):
|
|||
|
||||
# Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244
|
||||
t = time.time()
|
||||
if t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'):
|
||||
url = '/player/timeline/poll?wait=0&commandID=%s' % self._nextCommandId()
|
||||
query(url, headers=headers)
|
||||
if command == 'timeline/poll':
|
||||
self._last_call = t
|
||||
elif t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'):
|
||||
self._last_call = t
|
||||
self.sendCommand(ClientTimeline.key, wait=0)
|
||||
|
||||
params['commandID'] = self._nextCommandId()
|
||||
key = '/player/%s%s' % (command, utils.joinArgs(params))
|
||||
|
@ -296,7 +299,7 @@ class PlexClient(PlexObject):
|
|||
**params (dict): Additional GET parameters to include with the command.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
||||
:exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
||||
"""
|
||||
if not self._server:
|
||||
raise Unsupported('A server must be specified before using this command.')
|
||||
|
@ -466,7 +469,7 @@ class PlexClient(PlexObject):
|
|||
also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
||||
:exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
||||
"""
|
||||
if not self._server:
|
||||
raise Unsupported('A server must be specified before using this command.')
|
||||
|
@ -485,15 +488,6 @@ class PlexClient(PlexObject):
|
|||
if mediatype == "audio":
|
||||
mediatype = "music"
|
||||
|
||||
if self.product != 'OpenPHT':
|
||||
try:
|
||||
self.sendCommand('timeline/subscribe', port=server_port, protocol='http')
|
||||
except: # noqa: E722
|
||||
# some clients dont need or like this and raises http 400.
|
||||
# We want to include the exception in the log,
|
||||
# but it might still work so we swallow it.
|
||||
log.exception('%s failed to subscribe ' % self.title)
|
||||
|
||||
playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media)
|
||||
self.sendCommand('playback/playMedia', **dict({
|
||||
'machineIdentifier': self._server.machineIdentifier,
|
||||
|
@ -548,20 +542,68 @@ class PlexClient(PlexObject):
|
|||
|
||||
# -------------------
|
||||
# Timeline Commands
|
||||
def timeline(self, wait=1):
|
||||
""" Poll the current timeline and return the XML response. """
|
||||
return self.sendCommand('timeline/poll', wait=wait)
|
||||
def timelines(self, wait=0):
|
||||
"""Poll the client's timelines, create, and return timeline objects.
|
||||
Some clients may not always respond to timeline requests, believe this
|
||||
to be a Plex bug.
|
||||
"""
|
||||
t = time.time()
|
||||
if t - self._timeline_cache_timestamp > 1:
|
||||
self._timeline_cache_timestamp = t
|
||||
timelines = self.sendCommand(ClientTimeline.key, wait=wait) or []
|
||||
self._timeline_cache = [ClientTimeline(self, data) for data in timelines]
|
||||
|
||||
def isPlayingMedia(self, includePaused=False):
|
||||
return self._timeline_cache
|
||||
|
||||
@property
|
||||
def timeline(self):
|
||||
"""Returns the active timeline object."""
|
||||
return next((x for x in self.timelines() if x.state != 'stopped'), None)
|
||||
|
||||
def isPlayingMedia(self, includePaused=True):
|
||||
"""Returns True if any media is currently playing.
|
||||
|
||||
Parameters:
|
||||
includePaused (bool): Set True to treat currently paused items
|
||||
as playing (optional; default True).
|
||||
"""
|
||||
for mediatype in self.timeline(wait=0):
|
||||
if mediatype.get('state') == 'playing':
|
||||
return True
|
||||
if includePaused and mediatype.get('state') == 'paused':
|
||||
return True
|
||||
return False
|
||||
state = getattr(self.timeline, "state", None)
|
||||
return bool(state == 'playing' or (includePaused and state == 'paused'))
|
||||
|
||||
|
||||
class ClientTimeline(PlexObject):
|
||||
"""Get the timeline's attributes."""
|
||||
|
||||
key = 'timeline/poll'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.address = data.attrib.get('address')
|
||||
self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId'))
|
||||
self.autoPlay = utils.cast(bool, data.attrib.get('autoPlay'))
|
||||
self.containerKey = data.attrib.get('containerKey')
|
||||
self.controllable = data.attrib.get('controllable')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.itemType = data.attrib.get('itemType')
|
||||
self.key = data.attrib.get('key')
|
||||
self.location = data.attrib.get('location')
|
||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||
self.partCount = utils.cast(int, data.attrib.get('partCount'))
|
||||
self.partIndex = utils.cast(int, data.attrib.get('partIndex'))
|
||||
self.playQueueID = utils.cast(int, data.attrib.get('playQueueID'))
|
||||
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID'))
|
||||
self.playQueueVersion = utils.cast(int, data.attrib.get('playQueueVersion'))
|
||||
self.port = utils.cast(int, data.attrib.get('port'))
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.providerIdentifier = data.attrib.get('providerIdentifier')
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.repeat = utils.cast(bool, data.attrib.get('repeat'))
|
||||
self.seekRange = data.attrib.get('seekRange')
|
||||
self.shuffle = utils.cast(bool, data.attrib.get('shuffle'))
|
||||
self.state = data.attrib.get('state')
|
||||
self.subtitleColor = data.attrib.get('subtitleColor')
|
||||
self.subtitlePosition = data.attrib.get('subtitlePosition')
|
||||
self.subtitleSize = utils.cast(int, data.attrib.get('subtitleSize'))
|
||||
self.time = utils.cast(int, data.attrib.get('time'))
|
||||
self.type = data.attrib.get('type')
|
||||
self.volume = utils.cast(int, data.attrib.get('volume'))
|
||||
|
|
|
@ -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 -*-
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from plexapi.compat import ConfigParser
|
||||
from configparser import ConfigParser
|
||||
|
||||
|
||||
class PlexConfig(ConfigParser):
|
||||
|
@ -13,6 +13,7 @@ class PlexConfig(ConfigParser):
|
|||
Parameters:
|
||||
path (str): Path of the configuration file to load.
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
ConfigParser.__init__(self)
|
||||
self.read(path)
|
||||
|
|
|
@ -23,12 +23,12 @@ class GDM:
|
|||
"""Scan the network."""
|
||||
self.update(scan_for_clients)
|
||||
|
||||
def all(self):
|
||||
def all(self, scan_for_clients=False):
|
||||
"""Return all found entries.
|
||||
|
||||
Will scan for entries if not scanned recently.
|
||||
"""
|
||||
self.scan()
|
||||
self.scan(scan_for_clients)
|
||||
return list(self.entries)
|
||||
|
||||
def find_by_content_type(self, value):
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,41 +1,50 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import xml
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import compat, log, settings, utils
|
||||
from plexapi import log, settings, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.utils import cast, SEARCHTYPES
|
||||
from plexapi.utils import cast
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Media(PlexObject):
|
||||
""" Container object for all MediaPart objects. Provides useful data about the
|
||||
video this media belong to such as video framerate, resolution, etc.
|
||||
video or audio this media belong to such as video framerate, resolution, etc.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Media'
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
initpath (str): Relative path requested when retrieving specified data.
|
||||
video (str): Video this media belongs to.
|
||||
aspectRatio (float): Aspect ratio of the video (ex: 2.35).
|
||||
audioChannels (int): Number of audio channels for this video (ex: 6).
|
||||
audioCodec (str): Audio codec used within the video (ex: ac3).
|
||||
bitrate (int): Bitrate of the video (ex: 1624)
|
||||
container (str): Container this video is in (ex: avi).
|
||||
duration (int): Length of the video in milliseconds (ex: 6990483).
|
||||
height (int): Height of the video in pixels (ex: 256).
|
||||
id (int): Plex ID of this media item (ex: 46184).
|
||||
has64bitOffsets (bool): True if video has 64 bit offsets (?).
|
||||
aspectRatio (float): The aspect ratio of the media (ex: 2.35).
|
||||
audioChannels (int): The number of audio channels of the media (ex: 6).
|
||||
audioCodec (str): The audio codec of the media (ex: ac3).
|
||||
audioProfile (str): The audio profile of the media (ex: dts).
|
||||
bitrate (int): The bitrate of the media (ex: 1624).
|
||||
container (str): The container of the media (ex: avi).
|
||||
duration (int): The duration of the media in milliseconds (ex: 6990483).
|
||||
height (int): The height of the media in pixels (ex: 256).
|
||||
id (int): The unique ID for this media on the server.
|
||||
has64bitOffsets (bool): True if video has 64 bit offsets.
|
||||
optimizedForStreaming (bool): True if video is optimized for streaming.
|
||||
target (str): Media version target name.
|
||||
title (str): Media version title.
|
||||
videoCodec (str): Video codec used within the video (ex: ac3).
|
||||
videoFrameRate (str): Video frame rate (ex: 24p).
|
||||
videoResolution (str): Video resolution (ex: sd).
|
||||
videoProfile (str): Video profile (ex: high).
|
||||
width (int): Width of the video in pixels (ex: 608).
|
||||
parts (list<:class:`~plexapi.media.MediaPart`>): List of MediaParts in this video.
|
||||
parts (List<:class:`~plexapi.media.MediaPart`>): List of media part objects.
|
||||
proxyType (int): Equals 42 for optimized versions.
|
||||
target (str): The media version target name.
|
||||
title (str): The title of the media.
|
||||
videoCodec (str): The video codec of the media (ex: ac3).
|
||||
videoFrameRate (str): The video frame rate of the media (ex: 24p).
|
||||
videoProfile (str): The video profile of the media (ex: high).
|
||||
videoResolution (str): The video resolution of the media (ex: sd).
|
||||
width (int): The width of the video in pixels (ex: 608).
|
||||
|
||||
<Photo_only_attributes>: The following attributes are only available for photos.
|
||||
|
||||
* aperture (str): The apeture used to take the photo.
|
||||
* exposure (str): The exposure used to take the photo.
|
||||
* iso (int): The iso used to take the photo.
|
||||
* lens (str): The lens used to take the photo.
|
||||
* make (str): The make of the camera used to take the photo.
|
||||
* model (str): The model of the camera used to take the photo.
|
||||
"""
|
||||
TAG = 'Media'
|
||||
|
||||
|
@ -53,6 +62,8 @@ class Media(PlexObject):
|
|||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
|
||||
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
|
||||
self.parts = self.findItems(data, MediaPart)
|
||||
self.proxyType = cast(int, data.attrib.get('proxyType'))
|
||||
self.target = data.attrib.get('target')
|
||||
self.title = data.attrib.get('title')
|
||||
self.videoCodec = data.attrib.get('videoCodec')
|
||||
|
@ -60,11 +71,8 @@ class Media(PlexObject):
|
|||
self.videoProfile = data.attrib.get('videoProfile')
|
||||
self.videoResolution = data.attrib.get('videoResolution')
|
||||
self.width = cast(int, data.attrib.get('width'))
|
||||
self.parts = self.findItems(data, MediaPart)
|
||||
self.proxyType = cast(int, data.attrib.get('proxyType'))
|
||||
self.optimizedVersion = self.proxyType == SEARCHTYPES['optimizedVersion']
|
||||
|
||||
# For Photo only
|
||||
if self._isChildOf(etag='Photo'):
|
||||
self.aperture = data.attrib.get('aperture')
|
||||
self.exposure = data.attrib.get('exposure')
|
||||
self.iso = cast(int, data.attrib.get('iso'))
|
||||
|
@ -72,6 +80,11 @@ class Media(PlexObject):
|
|||
self.make = data.attrib.get('make')
|
||||
self.model = data.attrib.get('model')
|
||||
|
||||
@property
|
||||
def isOptimizedVersion(self):
|
||||
""" Returns True if the media is a Plex optimized version. """
|
||||
return self.proxyType == utils.SEARCHTYPES['optimizedVersion']
|
||||
|
||||
def delete(self):
|
||||
part = self._initpath + '/media/%s' % self.id
|
||||
try:
|
||||
|
@ -88,73 +101,77 @@ class MediaPart(PlexObject):
|
|||
|
||||
Attributes:
|
||||
TAG (str): 'Part'
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
initpath (str): Relative path requested when retrieving specified data.
|
||||
media (:class:`~plexapi.media.Media`): Media object this part belongs to.
|
||||
container (str): Container type of this media part (ex: avi).
|
||||
duration (int): Length of this media part in milliseconds.
|
||||
file (str): Path to this file on disk (ex: /media/Movies/Cars.(2006)/Cars.cd2.avi)
|
||||
id (int): Unique ID of this media part.
|
||||
indexes (str, None): None or SD.
|
||||
key (str): Key used to access this media part (ex: /library/parts/46618/1389985872/file.avi).
|
||||
size (int): Size of this file in bytes (ex: 733884416).
|
||||
streams (list<:class:`~plexapi.media.MediaPartStream`>): List of streams in this media part.
|
||||
exists (bool): Determine if file exists
|
||||
accessible (bool): Determine if file is accessible
|
||||
accessible (bool): True if the file is accessible.
|
||||
audioProfile (str): The audio profile of the file.
|
||||
container (str): The container type of the file (ex: avi).
|
||||
decision (str): Unknown.
|
||||
deepAnalysisVersion (int): The Plex deep analysis version for the file.
|
||||
duration (int): The duration of the file in milliseconds.
|
||||
exists (bool): True if the file exists.
|
||||
file (str): The path to this file on disk (ex: /media/Movies/Cars (2006)/Cars (2006).mkv)
|
||||
has64bitOffsets (bool): True if the file has 64 bit offsets.
|
||||
hasThumbnail (bool): True if the file (track) has an embedded thumbnail.
|
||||
id (int): The unique ID for this media part on the server.
|
||||
indexes (str, None): sd if the file has generated BIF thumbnails.
|
||||
key (str): API URL (ex: /library/parts/46618/1389985872/file.mkv).
|
||||
optimizedForStreaming (bool): True if the file is optimized for streaming.
|
||||
packetLength (int): The packet length of the file.
|
||||
requiredBandwidths (str): The required bandwidths to stream the file.
|
||||
size (int): The size of the file in bytes (ex: 733884416).
|
||||
streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects.
|
||||
syncItemId (int): The unique ID for this media part if it is synced.
|
||||
syncState (str): The sync state for this media part.
|
||||
videoProfile (str): The video profile of the file.
|
||||
"""
|
||||
TAG = 'Part'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.accessible = cast(bool, data.attrib.get('accessible'))
|
||||
self.audioProfile = data.attrib.get('audioProfile')
|
||||
self.container = data.attrib.get('container')
|
||||
self.decision = data.attrib.get('decision')
|
||||
self.deepAnalysisVersion = cast(int, data.attrib.get('deepAnalysisVersion'))
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.exists = cast(bool, data.attrib.get('exists'))
|
||||
self.file = data.attrib.get('file')
|
||||
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
|
||||
self.hasThumbnail = cast(bool, data.attrib.get('hasThumbnail'))
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.indexes = data.attrib.get('indexes')
|
||||
self.key = data.attrib.get('key')
|
||||
self.size = cast(int, data.attrib.get('size'))
|
||||
self.decision = data.attrib.get('decision')
|
||||
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
|
||||
self.packetLength = cast(int, data.attrib.get('packetLength'))
|
||||
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
||||
self.size = cast(int, data.attrib.get('size'))
|
||||
self.streams = self._buildStreams(data)
|
||||
self.syncItemId = cast(int, data.attrib.get('syncItemId'))
|
||||
self.syncState = data.attrib.get('syncState')
|
||||
self.videoProfile = data.attrib.get('videoProfile')
|
||||
self.streams = self._buildStreams(data)
|
||||
self.exists = cast(bool, data.attrib.get('exists'))
|
||||
self.accessible = cast(bool, data.attrib.get('accessible'))
|
||||
|
||||
# For Photo only
|
||||
self.orientation = cast(int, data.attrib.get('orientation'))
|
||||
|
||||
def _buildStreams(self, data):
|
||||
streams = []
|
||||
for elem in data:
|
||||
for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream):
|
||||
if elem.attrib.get('streamType') == str(cls.STREAMTYPE):
|
||||
streams.append(cls(self._server, elem, self._initpath))
|
||||
items = self.findItems(data, cls, streamType=cls.STREAMTYPE)
|
||||
streams.extend(items)
|
||||
return streams
|
||||
|
||||
def videoStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if stream.streamType == VideoStream.STREAMTYPE]
|
||||
return [stream for stream in self.streams if isinstance(stream, VideoStream)]
|
||||
|
||||
def audioStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.AudioStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if stream.streamType == AudioStream.STREAMTYPE]
|
||||
return [stream for stream in self.streams if isinstance(stream, AudioStream)]
|
||||
|
||||
def subtitleStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE]
|
||||
return [stream for stream in self.streams if isinstance(stream, SubtitleStream)]
|
||||
|
||||
def lyricStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if stream.streamType == LyricStream.STREAMTYPE]
|
||||
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if isinstance(stream, LyricStream)]
|
||||
|
||||
def setDefaultAudioStream(self, stream):
|
||||
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
|
||||
|
@ -187,73 +204,87 @@ class MediaPart(PlexObject):
|
|||
|
||||
|
||||
class MediaPartStream(PlexObject):
|
||||
""" Base class for media streams. These consist of video, audio and subtitles.
|
||||
""" Base class for media streams. These consist of video, audio, subtitles, and lyrics.
|
||||
|
||||
Attributes:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
initpath (str): Relative path requested when retrieving specified data.
|
||||
part (:class:`~plexapi.media.MediaPart`): Media part this stream belongs to.
|
||||
codec (str): Codec of this stream (ex: srt, ac3, mpeg4).
|
||||
codecID (str): Codec ID (ex: XVID).
|
||||
id (int): Unique stream ID on this server.
|
||||
index (int): Unknown
|
||||
language (str): Stream language (ex: English, ไทย).
|
||||
languageCode (str): Ascii code for language (ex: eng, tha).
|
||||
bitrate (int): The bitrate of the stream.
|
||||
codec (str): The codec of the stream (ex: srt, ac3, mpeg4).
|
||||
default (bool): True if this is the default stream.
|
||||
displayTitle (str): The display title of the stream.
|
||||
extendedDisplayTitle (str): The extended display title of the stream.
|
||||
key (str): API URL (/library/streams/<id>)
|
||||
id (int): The unique ID for this stream on the server.
|
||||
index (int): The index of the stream.
|
||||
language (str): The language of the stream (ex: English, ไทย).
|
||||
languageCode (str): The Ascii language code of the stream (ex: eng, tha).
|
||||
requiredBandwidths (str): The required bandwidths to stream the file.
|
||||
selected (bool): True if this stream is selected.
|
||||
streamType (int): Stream type (1=:class:`~plexapi.media.VideoStream`,
|
||||
2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`,
|
||||
4=:class:`~plexapi.media.LyricStream`).
|
||||
streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`,
|
||||
2= :class:`~plexapi.media.AudioStream`, 3= :class:`~plexapi.media.SubtitleStream`).
|
||||
title (str): The title of the stream.
|
||||
type (int): Alias for streamType.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
||||
self.codec = data.attrib.get('codec')
|
||||
self.default = cast(bool, data.attrib.get('selected', '0'))
|
||||
self.default = cast(bool, data.attrib.get('default'))
|
||||
self.displayTitle = data.attrib.get('displayTitle')
|
||||
self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
|
||||
self.key = data.attrib.get('key')
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.index = cast(int, data.attrib.get('index', '-1'))
|
||||
self.language = data.attrib.get('language')
|
||||
self.languageCode = data.attrib.get('languageCode')
|
||||
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
||||
self.selected = cast(bool, data.attrib.get('selected', '0'))
|
||||
self.streamType = cast(int, data.attrib.get('streamType'))
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = cast(int, data.attrib.get('streamType'))
|
||||
|
||||
@staticmethod
|
||||
def parse(server, data, initpath): # pragma: no cover seems to be dead code.
|
||||
""" Factory method returns a new MediaPartStream from xml data. """
|
||||
STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream, 4: LyricStream}
|
||||
stype = cast(int, data.attrib.get('streamType'))
|
||||
cls = STREAMCLS.get(stype, MediaPartStream)
|
||||
return cls(server, data, initpath)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class VideoStream(MediaPartStream):
|
||||
""" Respresents a video stream within a :class:`~plexapi.media.MediaPart`.
|
||||
""" Represents a video stream within a :class:`~plexapi.media.MediaPart`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Stream'
|
||||
STREAMTYPE (int): 1
|
||||
bitDepth (int): Bit depth (ex: 8).
|
||||
bitrate (int): Bitrate (ex: 1169)
|
||||
cabac (int): Unknown
|
||||
chromaSubsampling (str): Chroma Subsampling (ex: 4:2:0).
|
||||
colorSpace (str): Unknown
|
||||
duration (int): Duration of video stream in milliseconds.
|
||||
frameRate (float): Frame rate (ex: 23.976)
|
||||
frameRateMode (str): Unknown
|
||||
anamorphic (str): If the video is anamorphic.
|
||||
bitDepth (int): The bit depth of the video stream (ex: 8).
|
||||
cabac (int): The context-adaptive binary arithmetic coding.
|
||||
chromaLocation (str): The chroma location of the video stream.
|
||||
chromaSubsampling (str): The chroma subsampling of the video stream (ex: 4:2:0).
|
||||
codecID (str): The codec ID (ex: XVID).
|
||||
codedHeight (int): The coded height of the video stream in pixels.
|
||||
codedWidth (int): The coded width of the video stream in pixels.
|
||||
colorPrimaries (str): The color primaries of the video stream.
|
||||
colorRange (str): The color range of the video stream.
|
||||
colorSpace (str): The color space of the video stream (ex: bt2020).
|
||||
colorTrc (str): The color trc of the video stream.
|
||||
DOVIBLCompatID (int): Dolby Vision base layer compatibility ID.
|
||||
DOVIBLPresent (bool): True if Dolby Vision base layer is present.
|
||||
DOVIELPresent (bool): True if Dolby Vision enhancement layer is present.
|
||||
DOVILevel (int): Dolby Vision level.
|
||||
DOVIPresent (bool): True if Dolby Vision is present.
|
||||
DOVIProfile (int): Dolby Vision profile.
|
||||
DOVIRPUPresent (bool): True if Dolby Vision reference processing unit is present.
|
||||
DOVIVersion (float): The Dolby Vision version.
|
||||
duration (int): The duration of video stream in milliseconds.
|
||||
frameRate (float): The frame rate of the video stream (ex: 23.976).
|
||||
frameRateMode (str): The frame rate mode of the video stream.
|
||||
hasScallingMatrix (bool): True if video stream has a scaling matrix.
|
||||
height (int): Height of video stream.
|
||||
level (int): Videl stream level (?).
|
||||
profile (str): Video stream profile (ex: asp).
|
||||
refFrames (int): Unknown
|
||||
scanType (str): Video stream scan type (ex: progressive).
|
||||
title (str): Title of this video stream.
|
||||
width (int): Width of video stream.
|
||||
height (int): The hight of the video stream in pixels (ex: 1080).
|
||||
level (int): The codec encoding level of the video stream (ex: 41).
|
||||
profile (str): The profile of the video stream (ex: asp).
|
||||
pixelAspectRatio (str): The pixel aspect ratio of the video stream.
|
||||
pixelFormat (str): The pixel format of the video stream.
|
||||
refFrames (int): The number of reference frames of the video stream.
|
||||
scanType (str): The scan type of the video stream (ex: progressive).
|
||||
streamIdentifier(int): The stream identifier of the video stream.
|
||||
width (int): The width of the video stream in pixels (ex: 1920).
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 1
|
||||
|
@ -263,13 +294,12 @@ class VideoStream(MediaPartStream):
|
|||
super(VideoStream, self)._loadData(data)
|
||||
self.anamorphic = data.attrib.get('anamorphic')
|
||||
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
|
||||
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
||||
self.cabac = cast(int, data.attrib.get('cabac'))
|
||||
self.chromaLocation = data.attrib.get('chromaLocation')
|
||||
self.chromaSubsampling = data.attrib.get('chromaSubsampling')
|
||||
self.codecID = data.attrib.get('codecID')
|
||||
self.codedHeight = data.attrib.get('codedHeight')
|
||||
self.codedWidth = data.attrib.get('codedWidth')
|
||||
self.codedHeight = cast(int, data.attrib.get('codedHeight'))
|
||||
self.codedWidth = cast(int, data.attrib.get('codedWidth'))
|
||||
self.colorPrimaries = data.attrib.get('colorPrimaries')
|
||||
self.colorRange = data.attrib.get('colorRange')
|
||||
self.colorSpace = data.attrib.get('colorSpace')
|
||||
|
@ -285,14 +315,13 @@ class VideoStream(MediaPartStream):
|
|||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.frameRate = cast(float, data.attrib.get('frameRate'))
|
||||
self.frameRateMode = data.attrib.get('frameRateMode')
|
||||
self.hasScalingMatrix = cast(bool, data.attrib.get('hasScalingMatrix'))
|
||||
self.hasScallingMatrix = cast(bool, data.attrib.get('hasScallingMatrix'))
|
||||
self.height = cast(int, data.attrib.get('height'))
|
||||
self.level = cast(int, data.attrib.get('level'))
|
||||
self.profile = data.attrib.get('profile')
|
||||
self.refFrames = cast(int, data.attrib.get('refFrames'))
|
||||
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
||||
self.pixelAspectRatio = data.attrib.get('pixelAspectRatio')
|
||||
self.pixelFormat = data.attrib.get('pixelFormat')
|
||||
self.refFrames = cast(int, data.attrib.get('refFrames'))
|
||||
self.scanType = data.attrib.get('scanType')
|
||||
self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier'))
|
||||
self.width = cast(int, data.attrib.get('width'))
|
||||
|
@ -300,20 +329,31 @@ class VideoStream(MediaPartStream):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class AudioStream(MediaPartStream):
|
||||
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
||||
""" Represents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Stream'
|
||||
STREAMTYPE (int): 2
|
||||
audioChannelLayout (str): Audio channel layout (ex: 5.1(side)).
|
||||
bitDepth (int): Bit depth (ex: 16).
|
||||
bitrate (int): Audio bitrate (ex: 448).
|
||||
bitrateMode (str): Bitrate mode (ex: cbr).
|
||||
channels (int): number of channels in this stream (ex: 6).
|
||||
dialogNorm (int): Unknown (ex: -27).
|
||||
duration (int): Duration of audio stream in milliseconds.
|
||||
samplingRate (int): Sampling rate (ex: xxx)
|
||||
title (str): Title of this audio stream.
|
||||
audioChannelLayout (str): The audio channel layout of the audio stream (ex: 5.1(side)).
|
||||
bitDepth (int): The bit depth of the audio stream (ex: 16).
|
||||
bitrateMode (str): The bitrate mode of the audio stream (ex: cbr).
|
||||
channels (int): The number of audio channels of the audio stream (ex: 6).
|
||||
duration (int): The duration of audio stream in milliseconds.
|
||||
profile (str): The profile of the audio stream.
|
||||
samplingRate (int): The sampling rate of the audio stream (ex: xxx)
|
||||
streamIdentifier (int): The stream identifier of the audio stream.
|
||||
|
||||
<Track_only_attributes>: The following attributes are only available for tracks.
|
||||
|
||||
* albumGain (float): The gain for the album.
|
||||
* albumPeak (float): The peak for the album.
|
||||
* albumRange (float): The range for the album.
|
||||
* endRamp (str): The end ramp for the track.
|
||||
* gain (float): The gain for the track.
|
||||
* loudness (float): The loudness for the track.
|
||||
* lra (float): The lra for the track.
|
||||
* peak (float): The peak for the track.
|
||||
* startRamp (str): The start ramp for the track.
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 2
|
||||
|
@ -323,16 +363,14 @@ class AudioStream(MediaPartStream):
|
|||
super(AudioStream, self)._loadData(data)
|
||||
self.audioChannelLayout = data.attrib.get('audioChannelLayout')
|
||||
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
|
||||
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
||||
self.bitrateMode = data.attrib.get('bitrateMode')
|
||||
self.channels = cast(int, data.attrib.get('channels'))
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.profile = data.attrib.get('profile')
|
||||
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
||||
self.samplingRate = cast(int, data.attrib.get('samplingRate'))
|
||||
self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier'))
|
||||
|
||||
# For Track only
|
||||
if self._isChildOf(etag='Track'):
|
||||
self.albumGain = cast(float, data.attrib.get('albumGain'))
|
||||
self.albumPeak = cast(float, data.attrib.get('albumPeak'))
|
||||
self.albumRange = cast(float, data.attrib.get('albumRange'))
|
||||
|
@ -346,15 +384,16 @@ class AudioStream(MediaPartStream):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class SubtitleStream(MediaPartStream):
|
||||
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
||||
""" Represents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Stream'
|
||||
STREAMTYPE (int): 3
|
||||
forced (bool): True if this is a forced subtitle
|
||||
format (str): Subtitle format (ex: srt).
|
||||
key (str): Key of this subtitle stream (ex: /library/streams/212284).
|
||||
title (str): Title of this subtitle stream.
|
||||
container (str): The container of the subtitle stream.
|
||||
forced (bool): True if this is a forced subtitle.
|
||||
format (str): The format of the subtitle stream (ex: srt).
|
||||
headerCommpression (str): The header compression of the subtitle stream.
|
||||
transient (str): Unknown.
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 3
|
||||
|
@ -366,21 +405,19 @@ class SubtitleStream(MediaPartStream):
|
|||
self.forced = cast(bool, data.attrib.get('forced', '0'))
|
||||
self.format = data.attrib.get('format')
|
||||
self.headerCompression = data.attrib.get('headerCompression')
|
||||
self.key = data.attrib.get('key')
|
||||
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
|
||||
self.transient = data.attrib.get('transient')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class LyricStream(MediaPartStream):
|
||||
""" Respresents a lyric stream within a :class:`~plexapi.media.MediaPart`.
|
||||
""" Represents a lyric stream within a :class:`~plexapi.media.MediaPart`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Stream'
|
||||
STREAMTYPE (int): 4
|
||||
format (str): Lyric format (ex: lrc).
|
||||
key (str): Key of this subtitle stream (ex: /library/streams/212284).
|
||||
title (str): Title of this lyric stream.
|
||||
format (str): The format of the lyric stream (ex: lrc).
|
||||
minLines (int): The minimum number of lines in the (timed) lyric stream.
|
||||
provider (str): The provider of the lyric stream (ex: com.plexapp.agents.lyricfind).
|
||||
timed (bool): True if the lyrics are timed to the track.
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 4
|
||||
|
@ -389,7 +426,6 @@ class LyricStream(MediaPartStream):
|
|||
""" Load attribute values from Plex XML response. """
|
||||
super(LyricStream, self)._loadData(data)
|
||||
self.format = data.attrib.get('format')
|
||||
self.key = data.attrib.get('key')
|
||||
self.minLines = cast(int, data.attrib.get('minLines'))
|
||||
self.provider = data.attrib.get('provider')
|
||||
self.timed = cast(bool, data.attrib.get('timed', '0'))
|
||||
|
@ -397,7 +433,14 @@ class LyricStream(MediaPartStream):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class Session(PlexObject):
|
||||
""" Represents a current session. """
|
||||
""" Represents a current session.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Session'
|
||||
id (str): The unique identifier for the session.
|
||||
bandwidth (int): The Plex streaming brain reserved bandwidth for the session.
|
||||
location (str): The location of the session (lan, wan, or cellular)
|
||||
"""
|
||||
TAG = 'Session'
|
||||
|
||||
def _loadData(self, data):
|
||||
|
@ -412,7 +455,36 @@ class TranscodeSession(PlexObject):
|
|||
|
||||
Attributes:
|
||||
TAG (str): 'TranscodeSession'
|
||||
TODO: Document this.
|
||||
audioChannels (int): The number of audio channels of the transcoded media.
|
||||
audioCodec (str): The audio codec of the transcoded media.
|
||||
audioDecision (str): The transcode decision for the audio stream.
|
||||
complete (bool): True if the transcode is complete.
|
||||
container (str): The container of the transcoded media.
|
||||
context (str): The context for the transcode sesson.
|
||||
duration (int): The duration of the transcoded media in milliseconds.
|
||||
height (int): The height of the transcoded media in pixels.
|
||||
key (str): API URL (ex: /transcode/sessions/<id>).
|
||||
maxOffsetAvailable (float): Unknown.
|
||||
minOffsetAvailable (float): Unknown.
|
||||
progress (float): The progress percentage of the transcode.
|
||||
protocol (str): The protocol of the transcode.
|
||||
remaining (int): Unknown.
|
||||
size (int): The size of the transcoded media in bytes.
|
||||
sourceAudioCodec (str): The audio codec of the source media.
|
||||
sourceVideoCodec (str): The video codec of the source media.
|
||||
speed (float): The speed of the transcode.
|
||||
subtitleDecision (str): The transcode decision for the subtitle stream
|
||||
throttled (bool): True if the transcode is throttled.
|
||||
timestamp (int): The epoch timestamp when the transcode started.
|
||||
transcodeHwDecoding (str): The hardware transcoding decoder engine.
|
||||
transcodeHwDecodingTitle (str): The title of the hardware transcoding decoder engine.
|
||||
transcodeHwEncoding (str): The hardware transcoding encoder engine.
|
||||
transcodeHwEncodingTitle (str): The title of the hardware transcoding encoder engine.
|
||||
transcodeHwFullPipeline (str): True if hardware decoding and encoding is being used for the transcode.
|
||||
transcodeHwRequested (str): True if hardware transcoding was requested for the transcode.
|
||||
videoCodec (str): The video codec of the transcoded media.
|
||||
videoDecision (str): The transcode decision for the video stream.
|
||||
width (str): The width of the transcoded media in pixels.
|
||||
"""
|
||||
TAG = 'TranscodeSession'
|
||||
|
||||
|
@ -422,17 +494,30 @@ class TranscodeSession(PlexObject):
|
|||
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
|
||||
self.audioCodec = data.attrib.get('audioCodec')
|
||||
self.audioDecision = data.attrib.get('audioDecision')
|
||||
self.complete = cast(bool, data.attrib.get('complete', '0'))
|
||||
self.container = data.attrib.get('container')
|
||||
self.context = data.attrib.get('context')
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.height = cast(int, data.attrib.get('height'))
|
||||
self.key = data.attrib.get('key')
|
||||
self.maxOffsetAvailable = cast(float, data.attrib.get('maxOffsetAvailable'))
|
||||
self.minOffsetAvailable = cast(float, data.attrib.get('minOffsetAvailable'))
|
||||
self.progress = cast(float, data.attrib.get('progress'))
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.remaining = cast(int, data.attrib.get('remaining'))
|
||||
self.speed = cast(int, data.attrib.get('speed'))
|
||||
self.throttled = cast(int, data.attrib.get('throttled'))
|
||||
self.size = cast(int, data.attrib.get('size'))
|
||||
self.sourceAudioCodec = data.attrib.get('sourceAudioCodec')
|
||||
self.sourceVideoCodec = data.attrib.get('sourceVideoCodec')
|
||||
self.speed = cast(float, data.attrib.get('speed'))
|
||||
self.subtitleDecision = data.attrib.get('subtitleDecision')
|
||||
self.throttled = cast(bool, data.attrib.get('throttled', '0'))
|
||||
self.timestamp = cast(float, data.attrib.get('timeStamp'))
|
||||
self.transcodeHwDecoding = data.attrib.get('transcodeHwDecoding')
|
||||
self.transcodeHwDecodingTitle = data.attrib.get('transcodeHwDecodingTitle')
|
||||
self.transcodeHwEncoding = data.attrib.get('transcodeHwEncoding')
|
||||
self.transcodeHwEncodingTitle = data.attrib.get('transcodeHwEncodingTitle')
|
||||
self.transcodeHwFullPipeline = cast(bool, data.attrib.get('transcodeHwFullPipeline', '0'))
|
||||
self.transcodeHwRequested = cast(bool, data.attrib.get('transcodeHwRequested', '0'))
|
||||
self.videoCodec = data.attrib.get('videoCodec')
|
||||
self.videoDecision = data.attrib.get('videoDecision')
|
||||
self.width = cast(int, data.attrib.get('width'))
|
||||
|
@ -442,7 +527,7 @@ class TranscodeSession(PlexObject):
|
|||
class TranscodeJob(PlexObject):
|
||||
""" Represents an Optimizing job.
|
||||
TrancodeJobs are the process for optimizing conversions.
|
||||
Active or paused optimization items. Usually one item as a time"""
|
||||
Active or paused optimization items. Usually one item as a time."""
|
||||
TAG = 'TranscodeJob'
|
||||
|
||||
def _loadData(self, data):
|
||||
|
@ -598,25 +683,15 @@ class MediaTag(PlexObject):
|
|||
|
||||
class GuidTag(PlexObject):
|
||||
""" Base class for guid tags used only for Guids, as they contain only a string identifier
|
||||
|
||||
Attributes:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||
id (id): Tag ID (Used as a unique id, except for Guid's, used for external systems
|
||||
to plex identifiers, like imdb and tmdb).
|
||||
id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB).
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.id = data.attrib.get('id')
|
||||
self.tag = data.attrib.get('tag')
|
||||
|
||||
def items(self, *args, **kwargs):
|
||||
""" Return the list of items within this tag. This function is only applicable
|
||||
in search results from PlexServer :func:`~plexapi.server.PlexServer.search()`.
|
||||
"""
|
||||
if not self.key:
|
||||
raise BadRequest('Key is not defined for this tag: %s' % self.tag)
|
||||
return self.fetchItems(self.key)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
|
@ -700,7 +775,11 @@ class Genre(MediaTag):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class Guid(GuidTag):
|
||||
""" Represents a single Guid media tag. """
|
||||
""" Represents a single Guid media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Guid'
|
||||
"""
|
||||
TAG = "Guid"
|
||||
|
||||
|
||||
|
@ -741,12 +820,12 @@ class Poster(PlexObject):
|
|||
self._data = data
|
||||
self.key = data.attrib.get('key')
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.selected = cast(bool, data.attrib.get('selected'))
|
||||
self.selected = data.attrib.get('selected')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
|
||||
def select(self):
|
||||
key = self._initpath[:-1]
|
||||
data = '%s?url=%s' % (key, compat.quote_plus(self.ratingKey))
|
||||
data = '%s?url=%s' % (key, quote_plus(self.ratingKey))
|
||||
try:
|
||||
self._server.query(data, method=self._server._session.put)
|
||||
except xml.etree.ElementTree.ParseError:
|
||||
|
@ -816,7 +895,6 @@ class Chapter(PlexObject):
|
|||
self.filter = data.attrib.get('filter') # I couldn't filter on it anyways
|
||||
self.tag = data.attrib.get('tag')
|
||||
self.title = self.tag
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.index = cast(int, data.attrib.get('index'))
|
||||
self.start = cast(int, data.attrib.get('startTimeOffset'))
|
||||
self.end = cast(int, data.attrib.get('endTimeOffset'))
|
||||
|
@ -825,6 +903,7 @@ class Chapter(PlexObject):
|
|||
@utils.registerPlexObject
|
||||
class Marker(PlexObject):
|
||||
""" Represents a single Marker media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Marker'
|
||||
"""
|
||||
|
|
|
@ -2,14 +2,14 @@
|
|||
import copy
|
||||
import threading
|
||||
import time
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT,
|
||||
X_PLEX_IDENTIFIER, log, logfilter, utils)
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.compat import ElementTree
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||
from plexapi.library import LibrarySection
|
||||
from plexapi.server import PlexServer
|
||||
from plexapi.sonos import PlexSonosClient
|
||||
|
@ -43,7 +43,7 @@ class MyPlexAccount(PlexObject):
|
|||
guest (bool): Unknown.
|
||||
home (bool): Unknown.
|
||||
homeSize (int): Unknown.
|
||||
id (str): Your Plex account ID.
|
||||
id (int): Your Plex account ID.
|
||||
locale (str): Your Plex locale
|
||||
mailing_list_status (str): Your current mailing list status.
|
||||
maxHomeSize (int): Unknown.
|
||||
|
@ -71,11 +71,12 @@ class MyPlexAccount(PlexObject):
|
|||
PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get
|
||||
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete
|
||||
REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete
|
||||
REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=0&server=1&home=0' # delete
|
||||
REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=1&server=1&home=1' # delete
|
||||
REQUESTED = 'https://plex.tv/api/invites/requested' # get
|
||||
REQUESTS = 'https://plex.tv/api/invites/requests' # get
|
||||
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
|
||||
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
||||
LINK = 'https://plex.tv/api/v2/pins/link' # put
|
||||
# Hub sections
|
||||
VOD = 'https://vod.provider.plex.tv/' # get
|
||||
WEBSHOWS = 'https://webshows.provider.plex.tv/' # get
|
||||
|
@ -87,7 +88,7 @@ class MyPlexAccount(PlexObject):
|
|||
key = 'https://plex.tv/users/account'
|
||||
|
||||
def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
|
||||
self._token = token
|
||||
self._token = token or CONFIG.get('auth.server_token')
|
||||
self._session = session or requests.Session()
|
||||
self._sonos_cache = []
|
||||
self._sonos_cache_timestamp = 0
|
||||
|
@ -114,7 +115,7 @@ class MyPlexAccount(PlexObject):
|
|||
self.guest = utils.cast(bool, data.attrib.get('guest'))
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.homeSize = utils.cast(int, data.attrib.get('homeSize'))
|
||||
self.id = data.attrib.get('id')
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.locale = data.attrib.get('locale')
|
||||
self.mailing_list_status = data.attrib.get('mailing_list_status')
|
||||
self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
|
||||
|
@ -139,7 +140,7 @@ class MyPlexAccount(PlexObject):
|
|||
|
||||
roles = data.find('roles')
|
||||
self.roles = []
|
||||
if roles:
|
||||
if roles is not None:
|
||||
for role in roles.iter('role'):
|
||||
self.roles.append(role.attrib.get('id'))
|
||||
|
||||
|
@ -153,14 +154,15 @@ class MyPlexAccount(PlexObject):
|
|||
self.services = None
|
||||
self.joined_at = None
|
||||
|
||||
def device(self, name):
|
||||
def device(self, name=None, clientId=None):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
|
||||
|
||||
Parameters:
|
||||
name (str): Name to match against.
|
||||
clientId (str): clientIdentifier to match against.
|
||||
"""
|
||||
for device in self.devices():
|
||||
if device.name.lower() == name.lower():
|
||||
if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId):
|
||||
return device
|
||||
raise NotFound('Unable to find device %s' % name)
|
||||
|
||||
|
@ -217,7 +219,7 @@ class MyPlexAccount(PlexObject):
|
|||
return []
|
||||
|
||||
t = time.time()
|
||||
if t - self._sonos_cache_timestamp > 60:
|
||||
if t - self._sonos_cache_timestamp > 5:
|
||||
self._sonos_cache_timestamp = t
|
||||
data = self.query('https://sonos.plex.tv/resources')
|
||||
self._sonos_cache = [PlexSonosClient(self, elem) for elem in data]
|
||||
|
@ -225,10 +227,10 @@ class MyPlexAccount(PlexObject):
|
|||
return self._sonos_cache
|
||||
|
||||
def sonos_speaker(self, name):
|
||||
return [x for x in self.sonos_speakers() if x.title == name][0]
|
||||
return next((x for x in self.sonos_speakers() if x.title.split("+")[0].strip() == name), None)
|
||||
|
||||
def sonos_speaker_by_id(self, identifier):
|
||||
return [x for x in self.sonos_speakers() if x.machineIdentifier == identifier][0]
|
||||
return next((x for x in self.sonos_speakers() if x.machineIdentifier.startswith(identifier)), None)
|
||||
|
||||
def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
|
||||
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
|
||||
|
@ -578,8 +580,8 @@ class MyPlexAccount(PlexObject):
|
|||
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.BadRequest`: when client with provided clientId wasn`t found.
|
||||
:exc:`plexapi.exceptions.BadRequest`: provided client doesn`t provides `sync-target`.
|
||||
:exc:`~plexapi.exceptions.BadRequest`: When client with provided clientId wasn`t found.
|
||||
:exc:`~plexapi.exceptions.BadRequest`: Provided client doesn`t provides `sync-target`.
|
||||
"""
|
||||
if not client and not clientId:
|
||||
clientId = X_PLEX_IDENTIFIER
|
||||
|
@ -683,6 +685,19 @@ class MyPlexAccount(PlexObject):
|
|||
elem = ElementTree.fromstring(req.text)
|
||||
return self.findItems(elem)
|
||||
|
||||
def link(self, pin):
|
||||
""" Link a device to the account using a pin code.
|
||||
|
||||
Parameters:
|
||||
pin (str): The 4 digit link pin code.
|
||||
"""
|
||||
headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Plex-Product': 'Plex SSO'
|
||||
}
|
||||
data = {'code': pin}
|
||||
self.query(self.LINK, self._session.put, headers=headers, data=data)
|
||||
|
||||
|
||||
class MyPlexUser(PlexObject):
|
||||
""" This object represents non-signed in users such as friends and linked
|
||||
|
@ -942,7 +957,7 @@ class MyPlexResource(PlexObject):
|
|||
HTTP or HTTPS connection.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||
"""
|
||||
# Sort connections from (https, local) to (http, remote)
|
||||
# Only check non-local connections unless we own the resource
|
||||
|
@ -958,7 +973,7 @@ class MyPlexResource(PlexObject):
|
|||
# Try connecting to all known resource connections in parellel, but
|
||||
# only return the first server (in order) that provides a response.
|
||||
listargs = [[cls, url, self.accessToken, timeout] for url in connections]
|
||||
log.info('Testing %s resource connections..', len(listargs))
|
||||
log.debug('Testing %s resource connections..', len(listargs))
|
||||
results = utils.threaded(_connect, listargs)
|
||||
return _chooseConnection('Resource', self.name, results)
|
||||
|
||||
|
@ -1049,11 +1064,11 @@ class MyPlexDevice(PlexObject):
|
|||
at least one connection was successful, the PlexClient object is built and returned.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
|
||||
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
|
||||
"""
|
||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||
listargs = [[cls, url, self.token, timeout] for url in self.connections]
|
||||
log.info('Testing %s device connections..', len(listargs))
|
||||
log.debug('Testing %s device connections..', len(listargs))
|
||||
results = utils.threaded(_connect, listargs)
|
||||
return _chooseConnection('Device', self.name, results)
|
||||
|
||||
|
@ -1066,7 +1081,7 @@ class MyPlexDevice(PlexObject):
|
|||
""" Returns an instance of :class:`~plexapi.sync.SyncList` for current device.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`.
|
||||
:exc:`~plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`.
|
||||
"""
|
||||
if 'sync-target' not in self.provides:
|
||||
raise BadRequest('Requested syncList for device which do not provides sync-target')
|
||||
|
@ -1098,33 +1113,40 @@ class MyPlexPinLogin(object):
|
|||
requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT).
|
||||
|
||||
Attributes:
|
||||
PINS (str): 'https://plex.tv/pins.xml'
|
||||
CHECKPINS (str): 'https://plex.tv/pins/{pinid}.xml'
|
||||
PINS (str): 'https://plex.tv/api/v2/pins'
|
||||
CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}'
|
||||
LINK (str): 'https://plex.tv/api/v2/pins/link'
|
||||
POLLINTERVAL (int): 1
|
||||
finished (bool): Whether the pin login has finished or not.
|
||||
expired (bool): Whether the pin login has expired or not.
|
||||
token (str): Token retrieved through the pin login.
|
||||
pin (str): Pin to use for the login on https://plex.tv/link.
|
||||
"""
|
||||
PINS = 'https://plex.tv/pins.xml' # get
|
||||
CHECKPINS = 'https://plex.tv/pins/{pinid}.xml' # get
|
||||
PINS = 'https://plex.tv/api/v2/pins' # get
|
||||
CHECKPINS = 'https://plex.tv/api/v2/pins/{pinid}' # get
|
||||
POLLINTERVAL = 1
|
||||
|
||||
def __init__(self, session=None, requestTimeout=None):
|
||||
def __init__(self, session=None, requestTimeout=None, headers=None):
|
||||
super(MyPlexPinLogin, self).__init__()
|
||||
self._session = session or requests.Session()
|
||||
self._requestTimeout = requestTimeout or TIMEOUT
|
||||
self.headers = headers
|
||||
|
||||
self._loginTimeout = None
|
||||
self._callback = None
|
||||
self._thread = None
|
||||
self._abort = False
|
||||
self._id = None
|
||||
self._code = None
|
||||
self._getCode()
|
||||
|
||||
self.finished = False
|
||||
self.expired = False
|
||||
self.token = None
|
||||
self.pin = self._getPin()
|
||||
|
||||
@property
|
||||
def pin(self):
|
||||
return self._code
|
||||
|
||||
def run(self, callback=None, timeout=None):
|
||||
""" Starts the thread which monitors the PIN login state.
|
||||
|
@ -1133,8 +1155,8 @@ class MyPlexPinLogin(object):
|
|||
timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional).
|
||||
|
||||
Raises:
|
||||
:class:`RuntimeError`: if the thread is already running.
|
||||
:class:`RuntimeError`: if the PIN login for the current PIN has expired.
|
||||
:class:`RuntimeError`: If the thread is already running.
|
||||
:class:`RuntimeError`: If the PIN login for the current PIN has expired.
|
||||
"""
|
||||
if self._thread and not self._abort:
|
||||
raise RuntimeError('MyPlexPinLogin thread is already running')
|
||||
|
@ -1187,19 +1209,16 @@ class MyPlexPinLogin(object):
|
|||
|
||||
return False
|
||||
|
||||
def _getPin(self):
|
||||
if self.pin:
|
||||
return self.pin
|
||||
|
||||
def _getCode(self):
|
||||
url = self.PINS
|
||||
response = self._query(url, self._session.post)
|
||||
if not response:
|
||||
return None
|
||||
|
||||
self._id = response.find('id').text
|
||||
self.pin = response.find('code').text
|
||||
self._id = response.attrib.get('id')
|
||||
self._code = response.attrib.get('code')
|
||||
|
||||
return self.pin
|
||||
return self._code
|
||||
|
||||
def _checkLogin(self):
|
||||
if not self._id:
|
||||
|
@ -1213,7 +1232,7 @@ class MyPlexPinLogin(object):
|
|||
if not response:
|
||||
return False
|
||||
|
||||
token = response.find('auth_token').text
|
||||
token = response.attrib.get('authToken')
|
||||
if not token:
|
||||
return False
|
||||
|
||||
|
@ -1241,11 +1260,19 @@ class MyPlexPinLogin(object):
|
|||
finally:
|
||||
self.finished = True
|
||||
|
||||
def _query(self, url, method=None):
|
||||
def _headers(self, **kwargs):
|
||||
""" Returns dict containing base headers for all requests for pin login. """
|
||||
headers = BASE_HEADERS.copy()
|
||||
if self.headers:
|
||||
headers.update(self.headers)
|
||||
headers.update(kwargs)
|
||||
return headers
|
||||
|
||||
def _query(self, url, method=None, headers=None, **kwargs):
|
||||
method = method or self._session.get
|
||||
log.debug('%s %s', method.__name__.upper(), url)
|
||||
headers = BASE_HEADERS.copy()
|
||||
response = method(url, headers=headers, timeout=self._requestTimeout)
|
||||
headers = headers or self._headers()
|
||||
response = method(url, headers=headers, timeout=self._requestTimeout, **kwargs)
|
||||
if not response.ok: # pragma: no cover
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
|
@ -1288,9 +1315,9 @@ def _chooseConnection(ctype, name, results):
|
|||
# or (url, token, None, runtime) in the case a connection could not be established.
|
||||
for url, token, result, runtime in results:
|
||||
okerr = 'OK' if result else 'ERR'
|
||||
log.info('%s connection %s (%ss): %s?X-Plex-Token=%s', ctype, okerr, runtime, url, token)
|
||||
log.debug('%s connection %s (%ss): %s?X-Plex-Token=%s', ctype, okerr, runtime, url, token)
|
||||
results = [r[2] for r in results if r and r[2] is not None]
|
||||
if results:
|
||||
log.info('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token)
|
||||
log.debug('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token)
|
||||
return results[0]
|
||||
raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name))
|
||||
|
|
|
@ -1,157 +1,224 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import PlexPartialObject
|
||||
from plexapi.exceptions import NotFound, BadRequest
|
||||
from plexapi.compat import quote_plus
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils, video
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.exceptions import BadRequest
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photoalbum(PlexPartialObject):
|
||||
""" Represents a photoalbum (collection of photos).
|
||||
""" Represents a single Photoalbum (collection of photos).
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'photo'
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
art (str): Photo art (/library/metadata/<ratingkey>/art/<artid>)
|
||||
composite (str): Unknown
|
||||
guid (str): Unknown (unique ID)
|
||||
index (sting): Index number of this album.
|
||||
addedAt (datetime): Datetime the photo album was added to the library.
|
||||
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||
composite (str): URL to composite image (/library/metadata/<ratingKey>/composite/<compositeid>)
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the photo album (local://229674).
|
||||
index (sting): Plex index number for the photo album.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
ratingKey (int): Unique key identifying the photo album.
|
||||
summary (str): Summary of the photoalbum.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Photoalbum title. (Trip to Disney World)
|
||||
type (str): Unknown
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
title (str): Name of the photo album. (Trip to Disney World)
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'photo'
|
||||
updatedAt (datatime): Datetime the photo album was updated.
|
||||
userRating (float): Rating of the photoalbum (0.0 - 10.0) equaling (0 stars - 5 stars).
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'photo'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.listType = 'photo'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.composite = data.attrib.get('composite')
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
|
||||
self.key = data.attrib.get('key', '')
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.listType = 'photo'
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in this album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, etag='Directory', **kwargs)
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating', 0))
|
||||
|
||||
def album(self, title):
|
||||
""" Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. """
|
||||
for album in self.albums():
|
||||
if album.title.lower() == title.lower():
|
||||
return album
|
||||
raise NotFound('Unable to find album: %s' % title)
|
||||
""" Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title.
|
||||
|
||||
def photos(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photo` objects in this album. """
|
||||
Parameters:
|
||||
title (str): Title of the photo album to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, etag='Photo', **kwargs)
|
||||
return self.fetchItem(key, Photoalbum, title__iexact=title)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, Photoalbum, **kwargs)
|
||||
|
||||
def photo(self, title):
|
||||
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title. """
|
||||
for photo in self.photos():
|
||||
if photo.title.lower() == title.lower():
|
||||
return photo
|
||||
raise NotFound('Unable to find photo: %s' % title)
|
||||
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the photo to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItem(key, Photo, title__iexact=title)
|
||||
|
||||
def photos(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photo` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, Photo, **kwargs)
|
||||
|
||||
def clip(self, title):
|
||||
""" Returns the :class:`~plexapi.video.Clip` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the clip to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItem(key, video.Clip, title__iexact=title)
|
||||
|
||||
def clips(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Clip` objects in this album. """
|
||||
""" Returns a list of :class:`~plexapi.video.Clip` objects in the album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, etag='Video', **kwargs)
|
||||
return self.fetchItems(key, video.Clip, **kwargs)
|
||||
|
||||
def get(self, title):
|
||||
""" Alias to :func:`~plexapi.photo.Photoalbum.photo`. """
|
||||
return self.episode(title)
|
||||
|
||||
def iterParts(self):
|
||||
""" Iterates over the parts of the media item. """
|
||||
for album in self.albums():
|
||||
for photo in album.photos():
|
||||
for part in photo.iterParts():
|
||||
yield part
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, showstatus=False):
|
||||
""" Download photo files to specified directory.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original file name otherwise
|
||||
a friendlier is generated.
|
||||
showstatus(bool): Display a progressbar.
|
||||
"""
|
||||
filepaths = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
name = location.file
|
||||
if not keep_original_name:
|
||||
title = self.title.replace(' ', '.')
|
||||
name = '%s.%s' % (title, location.container)
|
||||
url = self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus,
|
||||
savepath=savepath, session=self._server._session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photo(PlexPartialObject):
|
||||
""" Represents a single photo.
|
||||
class Photo(PlexPartialObject, Playable):
|
||||
""" Represents a single Photo.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Photo'
|
||||
TYPE (str): 'photo'
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
index (sting): Index number of this photo.
|
||||
addedAt (datetime): Datetime the photo was added to the library.
|
||||
createdAtAccuracy (str): Unknown (local).
|
||||
createdAtTZOffset (int): Unknown (-25200).
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the photo (com.plexapp.agents.none://231714?lang=xn).
|
||||
index (sting): Plex index number for the photo.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||
media (TYPE): Unknown
|
||||
originallyAvailableAt (datetime): Datetime this photo was added to Plex.
|
||||
parentKey (str): Photoalbum API URL.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
originallyAvailableAt (datetime): Datetime the photo was added to Plex.
|
||||
parentGuid (str): Plex GUID for the photo album (local://229674).
|
||||
parentIndex (int): Plex index number for the photo album.
|
||||
parentKey (str): API URL of the photo album (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the photo album.
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
parentThumb (str): URL to photo album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the photo album for the photo.
|
||||
ratingKey (int): Unique key identifying the photo.
|
||||
summary (str): Summary of the photo.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Photo title.
|
||||
type (str): Unknown
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
year (int): Year this photo was taken.
|
||||
tag (List<:class:`~plexapi.media.Tag`>): List of tag objects.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
title (str): Name of the photo.
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'photo'
|
||||
updatedAt (datatime): Datetime the photo was updated.
|
||||
year (int): Year the photo was taken.
|
||||
"""
|
||||
TAG = 'Photo'
|
||||
TYPE = 'photo'
|
||||
METADATA_TYPE = 'photo'
|
||||
|
||||
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
|
||||
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
|
||||
'&includeMarkers=1&includeConcerts=1&includePreferences=1'
|
||||
'&includeBandwidths=1&includeLoudnessRamps=1')
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.key = data.attrib.get('key')
|
||||
self._details_key = self.key + self._include
|
||||
self.listType = 'photo'
|
||||
Playable._loadData(self, data)
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.createdAtAccuracy = data.attrib.get('createdAtAccuracy')
|
||||
self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset'))
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '')
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.listType = 'photo'
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = data.attrib.get('parentRatingKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.tag = self.findItems(data, media.Tag)
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.tag = self.findItems(data, media.Tag)
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
"""Return URL for the thumbnail image."""
|
||||
key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb')
|
||||
return self._server.url(key, includeToken=True) if key else None
|
||||
|
||||
def photoalbum(self):
|
||||
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||
""" Return the photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def section(self):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` the item belongs to. """
|
||||
if hasattr(self, 'librarySectionID'):
|
||||
return self._server.library.sectionByID(self.librarySectionID)
|
||||
elif self.parentKey:
|
||||
|
@ -162,10 +229,19 @@ class Photo(PlexPartialObject):
|
|||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Photo
|
||||
interface to get the locations of the photo.
|
||||
|
||||
Retruns:
|
||||
List<str> of file paths where the photo is found on disk.
|
||||
"""
|
||||
return [part.file for item in self.media for part in item.parts if part]
|
||||
|
||||
def iterParts(self):
|
||||
""" Iterates over the parts of the media item. """
|
||||
for item in self.media:
|
||||
for part in item.parts:
|
||||
yield part
|
||||
|
||||
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
|
||||
""" Add current photo as sync item for specified device.
|
||||
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
||||
|
@ -201,3 +277,26 @@ class Photo(PlexPartialObject):
|
|||
sync_item.mediaSettings = MediaSettings.createPhoto(resolution)
|
||||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, showstatus=False):
|
||||
""" Download photo files to specified directory.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original file name otherwise
|
||||
a friendlier is generated.
|
||||
showstatus(bool): Display a progressbar.
|
||||
"""
|
||||
filepaths = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
name = location.file
|
||||
if not keep_original_name:
|
||||
title = self.title.replace(' ', '.')
|
||||
name = '%s.%s' % (title, location.container)
|
||||
url = self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus,
|
||||
savepath=savepath, session=self._server._session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
return filepaths
|
||||
|
|
|
@ -1,17 +1,36 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import utils
|
||||
from plexapi.base import PlexPartialObject, Playable
|
||||
from plexapi.exceptions import BadRequest, Unsupported
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
||||
from plexapi.library import LibrarySection
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from plexapi.utils import cast, toDatetime
|
||||
from plexapi.compat import quote_plus
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Playlist(PlexPartialObject, Playable):
|
||||
""" Represents a single Playlist object.
|
||||
# TODO: Document attributes
|
||||
""" Represents a single Playlist.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Playlist'
|
||||
TYPE (str): 'playlist'
|
||||
addedAt (datetime): Datetime the playlist was added to the server.
|
||||
allowSync (bool): True if you allow syncing playlists.
|
||||
composite (str): URL to composite image (/playlist/<ratingKey>/composite/<compositeid>)
|
||||
duration (int): Duration of the playlist in milliseconds.
|
||||
durationInSeconds (int): Duration of the playlist in seconds.
|
||||
guid (str): Plex GUID for the playlist (com.plexapp.agents.none://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX).
|
||||
key (str): API URL (/playlist/<ratingkey>).
|
||||
leafCount (int): Number of items in the playlist view.
|
||||
playlistType (str): 'audio', 'video', or 'photo'
|
||||
ratingKey (int): Unique key identifying the playlist.
|
||||
smart (bool): True if the playlist is a smart playlist.
|
||||
summary (str): Summary of the playlist.
|
||||
title (str): Name of the playlist.
|
||||
type (str): 'playlist'
|
||||
updatedAt (datatime): Datetime the playlist was updated.
|
||||
"""
|
||||
TAG = 'Playlist'
|
||||
TYPE = 'playlist'
|
||||
|
@ -20,12 +39,12 @@ class Playlist(PlexPartialObject, Playable):
|
|||
""" Load attribute values from Plex XML response. """
|
||||
Playable._loadData(self, data)
|
||||
self.addedAt = toDatetime(data.attrib.get('addedAt'))
|
||||
self.allowSync = cast(bool, data.attrib.get('allowSync'))
|
||||
self.composite = data.attrib.get('composite') # url to thumbnail
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds'))
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.key = data.attrib.get('key')
|
||||
self.key = self.key.replace('/items', '') if self.key else self.key # FIX_BUG_50
|
||||
self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50
|
||||
self.leafCount = cast(int, data.attrib.get('leafCount'))
|
||||
self.playlistType = data.attrib.get('playlistType')
|
||||
self.ratingKey = cast(int, data.attrib.get('ratingKey'))
|
||||
|
@ -34,12 +53,15 @@ class Playlist(PlexPartialObject, Playable):
|
|||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
|
||||
self.allowSync = cast(bool, data.attrib.get('allowSync'))
|
||||
self._items = None # cache for self.items
|
||||
|
||||
def __len__(self): # pragma: no cover
|
||||
return len(self.items())
|
||||
|
||||
def __iter__(self): # pragma: no cover
|
||||
for item in self.items():
|
||||
yield item
|
||||
|
||||
@property
|
||||
def metadataType(self):
|
||||
if self.isVideo:
|
||||
|
@ -69,14 +91,29 @@ class Playlist(PlexPartialObject, Playable):
|
|||
def __getitem__(self, key): # pragma: no cover
|
||||
return self.items()[key]
|
||||
|
||||
def item(self, title):
|
||||
""" Returns the item in the playlist that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the item to return.
|
||||
"""
|
||||
for item in self.items():
|
||||
if item.title.lower() == title.lower():
|
||||
return item
|
||||
raise NotFound('Item with title "%s" not found in the playlist' % title)
|
||||
|
||||
def items(self):
|
||||
""" Returns a list of all items in the playlist. """
|
||||
if self._items is None:
|
||||
key = '%s/items' % self.key
|
||||
key = '/playlists/%s/items' % self.ratingKey
|
||||
items = self.fetchItems(key)
|
||||
self._items = items
|
||||
return self._items
|
||||
|
||||
def get(self, title):
|
||||
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """
|
||||
return self.item(title)
|
||||
|
||||
def addItems(self, items):
|
||||
""" Add items to a playlist. """
|
||||
if not isinstance(items, (list, tuple)):
|
||||
|
@ -130,6 +167,9 @@ class Playlist(PlexPartialObject, Playable):
|
|||
@classmethod
|
||||
def _create(cls, server, title, items):
|
||||
""" Create a playlist. """
|
||||
if not items:
|
||||
raise BadRequest('Must include items to add when creating new playlist')
|
||||
|
||||
if items and not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
ratingKeys = []
|
||||
|
@ -162,6 +202,9 @@ class Playlist(PlexPartialObject, Playable):
|
|||
|
||||
**kwargs (dict): is passed to the filters. For a example see the search method.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: when no items are included in create request.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.playlist.Playlist`: an instance of created Playlist.
|
||||
"""
|
||||
|
@ -235,8 +278,8 @@ class Playlist(PlexPartialObject, Playable):
|
|||
generated from metadata of current photo.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.BadRequest`: when playlist is not allowed to sync.
|
||||
:exc:`plexapi.exceptions.Unsupported`: when playlist content is unsupported.
|
||||
:exc:`~plexapi.exceptions.BadRequest`: When playlist is not allowed to sync.
|
||||
:exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
|
|
|
@ -1,75 +1,289 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest, Unsupported
|
||||
|
||||
|
||||
class PlayQueue(PlexObject):
|
||||
"""Control a PlayQueue.
|
||||
|
||||
Attributes:
|
||||
key (str): This is only added to support playMedia
|
||||
TAG (str): 'PlayQueue'
|
||||
TYPE (str): 'playqueue'
|
||||
identifier (str): com.plexapp.plugins.library
|
||||
initpath (str): Relative url where data was grabbed from.
|
||||
items (list): List of :class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist`
|
||||
items (list): List of :class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`
|
||||
mediaTagPrefix (str): Fx /system/bundle/media/flags/
|
||||
mediaTagVersion (str): Fx 1485957738
|
||||
playQueueID (str): a id for the playqueue
|
||||
playQueueSelectedItemID (str): playQueueSelectedItemID
|
||||
playQueueSelectedItemOffset (str): playQueueSelectedItemOffset
|
||||
playQueueSelectedMetadataItemID (<type 'str'>): 7
|
||||
playQueueShuffled (bool): True if shuffled
|
||||
playQueueSourceURI (str): Fx library://150425c9-0d99-4242-821e-e5ab81cd2221/item//library/metadata/7
|
||||
playQueueTotalCount (str): How many items in the play queue.
|
||||
playQueueVersion (str): What version the playqueue is.
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
size (str): Seems to be a alias for playQueueTotalCount.
|
||||
mediaTagVersion (int): Fx 1485957738
|
||||
playQueueID (int): ID of the PlayQueue.
|
||||
playQueueLastAddedItemID (int):
|
||||
Defines where the "Up Next" region starts. Empty unless PlayQueue is modified after creation.
|
||||
playQueueSelectedItemID (int): The queue item ID of the currently selected item.
|
||||
playQueueSelectedItemOffset (int):
|
||||
The offset of the selected item in the PlayQueue, from the beginning of the queue.
|
||||
playQueueSelectedMetadataItemID (int): ID of the currently selected item, matches ratingKey.
|
||||
playQueueShuffled (bool): True if shuffled.
|
||||
playQueueSourceURI (str): Original URI used to create the PlayQueue.
|
||||
playQueueTotalCount (int): How many items in the PlayQueue.
|
||||
playQueueVersion (int): Version of the PlayQueue. Increments every time a change is made to the PlayQueue.
|
||||
selectedItem (:class:`~plexapi.media.Media`): Media object for the currently selected item.
|
||||
_server (:class:`~plexapi.server.PlexServer`): PlexServer associated with the PlayQueue.
|
||||
size (int): Alias for playQueueTotalCount.
|
||||
"""
|
||||
|
||||
TAG = "PlayQueue"
|
||||
TYPE = "playqueue"
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.identifier = data.attrib.get('identifier')
|
||||
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
|
||||
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
||||
self.playQueueID = data.attrib.get('playQueueID')
|
||||
self.playQueueSelectedItemID = data.attrib.get('playQueueSelectedItemID')
|
||||
self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset')
|
||||
self.playQueueSelectedMetadataItemID = data.attrib.get('playQueueSelectedMetadataItemID')
|
||||
self.playQueueShuffled = utils.cast(bool, data.attrib.get('playQueueShuffled', 0))
|
||||
self.playQueueSourceURI = data.attrib.get('playQueueSourceURI')
|
||||
self.playQueueTotalCount = data.attrib.get('playQueueTotalCount')
|
||||
self.playQueueVersion = data.attrib.get('playQueueVersion')
|
||||
self.size = utils.cast(int, data.attrib.get('size', 0))
|
||||
self.identifier = data.attrib.get("identifier")
|
||||
self.mediaTagPrefix = data.attrib.get("mediaTagPrefix")
|
||||
self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion"))
|
||||
self.playQueueID = utils.cast(int, data.attrib.get("playQueueID"))
|
||||
self.playQueueLastAddedItemID = utils.cast(
|
||||
int, data.attrib.get("playQueueLastAddedItemID")
|
||||
)
|
||||
self.playQueueSelectedItemID = utils.cast(
|
||||
int, data.attrib.get("playQueueSelectedItemID")
|
||||
)
|
||||
self.playQueueSelectedItemOffset = utils.cast(
|
||||
int, data.attrib.get("playQueueSelectedItemOffset")
|
||||
)
|
||||
self.playQueueSelectedMetadataItemID = utils.cast(
|
||||
int, data.attrib.get("playQueueSelectedMetadataItemID")
|
||||
)
|
||||
self.playQueueShuffled = utils.cast(
|
||||
bool, data.attrib.get("playQueueShuffled", 0)
|
||||
)
|
||||
self.playQueueSourceURI = data.attrib.get("playQueueSourceURI")
|
||||
self.playQueueTotalCount = utils.cast(
|
||||
int, data.attrib.get("playQueueTotalCount")
|
||||
)
|
||||
self.playQueueVersion = utils.cast(int, data.attrib.get("playQueueVersion"))
|
||||
self.size = utils.cast(int, data.attrib.get("size", 0))
|
||||
self.items = self.findItems(data)
|
||||
self.selectedItem = self[self.playQueueSelectedItemOffset]
|
||||
|
||||
def __getitem__(self, key):
|
||||
if not self.items:
|
||||
return None
|
||||
return self.items[key]
|
||||
|
||||
def __len__(self):
|
||||
return self.playQueueTotalCount
|
||||
|
||||
def __iter__(self):
|
||||
yield from self.items
|
||||
|
||||
def __contains__(self, media):
|
||||
"""Returns True if the PlayQueue contains the provided media item."""
|
||||
return any(x.playQueueItemID == media.playQueueItemID for x in self.items)
|
||||
|
||||
def getQueueItem(self, item):
|
||||
"""
|
||||
Accepts a media item and returns a similar object from this PlayQueue.
|
||||
Useful for looking up playQueueItemIDs using items obtained from the Library.
|
||||
"""
|
||||
matches = [x for x in self.items if x == item]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
elif len(matches) > 1:
|
||||
raise BadRequest(
|
||||
"{item} occurs multiple times in this PlayQueue, provide exact item".format(item=item)
|
||||
)
|
||||
else:
|
||||
raise BadRequest("{item} not valid for this PlayQueue".format(item=item))
|
||||
|
||||
@classmethod
|
||||
def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1):
|
||||
""" Create and returns a new :class:`~plexapi.playqueue.PlayQueue`.
|
||||
def get(
|
||||
cls,
|
||||
server,
|
||||
playQueueID,
|
||||
own=False,
|
||||
center=None,
|
||||
window=50,
|
||||
includeBefore=True,
|
||||
includeAfter=True,
|
||||
):
|
||||
"""Retrieve an existing :class:`~plexapi.playqueue.PlayQueue` by identifier.
|
||||
|
||||
Paramaters:
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
item (:class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist`): A media or Playlist.
|
||||
playQueueID (int): Identifier of an existing PlayQueue.
|
||||
own (bool, optional): If server should transfer ownership.
|
||||
center (int, optional): The playQueueItemID of the center of the window. Does not change selectedItem.
|
||||
window (int, optional): Number of items to return from each side of the center item.
|
||||
includeBefore (bool, optional):
|
||||
Include items before the center, defaults True. Does not include center if False.
|
||||
includeAfter (bool, optional):
|
||||
Include items after the center, defaults True. Does not include center if False.
|
||||
"""
|
||||
args = {
|
||||
"own": utils.cast(int, own),
|
||||
"window": window,
|
||||
"includeBefore": utils.cast(int, includeBefore),
|
||||
"includeAfter": utils.cast(int, includeAfter),
|
||||
}
|
||||
if center:
|
||||
args["center"] = center
|
||||
|
||||
path = "/playQueues/{playQueueID}{args}".format(playQueueID=playQueueID, args=utils.joinArgs(args))
|
||||
data = server.query(path, method=server._session.get)
|
||||
c = cls(server, data, initpath=path)
|
||||
c._server = server
|
||||
return c
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
server,
|
||||
items,
|
||||
startItem=None,
|
||||
shuffle=0,
|
||||
repeat=0,
|
||||
includeChapters=1,
|
||||
includeRelated=1,
|
||||
continuous=0,
|
||||
):
|
||||
"""Create and return a new :class:`~plexapi.playqueue.PlayQueue`.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
items (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`):
|
||||
A media item, list of media items, or Playlist.
|
||||
startItem (:class:`~plexapi.media.Media`, optional):
|
||||
Media item in the PlayQueue where playback should begin.
|
||||
shuffle (int, optional): Start the playqueue shuffled.
|
||||
repeat (int, optional): Start the playqueue shuffled.
|
||||
includeChapters (int, optional): include Chapters.
|
||||
includeRelated (int, optional): include Related.
|
||||
continuous (int, optional): include additional items after the initial item.
|
||||
For a show this would be the next episodes, for a movie it does nothing.
|
||||
"""
|
||||
args = {}
|
||||
args['includeChapters'] = includeChapters
|
||||
args['includeRelated'] = includeRelated
|
||||
args['repeat'] = repeat
|
||||
args['shuffle'] = shuffle
|
||||
if item.type == 'playlist':
|
||||
args['playlistID'] = item.ratingKey
|
||||
args['type'] = item.playlistType
|
||||
args = {
|
||||
"includeChapters": includeChapters,
|
||||
"includeRelated": includeRelated,
|
||||
"repeat": repeat,
|
||||
"shuffle": shuffle,
|
||||
"continuous": continuous,
|
||||
}
|
||||
|
||||
if isinstance(items, list):
|
||||
item_keys = ",".join([str(x.ratingKey) for x in items])
|
||||
uri_args = quote_plus("/library/metadata/{item_keys}".format(item_keys=item_keys))
|
||||
args["uri"] = "library:///directory/{uri_args}".format(uri_args=uri_args)
|
||||
args["type"] = items[0].listType
|
||||
elif items.type == "playlist":
|
||||
args["playlistID"] = items.ratingKey
|
||||
args["type"] = items.playlistType
|
||||
else:
|
||||
uuid = item.section().uuid
|
||||
args['key'] = item.key
|
||||
args['type'] = item.listType
|
||||
args['uri'] = 'library://%s/item/%s' % (uuid, item.key)
|
||||
path = '/playQueues%s' % utils.joinArgs(args)
|
||||
uuid = items.section().uuid
|
||||
args["type"] = items.listType
|
||||
args["uri"] = "library://{uuid}/item/{key}".format(uuid=uuid, key=items.key)
|
||||
|
||||
if startItem:
|
||||
args["key"] = startItem.key
|
||||
|
||||
path = "/playQueues{args}".format(args=utils.joinArgs(args))
|
||||
data = server.query(path, method=server._session.post)
|
||||
c = cls(server, data, initpath=path)
|
||||
# we manually add a key so we can pass this to playMedia
|
||||
# since the data, does not contain a key.
|
||||
c.key = item.key
|
||||
c.playQueueType = args["type"]
|
||||
c._server = server
|
||||
return c
|
||||
|
||||
def addItem(self, item, playNext=False, refresh=True):
|
||||
"""
|
||||
Append the provided item to the "Up Next" section of the PlayQueue.
|
||||
Items can only be added to the section immediately following the current playing item.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist.
|
||||
playNext (bool, optional): If True, add this item to the front of the "Up Next" section.
|
||||
If False, the item will be appended to the end of the "Up Next" section.
|
||||
Only has an effect if an item has already been added to the "Up Next" section.
|
||||
See https://support.plex.tv/articles/202188298-play-queues/ for more details.
|
||||
refresh (bool, optional): Refresh the PlayQueue from the server before updating.
|
||||
"""
|
||||
if refresh:
|
||||
self.refresh()
|
||||
|
||||
args = {}
|
||||
if item.type == "playlist":
|
||||
args["playlistID"] = item.ratingKey
|
||||
itemType = item.playlistType
|
||||
else:
|
||||
uuid = item.section().uuid
|
||||
itemType = item.listType
|
||||
args["uri"] = "library://{uuid}/item{key}".format(uuid=uuid, key=item.key)
|
||||
|
||||
if itemType != self.playQueueType:
|
||||
raise Unsupported("Item type does not match PlayQueue type")
|
||||
|
||||
if playNext:
|
||||
args["next"] = 1
|
||||
|
||||
path = "/playQueues/{playQueueID}{args}".format(playQueueID=self.playQueueID, args=utils.joinArgs(args))
|
||||
data = self._server.query(path, method=self._server._session.put)
|
||||
self._loadData(data)
|
||||
|
||||
def moveItem(self, item, after=None, refresh=True):
|
||||
"""
|
||||
Moves an item to the beginning of the PlayQueue. If `after` is provided,
|
||||
the item will be placed immediately after the specified item.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move.
|
||||
afterItemID (:class:`~plexapi.base.Playable`, optional): A different item in the PlayQueue.
|
||||
If provided, `item` will be placed in the PlayQueue after this item.
|
||||
refresh (bool, optional): Refresh the PlayQueue from the server before updating.
|
||||
"""
|
||||
args = {}
|
||||
|
||||
if refresh:
|
||||
self.refresh()
|
||||
|
||||
if item not in self:
|
||||
item = self.getQueueItem(item)
|
||||
|
||||
if after:
|
||||
if after not in self:
|
||||
after = self.getQueueItem(after)
|
||||
args["after"] = after.playQueueItemID
|
||||
|
||||
path = "/playQueues/{playQueueID}/items/{playQueueItemID}/move{args}".format(
|
||||
playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID, args=utils.joinArgs(args)
|
||||
)
|
||||
data = self._server.query(path, method=self._server._session.put)
|
||||
self._loadData(data)
|
||||
|
||||
def removeItem(self, item, refresh=True):
|
||||
"""Remove an item from the PlayQueue.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move.
|
||||
refresh (bool, optional): Refresh the PlayQueue from the server before updating.
|
||||
"""
|
||||
if refresh:
|
||||
self.refresh()
|
||||
|
||||
if item not in self:
|
||||
item = self.getQueueItem(item)
|
||||
|
||||
path = "/playQueues/{playQueueID}/items/{playQueueItemID}".format(
|
||||
playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID
|
||||
)
|
||||
data = self._server.query(path, method=self._server._session.delete)
|
||||
self._loadData(data)
|
||||
|
||||
def clear(self):
|
||||
"""Remove all items from the PlayQueue."""
|
||||
path = "/playQueues/{playQueueID}/items".format(playQueueID=self.playQueueID)
|
||||
data = self._server.query(path, method=self._server._session.delete)
|
||||
self._loadData(data)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the PlayQueue from the Plex server."""
|
||||
path = "/playQueues/{playQueueID}".format(playQueueID=self.playQueueID)
|
||||
data = self._server.query(path, method=self._server._session.get)
|
||||
self._loadData(data)
|
||||
|
|
|
@ -1,23 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from urllib.parse import urlencode
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
from requests.status_codes import _codes as codes
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE
|
||||
from plexapi import log, logfilter, utils
|
||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log,
|
||||
logfilter)
|
||||
from plexapi import utils
|
||||
from plexapi.alert import AlertListener
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.compat import ElementTree, urlencode
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||
from plexapi.library import Hub, Library, Path, File
|
||||
from plexapi.settings import Settings
|
||||
from plexapi.media import Conversion, Optimized
|
||||
from plexapi.playlist import Playlist
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from plexapi.settings import Settings
|
||||
from plexapi.utils import cast
|
||||
from plexapi.media import Optimized, Conversion
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
# Need these imports to populate utils.PLEXOBJECTS
|
||||
from plexapi import (audio as _audio, video as _video, # noqa: F401
|
||||
photo as _photo, media as _media, playlist as _playlist) # noqa: F401
|
||||
from plexapi import audio as _audio # noqa: F401; noqa: F401
|
||||
from plexapi import media as _media # noqa: F401
|
||||
from plexapi import photo as _photo # noqa: F401
|
||||
from plexapi import playlist as _playlist # noqa: F401
|
||||
from plexapi import video as _video # noqa: F401
|
||||
|
||||
|
||||
class PlexServer(PlexObject):
|
||||
|
@ -101,6 +107,8 @@ class PlexServer(PlexObject):
|
|||
self._library = None # cached library
|
||||
self._settings = None # cached settings
|
||||
self._myPlexAccount = None # cached myPlexAccount
|
||||
self._systemAccounts = None # cached list of SystemAccount
|
||||
self._systemDevices = None # cached list of SystemDevice
|
||||
data = self.query(self.key, timeout=timeout)
|
||||
super(PlexServer, self).__init__(self, data, self.key)
|
||||
|
||||
|
@ -184,6 +192,14 @@ class PlexServer(PlexObject):
|
|||
data = self.query(Account.key)
|
||||
return Account(self, data)
|
||||
|
||||
@property
|
||||
def activities(self):
|
||||
"""Returns all current PMS activities."""
|
||||
activities = []
|
||||
for elem in self.query(Activity.key):
|
||||
activities.append(Activity(self, elem))
|
||||
return activities
|
||||
|
||||
def agents(self, mediaType=None):
|
||||
""" Returns the :class:`~plexapi.media.Agent` objects this server has available. """
|
||||
key = '/system/agents'
|
||||
|
@ -200,11 +216,18 @@ class PlexServer(PlexObject):
|
|||
return q.attrib.get('token')
|
||||
|
||||
def systemAccounts(self):
|
||||
""" Returns the :class:`~plexapi.server.SystemAccounts` objects this server contains. """
|
||||
accounts = []
|
||||
for elem in self.query('/accounts'):
|
||||
accounts.append(SystemAccount(self, data=elem))
|
||||
return accounts
|
||||
""" Returns a list of :class:`~plexapi.server.SystemAccounts` objects this server contains. """
|
||||
if self._systemAccounts is None:
|
||||
key = '/accounts'
|
||||
self._systemAccounts = self.fetchItems(key, SystemAccount)
|
||||
return self._systemAccounts
|
||||
|
||||
def systemDevices(self):
|
||||
""" Returns a list of :class:`~plexapi.server.SystemDevices` objects this server contains. """
|
||||
if self._systemDevices is None:
|
||||
key = '/devices'
|
||||
self._systemDevices = self.fetchItems(key, SystemDevice)
|
||||
return self._systemDevices
|
||||
|
||||
def myPlexAccount(self):
|
||||
""" Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same
|
||||
|
@ -303,7 +326,7 @@ class PlexServer(PlexObject):
|
|||
name (str): Name of the client to return.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.NotFound`: Unknown client name
|
||||
:exc:`~plexapi.exceptions.NotFound`: Unknown client name.
|
||||
"""
|
||||
for client in self.clients():
|
||||
if client and client.title == name:
|
||||
|
@ -325,7 +348,7 @@ class PlexServer(PlexObject):
|
|||
|
||||
Parameters:
|
||||
item (Media or Playlist): Media or playlist to add to PlayQueue.
|
||||
kwargs (dict): See `~plexapi.playerque.PlayQueue.create`.
|
||||
kwargs (dict): See `~plexapi.playqueue.PlayQueue.create`.
|
||||
"""
|
||||
return PlayQueue.create(self, item, **kwargs)
|
||||
|
||||
|
@ -413,11 +436,11 @@ class PlexServer(PlexObject):
|
|||
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
|
||||
return results
|
||||
|
||||
def playlists(self, **kwargs):
|
||||
def playlists(self):
|
||||
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """
|
||||
# TODO: Add sort and type options?
|
||||
# /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0
|
||||
return self.fetchItems('/playlists', **kwargs)
|
||||
return self.fetchItems('/playlists')
|
||||
|
||||
def playlist(self, title):
|
||||
""" Returns the :class:`~plexapi.client.Playlist` that matches the specified title.
|
||||
|
@ -426,7 +449,7 @@ class PlexServer(PlexObject):
|
|||
title (str): Title of the playlist to return.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.NotFound`: Invalid playlist title
|
||||
:exc:`~plexapi.exceptions.NotFound`: Invalid playlist title.
|
||||
"""
|
||||
return self.fetchItem('/playlists', title=title)
|
||||
|
||||
|
@ -471,7 +494,7 @@ class PlexServer(PlexObject):
|
|||
log.debug('%s %s', method.__name__.upper(), url)
|
||||
headers = self._headers(**headers or {})
|
||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||
if response.status_code not in (200, 201):
|
||||
if response.status_code not in (200, 201, 204):
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
||||
|
@ -499,16 +522,23 @@ class PlexServer(PlexObject):
|
|||
Parameters:
|
||||
query (str): Query to use when searching your library.
|
||||
mediatype (str): Optionally limit your search to the specified media type.
|
||||
actor, album, artist, autotag, collection, director, episode, game, genre,
|
||||
movie, photo, photoalbum, place, playlist, shared, show, tag, track
|
||||
limit (int): Optionally limit to the specified number of results per Hub.
|
||||
"""
|
||||
results = []
|
||||
params = {'query': query}
|
||||
if mediatype:
|
||||
params['section'] = utils.SEARCHTYPES[mediatype]
|
||||
params = {
|
||||
'query': query,
|
||||
'includeCollections': 1,
|
||||
'includeExternalMedia': 1}
|
||||
if limit:
|
||||
params['limit'] = limit
|
||||
key = '/hubs/search?%s' % urlencode(params)
|
||||
for hub in self.fetchItems(key, Hub):
|
||||
if mediatype:
|
||||
if hub.type == mediatype:
|
||||
return hub.items
|
||||
else:
|
||||
results += hub.items
|
||||
return results
|
||||
|
||||
|
@ -516,6 +546,10 @@ class PlexServer(PlexObject):
|
|||
""" Returns a list of all active session (currently playing) media objects. """
|
||||
return self.fetchItems('/status/sessions')
|
||||
|
||||
def transcodeSessions(self):
|
||||
""" Returns a list of all active :class:`~plexapi.media.TranscodeSession` objects. """
|
||||
return self.fetchItems('/transcode/sessions')
|
||||
|
||||
def startAlertListener(self, callback=None):
|
||||
""" Creates a websocket connection to the Plex Server to optionally recieve
|
||||
notifications. These often include messages from Plex about media scans
|
||||
|
@ -528,7 +562,7 @@ class PlexServer(PlexObject):
|
|||
callback (func): Callback function to call on recieved messages.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exception.Unsupported`: Websocket-client not installed.
|
||||
:exc:`~plexapi.exception.Unsupported`: Websocket-client not installed.
|
||||
"""
|
||||
notifier = AlertListener(self, callback)
|
||||
notifier.start()
|
||||
|
@ -593,6 +627,103 @@ class PlexServer(PlexObject):
|
|||
value = 1 if toggle is True else 0
|
||||
return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put)
|
||||
|
||||
def bandwidth(self, timespan=None, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.server.StatisticsBandwidth` objects
|
||||
with the Plex server dashboard bandwidth data.
|
||||
|
||||
Parameters:
|
||||
timespan (str, optional): The timespan to bin the bandwidth data. Default is seconds.
|
||||
Available timespans: seconds, hours, days, weeks, months.
|
||||
**kwargs (dict, optional): Any of the available filters that can be applied to the bandwidth data.
|
||||
The time frame (at) and bytes can also be filtered using less than or greater than (see examples below).
|
||||
|
||||
* accountID (int): The :class:`~plexapi.server.SystemAccount` ID to filter.
|
||||
* at (datetime): The time frame to filter (inclusive). The time frame can be either:
|
||||
1. An exact time frame (e.g. Only December 1st 2020 `at=datetime(2020, 12, 1)`).
|
||||
2. Before a specific time (e.g. Before and including December 2020 `at<=datetime(2020, 12, 1)`).
|
||||
3. After a specific time (e.g. After and including January 2021 `at>=datetime(2021, 1, 1)`).
|
||||
* bytes (int): The amount of bytes to filter (inclusive). The bytes can be either:
|
||||
1. An exact number of bytes (not very useful) (e.g. `bytes=1024**3`).
|
||||
2. Less than or equal number of bytes (e.g. `bytes<=1024**3`).
|
||||
3. Greater than or equal number of bytes (e.g. `bytes>=1024**3`).
|
||||
* deviceID (int): The :class:`~plexapi.server.SystemDevice` ID to filter.
|
||||
* lan (bool): True to only retrieve local bandwidth, False to only retrieve remote bandwidth.
|
||||
Default returns all local and remote bandwidth.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: When applying an invalid timespan or unknown filter.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi.server import PlexServer
|
||||
plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx')
|
||||
|
||||
# Filter bandwidth data for December 2020 and later, and more than 1 GB used.
|
||||
filters = {
|
||||
'at>': datetime(2020, 12, 1),
|
||||
'bytes>': 1024**3
|
||||
}
|
||||
|
||||
# Retrieve bandwidth data in one day timespans.
|
||||
bandwidthData = plex.bandwidth(timespan='days', **filters)
|
||||
|
||||
# Print out bandwidth usage for each account and device combination.
|
||||
for bandwidth in sorted(bandwidthData, key=lambda x: x.at):
|
||||
account = bandwidth.account()
|
||||
device = bandwidth.device()
|
||||
gigabytes = round(bandwidth.bytes / 1024**3, 3)
|
||||
local = 'local' if bandwidth.lan else 'remote'
|
||||
date = bandwidth.at.strftime('%Y-%m-%d')
|
||||
print('%s used %s GB of %s bandwidth on %s from %s'
|
||||
% (account.name, gigabytes, local, date, device.name))
|
||||
|
||||
"""
|
||||
params = {}
|
||||
|
||||
if timespan is None:
|
||||
params['timespan'] = 6 # Default to seconds
|
||||
else:
|
||||
timespans = {
|
||||
'seconds': 6,
|
||||
'hours': 4,
|
||||
'days': 3,
|
||||
'weeks': 2,
|
||||
'months': 1
|
||||
}
|
||||
try:
|
||||
params['timespan'] = timespans[timespan]
|
||||
except KeyError:
|
||||
raise BadRequest('Invalid timespan specified: %s. '
|
||||
'Available timespans: %s' % (timespan, ', '.join(timespans.keys())))
|
||||
|
||||
filters = {'accountID', 'at', 'at<', 'at>', 'bytes', 'bytes<', 'bytes>', 'deviceID', 'lan'}
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key not in filters:
|
||||
raise BadRequest('Unknown filter: %s=%s' % (key, value))
|
||||
if key.startswith('at'):
|
||||
try:
|
||||
value = cast(int, value.timestamp())
|
||||
except AttributeError:
|
||||
raise BadRequest('Time frame filter must be a datetime object: %s=%s' % (key, value))
|
||||
elif key.startswith('bytes') or key == 'lan':
|
||||
value = cast(int, value)
|
||||
elif key == 'accountID':
|
||||
if value == self.myPlexAccount().id:
|
||||
value = 1 # The admin account is accountID=1
|
||||
params[key] = value
|
||||
|
||||
key = '/statistics/bandwidth?%s' % urlencode(params)
|
||||
return self.fetchItems(key, StatisticsBandwidth)
|
||||
|
||||
def resources(self):
|
||||
""" Returns a list of :class:`~plexapi.server.StatisticsResources` objects
|
||||
with the Plex server dashboard resources data. """
|
||||
key = '/statistics/resources?timespan=6'
|
||||
return self.fetchItems(key, StatisticsResources)
|
||||
|
||||
|
||||
class Account(PlexObject):
|
||||
""" Contains the locally cached MyPlex account information. The properties provided don't
|
||||
|
@ -642,12 +773,148 @@ class Account(PlexObject):
|
|||
self.subscriptionState = data.attrib.get('subscriptionState')
|
||||
|
||||
|
||||
class SystemAccount(PlexObject):
|
||||
""" Minimal api to list system accounts. """
|
||||
key = '/accounts'
|
||||
class Activity(PlexObject):
|
||||
"""A currently running activity on the PlexServer."""
|
||||
key = '/activities'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.accountID = cast(int, data.attrib.get('id'))
|
||||
self.accountKey = data.attrib.get('key')
|
||||
self.cancellable = cast(bool, data.attrib.get('cancellable'))
|
||||
self.progress = cast(int, data.attrib.get('progress'))
|
||||
self.title = data.attrib.get('title')
|
||||
self.subtitle = data.attrib.get('subtitle')
|
||||
self.type = data.attrib.get('type')
|
||||
self.uuid = data.attrib.get('uuid')
|
||||
|
||||
|
||||
class SystemAccount(PlexObject):
|
||||
""" Represents a single system account.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Account'
|
||||
autoSelectAudio (bool): True or False if the account has automatic audio language enabled.
|
||||
defaultAudioLanguage (str): The default audio language code for the account.
|
||||
defaultSubtitleLanguage (str): The default subtitle language code for the account.
|
||||
id (int): The Plex account ID.
|
||||
key (str): API URL (/accounts/<id>)
|
||||
name (str): The username of the account.
|
||||
subtitleMode (bool): The subtitle mode for the account.
|
||||
thumb (str): URL for the account thumbnail.
|
||||
"""
|
||||
TAG = 'Account'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.autoSelectAudio = cast(bool, data.attrib.get('autoSelectAudio'))
|
||||
self.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage')
|
||||
self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage')
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.key = data.attrib.get('key')
|
||||
self.name = data.attrib.get('name')
|
||||
self.subtitleMode = cast(int, data.attrib.get('subtitleMode'))
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
# For backwards compatibility
|
||||
self.accountID = self.id
|
||||
self.accountKey = self.key
|
||||
|
||||
|
||||
class SystemDevice(PlexObject):
|
||||
""" Represents a single system device.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Device'
|
||||
createdAt (datatime): Datetime the device was created.
|
||||
id (int): The ID of the device (not the same as :class:`~plexapi.myplex.MyPlexDevice` ID).
|
||||
key (str): API URL (/devices/<id>)
|
||||
name (str): The name of the device.
|
||||
platform (str): OS the device is running (Linux, Windows, Chrome, etc.)
|
||||
"""
|
||||
TAG = 'Device'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.key = '/devices/%s' % self.id
|
||||
self.name = data.attrib.get('name')
|
||||
self.platform = data.attrib.get('platform')
|
||||
|
||||
|
||||
class StatisticsBandwidth(PlexObject):
|
||||
""" Represents a single statistics bandwidth data.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'StatisticsBandwidth'
|
||||
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
|
||||
at (datatime): Datetime of the bandwidth data.
|
||||
bytes (int): The total number of bytes for the specified timespan.
|
||||
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
|
||||
lan (bool): True or False wheter the bandwidth is local or remote.
|
||||
timespan (int): The timespan for the bandwidth data.
|
||||
1: months, 2: weeks, 3: days, 4: hours, 6: seconds.
|
||||
|
||||
"""
|
||||
TAG = 'StatisticsBandwidth'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.accountID = cast(int, data.attrib.get('accountID'))
|
||||
self.at = utils.toDatetime(data.attrib.get('at'))
|
||||
self.bytes = cast(int, data.attrib.get('bytes'))
|
||||
self.deviceID = cast(int, data.attrib.get('deviceID'))
|
||||
self.lan = cast(bool, data.attrib.get('lan'))
|
||||
self.timespan = cast(int, data.attrib.get('timespan'))
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
self.__class__.__name__,
|
||||
self._clean(self.accountID),
|
||||
self._clean(self.deviceID),
|
||||
self._clean(int(self.at.timestamp()))
|
||||
] if p])
|
||||
|
||||
def account(self):
|
||||
""" Returns the :class:`~plexapi.server.SystemAccount` associated with the bandwidth data. """
|
||||
accounts = self._server.systemAccounts()
|
||||
try:
|
||||
return next(account for account in accounts if account.id == self.accountID)
|
||||
except StopIteration:
|
||||
raise NotFound('Unknown account for this bandwidth data: accountID=%s' % self.accountID)
|
||||
|
||||
def device(self):
|
||||
""" Returns the :class:`~plexapi.server.SystemDevice` associated with the bandwidth data. """
|
||||
devices = self._server.systemDevices()
|
||||
try:
|
||||
return next(device for device in devices if device.id == self.deviceID)
|
||||
except StopIteration:
|
||||
raise NotFound('Unknown device for this bandwidth data: deviceID=%s' % self.deviceID)
|
||||
|
||||
|
||||
class StatisticsResources(PlexObject):
|
||||
""" Represents a single statistics resources data.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'StatisticsResources'
|
||||
at (datatime): Datetime of the resource data.
|
||||
hostCpuUtilization (float): The system CPU usage %.
|
||||
hostMemoryUtilization (float): The Plex Media Server CPU usage %.
|
||||
processCpuUtilization (float): The system RAM usage %.
|
||||
processMemoryUtilization (float): The Plex Media Server RAM usage %.
|
||||
timespan (int): The timespan for the resource data (6: seconds).
|
||||
"""
|
||||
TAG = 'StatisticsResources'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.at = utils.toDatetime(data.attrib.get('at'))
|
||||
self.hostCpuUtilization = cast(float, data.attrib.get('hostCpuUtilization'))
|
||||
self.hostMemoryUtilization = cast(float, data.attrib.get('hostMemoryUtilization'))
|
||||
self.processCpuUtilization = cast(float, data.attrib.get('processCpuUtilization'))
|
||||
self.processMemoryUtilization = cast(float, data.attrib.get('processMemoryUtilization'))
|
||||
self.timespan = cast(int, data.attrib.get('timespan'))
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
self.__class__.__name__,
|
||||
self._clean(int(self.at.timestamp()))
|
||||
] if p])
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from collections import defaultdict
|
||||
from urllib.parse import quote
|
||||
|
||||
from plexapi import log, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.compat import quote, string_type
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
||||
|
||||
|
@ -104,12 +104,11 @@ class Setting(PlexObject):
|
|||
"""
|
||||
_bool_cast = lambda x: True if x == 'true' or x == '1' else False
|
||||
_bool_str = lambda x: str(x).lower()
|
||||
_str = lambda x: str(x).encode('utf-8')
|
||||
TYPES = {
|
||||
'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str},
|
||||
'double': {'type': float, 'cast': float, 'tostr': _str},
|
||||
'int': {'type': int, 'cast': int, 'tostr': _str},
|
||||
'text': {'type': string_type, 'cast': _str, 'tostr': _str},
|
||||
'double': {'type': float, 'cast': float, 'tostr': str},
|
||||
'int': {'type': int, 'cast': int, 'tostr': str},
|
||||
'text': {'type': str, 'cast': str, 'tostr': str},
|
||||
}
|
||||
|
||||
def _loadData(self, data):
|
||||
|
@ -158,3 +157,21 @@ class Setting(PlexObject):
|
|||
def toUrl(self):
|
||||
"""Helper for urls"""
|
||||
return '%s=%s' % (self.id, self._value or self.value)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Preferences(Setting):
|
||||
""" Represents a single Preferences.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Preferences'
|
||||
FILTER (str): 'preferences'
|
||||
"""
|
||||
TAG = 'Preferences'
|
||||
FILTER = 'preferences'
|
||||
|
||||
def _default(self):
|
||||
""" Set the default value for this setting."""
|
||||
key = '%s/prefs?' % self._initpath
|
||||
url = key + '%s=%s' % (self.id, self.default)
|
||||
self._server.query(url, method=self._server._session.put)
|
||||
|
|
|
@ -201,7 +201,7 @@ class MediaSettings(object):
|
|||
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.BadRequest`: when provided unknown video quality.
|
||||
:exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality.
|
||||
"""
|
||||
if videoQuality == VIDEO_QUALITY_ORIGINAL:
|
||||
return MediaSettings('', '', '')
|
||||
|
@ -231,7 +231,7 @@ class MediaSettings(object):
|
|||
module.
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.BadRequest` when provided unknown video quality.
|
||||
:exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality.
|
||||
"""
|
||||
if resolution in PHOTO_QUALITIES:
|
||||
return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution)
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import base64
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import warnings
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from getpass import getpass
|
||||
from threading import Event, Thread
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from plexapi import compat
|
||||
from plexapi.exceptions import NotFound
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
|
@ -19,13 +21,13 @@ except ImportError:
|
|||
tqdm = None
|
||||
|
||||
log = logging.getLogger('plexapi')
|
||||
warnings.simplefilter('default', category=DeprecationWarning)
|
||||
|
||||
# Search Types - Plex uses these to filter specific media types when searching.
|
||||
# Library Types - Populated at runtime
|
||||
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7,
|
||||
'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14,
|
||||
'playlist': 15, 'playlistFolder': 16, 'collection': 18,
|
||||
'optimizedVersion': 42, 'userPlaylistItem': 1001}
|
||||
'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'optimizedVersion': 42, 'userPlaylistItem': 1001}
|
||||
PLEXOBJECTS = {}
|
||||
|
||||
|
||||
|
@ -43,7 +45,7 @@ class SecretsFilter(logging.Filter):
|
|||
def filter(self, record):
|
||||
cleanargs = list(record.args)
|
||||
for i in range(len(cleanargs)):
|
||||
if isinstance(cleanargs[i], compat.string_type):
|
||||
if isinstance(cleanargs[i], str):
|
||||
for secret in self.secrets:
|
||||
cleanargs[i] = cleanargs[i].replace(secret, '<hidden>')
|
||||
record.args = tuple(cleanargs)
|
||||
|
@ -55,7 +57,7 @@ def registerPlexObject(cls):
|
|||
define a few helper functions to dynamically convery the XML into objects. See
|
||||
buildItem() below for an example.
|
||||
"""
|
||||
etype = getattr(cls, 'STREAMTYPE', cls.TYPE)
|
||||
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
|
||||
ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG
|
||||
if ehash in PLEXOBJECTS:
|
||||
raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' %
|
||||
|
@ -101,8 +103,8 @@ def joinArgs(args):
|
|||
return ''
|
||||
arglist = []
|
||||
for key in sorted(args, key=lambda x: x.lower()):
|
||||
value = compat.ustr(args[key])
|
||||
arglist.append('%s=%s' % (key, compat.quote(value, safe='')))
|
||||
value = str(args[key])
|
||||
arglist.append('%s=%s' % (key, quote(value, safe='')))
|
||||
return '?%s' % '&'.join(arglist)
|
||||
|
||||
|
||||
|
@ -112,7 +114,7 @@ def lowerFirst(s):
|
|||
|
||||
def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover
|
||||
""" Returns the value at the specified attrstr location within a nexted tree of
|
||||
dicts, lists, tuples, functions, classes, etc. The lookup is done recursivley
|
||||
dicts, lists, tuples, functions, classes, etc. The lookup is done recursively
|
||||
for each key in attrstr (split by by the delimiter) This function is heavily
|
||||
influenced by the lookups used in Django templates.
|
||||
|
||||
|
@ -148,10 +150,10 @@ def searchType(libtype):
|
|||
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
|
||||
collection)
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.NotFound`: Unknown libtype
|
||||
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
||||
"""
|
||||
libtype = compat.ustr(libtype)
|
||||
if libtype in [compat.ustr(v) for v in SEARCHTYPES.values()]:
|
||||
libtype = str(libtype)
|
||||
if libtype in [str(v) for v in SEARCHTYPES.values()]:
|
||||
return libtype
|
||||
if SEARCHTYPES.get(libtype) is not None:
|
||||
return SEARCHTYPES[libtype]
|
||||
|
@ -159,12 +161,12 @@ def searchType(libtype):
|
|||
|
||||
|
||||
def threaded(callback, listargs):
|
||||
""" Returns the result of <callback> for each set of \*args in listargs. Each call
|
||||
""" Returns the result of <callback> for each set of `*args` in listargs. Each call
|
||||
to <callback> is called concurrently in their own separate threads.
|
||||
|
||||
Parameters:
|
||||
callback (func): Callback function to apply to each set of \*args.
|
||||
listargs (list): List of lists; \*args to pass each thread.
|
||||
callback (func): Callback function to apply to each set of `*args`.
|
||||
listargs (list): List of lists; `*args` to pass each thread.
|
||||
"""
|
||||
threads, results = [], []
|
||||
job_is_done_event = Event()
|
||||
|
@ -206,6 +208,19 @@ def toDatetime(value, format=None):
|
|||
return value
|
||||
|
||||
|
||||
def millisecondToHumanstr(milliseconds):
|
||||
""" Returns human readable time duration from milliseconds.
|
||||
HH:MM:SS:MMMM
|
||||
|
||||
Parameters:
|
||||
milliseconds (str,int): time duration in milliseconds.
|
||||
"""
|
||||
milliseconds = int(milliseconds)
|
||||
r = datetime.utcfromtimestamp(milliseconds / 1000)
|
||||
f = r.strftime("%H:%M:%S.%f")
|
||||
return f[:-2]
|
||||
|
||||
|
||||
def toList(value, itemcast=None, delim=','):
|
||||
""" Returns a list of strings from the specified value.
|
||||
|
||||
|
@ -277,7 +292,7 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4
|
|||
response = session.get(url, headers=headers, stream=True)
|
||||
# make sure the savepath directory exists
|
||||
savepath = savepath or os.getcwd()
|
||||
compat.makedirs(savepath, exist_ok=True)
|
||||
os.makedirs(savepath, exist_ok=True)
|
||||
|
||||
# try getting filename from header if not specified in arguments (used for logs, db)
|
||||
if not filename and response.headers.get('Content-Disposition'):
|
||||
|
@ -356,6 +371,10 @@ def getMyPlexAccount(opts=None): # pragma: no cover
|
|||
if config_username and config_password:
|
||||
print('Authenticating with Plex.tv as %s..' % config_username)
|
||||
return MyPlexAccount(config_username, config_password)
|
||||
config_token = CONFIG.get('auth.server_token')
|
||||
if config_token:
|
||||
print('Authenticating with Plex.tv with token')
|
||||
return MyPlexAccount(token=config_token)
|
||||
# 3. Prompt for username and password on the command line
|
||||
username = input('What is your plex.tv username: ')
|
||||
password = getpass('What is your plex.tv password: ')
|
||||
|
@ -363,6 +382,30 @@ def getMyPlexAccount(opts=None): # pragma: no cover
|
|||
return MyPlexAccount(username, password)
|
||||
|
||||
|
||||
def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover
|
||||
""" Helper function to create a new MyPlexDevice.
|
||||
|
||||
Parameters:
|
||||
headers (dict): Provide the X-Plex- headers for the new device.
|
||||
A unique X-Plex-Client-Identifier is required.
|
||||
account (MyPlexAccount): The Plex account to create the device on.
|
||||
timeout (int): Timeout in seconds to wait for device login.
|
||||
"""
|
||||
from plexapi.myplex import MyPlexPinLogin
|
||||
|
||||
if 'X-Plex-Client-Identifier' not in headers:
|
||||
raise BadRequest('The X-Plex-Client-Identifier header is required.')
|
||||
|
||||
clientIdentifier = headers['X-Plex-Client-Identifier']
|
||||
|
||||
pinlogin = MyPlexPinLogin(headers=headers)
|
||||
pinlogin.run(timeout=timeout)
|
||||
account.link(pinlogin.pin)
|
||||
pinlogin.waitForLogin()
|
||||
|
||||
return account.device(clientId=clientIdentifier)
|
||||
|
||||
|
||||
def choose(msg, items, attr): # pragma: no cover
|
||||
""" Command line helper to display a list of choices, asking the
|
||||
user to choose one of the options.
|
||||
|
@ -404,3 +447,18 @@ def getAgentIdentifier(section, agent):
|
|||
|
||||
def base64str(text):
|
||||
return base64.b64encode(text.encode('utf-8')).decode('utf-8')
|
||||
|
||||
|
||||
def deprecated(message):
|
||||
def decorator(func):
|
||||
"""This is a decorator which can be used to mark functions
|
||||
as deprecated. It will result in a warning being emitted
|
||||
when the function is used."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message)
|
||||
warnings.warn(msg, category=DeprecationWarning, stacklevel=3)
|
||||
log.warning(msg)
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
|
|
@ -1,47 +1,54 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.compat import quote_plus, urlencode
|
||||
import os
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
|
||||
from plexapi import library, media, settings, utils
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
||||
|
||||
class Video(PlexPartialObject):
|
||||
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
||||
:class:`~plexapi.video.Episode`.
|
||||
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
art (str): URL to artwork image.
|
||||
addedAt (datetime): Datetime the item was added to the library.
|
||||
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||
artBlurHash (str): BlurHash string for artwork image.
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the movie, show, season, episode, or clip (plex://movie/5d776b59ad5437001f79c6f8).
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastViewedAt (datetime): Datetime item was last accessed.
|
||||
lastViewedAt (datetime): Datetime the item was last played.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the artist, track, or album.
|
||||
thumb (str): URL to thumbnail image.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
listType (str): Hardcoded as 'video' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying the item.
|
||||
summary (str): Summary of the movie, show, season, episode, or clip.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
thumbBlurHash (str): BlurHash string for thumbnail image.
|
||||
title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.)
|
||||
title (str): Name of the movie, show, season, episode, or clip.
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'artist', 'album', or 'track'.
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
viewCount (int): Count of times this item was accessed.
|
||||
type (str): 'movie', 'show', 'season', 'episode', or 'clip'.
|
||||
updatedAt (datatime): Datetime the item was updated.
|
||||
viewCount (int): Count of times the item was played.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.listType = 'video'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.artBlurHash = data.attrib.get('artBlurHash')
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.key = data.attrib.get('key', '')
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.listType = 'video'
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
|
@ -133,8 +140,9 @@ class Video(PlexPartialObject):
|
|||
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None):
|
||||
""" Optimize item
|
||||
|
||||
locationID (int): -1 in folder with orginal items
|
||||
2 library path
|
||||
locationID (int): -1 in folder with original items
|
||||
2 library path id
|
||||
library path id is found in library.locations[i].id
|
||||
|
||||
target (str): custom quality name.
|
||||
if none provided use "Custom: {deviceProfile}"
|
||||
|
@ -164,6 +172,13 @@ class Video(PlexPartialObject):
|
|||
if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None):
|
||||
raise BadRequest('Unexpected or missing quality profile.')
|
||||
|
||||
libraryLocationIDs = [location.id for location in self.section()._locations()]
|
||||
libraryLocationIDs.append(-1)
|
||||
|
||||
if locationID not in libraryLocationIDs:
|
||||
raise BadRequest('Unexpected library path ID. %s not in %s' %
|
||||
(locationID, libraryLocationIDs))
|
||||
|
||||
if isinstance(targetTagID, str):
|
||||
tagIndex = tagKeys.index(targetTagID)
|
||||
targetTagID = tagValues[tagIndex]
|
||||
|
@ -250,35 +265,33 @@ class Movie(Playable, Video):
|
|||
Attributes:
|
||||
TAG (str): 'Video'
|
||||
TYPE (str): 'movie'
|
||||
art (str): Key to movie artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
audienceRating (float): Audience rating (usually from Rotten Tomatoes).
|
||||
audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled)
|
||||
audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled).
|
||||
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
||||
chapterSource (str): Chapter source (agent; media; mixed).
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
duration (int): Duration of movie in milliseconds.
|
||||
guid: Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
|
||||
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
|
||||
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
|
||||
duration (int): Duration of the movie in milliseconds.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
originallyAvailableAt (datetime): Datetime the movie was released.
|
||||
originalTitle (str): Original title, often the foreign title (転々; 엽기적인 그녀).
|
||||
originallyAvailableAt (datetime): Datetime movie was released.
|
||||
primaryExtraKey (str) Primary extra key (/library/metadata/66351).
|
||||
rating (float): Movie rating (7.9; 9.8; 8.1).
|
||||
ratingImage (str): Key to rating image (rottentomatoes://image.rating.rotten).
|
||||
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
|
||||
rating (float): Movie critic rating (7.9; 9.8; 8.1).
|
||||
ratingImage (str): Key to critic rating image (rottentomatoes://image.rating.rotten).
|
||||
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
||||
studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||
tagline (str): Movie tag line (Back 2 Work; Who says men can't change?).
|
||||
userRating (float): User rating (2.0; 8.0).
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year movie was released.
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs.
|
||||
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
|
||||
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
|
||||
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
||||
year (int): Year movie was released.
|
||||
"""
|
||||
TAG = 'Video'
|
||||
TYPE = 'movie'
|
||||
|
@ -288,38 +301,33 @@ class Movie(Playable, Video):
|
|||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
|
||||
self.art = data.attrib.get('art')
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.directors = self.findItems(data, media.Director)
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.originalTitle = data.attrib.get('originalTitle')
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||
self.producers = self.findItems(data, media.Producer)
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.ratingImage = data.attrib.get('ratingImage')
|
||||
self.roles = self.findItems(data, media.Role)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.tagline = data.attrib.get('tagline')
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.directors = self.findItems(data, media.Director)
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.producers = self.findItems(data, media.Producer)
|
||||
self.roles = self.findItems(data, media.Role)
|
||||
self.writers = self.findItems(data, media.Writer)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
@property
|
||||
def actors(self):
|
||||
|
@ -329,7 +337,10 @@ class Movie(Playable, Video):
|
|||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Movie
|
||||
interface to get the locations of the movie.
|
||||
|
||||
Retruns:
|
||||
List<str> of file paths where the movie is found on disk.
|
||||
"""
|
||||
return [part.file for part in self.iterParts() if part]
|
||||
|
||||
|
@ -337,6 +348,15 @@ class Movie(Playable, Video):
|
|||
# This is just for compat.
|
||||
return self.title
|
||||
|
||||
def hubs(self):
|
||||
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
|
||||
data = self._server.query(self._details_key)
|
||||
video = data.find('Video')
|
||||
if video:
|
||||
related = video.find('Related')
|
||||
if related:
|
||||
return self.findItems(related, library.Hub)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Download video files to specified directory.
|
||||
|
||||
|
@ -371,61 +391,56 @@ class Show(Video):
|
|||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'show'
|
||||
art (str): Key to show artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
childCount (int): Unknown.
|
||||
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/banner/<bannerid>).
|
||||
childCount (int): Number of seasons in the show.
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs.
|
||||
duration (int): Duration of show in milliseconds.
|
||||
guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
|
||||
index (int): Plex index (?)
|
||||
leafCount (int): Unknown.
|
||||
locations (list<str>): List of locations paths.
|
||||
originallyAvailableAt (datetime): Datetime show was released.
|
||||
rating (float): Show rating (7.9; 9.8; 8.1).
|
||||
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||
theme (str): Key to theme resource (/library/metadata/<ratingkey>/theme/<themeid>)
|
||||
viewedLeafCount (int): Unknown.
|
||||
year (int): Year the show was released.
|
||||
duration (int): Typical duration of the show episodes in milliseconds.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
index (int): Plex index number for the show.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
leafCount (int): Number of items in the show view.
|
||||
locations (List<str>): List of folder paths where the show is found on disk.
|
||||
originallyAvailableAt (datetime): Datetime the show was released.
|
||||
rating (float): Show rating (7.9; 9.8; 8.1).
|
||||
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
||||
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
||||
viewedLeafCount (int): Number of items marked as played in the show view.
|
||||
year (int): Year the show was released.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'show'
|
||||
METADATA_TYPE = 'episode'
|
||||
|
||||
def __iter__(self):
|
||||
for season in self.seasons():
|
||||
yield season
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
# fix key if loaded from search
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.art = data.attrib.get('art')
|
||||
self.banner = data.attrib.get('banner')
|
||||
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = data.attrib.get('index')
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.roles = self.findItems(data, media.Role)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.theme = data.attrib.get('theme')
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.roles = self.findItems(data, media.Role)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
|
||||
def __iter__(self):
|
||||
for season in self.seasons():
|
||||
yield season
|
||||
|
||||
@property
|
||||
def actors(self):
|
||||
|
@ -434,29 +449,88 @@ class Show(Video):
|
|||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if this show is fully watched. """
|
||||
""" Returns True if the show is fully watched. """
|
||||
return bool(self.viewedLeafCount == self.leafCount)
|
||||
|
||||
def seasons(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Season` objects. """
|
||||
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
|
||||
return self.fetchItems(key, **kwargs)
|
||||
def preferences(self):
|
||||
""" Returns a list of :class:`~plexapi.settings.Preferences` objects. """
|
||||
items = []
|
||||
data = self._server.query(self._details_key)
|
||||
for item in data.iter('Preferences'):
|
||||
for elem in item:
|
||||
setting = settings.Preferences(data=elem, server=self._server)
|
||||
setting._initpath = self.key
|
||||
items.append(setting)
|
||||
|
||||
def season(self, title=None):
|
||||
return items
|
||||
|
||||
def editAdvanced(self, **kwargs):
|
||||
""" Edit a show's advanced settings. """
|
||||
data = {}
|
||||
key = '%s/prefs?' % self.key
|
||||
preferences = {pref.id: list(pref.enumValues.keys()) for pref in self.preferences()}
|
||||
for settingID, value in kwargs.items():
|
||||
enumValues = preferences.get(settingID)
|
||||
if value in enumValues:
|
||||
data[settingID] = value
|
||||
else:
|
||||
raise NotFound('%s not found in %s' % (value, enumValues))
|
||||
url = key + urlencode(data)
|
||||
self._server.query(url, method=self._server._session.put)
|
||||
|
||||
def defaultAdvanced(self):
|
||||
""" Edit all of show's advanced settings to default. """
|
||||
data = {}
|
||||
key = '%s/prefs?' % self.key
|
||||
for preference in self.preferences():
|
||||
data[preference.id] = preference.default
|
||||
url = key + urlencode(data)
|
||||
self._server.query(url, method=self._server._session.put)
|
||||
|
||||
def hubs(self):
|
||||
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
|
||||
data = self._server.query(self._details_key)
|
||||
directory = data.find('Directory')
|
||||
if directory:
|
||||
related = directory.find('Related')
|
||||
if related:
|
||||
return self.findItems(related, library.Hub)
|
||||
|
||||
def onDeck(self):
|
||||
""" Returns show's On Deck :class:`~plexapi.video.Video` object or `None`.
|
||||
If show is unwatched, return will likely be the first episode.
|
||||
"""
|
||||
data = self._server.query(self._details_key)
|
||||
episode = next(data.iter('OnDeck'), None)
|
||||
if episode:
|
||||
return self.findItems(episode)[0]
|
||||
return None
|
||||
|
||||
def season(self, title=None, season=None):
|
||||
""" Returns the season with the specified title or number.
|
||||
|
||||
Parameters:
|
||||
title (str or int): Title or Number of the season to return.
|
||||
title (str): Title of the season to return.
|
||||
season (int): Season number (default: None; required if title not specified).
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
if title is not None and not isinstance(title, int):
|
||||
return self.fetchItem(key, Season, title__iexact=title)
|
||||
elif season is not None or isinstance(title, int):
|
||||
if isinstance(title, int):
|
||||
return self.fetchItem(key, etag='Directory', index__iexact=str(title))
|
||||
return self.fetchItem(key, etag='Directory', title__iexact=title)
|
||||
index = title
|
||||
else:
|
||||
index = season
|
||||
return self.fetchItem(key, Season, index=index)
|
||||
raise BadRequest('Missing argument: title or season is required')
|
||||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return self.fetchItems(key, **kwargs)
|
||||
def seasons(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Season` objects in the show. """
|
||||
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
|
||||
return self.fetchItems(key, Season, **kwargs)
|
||||
|
||||
def episode(self, title=None, season=None, episode=None):
|
||||
""" Find a episode using a title or season and episode.
|
||||
|
@ -467,19 +541,24 @@ class Show(Video):
|
|||
episode (int): Episode number (default: None; required if title not specified).
|
||||
|
||||
Raises:
|
||||
:exc:`plexapi.exceptions.BadRequest`: If season and episode is missing.
|
||||
:exc:`plexapi.exceptions.NotFound`: If the episode is missing.
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing.
|
||||
"""
|
||||
if title:
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
elif season is not None and episode:
|
||||
results = [i for i in self.episodes() if i.seasonNumber == season and i.index == episode]
|
||||
if results:
|
||||
return results[0]
|
||||
raise NotFound('Couldnt find %s S%s E%s' % (self.title, season, episode))
|
||||
if title is not None:
|
||||
return self.fetchItem(key, Episode, title__iexact=title)
|
||||
elif season is not None and episode is not None:
|
||||
return self.fetchItem(key, Episode, parentIndex=season, index=episode)
|
||||
raise BadRequest('Missing argument: title or season and episode are required')
|
||||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects in the show. """
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return self.fetchItems(key, Episode, **kwargs)
|
||||
|
||||
def get(self, title=None, season=None, episode=None):
|
||||
""" Alias to :func:`~plexapi.video.Show.episode`. """
|
||||
return self.episode(title, season, episode)
|
||||
|
||||
def watched(self):
|
||||
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(viewCount__gt=0)
|
||||
|
@ -488,10 +567,6 @@ class Show(Video):
|
|||
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(viewCount=0)
|
||||
|
||||
def get(self, title=None, season=None, episode=None):
|
||||
""" Alias to :func:`~plexapi.video.Show.episode`. """
|
||||
return self.episode(title, season, episode)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Download video files to specified directory.
|
||||
|
||||
|
@ -514,31 +589,28 @@ class Season(Video):
|
|||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'season'
|
||||
leafCount (int): Number of episodes in season.
|
||||
index (int): Season number.
|
||||
parentKey (str): Key to this seasons :class:`~plexapi.video.Show`.
|
||||
parentRatingKey (int): Unique key for this seasons :class:`~plexapi.video.Show`.
|
||||
parentTitle (str): Title of this seasons :class:`~plexapi.video.Show`.
|
||||
viewedLeafCount (int): Number of watched episodes in season.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
leafCount (int): Number of items in the season view.
|
||||
parentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6).
|
||||
parentIndex (int): Plex index number for the show.
|
||||
parentKey (str): API URL of the show (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the show.
|
||||
parentTheme (str): URL to show theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>).
|
||||
parentThumb (str): URL to show thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the show for the season.
|
||||
viewedLeafCount (int): Number of items marked as played in the season view.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'season'
|
||||
METADATA_TYPE = 'episode'
|
||||
|
||||
def __iter__(self):
|
||||
for episode in self.episodes():
|
||||
yield episode
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
# fix key if loaded from search
|
||||
self.key = self.key.replace('/children', '')
|
||||
art = data.attrib.get('art')
|
||||
self.art = art if art and str(self.ratingKey) in art else None
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentIndex = data.attrib.get('parentIndex')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
|
@ -547,7 +619,10 @@ class Season(Video):
|
|||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
|
||||
def __iter__(self):
|
||||
for episode in self.episodes():
|
||||
yield episode
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
|
@ -558,7 +633,7 @@ class Season(Video):
|
|||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if this season is fully watched. """
|
||||
""" Returns True if the season is fully watched. """
|
||||
return bool(self.viewedLeafCount == self.leafCount)
|
||||
|
||||
@property
|
||||
|
@ -567,9 +642,9 @@ class Season(Video):
|
|||
return self.index
|
||||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, **kwargs)
|
||||
return self.fetchItems(key, Episode, **kwargs)
|
||||
|
||||
def episode(self, title=None, episode=None):
|
||||
""" Returns the episode with the given title or number.
|
||||
|
@ -577,21 +652,34 @@ class Season(Video):
|
|||
Parameters:
|
||||
title (str): Title of the episode to return.
|
||||
episode (int): Episode number (default: None; required if title not specified).
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing.
|
||||
"""
|
||||
if not title and not episode:
|
||||
raise BadRequest('Missing argument, you need to use title or episode.')
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
if title:
|
||||
return self.fetchItem(key, title=title)
|
||||
return self.fetchItem(key, parentIndex=self.index, index=episode)
|
||||
if title is not None:
|
||||
return self.fetchItem(key, Episode, title__iexact=title)
|
||||
elif episode is not None:
|
||||
return self.fetchItem(key, Episode, parentIndex=self.index, index=episode)
|
||||
raise BadRequest('Missing argument: title or episode is required')
|
||||
|
||||
def get(self, title=None, episode=None):
|
||||
""" Alias to :func:`~plexapi.video.Season.episode`. """
|
||||
return self.episode(title, episode)
|
||||
|
||||
def onDeck(self):
|
||||
""" Returns season's On Deck :class:`~plexapi.video.Video` object or `None`.
|
||||
Will only return a match if the show's On Deck episode is in this season.
|
||||
"""
|
||||
data = self._server.query(self._details_key)
|
||||
episode = next(data.iter('OnDeck'), None)
|
||||
if episode:
|
||||
return self.findItems(episode)[0]
|
||||
return None
|
||||
|
||||
def show(self):
|
||||
""" Return this seasons :func:`~plexapi.video.Show`.. """
|
||||
return self.fetchItem(int(self.parentRatingKey))
|
||||
""" Return the season's :class:`~plexapi.video.Show`. """
|
||||
return self.fetchItem(self.parentRatingKey)
|
||||
|
||||
def watched(self):
|
||||
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
||||
|
@ -627,31 +715,32 @@ class Episode(Playable, Video):
|
|||
Attributes:
|
||||
TAG (str): 'Video'
|
||||
TYPE (str): 'episode'
|
||||
art (str): Key to episode artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
chapterSource (str): Unknown (media).
|
||||
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
||||
chapterSource (str): Chapter source (agent; media; mixed).
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
duration (int): Duration of episode in milliseconds.
|
||||
grandparentArt (str): Key to this episodes :class:`~plexapi.video.Show` artwork.
|
||||
grandparentKey (str): Key to this episodes :class:`~plexapi.video.Show`.
|
||||
grandparentRatingKey (str): Unique key for this episodes :class:`~plexapi.video.Show`.
|
||||
grandparentTheme (str): Key to this episodes :class:`~plexapi.video.Show` theme.
|
||||
grandparentThumb (str): Key to this episodes :class:`~plexapi.video.Show` thumb.
|
||||
grandparentTitle (str): Title of this episodes :class:`~plexapi.video.Show`.
|
||||
guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
|
||||
index (int): Episode number.
|
||||
originallyAvailableAt (datetime): Datetime episode was released.
|
||||
parentIndex (str): Season number of episode.
|
||||
parentKey (str): Key to this episodes :class:`~plexapi.video.Season`.
|
||||
parentRatingKey (int): Unique key for this episodes :class:`~plexapi.video.Season`.
|
||||
parentThumb (str): Key to this episodes thumbnail.
|
||||
parentTitle (str): Name of this episode's season
|
||||
title (str): Name of this Episode
|
||||
rating (float): Movie rating (7.9; 9.8; 8.1).
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year episode was released.
|
||||
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
|
||||
duration (int): Duration of the episode in milliseconds.
|
||||
grandparentArt (str): URL to show artwork (/library/metadata/<grandparentRatingKey>/art/<artid>).
|
||||
grandparentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6).
|
||||
grandparentKey (str): API URL of the show (/library/metadata/<grandparentRatingKey>).
|
||||
grandparentRatingKey (int): Unique key identifying the show.
|
||||
grandparentTheme (str): URL to show theme resource (/library/metadata/<grandparentRatingkey>/theme/<themeid>).
|
||||
grandparentThumb (str): URL to show thumbnail image (/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
|
||||
grandparentTitle (str): Name of the show for the episode.
|
||||
index (int): Episode number.
|
||||
markers (List<:class:`~plexapi.media.Marker`>): List of marker objects.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
originallyAvailableAt (datetime): Datetime the episode was released.
|
||||
parentGuid (str): Plex GUID for the season (plex://season/5d9c09e42df347001e3c2a72).
|
||||
parentIndex (int): Season number of episode.
|
||||
parentKey (str): API URL of the season (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the season.
|
||||
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the season for the episode.
|
||||
rating (float): Episode rating (7.9; 9.8; 8.1).
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||
year (int): Year episode was released.
|
||||
"""
|
||||
TAG = 'Video'
|
||||
TYPE = 'episode'
|
||||
|
@ -662,10 +751,10 @@ class Episode(Playable, Video):
|
|||
Video._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self._seasonNumber = None # cached season number
|
||||
art = data.attrib.get('art')
|
||||
self.art = art if art and str(self.ratingKey) in art else None
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.directors = self.findItems(data, media.Director)
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.grandparentArt = data.attrib.get('grandparentArt')
|
||||
self.grandparentGuid = data.attrib.get('grandparentGuid')
|
||||
|
@ -674,27 +763,20 @@ class Episode(Playable, Video):
|
|||
self.grandparentTheme = data.attrib.get('grandparentTheme')
|
||||
self.grandparentThumb = data.attrib.get('grandparentThumb')
|
||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.markers = self.findItems(data, media.Marker)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentIndex = data.attrib.get('parentIndex')
|
||||
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.title = data.attrib.get('title')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.directors = self.findItems(data, media.Director)
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.writers = self.findItems(data, media.Writer)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
self.markers = self.findItems(data, media.Marker)
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
|
@ -710,13 +792,16 @@ class Episode(Playable, Video):
|
|||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Episode
|
||||
interface to get the locations of the episode.
|
||||
|
||||
Retruns:
|
||||
List<str> of file paths where the episode is found on disk.
|
||||
"""
|
||||
return [part.file for part in self.iterParts() if part]
|
||||
|
||||
@property
|
||||
def seasonNumber(self):
|
||||
""" Returns this episodes season number. """
|
||||
""" Returns the episodes season number. """
|
||||
if self._seasonNumber is None:
|
||||
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
|
||||
return utils.cast(int, self._seasonNumber)
|
||||
|
@ -728,18 +813,18 @@ class Episode(Playable, Video):
|
|||
|
||||
@property
|
||||
def hasIntroMarker(self):
|
||||
""" Returns True if this episode has an intro marker in the xml. """
|
||||
""" Returns True if the episode has an intro marker in the xml. """
|
||||
if not self.isFullObject():
|
||||
self.reload()
|
||||
return any(marker.type == 'intro' for marker in self.markers)
|
||||
|
||||
def season(self):
|
||||
"""" Return this episodes :func:`~plexapi.video.Season`.. """
|
||||
"""" Return the episode's :class:`~plexapi.video.Season`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def show(self):
|
||||
"""" Return this episodes :func:`~plexapi.video.Show`.. """
|
||||
return self.fetchItem(int(self.grandparentRatingKey))
|
||||
"""" Return the episode's :class:`~plexapi.video.Show`. """
|
||||
return self.fetchItem(self.grandparentRatingKey)
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
|
@ -748,7 +833,22 @@ class Episode(Playable, Video):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class Clip(Playable, Video):
|
||||
""" Represents a single Clip."""
|
||||
"""Represents a single Clip.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Video'
|
||||
TYPE (str): 'clip'
|
||||
duration (int): Duration of the clip in milliseconds.
|
||||
extraType (int): Unknown.
|
||||
index (int): Plex index number for the clip.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
originallyAvailableAt (datetime): Datetime the clip was released.
|
||||
skipDetails (int): Unknown.
|
||||
subtype (str): Type of clip (trailer, behindTheScenes, sceneOrSample, etc.).
|
||||
thumbAspectRatio (str): Aspect ratio of the thumbnail image.
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year clip was released.
|
||||
"""
|
||||
|
||||
TAG = 'Video'
|
||||
TYPE = 'clip'
|
||||
|
@ -759,24 +859,23 @@ class Clip(Playable, Video):
|
|||
Video._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self._data = data
|
||||
self.addedAt = data.attrib.get('addedAt')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.key = data.attrib.get('key')
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.extraType = utils.cast(int, data.attrib.get('extraType'))
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.originallyAvailableAt = data.attrib.get('originallyAvailableAt')
|
||||
self.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))
|
||||
self.subtype = data.attrib.get('subtype')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.thumbAspectRatio = data.attrib.get('thumbAspectRatio')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.media = self.findItems(data, media.Media)
|
||||
|
||||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Clip
|
||||
interface to get the locations of the clip.
|
||||
|
||||
Retruns:
|
||||
List<str> of file paths where the clip is found on disk.
|
||||
"""
|
||||
return [part.file for part in self.iterParts() if part]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue