Bump plexapi from 4.12.1 to 4.13.1 (#1888)

Bumps [plexapi](https://github.com/pkkid/python-plexapi) from 4.12.1 to 4.13.1.
- [Release notes](https://github.com/pkkid/python-plexapi/releases)
- [Commits](https://github.com/pkkid/python-plexapi/compare/4.12.1...4.13.1)

---
updated-dependencies:
- dependency-name: plexapi
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

[skip ci]
This commit is contained in:
dependabot[bot] 2022-11-12 17:29:35 -08:00 committed by GitHub
parent 3af08f0d07
commit e79da07973
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1791 additions and 724 deletions

View file

@ -1,21 +1,21 @@
# -*- coding: utf-8 -*-
import os
from urllib.parse import quote_plus, urlencode
from urllib.parse import quote_plus
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.base import Playable, PlexPartialObject, PlexSession
from plexapi.exceptions import BadRequest
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
WatchlistMixin
)
class Video(PlexPartialObject):
class Video(PlexPartialObject, PlayedUnplayedMixin):
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
@ -71,25 +71,10 @@ class Video(PlexPartialObject):
self.userRating = utils.cast(float, data.attrib.get('userRating'))
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
@property
def isWatched(self):
""" Returns True if this video is watched. """
return bool(self.viewCount > 0) if self.viewCount else False
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
def markWatched(self):
""" Mark the video as played. """
key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
self._server.query(key)
def markUnwatched(self):
""" Mark the video as unplayed. """
key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
self._server.query(key)
def augmentation(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects.
Augmentation returns hub items relating to online media sources
@ -132,7 +117,7 @@ class Video(PlexPartialObject):
def uploadSubtitles(self, filepath):
""" Upload Subtitle file for video. """
url = '%s/subtitles' % self.key
url = f'{self.key}/subtitles'
filename = os.path.basename(filepath)
subFormat = os.path.splitext(filepath)[1][1:]
with open(filepath, 'rb') as subfile:
@ -141,6 +126,7 @@ class Video(PlexPartialObject):
}
headers = {'Accept': 'text/plain, */*'}
self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers)
return self
def removeSubtitles(self, streamID=None, streamTitle=None):
""" Remove Subtitle from movie's subtitles listing.
@ -151,74 +137,103 @@ class Video(PlexPartialObject):
for stream in self.subtitleStreams():
if streamID == stream.id or streamTitle == stream.title:
self._server.query(stream.key, self._server._session.delete)
return self
def optimize(self, title=None, target="", targetTagID=None, locationID=-1, policyScope='all',
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None):
""" Optimize item
def optimize(self, title='', target='', deviceProfile='', videoQuality=None,
locationID=-1, limit=None, unwatched=False):
""" Create an optimized version of the video.
locationID (int): -1 in folder with original items
2 library path id
library path id is found in library.locations[i].id
Parameters:
title (str, optional): Title of the optimized video.
target (str, optional): Target quality profile:
"Optimized for Mobile" ("mobile"), "Optimized for TV" ("tv"), "Original Quality" ("original"),
or custom quality profile name (default "Custom: {deviceProfile}").
deviceProfile (str, optional): Custom quality device profile:
"Android", "iOS", "Universal Mobile", "Universal TV", "Windows Phone", "Windows", "Xbox One".
Required if ``target`` is custom.
videoQuality (int, optional): Index of the quality profile, one of ``VIDEO_QUALITY_*``
values defined in the :mod:`~plexapi.sync` module. Only used if ``target`` is custom.
locationID (int or :class:`~plexapi.library.Location`, optional): Default -1 for
"In folder with original items", otherwise a :class:`~plexapi.library.Location` object or ID.
See examples below.
limit (int, optional): Maximum count of items to optimize, unlimited if ``None``.
unwatched (bool, optional): ``True`` to only optimized unwatched videos.
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
Raises:
:exc:`~plexapi.exceptions.BadRequest`: Unknown quality profile target
or missing deviceProfile and videoQuality.
:exc:`~plexapi.exceptions.BadRequest`: Unknown location ID.
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
.. code-block:: python
# Optimize for mobile using defaults
video.optimize(target="mobile")
# Optimize for Android at 10 Mbps 1080p
from plexapi.sync import VIDEO_QUALITY_10_MBPS_1080p
video.optimize(deviceProfile="Android", videoQuality=sync.VIDEO_QUALITY_10_MBPS_1080p)
# Optimize for iOS at original quality in library location
from plexapi.sync import VIDEO_QUALITY_ORIGINAL
locations = plex.library.section("Movies")._locations()
video.optimize(deviceProfile="iOS", videoQuality=VIDEO_QUALITY_ORIGINAL, locationID=locations[0])
# Optimize for tv the next 5 unwatched episodes
show.optimize(target="tv", limit=5, unwatched=True)
"""
tagValues = [1, 2, 3]
tagKeys = ["Mobile", "TV", "Original Quality"]
tagIDs = tagKeys + tagValues
if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None):
raise BadRequest('Unexpected or missing quality profile.')
libraryLocationIDs = [location.id for location in self.section()._locations()]
libraryLocationIDs.append(-1)
if locationID not in libraryLocationIDs:
raise BadRequest('Unexpected library path ID. %s not in %s' %
(locationID, libraryLocationIDs))
if isinstance(targetTagID, str):
tagIndex = tagKeys.index(targetTagID)
targetTagID = tagValues[tagIndex]
if title is None:
title = self.title
from plexapi.library import Location
from plexapi.sync import Policy, MediaSettings
backgroundProcessing = self.fetchItem('/playlists?type=42')
key = '%s/items?' % backgroundProcessing.key
key = f'{backgroundProcessing.key}/items'
tags = {t.tag.lower(): t.id for t in self._server.library.tags('mediaProcessingTarget')}
# Additional keys for shorthand values
tags['mobile'] = tags['optimized for mobile']
tags['tv'] = tags['optimized for tv']
tags['original'] = tags['original quality']
targetTagID = tags.get(target.lower(), '')
if not targetTagID and (not deviceProfile or videoQuality is None):
raise BadRequest('Unknown quality profile target or missing deviceProfile and videoQuality.')
if targetTagID:
target = ''
elif deviceProfile and not target:
target = f'Custom: {deviceProfile}'
section = self.section()
libraryLocationIDs = [-1] + [location.id for location in section._locations()]
if isinstance(locationID, Location):
locationID = locationID.id
if locationID not in libraryLocationIDs:
raise BadRequest(f'Unknown location ID "{locationID}" not in {libraryLocationIDs}')
if isinstance(self, (Show, Season)):
uri = f'library:///directory/{quote_plus(f"{self.key}/children")}'
else:
uri = f'library://{section.uuid}/item/{quote_plus(self.key)}'
policy = Policy.create(limit, unwatched)
params = {
'Item[type]': 42,
'Item[title]': title or self._defaultSyncTitle(),
'Item[target]': target,
'Item[targetTagID]': targetTagID if targetTagID else '',
'Item[targetTagID]': targetTagID,
'Item[locationID]': locationID,
'Item[Policy][scope]': policyScope,
'Item[Policy][value]': policyValue,
'Item[Policy][unwatched]': policyUnwatched
'Item[Location][uri]': uri,
'Item[Policy][scope]': policy.scope,
'Item[Policy][value]': str(policy.value),
'Item[Policy][unwatched]': str(int(policy.unwatched)),
}
if deviceProfile:
params['Item[Device][profile]'] = deviceProfile
if videoQuality:
from plexapi.sync import MediaSettings
mediaSettings = MediaSettings.createVideo(videoQuality)
params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality
params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution
@ -227,14 +242,11 @@ class Video(PlexPartialObject):
params['Item[MediaSettings][subtitleSize]'] = ''
params['Item[MediaSettings][musicBitrate]'] = ''
params['Item[MediaSettings][photoQuality]'] = ''
params['Item[MediaSettings][photoResolution]'] = ''
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')
data = key + urlencode(params) + '&' + urlencode(titleParam)
return self._server.query(data, method=self._server._session.put)
url = key + utils.joinArgs(params)
self._server.query(url, method=self._server._session.put)
return self
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.
@ -267,7 +279,7 @@ class Video(PlexPartialObject):
section = self._server.library.sectionByID(self.librarySectionID)
sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key))
sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}'
sync_item.policy = Policy.create(limit, unwatched)
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
@ -279,7 +291,7 @@ class Movie(
Video, Playable,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
WatchlistMixin
@ -298,6 +310,7 @@ class Movie(
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
duration (int): Duration of the movie in milliseconds.
editionTitle (str): The edition title of the movie (e.g. Director's Cut, Extended Edition, etc.).
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.
@ -338,6 +351,7 @@ class Movie(
self.countries = self.findItems(data, media.Country)
self.directors = self.findItems(data, media.Director)
self.duration = utils.cast(int, data.attrib.get('duration'))
self.editionTitle = data.attrib.get('editionTitle')
self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.labels = self.findItems(data, media.Label)
@ -381,13 +395,23 @@ class Movie(
def _prettyfilename(self):
""" Returns a filename for use in download. """
return '%s (%s)' % (self.title, self.year)
return f'{self.title} ({self.year})'
def reviews(self):
""" Returns a list of :class:`~plexapi.media.Review` objects. """
data = self._server.query(self._details_key)
return self.findItems(data, media.Review, rtag='Video')
def editions(self):
""" Returns a list of :class:`~plexapi.video.Movie` objects
for other editions of the same movie.
"""
filters = {
'guid': self.guid,
'id!': self.ratingKey
}
return self.section().search(filters=filters)
@utils.registerPlexObject
class Show(
@ -499,8 +523,8 @@ class Show(
return self.roles
@property
def isWatched(self):
""" Returns True if the show is fully watched. """
def isPlayed(self):
""" Returns True if the show is fully played. """
return bool(self.viewedLeafCount == self.leafCount)
def onDeck(self):
@ -520,7 +544,7 @@ class Show(
Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing.
"""
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
key = f'{self.key}/children?excludeAllLeaves=1'
if title is not None and not isinstance(title, int):
return self.fetchItem(key, Season, title__iexact=title)
elif season is not None or isinstance(title, int):
@ -533,8 +557,8 @@ class Show(
def seasons(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Season` objects in the show. """
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
return self.fetchItems(key, Season, **kwargs)
key = f'{self.key}/children?excludeAllLeaves=1'
return self.fetchItems(key, Season, container_size=self.childCount, **kwargs)
def episode(self, title=None, season=None, episode=None):
""" Find a episode using a title or season and episode.
@ -547,7 +571,7 @@ class Show(
Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing.
"""
key = '/library/metadata/%s/allLeaves' % self.ratingKey
key = f'{self.key}/allLeaves'
if title is not None:
return self.fetchItem(key, Episode, title__iexact=title)
elif season is not None and episode is not None:
@ -556,7 +580,7 @@ class Show(
def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects in the show. """
key = '/library/metadata/%s/allLeaves' % self.ratingKey
key = f'{self.key}/allLeaves'
return self.fetchItems(key, Episode, **kwargs)
def get(self, title=None, season=None, episode=None):
@ -583,7 +607,7 @@ class Show(
"""
filepaths = []
for episode in self.episodes():
_savepath = os.path.join(savepath, 'Season %s' % str(episode.seasonNumber).zfill(2)) if subfolders else savepath
_savepath = os.path.join(savepath, f'Season {str(episode.seasonNumber).zfill(2)}') if subfolders else savepath
filepaths += episode.download(_savepath, keep_original_name, **kwargs)
return filepaths
@ -647,15 +671,17 @@ class Season(
yield episode
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
self.__class__.__name__,
self.key.replace('/library/metadata/', '').replace('/children', ''),
'%s-s%s' % (self.parentTitle.replace(' ', '-')[:20], self.seasonNumber),
] if p])
return '<{}>'.format(
':'.join([p for p in [
self.__class__.__name__,
self.key.replace('/library/metadata/', '').replace('/children', ''),
f"{self.parentTitle.replace(' ', '-')[:20]}-{self.seasonNumber}",
] if p])
)
@property
def isWatched(self):
""" Returns True if the season is fully watched. """
def isPlayed(self):
""" Returns True if the season is fully played. """
return bool(self.viewedLeafCount == self.leafCount)
@property
@ -665,7 +691,7 @@ class Season(
def episodes(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Episode` objects in the season. """
key = '/library/metadata/%s/children' % self.ratingKey
key = f'{self.key}/children'
return self.fetchItems(key, Episode, **kwargs)
def episode(self, title=None, episode=None):
@ -678,7 +704,7 @@ class Season(
Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing.
"""
key = '/library/metadata/%s/children' % self.ratingKey
key = f'{self.key}/children'
if title is not None and not isinstance(title, int):
return self.fetchItem(key, Episode, title__iexact=title)
elif episode is not None or isinstance(title, int):
@ -728,7 +754,7 @@ class Season(
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return '%s - %s' % (self.parentTitle, self.title)
return f'{self.parentTitle} - {self.title}'
@utils.registerPlexObject
@ -835,18 +861,20 @@ class Episode(
if not self.parentRatingKey and self.grandparentRatingKey:
self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey
if self.parentRatingKey:
self.parentKey = '/library/metadata/%s' % self.parentRatingKey
self.parentKey = f'/library/metadata/{self.parentRatingKey}'
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])
return '<{}>'.format(
':'.join([p for p in [
self.__class__.__name__,
self.key.replace('/library/metadata/', '').replace('/children', ''),
f"{self.grandparentTitle.replace(' ', '-')[:20]}-{self.seasonEpisode}",
] if p])
)
def _prettyfilename(self):
""" Returns a filename for use in download. """
return '%s - %s - %s' % (self.grandparentTitle, self.seasonEpisode, self.title)
return f'{self.grandparentTitle} - {self.seasonEpisode} - {self.title}'
@property
def actors(self):
@ -878,7 +906,7 @@ class Episode(
@property
def seasonEpisode(self):
""" Returns the s00e00 string containing the season and episode numbers. """
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2))
return f's{str(self.seasonNumber).zfill(2)}e{str(self.episodeNumber).zfill(2)}'
@property
def hasCommercialMarker(self):
@ -905,7 +933,7 @@ class Episode(
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title)
return f'{self.grandparentTitle} - {self.parentTitle} - ({self.seasonEpisode}) {self.title}'
@utils.registerPlexObject
@ -979,4 +1007,43 @@ class Extra(Clip):
def _prettyfilename(self):
""" Returns a filename for use in download. """
return '%s (%s)' % (self.title, self.subtype)
return f'{self.title} ({self.subtype})'
@utils.registerPlexObject
class MovieSession(PlexSession, Movie):
""" Represents a single Movie session
loaded from :func:`~plexapi.server.PlexServer.sessions`.
"""
_SESSIONTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Movie._loadData(self, data)
PlexSession._loadData(self, data)
@utils.registerPlexObject
class EpisodeSession(PlexSession, Episode):
""" Represents a single Episode session
loaded from :func:`~plexapi.server.PlexServer.sessions`.
"""
_SESSIONTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Episode._loadData(self, data)
PlexSession._loadData(self, data)
@utils.registerPlexObject
class ClipSession(PlexSession, Clip):
""" Represents a single Clip session
loaded from :func:`~plexapi.server.PlexServer.sessions`.
"""
_SESSIONTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Clip._loadData(self, data)
PlexSession._loadData(self, data)