Merge branch 'master' into reviews_extras

This commit is contained in:
blacktwin 2021-03-09 08:26:52 -05:00 committed by GitHub
commit 9d2ec9546e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1380 additions and 765 deletions

View file

@ -1,7 +1,6 @@
Table of Contents
=================
.. include:: toc.rst
.. automodule:: myplex
Usage & Contributions
---------------------

View 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
View file

@ -0,0 +1,7 @@
.. include:: ../global.rst
Mixins :modname:`plexapi.mixins`
--------------------------------
.. automodule:: plexapi.mixins
:members:
:show-inheritance:

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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
View 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)

View file

@ -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."""

View file

@ -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.

View file

@ -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
View 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)

View file

@ -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)

View file

@ -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)

View file

@ -5,12 +5,13 @@ from plexapi import utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection
from plexapi.mixins import ArtMixin, PosterMixin
from plexapi.playqueue import PlayQueue
from plexapi.utils import cast, toDatetime
@utils.registerPlexObject
class Playlist(PlexPartialObject, Playable):
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
""" Represents a single Playlist.
Attributes:
@ -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()

View file

@ -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.

View file

@ -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},

View file

@ -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

View file

@ -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:

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
import os
import time
from datetime import datetime
from functools import partial
from os import environ
import plexapi
import pytest
@ -66,6 +66,11 @@ TEST_ANONYMOUSLY = "anonymously"
ANON_PARAM = pytest.param(TEST_ANONYMOUSLY, marks=pytest.mark.anonymous)
AUTH_PARAM = pytest.param(TEST_AUTHENTICATED, marks=pytest.mark.authenticated)
BASE_DIR_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STUB_MOVIE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "video_stub.mp4")
STUB_MP3_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "audio_stub.mp3")
STUB_IMAGE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "cute_cat.jpg")
def pytest_addoption(parser):
parser.addoption(
@ -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/")

View file

@ -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
View 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)

View file

@ -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):

View file

@ -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
View 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')

View file

@ -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)

View file

@ -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]

View file

@ -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()