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:
JonnyWong16 2023-07-27 14:45:23 -07:00 committed by GitHub
parent b06afb8f84
commit 9ede493f4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 468 additions and 168 deletions

View file

@ -8,14 +8,12 @@ from plexapi.exceptions import BadRequest, NotFound
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin,
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
ArtistEditMixins, AlbumEditMixins, TrackEditMixins
)
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`,
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
@ -132,8 +130,7 @@ class Artist(
Audio,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
SortTitleMixin, SummaryMixin, TitleMixin,
CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin
ArtistEditMixins
):
""" Represents a single Artist.
@ -244,8 +241,7 @@ class Album(
Audio,
UnmatchMatchMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeUrlMixin,
OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin,
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin
AlbumEditMixins
):
""" Represents a single Album.
@ -364,8 +360,7 @@ class Track(
Audio, Playable,
ExtrasMixin, RatingMixin,
ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin,
TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin,
CollectionMixin, LabelMixin, MoodMixin
TrackEditMixins
):
""" Represents a single Track.

View file

@ -152,7 +152,9 @@ class PlexObject:
and attrs.
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
items to be fetched, passing this in will help the parser ensure
it only returns those items. By default we convert the xml elements
@ -225,6 +227,9 @@ class PlexObject:
if ekey is None:
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_size = container_size or X_PLEX_CONTAINER_SIZE
offset = container_start
@ -559,13 +564,10 @@ class PlexPartialObject(PlexObject):
self._edits.update(kwargs)
return self
if 'id' not in kwargs:
kwargs['id'] = self.ratingKey
if 'type' not in kwargs:
kwargs['type'] = utils.searchType(self._searchType)
part = f'/library/sections/{self.librarySectionID}/all{utils.joinArgs(kwargs)}'
self._server.query(part, method=self._server._session.put)
self.section()._edit(items=self, **kwargs)
return self
def edit(self, **kwargs):

View file

@ -8,8 +8,7 @@ from plexapi.library import LibrarySection, ManagedHub
from plexapi.mixins import (
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
LabelMixin
CollectionEditMixins
)
from plexapi.utils import deprecated
@ -19,8 +18,7 @@ class Collection(
PlexPartialObject,
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin,
LabelMixin
CollectionEditMixins
):
""" Represents a single Collection.

View file

@ -1,11 +1,15 @@
# -*- coding: utf-8 -*-
import re
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.base import OPERATORS, PlexObject
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.utils import cached_property, deprecated
@ -440,6 +444,20 @@ class LibrarySection(PlexObject):
self._getTotalDurationStorage()
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):
""" Queries the Plex server for the total library duration and storage and caches the values. """
data = self._server.query('/media/providers?includeStorage=1')
@ -1658,8 +1676,101 @@ class LibrarySection(PlexObject):
params['pageType'] = 'list'
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.')
class MovieSection(LibrarySection):
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}')
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.
Attributes:
@ -1719,7 +1830,7 @@ class MovieSection(LibrarySection):
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.
Attributes:
@ -1803,7 +1914,7 @@ class ShowSection(LibrarySection):
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.
Attributes:
@ -1895,7 +2006,7 @@ class MusicSection(LibrarySection):
return super(MusicSection, self).sync(**kwargs)
class PhotoSection(LibrarySection):
class PhotoSection(LibrarySection, PhotoalbumEditMixins, PhotoEditMixins):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
Attributes:
@ -2160,16 +2271,6 @@ class Autotag(LibraryMediaTag):
TAGTYPE = 207
@utils.registerPlexObject
class Banner(LibraryMediaTag):
""" Represents a single Banner library media tag.
Attributes:
TAGTYPE (int): 311
"""
TAGTYPE = 311
@utils.registerPlexObject
class Chapter(LibraryMediaTag):
""" Represents a single Chapter library media tag.
@ -3005,7 +3106,6 @@ class Path(PlexObject):
Attributes:
TAG (str): 'Path'
home (bool): True if the path is the home directory
key (str): API URL (/services/browse/<base64path>)
network (bool): True if path is a network location
@ -3037,7 +3137,6 @@ class File(PlexObject):
Attributes:
TAG (str): 'File'
key (str): API URL (/services/browse/<base64path>)
path (str): Full path to file
title (str): File name
@ -3048,3 +3147,105 @@ class File(PlexObject):
self.key = data.attrib.get('key')
self.path = data.attrib.get('path')
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)

View file

@ -955,7 +955,7 @@ class Review(PlexObject):
class BaseResource(PlexObject):
""" Base class for all Art, Banner, Poster, and Theme objects.
""" Base class for all Art, Poster, and Theme objects.
Attributes:
TAG (str): 'Photo' or 'Track'
@ -987,11 +987,6 @@ class Art(BaseResource):
TAG = 'Photo'
class Banner(BaseResource):
""" Represents a single Banner object. """
TAG = 'Photo'
class Poster(BaseResource):
""" Represents a single Poster object. """
TAG = 'Photo'

View file

@ -138,7 +138,7 @@ class SplitMergeMixin:
if not isinstance(ratingKeys, list):
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)
return self
@ -328,7 +328,19 @@ class ArtUrlMixin:
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. """
def arts(self):
@ -360,65 +372,6 @@ class ArtMixin(ArtUrlMixin):
art.select()
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:
""" Mixin for Plex objects that can have a poster url. """
@ -435,7 +388,19 @@ class PosterUrlMixin:
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. """
def posters(self):
@ -467,14 +432,6 @@ class PosterMixin(PosterUrlMixin):
poster.select()
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:
""" 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
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. """
def themes(self):
@ -519,14 +488,6 @@ class ThemeMixin(ThemeUrlMixin):
'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:
""" Mixin for editing Plex object fields. """
@ -751,6 +712,19 @@ class PhotoCapturedTimeMixin(EditFieldMixin):
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:
""" Mixin for editing Plex object tags. """
@ -780,7 +754,7 @@ class EditTagsMixin:
items = [items]
if not remove:
tags = getattr(self, self._tagPlural(tag))
tags = getattr(self, self._tagPlural(tag), [])
items = tags + items
edits = self._tagHelper(self._tagSingular(tag), items, locked, remove)
@ -821,7 +795,7 @@ class EditTagsMixin:
if remove:
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:
for i, item in enumerate(items):
tagname = f'{str(tag)}[{i}].tag.tag'
@ -1134,3 +1108,84 @@ class WatchlistMixin:
ratingKey = self.guid.rsplit('/', 1)[-1]
data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities")
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

View file

@ -8,8 +8,7 @@ from plexapi.exceptions import BadRequest
from plexapi.mixins import (
RatingMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin,
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, PhotoCapturedTimeMixin,
TagMixin
PhotoalbumEditMixins, PhotoEditMixins
)
@ -18,7 +17,7 @@ class Photoalbum(
PlexPartialObject,
RatingMixin,
ArtMixin, PosterMixin,
AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin
PhotoalbumEditMixins
):
""" Represents a single Photoalbum (collection of photos).
@ -146,8 +145,7 @@ class Photo(
PlexPartialObject, Playable,
RatingMixin,
ArtUrlMixin, PosterUrlMixin,
AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin,
TagMixin
PhotoEditMixins
):
""" Represents a single Photo.

View file

@ -170,7 +170,7 @@ class PlayQueue(PlexObject):
}
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}")
args["uri"] = f"library:///directory/{uri_args}"
args["type"] = items[0].listType

View file

@ -81,7 +81,7 @@ class Settings(PlexObject):
params[setting.id] = quote(setting._setValue)
if not params:
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}'
self._server.query(url, self._server._session.put)
self.reload()

View file

@ -7,15 +7,13 @@ from plexapi.base import Playable, PlexPartialObject, PlexSession
from plexapi.exceptions import BadRequest
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin,
StudioMixin, SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin,
MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins,
WatchlistMixin
)
class Video(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin):
class Video(PlexPartialObject, PlayedUnplayedMixin):
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
:class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`.
@ -309,9 +307,7 @@ class Movie(
Video, Playable,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeMixin,
ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin,
MovieEditMixins,
WatchlistMixin
):
""" Represents a single Movie.
@ -453,10 +449,8 @@ class Movie(
class Show(
Video,
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
ArtMixin, BannerMixin, PosterMixin, ThemeMixin,
ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin,
SummaryMixin, TaglineMixin, TitleMixin,
CollectionMixin, GenreMixin, LabelMixin,
ArtMixin, PosterMixin, ThemeMixin,
ShowEditMixins,
WatchlistMixin
):
""" Represents a single Show (including all seasons and episodes).
@ -474,7 +468,6 @@ class Show(
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,
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.
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
contentRating (str) Content rating (PG-13; NR; TV-G).
@ -528,7 +521,6 @@ class Show(
int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0'))
self.autoDeletionItemPolicyWatchedLibrary = utils.cast(
int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0'))
self.banner = data.attrib.get('banner')
self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating')
@ -666,8 +658,7 @@ class Season(
Video,
AdvancedSettingsMixin, ExtrasMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeUrlMixin,
SummaryMixin, TitleMixin,
CollectionMixin, LabelMixin
SeasonEditMixins
):
""" Represents a single Show Season (including all episodes).
@ -820,8 +811,7 @@ class Episode(
Video, Playable,
ExtrasMixin, RatingMixin,
ArtMixin, PosterMixin, ThemeUrlMixin,
ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin,
CollectionMixin, DirectorMixin, LabelMixin, WriterMixin
EpisodeEditMixins
):
""" Represents a single Shows Episode.

View file

@ -416,10 +416,6 @@ 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

@ -110,6 +110,7 @@ def test_audio_Artist_mixins_fields(artist):
test_mixins.edit_sort_title(artist)
test_mixins.edit_summary(artist)
test_mixins.edit_title(artist)
test_mixins.edit_user_rating(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_summary(album)
test_mixins.edit_title(album)
test_mixins.edit_user_rating(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_number(track)
test_mixins.edit_track_disc_number(track)
test_mixins.edit_user_rating(track)
def test_audio_Track_mixins_tags(track):

View file

@ -347,6 +347,7 @@ def test_Collection_mixins_fields(collection):
test_mixins.edit_sort_title(collection)
test_mixins.edit_summary(collection)
test_mixins.edit_title(collection)
test_mixins.edit_user_rating(collection)
def test_Collection_mixins_tags(collection):

View file

@ -844,3 +844,69 @@ def _do_test_library_search(library, obj, field, operator, searchValue):
assert obj not in results
else:
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")

View file

@ -13,14 +13,16 @@ CUTE_CAT_SHA1 = "9f7003fc401761d8e0b0364d428b2dab2f789dbb"
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)
_value = lambda: getattr(obj, attr)
_fields = lambda: [f for f in obj.fields if f.name == attr]
# Check field does not match to begin with
default_value = _value()
if isinstance(default_value, datetime):
default_value = default or _value()
if value:
test_value = value
elif isinstance(default_value, datetime):
test_value = TEST_MIXIN_DATE
elif isinstance(default_value, int):
test_value = default_value + 1
@ -101,6 +103,10 @@ def edit_photo_captured_time(obj):
_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):
add_tag_method = getattr(obj, "add" + tag_method)
remove_tag_method = getattr(obj, "remove" + tag_method)
@ -205,10 +211,6 @@ def lock_art(obj):
_test_mixins_lock_image(obj, "arts")
def lock_banner(obj):
_test_mixins_lock_image(obj, "banners")
def lock_poster(obj):
_test_mixins_lock_image(obj, "posters")
@ -273,10 +275,6 @@ def edit_art(obj):
_test_mixins_edit_image(obj, "arts")
def edit_banner(obj):
_test_mixins_edit_image(obj, "banners")
def edit_poster(obj):
_test_mixins_edit_image(obj, "posters")
@ -297,10 +295,6 @@ 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

@ -34,6 +34,7 @@ def test_photo_Photoalbum_mixins_fields(photoalbum):
test_mixins.edit_sort_title(photoalbum)
test_mixins.edit_summary(photoalbum)
test_mixins.edit_title(photoalbum)
test_mixins.edit_user_rating(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_title(photo)
test_mixins.edit_photo_captured_time(photo)
test_mixins.edit_user_rating(photo)
def test_photo_Photo_mixins_tags(photo):

View file

@ -668,12 +668,13 @@ def test_video_Movie_mixins_fields(movie):
test_mixins.edit_summary(movie)
test_mixins.edit_tagline(movie)
test_mixins.edit_title(movie)
test_mixins.edit_user_rating(movie)
with pytest.raises(BadRequest):
test_mixins.edit_edition_title(movie)
@pytest.mark.authenticated
def test_video_Movie_mixins_fields(movie):
def test_video_Movie_mixins_fields_edition(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_tagline(show)
test_mixins.edit_title(show)
test_mixins.edit_user_rating(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_summary(season)
test_mixins.edit_title(season)
test_mixins.edit_user_rating(season)
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_summary(episode)
test_mixins.edit_title(episode)
test_mixins.edit_user_rating(episode)
def test_video_Episode_mixins_tags(episode):