mirror of
https://github.com/pkkid/python-plexapi
synced 2024-11-10 14:14:19 +00:00
Merge remote-tracking branch 'upstream/master' into feature/edit_tags
This commit is contained in:
commit
84f787a2cc
10 changed files with 334 additions and 253 deletions
|
@ -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:
|
||||
|
|
167
plexapi/base.py
167
plexapi/base.py
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue