Update plexapi to 4.3.0

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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