mirror of
https://github.com/pkkid/python-plexapi
synced 2024-11-25 13:10:17 +00:00
Merge branch 'master' into reviews_extras
This commit is contained in:
commit
9d2ec9546e
29 changed files with 1380 additions and 765 deletions
|
@ -1,7 +1,6 @@
|
|||
Table of Contents
|
||||
=================
|
||||
.. include:: toc.rst
|
||||
.. automodule:: myplex
|
||||
|
||||
Usage & Contributions
|
||||
---------------------
|
||||
|
|
7
docs/modules/collection.rst
Normal file
7
docs/modules/collection.rst
Normal file
|
@ -0,0 +1,7 @@
|
|||
.. include:: ../global.rst
|
||||
|
||||
Collection :modname:`plexapi.collection`
|
||||
----------------------------------------
|
||||
.. automodule:: plexapi.collection
|
||||
:members:
|
||||
:show-inheritance:
|
7
docs/modules/mixins.rst
Normal file
7
docs/modules/mixins.rst
Normal file
|
@ -0,0 +1,7 @@
|
|||
.. include:: ../global.rst
|
||||
|
||||
Mixins :modname:`plexapi.mixins`
|
||||
--------------------------------
|
||||
.. automodule:: plexapi.mixins
|
||||
:members:
|
||||
:show-inheritance:
|
|
@ -15,11 +15,13 @@
|
|||
modules/audio
|
||||
modules/base
|
||||
modules/client
|
||||
modules/collection
|
||||
modules/config
|
||||
modules/exceptions
|
||||
modules/gdm
|
||||
modules/library
|
||||
modules/media
|
||||
modules/mixins
|
||||
modules/myplex
|
||||
modules/photo
|
||||
modules/playlist
|
||||
|
|
|
@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH)
|
|||
|
||||
# PlexAPI Settings
|
||||
PROJECT = 'PlexAPI'
|
||||
VERSION = '4.3.0'
|
||||
VERSION = '4.4.0'
|
||||
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
|
||||
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
|
||||
X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)
|
||||
|
|
|
@ -84,4 +84,4 @@ class AlertListener(threading.Thread):
|
|||
This is to support compatibility with current and previous releases of websocket-client.
|
||||
"""
|
||||
err = args[-1]
|
||||
log.error('AlertListener Error: %s' % err)
|
||||
log.error('AlertListener Error: %s', err)
|
||||
|
|
|
@ -4,6 +4,9 @@ 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 ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin
|
||||
from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin
|
||||
from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
|
||||
|
||||
|
||||
class Audio(PlexPartialObject):
|
||||
|
@ -65,18 +68,6 @@ class Audio(PlexPartialObject):
|
|||
self.userRating = utils.cast(float, data.attrib.get('userRating', 0))
|
||||
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
""" Return url to for the thumbnail image. """
|
||||
key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb')
|
||||
return self._server.url(key, includeToken=True) if key else None
|
||||
|
||||
@property
|
||||
def artUrl(self):
|
||||
""" Return the 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 the audio item. Typically used for getting a specific track. """
|
||||
return self._server.url(part, includeToken=True) if part else None
|
||||
|
@ -123,7 +114,8 @@ class Audio(PlexPartialObject):
|
|||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Artist(Audio):
|
||||
class Artist(Audio, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin,
|
||||
CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin):
|
||||
""" Represents a single Artist.
|
||||
|
||||
Attributes:
|
||||
|
@ -226,7 +218,8 @@ class Artist(Audio):
|
|||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Album(Audio):
|
||||
class Album(Audio, ArtMixin, PosterMixin, UnmatchMatchMixin,
|
||||
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin):
|
||||
""" Represents a single Album.
|
||||
|
||||
Attributes:
|
||||
|
@ -332,7 +325,7 @@ class Album(Audio):
|
|||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Track(Audio, Playable):
|
||||
class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, MoodMixin):
|
||||
""" Represents a single Track.
|
||||
|
||||
Attributes:
|
||||
|
|
225
plexapi/base.py
225
plexapi/base.py
|
@ -5,9 +5,10 @@ from urllib.parse import quote_plus, urlencode
|
|||
|
||||
from plexapi import log, utils
|
||||
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
|
||||
from plexapi.utils import tag_helper
|
||||
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):
|
||||
|
@ -119,9 +124,9 @@ class PlexObject(object):
|
|||
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
|
||||
|
||||
|
@ -227,7 +232,7 @@ class PlexObject(object):
|
|||
def firstAttr(self, *attrs):
|
||||
""" Return the first attribute in attrs that is not None. """
|
||||
for attr in attrs:
|
||||
value = self.__dict__.get(attr)
|
||||
value = getattr(self, attr, None)
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
|
@ -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
|
||||
|
@ -391,7 +397,7 @@ class PlexPartialObject(PlexObject):
|
|||
clsname = self.__class__.__name__
|
||||
title = self.__dict__.get('title', self.__dict__.get('name'))
|
||||
objname = "%s '%s'" % (clsname, title) if title else clsname
|
||||
log.debug("Reloading %s for attr '%s'" % (objname, attr))
|
||||
log.debug("Reloading %s for attr '%s'", objname, attr)
|
||||
# Reload and return the value
|
||||
self.reload()
|
||||
return super(PlexPartialObject, self).__getattribute__(attr)
|
||||
|
@ -452,49 +458,20 @@ class PlexPartialObject(PlexObject):
|
|||
self._server.query(part, method=self._server._session.put)
|
||||
|
||||
def _edit_tags(self, tag, items, locked=True, remove=False):
|
||||
""" Helper to edit and refresh a tags.
|
||||
""" Helper to edit tags.
|
||||
|
||||
Parameters:
|
||||
tag (str): tag name
|
||||
items (list): list of tags to add
|
||||
locked (bool): lock this field.
|
||||
remove (bool): If this is active remove the tags in items.
|
||||
tag (str): Tag name.
|
||||
items (list): List of tags to add.
|
||||
locked (bool): True to lock the field.
|
||||
remove (bool): True to remove the tags in items.
|
||||
"""
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
value = getattr(self, tag + 's')
|
||||
existing_cols = [t.tag for t in value if t and remove is False]
|
||||
d = tag_helper(tag, existing_cols + items, locked, remove)
|
||||
self.edit(**d)
|
||||
self.refresh()
|
||||
|
||||
def addCollection(self, collections):
|
||||
""" Add a collection(s).
|
||||
|
||||
Parameters:
|
||||
collections (list): list of strings
|
||||
"""
|
||||
self._edit_tags('collection', collections)
|
||||
|
||||
def removeCollection(self, collections):
|
||||
""" Remove a collection(s). """
|
||||
self._edit_tags('collection', collections, remove=True)
|
||||
|
||||
def addLabel(self, labels):
|
||||
""" Add a label(s). """
|
||||
self._edit_tags('label', labels)
|
||||
|
||||
def removeLabel(self, labels):
|
||||
""" Remove a label(s). """
|
||||
self._edit_tags('label', labels, remove=True)
|
||||
|
||||
def addGenre(self, genres):
|
||||
""" Add a genre(s). """
|
||||
self._edit_tags('genre', genres)
|
||||
|
||||
def removeGenre(self, genres):
|
||||
""" Remove a genre(s). """
|
||||
self._edit_tags('genre', genres, remove=True)
|
||||
value = getattr(self, tag_plural(tag))
|
||||
existing_tags = [t.tag for t in value if t and remove is False]
|
||||
tag_edits = tag_helper(tag, existing_tags + items, locked, remove)
|
||||
self.edit(**tag_edits)
|
||||
|
||||
def refresh(self):
|
||||
""" Refreshing a Library or individual item causes the metadata for the item to be
|
||||
|
@ -524,7 +501,7 @@ class PlexPartialObject(PlexObject):
|
|||
return self._server.query(self.key, method=self._server._session.delete)
|
||||
except BadRequest: # pragma: no cover
|
||||
log.error('Failed to delete %s. This could be because you '
|
||||
'havnt allowed items to be deleted' % self.key)
|
||||
'have not allowed items to be deleted', self.key)
|
||||
raise
|
||||
|
||||
def history(self, maxresults=9999999, mindate=None):
|
||||
|
@ -535,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.
|
||||
|
@ -739,24 +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
|
||||
return self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def play(self, client):
|
||||
""" Start playback on the specified client.
|
||||
|
||||
|
|
155
plexapi/collection.py
Normal file
155
plexapi/collection.py
Normal file
|
@ -0,0 +1,155 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import PlexPartialObject
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.mixins import ArtMixin, PosterMixin
|
||||
from plexapi.mixins import LabelMixin
|
||||
from plexapi.settings import Setting
|
||||
from plexapi.utils import deprecated
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin):
|
||||
""" Represents a single Collection.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'collection'
|
||||
addedAt (datetime): Datetime the collection was added to the library.
|
||||
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||
artBlurHash (str): BlurHash string for artwork image.
|
||||
childCount (int): Number of items in the collection.
|
||||
collectionMode (str): How the items in the collection are displayed.
|
||||
collectionSort (str): How to sort the items in the collection.
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the collection (collection://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX).
|
||||
index (int): Plex index number for the collection.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
maxYear (int): Maximum year for the items in the collection.
|
||||
minYear (int): Minimum year for the items in the collection.
|
||||
ratingKey (int): Unique key identifying the collection.
|
||||
subtype (str): Media type of the items in the collection (movie, show, artist, or album).
|
||||
summary (str): Summary of the collection.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
thumbBlurHash (str): BlurHash string for thumbnail image.
|
||||
title (str): Name of the collection.
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'collection'
|
||||
updatedAt (datatime): Datetime the collection was updated.
|
||||
"""
|
||||
|
||||
TAG = 'Directory'
|
||||
TYPE = 'collection'
|
||||
|
||||
def _loadData(self, data):
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.artBlurHash = data.attrib.get('artBlurHash')
|
||||
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
||||
self.collectionMode = utils.cast(int, data.attrib.get('collectionMode', '-1'))
|
||||
self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0'))
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.maxYear = utils.cast(int, data.attrib.get('maxYear'))
|
||||
self.minYear = utils.cast(int, data.attrib.get('minYear'))
|
||||
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.thumbBlurHash = data.attrib.get('thumbBlurHash')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
|
||||
@property
|
||||
@deprecated('use "items" instead', stacklevel=3)
|
||||
def children(self):
|
||||
return self.items()
|
||||
|
||||
def item(self, title):
|
||||
""" Returns the item in the collection that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the item to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
|
||||
def items(self):
|
||||
""" Returns a list of all items in the collection. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key)
|
||||
|
||||
def get(self, title):
|
||||
""" Alias to :func:`~plexapi.library.Collection.item`. """
|
||||
return self.item(title)
|
||||
|
||||
def __len__(self):
|
||||
return self.childCount
|
||||
|
||||
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('Setting'):
|
||||
items.append(Setting(data=item, server=self._server))
|
||||
|
||||
return items
|
||||
|
||||
def modeUpdate(self, mode=None):
|
||||
""" Update Collection Mode
|
||||
|
||||
Parameters:
|
||||
mode: default (Library default)
|
||||
hide (Hide Collection)
|
||||
hideItems (Hide Items in this Collection)
|
||||
showItems (Show this Collection and its Items)
|
||||
Example:
|
||||
|
||||
collection = 'plexapi.library.Collections'
|
||||
collection.updateMode(mode="hide")
|
||||
"""
|
||||
mode_dict = {'default': -1,
|
||||
'hide': 0,
|
||||
'hideItems': 1,
|
||||
'showItems': 2}
|
||||
key = mode_dict.get(mode)
|
||||
if key is None:
|
||||
raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict)))
|
||||
part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key)
|
||||
return self._server.query(part, method=self._server._session.put)
|
||||
|
||||
def sortUpdate(self, sort=None):
|
||||
""" Update Collection Sorting
|
||||
|
||||
Parameters:
|
||||
sort: realease (Order Collection by realease dates)
|
||||
alpha (Order Collection alphabetically)
|
||||
custom (Custom collection order)
|
||||
|
||||
Example:
|
||||
|
||||
colleciton = 'plexapi.library.Collections'
|
||||
collection.updateSort(mode="alpha")
|
||||
"""
|
||||
sort_dict = {'release': 0,
|
||||
'alpha': 1,
|
||||
'custom': 2}
|
||||
key = sort_dict.get(sort)
|
||||
if key is None:
|
||||
raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict)))
|
||||
part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key)
|
||||
return self._server.query(part, method=self._server._session.put)
|
|
@ -13,11 +13,14 @@ import struct
|
|||
|
||||
|
||||
class GDM:
|
||||
"""Base class to discover GDM services."""
|
||||
"""Base class to discover GDM services.
|
||||
|
||||
Atrributes:
|
||||
entries (List<dict>): List of server and/or client data discovered.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.entries = []
|
||||
self.last_scan = None
|
||||
|
||||
def scan(self, scan_for_clients=False):
|
||||
"""Scan the network."""
|
||||
|
@ -35,7 +38,7 @@ class GDM:
|
|||
"""Return a list of entries that match the content_type."""
|
||||
self.scan()
|
||||
return [entry for entry in self.entries
|
||||
if value in entry['data']['Content_Type']]
|
||||
if value in entry['data']['Content-Type']]
|
||||
|
||||
def find_by_data(self, values):
|
||||
"""Return a list of entries that match the search parameters."""
|
||||
|
|
|
@ -2,7 +2,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.base import OPERATORS, PlexObject
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.settings import Setting
|
||||
from plexapi.utils import deprecated
|
||||
|
@ -723,7 +723,7 @@ class LibrarySection(PlexObject):
|
|||
result = set()
|
||||
choices = self.listChoices(category, libtype)
|
||||
lookup = {c.title.lower(): unquote(unquote(c.key)) for c in choices}
|
||||
allowed = set(c.key for c in choices)
|
||||
allowed = {c.key for c in choices}
|
||||
for item in value:
|
||||
item = str((item.id or item.tag) if isinstance(item, media.MediaTag) else item).lower()
|
||||
# find most logical choice(s) to use in url
|
||||
|
@ -1525,196 +1525,6 @@ class FirstCharacter(PlexObject):
|
|||
self.title = data.attrib.get('title')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Collections(PlexPartialObject):
|
||||
""" Represents a single Collection.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'collection'
|
||||
addedAt (datetime): Datetime the collection was added to the library.
|
||||
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||
artBlurHash (str): BlurHash string for artwork image.
|
||||
childCount (int): Number of items in the collection.
|
||||
collectionMode (str): How the items in the collection are displayed.
|
||||
collectionSort (str): How to sort the items in the collection.
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the collection (collection://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX).
|
||||
index (int): Plex index number for the collection.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
maxYear (int): Maximum year for the items in the collection.
|
||||
minYear (int): Minimum year for the items in the collection.
|
||||
ratingKey (int): Unique key identifying the collection.
|
||||
subtype (str): Media type of the items in the collection (movie, show, artist, or album).
|
||||
summary (str): Summary of the collection.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
thumbBlurHash (str): BlurHash string for thumbnail image.
|
||||
title (str): Name of the collection.
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'collection'
|
||||
updatedAt (datatime): Datetime the collection was updated.
|
||||
"""
|
||||
|
||||
TAG = 'Directory'
|
||||
TYPE = 'collection'
|
||||
|
||||
def _loadData(self, data):
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.artBlurHash = data.attrib.get('artBlurHash')
|
||||
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
||||
self.collectionMode = data.attrib.get('collectionMode')
|
||||
self.collectionSort = data.attrib.get('collectionSort')
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.maxYear = utils.cast(int, data.attrib.get('maxYear'))
|
||||
self.minYear = utils.cast(int, data.attrib.get('minYear'))
|
||||
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.thumbBlurHash = data.attrib.get('thumbBlurHash')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
|
||||
@property
|
||||
@deprecated('use "items" instead')
|
||||
def children(self):
|
||||
return self.fetchItems(self.key)
|
||||
|
||||
def item(self, title):
|
||||
""" Returns the item in the collection that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the item to return.
|
||||
"""
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
|
||||
def items(self):
|
||||
""" Returns a list of all items in the collection. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key)
|
||||
|
||||
def get(self, title):
|
||||
""" Alias to :func:`~plexapi.library.Collection.item`. """
|
||||
return self.item(title)
|
||||
|
||||
def __len__(self):
|
||||
return self.childCount
|
||||
|
||||
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('Setting'):
|
||||
items.append(Setting(data=item, server=self._server))
|
||||
|
||||
return items
|
||||
|
||||
def delete(self):
|
||||
part = '/library/metadata/%s' % self.ratingKey
|
||||
return self._server.query(part, method=self._server._session.delete)
|
||||
|
||||
def modeUpdate(self, mode=None):
|
||||
""" Update Collection Mode
|
||||
|
||||
Parameters:
|
||||
mode: default (Library default)
|
||||
hide (Hide Collection)
|
||||
hideItems (Hide Items in this Collection)
|
||||
showItems (Show this Collection and its Items)
|
||||
Example:
|
||||
|
||||
collection = 'plexapi.library.Collections'
|
||||
collection.updateMode(mode="hide")
|
||||
"""
|
||||
mode_dict = {'default': '-1',
|
||||
'hide': '0',
|
||||
'hideItems': '1',
|
||||
'showItems': '2'}
|
||||
key = mode_dict.get(mode)
|
||||
if key is None:
|
||||
raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict)))
|
||||
part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key)
|
||||
return self._server.query(part, method=self._server._session.put)
|
||||
|
||||
def sortUpdate(self, sort=None):
|
||||
""" Update Collection Sorting
|
||||
|
||||
Parameters:
|
||||
sort: realease (Order Collection by realease dates)
|
||||
alpha (Order Collection Alphabetically)
|
||||
|
||||
Example:
|
||||
|
||||
colleciton = 'plexapi.library.Collections'
|
||||
collection.updateSort(mode="alpha")
|
||||
"""
|
||||
sort_dict = {'release': '0',
|
||||
'alpha': '1'}
|
||||
key = sort_dict.get(sort)
|
||||
if key is None:
|
||||
raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict)))
|
||||
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
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Path(PlexObject):
|
||||
""" Represents a single directory Path.
|
||||
|
|
|
@ -708,10 +708,10 @@ class Collection(MediaTag):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class Label(MediaTag):
|
||||
""" Represents a single label media tag.
|
||||
""" Represents a single Label media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'label'
|
||||
TAG (str): 'Label'
|
||||
FILTER (str): 'label'
|
||||
"""
|
||||
TAG = 'Label'
|
||||
|
@ -720,10 +720,10 @@ class Label(MediaTag):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class Tag(MediaTag):
|
||||
""" Represents a single tag media tag.
|
||||
""" Represents a single Tag media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'tag'
|
||||
TAG (str): 'Tag'
|
||||
FILTER (str): 'tag'
|
||||
"""
|
||||
TAG = 'Tag'
|
||||
|
@ -807,20 +807,25 @@ class Style(MediaTag):
|
|||
FILTER = 'style'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Poster(PlexObject):
|
||||
""" Represents a Poster.
|
||||
class BaseImage(PlexObject):
|
||||
""" Base class for all Art, Banner, and Poster 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,18 @@ class Poster(PlexObject):
|
|||
pass
|
||||
|
||||
|
||||
class Art(BaseImage):
|
||||
""" Represents a single Art object. """
|
||||
|
||||
|
||||
class Banner(BaseImage):
|
||||
""" Represents a single Banner object. """
|
||||
|
||||
|
||||
class Poster(BaseImage):
|
||||
""" Represents a single Poster object. """
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Producer(MediaTag):
|
||||
""" Represents a single Producer media tag.
|
||||
|
|
489
plexapi/mixins.py
Normal file
489
plexapi/mixins.py
Normal file
|
@ -0,0 +1,489 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
|
||||
class ArtUrlMixin(object):
|
||||
""" Mixin for Plex objects that can have a background artwork url. """
|
||||
|
||||
@property
|
||||
def artUrl(self):
|
||||
""" Return the art url for the Plex object. """
|
||||
art = self.firstAttr('art', 'grandparentArt')
|
||||
return self._server.url(art, includeToken=True) if art else None
|
||||
|
||||
|
||||
class ArtMixin(ArtUrlMixin):
|
||||
""" Mixin for Plex objects that can have background 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 a background artwork from a url or filepath.
|
||||
|
||||
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 background artwork for a Plex object.
|
||||
|
||||
Parameters:
|
||||
art (:class:`~plexapi.media.Art`): The art object to select.
|
||||
"""
|
||||
art.select()
|
||||
|
||||
|
||||
class BannerUrlMixin(object):
|
||||
""" Mixin for Plex objects that can have a banner url. """
|
||||
|
||||
@property
|
||||
def bannerUrl(self):
|
||||
""" Return the banner url for the Plex object. """
|
||||
banner = self.firstAttr('banner')
|
||||
return self._server.url(banner, includeToken=True) if banner else None
|
||||
|
||||
|
||||
class BannerMixin(BannerUrlMixin):
|
||||
""" Mixin for Plex objects that can have banners. """
|
||||
|
||||
def banners(self):
|
||||
""" Returns list of available :class:`~plexapi.media.Banner` objects. """
|
||||
return self.fetchItems('/library/metadata/%s/banners' % self.ratingKey, cls=media.Banner)
|
||||
|
||||
def uploadBanner(self, url=None, filepath=None):
|
||||
""" Upload a banner from a url or filepath.
|
||||
|
||||
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/banners?url=%s' % (self.ratingKey, quote_plus(url))
|
||||
self._server.query(key, method=self._server._session.post)
|
||||
elif filepath:
|
||||
key = '/library/metadata/%s/banners?' % self.ratingKey
|
||||
data = open(filepath, 'rb').read()
|
||||
self._server.query(key, method=self._server._session.post, data=data)
|
||||
|
||||
def setBanner(self, banner):
|
||||
""" Set the banner for a Plex object.
|
||||
|
||||
Parameters:
|
||||
banner (:class:`~plexapi.media.Banner`): The banner object to select.
|
||||
"""
|
||||
banner.select()
|
||||
|
||||
|
||||
class PosterUrlMixin(object):
|
||||
""" Mixin for Plex objects that can have a poster url. """
|
||||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
""" Return the thumb url for the Plex object. """
|
||||
thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb')
|
||||
return self._server.url(thumb, includeToken=True) if thumb else None
|
||||
|
||||
@property
|
||||
def posterUrl(self):
|
||||
""" Alias to self.thumbUrl. """
|
||||
return self.thumbUrl
|
||||
|
||||
|
||||
class PosterMixin(PosterUrlMixin):
|
||||
""" 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 a poster from a url or filepath.
|
||||
|
||||
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):
|
||||
""" Mixin for Plex objects that can have collections. """
|
||||
|
||||
def addCollection(self, collections, locked=True):
|
||||
""" Add a collection tag(s).
|
||||
|
||||
Parameters:
|
||||
collections (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('collection', collections, locked=locked)
|
||||
|
||||
def removeCollection(self, collections, locked=True):
|
||||
""" Remove a collection tag(s).
|
||||
|
||||
Parameters:
|
||||
collections (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('collection', collections, locked=locked, remove=True)
|
||||
|
||||
|
||||
class CountryMixin(object):
|
||||
""" Mixin for Plex objects that can have countries. """
|
||||
|
||||
def addCountry(self, countries, locked=True):
|
||||
""" Add a country tag(s).
|
||||
|
||||
Parameters:
|
||||
countries (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('country', countries, locked=locked)
|
||||
|
||||
def removeCountry(self, countries, locked=True):
|
||||
""" Remove a country tag(s).
|
||||
|
||||
Parameters:
|
||||
countries (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('country', countries, locked=locked, remove=True)
|
||||
|
||||
|
||||
class DirectorMixin(object):
|
||||
""" Mixin for Plex objects that can have directors. """
|
||||
|
||||
def addDirector(self, directors, locked=True):
|
||||
""" Add a director tag(s).
|
||||
|
||||
Parameters:
|
||||
directors (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('director', directors, locked=locked)
|
||||
|
||||
def removeDirector(self, directors, locked=True):
|
||||
""" Remove a director tag(s).
|
||||
|
||||
Parameters:
|
||||
directors (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('director', directors, locked=locked, remove=True)
|
||||
|
||||
|
||||
class GenreMixin(object):
|
||||
""" Mixin for Plex objects that can have genres. """
|
||||
|
||||
def addGenre(self, genres, locked=True):
|
||||
""" Add a genre tag(s).
|
||||
|
||||
Parameters:
|
||||
genres (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('genre', genres, locked=locked)
|
||||
|
||||
def removeGenre(self, genres, locked=True):
|
||||
""" Remove a genre tag(s).
|
||||
|
||||
Parameters:
|
||||
genres (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('genre', genres, locked=locked, remove=True)
|
||||
|
||||
|
||||
class LabelMixin(object):
|
||||
""" Mixin for Plex objects that can have labels. """
|
||||
|
||||
def addLabel(self, labels, locked=True):
|
||||
""" Add a label tag(s).
|
||||
|
||||
Parameters:
|
||||
labels (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('label', labels, locked=locked)
|
||||
|
||||
def removeLabel(self, labels, locked=True):
|
||||
""" Remove a label tag(s).
|
||||
|
||||
Parameters:
|
||||
labels (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('label', labels, locked=locked, remove=True)
|
||||
|
||||
|
||||
class MoodMixin(object):
|
||||
""" Mixin for Plex objects that can have moods. """
|
||||
|
||||
def addMood(self, moods, locked=True):
|
||||
""" Add a mood tag(s).
|
||||
|
||||
Parameters:
|
||||
moods (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('mood', moods, locked=locked)
|
||||
|
||||
def removeMood(self, moods, locked=True):
|
||||
""" Remove a mood tag(s).
|
||||
|
||||
Parameters:
|
||||
moods (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('mood', moods, locked=locked, remove=True)
|
||||
|
||||
|
||||
class ProducerMixin(object):
|
||||
""" Mixin for Plex objects that can have producers. """
|
||||
|
||||
def addProducer(self, producers, locked=True):
|
||||
""" Add a producer tag(s).
|
||||
|
||||
Parameters:
|
||||
producers (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('producer', producers, locked=locked)
|
||||
|
||||
def removeProducer(self, producers, locked=True):
|
||||
""" Remove a producer tag(s).
|
||||
|
||||
Parameters:
|
||||
producers (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('producer', producers, locked=locked, remove=True)
|
||||
|
||||
|
||||
class SimilarArtistMixin(object):
|
||||
""" Mixin for Plex objects that can have similar artists. """
|
||||
|
||||
def addSimilarArtist(self, artists, locked=True):
|
||||
""" Add a similar artist tag(s).
|
||||
|
||||
Parameters:
|
||||
artists (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('similar', artists, locked=locked)
|
||||
|
||||
def removeSimilarArtist(self, artists, locked=True):
|
||||
""" Remove a similar artist tag(s).
|
||||
|
||||
Parameters:
|
||||
artists (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('similar', artists, locked=locked, remove=True)
|
||||
|
||||
|
||||
class StyleMixin(object):
|
||||
""" Mixin for Plex objects that can have styles. """
|
||||
|
||||
def addStyle(self, styles, locked=True):
|
||||
""" Add a style tag(s).
|
||||
|
||||
Parameters:
|
||||
styles (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('style', styles, locked=locked)
|
||||
|
||||
def removeStyle(self, styles, locked=True):
|
||||
""" Remove a style tag(s).
|
||||
|
||||
Parameters:
|
||||
styles (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('style', styles, locked=locked, remove=True)
|
||||
|
||||
|
||||
class TagMixin(object):
|
||||
""" Mixin for Plex objects that can have tags. """
|
||||
|
||||
def addTag(self, tags, locked=True):
|
||||
""" Add a tag(s).
|
||||
|
||||
Parameters:
|
||||
tags (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('tag', tags, locked=locked)
|
||||
|
||||
def removeTag(self, tags, locked=True):
|
||||
""" Remove a tag(s).
|
||||
|
||||
Parameters:
|
||||
tags (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('tag', tags, locked=locked, remove=True)
|
||||
|
||||
|
||||
class WriterMixin(object):
|
||||
""" Mixin for Plex objects that can have writers. """
|
||||
|
||||
def addWriter(self, writers, locked=True):
|
||||
""" Add a writer tag(s).
|
||||
|
||||
Parameters:
|
||||
writers (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('writer', writers, locked=locked)
|
||||
|
||||
def removeWriter(self, writers, locked=True):
|
||||
""" Remove a writer tag(s).
|
||||
|
||||
Parameters:
|
||||
writers (list): List of strings.
|
||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||
"""
|
||||
self._edit_tags('writer', writers, locked=locked, remove=True)
|
|
@ -238,19 +238,21 @@ class MyPlexAccount(PlexObject):
|
|||
""" Share library content with the specified user.
|
||||
|
||||
Parameters:
|
||||
user (str): MyPlexUser, username, email of the user to be added.
|
||||
server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share.
|
||||
sections ([Section]): Library sections, names or ids to be shared (default None).
|
||||
[Section] must be defined in order to update shared sections.
|
||||
user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email
|
||||
of the user to be added.
|
||||
server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier
|
||||
containing the library sections to share.
|
||||
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names
|
||||
to be shared (default None). `sections` must be defined in order to update shared libraries.
|
||||
allowSync (Bool): Set True to allow user to sync content.
|
||||
allowCameraUpload (Bool): Set True to allow user to upload photos.
|
||||
allowChannels (Bool): Set True to allow user to utilize installed channels.
|
||||
filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}`
|
||||
filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}`
|
||||
filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered.
|
||||
ex: {'label':['foo']}
|
||||
ex: `{'label':['foo']}`
|
||||
"""
|
||||
username = user.username if isinstance(user, MyPlexUser) else user
|
||||
machineId = server.machineIdentifier if isinstance(server, PlexServer) else server
|
||||
|
@ -276,18 +278,21 @@ class MyPlexAccount(PlexObject):
|
|||
""" Share library content with the specified user.
|
||||
|
||||
Parameters:
|
||||
user (str): MyPlexUser, username, email of the user to be added.
|
||||
server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share.
|
||||
sections ([Section]): Library sections, names or ids to be shared (default None shares all sections).
|
||||
user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email
|
||||
of the user to be added.
|
||||
server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier
|
||||
containing the library sections to share.
|
||||
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names
|
||||
to be shared (default None). `sections` must be defined in order to update shared libraries.
|
||||
allowSync (Bool): Set True to allow user to sync content.
|
||||
allowCameraUpload (Bool): Set True to allow user to upload photos.
|
||||
allowChannels (Bool): Set True to allow user to utilize installed channels.
|
||||
filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}`
|
||||
filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}`
|
||||
filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered.
|
||||
ex: {'label':['foo']}
|
||||
ex: `{'label':['foo']}`
|
||||
"""
|
||||
machineId = server.machineIdentifier if isinstance(server, PlexServer) else server
|
||||
sectionIds = self._getSectionIds(server, sections)
|
||||
|
@ -322,18 +327,21 @@ class MyPlexAccount(PlexObject):
|
|||
""" Share library content with the specified user.
|
||||
|
||||
Parameters:
|
||||
user (str): MyPlexUser, username, email of the user to be added.
|
||||
server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share.
|
||||
sections ([Section]): Library sections, names or ids to be shared (default None shares all sections).
|
||||
user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email
|
||||
of the user to be added.
|
||||
server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier
|
||||
containing the library sections to share.
|
||||
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names
|
||||
to be shared (default None). `sections` must be defined in order to update shared libraries.
|
||||
allowSync (Bool): Set True to allow user to sync content.
|
||||
allowCameraUpload (Bool): Set True to allow user to upload photos.
|
||||
allowChannels (Bool): Set True to allow user to utilize installed channels.
|
||||
filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}`
|
||||
filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}`
|
||||
filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered.
|
||||
ex: {'label':['foo']}
|
||||
ex: `{'label':['foo']}`
|
||||
"""
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
# If user already exists, carry over sections and settings.
|
||||
|
@ -393,20 +401,22 @@ class MyPlexAccount(PlexObject):
|
|||
""" Update the specified user's share settings.
|
||||
|
||||
Parameters:
|
||||
user (str): MyPlexUser, username, email of the user to be added.
|
||||
server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share.
|
||||
sections: ([Section]): Library sections, names or ids to be shared (default None).
|
||||
[Section] must be defined in order to update shared sections.
|
||||
user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email
|
||||
of the user to be updated.
|
||||
server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier
|
||||
containing the library sections to share.
|
||||
sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names
|
||||
to be shared (default None). `sections` must be defined in order to update shared libraries.
|
||||
removeSections (Bool): Set True to remove all shares. Supersedes sections.
|
||||
allowSync (Bool): Set True to allow user to sync content.
|
||||
allowCameraUpload (Bool): Set True to allow user to upload photos.
|
||||
allowChannels (Bool): Set True to allow user to utilize installed channels.
|
||||
filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}`
|
||||
filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}`
|
||||
filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered.
|
||||
ex: {'label':['foo']}
|
||||
ex: `{'label':['foo']}`
|
||||
"""
|
||||
# Update friend servers
|
||||
response_filters = ''
|
||||
|
@ -974,31 +984,38 @@ class MyPlexResource(PlexObject):
|
|||
def connect(self, ssl=None, timeout=None):
|
||||
""" Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object.
|
||||
Often times there is more than one address specified for a server or client.
|
||||
This function will prioritize local connections before remote and HTTPS before HTTP.
|
||||
This function will prioritize local connections before remote or relay and HTTPS before HTTP.
|
||||
After trying to connect to all available addresses for this resource and
|
||||
assuming at least one connection was successful, the PlexServer object is built and returned.
|
||||
|
||||
Parameters:
|
||||
ssl (optional): Set True to only connect to HTTPS connections. Set False to
|
||||
ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to
|
||||
only connect to HTTP connections. Set None (default) to connect to any
|
||||
HTTP or HTTPS connection.
|
||||
timeout (int, optional): The timeout in seconds to attempt each connection.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||
"""
|
||||
# Sort connections from (https, local) to (http, remote)
|
||||
# Keys in the order we want the connections to be sorted
|
||||
locations = ['local', 'remote', 'relay']
|
||||
schemes = ['https', 'http']
|
||||
connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations}
|
||||
for connection in self.connections:
|
||||
# Only check non-local connections unless we own the resource
|
||||
connections = sorted(self.connections, key=lambda c: c.local, reverse=True)
|
||||
owned_or_unowned_non_local = lambda x: self.owned or (not self.owned and not x.local)
|
||||
https = [c.uri for c in connections if owned_or_unowned_non_local(c)]
|
||||
http = [c.httpuri for c in connections if owned_or_unowned_non_local(c)]
|
||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||
# Force ssl, no ssl, or any (default)
|
||||
if ssl is True: connections = https
|
||||
elif ssl is False: connections = http
|
||||
else: connections = https + http
|
||||
if self.owned or (not self.owned and not connection.local):
|
||||
location = 'relay' if connection.relay else ('local' if connection.local else 'remote')
|
||||
connections_dict[location]['http'].append(connection.httpuri)
|
||||
connections_dict[location]['https'].append(connection.uri)
|
||||
if ssl is True: schemes.remove('http')
|
||||
elif ssl is False: schemes.remove('https')
|
||||
connections = []
|
||||
for location in locations:
|
||||
for scheme in schemes:
|
||||
connections.extend(connections_dict[location][scheme])
|
||||
# Try connecting to all known resource connections in parellel, but
|
||||
# only return the first server (in order) that provides a response.
|
||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||
listargs = [[cls, url, self.accessToken, timeout] for url in connections]
|
||||
log.debug('Testing %s resource connections..', len(listargs))
|
||||
results = utils.threaded(_connect, listargs)
|
||||
|
|
|
@ -4,10 +4,11 @@ from urllib.parse import quote_plus
|
|||
from plexapi import media, utils, video
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, TagMixin
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photoalbum(PlexPartialObject):
|
||||
class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin):
|
||||
""" Represents a single Photoalbum (collection of photos).
|
||||
|
||||
Attributes:
|
||||
|
@ -44,7 +45,7 @@ class Photoalbum(PlexPartialObject):
|
|||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '')
|
||||
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
|
@ -136,7 +137,7 @@ class Photoalbum(PlexPartialObject):
|
|||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photo(PlexPartialObject, Playable):
|
||||
class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin):
|
||||
""" Represents a single Photo.
|
||||
|
||||
Attributes:
|
||||
|
@ -163,7 +164,7 @@ class Photo(PlexPartialObject, Playable):
|
|||
parentTitle (str): Name of the photo album for the photo.
|
||||
ratingKey (int): Unique key identifying the photo.
|
||||
summary (str): Summary of the photo.
|
||||
tag (List<:class:`~plexapi.media.Tag`>): List of tag objects.
|
||||
tags (List<:class:`~plexapi.media.Tag`>): List of tag objects.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
title (str): Name of the photo.
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
|
@ -199,7 +200,7 @@ class Photo(PlexPartialObject, Playable):
|
|||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.tag = self.findItems(data, media.Tag)
|
||||
self.tags = self.findItems(data, media.Tag)
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
|
@ -207,12 +208,6 @@ class Photo(PlexPartialObject, Playable):
|
|||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
"""Return URL for the thumbnail image."""
|
||||
key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb')
|
||||
return self._server.url(key, includeToken=True) if key else None
|
||||
|
||||
def photoalbum(self):
|
||||
""" Return the photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
|
|
@ -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:
|
||||
|
@ -62,6 +63,11 @@ class Playlist(PlexPartialObject, Playable):
|
|||
for item in self.items():
|
||||
yield item
|
||||
|
||||
@property
|
||||
def thumb(self):
|
||||
""" Alias to self.composite. """
|
||||
return self.composite
|
||||
|
||||
@property
|
||||
def metadataType(self):
|
||||
if self.isVideo:
|
||||
|
@ -311,41 +317,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()
|
||||
|
|
|
@ -15,11 +15,12 @@ from plexapi.media import Conversion, Optimized
|
|||
from plexapi.playlist import Playlist
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from plexapi.settings import Settings
|
||||
from plexapi.utils import cast
|
||||
from plexapi.utils import cast, deprecated
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
# Need these imports to populate utils.PLEXOBJECTS
|
||||
from plexapi import audio as _audio # noqa: F401; noqa: F401
|
||||
from plexapi import audio as _audio # noqa: F401
|
||||
from plexapi import collection as _collection # noqa: F401
|
||||
from plexapi import media as _media # noqa: F401
|
||||
from plexapi import photo as _photo # noqa: F401
|
||||
from plexapi import playlist as _playlist # noqa: F401
|
||||
|
@ -374,7 +375,11 @@ class PlexServer(PlexObject):
|
|||
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack)
|
||||
return filepath
|
||||
|
||||
@deprecated('use "checkForUpdate" instead')
|
||||
def check_for_update(self, force=True, download=False):
|
||||
return self.checkForUpdate()
|
||||
|
||||
def checkForUpdate(self, force=True, download=False):
|
||||
""" Returns a :class:`~plexapi.base.Release` object containing release info.
|
||||
|
||||
Parameters:
|
||||
|
@ -390,7 +395,7 @@ class PlexServer(PlexObject):
|
|||
|
||||
def isLatest(self):
|
||||
""" Check if the installed version of PMS is the latest. """
|
||||
release = self.check_for_update(force=True)
|
||||
release = self.checkForUpdate(force=True)
|
||||
return release is None
|
||||
|
||||
def installUpdate(self):
|
||||
|
@ -398,7 +403,7 @@ class PlexServer(PlexObject):
|
|||
# We can add this but dunno how useful this is since it sometimes
|
||||
# requires user action using a gui.
|
||||
part = '/updater/apply'
|
||||
release = self.check_for_update(force=True, download=True)
|
||||
release = self.checkForUpdate(force=True, download=True)
|
||||
if release and release.version != self.version:
|
||||
# figure out what method this is..
|
||||
return self.query(part, method=self._session.put)
|
||||
|
@ -787,6 +792,20 @@ class Activity(PlexObject):
|
|||
self.uuid = data.attrib.get('uuid')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Release(PlexObject):
|
||||
TAG = 'Release'
|
||||
key = '/updater/status'
|
||||
|
||||
def _loadData(self, data):
|
||||
self.download_key = data.attrib.get('key')
|
||||
self.version = data.attrib.get('version')
|
||||
self.added = data.attrib.get('added')
|
||||
self.fixed = data.attrib.get('fixed')
|
||||
self.downloadURL = data.attrib.get('downloadURL')
|
||||
self.state = data.attrib.get('state')
|
||||
|
||||
|
||||
class SystemAccount(PlexObject):
|
||||
""" Represents a single system account.
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ class Settings(PlexObject):
|
|||
|
||||
def all(self):
|
||||
""" Returns a list of all :class:`~plexapi.settings.Setting` objects available. """
|
||||
return list(v for id, v in sorted(self._settings.items()))
|
||||
return [v for id, v in sorted(self._settings.items())]
|
||||
|
||||
def get(self, id):
|
||||
""" Return the :class:`~plexapi.settings.Setting` object with the specified id. """
|
||||
|
@ -102,7 +102,7 @@ class Setting(PlexObject):
|
|||
group (str): Group name this setting is categorized as.
|
||||
enumValues (list,dict): List or dictionary of valis values for this setting.
|
||||
"""
|
||||
_bool_cast = lambda x: True if x == 'true' or x == '1' else False
|
||||
_bool_cast = lambda x: bool(x == 'true' or x == '1')
|
||||
_bool_str = lambda x: str(x).lower()
|
||||
TYPES = {
|
||||
'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str},
|
||||
|
|
|
@ -21,7 +21,6 @@ except ImportError:
|
|||
tqdm = None
|
||||
|
||||
log = logging.getLogger('plexapi')
|
||||
warnings.simplefilter('default', category=DeprecationWarning)
|
||||
|
||||
# Search Types - Plex uses these to filter specific media types when searching.
|
||||
# Library Types - Populated at runtime
|
||||
|
@ -177,7 +176,7 @@ def threaded(callback, listargs):
|
|||
threads[-1].setDaemon(True)
|
||||
threads[-1].start()
|
||||
while not job_is_done_event.is_set():
|
||||
if all([not t.is_alive() for t in threads]):
|
||||
if all(not t.is_alive() for t in threads):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
|
@ -335,6 +334,24 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4
|
|||
return fullpath
|
||||
|
||||
|
||||
def tag_singular(tag):
|
||||
if tag == 'countries':
|
||||
return 'country'
|
||||
elif tag == 'similar':
|
||||
return 'similar'
|
||||
else:
|
||||
return tag[:-1]
|
||||
|
||||
|
||||
def tag_plural(tag):
|
||||
if tag == 'country':
|
||||
return 'countries'
|
||||
elif tag == 'similar':
|
||||
return 'similar'
|
||||
else:
|
||||
return tag + 's'
|
||||
|
||||
|
||||
def tag_helper(tag, items, locked=True, remove=False):
|
||||
""" Simple tag helper for editing a object. """
|
||||
if not isinstance(items, list):
|
||||
|
@ -449,7 +466,7 @@ def base64str(text):
|
|||
return base64.b64encode(text.encode('utf-8')).decode('utf-8')
|
||||
|
||||
|
||||
def deprecated(message):
|
||||
def deprecated(message, stacklevel=2):
|
||||
def decorator(func):
|
||||
"""This is a decorator which can be used to mark functions
|
||||
as deprecated. It will result in a warning being emitted
|
||||
|
@ -457,7 +474,7 @@ def deprecated(message):
|
|||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message)
|
||||
warnings.warn(msg, category=DeprecationWarning, stacklevel=3)
|
||||
warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel)
|
||||
log.warning(msg)
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
|
|
@ -5,6 +5,9 @@ from urllib.parse import quote_plus, urlencode
|
|||
from plexapi import library, media, settings, utils
|
||||
from plexapi.base import Playable, PlexPartialObject, MediaContainer
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.mixins import ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin
|
||||
from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin
|
||||
from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin
|
||||
|
||||
|
||||
class Video(PlexPartialObject):
|
||||
|
@ -64,20 +67,6 @@ class Video(PlexPartialObject):
|
|||
""" Returns True if this video is watched. """
|
||||
return bool(self.viewCount > 0) if self.viewCount else False
|
||||
|
||||
@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
|
||||
|
@ -277,7 +266,8 @@ class Video(PlexPartialObject):
|
|||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Movie(Playable, Video):
|
||||
class Movie(Video, Playable, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin,
|
||||
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin):
|
||||
""" Represents a single Movie.
|
||||
|
||||
Attributes:
|
||||
|
@ -422,7 +412,8 @@ class Movie(Playable, Video):
|
|||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Show(Video):
|
||||
class Show(Video, ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin,
|
||||
CollectionMixin, GenreMixin, LabelMixin):
|
||||
""" Represents a single Show (including all seasons and episodes).
|
||||
|
||||
Attributes:
|
||||
|
@ -440,10 +431,12 @@ class Show(Video):
|
|||
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.
|
||||
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||
tagline (str): Show tag line.
|
||||
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
||||
viewedLeafCount (int): Number of items marked as played in the show view.
|
||||
year (int): Year the show was released.
|
||||
|
@ -467,10 +460,12 @@ class Show(Video):
|
|||
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)
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.tagline = data.attrib.get('tagline')
|
||||
self.theme = data.attrib.get('theme')
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
@ -620,7 +615,7 @@ class Show(Video):
|
|||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Season(Video):
|
||||
class Season(Video, ArtMixin, PosterMixin):
|
||||
""" Represents a single Show Season (including all episodes).
|
||||
|
||||
Attributes:
|
||||
|
@ -720,11 +715,11 @@ class Season(Video):
|
|||
|
||||
def watched(self):
|
||||
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(watched=True)
|
||||
return self.episodes(viewCount__gt=0)
|
||||
|
||||
def unwatched(self):
|
||||
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(watched=False)
|
||||
return self.episodes(viewCount=0)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Download video files to specified directory.
|
||||
|
@ -746,7 +741,8 @@ class Season(Video):
|
|||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Episode(Playable, Video):
|
||||
class Episode(Video, Playable, ArtMixin, PosterMixin,
|
||||
DirectorMixin, WriterMixin):
|
||||
""" Represents a single Shows Episode.
|
||||
|
||||
Attributes:
|
||||
|
@ -775,6 +771,7 @@ class Episode(Playable, Video):
|
|||
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.
|
||||
|
@ -811,10 +808,23 @@ class Episode(Playable, Video):
|
|||
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__,
|
||||
|
@ -869,7 +879,7 @@ class Episode(Playable, Video):
|
|||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Clip(Playable, Video):
|
||||
class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin):
|
||||
""" 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(
|
||||
|
@ -107,22 +112,25 @@ def pytest_runtest_setup(item):
|
|||
# ---------------------------------
|
||||
|
||||
|
||||
def get_account():
|
||||
return MyPlexAccount()
|
||||
@pytest.fixture(scope="session")
|
||||
def sess():
|
||||
session = requests.Session()
|
||||
session.request = partial(session.request, timeout=120)
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def account():
|
||||
def account(sess):
|
||||
if SERVER_TOKEN:
|
||||
return get_account()
|
||||
return MyPlexAccount(session=sess)
|
||||
assert MYPLEX_USERNAME, "Required MYPLEX_USERNAME not specified."
|
||||
assert MYPLEX_PASSWORD, "Required MYPLEX_PASSWORD not specified."
|
||||
return get_account()
|
||||
return MyPlexAccount(session=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
|
||||
|
||||
|
@ -152,14 +160,14 @@ def mocked_account(requests_mock):
|
|||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def plex(request):
|
||||
def plex(request, sess):
|
||||
assert SERVER_BASEURL, "Required SERVER_BASEURL not specified."
|
||||
session = requests.Session()
|
||||
|
||||
if request.param == TEST_AUTHENTICATED:
|
||||
token = get_account().authenticationToken
|
||||
token = MyPlexAccount(session=sess).authenticationToken
|
||||
else:
|
||||
token = None
|
||||
return PlexServer(SERVER_BASEURL, token, session=session)
|
||||
return PlexServer(SERVER_BASEURL, token, session=sess)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
@ -224,15 +232,13 @@ def movie(movies):
|
|||
|
||||
|
||||
@pytest.fixture()
|
||||
def collection(plex):
|
||||
def collection(movies):
|
||||
try:
|
||||
return plex.library.section("Movies").collections()[0]
|
||||
return movies.collections(title="marvel")[0]
|
||||
except IndexError:
|
||||
movie = plex.library.section("Movies").get("Elephants Dream")
|
||||
movie.addCollection(["marvel"])
|
||||
|
||||
n = plex.library.section("Movies").reload()
|
||||
return n.collections()[0]
|
||||
movie = movies.get("Elephants Dream")
|
||||
movie.addCollection("marvel")
|
||||
return movies.collections(title="marvel")[0]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
@ -268,6 +274,11 @@ def photoalbum(photos):
|
|||
return photos.get("photo_album1")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def photo(photoalbum):
|
||||
return photoalbum.photo("photo1")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def subtitle():
|
||||
mopen = mock_open()
|
||||
|
@ -279,7 +290,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
|
||||
|
@ -369,6 +380,14 @@ def is_string(value, gte=1):
|
|||
return isinstance(value, str) and len(value) >= gte
|
||||
|
||||
|
||||
def is_art(key):
|
||||
return is_metadata(key, contains="/art/")
|
||||
|
||||
|
||||
def is_banner(key):
|
||||
return is_metadata(key, contains="/banner/")
|
||||
|
||||
|
||||
def is_thumb(key):
|
||||
return is_metadata(key, contains="/thumb/")
|
||||
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
from . import conftest as utils
|
||||
from . import test_mixins
|
||||
|
||||
|
||||
def test_audio_Artist_attr(artist):
|
||||
artist.reload()
|
||||
assert utils.is_datetime(artist.addedAt)
|
||||
if artist.art:
|
||||
assert utils.is_art(artist.art)
|
||||
if artist.countries:
|
||||
assert "United States of America" in [i.tag for i in artist.countries]
|
||||
#assert "Electronic" in [i.tag for i in artist.genres]
|
||||
|
@ -23,6 +24,8 @@ def test_audio_Artist_attr(artist):
|
|||
assert isinstance(artist.similar, list)
|
||||
if artist.summary:
|
||||
assert "Alias" in artist.summary
|
||||
if artist.thumb:
|
||||
assert utils.is_thumb(artist.thumb)
|
||||
assert artist.title == "Broke For Free"
|
||||
assert artist.titleSort == "Broke For Free"
|
||||
assert artist.type == "artist"
|
||||
|
@ -63,8 +66,26 @@ def test_audio_Artist_albums(artist):
|
|||
assert len(albums) == 1 and albums[0].title == "Layers"
|
||||
|
||||
|
||||
def test_audio_Artist_mixins_images(artist):
|
||||
test_mixins.edit_art(artist)
|
||||
test_mixins.edit_poster(artist)
|
||||
test_mixins.attr_artUrl(artist)
|
||||
test_mixins.attr_posterUrl(artist)
|
||||
|
||||
|
||||
def test_audio_Artist_mixins_tags(artist):
|
||||
test_mixins.edit_collection(artist)
|
||||
test_mixins.edit_country(artist)
|
||||
test_mixins.edit_genre(artist)
|
||||
test_mixins.edit_mood(artist)
|
||||
test_mixins.edit_similar_artist(artist)
|
||||
test_mixins.edit_style(artist)
|
||||
|
||||
|
||||
def test_audio_Album_attrs(album):
|
||||
assert utils.is_datetime(album.addedAt)
|
||||
if album.art:
|
||||
assert utils.is_art(album.art)
|
||||
assert isinstance(album.genres, list)
|
||||
assert album.index == 1
|
||||
assert utils.is_metadata(album._initpath)
|
||||
|
@ -75,21 +96,20 @@ def test_audio_Album_attrs(album):
|
|||
assert utils.is_metadata(album.parentKey)
|
||||
assert utils.is_int(album.parentRatingKey)
|
||||
if album.parentThumb:
|
||||
assert utils.is_metadata(album.parentThumb, contains="/thumb/")
|
||||
assert utils.is_thumb(album.parentThumb)
|
||||
assert album.parentTitle == "Broke For Free"
|
||||
assert album.ratingKey >= 1
|
||||
assert album._server._baseurl == utils.SERVER_BASEURL
|
||||
assert album.studio == "[no label]"
|
||||
assert album.summary == ""
|
||||
if album.thumb:
|
||||
assert utils.is_metadata(album.thumb, contains="/thumb/")
|
||||
assert utils.is_thumb(album.thumb)
|
||||
assert album.title == "Layers"
|
||||
assert album.titleSort == "Layers"
|
||||
assert album.type == "album"
|
||||
assert utils.is_datetime(album.updatedAt)
|
||||
assert utils.is_int(album.viewCount, gte=0)
|
||||
assert album.year in (2012,)
|
||||
assert album.artUrl is None
|
||||
|
||||
|
||||
def test_audio_Album_history(album):
|
||||
|
@ -104,35 +124,7 @@ def test_audio_Track_history(track):
|
|||
|
||||
def test_audio_Album_tracks(album):
|
||||
tracks = album.tracks()
|
||||
track = tracks[0]
|
||||
assert len(tracks) == 1
|
||||
assert utils.is_metadata(track.grandparentKey)
|
||||
assert utils.is_int(track.grandparentRatingKey)
|
||||
assert track.grandparentTitle == "Broke For Free"
|
||||
assert track.index == 1
|
||||
assert utils.is_metadata(track._initpath)
|
||||
assert utils.is_metadata(track.key)
|
||||
assert track.listType == "audio"
|
||||
assert track.originalTitle in (None, "Broke For Free")
|
||||
# assert utils.is_int(track.parentIndex)
|
||||
assert utils.is_metadata(track.parentKey)
|
||||
assert utils.is_int(track.parentRatingKey)
|
||||
if track.parentThumb:
|
||||
assert utils.is_metadata(track.parentThumb, contains="/thumb/")
|
||||
assert track.parentTitle == "Layers"
|
||||
# assert track.ratingCount == 9 # Flaky
|
||||
assert utils.is_int(track.ratingKey)
|
||||
assert track._server._baseurl == utils.SERVER_BASEURL
|
||||
assert track.summary == ""
|
||||
if track.thumb:
|
||||
assert utils.is_metadata(track.thumb, contains="/thumb/")
|
||||
assert track.title == "As Colourful as Ever"
|
||||
assert track.titleSort == "As Colourful as Ever"
|
||||
assert not track.transcodeSessions
|
||||
assert track.type == "track"
|
||||
assert utils.is_datetime(track.updatedAt)
|
||||
assert utils.is_int(track.viewCount, gte=0)
|
||||
assert track.viewOffset == 0
|
||||
|
||||
|
||||
def test_audio_Album_track(album, track=None):
|
||||
|
@ -140,64 +132,6 @@ def test_audio_Album_track(album, track=None):
|
|||
track = track or album.track("As Colourful As Ever")
|
||||
track2 = album.track(track=1)
|
||||
assert track == track2
|
||||
assert utils.is_datetime(track.addedAt)
|
||||
assert utils.is_int(track.duration)
|
||||
assert utils.is_metadata(track.grandparentKey)
|
||||
assert utils.is_int(track.grandparentRatingKey)
|
||||
assert track.grandparentTitle == "Broke For Free"
|
||||
assert int(track.index) == 1
|
||||
assert utils.is_metadata(track._initpath)
|
||||
assert utils.is_metadata(track.key)
|
||||
assert track.listType == "audio"
|
||||
# Assign 0 track.media
|
||||
media = track.media[0]
|
||||
assert track.originalTitle in (None, "As Colourful As Ever")
|
||||
# Fix me
|
||||
assert utils.is_int(track.parentIndex)
|
||||
assert utils.is_metadata(track.parentKey)
|
||||
assert utils.is_int(track.parentRatingKey)
|
||||
if track.parentThumb:
|
||||
assert utils.is_metadata(track.parentThumb, contains="/thumb/")
|
||||
assert track.parentTitle == "Layers"
|
||||
# assert track.ratingCount == 9
|
||||
assert utils.is_int(track.ratingKey)
|
||||
assert track._server._baseurl == utils.SERVER_BASEURL
|
||||
assert track.summary == ""
|
||||
if track.thumb:
|
||||
assert utils.is_metadata(track.thumb, contains="/thumb/")
|
||||
assert track.title == "As Colourful as Ever"
|
||||
assert track.titleSort == "As Colourful as Ever"
|
||||
assert not track.transcodeSessions
|
||||
assert track.type == "track"
|
||||
assert utils.is_datetime(track.updatedAt)
|
||||
assert utils.is_int(track.viewCount, gte=0)
|
||||
assert track.viewOffset == 0
|
||||
assert media.aspectRatio is None
|
||||
assert media.audioChannels == 2
|
||||
assert media.audioCodec == "mp3"
|
||||
assert media.bitrate == 128
|
||||
assert media.container == "mp3"
|
||||
assert utils.is_int(media.duration)
|
||||
assert media.height in (None, 1080)
|
||||
assert utils.is_int(media.id, gte=1)
|
||||
assert utils.is_metadata(media._initpath)
|
||||
assert media.optimizedForStreaming in (None, True)
|
||||
# Assign 0 media.parts
|
||||
part = media.parts[0]
|
||||
assert media._server._baseurl == utils.SERVER_BASEURL
|
||||
assert media.videoCodec is None
|
||||
assert media.videoFrameRate is None
|
||||
assert media.videoResolution is None
|
||||
assert media.width is None
|
||||
assert part.container == "mp3"
|
||||
assert utils.is_int(part.duration)
|
||||
assert part.file.endswith(".mp3")
|
||||
assert utils.is_int(part.id)
|
||||
assert utils.is_metadata(part._initpath)
|
||||
assert utils.is_part(part.key)
|
||||
assert part._server._baseurl == utils.SERVER_BASEURL
|
||||
assert part.size == 3761053
|
||||
assert track.artUrl is None
|
||||
|
||||
|
||||
def test_audio_Album_get(album):
|
||||
|
@ -211,17 +145,34 @@ def test_audio_Album_artist(album):
|
|||
artist.title == "Broke For Free"
|
||||
|
||||
|
||||
def test_audio_Album_mixins_images(album):
|
||||
test_mixins.edit_art(album)
|
||||
test_mixins.edit_poster(album)
|
||||
test_mixins.attr_artUrl(album)
|
||||
test_mixins.attr_posterUrl(album)
|
||||
|
||||
|
||||
def test_audio_Album_mixins_tags(album):
|
||||
test_mixins.edit_collection(album)
|
||||
test_mixins.edit_genre(album)
|
||||
test_mixins.edit_label(album)
|
||||
test_mixins.edit_mood(album)
|
||||
test_mixins.edit_style(album)
|
||||
|
||||
|
||||
def test_audio_Track_attrs(album):
|
||||
track = album.get("As Colourful As Ever").reload()
|
||||
assert utils.is_datetime(track.addedAt)
|
||||
assert track.art is None
|
||||
if track.art:
|
||||
assert utils.is_art(track.art)
|
||||
assert track.chapterSource is None
|
||||
assert utils.is_int(track.duration)
|
||||
assert track.grandparentArt is None
|
||||
if track.grandparentArt:
|
||||
assert utils.is_art(track.grandparentArt)
|
||||
assert utils.is_metadata(track.grandparentKey)
|
||||
assert utils.is_int(track.grandparentRatingKey)
|
||||
if track.grandparentThumb:
|
||||
assert utils.is_metadata(track.grandparentThumb, contains="/thumb/")
|
||||
assert utils.is_thumb(track.grandparentThumb)
|
||||
assert track.grandparentTitle == "Broke For Free"
|
||||
assert track.guid.startswith("mbid://") or track.guid.startswith("plex://track/")
|
||||
assert int(track.index) == 1
|
||||
|
@ -240,7 +191,7 @@ def test_audio_Track_attrs(album):
|
|||
assert utils.is_metadata(track.parentKey)
|
||||
assert utils.is_int(track.parentRatingKey)
|
||||
if track.parentThumb:
|
||||
assert utils.is_metadata(track.parentThumb, contains="/thumb/")
|
||||
assert utils.is_thumb(track.parentThumb)
|
||||
assert track.parentTitle == "Layers"
|
||||
assert track.playlistItemID is None
|
||||
assert track.primaryExtraKey is None
|
||||
|
@ -250,7 +201,7 @@ def test_audio_Track_attrs(album):
|
|||
assert track.sessionKey is None
|
||||
assert track.summary == ""
|
||||
if track.thumb:
|
||||
assert utils.is_metadata(track.thumb, contains="/thumb/")
|
||||
assert utils.is_thumb(track.thumb)
|
||||
assert track.title == "As Colourful as Ever"
|
||||
assert track.titleSort == "As Colourful as Ever"
|
||||
assert not track.transcodeSessions
|
||||
|
@ -328,6 +279,15 @@ def test_audio_Track_artist(album, artist):
|
|||
assert tracks[0].artist() == artist
|
||||
|
||||
|
||||
def test_audio_Track_mixins_images(track):
|
||||
test_mixins.attr_artUrl(track)
|
||||
test_mixins.attr_posterUrl(track)
|
||||
|
||||
|
||||
def test_audio_Track_mixins_tags(track):
|
||||
test_mixins.edit_mood(track)
|
||||
|
||||
|
||||
def test_audio_Audio_section(artist, album, track):
|
||||
assert artist.section()
|
||||
assert album.section()
|
||||
|
@ -348,7 +308,3 @@ def test_audio_album_download(monkeydownload, album, tmpdir):
|
|||
def test_audio_Artist_download(monkeydownload, artist, tmpdir):
|
||||
f = artist.download(savepath=str(tmpdir))
|
||||
assert len(f) == 1
|
||||
|
||||
|
||||
def test_audio_Album_label(album, patched_http_call):
|
||||
album.addLabel("YO")
|
||||
|
|
114
tests/test_collection.py
Normal file
114
tests/test_collection.py
Normal file
|
@ -0,0 +1,114 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import pytest
|
||||
from plexapi.exceptions import BadRequest
|
||||
|
||||
from . import conftest as utils
|
||||
from . import test_mixins
|
||||
|
||||
|
||||
def test_Collection_attrs(collection):
|
||||
assert utils.is_datetime(collection.addedAt)
|
||||
assert collection.art is None
|
||||
assert collection.artBlurHash is None
|
||||
assert collection.childCount == 1
|
||||
assert collection.collectionMode == -1
|
||||
assert collection.collectionSort == 0
|
||||
assert collection.contentRating
|
||||
assert not collection.fields
|
||||
assert collection.guid.startswith("collection://")
|
||||
assert utils.is_int(collection.index)
|
||||
assert collection.key.startswith("/library/collections/")
|
||||
assert not collection.labels
|
||||
assert utils.is_int(collection.librarySectionID)
|
||||
assert collection.librarySectionKey == "/library/sections/%s" % collection.librarySectionID
|
||||
assert collection.librarySectionTitle == "Movies"
|
||||
assert utils.is_int(collection.maxYear)
|
||||
assert utils.is_int(collection.minYear)
|
||||
assert utils.is_int(collection.ratingKey)
|
||||
assert collection.subtype == "movie"
|
||||
assert collection.summary == ""
|
||||
assert collection.thumb.startswith("/library/collections/%s/composite" % collection.ratingKey)
|
||||
assert collection.thumbBlurHash is None
|
||||
assert collection.title == "marvel"
|
||||
assert collection.titleSort == collection.title
|
||||
assert collection.type == "collection"
|
||||
assert utils.is_datetime(collection.updatedAt)
|
||||
|
||||
|
||||
def test_Collection_modeUpdate(collection):
|
||||
mode_dict = {"default": -1, "hide": 0, "hideItems": 1, "showItems": 2}
|
||||
for key, value in mode_dict.items():
|
||||
collection.modeUpdate(mode=key)
|
||||
collection.reload()
|
||||
assert collection.collectionMode == value
|
||||
with pytest.raises(BadRequest):
|
||||
collection.modeUpdate(mode="bad-mode")
|
||||
collection.modeUpdate("default")
|
||||
|
||||
|
||||
def test_Collection_sortUpdate(collection):
|
||||
sort_dict = {"release": 0, "alpha": 1, "custom": 2}
|
||||
for key, value in sort_dict.items():
|
||||
collection.sortUpdate(sort=key)
|
||||
collection.reload()
|
||||
assert collection.collectionSort == value
|
||||
with pytest.raises(BadRequest):
|
||||
collection.sortUpdate(sort="bad-sort")
|
||||
collection.sortUpdate("release")
|
||||
|
||||
|
||||
def test_Collection_edit(collection):
|
||||
edits = {"titleSort.value": "New Title Sort", "titleSort.locked": 1}
|
||||
collectionTitleSort = collection.titleSort
|
||||
collection.edit(**edits)
|
||||
collection.reload()
|
||||
for field in collection.fields:
|
||||
if field.name == "titleSort":
|
||||
assert collection.titleSort == "New Title Sort"
|
||||
assert field.locked is True
|
||||
collection.edit(**{"titleSort.value": collectionTitleSort, "titleSort.locked": 0})
|
||||
|
||||
|
||||
def test_Collection_delete(movies):
|
||||
delete_collection = "delete_collection"
|
||||
movie = movies.get("Sintel")
|
||||
movie.addCollection(delete_collection)
|
||||
collections = movies.collections(title=delete_collection)
|
||||
assert len(collections) == 1
|
||||
collections[0].delete()
|
||||
collections = movies.collections(title=delete_collection)
|
||||
assert len(collections) == 0
|
||||
|
||||
|
||||
def test_Collection_item(collection):
|
||||
item1 = collection.item("Elephants Dream")
|
||||
assert item1.title == "Elephants Dream"
|
||||
item2 = collection.get("Elephants Dream")
|
||||
assert item2.title == "Elephants Dream"
|
||||
assert item1 == item2
|
||||
|
||||
|
||||
def test_Collection_items(collection):
|
||||
items = collection.items()
|
||||
assert len(items) == 1
|
||||
|
||||
|
||||
def test_Collection_posters(collection):
|
||||
posters = collection.posters()
|
||||
assert posters
|
||||
|
||||
|
||||
def test_Collection_art(collection):
|
||||
arts = collection.arts()
|
||||
assert not arts # Collection has no default art
|
||||
|
||||
|
||||
def test_Collection_mixins_images(collection):
|
||||
test_mixins.edit_art(collection)
|
||||
test_mixins.edit_poster(collection)
|
||||
test_mixins.attr_artUrl(collection)
|
||||
test_mixins.attr_posterUrl(collection)
|
||||
|
||||
|
||||
def test_Collection_mixins_tags(collection):
|
||||
test_mixins.edit_label(collection)
|
|
@ -1,10 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
||||
from . import conftest as utils
|
||||
|
||||
|
||||
def test_history_Movie(movie):
|
||||
|
|
|
@ -258,61 +258,6 @@ def test_library_editAdvanced_default(movies):
|
|||
assert str(setting.value) == str(setting.default)
|
||||
|
||||
|
||||
def test_library_Collection_modeUpdate(collection):
|
||||
mode_dict = {"default": "-1", "hide": "0", "hideItems": "1", "showItems": "2"}
|
||||
for key, value in mode_dict.items():
|
||||
collection.modeUpdate(key)
|
||||
collection.reload()
|
||||
assert collection.collectionMode == value
|
||||
|
||||
|
||||
def test_library_Colletion_sortAlpha(collection):
|
||||
collection.sortUpdate(sort="alpha")
|
||||
collection.reload()
|
||||
assert collection.collectionSort == "1"
|
||||
|
||||
|
||||
def test_library_Colletion_sortRelease(collection):
|
||||
collection.sortUpdate(sort="release")
|
||||
collection.reload()
|
||||
assert collection.collectionSort == "0"
|
||||
|
||||
|
||||
def test_library_Colletion_edit(collection):
|
||||
edits = {'titleSort.value': 'New Title Sort', 'titleSort.locked': 1}
|
||||
collectionTitleSort = collection.titleSort
|
||||
collection.edit(**edits)
|
||||
collection.reload()
|
||||
for field in collection.fields:
|
||||
if field.name == 'titleSort':
|
||||
assert collection.titleSort == 'New Title Sort'
|
||||
assert field.locked is True
|
||||
collection.edit(**{'titleSort.value': collectionTitleSort, 'titleSort.locked': 0})
|
||||
|
||||
|
||||
def test_library_Collection_delete(movies, movie):
|
||||
delete_collection = 'delete_collection'
|
||||
movie.addCollection(delete_collection)
|
||||
collections = movies.collections(title=delete_collection)
|
||||
assert len(collections) == 1
|
||||
collections[0].delete()
|
||||
collections = movies.collections(title=delete_collection)
|
||||
assert len(collections) == 0
|
||||
|
||||
|
||||
def test_library_Collection_item(collection):
|
||||
item1 = collection.item("Elephants Dream")
|
||||
assert item1.title == "Elephants Dream"
|
||||
item2 = collection.get("Elephants Dream")
|
||||
assert item2.title == "Elephants Dream"
|
||||
assert item1 == item2
|
||||
|
||||
|
||||
def test_library_Collection_items(collection):
|
||||
items = collection.items()
|
||||
assert len(items) == 1
|
||||
|
||||
|
||||
def test_search_with_weird_a(plex):
|
||||
ep_title = "Coup de Grâce"
|
||||
result_root = plex.search(ep_title)
|
||||
|
@ -356,6 +301,6 @@ def test_library_section_timeline(plex):
|
|||
assert tl.mediaTagVersion > 1
|
||||
assert tl.thumb == "/:/resources/movie.png"
|
||||
assert tl.title1 == "Movies"
|
||||
assert tl.updateQueueSize == 0
|
||||
assert utils.is_int(tl.updateQueueSize, gte=0)
|
||||
assert tl.viewGroup == "secondary"
|
||||
assert tl.viewMode == 65592
|
||||
|
|
148
tests/test_mixins.py
Normal file
148
tests/test_mixins.py
Normal file
|
@ -0,0 +1,148 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi.utils import tag_singular
|
||||
|
||||
from . import conftest as utils
|
||||
|
||||
TEST_MIXIN_TAG = "Test Tag"
|
||||
CUTE_CAT_SHA1 = "9f7003fc401761d8e0b0364d428b2dab2f789dbb"
|
||||
|
||||
|
||||
def _test_mixins_tag(obj, attr, tag_method):
|
||||
add_tag_method = getattr(obj, "add" + tag_method)
|
||||
remove_tag_method = getattr(obj, "remove" + tag_method)
|
||||
field_name = tag_singular(attr)
|
||||
_tags = lambda: [t.tag for t in getattr(obj, attr)]
|
||||
_fields = lambda: [f for f in obj.fields if f.name == field_name]
|
||||
# Check tag is not present to begin with
|
||||
tags = _tags()
|
||||
assert TEST_MIXIN_TAG not in tags
|
||||
# Add tag and lock the field
|
||||
add_tag_method(TEST_MIXIN_TAG)
|
||||
obj.reload()
|
||||
tags = _tags()
|
||||
fields = _fields()
|
||||
assert TEST_MIXIN_TAG in tags
|
||||
assert fields and fields[0].locked
|
||||
# Remove tag and unlock to field to restore the clean state
|
||||
remove_tag_method(TEST_MIXIN_TAG, locked=False)
|
||||
obj.reload()
|
||||
tags = _tags()
|
||||
fields = _fields()
|
||||
assert TEST_MIXIN_TAG not in tags
|
||||
assert not fields
|
||||
|
||||
|
||||
def edit_collection(obj):
|
||||
_test_mixins_tag(obj, "collections", "Collection")
|
||||
|
||||
|
||||
def edit_country(obj):
|
||||
_test_mixins_tag(obj, "countries", "Country")
|
||||
|
||||
|
||||
def edit_director(obj):
|
||||
_test_mixins_tag(obj, "directors", "Director")
|
||||
|
||||
|
||||
def edit_genre(obj):
|
||||
_test_mixins_tag(obj, "genres", "Genre")
|
||||
|
||||
|
||||
def edit_label(obj):
|
||||
_test_mixins_tag(obj, "labels", "Label")
|
||||
|
||||
|
||||
def edit_mood(obj):
|
||||
_test_mixins_tag(obj, "moods", "Mood")
|
||||
|
||||
|
||||
def edit_producer(obj):
|
||||
_test_mixins_tag(obj, "producers", "Producer")
|
||||
|
||||
|
||||
def edit_similar_artist(obj):
|
||||
_test_mixins_tag(obj, "similar", "SimilarArtist")
|
||||
|
||||
|
||||
def edit_style(obj):
|
||||
_test_mixins_tag(obj, "styles", "Style")
|
||||
|
||||
|
||||
def edit_tag(obj):
|
||||
_test_mixins_tag(obj, "tags", "Tag")
|
||||
|
||||
|
||||
def edit_writer(obj):
|
||||
_test_mixins_tag(obj, "writers", "Writer")
|
||||
|
||||
|
||||
def _test_mixins_image(obj, attr):
|
||||
cap_attr = attr[:-1].capitalize()
|
||||
get_img_method = getattr(obj, attr)
|
||||
set_img_method = getattr(obj, "set" + cap_attr)
|
||||
upload_img_method = getattr(obj, "upload" + cap_attr)
|
||||
images = get_img_method()
|
||||
if images:
|
||||
default_image = images[0]
|
||||
image = images[0]
|
||||
assert len(image.key) >= 10
|
||||
if not image.ratingKey.startswith(("default://", "id://", "media://", "upload://")):
|
||||
assert image.provider
|
||||
assert len(image.ratingKey) >= 10
|
||||
assert utils.is_bool(image.selected)
|
||||
assert len(image.thumb) >= 10
|
||||
if len(images) >= 2:
|
||||
# Select a different image
|
||||
set_img_method(images[1])
|
||||
images = get_img_method()
|
||||
assert images[0].selected is False
|
||||
assert images[1].selected is True
|
||||
else:
|
||||
default_image = None
|
||||
# Test upload image from file
|
||||
upload_img_method(filepath=utils.STUB_IMAGE_PATH)
|
||||
images = get_img_method()
|
||||
file_image = [
|
||||
i for i in images
|
||||
if i.ratingKey.startswith('upload://') and i.ratingKey.endswith(CUTE_CAT_SHA1)
|
||||
]
|
||||
assert file_image
|
||||
# Reset to default image
|
||||
if default_image:
|
||||
set_img_method(default_image)
|
||||
|
||||
|
||||
def edit_art(obj):
|
||||
_test_mixins_image(obj, 'arts')
|
||||
|
||||
|
||||
def edit_banner(obj):
|
||||
_test_mixins_image(obj, 'banners')
|
||||
|
||||
|
||||
def edit_poster(obj):
|
||||
_test_mixins_image(obj, 'posters')
|
||||
|
||||
|
||||
def _test_mixins_imageUrl(obj, attr):
|
||||
url = getattr(obj, attr + 'Url')
|
||||
if getattr(obj, attr):
|
||||
assert url.startswith(utils.SERVER_BASEURL)
|
||||
assert "/library/metadata/" in url or "/library/collections/" in url
|
||||
assert attr in url or "composite" in url
|
||||
if attr == 'thumb':
|
||||
assert getattr(obj, 'posterUrl') == url
|
||||
else:
|
||||
assert url is None
|
||||
|
||||
|
||||
def attr_artUrl(obj):
|
||||
_test_mixins_imageUrl(obj, 'art')
|
||||
|
||||
|
||||
def attr_bannerUrl(obj):
|
||||
_test_mixins_imageUrl(obj, 'banner')
|
||||
|
||||
|
||||
def attr_posterUrl(obj):
|
||||
_test_mixins_imageUrl(obj, 'thumb')
|
|
@ -1,3 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import test_mixins
|
||||
|
||||
|
||||
def test_photo_Photoalbum(photoalbum):
|
||||
assert len(photoalbum.albums()) == 3
|
||||
assert len(photoalbum.photos()) == 3
|
||||
|
@ -5,3 +9,14 @@ def test_photo_Photoalbum(photoalbum):
|
|||
assert len(cats_in_bed.photos()) == 7
|
||||
a_pic = cats_in_bed.photo("photo7")
|
||||
assert a_pic
|
||||
|
||||
|
||||
def test_photo_Photoalbum_mixins_images(photoalbum):
|
||||
test_mixins.edit_art(photoalbum)
|
||||
test_mixins.edit_poster(photoalbum)
|
||||
test_mixins.attr_artUrl(photoalbum)
|
||||
test_mixins.attr_posterUrl(photoalbum)
|
||||
|
||||
|
||||
def test_photo_Photo_mixins_tags(photo):
|
||||
test_mixins.edit_tag(photo)
|
||||
|
|
|
@ -8,6 +8,7 @@ import pytest
|
|||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
||||
from . import conftest as utils
|
||||
from . import test_mixins
|
||||
|
||||
|
||||
def test_video_Movie(movies, movie):
|
||||
|
@ -39,16 +40,19 @@ def test_video_Movie_merge(movie, patched_http_call):
|
|||
movie.merge(1337)
|
||||
|
||||
|
||||
def test_video_Movie_addCollection(movie):
|
||||
labelname = "Random_label"
|
||||
org_collection = [tag.tag for tag in movie.collections if tag]
|
||||
assert labelname not in org_collection
|
||||
movie.addCollection(labelname)
|
||||
movie.reload()
|
||||
assert labelname in [tag.tag for tag in movie.collections if tag]
|
||||
movie.removeCollection(labelname)
|
||||
movie.reload()
|
||||
assert labelname not in [tag.tag for tag in movie.collections if tag]
|
||||
def test_video_Movie_mixins_images(movie):
|
||||
test_mixins.edit_art(movie)
|
||||
test_mixins.edit_poster(movie)
|
||||
|
||||
|
||||
def test_video_Movie_mixins_tags(movie):
|
||||
test_mixins.edit_collection(movie)
|
||||
test_mixins.edit_country(movie)
|
||||
test_mixins.edit_director(movie)
|
||||
test_mixins.edit_genre(movie)
|
||||
test_mixins.edit_label(movie)
|
||||
test_mixins.edit_producer(movie)
|
||||
test_mixins.edit_writer(movie)
|
||||
|
||||
|
||||
def test_video_Movie_getStreamURL(movie, account):
|
||||
|
@ -130,7 +134,7 @@ def test_video_Movie_upload_select_remove_subtitle(movie, subtitle):
|
|||
assert subname in subtitles
|
||||
|
||||
subtitleSelection = movie.subtitleStreams()[0]
|
||||
parts = [part for part in movie.iterParts()]
|
||||
parts = list(movie.iterParts())
|
||||
parts[0].setDefaultSubtitleStream(subtitleSelection)
|
||||
movie.reload()
|
||||
|
||||
|
@ -153,8 +157,8 @@ def test_video_Movie_attrs(movies):
|
|||
assert len(movie.locations) == 1
|
||||
assert len(movie.locations[0]) >= 10
|
||||
assert utils.is_datetime(movie.addedAt)
|
||||
assert utils.is_metadata(movie.art)
|
||||
assert movie.artUrl
|
||||
if movie.art:
|
||||
assert utils.is_art(movie.art)
|
||||
assert float(movie.rating) >= 6.4
|
||||
assert movie.ratingImage == 'rottentomatoes://image.rating.ripe'
|
||||
assert movie.audienceRating >= 8.5
|
||||
|
@ -195,6 +199,7 @@ def test_video_Movie_attrs(movies):
|
|||
assert movie.studio == "Nina Paley"
|
||||
assert utils.is_string(movie.summary, gte=100)
|
||||
assert movie.tagline == "The Greatest Break-Up Story Ever Told"
|
||||
if movie.thumb:
|
||||
assert utils.is_thumb(movie.thumb)
|
||||
assert movie.title == "Sita Sings the Blues"
|
||||
assert movie.titleSort == "Sita Sings the Blues"
|
||||
|
@ -524,14 +529,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()
|
||||
|
||||
|
||||
def test_video_Episode_updateProgress(episode, patched_http_call):
|
||||
episode.updateProgress(10 * 60 * 1000) # 10 minutes.
|
||||
|
||||
|
@ -551,8 +548,10 @@ def test_video_Episode_stop(episode, mocker, patched_http_call):
|
|||
|
||||
def test_video_Show_attrs(show):
|
||||
assert utils.is_datetime(show.addedAt)
|
||||
assert utils.is_metadata(show.art, contains="/art/")
|
||||
assert utils.is_metadata(show.banner, contains="/banner/")
|
||||
if show.art:
|
||||
assert utils.is_art(show.art)
|
||||
if show.banner:
|
||||
assert utils.is_banner(show.banner)
|
||||
assert utils.is_int(show.childCount)
|
||||
assert show.contentRating in utils.CONTENTRATINGS
|
||||
assert utils.is_int(show.duration, gte=1600000)
|
||||
|
@ -571,6 +570,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] == [
|
||||
|
@ -588,8 +588,10 @@ def test_video_Show_attrs(show):
|
|||
assert show._server._baseurl == utils.SERVER_BASEURL
|
||||
assert show.studio == "HBO"
|
||||
assert utils.is_string(show.summary, gte=100)
|
||||
assert show.tagline is None
|
||||
assert utils.is_metadata(show.theme, contains="/theme/")
|
||||
assert utils.is_metadata(show.thumb, contains="/thumb/")
|
||||
if show.thumb:
|
||||
assert utils.is_thumb(show.thumb)
|
||||
assert show.title == "Game of Thrones"
|
||||
assert show.titleSort == "Game of Thrones"
|
||||
assert show.type == "show"
|
||||
|
@ -609,17 +611,21 @@ def test_video_Show_history(show):
|
|||
|
||||
def test_video_Show_watched(tvshows):
|
||||
show = tvshows.get("The 100")
|
||||
show.episodes()[0].markWatched()
|
||||
episode = show.episodes()[0]
|
||||
episode.markWatched()
|
||||
watched = show.watched()
|
||||
assert len(watched) == 1 and watched[0].title == "Pilot"
|
||||
episode.markUnwatched()
|
||||
|
||||
|
||||
def test_video_Show_unwatched(tvshows):
|
||||
show = tvshows.get("The 100")
|
||||
episodes = show.episodes()
|
||||
episodes[0].markWatched()
|
||||
episode = episodes[0]
|
||||
episode.markWatched()
|
||||
unwatched = show.unwatched()
|
||||
assert len(unwatched) == len(episodes) - 1
|
||||
episode.markUnwatched()
|
||||
|
||||
|
||||
def test_video_Show_settings(show):
|
||||
|
@ -684,12 +690,6 @@ def test_video_Episode_download(monkeydownload, tmpdir, episode):
|
|||
assert len(with_sceen_size) == 1
|
||||
|
||||
|
||||
def test_video_Show_thumbUrl(show):
|
||||
assert utils.SERVER_BASEURL in show.thumbUrl
|
||||
assert "/library/metadata/" in show.thumbUrl
|
||||
assert "/thumb/" in show.thumbUrl
|
||||
|
||||
|
||||
# Analyze seems to fail intermittently
|
||||
@pytest.mark.xfail
|
||||
def test_video_Show_analyze(show):
|
||||
|
@ -723,6 +723,21 @@ def test_video_Show_section(show):
|
|||
assert section.title == "TV Shows"
|
||||
|
||||
|
||||
def test_video_Show_mixins_images(show):
|
||||
test_mixins.edit_art(show)
|
||||
test_mixins.edit_banner(show)
|
||||
test_mixins.edit_poster(show)
|
||||
test_mixins.attr_artUrl(show)
|
||||
test_mixins.attr_bannerUrl(show)
|
||||
test_mixins.attr_posterUrl(show)
|
||||
|
||||
|
||||
def test_video_Show_mixins_tags(show):
|
||||
test_mixins.edit_collection(show)
|
||||
test_mixins.edit_genre(show)
|
||||
test_mixins.edit_label(show)
|
||||
|
||||
|
||||
def test_video_Episode(show):
|
||||
episode = show.episode("Winter Is Coming")
|
||||
assert episode == show.episode(season=1, episode=1)
|
||||
|
@ -739,6 +754,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):
|
||||
|
@ -748,10 +788,16 @@ def test_video_Episode_analyze(tvshows):
|
|||
|
||||
def test_video_Episode_attrs(episode):
|
||||
assert utils.is_datetime(episode.addedAt)
|
||||
if episode.art:
|
||||
assert utils.is_art(episode.art)
|
||||
assert episode.contentRating in utils.CONTENTRATINGS
|
||||
if len(episode.directors):
|
||||
assert [i.tag for i in episode.directors] == ["Tim Van Patten"]
|
||||
assert utils.is_int(episode.duration, gte=120000)
|
||||
if episode.grandparentArt:
|
||||
assert utils.is_art(episode.grandparentArt)
|
||||
if episode.grandparentThumb:
|
||||
assert utils.is_thumb(episode.grandparentThumb)
|
||||
assert episode.grandparentTitle == "Game of Thrones"
|
||||
assert episode.index == 1
|
||||
assert utils.is_metadata(episode._initpath)
|
||||
|
@ -761,12 +807,15 @@ def test_video_Episode_attrs(episode):
|
|||
assert utils.is_int(episode.parentIndex)
|
||||
assert utils.is_metadata(episode.parentKey)
|
||||
assert utils.is_int(episode.parentRatingKey)
|
||||
assert utils.is_metadata(episode.parentThumb, contains="/thumb/")
|
||||
if episode.parentThumb:
|
||||
assert utils.is_thumb(episode.parentThumb)
|
||||
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/")
|
||||
if episode.thumb:
|
||||
assert utils.is_thumb(episode.thumb)
|
||||
assert episode.title == "Winter Is Coming"
|
||||
assert episode.titleSort == "Winter Is Coming"
|
||||
assert not episode.transcodeSessions
|
||||
|
@ -813,6 +862,18 @@ def test_video_Episode_attrs(episode):
|
|||
assert part.accessible
|
||||
|
||||
|
||||
def test_video_Episode_mixins_images(episode):
|
||||
#test_mixins.edit_art(episode) # Uploading episode artwork is broken in Plex
|
||||
test_mixins.edit_poster(episode)
|
||||
test_mixins.attr_artUrl(episode)
|
||||
test_mixins.attr_posterUrl(episode)
|
||||
|
||||
|
||||
def test_video_Episode_mixins_tags(episode):
|
||||
test_mixins.edit_director(episode)
|
||||
test_mixins.edit_writer(episode)
|
||||
|
||||
|
||||
def test_video_Season(show):
|
||||
seasons = show.seasons()
|
||||
assert len(seasons) == 2
|
||||
|
@ -828,9 +889,30 @@ def test_video_Season_history(show):
|
|||
season.markUnwatched()
|
||||
|
||||
|
||||
def test_video_Season_watched(tvshows):
|
||||
season = tvshows.get("The 100").season(1)
|
||||
episode = season.episode(1)
|
||||
episode.markWatched()
|
||||
watched = season.watched()
|
||||
assert len(watched) == 1 and watched[0].title == "Pilot"
|
||||
episode.markUnwatched()
|
||||
|
||||
|
||||
def test_video_Season_unwatched(tvshows):
|
||||
season = tvshows.get("The 100").season(1)
|
||||
episodes = season.episodes()
|
||||
episode = episodes[0]
|
||||
episode.markWatched()
|
||||
unwatched = season.unwatched()
|
||||
assert len(unwatched) == len(episodes) - 1
|
||||
episode.markUnwatched()
|
||||
|
||||
|
||||
def test_video_Season_attrs(show):
|
||||
season = show.season("Season 1")
|
||||
assert utils.is_datetime(season.addedAt)
|
||||
if season.art:
|
||||
assert utils.is_art(season.art)
|
||||
assert season.index == 1
|
||||
assert utils.is_metadata(season._initpath)
|
||||
assert utils.is_metadata(season.key)
|
||||
|
@ -839,11 +921,14 @@ def test_video_Season_attrs(show):
|
|||
assert season.listType == "video"
|
||||
assert utils.is_metadata(season.parentKey)
|
||||
assert utils.is_int(season.parentRatingKey)
|
||||
if season.parentThumb:
|
||||
assert utils.is_thumb(season.parentThumb)
|
||||
assert season.parentTitle == "Game of Thrones"
|
||||
assert utils.is_int(season.ratingKey)
|
||||
assert season._server._baseurl == utils.SERVER_BASEURL
|
||||
assert season.summary == ""
|
||||
assert utils.is_metadata(season.thumb, contains="/thumb/")
|
||||
if season.thumb:
|
||||
assert utils.is_thumb(season.thumb)
|
||||
assert season.title == "Season 1"
|
||||
assert season.titleSort == "Season 1"
|
||||
assert season.type == "season"
|
||||
|
@ -892,6 +977,14 @@ def test_video_Season_episodes(show):
|
|||
assert len(episodes) >= 1
|
||||
|
||||
|
||||
def test_video_Season_mixins_images(show):
|
||||
season = show.season(season=1)
|
||||
test_mixins.edit_art(season)
|
||||
test_mixins.edit_poster(season)
|
||||
test_mixins.attr_artUrl(season)
|
||||
test_mixins.attr_posterUrl(season)
|
||||
|
||||
|
||||
def test_that_reload_return_the_same_object(plex):
|
||||
# we want to check this that all the urls are correct
|
||||
movie_library_search = plex.library.section("Movies").search("Elephants Dream")[0]
|
||||
|
|
|
@ -51,7 +51,7 @@ def _iter_items(section):
|
|||
|
||||
def backup_watched(plex, opts):
|
||||
""" Backup watched status to the specified filepath. """
|
||||
data = defaultdict(lambda: dict())
|
||||
data = defaultdict(lambda: {})
|
||||
for section in _iter_sections(plex, opts):
|
||||
print('Fetching watched status for %s..' % section.title)
|
||||
skey = section.title.lower()
|
||||
|
@ -70,7 +70,7 @@ def restore_watched(plex, opts):
|
|||
with open(opts.filepath, 'r') as handle:
|
||||
source = json.load(handle)
|
||||
# Find the differences
|
||||
differences = defaultdict(lambda: dict())
|
||||
differences = defaultdict(lambda: {})
|
||||
for section in _iter_sections(plex, opts):
|
||||
print('Finding differences in %s..' % section.title)
|
||||
skey = section.title.lower()
|
||||
|
|
Loading…
Reference in a new issue