diff --git a/plexapi/audio.py b/plexapi/audio.py index c8f80106..2cbd82d6 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -4,7 +4,7 @@ from urllib.parse import quote_plus from plexapi import library, media, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest -from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin @@ -125,7 +125,7 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio, SplitMergeMixin, UnmatchMatchMixin, +class Artist(Audio, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin): """ Represents a single Artist. @@ -229,7 +229,7 @@ class Artist(Audio, SplitMergeMixin, UnmatchMatchMixin, @utils.registerPlexObject -class Album(Audio, UnmatchMatchMixin, +class Album(Audio, ArtMixin, PosterMixin, UnmatchMatchMixin, CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin): """ Represents a single Album. diff --git a/plexapi/base.py b/plexapi/base.py index 8a8dc9e4..307211fb 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -513,53 +513,6 @@ class PlexPartialObject(PlexObject): """ return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey) - def posters(self): - """ Returns list of available poster objects. :class:`~plexapi.media.Poster`. """ - - return self.fetchItems('%s/posters' % self.key) - - def uploadPoster(self, url=None, filepath=None): - """ Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ - if url: - key = '%s/posters?url=%s' % (self.key, quote_plus(url)) - self._server.query(key, method=self._server._session.post) - elif filepath: - key = '%s/posters?' % self.key - data = open(filepath, 'rb').read() - self._server.query(key, method=self._server._session.post, data=data) - - def setPoster(self, poster): - """ Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ - poster.select() - - def arts(self): - """ Returns list of available art objects. :class:`~plexapi.media.Poster`. """ - - return self.fetchItems('%s/arts' % self.key) - - def uploadArt(self, url=None, filepath=None): - """ Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ - if url: - key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url)) - self._server.query(key, method=self._server._session.post) - elif filepath: - key = '/library/metadata/%s/arts?' % self.ratingKey - data = open(filepath, 'rb').read() - self._server.query(key, method=self._server._session.post, data=data) - - def setArt(self, art): - """ Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ - art.select() - - # The photo tag cant be built atm. TODO - # def arts(self): - # part = '%s/arts' % self.key - # return self.fetchItem(part) - - # def poster(self): - # part = '%s/posters' % self.key - # return self.fetchItem(part, etag='Photo') - class Playable(object): """ This is a general place to store functions specific to media that is Playable. diff --git a/plexapi/library.py b/plexapi/library.py index 1281b6e1..8a6413f6 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -4,6 +4,7 @@ from urllib.parse import quote, quote_plus, unquote, urlencode from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils from plexapi.base import OPERATORS, PlexObject, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound +from plexapi.mixins import ArtMixin, PosterMixin from plexapi.mixins import LabelMixin from plexapi.settings import Setting from plexapi.utils import deprecated @@ -1527,7 +1528,7 @@ class FirstCharacter(PlexObject): @utils.registerPlexObject -class Collections(PlexPartialObject, LabelMixin): +class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): """ Represents a single Collection. Attributes: @@ -1684,44 +1685,6 @@ class Collections(PlexPartialObject, LabelMixin): part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) return self._server.query(part, method=self._server._session.put) - def posters(self): - """ Returns list of available poster objects. :class:`~plexapi.media.Poster`. """ - - return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey) - - def uploadPoster(self, url=None, filepath=None): - """ Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ - if url: - key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url)) - self._server.query(key, method=self._server._session.post) - elif filepath: - key = '/library/metadata/%s/posters?' % self.ratingKey - data = open(filepath, 'rb').read() - self._server.query(key, method=self._server._session.post, data=data) - - def setPoster(self, poster): - """ Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ - poster.select() - - def arts(self): - """ Returns list of available art objects. :class:`~plexapi.media.Poster`. """ - - return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey) - - def uploadArt(self, url=None, filepath=None): - """ Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ - if url: - key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url)) - self._server.query(key, method=self._server._session.post) - elif filepath: - key = '/library/metadata/%s/arts?' % self.ratingKey - data = open(filepath, 'rb').read() - self._server.query(key, method=self._server._session.post, data=data) - - def setArt(self, art): - """ Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ - art.select() - # def edit(self, **kwargs): # TODO diff --git a/plexapi/media.py b/plexapi/media.py index 00007896..dea37266 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -807,20 +807,25 @@ class Style(MediaTag): FILTER = 'style' -@utils.registerPlexObject -class Poster(PlexObject): - """ Represents a Poster. +class BasePosterArt(PlexObject): + """ Base class for all Poster and Art objects. Attributes: TAG (str): 'Photo' + key (str): API URL (/library/metadata/). + provider (str): The source of the poster or art. + ratingKey (str): Unique key identifying the poster or art. + selected (bool): True if the poster or art is currently selected. + thumb (str): The URL to retrieve the poster or art thumbnail. """ TAG = 'Photo' def _loadData(self, data): self._data = data self.key = data.attrib.get('key') + self.provider = data.attrib.get('provider') self.ratingKey = data.attrib.get('ratingKey') - self.selected = data.attrib.get('selected') + self.selected = cast(bool, data.attrib.get('selected')) self.thumb = data.attrib.get('thumb') def select(self): @@ -832,6 +837,14 @@ class Poster(PlexObject): pass +class Poster(BasePosterArt): + """ Represents a single Poster object. """ + + +class Art(BasePosterArt): + """ Represents a single Art object. """ + + @utils.registerPlexObject class Producer(MediaTag): """ Represents a single Producer media tag. diff --git a/plexapi/mixins.py b/plexapi/mixins.py index d200a464..7c65e632 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -1,10 +1,72 @@ # -*- coding: utf-8 -*- -from urllib.parse import urlencode +from urllib.parse import quote_plus, urlencode -from plexapi import utils +from plexapi import media, utils from plexapi.exceptions import NotFound +class ArtMixin(object): + """ Mixin for Plex objects that can have artwork.""" + + def arts(self): + """ Returns list of available :class:`~plexapi.media.Art` objects. """ + return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=media.Art) + + def uploadArt(self, url=None, filepath=None): + """ Upload art from url or filepath and set it as the selected art. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload. + """ + if url: + key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/arts?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setArt(self, art): + """ Set the artwork for a Plex object. + + Parameters: + art (:class:`~plexapi.media.Art`): The art object to select. + """ + art.select() + + +class PosterMixin(object): + """ Mixin for Plex objects that can have posters.""" + + def posters(self): + """ Returns list of available :class:`~plexapi.media.Poster` objects. """ + return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=media.Poster) + + def uploadPoster(self, url=None, filepath=None): + """ Upload poster from url or filepath and set it as the selected poster. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload. + """ + if url: + key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/posters?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setPoster(self, poster): + """ Set the poster for a Plex object. + + Parameters: + poster (:class:`~plexapi.media.Poster`): The poster object to select. + """ + poster.select() + + class SplitMergeMixin(object): """ Mixin for Plex objects that can be split and merged.""" @@ -319,7 +381,7 @@ class TagMixin(object): self._edit_tags('tag', tags, remove=True) -class EditWriter(object): +class WriterMixin(object): """ Mixin for Plex objects that can have writers. """ def addWriter(self, writers): diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 9e691b52..399d55aa 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -5,12 +5,13 @@ from plexapi import utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection +from plexapi.mixins import ArtMixin, PosterMixin from plexapi.playqueue import PlayQueue from plexapi.utils import cast, toDatetime @utils.registerPlexObject -class Playlist(PlexPartialObject, Playable): +class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): """ Represents a single Playlist. Attributes: @@ -311,41 +312,3 @@ class Playlist(PlexPartialObject, Playable): raise Unsupported('Unsupported playlist content') return myplex.sync(sync_item, client=client, clientId=clientId) - - def posters(self): - """ Returns list of available poster objects. :class:`~plexapi.media.Poster`. """ - - return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey) - - def uploadPoster(self, url=None, filepath=None): - """ Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ - if url: - key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url)) - self._server.query(key, method=self._server._session.post) - elif filepath: - key = '/library/metadata/%s/posters?' % self.ratingKey - data = open(filepath, 'rb').read() - self._server.query(key, method=self._server._session.post, data=data) - - def setPoster(self, poster): - """ Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ - poster.select() - - def arts(self): - """ Returns list of available art objects. :class:`~plexapi.media.Poster`. """ - - return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey) - - def uploadArt(self, url=None, filepath=None): - """ Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ - if url: - key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url)) - self._server.query(key, method=self._server._session.post) - elif filepath: - key = '/library/metadata/%s/arts?' % self.ratingKey - data = open(filepath, 'rb').read() - self._server.query(key, method=self._server._session.post, data=data) - - def setArt(self, art): - """ Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ - art.select() diff --git a/plexapi/video.py b/plexapi/video.py index c2957b1e..3f1fd9bb 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,8 +5,8 @@ from urllib.parse import quote_plus, urlencode from plexapi import library, media, settings, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound -from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin -from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, EditWriter +from plexapi.mixins import ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin class Video(PlexPartialObject): @@ -261,8 +261,8 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Playable, Video, SplitMergeMixin, UnmatchMatchMixin, - CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, EditWriter): +class Movie(Video, Playable, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, + CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin): """ Represents a single Movie. Attributes: @@ -388,7 +388,7 @@ class Movie(Playable, Video, SplitMergeMixin, UnmatchMatchMixin, @utils.registerPlexObject -class Show(Video, SplitMergeMixin, UnmatchMatchMixin, +class Show(Video, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, CollectionMixin, GenreMixin, LabelMixin): """ Represents a single Show (including all seasons and episodes). @@ -587,7 +587,7 @@ class Show(Video, SplitMergeMixin, UnmatchMatchMixin, @utils.registerPlexObject -class Season(Video): +class Season(Video, ArtMixin, PosterMixin): """ Represents a single Show Season (including all episodes). Attributes: @@ -713,7 +713,8 @@ class Season(Video): @utils.registerPlexObject -class Episode(Playable, Video, DirectorMixin, EditWriter): +class Episode(Video, Playable, ArtMixin, PosterMixin, + DirectorMixin, WriterMixin): """ Represents a single Shows Episode. Attributes: @@ -850,7 +851,7 @@ class Episode(Playable, Video, DirectorMixin, EditWriter): @utils.registerPlexObject -class Clip(Playable, Video): +class Clip(Video, Playable): """Represents a single Clip. Attributes: diff --git a/tests/conftest.py b/tests/conftest.py index 25d82cae..5f676fe7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- +import os import time from datetime import datetime from functools import partial -from os import environ import plexapi import pytest @@ -66,6 +66,11 @@ TEST_ANONYMOUSLY = "anonymously" ANON_PARAM = pytest.param(TEST_ANONYMOUSLY, marks=pytest.mark.anonymous) AUTH_PARAM = pytest.param(TEST_AUTHENTICATED, marks=pytest.mark.authenticated) +BASE_DIR_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STUB_MOVIE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "video_stub.mp4") +STUB_MP3_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "audio_stub.mp3") +STUB_IMAGE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "cute_cat.jpg") + def pytest_addoption(parser): parser.addoption( @@ -125,7 +130,7 @@ def account(sess): @pytest.fixture(scope="session") def account_once(account): - if environ.get("TEST_ACCOUNT_ONCE") not in ("1", "true") and environ.get("CI") == "true": + if os.environ.get("TEST_ACCOUNT_ONCE") not in ("1", "true") and os.environ.get("CI") == "true": pytest.skip("Do not forget to test this by providing TEST_ACCOUNT_ONCE=1") return account @@ -282,7 +287,7 @@ def subtitle(): @pytest.fixture() def shared_username(account): - username = environ.get("SHARED_USERNAME", "PKKid") + username = os.environ.get("SHARED_USERNAME", "PKKid") for user in account.users(): if user.title.lower() == username.lower(): return username diff --git a/tests/test_library.py b/tests/test_library.py index 98c5b484..b8602def 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -323,6 +323,16 @@ def test_library_Collection_thumbUrl(collection): def test_library_Collection_artUrl(collection): assert collection.artUrl is None # Collections don't have default art + +def test_library_Collection_posters(collection): + posters = collection.posters() + assert posters + + +def test_library_Collection_art(collection): + arts = collection.arts() + assert not arts # Collection has no default art + def test_library_Collection_mixins_tags(collection): test_mixins.edit_label(collection) diff --git a/tests/test_video.py b/tests/test_video.py index 87c3014c..0d06ee24 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -495,6 +495,50 @@ def test_video_Movie_match(movies): assert len(results) == 0 +def test_video_Movie_poster(movie): + posters = movie.posters() + poster = posters[0] + assert len(poster.key) >= 10 + if not poster.ratingKey.startswith("media://"): + assert poster.provider + assert len(poster.ratingKey) >= 10 + assert utils.is_bool(poster.selected) + assert len(poster.thumb) >= 10 + # Select a different poster + movie.setPoster(posters[1]) + posters = movie.posters() + assert posters[0].selected is False + assert posters[1].selected is True + # Test upload poster from file + movie.uploadPoster(filepath=utils.STUB_IMAGE_PATH) + posters = movie.posters() + file_poster = next(p for p in posters if p.ratingKey.startswith('upload://')) + assert file_poster.selected is True + movie.setPoster(posters[0]) # Reset to default poster + + +def test_video_Movie_art(movie): + arts = movie.arts() + art = arts[0] + assert len(art.key) >= 10 + if not art.ratingKey.startswith("media://"): + assert art.provider + assert len(art.ratingKey) >= 10 + assert utils.is_bool(art.selected) + assert len(art.thumb) >= 10 + # Select a different art + movie.setArt(arts[1]) + arts = movie.arts() + assert arts[0].selected is False + assert arts[1].selected is True + # Test upload poster from file + movie.uploadArt(filepath=utils.STUB_IMAGE_PATH) + arts = movie.arts() + file_art = next(a for a in arts if a.ratingKey.startswith('upload://')) + assert file_art.selected is True + movie.setArt(arts[0]) # Reset to default art + + def test_video_Movie_hubs(movies): movie = movies.get('Big Buck Bunny') hubs = movie.hubs()