mirror of
https://github.com/pkkid/python-plexapi
synced 2024-11-24 20:53:09 +00:00
Add LibrarySection
methods to multi-edit items (#1184)
* Use generator for string join * Group media type edit mixins * Add UserRatingMixin * Add LibrarySection methods to multi-edit items * Remove deprecated banners * Factor out resource lock/unlock mixins * Update `fetchItems` to accept list of rating keys * Add repr and helper methods to Common object * Update tests
This commit is contained in:
parent
b06afb8f84
commit
9ede493f4c
17 changed files with 468 additions and 168 deletions
|
@ -8,14 +8,12 @@ from plexapi.exceptions import BadRequest, NotFound
|
||||||
from plexapi.mixins import (
|
from plexapi.mixins import (
|
||||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
||||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
|
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
|
||||||
AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
|
ArtistEditMixins, AlbumEditMixins, TrackEditMixins
|
||||||
TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin,
|
|
||||||
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
|
|
||||||
)
|
)
|
||||||
from plexapi.playlist import Playlist
|
from plexapi.playlist import Playlist
|
||||||
|
|
||||||
|
|
||||||
class Audio(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin):
|
class Audio(PlexPartialObject, PlayedUnplayedMixin):
|
||||||
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
|
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
|
||||||
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
|
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
|
||||||
|
|
||||||
|
@ -132,8 +130,7 @@ class Artist(
|
||||||
Audio,
|
Audio,
|
||||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeMixin,
|
ArtMixin, PosterMixin, ThemeMixin,
|
||||||
SortTitleMixin, SummaryMixin, TitleMixin,
|
ArtistEditMixins
|
||||||
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Artist.
|
""" Represents a single Artist.
|
||||||
|
|
||||||
|
@ -244,8 +241,7 @@ class Album(
|
||||||
Audio,
|
Audio,
|
||||||
UnmatchMatchMixin, RatingMixin,
|
UnmatchMatchMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||||
OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
|
AlbumEditMixins
|
||||||
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Album.
|
""" Represents a single Album.
|
||||||
|
|
||||||
|
@ -364,8 +360,7 @@ class Track(
|
||||||
Audio, Playable,
|
Audio, Playable,
|
||||||
ExtrasMixin, RatingMixin,
|
ExtrasMixin, RatingMixin,
|
||||||
ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin,
|
ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin,
|
||||||
TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin,
|
TrackEditMixins
|
||||||
CollectionMixin, LabelMixin, MoodMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Track.
|
""" Represents a single Track.
|
||||||
|
|
||||||
|
|
|
@ -152,7 +152,9 @@ class PlexObject:
|
||||||
and attrs.
|
and attrs.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
ekey (str): API URL path in Plex to fetch items from.
|
ekey (str or List<int>): API URL path in Plex to fetch items from. If a list of ints is passed
|
||||||
|
in, the key will be translated to /library/metadata/<key1,key2,key3>. This allows
|
||||||
|
fetching multiple items only knowing their key-ids.
|
||||||
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
||||||
items to be fetched, passing this in will help the parser ensure
|
items to be fetched, passing this in will help the parser ensure
|
||||||
it only returns those items. By default we convert the xml elements
|
it only returns those items. By default we convert the xml elements
|
||||||
|
@ -225,6 +227,9 @@ class PlexObject:
|
||||||
if ekey is None:
|
if ekey is None:
|
||||||
raise BadRequest('ekey was not provided')
|
raise BadRequest('ekey was not provided')
|
||||||
|
|
||||||
|
if isinstance(ekey, list) and all(isinstance(key, int) for key in ekey):
|
||||||
|
ekey = f'/library/metadata/{",".join(str(key) for key in ekey)}'
|
||||||
|
|
||||||
container_start = container_start or 0
|
container_start = container_start or 0
|
||||||
container_size = container_size or X_PLEX_CONTAINER_SIZE
|
container_size = container_size or X_PLEX_CONTAINER_SIZE
|
||||||
offset = container_start
|
offset = container_start
|
||||||
|
@ -559,13 +564,10 @@ class PlexPartialObject(PlexObject):
|
||||||
self._edits.update(kwargs)
|
self._edits.update(kwargs)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
if 'id' not in kwargs:
|
|
||||||
kwargs['id'] = self.ratingKey
|
|
||||||
if 'type' not in kwargs:
|
if 'type' not in kwargs:
|
||||||
kwargs['type'] = utils.searchType(self._searchType)
|
kwargs['type'] = utils.searchType(self._searchType)
|
||||||
|
|
||||||
part = f'/library/sections/{self.librarySectionID}/all{utils.joinArgs(kwargs)}'
|
self.section()._edit(items=self, **kwargs)
|
||||||
self._server.query(part, method=self._server._session.put)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def edit(self, **kwargs):
|
def edit(self, **kwargs):
|
||||||
|
|
|
@ -8,8 +8,7 @@ from plexapi.library import LibrarySection, ManagedHub
|
||||||
from plexapi.mixins import (
|
from plexapi.mixins import (
|
||||||
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeMixin,
|
ArtMixin, PosterMixin, ThemeMixin,
|
||||||
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
CollectionEditMixins
|
||||||
LabelMixin
|
|
||||||
)
|
)
|
||||||
from plexapi.utils import deprecated
|
from plexapi.utils import deprecated
|
||||||
|
|
||||||
|
@ -19,8 +18,7 @@ class Collection(
|
||||||
PlexPartialObject,
|
PlexPartialObject,
|
||||||
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeMixin,
|
ArtMixin, PosterMixin, ThemeMixin,
|
||||||
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
CollectionEditMixins
|
||||||
LabelMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Collection.
|
""" Represents a single Collection.
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import quote_plus, urlencode
|
from urllib.parse import parse_qs, quote_plus, urlencode, urlparse
|
||||||
|
|
||||||
from plexapi import log, media, utils
|
from plexapi import log, media, utils
|
||||||
from plexapi.base import OPERATORS, PlexObject
|
from plexapi.base import OPERATORS, PlexObject
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
from plexapi.exceptions import BadRequest, NotFound
|
||||||
|
from plexapi.mixins import (
|
||||||
|
MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins,
|
||||||
|
ArtistEditMixins, AlbumEditMixins, TrackEditMixins, PhotoalbumEditMixins, PhotoEditMixins
|
||||||
|
)
|
||||||
from plexapi.settings import Setting
|
from plexapi.settings import Setting
|
||||||
from plexapi.utils import cached_property, deprecated
|
from plexapi.utils import cached_property, deprecated
|
||||||
|
|
||||||
|
@ -440,6 +444,20 @@ class LibrarySection(PlexObject):
|
||||||
self._getTotalDurationStorage()
|
self._getTotalDurationStorage()
|
||||||
return self._totalStorage
|
return self._totalStorage
|
||||||
|
|
||||||
|
def __getattribute__(self, attr):
|
||||||
|
# Intercept to call EditFieldMixin and EditTagMixin methods
|
||||||
|
# based on the item type being batch multi-edited
|
||||||
|
value = super().__getattribute__(attr)
|
||||||
|
if attr.startswith('_'): return value
|
||||||
|
if callable(value) and 'Mixin' in value.__qualname__:
|
||||||
|
if not isinstance(self._edits, dict):
|
||||||
|
raise AttributeError("Must enable batchMultiEdit() to use this method")
|
||||||
|
elif not hasattr(self._edits['items'][0], attr):
|
||||||
|
raise AttributeError(
|
||||||
|
f"Batch multi-editing '{self._edits['items'][0].__class__.__name__}' object has no attribute '{attr}'"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
def _getTotalDurationStorage(self):
|
def _getTotalDurationStorage(self):
|
||||||
""" Queries the Plex server for the total library duration and storage and caches the values. """
|
""" Queries the Plex server for the total library duration and storage and caches the values. """
|
||||||
data = self._server.query('/media/providers?includeStorage=1')
|
data = self._server.query('/media/providers?includeStorage=1')
|
||||||
|
@ -1658,8 +1676,101 @@ class LibrarySection(PlexObject):
|
||||||
params['pageType'] = 'list'
|
params['pageType'] = 'list'
|
||||||
return self._server._buildWebURL(base=base, **params)
|
return self._server._buildWebURL(base=base, **params)
|
||||||
|
|
||||||
|
def _validateItems(self, items):
|
||||||
|
""" Validates the specified items are from this library and of the same type. """
|
||||||
|
if not items:
|
||||||
|
raise BadRequest('No items specified.')
|
||||||
|
|
||||||
|
if not isinstance(items, list):
|
||||||
|
items = [items]
|
||||||
|
|
||||||
|
itemType = items[0].type
|
||||||
|
for item in items:
|
||||||
|
if item.librarySectionID != self.key:
|
||||||
|
raise BadRequest(f'{item.title} is not from this library.')
|
||||||
|
elif item.type != itemType:
|
||||||
|
raise BadRequest(f'Cannot mix items of different type: {itemType} and {item.type}')
|
||||||
|
|
||||||
class MovieSection(LibrarySection):
|
return items
|
||||||
|
|
||||||
|
def common(self, items):
|
||||||
|
""" Returns a :class:`~plexapi.library.Common` object for the specified items. """
|
||||||
|
params = {
|
||||||
|
'id': ','.join(str(item.ratingKey) for item in self._validateItems(items)),
|
||||||
|
'type': utils.searchType(items[0].type)
|
||||||
|
}
|
||||||
|
part = f'/library/sections/{self.key}/common{utils.joinArgs(params)}'
|
||||||
|
return self.fetchItem(part, cls=Common)
|
||||||
|
|
||||||
|
def _edit(self, items=None, **kwargs):
|
||||||
|
""" Actually edit multiple objects. """
|
||||||
|
if isinstance(self._edits, dict):
|
||||||
|
self._edits.update(kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
kwargs['id'] = ','.join(str(item.ratingKey) for item in self._validateItems(items))
|
||||||
|
if 'type' not in kwargs:
|
||||||
|
kwargs['type'] = utils.searchType(items[0].type)
|
||||||
|
|
||||||
|
part = f'/library/sections/{self.key}/all{utils.joinArgs(kwargs)}'
|
||||||
|
self._server.query(part, method=self._server._session.put)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def multiEdit(self, items, **kwargs):
|
||||||
|
""" Edit multiple objects at once.
|
||||||
|
Note: This is a low level method and you need to know all the field/tag keys.
|
||||||
|
See :class:`~plexapi.LibrarySection.batchMultiEdits` instead.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||||
|
:class:`~plexapi.photo.Photo`, or :class:`~plexapi.collection.Collection`
|
||||||
|
objects to be edited.
|
||||||
|
kwargs (dict): Dict of settings to edit.
|
||||||
|
"""
|
||||||
|
return self._edit(items, **kwargs)
|
||||||
|
|
||||||
|
def batchMultiEdits(self, items):
|
||||||
|
""" Enable batch multi-editing mode to save API calls.
|
||||||
|
Must call :func:`~plexapi.library.LibrarySection.saveMultiEdits` at the end to save all the edits.
|
||||||
|
See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin`
|
||||||
|
for individual field and tag editing methods.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||||
|
:class:`~plexapi.photo.Photo`, or :class:`~plexapi.collection.Collection`
|
||||||
|
objects to be edited.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
movies = MovieSection.all()
|
||||||
|
items = [movies[0], movies[3], movies[5]]
|
||||||
|
|
||||||
|
# Batch multi-editing multiple fields and tags in a single API call
|
||||||
|
MovieSection.batchMultiEdits(items)
|
||||||
|
MovieSection.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline') \\
|
||||||
|
.addCollection('New Collection').removeGenre('Action').addLabel('Favorite')
|
||||||
|
MovieSection.saveMultiEdits()
|
||||||
|
|
||||||
|
"""
|
||||||
|
self._edits = {'items': self._validateItems(items)}
|
||||||
|
return self
|
||||||
|
|
||||||
|
def saveMultiEdits(self):
|
||||||
|
""" Save all the batch multi-edits.
|
||||||
|
See :func:`~plexapi.library.LibrarySection.batchMultiEdits` for details.
|
||||||
|
"""
|
||||||
|
if not isinstance(self._edits, dict):
|
||||||
|
raise BadRequest('Batch multi-editing mode not enabled. Must call `batchMultiEdits()` first.')
|
||||||
|
|
||||||
|
edits = self._edits
|
||||||
|
self._edits = None
|
||||||
|
self._edit(items=edits.pop('items'), **edits)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class MovieSection(LibrarySection, MovieEditMixins):
|
||||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -1719,7 +1830,7 @@ class MovieSection(LibrarySection):
|
||||||
return super(MovieSection, self).sync(**kwargs)
|
return super(MovieSection, self).sync(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ShowSection(LibrarySection):
|
class ShowSection(LibrarySection, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins):
|
||||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows.
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -1803,7 +1914,7 @@ class ShowSection(LibrarySection):
|
||||||
return super(ShowSection, self).sync(**kwargs)
|
return super(ShowSection, self).sync(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class MusicSection(LibrarySection):
|
class MusicSection(LibrarySection, ArtistEditMixins, AlbumEditMixins, TrackEditMixins):
|
||||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing music artists.
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing music artists.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -1895,7 +2006,7 @@ class MusicSection(LibrarySection):
|
||||||
return super(MusicSection, self).sync(**kwargs)
|
return super(MusicSection, self).sync(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class PhotoSection(LibrarySection):
|
class PhotoSection(LibrarySection, PhotoalbumEditMixins, PhotoEditMixins):
|
||||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -2160,16 +2271,6 @@ class Autotag(LibraryMediaTag):
|
||||||
TAGTYPE = 207
|
TAGTYPE = 207
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
|
||||||
class Banner(LibraryMediaTag):
|
|
||||||
""" Represents a single Banner library media tag.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
TAGTYPE (int): 311
|
|
||||||
"""
|
|
||||||
TAGTYPE = 311
|
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Chapter(LibraryMediaTag):
|
class Chapter(LibraryMediaTag):
|
||||||
""" Represents a single Chapter library media tag.
|
""" Represents a single Chapter library media tag.
|
||||||
|
@ -3005,7 +3106,6 @@ class Path(PlexObject):
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Path'
|
TAG (str): 'Path'
|
||||||
|
|
||||||
home (bool): True if the path is the home directory
|
home (bool): True if the path is the home directory
|
||||||
key (str): API URL (/services/browse/<base64path>)
|
key (str): API URL (/services/browse/<base64path>)
|
||||||
network (bool): True if path is a network location
|
network (bool): True if path is a network location
|
||||||
|
@ -3037,7 +3137,6 @@ class File(PlexObject):
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'File'
|
TAG (str): 'File'
|
||||||
|
|
||||||
key (str): API URL (/services/browse/<base64path>)
|
key (str): API URL (/services/browse/<base64path>)
|
||||||
path (str): Full path to file
|
path (str): Full path to file
|
||||||
title (str): File name
|
title (str): File name
|
||||||
|
@ -3048,3 +3147,105 @@ class File(PlexObject):
|
||||||
self.key = data.attrib.get('key')
|
self.key = data.attrib.get('key')
|
||||||
self.path = data.attrib.get('path')
|
self.path = data.attrib.get('path')
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
|
|
||||||
|
|
||||||
|
@utils.registerPlexObject
|
||||||
|
class Common(PlexObject):
|
||||||
|
""" Represents a Common element from a library. This object lists common fields between multiple objects.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
TAG (str): 'Common'
|
||||||
|
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||||
|
contentRating (str): Content rating of the items.
|
||||||
|
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
|
||||||
|
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
|
||||||
|
editionTitle (str): Edition title of the items.
|
||||||
|
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||||
|
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||||
|
grandparentRatingKey (int): Grandparent rating key of the items.
|
||||||
|
grandparentTitle (str): Grandparent title of the items.
|
||||||
|
guid (str): Plex GUID of the items.
|
||||||
|
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||||
|
index (int): Index of the items.
|
||||||
|
key (str): API URL (/library/metadata/<ratingkey>).
|
||||||
|
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||||
|
mixedFields (List<str>): List of mixed fields.
|
||||||
|
moods (List<:class:`~plexapi.media.Mood`>): List of mood objects.
|
||||||
|
originallyAvailableAt (datetime): Datetime of the release date of the items.
|
||||||
|
parentRatingKey (int): Parent rating key of the items.
|
||||||
|
parentTitle (str): Parent title of the items.
|
||||||
|
producers (List<:class:`~plexapi.media.Producer`>): List of producer objects.
|
||||||
|
ratingKey (int): Rating key of the items.
|
||||||
|
ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects.
|
||||||
|
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||||
|
studio (str): Studio name of the items.
|
||||||
|
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||||
|
summary (str): Summary of the items.
|
||||||
|
tagline (str): Tagline of the items.
|
||||||
|
tags (List<:class:`~plexapi.media.Tag`>): List of tag objects.
|
||||||
|
title (str): Title of the items.
|
||||||
|
titleSort (str): Title to use when sorting of the items.
|
||||||
|
type (str): Type of the media (common).
|
||||||
|
writers (List<:class:`~plexapi.media.Writer`>): List of writer objects.
|
||||||
|
year (int): Year of the items.
|
||||||
|
"""
|
||||||
|
TAG = 'Common'
|
||||||
|
|
||||||
|
def _loadData(self, data):
|
||||||
|
self._data = data
|
||||||
|
self.collections = self.findItems(data, media.Collection)
|
||||||
|
self.contentRating = data.attrib.get('contentRating')
|
||||||
|
self.countries = self.findItems(data, media.Country)
|
||||||
|
self.directors = self.findItems(data, media.Director)
|
||||||
|
self.editionTitle = data.attrib.get('editionTitle')
|
||||||
|
self.fields = self.findItems(data, media.Field)
|
||||||
|
self.genres = self.findItems(data, media.Genre)
|
||||||
|
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
|
||||||
|
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||||
|
self.guid = data.attrib.get('guid')
|
||||||
|
self.guids = self.findItems(data, media.Guid)
|
||||||
|
self.index = utils.cast(int, data.attrib.get('index'))
|
||||||
|
self.key = data.attrib.get('key')
|
||||||
|
self.labels = self.findItems(data, media.Label)
|
||||||
|
self.mixedFields = data.attrib.get('mixedFields').split(',')
|
||||||
|
self.moods = self.findItems(data, media.Mood)
|
||||||
|
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'))
|
||||||
|
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||||
|
self.parentTitle = data.attrib.get('parentTitle')
|
||||||
|
self.producers = self.findItems(data, media.Producer)
|
||||||
|
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||||
|
self.ratings = self.findItems(data, media.Rating)
|
||||||
|
self.roles = self.findItems(data, media.Role)
|
||||||
|
self.studio = data.attrib.get('studio')
|
||||||
|
self.styles = self.findItems(data, media.Style)
|
||||||
|
self.summary = data.attrib.get('summary')
|
||||||
|
self.tagline = data.attrib.get('tagline')
|
||||||
|
self.tags = self.findItems(data, media.Tag)
|
||||||
|
self.title = data.attrib.get('title')
|
||||||
|
self.titleSort = data.attrib.get('titleSort')
|
||||||
|
self.type = data.attrib.get('type')
|
||||||
|
self.writers = self.findItems(data, media.Writer)
|
||||||
|
self.year = utils.cast(int, data.attrib.get('year'))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<%s:%s:%s>' % (
|
||||||
|
self.__class__.__name__,
|
||||||
|
self.commonType,
|
||||||
|
','.join(str(key) for key in self.ratingKeys)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def commonType(self):
|
||||||
|
""" Returns the media type of the common items. """
|
||||||
|
parsed_query = parse_qs(urlparse(self._initpath).query)
|
||||||
|
return utils.reverseSearchType(parsed_query['type'][0])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ratingKeys(self):
|
||||||
|
""" Returns a list of rating keys for the common items. """
|
||||||
|
parsed_query = parse_qs(urlparse(self._initpath).query)
|
||||||
|
return [int(value.strip()) for value in parsed_query['id'][0].split(',')]
|
||||||
|
|
||||||
|
def items(self):
|
||||||
|
""" Returns a list of the common items. """
|
||||||
|
return self._server.fetchItems(self.ratingKeys)
|
||||||
|
|
|
@ -955,7 +955,7 @@ class Review(PlexObject):
|
||||||
|
|
||||||
|
|
||||||
class BaseResource(PlexObject):
|
class BaseResource(PlexObject):
|
||||||
""" Base class for all Art, Banner, Poster, and Theme objects.
|
""" Base class for all Art, Poster, and Theme objects.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
TAG (str): 'Photo' or 'Track'
|
TAG (str): 'Photo' or 'Track'
|
||||||
|
@ -987,11 +987,6 @@ class Art(BaseResource):
|
||||||
TAG = 'Photo'
|
TAG = 'Photo'
|
||||||
|
|
||||||
|
|
||||||
class Banner(BaseResource):
|
|
||||||
""" Represents a single Banner object. """
|
|
||||||
TAG = 'Photo'
|
|
||||||
|
|
||||||
|
|
||||||
class Poster(BaseResource):
|
class Poster(BaseResource):
|
||||||
""" Represents a single Poster object. """
|
""" Represents a single Poster object. """
|
||||||
TAG = 'Photo'
|
TAG = 'Photo'
|
||||||
|
|
|
@ -138,7 +138,7 @@ class SplitMergeMixin:
|
||||||
if not isinstance(ratingKeys, list):
|
if not isinstance(ratingKeys, list):
|
||||||
ratingKeys = str(ratingKeys).split(',')
|
ratingKeys = str(ratingKeys).split(',')
|
||||||
|
|
||||||
key = f"{self.key}/merge?ids={','.join([str(r) for r in ratingKeys])}"
|
key = f"{self.key}/merge?ids={','.join(str(r) for r in ratingKeys)}"
|
||||||
self._server.query(key, method=self._server._session.put)
|
self._server.query(key, method=self._server._session.put)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@ -328,7 +328,19 @@ class ArtUrlMixin:
|
||||||
return self._server.url(art, includeToken=True) if art else None
|
return self._server.url(art, includeToken=True) if art else None
|
||||||
|
|
||||||
|
|
||||||
class ArtMixin(ArtUrlMixin):
|
class ArtLockMixin:
|
||||||
|
""" Mixin for Plex objects that can have a locked background artwork. """
|
||||||
|
|
||||||
|
def lockArt(self):
|
||||||
|
""" Lock the background artwork for a Plex object. """
|
||||||
|
return self._edit(**{'art.locked': 1})
|
||||||
|
|
||||||
|
def unlockArt(self):
|
||||||
|
""" Unlock the background artwork for a Plex object. """
|
||||||
|
return self._edit(**{'art.locked': 0})
|
||||||
|
|
||||||
|
|
||||||
|
class ArtMixin(ArtUrlMixin, ArtLockMixin):
|
||||||
""" Mixin for Plex objects that can have background artwork. """
|
""" Mixin for Plex objects that can have background artwork. """
|
||||||
|
|
||||||
def arts(self):
|
def arts(self):
|
||||||
|
@ -360,65 +372,6 @@ class ArtMixin(ArtUrlMixin):
|
||||||
art.select()
|
art.select()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def lockArt(self):
|
|
||||||
""" Lock the background artwork for a Plex object. """
|
|
||||||
return self._edit(**{'art.locked': 1})
|
|
||||||
|
|
||||||
def unlockArt(self):
|
|
||||||
""" Unlock the background artwork for a Plex object. """
|
|
||||||
return self._edit(**{'art.locked': 0})
|
|
||||||
|
|
||||||
|
|
||||||
class BannerUrlMixin:
|
|
||||||
""" 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(f'/library/metadata/{self.ratingKey}/banners', 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 or file-like object.
|
|
||||||
"""
|
|
||||||
if url:
|
|
||||||
key = f'/library/metadata/{self.ratingKey}/banners?url={quote_plus(url)}'
|
|
||||||
self._server.query(key, method=self._server._session.post)
|
|
||||||
elif filepath:
|
|
||||||
key = f'/library/metadata/{self.ratingKey}/banners'
|
|
||||||
data = openOrRead(filepath)
|
|
||||||
self._server.query(key, method=self._server._session.post, data=data)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def setBanner(self, banner):
|
|
||||||
""" Set the banner for a Plex object.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
banner (:class:`~plexapi.media.Banner`): The banner object to select.
|
|
||||||
"""
|
|
||||||
banner.select()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def lockBanner(self):
|
|
||||||
""" Lock the banner for a Plex object. """
|
|
||||||
return self._edit(**{'banner.locked': 1})
|
|
||||||
|
|
||||||
def unlockBanner(self):
|
|
||||||
""" Unlock the banner for a Plex object. """
|
|
||||||
return self._edit(**{'banner.locked': 0})
|
|
||||||
|
|
||||||
|
|
||||||
class PosterUrlMixin:
|
class PosterUrlMixin:
|
||||||
""" Mixin for Plex objects that can have a poster url. """
|
""" Mixin for Plex objects that can have a poster url. """
|
||||||
|
@ -435,7 +388,19 @@ class PosterUrlMixin:
|
||||||
return self.thumbUrl
|
return self.thumbUrl
|
||||||
|
|
||||||
|
|
||||||
class PosterMixin(PosterUrlMixin):
|
class PosterLockMixin:
|
||||||
|
""" Mixin for Plex objects that can have a locked poster. """
|
||||||
|
|
||||||
|
def lockPoster(self):
|
||||||
|
""" Lock the poster for a Plex object. """
|
||||||
|
return self._edit(**{'thumb.locked': 1})
|
||||||
|
|
||||||
|
def unlockPoster(self):
|
||||||
|
""" Unlock the poster for a Plex object. """
|
||||||
|
return self._edit(**{'thumb.locked': 0})
|
||||||
|
|
||||||
|
|
||||||
|
class PosterMixin(PosterUrlMixin, PosterLockMixin):
|
||||||
""" Mixin for Plex objects that can have posters. """
|
""" Mixin for Plex objects that can have posters. """
|
||||||
|
|
||||||
def posters(self):
|
def posters(self):
|
||||||
|
@ -467,14 +432,6 @@ class PosterMixin(PosterUrlMixin):
|
||||||
poster.select()
|
poster.select()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def lockPoster(self):
|
|
||||||
""" Lock the poster for a Plex object. """
|
|
||||||
return self._edit(**{'thumb.locked': 1})
|
|
||||||
|
|
||||||
def unlockPoster(self):
|
|
||||||
""" Unlock the poster for a Plex object. """
|
|
||||||
return self._edit(**{'thumb.locked': 0})
|
|
||||||
|
|
||||||
|
|
||||||
class ThemeUrlMixin:
|
class ThemeUrlMixin:
|
||||||
""" Mixin for Plex objects that can have a theme url. """
|
""" Mixin for Plex objects that can have a theme url. """
|
||||||
|
@ -486,7 +443,19 @@ class ThemeUrlMixin:
|
||||||
return self._server.url(theme, includeToken=True) if theme else None
|
return self._server.url(theme, includeToken=True) if theme else None
|
||||||
|
|
||||||
|
|
||||||
class ThemeMixin(ThemeUrlMixin):
|
class ThemeLockMixin:
|
||||||
|
""" Mixin for Plex objects that can have a locked theme. """
|
||||||
|
|
||||||
|
def lockTheme(self):
|
||||||
|
""" Lock the theme for a Plex object. """
|
||||||
|
return self._edit(**{'theme.locked': 1})
|
||||||
|
|
||||||
|
def unlockTheme(self):
|
||||||
|
""" Unlock the theme for a Plex object. """
|
||||||
|
return self._edit(**{'theme.locked': 0})
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeMixin(ThemeUrlMixin, ThemeLockMixin):
|
||||||
""" Mixin for Plex objects that can have themes. """
|
""" Mixin for Plex objects that can have themes. """
|
||||||
|
|
||||||
def themes(self):
|
def themes(self):
|
||||||
|
@ -519,14 +488,6 @@ class ThemeMixin(ThemeUrlMixin):
|
||||||
'Re-upload the theme using "uploadTheme" to set it.'
|
'Re-upload the theme using "uploadTheme" to set it.'
|
||||||
)
|
)
|
||||||
|
|
||||||
def lockTheme(self):
|
|
||||||
""" Lock the theme for a Plex object. """
|
|
||||||
return self._edit(**{'theme.locked': 1})
|
|
||||||
|
|
||||||
def unlockTheme(self):
|
|
||||||
""" Unlock the theme for a Plex object. """
|
|
||||||
return self._edit(**{'theme.locked': 0})
|
|
||||||
|
|
||||||
|
|
||||||
class EditFieldMixin:
|
class EditFieldMixin:
|
||||||
""" Mixin for editing Plex object fields. """
|
""" Mixin for editing Plex object fields. """
|
||||||
|
@ -751,6 +712,19 @@ class PhotoCapturedTimeMixin(EditFieldMixin):
|
||||||
return self.editField('originallyAvailableAt', capturedTime, locked=locked)
|
return self.editField('originallyAvailableAt', capturedTime, locked=locked)
|
||||||
|
|
||||||
|
|
||||||
|
class UserRatingMixin(EditFieldMixin):
|
||||||
|
""" Mixin for Plex objects that can have a user rating. """
|
||||||
|
|
||||||
|
def editUserRating(self, userRating, locked=True):
|
||||||
|
""" Edit the user rating.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
userRating (int): The new value.
|
||||||
|
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||||
|
"""
|
||||||
|
return self.editField('userRating', userRating, locked=locked)
|
||||||
|
|
||||||
|
|
||||||
class EditTagsMixin:
|
class EditTagsMixin:
|
||||||
""" Mixin for editing Plex object tags. """
|
""" Mixin for editing Plex object tags. """
|
||||||
|
|
||||||
|
@ -780,7 +754,7 @@ class EditTagsMixin:
|
||||||
items = [items]
|
items = [items]
|
||||||
|
|
||||||
if not remove:
|
if not remove:
|
||||||
tags = getattr(self, self._tagPlural(tag))
|
tags = getattr(self, self._tagPlural(tag), [])
|
||||||
items = tags + items
|
items = tags + items
|
||||||
|
|
||||||
edits = self._tagHelper(self._tagSingular(tag), items, locked, remove)
|
edits = self._tagHelper(self._tagSingular(tag), items, locked, remove)
|
||||||
|
@ -821,7 +795,7 @@ class EditTagsMixin:
|
||||||
|
|
||||||
if remove:
|
if remove:
|
||||||
tagname = f'{tag}[].tag.tag-'
|
tagname = f'{tag}[].tag.tag-'
|
||||||
data[tagname] = ','.join([quote(str(t)) for t in items])
|
data[tagname] = ','.join(quote(str(t)) for t in items)
|
||||||
else:
|
else:
|
||||||
for i, item in enumerate(items):
|
for i, item in enumerate(items):
|
||||||
tagname = f'{str(tag)}[{i}].tag.tag'
|
tagname = f'{str(tag)}[{i}].tag.tag'
|
||||||
|
@ -1134,3 +1108,84 @@ class WatchlistMixin:
|
||||||
ratingKey = self.guid.rsplit('/', 1)[-1]
|
ratingKey = self.guid.rsplit('/', 1)[-1]
|
||||||
data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities")
|
data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities")
|
||||||
return self.findItems(data)
|
return self.findItems(data)
|
||||||
|
|
||||||
|
|
||||||
|
class MovieEditMixins(
|
||||||
|
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||||
|
AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin,
|
||||||
|
StudioMixin, SummaryMixin, TaglineMixin, TitleMixin, UserRatingMixin,
|
||||||
|
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ShowEditMixins(
|
||||||
|
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||||
|
AddedAtMixin, ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
||||||
|
SummaryMixin, TaglineMixin, TitleMixin, UserRatingMixin,
|
||||||
|
CollectionMixin, GenreMixin, LabelMixin,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SeasonEditMixins(
|
||||||
|
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||||
|
AddedAtMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||||
|
CollectionMixin, LabelMixin
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeEditMixins(
|
||||||
|
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||||
|
AddedAtMixin, ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||||
|
CollectionMixin, DirectorMixin, LabelMixin, WriterMixin
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistEditMixins(
|
||||||
|
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||||
|
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||||
|
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumEditMixins(
|
||||||
|
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||||
|
AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||||
|
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TrackEditMixins(
|
||||||
|
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||||
|
AddedAtMixin, TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin, UserRatingMixin,
|
||||||
|
CollectionMixin, LabelMixin, MoodMixin
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoalbumEditMixins(
|
||||||
|
ArtLockMixin, PosterLockMixin,
|
||||||
|
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoEditMixins(
|
||||||
|
ArtLockMixin, PosterLockMixin,
|
||||||
|
AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||||
|
TagMixin
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionEditMixins(
|
||||||
|
ArtLockMixin, PosterLockMixin, ThemeLockMixin,
|
||||||
|
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin,
|
||||||
|
LabelMixin
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
|
@ -8,8 +8,7 @@ from plexapi.exceptions import BadRequest
|
||||||
from plexapi.mixins import (
|
from plexapi.mixins import (
|
||||||
RatingMixin,
|
RatingMixin,
|
||||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin,
|
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin,
|
||||||
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, PhotoCapturedTimeMixin,
|
PhotoalbumEditMixins, PhotoEditMixins
|
||||||
TagMixin
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,7 +17,7 @@ class Photoalbum(
|
||||||
PlexPartialObject,
|
PlexPartialObject,
|
||||||
RatingMixin,
|
RatingMixin,
|
||||||
ArtMixin, PosterMixin,
|
ArtMixin, PosterMixin,
|
||||||
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin
|
PhotoalbumEditMixins
|
||||||
):
|
):
|
||||||
""" Represents a single Photoalbum (collection of photos).
|
""" Represents a single Photoalbum (collection of photos).
|
||||||
|
|
||||||
|
@ -146,8 +145,7 @@ class Photo(
|
||||||
PlexPartialObject, Playable,
|
PlexPartialObject, Playable,
|
||||||
RatingMixin,
|
RatingMixin,
|
||||||
ArtUrlMixin, PosterUrlMixin,
|
ArtUrlMixin, PosterUrlMixin,
|
||||||
AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
PhotoEditMixins
|
||||||
TagMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Photo.
|
""" Represents a single Photo.
|
||||||
|
|
||||||
|
|
|
@ -170,7 +170,7 @@ class PlayQueue(PlexObject):
|
||||||
}
|
}
|
||||||
|
|
||||||
if isinstance(items, list):
|
if isinstance(items, list):
|
||||||
item_keys = ",".join([str(x.ratingKey) for x in items])
|
item_keys = ",".join(str(x.ratingKey) for x in items)
|
||||||
uri_args = quote_plus(f"/library/metadata/{item_keys}")
|
uri_args = quote_plus(f"/library/metadata/{item_keys}")
|
||||||
args["uri"] = f"library:///directory/{uri_args}"
|
args["uri"] = f"library:///directory/{uri_args}"
|
||||||
args["type"] = items[0].listType
|
args["type"] = items[0].listType
|
||||||
|
|
|
@ -81,7 +81,7 @@ class Settings(PlexObject):
|
||||||
params[setting.id] = quote(setting._setValue)
|
params[setting.id] = quote(setting._setValue)
|
||||||
if not params:
|
if not params:
|
||||||
raise BadRequest('No setting have been modified.')
|
raise BadRequest('No setting have been modified.')
|
||||||
querystr = '&'.join([f'{k}={v}' for k, v in params.items()])
|
querystr = '&'.join(f'{k}={v}' for k, v in params.items())
|
||||||
url = f'{self.key}?{querystr}'
|
url = f'{self.key}?{querystr}'
|
||||||
self._server.query(url, self._server._session.put)
|
self._server.query(url, self._server._session.put)
|
||||||
self.reload()
|
self.reload()
|
||||||
|
|
|
@ -7,15 +7,13 @@ from plexapi.base import Playable, PlexPartialObject, PlexSession
|
||||||
from plexapi.exceptions import BadRequest
|
from plexapi.exceptions import BadRequest
|
||||||
from plexapi.mixins import (
|
from plexapi.mixins import (
|
||||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
||||||
ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
|
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
|
||||||
AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin,
|
MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins,
|
||||||
StudioMixin, SummaryMixin, TaglineMixin, TitleMixin,
|
|
||||||
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
|
|
||||||
WatchlistMixin
|
WatchlistMixin
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Video(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin):
|
class Video(PlexPartialObject, PlayedUnplayedMixin):
|
||||||
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
||||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
||||||
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
|
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
|
||||||
|
@ -309,9 +307,7 @@ class Movie(
|
||||||
Video, Playable,
|
Video, Playable,
|
||||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeMixin,
|
ArtMixin, PosterMixin, ThemeMixin,
|
||||||
ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
MovieEditMixins,
|
||||||
SummaryMixin, TaglineMixin, TitleMixin,
|
|
||||||
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
|
|
||||||
WatchlistMixin
|
WatchlistMixin
|
||||||
):
|
):
|
||||||
""" Represents a single Movie.
|
""" Represents a single Movie.
|
||||||
|
@ -453,10 +449,8 @@ class Movie(
|
||||||
class Show(
|
class Show(
|
||||||
Video,
|
Video,
|
||||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||||
ArtMixin, BannerMixin, PosterMixin, ThemeMixin,
|
ArtMixin, PosterMixin, ThemeMixin,
|
||||||
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
|
ShowEditMixins,
|
||||||
SummaryMixin, TaglineMixin, TitleMixin,
|
|
||||||
CollectionMixin, GenreMixin, LabelMixin,
|
|
||||||
WatchlistMixin
|
WatchlistMixin
|
||||||
):
|
):
|
||||||
""" Represents a single Show (including all seasons and episodes).
|
""" Represents a single Show (including all seasons and episodes).
|
||||||
|
@ -474,7 +468,6 @@ class Show(
|
||||||
autoDeletionItemPolicyWatchedLibrary (int): Setting that indicates if episodes are deleted
|
autoDeletionItemPolicyWatchedLibrary (int): Setting that indicates if episodes are deleted
|
||||||
after being watched for the show (0 = Never, 1 = After a day, 7 = After a week,
|
after being watched for the show (0 = Never, 1 = After a day, 7 = After a week,
|
||||||
100 = On next refresh).
|
100 = On next refresh).
|
||||||
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/banner/<bannerid>).
|
|
||||||
childCount (int): Number of seasons (including Specials) in the show.
|
childCount (int): Number of seasons (including Specials) in the show.
|
||||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||||
|
@ -528,7 +521,6 @@ class Show(
|
||||||
int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0'))
|
int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0'))
|
||||||
self.autoDeletionItemPolicyWatchedLibrary = utils.cast(
|
self.autoDeletionItemPolicyWatchedLibrary = utils.cast(
|
||||||
int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0'))
|
int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0'))
|
||||||
self.banner = data.attrib.get('banner')
|
|
||||||
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
||||||
self.collections = self.findItems(data, media.Collection)
|
self.collections = self.findItems(data, media.Collection)
|
||||||
self.contentRating = data.attrib.get('contentRating')
|
self.contentRating = data.attrib.get('contentRating')
|
||||||
|
@ -666,8 +658,7 @@ class Season(
|
||||||
Video,
|
Video,
|
||||||
AdvancedSettingsMixin, ExtrasMixin, RatingMixin,
|
AdvancedSettingsMixin, ExtrasMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||||
SummaryMixin, TitleMixin,
|
SeasonEditMixins
|
||||||
CollectionMixin, LabelMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Show Season (including all episodes).
|
""" Represents a single Show Season (including all episodes).
|
||||||
|
|
||||||
|
@ -820,8 +811,7 @@ class Episode(
|
||||||
Video, Playable,
|
Video, Playable,
|
||||||
ExtrasMixin, RatingMixin,
|
ExtrasMixin, RatingMixin,
|
||||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||||
ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin,
|
EpisodeEditMixins
|
||||||
CollectionMixin, DirectorMixin, LabelMixin, WriterMixin
|
|
||||||
):
|
):
|
||||||
""" Represents a single Shows Episode.
|
""" Represents a single Shows Episode.
|
||||||
|
|
||||||
|
|
|
@ -416,10 +416,6 @@ def is_art(key):
|
||||||
return is_metadata(key, contains="/art/")
|
return is_metadata(key, contains="/art/")
|
||||||
|
|
||||||
|
|
||||||
def is_banner(key):
|
|
||||||
return is_metadata(key, contains="/banner/")
|
|
||||||
|
|
||||||
|
|
||||||
def is_thumb(key):
|
def is_thumb(key):
|
||||||
return is_metadata(key, contains="/thumb/")
|
return is_metadata(key, contains="/thumb/")
|
||||||
|
|
||||||
|
|
|
@ -110,6 +110,7 @@ def test_audio_Artist_mixins_fields(artist):
|
||||||
test_mixins.edit_sort_title(artist)
|
test_mixins.edit_sort_title(artist)
|
||||||
test_mixins.edit_summary(artist)
|
test_mixins.edit_summary(artist)
|
||||||
test_mixins.edit_title(artist)
|
test_mixins.edit_title(artist)
|
||||||
|
test_mixins.edit_user_rating(artist)
|
||||||
|
|
||||||
|
|
||||||
def test_audio_Artist_mixins_tags(artist):
|
def test_audio_Artist_mixins_tags(artist):
|
||||||
|
@ -238,6 +239,7 @@ def test_audio_Album_mixins_fields(album):
|
||||||
test_mixins.edit_studio(album)
|
test_mixins.edit_studio(album)
|
||||||
test_mixins.edit_summary(album)
|
test_mixins.edit_summary(album)
|
||||||
test_mixins.edit_title(album)
|
test_mixins.edit_title(album)
|
||||||
|
test_mixins.edit_user_rating(album)
|
||||||
|
|
||||||
|
|
||||||
def test_audio_Album_mixins_tags(album):
|
def test_audio_Album_mixins_tags(album):
|
||||||
|
@ -408,6 +410,7 @@ def test_audio_Track_mixins_fields(track):
|
||||||
test_mixins.edit_track_artist(track)
|
test_mixins.edit_track_artist(track)
|
||||||
test_mixins.edit_track_number(track)
|
test_mixins.edit_track_number(track)
|
||||||
test_mixins.edit_track_disc_number(track)
|
test_mixins.edit_track_disc_number(track)
|
||||||
|
test_mixins.edit_user_rating(track)
|
||||||
|
|
||||||
|
|
||||||
def test_audio_Track_mixins_tags(track):
|
def test_audio_Track_mixins_tags(track):
|
||||||
|
|
|
@ -347,6 +347,7 @@ def test_Collection_mixins_fields(collection):
|
||||||
test_mixins.edit_sort_title(collection)
|
test_mixins.edit_sort_title(collection)
|
||||||
test_mixins.edit_summary(collection)
|
test_mixins.edit_summary(collection)
|
||||||
test_mixins.edit_title(collection)
|
test_mixins.edit_title(collection)
|
||||||
|
test_mixins.edit_user_rating(collection)
|
||||||
|
|
||||||
|
|
||||||
def test_Collection_mixins_tags(collection):
|
def test_Collection_mixins_tags(collection):
|
||||||
|
|
|
@ -844,3 +844,69 @@ def _do_test_library_search(library, obj, field, operator, searchValue):
|
||||||
assert obj not in results
|
assert obj not in results
|
||||||
else:
|
else:
|
||||||
assert obj in results
|
assert obj in results
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_common(movies):
|
||||||
|
items = movies.all()
|
||||||
|
common = movies.common(items)
|
||||||
|
assert common.commonType == "movie"
|
||||||
|
assert common.ratingKeys == [m.ratingKey for m in items]
|
||||||
|
assert common.items() == items
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_multiedit(movies, tvshows):
|
||||||
|
movie1, movie2 = movies.all()[:2]
|
||||||
|
show1, show2 = tvshows.all()[:2]
|
||||||
|
|
||||||
|
movie1_title = movie1.title
|
||||||
|
movie2_title = movie2.title
|
||||||
|
show1_title = show1.title
|
||||||
|
|
||||||
|
# Edit multiple titles
|
||||||
|
title = "Test Title"
|
||||||
|
movies.multiEdit([movie1, movie2], **{"title.value": title})
|
||||||
|
assert movie1.reload().title == title
|
||||||
|
assert movie2.reload().title == title
|
||||||
|
|
||||||
|
# Reset titles
|
||||||
|
movie1.editTitle(movie1_title, locked=False).reload()
|
||||||
|
movie2.editTitle(movie2_title, locked=False).reload()
|
||||||
|
assert movie1.title == movie1_title
|
||||||
|
assert movie2.title == movie2_title
|
||||||
|
|
||||||
|
# Test batch multi-editing
|
||||||
|
genre = "Test Genre"
|
||||||
|
tvshows.batchMultiEdits([show1, show2]).addGenre(genre).saveMultiEdits()
|
||||||
|
assert genre in [g.tag for g in show1.reload().genres]
|
||||||
|
assert genre in [g.tag for g in show2.reload().genres]
|
||||||
|
|
||||||
|
# Reset genres
|
||||||
|
tvshows.batchMultiEdits([show1, show2]).removeGenre(genre, locked=False).saveMultiEdits()
|
||||||
|
assert genre not in [g.tag for g in show1.reload().genres]
|
||||||
|
assert genre not in [g.tag for g in show2.reload().genres]
|
||||||
|
|
||||||
|
# Test multi-editing with a single item
|
||||||
|
tvshows.batchMultiEdits(show1).editTitle(title).saveMultiEdits()
|
||||||
|
assert show1.reload().title == title
|
||||||
|
|
||||||
|
# Reset title
|
||||||
|
show1.editTitle(show1_title, locked=False).reload()
|
||||||
|
assert show1.title == show1_title
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_multiedit_exceptions(music, artist, album, photos):
|
||||||
|
with pytest.raises(BadRequest):
|
||||||
|
music.multiEdit([])
|
||||||
|
with pytest.raises(BadRequest):
|
||||||
|
music.multiEdit([artist, album])
|
||||||
|
with pytest.raises(BadRequest):
|
||||||
|
photos.batchMultiEdits(artist)
|
||||||
|
with pytest.raises(BadRequest):
|
||||||
|
photos.saveMultiEdits()
|
||||||
|
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
photos.editTitle("test")
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
music.batchMultiEdits(artist).editEdition("test")
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
music.batchMultiEdits(album).addCountry("test")
|
||||||
|
|
|
@ -13,14 +13,16 @@ CUTE_CAT_SHA1 = "9f7003fc401761d8e0b0364d428b2dab2f789dbb"
|
||||||
AUDIO_STUB_SHA1 = "1abc20d5fdc904201bf8988ca6ef30f96bb73617"
|
AUDIO_STUB_SHA1 = "1abc20d5fdc904201bf8988ca6ef30f96bb73617"
|
||||||
|
|
||||||
|
|
||||||
def _test_mixins_field(obj, attr, field_method):
|
def _test_mixins_field(obj, attr, field_method, default=None, value=None):
|
||||||
edit_field_method = getattr(obj, "edit" + field_method)
|
edit_field_method = getattr(obj, "edit" + field_method)
|
||||||
_value = lambda: getattr(obj, attr)
|
_value = lambda: getattr(obj, attr)
|
||||||
_fields = lambda: [f for f in obj.fields if f.name == attr]
|
_fields = lambda: [f for f in obj.fields if f.name == attr]
|
||||||
|
|
||||||
# Check field does not match to begin with
|
# Check field does not match to begin with
|
||||||
default_value = _value()
|
default_value = default or _value()
|
||||||
if isinstance(default_value, datetime):
|
if value:
|
||||||
|
test_value = value
|
||||||
|
elif isinstance(default_value, datetime):
|
||||||
test_value = TEST_MIXIN_DATE
|
test_value = TEST_MIXIN_DATE
|
||||||
elif isinstance(default_value, int):
|
elif isinstance(default_value, int):
|
||||||
test_value = default_value + 1
|
test_value = default_value + 1
|
||||||
|
@ -101,6 +103,10 @@ def edit_photo_captured_time(obj):
|
||||||
_test_mixins_field(obj, "originallyAvailableAt", "CapturedTime")
|
_test_mixins_field(obj, "originallyAvailableAt", "CapturedTime")
|
||||||
|
|
||||||
|
|
||||||
|
def edit_user_rating(obj):
|
||||||
|
_test_mixins_field(obj, "userRating", "UserRating", default=None, value=10)
|
||||||
|
|
||||||
|
|
||||||
def _test_mixins_tag(obj, attr, tag_method):
|
def _test_mixins_tag(obj, attr, tag_method):
|
||||||
add_tag_method = getattr(obj, "add" + tag_method)
|
add_tag_method = getattr(obj, "add" + tag_method)
|
||||||
remove_tag_method = getattr(obj, "remove" + tag_method)
|
remove_tag_method = getattr(obj, "remove" + tag_method)
|
||||||
|
@ -205,10 +211,6 @@ def lock_art(obj):
|
||||||
_test_mixins_lock_image(obj, "arts")
|
_test_mixins_lock_image(obj, "arts")
|
||||||
|
|
||||||
|
|
||||||
def lock_banner(obj):
|
|
||||||
_test_mixins_lock_image(obj, "banners")
|
|
||||||
|
|
||||||
|
|
||||||
def lock_poster(obj):
|
def lock_poster(obj):
|
||||||
_test_mixins_lock_image(obj, "posters")
|
_test_mixins_lock_image(obj, "posters")
|
||||||
|
|
||||||
|
@ -273,10 +275,6 @@ def edit_art(obj):
|
||||||
_test_mixins_edit_image(obj, "arts")
|
_test_mixins_edit_image(obj, "arts")
|
||||||
|
|
||||||
|
|
||||||
def edit_banner(obj):
|
|
||||||
_test_mixins_edit_image(obj, "banners")
|
|
||||||
|
|
||||||
|
|
||||||
def edit_poster(obj):
|
def edit_poster(obj):
|
||||||
_test_mixins_edit_image(obj, "posters")
|
_test_mixins_edit_image(obj, "posters")
|
||||||
|
|
||||||
|
@ -297,10 +295,6 @@ def attr_artUrl(obj):
|
||||||
_test_mixins_imageUrl(obj, "art")
|
_test_mixins_imageUrl(obj, "art")
|
||||||
|
|
||||||
|
|
||||||
def attr_bannerUrl(obj):
|
|
||||||
_test_mixins_imageUrl(obj, "banner")
|
|
||||||
|
|
||||||
|
|
||||||
def attr_posterUrl(obj):
|
def attr_posterUrl(obj):
|
||||||
_test_mixins_imageUrl(obj, "thumb")
|
_test_mixins_imageUrl(obj, "thumb")
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,7 @@ def test_photo_Photoalbum_mixins_fields(photoalbum):
|
||||||
test_mixins.edit_sort_title(photoalbum)
|
test_mixins.edit_sort_title(photoalbum)
|
||||||
test_mixins.edit_summary(photoalbum)
|
test_mixins.edit_summary(photoalbum)
|
||||||
test_mixins.edit_title(photoalbum)
|
test_mixins.edit_title(photoalbum)
|
||||||
|
test_mixins.edit_user_rating(photoalbum)
|
||||||
|
|
||||||
|
|
||||||
def test_photo_Photoalbum_PlexWebURL(plex, photoalbum):
|
def test_photo_Photoalbum_PlexWebURL(plex, photoalbum):
|
||||||
|
@ -55,6 +56,7 @@ def test_photo_Photo_mixins_fields(photo):
|
||||||
test_mixins.edit_summary(photo)
|
test_mixins.edit_summary(photo)
|
||||||
test_mixins.edit_title(photo)
|
test_mixins.edit_title(photo)
|
||||||
test_mixins.edit_photo_captured_time(photo)
|
test_mixins.edit_photo_captured_time(photo)
|
||||||
|
test_mixins.edit_user_rating(photo)
|
||||||
|
|
||||||
|
|
||||||
def test_photo_Photo_mixins_tags(photo):
|
def test_photo_Photo_mixins_tags(photo):
|
||||||
|
|
|
@ -668,12 +668,13 @@ def test_video_Movie_mixins_fields(movie):
|
||||||
test_mixins.edit_summary(movie)
|
test_mixins.edit_summary(movie)
|
||||||
test_mixins.edit_tagline(movie)
|
test_mixins.edit_tagline(movie)
|
||||||
test_mixins.edit_title(movie)
|
test_mixins.edit_title(movie)
|
||||||
|
test_mixins.edit_user_rating(movie)
|
||||||
with pytest.raises(BadRequest):
|
with pytest.raises(BadRequest):
|
||||||
test_mixins.edit_edition_title(movie)
|
test_mixins.edit_edition_title(movie)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.authenticated
|
@pytest.mark.authenticated
|
||||||
def test_video_Movie_mixins_fields(movie):
|
def test_video_Movie_mixins_fields_edition(movie):
|
||||||
test_mixins.edit_edition_title(movie)
|
test_mixins.edit_edition_title(movie)
|
||||||
|
|
||||||
|
|
||||||
|
@ -926,6 +927,7 @@ def test_video_Show_mixins_fields(show):
|
||||||
test_mixins.edit_summary(show)
|
test_mixins.edit_summary(show)
|
||||||
test_mixins.edit_tagline(show)
|
test_mixins.edit_tagline(show)
|
||||||
test_mixins.edit_title(show)
|
test_mixins.edit_title(show)
|
||||||
|
test_mixins.edit_user_rating(show)
|
||||||
|
|
||||||
|
|
||||||
def test_video_Show_mixins_tags(show):
|
def test_video_Show_mixins_tags(show):
|
||||||
|
@ -1075,6 +1077,7 @@ def test_video_Season_mixins_fields(show):
|
||||||
test_mixins.edit_added_at(season)
|
test_mixins.edit_added_at(season)
|
||||||
test_mixins.edit_summary(season)
|
test_mixins.edit_summary(season)
|
||||||
test_mixins.edit_title(season)
|
test_mixins.edit_title(season)
|
||||||
|
test_mixins.edit_user_rating(season)
|
||||||
|
|
||||||
|
|
||||||
def test_video_Season_mixins_tags(show):
|
def test_video_Season_mixins_tags(show):
|
||||||
|
@ -1286,6 +1289,7 @@ def test_video_Episode_mixins_fields(episode):
|
||||||
test_mixins.edit_sort_title(episode)
|
test_mixins.edit_sort_title(episode)
|
||||||
test_mixins.edit_summary(episode)
|
test_mixins.edit_summary(episode)
|
||||||
test_mixins.edit_title(episode)
|
test_mixins.edit_title(episode)
|
||||||
|
test_mixins.edit_user_rating(episode)
|
||||||
|
|
||||||
|
|
||||||
def test_video_Episode_mixins_tags(episode):
|
def test_video_Episode_mixins_tags(episode):
|
||||||
|
|
Loading…
Reference in a new issue