Merge remote-tracking branch 'upstream/master' into feature/edit_tags

This commit is contained in:
JonnyWong16 2021-02-14 14:38:21 -08:00
commit 84f787a2cc
No known key found for this signature in database
GPG key ID: B1F1F9807184697A
10 changed files with 334 additions and 253 deletions

View file

@ -4,6 +4,7 @@ from urllib.parse import quote_plus
from plexapi import library, media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest
from plexapi.mixins import ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin
from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
@ -124,7 +125,8 @@ class Audio(PlexPartialObject):
@utils.registerPlexObject
class Artist(Audio, CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin):
class Artist(Audio, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin,
CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin):
""" Represents a single Artist.
Attributes:
@ -227,7 +229,8 @@ class Artist(Audio, CollectionMixin, CountryMixin, GenreMixin, MoodMixin, Simila
@utils.registerPlexObject
class Album(Audio, CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin):
class Album(Audio, ArtMixin, PosterMixin, UnmatchMatchMixin,
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin):
""" Represents a single Album.
Attributes:

View file

@ -7,7 +7,8 @@ from plexapi import log, utils
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
from plexapi.utils import tag_plural, tag_helper
DONT_RELOAD_FOR_KEYS = ['key', 'session']
DONT_RELOAD_FOR_KEYS = {'key', 'session'}
DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'}
OPERATORS = {
'exact': lambda v, q: v == q,
'iexact': lambda v, q: v.lower() == q.lower(),
@ -47,6 +48,7 @@ class PlexObject(object):
self._data = data
self._initpath = initpath or self.key
self._parent = weakref.ref(parent) if parent else None
self._details_key = None
if data is not None:
self._loadData(data)
self._details_key = self._buildDetailsKey()
@ -57,8 +59,11 @@ class PlexObject(object):
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p])
def __setattr__(self, attr, value):
# Don't overwrite an attr with None or [] unless it's a private variable
if value not in [None, []] or attr.startswith('_') or attr not in self.__dict__:
# Don't overwrite session specific attr with []
if attr in DONT_OVERWRITE_SESSION_KEYS and value == []:
value = getattr(self, attr, [])
# Don't overwrite an attr with None unless it's a private variable
if value is not None or attr.startswith('_') or attr not in self.__dict__:
self.__dict__[attr] = value
def _clean(self, value):
@ -113,15 +118,15 @@ class PlexObject(object):
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:
while obj and obj._parent is not None:
obj = obj._parent()
if obj._checkAttrs(obj._data, **kwargs):
if obj and obj._checkAttrs(obj._data, **kwargs):
return True
return False
@ -384,6 +389,7 @@ class PlexPartialObject(PlexObject):
value = super(PlexPartialObject, self).__getattribute__(attr)
# Check a few cases where we dont want to reload
if attr in DONT_RELOAD_FOR_KEYS: return value
if attr in DONT_OVERWRITE_SESSION_KEYS: return value
if attr.startswith('_'): return value
if value not in (None, []): return value
if self.isFullObject(): return value
@ -506,142 +512,6 @@ class PlexPartialObject(PlexObject):
"""
return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey)
def posters(self):
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('%s/posters' % self.key)
def uploadPoster(self, url=None, filepath=None):
""" Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '%s/posters?url=%s' % (self.key, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '%s/posters?' % self.key
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setPoster(self, poster):
""" Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
poster.select()
def arts(self):
""" Returns list of available art objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('%s/arts' % self.key)
def uploadArt(self, url=None, filepath=None):
""" Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/arts?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setArt(self, art):
""" Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
art.select()
def unmatch(self):
""" Unmatches metadata match from object. """
key = '/library/metadata/%s/unmatch' % self.ratingKey
self._server.query(key, method=self._server._session.put)
def matches(self, agent=None, title=None, year=None, language=None):
""" Return list of (:class:`~plexapi.media.SearchResult`) metadata matches.
Parameters:
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.)
title (str): Title of item to search for
year (str): Year of item to search in
language (str) : Language of item to search in
Examples:
1. video.matches()
2. video.matches(title="something", year=2020)
3. video.matches(title="something")
4. video.matches(year=2020)
5. video.matches(title="something", year="")
6. video.matches(title="", year=2020)
7. video.matches(title="", year="")
1. The default behaviour in Plex Web = no params in plexapi
2. Both title and year specified by user
3. Year automatically filled in
4. Title automatically filled in
5. Explicitly searches for title with blank year
6. Explicitly searches for blank title with year
7. I don't know what the user is thinking... return the same result as 1
For 2 to 7, the agent and language is automatically filled in
"""
key = '/library/metadata/%s/matches' % self.ratingKey
params = {'manual': 1}
if agent and not any([title, year, language]):
params['language'] = self.section().language
params['agent'] = utils.getAgentIdentifier(self.section(), agent)
else:
if any(x is not None for x in [agent, title, year, language]):
if title is None:
params['title'] = self.title
else:
params['title'] = title
if year is None:
params['year'] = self.year
else:
params['year'] = year
params['language'] = language or self.section().language
if agent is None:
params['agent'] = self.section().agent
else:
params['agent'] = utils.getAgentIdentifier(self.section(), agent)
key = key + '?' + urlencode(params)
data = self._server.query(key, method=self._server._session.get)
return self.findItems(data, initpath=key)
def fixMatch(self, searchResult=None, auto=False, agent=None):
""" Use match result to update show metadata.
Parameters:
auto (bool): True uses first match from matches
False allows user to provide the match
searchResult (:class:`~plexapi.media.SearchResult`): Search result from
~plexapi.base.matches()
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.)
"""
key = '/library/metadata/%s/match' % self.ratingKey
if auto:
autoMatch = self.matches(agent=agent)
if autoMatch:
searchResult = autoMatch[0]
else:
raise NotFound('No matches found using this agent: (%s:%s)' % (agent, autoMatch))
elif not searchResult:
raise NotFound('fixMatch() requires either auto=True or '
'searchResult=:class:`~plexapi.media.SearchResult`.')
params = {'guid': searchResult.guid,
'name': searchResult.name}
data = key + '?' + urlencode(params)
self._server.query(data, method=self._server._session.put)
# The photo tag cant be built atm. TODO
# def arts(self):
# part = '%s/arts' % self.key
# return self.fetchItem(part)
# def poster(self):
# part = '%s/posters' % self.key
# return self.fetchItem(part, etag='Photo')
class Playable(object):
""" This is a general place to store functions specific to media that is Playable.
@ -710,19 +580,6 @@ class Playable(object):
for part in item.parts:
yield part
def split(self):
"""Split a duplicate."""
key = '%s/split' % self.key
return self._server.query(key, method=self._server._session.put)
def merge(self, ratingKeys):
"""Merge duplicate items."""
if not isinstance(ratingKeys, list):
ratingKeys = str(ratingKeys).split(",")
key = '%s/merge?ids=%s' % (self.key, ','.join(ratingKeys))
return self._server.query(key, method=self._server._session.put)
def unmatch(self):
"""Unmatch a media file."""
key = '%s/unmatch' % self.key

View file

@ -4,6 +4,7 @@ from urllib.parse import quote, quote_plus, unquote, urlencode
from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils
from plexapi.base import OPERATORS, PlexObject, PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound
from plexapi.mixins import ArtMixin, PosterMixin
from plexapi.mixins import LabelMixin
from plexapi.settings import Setting
from plexapi.utils import deprecated
@ -1527,7 +1528,7 @@ class FirstCharacter(PlexObject):
@utils.registerPlexObject
class Collections(PlexPartialObject, LabelMixin):
class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin):
""" Represents a single Collection.
Attributes:
@ -1684,44 +1685,6 @@ class Collections(PlexPartialObject, LabelMixin):
part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key)
return self._server.query(part, method=self._server._session.put)
def posters(self):
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey)
def uploadPoster(self, url=None, filepath=None):
""" Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/posters?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setPoster(self, poster):
""" Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
poster.select()
def arts(self):
""" Returns list of available art objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey)
def uploadArt(self, url=None, filepath=None):
""" Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/arts?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setArt(self, art):
""" Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
art.select()
# def edit(self, **kwargs):
# TODO

View file

@ -807,20 +807,25 @@ class Style(MediaTag):
FILTER = 'style'
@utils.registerPlexObject
class Poster(PlexObject):
""" Represents a Poster.
class BasePosterArt(PlexObject):
""" Base class for all Poster and Art objects.
Attributes:
TAG (str): 'Photo'
key (str): API URL (/library/metadata/<ratingkey>).
provider (str): The source of the poster or art.
ratingKey (str): Unique key identifying the poster or art.
selected (bool): True if the poster or art is currently selected.
thumb (str): The URL to retrieve the poster or art thumbnail.
"""
TAG = 'Photo'
def _loadData(self, data):
self._data = data
self.key = data.attrib.get('key')
self.provider = data.attrib.get('provider')
self.ratingKey = data.attrib.get('ratingKey')
self.selected = data.attrib.get('selected')
self.selected = cast(bool, data.attrib.get('selected'))
self.thumb = data.attrib.get('thumb')
def select(self):
@ -832,6 +837,14 @@ class Poster(PlexObject):
pass
class Poster(BasePosterArt):
""" Represents a single Poster object. """
class Art(BasePosterArt):
""" Represents a single Art object. """
@utils.registerPlexObject
class Producer(MediaTag):
""" Represents a single Producer media tag.

View file

@ -1,4 +1,184 @@
# -*- coding: utf-8 -*-
from urllib.parse import quote_plus, urlencode
from plexapi import media, utils
from plexapi.exceptions import NotFound
class ArtMixin(object):
""" Mixin for Plex objects that can have artwork."""
def arts(self):
""" Returns list of available :class:`~plexapi.media.Art` objects. """
return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=media.Art)
def uploadArt(self, url=None, filepath=None):
""" Upload art from url or filepath and set it as the selected art.
Parameters:
url (str): The full URL to the image to upload.
filepath (str): The full file path the the image to upload.
"""
if url:
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/arts?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setArt(self, art):
""" Set the artwork for a Plex object.
Parameters:
art (:class:`~plexapi.media.Art`): The art object to select.
"""
art.select()
class PosterMixin(object):
""" Mixin for Plex objects that can have posters."""
def posters(self):
""" Returns list of available :class:`~plexapi.media.Poster` objects. """
return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=media.Poster)
def uploadPoster(self, url=None, filepath=None):
""" Upload poster from url or filepath and set it as the selected poster.
Parameters:
url (str): The full URL to the image to upload.
filepath (str): The full file path the the image to upload.
"""
if url:
key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/posters?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setPoster(self, poster):
""" Set the poster for a Plex object.
Parameters:
poster (:class:`~plexapi.media.Poster`): The poster object to select.
"""
poster.select()
class SplitMergeMixin(object):
""" Mixin for Plex objects that can be split and merged."""
def split(self):
""" Split duplicated Plex object into separate objects. """
key = '/library/metadata/%s/split' % self.ratingKey
return self._server.query(key, method=self._server._session.put)
def merge(self, ratingKeys):
""" Merge other Plex objects into the current object.
Parameters:
ratingKeys (list): A list of rating keys to merge.
"""
if not isinstance(ratingKeys, list):
ratingKeys = str(ratingKeys).split(',')
key = '%s/merge?ids=%s' % (self.key, ','.join([str(r) for r in ratingKeys]))
return self._server.query(key, method=self._server._session.put)
class UnmatchMatchMixin(object):
""" Mixin for Plex objects that can be unmatched and matched."""
def unmatch(self):
""" Unmatches metadata match from object. """
key = '/library/metadata/%s/unmatch' % self.ratingKey
self._server.query(key, method=self._server._session.put)
def matches(self, agent=None, title=None, year=None, language=None):
""" Return list of (:class:`~plexapi.media.SearchResult`) metadata matches.
Parameters:
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.)
title (str): Title of item to search for
year (str): Year of item to search in
language (str) : Language of item to search in
Examples:
1. video.matches()
2. video.matches(title="something", year=2020)
3. video.matches(title="something")
4. video.matches(year=2020)
5. video.matches(title="something", year="")
6. video.matches(title="", year=2020)
7. video.matches(title="", year="")
1. The default behaviour in Plex Web = no params in plexapi
2. Both title and year specified by user
3. Year automatically filled in
4. Title automatically filled in
5. Explicitly searches for title with blank year
6. Explicitly searches for blank title with year
7. I don't know what the user is thinking... return the same result as 1
For 2 to 7, the agent and language is automatically filled in
"""
key = '/library/metadata/%s/matches' % self.ratingKey
params = {'manual': 1}
if agent and not any([title, year, language]):
params['language'] = self.section().language
params['agent'] = utils.getAgentIdentifier(self.section(), agent)
else:
if any(x is not None for x in [agent, title, year, language]):
if title is None:
params['title'] = self.title
else:
params['title'] = title
if year is None:
params['year'] = self.year
else:
params['year'] = year
params['language'] = language or self.section().language
if agent is None:
params['agent'] = self.section().agent
else:
params['agent'] = utils.getAgentIdentifier(self.section(), agent)
key = key + '?' + urlencode(params)
data = self._server.query(key, method=self._server._session.get)
return self.findItems(data, initpath=key)
def fixMatch(self, searchResult=None, auto=False, agent=None):
""" Use match result to update show metadata.
Parameters:
auto (bool): True uses first match from matches
False allows user to provide the match
searchResult (:class:`~plexapi.media.SearchResult`): Search result from
~plexapi.base.matches()
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.)
"""
key = '/library/metadata/%s/match' % self.ratingKey
if auto:
autoMatch = self.matches(agent=agent)
if autoMatch:
searchResult = autoMatch[0]
else:
raise NotFound('No matches found using this agent: (%s:%s)' % (agent, autoMatch))
elif not searchResult:
raise NotFound('fixMatch() requires either auto=True or '
'searchResult=:class:`~plexapi.media.SearchResult`.')
params = {'guid': searchResult.guid,
'name': searchResult.name}
data = key + '?' + urlencode(params)
self._server.query(data, method=self._server._session.put)
class CollectionMixin(object):
@ -221,7 +401,7 @@ class TagMixin(object):
self._edit_tags('tag', tags, locked=locked, remove=True)
class EditWriter(object):
class WriterMixin(object):
""" Mixin for Plex objects that can have writers. """
def addWriter(self, writers, locked=True):

View file

@ -5,12 +5,13 @@ from plexapi import utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection
from plexapi.mixins import ArtMixin, PosterMixin
from plexapi.playqueue import PlayQueue
from plexapi.utils import cast, toDatetime
@utils.registerPlexObject
class Playlist(PlexPartialObject, Playable):
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
""" Represents a single Playlist.
Attributes:
@ -311,41 +312,3 @@ class Playlist(PlexPartialObject, Playable):
raise Unsupported('Unsupported playlist content')
return myplex.sync(sync_item, client=client, clientId=clientId)
def posters(self):
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey)
def uploadPoster(self, url=None, filepath=None):
""" Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/posters?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setPoster(self, poster):
""" Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
poster.select()
def arts(self):
""" Returns list of available art objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey)
def uploadArt(self, url=None, filepath=None):
""" Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/arts?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setArt(self, art):
""" Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
art.select()

View file

@ -5,7 +5,8 @@ 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
from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, EditWriter
from plexapi.mixins import ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin
from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin
class Video(PlexPartialObject):
@ -260,7 +261,8 @@ class Video(PlexPartialObject):
@utils.registerPlexObject
class Movie(Playable, Video, CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, EditWriter):
class Movie(Video, Playable, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin):
""" Represents a single Movie.
Attributes:
@ -386,7 +388,8 @@ class Movie(Playable, Video, CollectionMixin, CountryMixin, DirectorMixin, Genre
@utils.registerPlexObject
class Show(Video, CollectionMixin, GenreMixin, LabelMixin):
class Show(Video, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin,
CollectionMixin, GenreMixin, LabelMixin):
""" Represents a single Show (including all seasons and episodes).
Attributes:
@ -404,6 +407,7 @@ class Show(Video, CollectionMixin, GenreMixin, LabelMixin):
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.
originalTitle (str): The original title of the show.
rating (float): Show rating (7.9; 9.8; 8.1).
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
@ -431,6 +435,7 @@ class Show(Video, CollectionMixin, GenreMixin, LabelMixin):
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.locations = self.listAttrs(data, 'path', etag='Location')
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.originalTitle = data.attrib.get('originalTitle')
self.rating = utils.cast(float, data.attrib.get('rating'))
self.roles = self.findItems(data, media.Role)
self.similar = self.findItems(data, media.Similar)
@ -584,7 +589,7 @@ class Show(Video, CollectionMixin, GenreMixin, LabelMixin):
@utils.registerPlexObject
class Season(Video):
class Season(Video, ArtMixin, PosterMixin):
""" Represents a single Show Season (including all episodes).
Attributes:
@ -710,7 +715,8 @@ class Season(Video):
@utils.registerPlexObject
class Episode(Playable, Video, DirectorMixin, EditWriter):
class Episode(Video, Playable, ArtMixin, PosterMixin,
DirectorMixin, WriterMixin):
""" Represents a single Shows Episode.
Attributes:
@ -739,6 +745,7 @@ class Episode(Playable, Video, DirectorMixin, EditWriter):
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).
skipParent (bool): True if the show's seasons are set to hidden.
viewOffset (int): View offset in milliseconds.
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
year (int): Year episode was released.
@ -775,10 +782,23 @@ class Episode(Playable, Video, DirectorMixin, EditWriter):
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.rating = utils.cast(float, data.attrib.get('rating'))
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.writers = self.findItems(data, media.Writer)
self.year = utils.cast(int, data.attrib.get('year'))
# If seasons are hidden, parentKey and parentRatingKey are missing from the XML response.
# https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553
if self.skipParent and not self.parentRatingKey:
# Parse the parentRatingKey from the parentThumb
if self.parentThumb.startswith('/library/metadata/'):
self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3])
# Get the parentRatingKey from the season's ratingKey
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
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
self.__class__.__name__,
@ -833,7 +853,7 @@ class Episode(Playable, Video, DirectorMixin, EditWriter):
@utils.registerPlexObject
class Clip(Playable, Video):
class Clip(Video, Playable):
"""Represents a single Clip.
Attributes:

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
import os
import time
from datetime import datetime
from functools import partial
from os import environ
import plexapi
import pytest
@ -66,6 +66,11 @@ TEST_ANONYMOUSLY = "anonymously"
ANON_PARAM = pytest.param(TEST_ANONYMOUSLY, marks=pytest.mark.anonymous)
AUTH_PARAM = pytest.param(TEST_AUTHENTICATED, marks=pytest.mark.authenticated)
BASE_DIR_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STUB_MOVIE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "video_stub.mp4")
STUB_MP3_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "audio_stub.mp3")
STUB_IMAGE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "cute_cat.jpg")
def pytest_addoption(parser):
parser.addoption(
@ -125,7 +130,7 @@ def account(sess):
@pytest.fixture(scope="session")
def account_once(account):
if environ.get("TEST_ACCOUNT_ONCE") not in ("1", "true") and environ.get("CI") == "true":
if os.environ.get("TEST_ACCOUNT_ONCE") not in ("1", "true") and os.environ.get("CI") == "true":
pytest.skip("Do not forget to test this by providing TEST_ACCOUNT_ONCE=1")
return account
@ -282,7 +287,7 @@ def subtitle():
@pytest.fixture()
def shared_username(account):
username = environ.get("SHARED_USERNAME", "PKKid")
username = os.environ.get("SHARED_USERNAME", "PKKid")
for user in account.users():
if user.title.lower() == username.lower():
return username

View file

@ -323,6 +323,16 @@ def test_library_Collection_thumbUrl(collection):
def test_library_Collection_artUrl(collection):
assert collection.artUrl is None # Collections don't have default art
def test_library_Collection_posters(collection):
posters = collection.posters()
assert posters
def test_library_Collection_art(collection):
arts = collection.arts()
assert not arts # Collection has no default art
def test_library_Collection_mixins_tags(collection):
test_mixins.edit_label(collection)

View file

@ -495,6 +495,50 @@ def test_video_Movie_match(movies):
assert len(results) == 0
def test_video_Movie_poster(movie):
posters = movie.posters()
poster = posters[0]
assert len(poster.key) >= 10
if not poster.ratingKey.startswith("media://"):
assert poster.provider
assert len(poster.ratingKey) >= 10
assert utils.is_bool(poster.selected)
assert len(poster.thumb) >= 10
# Select a different poster
movie.setPoster(posters[1])
posters = movie.posters()
assert posters[0].selected is False
assert posters[1].selected is True
# Test upload poster from file
movie.uploadPoster(filepath=utils.STUB_IMAGE_PATH)
posters = movie.posters()
file_poster = next(p for p in posters if p.ratingKey.startswith('upload://'))
assert file_poster.selected is True
movie.setPoster(posters[0]) # Reset to default poster
def test_video_Movie_art(movie):
arts = movie.arts()
art = arts[0]
assert len(art.key) >= 10
if not art.ratingKey.startswith("media://"):
assert art.provider
assert len(art.ratingKey) >= 10
assert utils.is_bool(art.selected)
assert len(art.thumb) >= 10
# Select a different art
movie.setArt(arts[1])
arts = movie.arts()
assert arts[0].selected is False
assert arts[1].selected is True
# Test upload poster from file
movie.uploadArt(filepath=utils.STUB_IMAGE_PATH)
arts = movie.arts()
file_art = next(a for a in arts if a.ratingKey.startswith('upload://'))
assert file_art.selected is True
movie.setArt(arts[0]) # Reset to default art
def test_video_Movie_hubs(movies):
movie = movies.get('Big Buck Bunny')
hubs = movie.hubs()
@ -523,10 +567,6 @@ def test_video_Show(show):
assert show.title == "Game of Thrones"
def test_video_Episode_split(episode, patched_http_call):
episode.split()
def test_video_Episode_unmatch(episode, patched_http_call):
episode.unmatch()
@ -570,6 +610,7 @@ def test_video_Show_attrs(show):
assert len(show.locations) == 1
assert len(show.locations[0]) >= 10
assert utils.is_datetime(show.originallyAvailableAt)
assert show.originalTitle is None
assert show.rating >= 8.0
assert utils.is_int(show.ratingKey)
assert sorted([i.tag for i in show.roles])[:4] == [
@ -744,6 +785,31 @@ def test_video_Episode_history(episode):
episode.markUnwatched()
def test_video_Episode_hidden_season(episode):
assert episode.skipParent is False
assert episode.parentRatingKey
assert episode.parentKey
assert episode.seasonNumber
show = episode.show()
show.editAdvanced(flattenSeasons=1)
episode.reload()
assert episode.skipParent is True
assert episode.parentRatingKey
assert episode.parentKey
assert episode.seasonNumber
show.defaultAdvanced()
def test_video_Episode_parent_weakref(show):
season = show.season(season=1)
episode = season.episode(episode=1)
assert episode._parent is not None
assert episode._parent() == season
episode = show.season(season=1).episode(episode=1)
assert episode._parent is not None
assert episode._parent() is None
# Analyze seems to fail intermittently
@pytest.mark.xfail
def test_video_Episode_analyze(tvshows):
@ -770,6 +836,7 @@ def test_video_Episode_attrs(episode):
assert episode.rating >= 7.7
assert utils.is_int(episode.ratingKey)
assert episode._server._baseurl == utils.SERVER_BASEURL
assert episode.skipParent is False
assert utils.is_string(episode.summary, gte=100)
assert utils.is_metadata(episode.thumb, contains="/thumb/")
assert episode.title == "Winter Is Coming"