diff --git a/plexapi/audio.py b/plexapi/audio.py index 686073a3..05d38a9c 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -33,6 +33,7 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin): distance (float): Sonic Distance of the item from the seed item. fields (List<:class:`~plexapi.media.Field`>): List of field objects. guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c). + images (List<:class:`~plexapi.media.Image`>): List of image objects. index (int): Plex index number (often the track number). key (str): API URL (/library/metadata/). lastRatedAt (datetime): Datetime the item was last rated. @@ -65,6 +66,7 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin): self.distance = utils.cast(float, data.attrib.get('distance')) self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') + self.images = self.findItems(data, media.Image) self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '') self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) diff --git a/plexapi/base.py b/plexapi/base.py index 4a05eb95..675ac5d9 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -71,7 +71,7 @@ class PlexObject: self._details_key = self._buildDetailsKey() def __repr__(self): - uid = self._clean(self.firstAttr('_baseurl', 'ratingKey', 'id', 'key', 'playQueueID', 'uri')) + uid = self._clean(self.firstAttr('_baseurl', 'ratingKey', 'id', 'key', 'playQueueID', 'uri', 'type')) name = self._clean(self.firstAttr('title', 'name', 'username', 'product', 'tag', 'value')) return f"<{':'.join([p for p in [self.__class__.__name__, uid, name] if p])}>" diff --git a/plexapi/collection.py b/plexapi/collection.py index 1c3ba3f7..63ea8373 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -39,6 +39,7 @@ class Collection( contentRating (str) Content rating (PG-13; NR; TV-G). fields (List<:class:`~plexapi.media.Field`>): List of field objects. guid (str): Plex GUID for the collection (collection://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX). + images (List<:class:`~plexapi.media.Image`>): List of image objects. index (int): Plex index number for the collection. key (str): API URL (/library/metadata/). labels (List<:class:`~plexapi.media.Label`>): List of label objects. @@ -82,6 +83,7 @@ class Collection( self.contentRating = data.attrib.get('contentRating') self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') + self.images = self.findItems(data, media.Image) self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 self.labels = self.findItems(data, media.Label) diff --git a/plexapi/media.py b/plexapi/media.py index a72e72cb..9c6e3115 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -958,6 +958,26 @@ class Guid(PlexObject): self.id = data.attrib.get('id') +@utils.registerPlexObject +class Image(PlexObject): + """ Represents a single Image media tag. + + Attributes: + TAG (str): 'Image' + alt (str): The alt text for the image. + type (str): The type of image (e.g. coverPoster, background, snapshot). + url (str): The API URL (/library/metadata//thumb/). + """ + TAG = 'Image' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.alt = data.attrib.get('alt') + self.type = data.attrib.get('type') + self.url = data.attrib.get('url') + + @utils.registerPlexObject class Rating(PlexObject): """ Represents a single Rating media tag. @@ -1078,6 +1098,11 @@ class Art(BaseResource): TAG = 'Photo' +class Logo(BaseResource): + """ Represents a single Logo object. """ + TAG = 'Photo' + + class Poster(BaseResource): """ Represents a single Poster object. """ TAG = 'Photo' diff --git a/plexapi/mixins.py b/plexapi/mixins.py index bdf4607e..95f785fc 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -403,6 +403,63 @@ class ArtMixin(ArtUrlMixin, ArtLockMixin): return self +class LogoUrlMixin: + """ Mixin for Plex objects that can have a logo url. """ + + @property + def logoUrl(self): + """ Return the logo url for the Plex object. """ + image = next((i for i in self.images if i.type == 'clearLogo'), None) + return self._server.url(image.url, includeToken=True) if image else None + + +class LogoLockMixin: + """ Mixin for Plex objects that can have a locked logo. """ + + def lockLogo(self): + """ Lock the logo for a Plex object. """ + raise NotImplementedError('Logo cannot be locked through the API.') + + def unlockLogo(self): + """ Unlock the logo for a Plex object. """ + raise NotImplementedError('Logo cannot be unlocked through the API.') + + +class LogoMixin(LogoUrlMixin, LogoLockMixin): + """ Mixin for Plex objects that can have logos. """ + + def logos(self): + """ Returns list of available :class:`~plexapi.media.Logo` objects. """ + return self.fetchItems(f'/library/metadata/{self.ratingKey}/clearLogos', cls=media.Logo) + + def uploadLogo(self, url=None, filepath=None): + """ Upload a logo 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}/clearLogos?url={quote_plus(url)}' + self._server.query(key, method=self._server._session.post) + elif filepath: + key = f'/library/metadata/{self.ratingKey}/clearLogos' + data = openOrRead(filepath) + self._server.query(key, method=self._server._session.post, data=data) + return self + + def setLogo(self, logo): + """ Set the logo for a Plex object. + + Raises: + :exc:`~plexapi.exceptions.NotImplementedError`: Logo cannot be set through the API. + """ + raise NotImplementedError( + 'Logo cannot be set through the API. ' + 'Re-upload the logo using "uploadLogo" to set it.' + ) + + class PosterUrlMixin: """ Mixin for Plex objects that can have a poster url. """ @@ -513,6 +570,11 @@ class ThemeMixin(ThemeUrlMixin, ThemeLockMixin): return self def setTheme(self, theme): + """ Set the theme for a Plex object. + + Raises: + :exc:`~plexapi.exceptions.NotImplementedError`: Themes cannot be set through the API. + """ raise NotImplementedError( 'Themes cannot be set through the API. ' 'Re-upload the theme using "uploadTheme" to set it.' diff --git a/plexapi/photo.py b/plexapi/photo.py index c68b3613..4347f31a 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -30,6 +30,7 @@ class Photoalbum( composite (str): URL to composite image (/library/metadata//composite/) fields (List<:class:`~plexapi.media.Field`>): List of field objects. guid (str): Plex GUID for the photo album (local://229674). + images (List<:class:`~plexapi.media.Image`>): List of image objects. index (sting): Plex index number for the photo album. key (str): API URL (/library/metadata/). lastRatedAt (datetime): Datetime the photo album was last rated. @@ -57,6 +58,7 @@ class Photoalbum( self.composite = data.attrib.get('composite') self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') + self.images = self.findItems(data, media.Image) self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) @@ -164,6 +166,7 @@ class Photo( createdAtTZOffset (int): Unknown (-25200). fields (List<:class:`~plexapi.media.Field`>): List of field objects. guid (str): Plex GUID for the photo (com.plexapp.agents.none://231714?lang=xn). + images (List<:class:`~plexapi.media.Image`>): List of image objects. index (sting): Plex index number for the photo. key (str): API URL (/library/metadata/). lastRatedAt (datetime): Datetime the photo was last rated. @@ -204,6 +207,7 @@ class Photo( self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset')) self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') + self.images = self.findItems(data, media.Image) self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '') self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) diff --git a/plexapi/utils.py b/plexapi/utils.py index 549afc5b..dd1cfc9c 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -90,6 +90,8 @@ TAGTYPES = { 'theme': 317, 'studio': 318, 'network': 319, + 'showOrdering': 322, + 'clearLogo': 323, 'place': 400, } REVERSETAGTYPES = {v: k for k, v in TAGTYPES.items()} diff --git a/plexapi/video.py b/plexapi/video.py index 5f118b40..6e811aa4 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -9,7 +9,7 @@ from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, - ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin, + ArtUrlMixin, ArtMixin, LogoMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin, MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins, WatchlistMixin ) @@ -26,6 +26,7 @@ class Video(PlexPartialObject, PlayedUnplayedMixin): artBlurHash (str): BlurHash string for artwork image. fields (List<:class:`~plexapi.media.Field`>): List of field objects. guid (str): Plex GUID for the movie, show, season, episode, or clip (plex://movie/5d776b59ad5437001f79c6f8). + images (List<:class:`~plexapi.media.Image`>): List of image objects. key (str): API URL (/library/metadata/). lastRatedAt (datetime): Datetime the item was last rated. lastViewedAt (datetime): Datetime the item was last played. @@ -53,6 +54,7 @@ class Video(PlexPartialObject, PlayedUnplayedMixin): self.artBlurHash = data.attrib.get('artBlurHash') self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') + self.images = self.findItems(data, media.Image) self.key = data.attrib.get('key', '') self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) @@ -332,7 +334,7 @@ class Video(PlexPartialObject, PlayedUnplayedMixin): class Movie( Video, Playable, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, - ArtMixin, PosterMixin, ThemeMixin, + ArtMixin, LogoMixin, PosterMixin, ThemeMixin, MovieEditMixins, WatchlistMixin ): @@ -494,7 +496,7 @@ class Movie( class Show( Video, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, - ArtMixin, PosterMixin, ThemeMixin, + ArtMixin, LogoMixin, PosterMixin, ThemeMixin, ShowEditMixins, WatchlistMixin ): diff --git a/tests/test_audio.py b/tests/test_audio.py index 5246dc6e..b73f2ba1 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -24,6 +24,8 @@ def test_audio_Artist_attr(artist): # assert "Electronic" in [i.tag for i in artist.genres] assert artist.guid in artist_guids assert artist_guids[0] in [i.id for i in artist.guids] + if artist.images: + assert any("coverPoster" in i.type for i in artist.images) assert artist.index == 1 assert utils.is_metadata(artist._initpath) assert utils.is_metadata(artist.key) diff --git a/tests/test_collection.py b/tests/test_collection.py index 8134d93e..7522e943 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -47,6 +47,8 @@ def test_Collection_attrs(collection): assert collection.isVideo is True assert collection.isAudio is False assert collection.isPhoto is False + if collection.images: + assert any("coverPoster" in i.type for i in collection.images) def test_Collection_section(collection, movies): diff --git a/tests/test_video.py b/tests/test_video.py index 21b6c106..e7f9c8db 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -41,7 +41,7 @@ def test_video_Movie_merge(movie, patched_http_call): movie.merge(1337) -def test_video_Movie_attrs(movies): +def test_video_Movie_attrs(movies): # noqa: C901 movie = movies.get("Sita Sings the Blues") assert len(movie.locations) == 1 assert len(movie.locations[0]) >= 10 @@ -54,6 +54,8 @@ def test_video_Movie_attrs(movies): assert movie.audienceRatingImage == 'rottentomatoes://image.rating.upright' if movie.ratings: assert "imdb://image.rating" in [i.image for i in movie.ratings] + if movie.images: + assert any("coverPoster" in i.type for i in movie.images) movie.reload() # RELOAD assert movie.chapterSource is None assert not movie.collections