From 9ede493f4cae9341db71806b997089d50d851c65 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 27 Jul 2023 14:45:23 -0700 Subject: [PATCH] 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 --- plexapi/audio.py | 15 +-- plexapi/base.py | 12 +- plexapi/collection.py | 6 +- plexapi/library.py | 235 ++++++++++++++++++++++++++++++++++++--- plexapi/media.py | 7 +- plexapi/mixins.py | 217 ++++++++++++++++++++++-------------- plexapi/photo.py | 8 +- plexapi/playqueue.py | 2 +- plexapi/settings.py | 2 +- plexapi/video.py | 26 ++--- tests/conftest.py | 4 - tests/test_audio.py | 3 + tests/test_collection.py | 1 + tests/test_library.py | 66 +++++++++++ tests/test_mixins.py | 24 ++-- tests/test_photo.py | 2 + tests/test_video.py | 6 +- 17 files changed, 468 insertions(+), 168 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 046bb612..d0dcaca7 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -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. diff --git a/plexapi/base.py b/plexapi/base.py index 35b9f4d3..d0d52374 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -152,7 +152,9 @@ class PlexObject: and attrs. Parameters: - ekey (str): API URL path in Plex to fetch items from. + ekey (str or List): 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/. 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): diff --git a/plexapi/collection.py b/plexapi/collection.py index f13a2883..d4820fe2 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -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. diff --git a/plexapi/library.py b/plexapi/library.py index 5e8bb3f7..9ef020f5 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -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.') + + 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. 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/) 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/) 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/). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + mixedFields (List): 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) diff --git a/plexapi/media.py b/plexapi/media.py index bf401ee0..6d19f201 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -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' diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 860f6b78..f0c21cfe 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -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 diff --git a/plexapi/photo.py b/plexapi/photo.py index 4c3d89b5..039ac80c 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -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. diff --git a/plexapi/playqueue.py b/plexapi/playqueue.py index 4c49f8d2..9835c0dd 100644 --- a/plexapi/playqueue.py +++ b/plexapi/playqueue.py @@ -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 diff --git a/plexapi/settings.py b/plexapi/settings.py index ef91391b..c191e368 100644 --- a/plexapi/settings.py +++ b/plexapi/settings.py @@ -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() diff --git a/plexapi/video.py b/plexapi/video.py index 7bb1c418..df33855d 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -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//banner/). 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. diff --git a/tests/conftest.py b/tests/conftest.py index a11f8e90..386d0817 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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/") diff --git a/tests/test_audio.py b/tests/test_audio.py index 3ef8ec57..a290c616 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -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): diff --git a/tests/test_collection.py b/tests/test_collection.py index 1bbc0866..fcb0e149 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -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): diff --git a/tests/test_library.py b/tests/test_library.py index c748e1aa..70148ae2 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -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") diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 2634b779..aa6e4231 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -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") diff --git a/tests/test_photo.py b/tests/test_photo.py index 8e7a2cf2..b1613bc4 100644 --- a/tests/test_photo.py +++ b/tests/test_photo.py @@ -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): diff --git a/tests/test_video.py b/tests/test_video.py index c549d5dc..4a5b3e40 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -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):