python-plexapi/plexapi/video.py

862 lines
39 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
2020-05-28 01:53:04 +00:00
import os
from urllib.parse import quote_plus, urlencode
2020-05-24 03:20:22 +00:00
from plexapi import media, utils, settings, library
2020-07-15 20:15:35 +00:00
from plexapi.base import Playable, PlexPartialObject, MediaContainer
2020-05-28 01:53:04 +00:00
from plexapi.exceptions import BadRequest, NotFound
2014-12-29 03:21:58 +00:00
class Video(PlexPartialObject):
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
:class:`~plexapi.video.Episode`.
Attributes:
addedAt (datetime): Datetime this item was added to the library.
key (str): API URL (/library/metadata/<ratingkey>).
lastViewedAt (datetime): Datetime item was last accessed.
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
listType (str): Hardcoded as 'audio' (useful for search filters).
ratingKey (int): Unique key identifying this item.
summary (str): Summary of the artist, track, or album.
thumb (str): URL to thumbnail image.
title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.)
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'artist', 'album', or 'track'.
updatedAt (datatime): Datetime this item was updated.
viewCount (int): Count of times this item was accessed.
"""
2014-12-29 03:21:58 +00:00
def _loadData(self, data):
2017-02-13 19:38:40 +00:00
""" Load attribute values from Plex XML response. """
self._data = data
self.listType = 'video'
2017-02-04 17:43:50 +00:00
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
2018-03-02 19:13:38 +00:00
self.key = data.attrib.get('key', '')
2017-02-04 17:43:50 +00:00
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort', self.title)
2017-02-04 17:43:50 +00:00
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
2014-12-29 03:21:58 +00:00
2017-02-13 19:38:40 +00:00
@property
def isWatched(self):
""" Returns True if this video is watched. """
2017-09-29 16:10:55 +00:00
return bool(self.viewCount > 0) if self.viewCount else False
2017-09-29 16:11:14 +00:00
@property
def thumbUrl(self):
""" Return the first first thumbnail url starting on
the most specific thumbnail for that item.
"""
thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb')
return self._server.url(thumb, includeToken=True) if thumb else None
@property
def artUrl(self):
""" Return the first first art url starting on the most specific for that item."""
art = self.firstAttr('art', 'grandparentArt')
return self._server.url(art, includeToken=True) if art else None
def url(self, part):
""" Returns the full url for something. Typically used for getting a specific image. """
return self._server.url(part, includeToken=True) if part else None
2014-12-29 03:21:58 +00:00
def markWatched(self):
""" Mark video as watched. """
key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
self._server.query(key)
2014-12-29 03:21:58 +00:00
self.reload()
def markUnwatched(self):
""" Mark video unwatched. """
key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
self._server.query(key)
2014-12-29 03:21:58 +00:00
self.reload()
2020-07-15 20:07:02 +00:00
def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """
2020-07-16 09:27:44 +00:00
items = []
2020-07-15 20:07:02 +00:00
data = self._server.query(self._details_key)
2020-07-16 09:27:44 +00:00
for item in data.iter('Hub'):
items.append(library.Hub(data=item, server=self._server))
return items
2020-07-15 20:07:02 +00:00
def augmentation(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects.
augmentation returns hub items relating to online media sources
such as Tidal Music "Track From {item}" or "Soundtrack of {item}"
Plex Pass and linked Tidal account are required
"""
account = self._server.myPlexAccount()
tidalOptOut = [service.value for service in account.onlineMediaSources()
if service.key.endswith('music')][0]
if account.subscriptionStatus != 'Active' or tidalOptOut == 'opt_out':
raise BadRequest('Requires Plex Pass and Tidal Music enabled.')
data = self._server.query(self.key + '?asyncAugmentMetadata=1')
mediaContainer = MediaContainer(data=data, server=self._server)
augmentationKey = mediaContainer.augmentationKey
return self.fetchItems(augmentationKey)
2019-08-06 19:53:31 +00:00
def rate(self, rate):
""" Rate video. """
key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rate)
self._server.query(key)
self.reload()
2018-09-08 15:25:16 +00:00
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return self.title
def subtitleStreams(self):
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """
streams = []
parts = self.iterParts()
for part in parts:
streams += part.subtitleStreams()
return streams
def uploadSubtitles(self, filepath):
""" Upload Subtitle file for video. """
url = '%s/subtitles' % self.key
filename = os.path.basename(filepath)
subFormat = os.path.splitext(filepath)[1][1:]
with open(filepath, 'rb') as subfile:
params = {'title': filename,
'format': subFormat
}
headers = {'Accept': 'text/plain, */*'}
self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers)
def removeSubtitles(self, streamID=None, streamTitle=None):
""" Remove Subtitle from movie's subtitles listing.
Note: If subtitle file is located inside video directory it will bbe deleted.
Files outside of video directory are not effected.
"""
for stream in self.subtitleStreams():
if streamID == stream.id or streamTitle == stream.title:
self._server.query(stream.key, self._server._session.delete)
2020-01-30 15:55:29 +00:00
def optimize(self, title=None, target="", targetTagID=None, locationID=-1, policyScope='all',
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None):
""" Optimize item
locationID (int): -1 in folder with orginal items
2 library path
target (str): custom quality name.
if none provided use "Custom: {deviceProfile}"
targetTagID (int): Default quality settings
1 Mobile
2 TV
3 Original Quality
deviceProfile (str): Android, IOS, Universal TV, Universal Mobile, Windows Phone,
Windows, Xbox One
Example:
Optimize for Mobile
item.optimize(targetTagID="Mobile") or item.optimize(targetTagID=1")
Optimize for Android 10 MBPS 1080p
item.optimize(deviceProfile="Android", videoQuality=10)
Optimize for IOS Original Quality
item.optimize(deviceProfile="IOS", videoQuality=-1)
* see sync.py VIDEO_QUALITIES for additional information for using videoQuality
"""
2020-01-30 16:25:45 +00:00
tagValues = [1, 2, 3]
2020-01-30 15:55:29 +00:00
tagKeys = ["Mobile", "TV", "Original Quality"]
tagIDs = tagKeys + tagValues
if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None):
2020-01-30 15:55:29 +00:00
raise BadRequest('Unexpected or missing quality profile.')
if isinstance(targetTagID, str):
tagIndex = tagKeys.index(targetTagID)
targetTagID = tagValues[tagIndex]
if title is None:
title = self.title
2020-01-30 15:55:29 +00:00
2020-02-17 22:17:36 +00:00
backgroundProcessing = self.fetchItem('/playlists?type=42')
key = '%s/items?' % backgroundProcessing.key
params = {
'Item[type]': 42,
'Item[target]': target,
'Item[targetTagID]': targetTagID if targetTagID else '',
'Item[locationID]': locationID,
'Item[Policy][scope]': policyScope,
'Item[Policy][value]': policyValue,
'Item[Policy][unwatched]': policyUnwatched
}
2020-01-30 15:55:29 +00:00
if deviceProfile:
params['Item[Device][profile]'] = deviceProfile
2020-01-30 15:55:29 +00:00
if videoQuality:
from plexapi.sync import MediaSettings
2020-01-30 15:55:29 +00:00
mediaSettings = MediaSettings.createVideo(videoQuality)
params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality
params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution
params['Item[MediaSettings][maxVideoBitrate]'] = mediaSettings.maxVideoBitrate
params['Item[MediaSettings][audioBoost]'] = ''
params['Item[MediaSettings][subtitleSize]'] = ''
params['Item[MediaSettings][musicBitrate]'] = ''
params['Item[MediaSettings][photoQuality]'] = ''
titleParam = {'Item[title]': title}
section = self._server.library.sectionByID(self.librarySectionID)
params['Item[Location][uri]'] = 'library://' + section.uuid + '/item/' + \
quote_plus(self.key + '?includeExternalMedia=1')
2020-01-30 15:55:29 +00:00
data = key + urlencode(params) + '&' + urlencode(titleParam)
2020-01-30 16:28:33 +00:00
return self._server.query(data, method=self._server._session.put)
2020-01-30 15:55:29 +00:00
2018-09-08 15:25:16 +00:00
def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None):
""" Add current video (movie, tv-show, season or episode) as sync item for specified device.
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
Parameters:
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
:mod:`plexapi.sync` module.
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
:func:`plexapi.myplex.MyPlexAccount.sync`.
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
limit (int): maximum count of items to sync, unlimited if `None`.
unwatched (bool): if `True` watched videos wouldn't be synced.
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
generated from metadata of current media.
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
"""
from plexapi.sync import SyncItem, Policy, MediaSettings
myplex = self._server.myPlexAccount()
sync_item = SyncItem(self._server, None)
sync_item.title = title if title else self._defaultSyncTitle()
sync_item.rootTitle = self.title
sync_item.contentType = self.listType
sync_item.metadataType = self.METADATA_TYPE
sync_item.machineIdentifier = self._server.machineIdentifier
section = self._server.library.sectionByID(self.librarySectionID)
sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key))
sync_item.policy = Policy.create(limit, unwatched)
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
return myplex.sync(sync_item, client=client, clientId=clientId)
@utils.registerPlexObject
class Movie(Playable, Video):
""" Represents a single Movie.
Attributes:
2018-03-02 17:43:31 +00:00
TAG (str): 'Video'
2017-02-13 19:38:40 +00:00
TYPE (str): 'movie'
art (str): Key to movie artwork (/library/metadata/<ratingkey>/art/<artid>)
audienceRating (float): Audience rating (usually from Rotten Tomatoes).
audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled)
chapterSource (str): Chapter source (agent; media; mixed).
contentRating (str) Content rating (PG-13; NR; TV-G).
duration (int): Duration of movie in milliseconds.
guid: Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
originalTitle (str): Original title, often the foreign title (転々; 엽기적인 그녀).
originallyAvailableAt (datetime): Datetime movie was released.
primaryExtraKey (str) Primary extra key (/library/metadata/66351).
rating (float): Movie rating (7.9; 9.8; 8.1).
ratingImage (str): Key to rating image (rottentomatoes://image.rating.rotten).
studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment).
tagline (str): Movie tag line (Back 2 Work; Who says men can't change?).
userRating (float): User rating (2.0; 8.0).
viewOffset (int): View offset in milliseconds.
year (int): Year movie was released.
2017-02-13 19:38:40 +00:00
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.
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.
2018-03-02 12:15:15 +00:00
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
2018-03-02 17:43:31 +00:00
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
"""
TAG = 'Video'
2014-12-29 03:21:58 +00:00
TYPE = 'movie'
2018-09-08 15:25:16 +00:00
METADATA_TYPE = 'movie'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeConcerts=1&includePreferences=1')
2014-12-29 03:21:58 +00:00
def _loadData(self, data):
2017-02-13 19:38:40 +00:00
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
Playable._loadData(self, data)
2018-03-02 12:15:15 +00:00
self._details_key = self.key + self._include
2017-02-04 17:43:50 +00:00
self.art = data.attrib.get('art')
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
2017-02-04 17:43:50 +00:00
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
self.chapterSource = data.attrib.get('chapterSource')
self.contentRating = data.attrib.get('contentRating')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.guid = data.attrib.get('guid')
self.originalTitle = data.attrib.get('originalTitle')
2016-12-16 23:38:08 +00:00
self.originallyAvailableAt = utils.toDatetime(
2017-02-04 17:43:50 +00:00
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
self.rating = utils.cast(float, data.attrib.get('rating'))
2017-02-04 17:43:50 +00:00
self.ratingImage = data.attrib.get('ratingImage')
self.studio = data.attrib.get('studio')
self.tagline = data.attrib.get('tagline')
self.userRating = utils.cast(float, data.attrib.get('userRating'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
2017-02-04 17:43:50 +00:00
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.media = self.findItems(data, media.Media)
self.producers = self.findItems(data, media.Producer)
self.roles = self.findItems(data, media.Role)
self.writers = self.findItems(data, media.Writer)
self.labels = self.findItems(data, media.Label)
2018-03-02 12:15:15 +00:00
self.chapters = self.findItems(data, media.Chapter)
self.similar = self.findItems(data, media.Similar)
2016-12-16 23:38:08 +00:00
@property
def actors(self):
2017-02-13 19:38:40 +00:00
""" Alias to self.roles. """
return self.roles
2016-12-16 23:38:08 +00:00
2017-01-09 14:21:54 +00:00
@property
def locations(self):
2017-01-09 14:21:54 +00:00
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Movie/Show/Episode
"""
return [part.file for part in self.iterParts() if part]
def _prettyfilename(self):
# This is just for compat.
return self.title
def reviews(self):
""" Returns a list of :class:`~plexapi.media.Review` objects. """
items = []
data = self._server.query(self.key + '?includeReviews=1')
for item in data.iter('Review'):
items.append(media.Review(data=item, server=self._server))
return items
2020-07-15 18:42:23 +00:00
def extras(self):
""" Returns a list of :class:`~plexapi.video.Extra` objects. """
items = []
data = self._server.query(self._details_key)
for extra in data.iter('Extras'):
for video in extra.iter('Video'):
items.append(Extra(data=video, server=self._server))
return items
2019-01-07 13:04:53 +00:00
def download(self, savepath=None, keep_original_name=False, **kwargs):
2017-02-13 19:38:40 +00:00
""" Download video files to specified directory.
2017-02-13 19:38:40 +00:00
Parameters:
savepath (str): Defaults to current working dir.
2019-01-07 13:04:53 +00:00
keep_original_name (bool): True to keep the original file name otherwise
2017-02-13 19:38:40 +00:00
a friendlier is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
"""
filepaths = []
locations = [i for i in self.iterParts() if i]
for location in locations:
name = location.file
2019-01-07 13:04:53 +00:00
if not keep_original_name:
title = self.title.replace(' ', '.')
name = '%s.%s' % (title, location.container)
if kwargs is not None:
url = self.getStreamURL(**kwargs)
2017-01-09 14:21:54 +00:00
else:
self._server.url('%s?download=1' % location.key)
filepath = utils.download(url, self._server._token, filename=name,
2020-03-14 14:52:54 +00:00
savepath=savepath, session=self._server._session)
2017-08-18 19:44:40 +00:00
if filepath:
filepaths.append(filepath)
return filepaths
2017-01-09 14:21:54 +00:00
2014-12-29 03:21:58 +00:00
@utils.registerPlexObject
2014-12-29 03:21:58 +00:00
class Show(Video):
2017-02-13 19:38:40 +00:00
""" Represents a single Show (including all seasons and episodes).
Attributes:
2018-03-02 17:43:31 +00:00
TAG (str): 'Directory'
2017-02-13 19:38:40 +00:00
TYPE (str): 'show'
art (str): Key to show artwork (/library/metadata/<ratingkey>/art/<artid>)
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/art/<artid>)
childCount (int): Unknown.
contentRating (str) Content rating (PG-13; NR; TV-G).
collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs.
2017-02-13 19:38:40 +00:00
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.
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
2018-03-02 17:43:31 +00:00
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
2017-02-13 19:38:40 +00:00
"""
TAG = 'Directory'
2014-12-29 03:21:58 +00:00
TYPE = 'show'
2018-09-08 15:25:16 +00:00
METADATA_TYPE = 'episode'
2015-02-24 03:42:29 +00:00
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeMarkers=1&includeConcerts=1&includePreferences=1')
def __iter__(self):
for season in self.seasons():
yield season
2014-12-29 03:21:58 +00:00
def _loadData(self, data):
2017-02-13 19:38:40 +00:00
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
2017-02-13 19:38:40 +00:00
# fix key if loaded from search
2017-01-09 14:21:54 +00:00
self.key = self.key.replace('/children', '')
self._details_key = self.key + self._include
2017-02-04 17:43:50 +00:00
self.art = data.attrib.get('art')
self.banner = data.attrib.get('banner')
self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.contentRating = data.attrib.get('contentRating')
self.collections = self.findItems(data, media.Collection)
2017-02-04 17:43:50 +00:00
self.duration = utils.cast(int, data.attrib.get('duration'))
self.guid = data.attrib.get('guid')
self.index = data.attrib.get('index')
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
2017-02-13 03:15:47 +00:00
self.locations = self.listAttrs(data, 'path', etag='Location')
2016-12-16 23:38:08 +00:00
self.originallyAvailableAt = utils.toDatetime(
2017-02-04 17:43:50 +00:00
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.rating = utils.cast(float, data.attrib.get('rating'))
self.studio = data.attrib.get('studio')
self.theme = data.attrib.get('theme')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
2017-02-04 17:43:50 +00:00
self.year = utils.cast(int, data.attrib.get('year'))
self.genres = self.findItems(data, media.Genre)
self.roles = self.findItems(data, media.Role)
2017-10-09 13:40:46 +00:00
self.labels = self.findItems(data, media.Label)
self.similar = self.findItems(data, media.Similar)
@property
def actors(self):
2017-02-13 19:38:40 +00:00
""" Alias to self.roles. """
return self.roles
2016-12-16 23:38:08 +00:00
@property
def isWatched(self):
2017-02-13 19:38:40 +00:00
""" Returns True if this show is fully watched. """
return bool(self.viewedLeafCount == self.leafCount)
2014-12-29 03:21:58 +00:00
2020-05-24 03:07:44 +00:00
def preferences(self):
""" Returns a list of :class:`~plexapi.settings.Preferences` objects. """
items = []
data = self._server.query(self._details_key)
for item in data.iter('Preferences'):
for elem in item:
items.append(settings.Preferences(data=elem, server=self._server))
return items
2020-05-24 03:30:44 +00:00
def onDeck(self):
2020-05-25 02:24:32 +00:00
""" Returns shows On Deck :class:`~plexapi.video.Video` object.
If show is unwatched, return will likely be the first episode.
"""
2020-05-24 03:30:44 +00:00
data = self._server.query(self._details_key)
return self.findItems([item for item in data.iter('OnDeck')][0])[0]
2020-05-24 03:20:22 +00:00
2017-02-09 04:08:25 +00:00
def seasons(self, **kwargs):
2017-02-13 19:38:40 +00:00
""" Returns a list of :class:`~plexapi.video.Season` objects. """
Improvements in tests process (#297) * lets begin * skip plexpass tests if there is not plexpass on account * test new myplex attrubutes * bootstrap: proper photos organisation * fix rest of photos tests * fix myplex new attributes test * fix music bootstrap by setting agent to lastfm * fix sync tests * increase bootstrap timeout * remove timeout from .travis.yml * do not create playlist-style photoalbums in plex-bootstraptest.py * allow negative filtering in LibrarySection.search() * fix sync tests once again * use sendCrashReports in test_settings * fix test_settings * fix test_video * do not accept eula in bootstrap * fix PlexServer.isLatest() * add test against old version of PlexServer * fix MyPlexAccount.OutOut * add flag for one-time testing in Travis * fix test_library onDeck tests * fix more tests * use tqdm in plex-bootstraptest for media scanning progress * create sections one-by-one * update docs on AlertListener for timeline entries * fix plex-bootstraptest for server version 1.3.2 * display skip/xpass/xfail reasons * fix tests on 1.3 * wait for music to be fully processed in plex-bootstraptest * fix misplaced TEST_ACCOUNT_ONCE * fix test_myplex_users, not sure if in proper-way * add pytest-rerunfailures; mark test_myplex_optout as flaky * fix comment * Revert "add pytest-rerunfailures; mark test_myplex_optout as flaky" This reverts commit 580e4c95a758c92329d757eb2f3fc3bf44b26f09. * restart plex container on failure * add conftest.wait_until() and used where some retries are required * add more wait_until() usage in test_sync * fix managed user search * fix updating managed users in myplex * allow to add new servers to existent users * add new server to a shared user while bootstrapping * add some docs on testing process * perform few attemps when unable to get the claim token * unlock websocket-client in requirements_dev * fix docblock in tools/plex-teardowntest * do not hardcode mediapart size in test_video * remove cache:pip from travis * Revert "unlock websocket-client in requirements_dev" This reverts commit 0d536bd06dbdc4a4b869a1686f8cd008898859fe. * remove debug from server.py * improve webhook tests * fix type() check to isinstance() * remove excessive `else` branch due to Hellowlol advice * add `unknown` as allowed `myPlexMappingState` in test_server
2018-09-14 18:03:23 +00:00
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
return self.fetchItems(key, **kwargs)
2014-12-29 03:21:58 +00:00
2017-01-04 20:38:04 +00:00
def season(self, title=None):
""" Returns the season with the specified title or number.
2017-01-02 21:06:40 +00:00
Parameters:
title (str or int): Title or Number of the season to return.
2017-01-02 21:06:40 +00:00
"""
key = '/library/metadata/%s/children' % self.ratingKey
if isinstance(title, int):
return self.fetchItem(key, etag='Directory', index__iexact=str(title))
return self.fetchItem(key, etag='Directory', title__iexact=title)
2014-12-29 03:21:58 +00:00
2017-02-09 04:08:25 +00:00
def episodes(self, **kwargs):
2017-02-13 19:38:40 +00:00
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
key = '/library/metadata/%s/allLeaves' % self.ratingKey
2017-02-09 04:08:25 +00:00
return self.fetchItems(key, **kwargs)
2014-12-29 03:21:58 +00:00
2017-01-04 20:38:04 +00:00
def episode(self, title=None, season=None, episode=None):
2017-02-13 19:38:40 +00:00
""" Find a episode using a title or season and episode.
2017-02-13 19:38:40 +00:00
Parameters:
title (str): Title of the episode to return
season (int): Season number (default:None; required if title not specified).
episode (int): Episode number (default:None; required if title not specified).
Raises:
2018-10-03 10:09:43 +00:00
:class:`plexapi.exceptions.BadRequest`: If season and episode is missing.
:class:`plexapi.exceptions.NotFound`: If the episode is missing.
"""
2017-01-04 20:38:04 +00:00
if title:
key = '/library/metadata/%s/allLeaves' % self.ratingKey
return self.fetchItem(key, title__iexact=title)
elif season is not None and episode:
results = [i for i in self.episodes() if i.seasonNumber == season and i.index == episode]
2017-01-04 20:38:04 +00:00
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')
2014-12-29 03:21:58 +00:00
def watched(self):
2017-02-13 19:38:40 +00:00
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
return self.episodes(viewCount__gt=0)
def unwatched(self):
2017-02-13 19:38:40 +00:00
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
return self.episodes(viewCount=0)
2017-02-13 19:38:40 +00:00
def get(self, title=None, season=None, episode=None):
""" Alias to :func:`~plexapi.video.Show.episode()`. """
return self.episode(title, season, episode)
2014-12-29 03:21:58 +00:00
2019-01-07 13:04:53 +00:00
def download(self, savepath=None, keep_original_name=False, **kwargs):
2017-02-13 19:38:40 +00:00
""" Download video files to specified directory.
2017-02-13 19:38:40 +00:00
Parameters:
savepath (str): Defaults to current working dir.
2019-01-07 13:04:53 +00:00
keep_original_name (bool): True to keep the original file name otherwise
2017-02-13 19:38:40 +00:00
a friendlier is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
"""
filepaths = []
for episode in self.episodes():
2019-01-07 13:04:53 +00:00
filepaths += episode.download(savepath, keep_original_name, **kwargs)
return filepaths
2014-12-29 03:21:58 +00:00
@utils.registerPlexObject
2014-12-29 03:21:58 +00:00
class Season(Video):
2017-02-13 19:38:40 +00:00
""" Represents a single Show Season (including all episodes).
Attributes:
2018-03-02 17:43:31 +00:00
TAG (str): 'Directory'
2017-02-13 19:38:40 +00:00
TYPE (str): 'season'
leafCount (int): Number of episodes in season.
index (int): Season number.
parentKey (str): Key to this seasons :class:`~plexapi.video.Show`.
parentRatingKey (int): Unique key for this seasons :class:`~plexapi.video.Show`.
parentTitle (str): Title of this seasons :class:`~plexapi.video.Show`.
viewedLeafCount (int): Number of watched episodes in season.
"""
TAG = 'Directory'
2014-12-29 03:21:58 +00:00
TYPE = 'season'
2018-09-08 15:25:16 +00:00
METADATA_TYPE = 'episode'
2014-12-29 03:21:58 +00:00
def __iter__(self):
for episode in self.episodes():
yield episode
2014-12-29 03:21:58 +00:00
def _loadData(self, data):
2017-02-13 19:38:40 +00:00
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
2017-02-13 19:38:40 +00:00
# fix key if loaded from search
2017-01-09 14:21:54 +00:00
self.key = self.key.replace('/children', '')
2017-02-04 17:43:50 +00:00
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.index = utils.cast(int, data.attrib.get('index'))
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentTitle = data.attrib.get('parentTitle')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
2016-12-16 23:38:08 +00:00
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
self.__class__.__name__,
self.key.replace('/library/metadata/', '').replace('/children', ''),
2017-02-20 05:37:00 +00:00
'%s-s%s' % (self.parentTitle.replace(' ', '-')[:20], self.seasonNumber),
] if p])
@property
def isWatched(self):
2017-02-13 19:38:40 +00:00
""" Returns True if this season is fully watched. """
return bool(self.viewedLeafCount == self.leafCount)
2014-12-29 03:21:58 +00:00
@property
def seasonNumber(self):
2017-02-13 19:38:40 +00:00
""" Returns season number. """
return self.index
2017-02-09 04:08:25 +00:00
def episodes(self, **kwargs):
2017-02-13 19:38:40 +00:00
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, **kwargs)
2014-12-29 03:21:58 +00:00
2017-02-13 19:38:40 +00:00
def episode(self, title=None, episode=None):
""" Returns the episode with the given title or number.
2017-01-02 21:06:40 +00:00
Parameters:
title (str): Title of the episode to return.
2017-02-13 19:38:40 +00:00
episode (int): Episode number (default:None; required if title not specified).
2016-12-16 23:38:08 +00:00
"""
2017-02-13 19:38:40 +00:00
if not title and not episode:
raise BadRequest('Missing argument, you need to use title or episode.')
key = '/library/metadata/%s/children' % self.ratingKey
2017-01-04 20:38:04 +00:00
if title:
return self.fetchItem(key, title=title)
return self.fetchItem(key, parentIndex=self.index, index=episode)
2017-01-04 20:38:04 +00:00
2017-02-13 19:38:40 +00:00
def get(self, title=None, episode=None):
""" Alias to :func:`~plexapi.video.Season.episode()`. """
return self.episode(title, episode)
2014-12-29 03:21:58 +00:00
def show(self):
2017-02-13 19:38:40 +00:00
""" Return this seasons :func:`~plexapi.video.Show`.. """
return self.fetchItem(int(self.parentRatingKey))
2014-12-29 03:21:58 +00:00
def watched(self):
2017-02-13 19:38:40 +00:00
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
return self.episodes(watched=True)
def unwatched(self):
2017-02-13 19:38:40 +00:00
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
return self.episodes(watched=False)
2019-01-07 13:04:53 +00:00
def download(self, savepath=None, keep_original_name=False, **kwargs):
2017-02-13 19:38:40 +00:00
""" Download video files to specified directory.
2017-02-13 19:38:40 +00:00
Parameters:
savepath (str): Defaults to current working dir.
2019-01-07 13:04:53 +00:00
keep_original_name (bool): True to keep the original file name otherwise
2017-02-13 19:38:40 +00:00
a friendlier is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
"""
filepaths = []
for episode in self.episodes():
2019-01-07 13:04:53 +00:00
filepaths += episode.download(savepath, keep_original_name, **kwargs)
return filepaths
2018-09-08 15:25:16 +00:00
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return '%s - %s' % (self.parentTitle, self.title)
2014-12-29 03:21:58 +00:00
@utils.registerPlexObject
class Episode(Playable, Video):
2017-02-13 19:38:40 +00:00
""" Represents a single Shows Episode.
2017-02-13 19:38:40 +00:00
Attributes:
2018-03-02 17:43:31 +00:00
TAG (str): 'Video'
2017-02-13 19:38:40 +00:00
TYPE (str): 'episode'
art (str): Key to episode artwork (/library/metadata/<ratingkey>/art/<artid>)
chapterSource (str): Unknown (media).
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
2017-02-13 19:38:40 +00:00
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.
media (List<:class:`~plexapi.media.Media`>): List of media objects.
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
"""
TAG = 'Video'
2014-12-29 03:21:58 +00:00
TYPE = 'episode'
2018-09-08 15:25:16 +00:00
METADATA_TYPE = 'episode'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeMarkers=1&includeConcerts=1&includePreferences=1')
2014-12-29 03:21:58 +00:00
def _loadData(self, data):
2017-02-13 19:38:40 +00:00
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
Playable._loadData(self, data)
self._details_key = self.key + self._include
self._seasonNumber = None # cached season number
2017-02-04 17:43:50 +00:00
self.art = data.attrib.get('art')
self.chapterSource = data.attrib.get('chapterSource')
self.contentRating = data.attrib.get('contentRating')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentKey = data.attrib.get('grandparentKey')
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
self.grandparentTheme = data.attrib.get('grandparentTheme')
self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index'))
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.title = data.attrib.get('title')
2017-02-04 17:43:50 +00:00
self.rating = utils.cast(float, data.attrib.get('rating'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
2017-02-04 17:43:50 +00:00
self.year = utils.cast(int, data.attrib.get('year'))
self.directors = self.findItems(data, media.Director)
self.media = self.findItems(data, media.Media)
self.writers = self.findItems(data, media.Writer)
self.labels = self.findItems(data, media.Label)
self.collections = self.findItems(data, media.Collection)
self.chapters = self.findItems(data, media.Chapter)
self.markers = self.findItems(data, media.Marker)
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
self.__class__.__name__,
self.key.replace('/library/metadata/', '').replace('/children', ''),
'%s-%s' % (self.grandparentTitle.replace(' ', '-')[:20], self.seasonEpisode),
] if p])
2017-02-13 19:38:40 +00:00
def _prettyfilename(self):
""" Returns a human friendly filename. """
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
2017-02-13 19:38:40 +00:00
@property
2017-02-13 19:38:40 +00:00
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Movie/Show
"""
return [part.file for part in self.iterParts() if part]
2014-12-29 03:21:58 +00:00
@property
def seasonNumber(self):
2017-02-13 19:38:40 +00:00
""" Returns this episodes season number. """
if self._seasonNumber is None:
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
2017-01-04 20:38:04 +00:00
return utils.cast(int, self._seasonNumber)
@property
def seasonEpisode(self):
""" Returns the s00e00 string containing the season and episode. """
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.index).zfill(2))
@property
def hasIntroMarker(self):
""" Returns True if this episode has an intro marker in the xml. """
if not self.isFullObject():
self.reload()
2020-05-27 16:26:54 +00:00
return any(marker.type == 'intro' for marker in self.markers)
2014-12-29 03:21:58 +00:00
def season(self):
2017-02-13 19:38:40 +00:00
"""" Return this episodes :func:`~plexapi.video.Season`.. """
return self.fetchItem(self.parentKey)
2014-12-29 03:21:58 +00:00
def show(self):
2017-02-13 19:38:40 +00:00
"""" Return this episodes :func:`~plexapi.video.Show`.. """
return self.fetchItem(int(self.grandparentRatingKey))
2018-09-08 15:25:16 +00:00
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title)
2019-12-05 18:02:50 +00:00
@utils.registerPlexObject
class Clip(Playable, Video):
2020-03-14 14:52:54 +00:00
""" Represents a single Clip."""
2019-12-05 18:02:50 +00:00
TAG = 'Video'
TYPE = 'clip'
METADATA_TYPE = 'clip'
def _loadData(self, data):
self._data = data
self.addedAt = data.attrib.get('addedAt')
self.duration = data.attrib.get('duration')
self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key')
self.originallyAvailableAt = data.attrib.get('originallyAvailableAt')
self.ratingKey = data.attrib.get('ratingKey')
self.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))
self.subtype = data.attrib.get('subtype')
self.thumb = data.attrib.get('thumb')
self.thumbAspectRatio = data.attrib.get('thumbAspectRatio')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
2020-03-14 14:52:54 +00:00
self.year = data.attrib.get('year')
2020-08-30 05:11:26 +00:00
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
def section(self):
"""Return the :class:`~plexapi.library.LibrarySection` this item belongs to."""
# Clip payloads currently do not contain 'librarySectionID'.
# Return None to avoid unnecessary attribute lookup attempts.
return None
2020-07-15 18:41:52 +00:00
@utils.registerPlexObject
class Extra(Clip):
""" Represents a single Extra (trailer, behindTheScenes, etc).
Attributes:
TAG (str): 'Extras'
"""
TAG = 'Extras'
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.duration = utils.cast(int, data.attrib.get('duration'))
self.extraType = data.attrib.get('extraType')
self.index = data.attrib.get('index')
self.key = data.attrib.get('key', '')
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = utils.toDatetime(
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.subtype = data.attrib.get('subtype')
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
self.year = data.attrib.get('year')