From 48c41a1c68e92cfbbb091e99c70ca5542e19accd Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 11:09:42 -0800 Subject: [PATCH 01/96] Separate Poster and Art objects --- plexapi/base.py | 8 ++++---- plexapi/media.py | 21 +++++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index e905fdd9..6e5fd29b 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -534,8 +534,8 @@ class PlexPartialObject(PlexObject): def posters(self): """ Returns list of available poster objects. :class:`~plexapi.media.Poster`. """ - - return self.fetchItems('%s/posters' % self.key) + from plexapi.media import Poster + return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=Poster) def uploadPoster(self, url=None, filepath=None): """ Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ @@ -553,8 +553,8 @@ class PlexPartialObject(PlexObject): def arts(self): """ Returns list of available art objects. :class:`~plexapi.media.Poster`. """ - - return self.fetchItems('%s/arts' % self.key) + from plexapi.media import Art + return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=Art) def uploadArt(self, url=None, filepath=None): """ Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ diff --git a/plexapi/media.py b/plexapi/media.py index 2532a68a..01891ec7 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -614,20 +614,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): @@ -639,6 +644,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. From bc153b889627ac34a255451f3d5463116167c932 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 11:10:11 -0800 Subject: [PATCH 02/96] Update poster and art doc strings --- plexapi/base.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 6e5fd29b..92f72199 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -533,12 +533,17 @@ 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`. """ + """ Returns list of available :class:`~plexapi.media.Poster` objects. """ from plexapi.media import Poster return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=Poster) def uploadPoster(self, url=None, filepath=None): - """ Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ + """ 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 = '%s/posters?url=%s' % (self.key, quote_plus(url)) self._server.query(key, method=self._server._session.post) @@ -548,16 +553,25 @@ class PlexPartialObject(PlexObject): 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` """ + """ Set the poster for a Plex object. + + Parameters: + poster (:class:`~plexapi.media.Poster`): The poster object to select. + """ poster.select() def arts(self): - """ Returns list of available art objects. :class:`~plexapi.media.Poster`. """ + """ Returns list of available :class:`~plexapi.media.Art` objects. """ from plexapi.media import Art return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=Art) def uploadArt(self, url=None, filepath=None): - """ Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ + """ 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) @@ -567,7 +581,11 @@ class PlexPartialObject(PlexObject): 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` """ + """ Set the artwork for a Plex object. + + Parameters: + art (:class:`~plexapi.media.Art`): The art object to select. + """ art.select() def unmatch(self): From 13325a50ba41cfc246318368e3676ca80409c860 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 11:12:02 -0800 Subject: [PATCH 03/96] Remove poster and art methods from collections and playlists * Methods are already defined in PlexPartialObject --- plexapi/base.py | 4 ++-- plexapi/library.py | 38 -------------------------------------- plexapi/playlist.py | 38 -------------------------------------- 3 files changed, 2 insertions(+), 78 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 92f72199..1e552c0f 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -545,10 +545,10 @@ class PlexPartialObject(PlexObject): filepath (str): The full file path the the image to upload. """ if url: - key = '%s/posters?url=%s' % (self.key, quote_plus(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 = '%s/posters?' % self.key + key = '/library/metadata/%s/posters?' % self.ratingKey data = open(filepath, 'rb').read() self._server.query(key, method=self._server._session.post, data=data) diff --git a/plexapi/library.py b/plexapi/library.py index f6c515df..88cea56e 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -1553,44 +1553,6 @@ class Collections(PlexPartialObject): 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/playlist.py b/plexapi/playlist.py index 9e691b52..bf901c5d 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -311,41 +311,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() From 2263e94420c48b1c95be8039fe2b5bb8fc555a9c Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 11:47:15 -0800 Subject: [PATCH 04/96] Add tests for posters and art --- tests/conftest.py | 11 ++++++++--- tests/test_library.py | 10 ++++++++++ tests/test_video.py | 44 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 204cf7a1..626083ee 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 @@ -64,6 +64,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( @@ -120,7 +125,7 @@ def account(): @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 @@ -277,7 +282,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 83404f8f..22c631e5 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -313,6 +313,16 @@ def test_library_Collection_items(collection): assert len(items) == 1 +def test_library_Collection_posters(collection): + posters = collection.posters() + assert posters + + +def test_library_Collection_posters(collection): + arts = collection.arts() + assert not arts # Collection has no default art + + def test_search_with_weird_a(plex): ep_title = "Coup de Grâce" result_root = plex.search(ep_title) diff --git a/tests/test_video.py b/tests/test_video.py index c40fefa8..87609559 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -451,6 +451,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_Show(show): assert show.title == "Game of Thrones" From 7aaf56a62d60e2079347d24d804de772852d838b Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 14:35:13 -0800 Subject: [PATCH 05/96] Move all tag editing to a mixin --- plexapi/audio.py | 7 +- plexapi/base.py | 28 ------ plexapi/library.py | 3 +- plexapi/mixin.py | 221 +++++++++++++++++++++++++++++++++++++++++++++ plexapi/photo.py | 3 +- plexapi/video.py | 7 +- 6 files changed, 233 insertions(+), 36 deletions(-) create mode 100644 plexapi/mixin.py diff --git a/plexapi/audio.py b/plexapi/audio.py index 53f7d9bd..a8052922 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -4,6 +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.mixin import EditCollection, EditCountry, EditGenre, EditLabel, EditMood, EditSimilarArtist, EditStyle class Audio(PlexPartialObject): @@ -123,7 +124,7 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio): +class Artist(Audio, EditCollection, EditCountry, EditGenre, EditMood, EditSimilarArtist, EditStyle): """ Represents a single Artist. Attributes: @@ -226,7 +227,7 @@ class Artist(Audio): @utils.registerPlexObject -class Album(Audio): +class Album(Audio, EditCollection, EditGenre, EditLabel, EditMood, EditStyle): """ Represents a single Album. Attributes: @@ -332,7 +333,7 @@ class Album(Audio): @utils.registerPlexObject -class Track(Audio, Playable): +class Track(Audio, Playable, EditMood): """ Represents a single Track. Attributes: diff --git a/plexapi/base.py b/plexapi/base.py index 4d9f397f..814a4d94 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -468,34 +468,6 @@ class PlexPartialObject(PlexObject): self.edit(**d) self.refresh() - def addCollection(self, collections): - """ Add a collection(s). - - Parameters: - collections (list): list of strings - """ - self._edit_tags('collection', collections) - - def removeCollection(self, collections): - """ Remove a collection(s). """ - self._edit_tags('collection', collections, remove=True) - - def addLabel(self, labels): - """ Add a label(s). """ - self._edit_tags('label', labels) - - def removeLabel(self, labels): - """ Remove a label(s). """ - self._edit_tags('label', labels, remove=True) - - def addGenre(self, genres): - """ Add a genre(s). """ - self._edit_tags('genre', genres) - - def removeGenre(self, genres): - """ Remove a genre(s). """ - self._edit_tags('genre', genres, remove=True) - def refresh(self): """ Refreshing a Library or individual item causes the metadata for the item to be refreshed, even if it already has metadata. You can think of refreshing as diff --git a/plexapi/library.py b/plexapi/library.py index e282a9f7..860a7bc8 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.mixin import EditLabel from plexapi.settings import Setting from plexapi.utils import deprecated @@ -1525,7 +1526,7 @@ class FirstCharacter(PlexObject): @utils.registerPlexObject -class Collections(PlexPartialObject): +class Collections(PlexPartialObject, EditLabel): """ Represents a single Collection. Attributes: diff --git a/plexapi/mixin.py b/plexapi/mixin.py new file mode 100644 index 00000000..1e887208 --- /dev/null +++ b/plexapi/mixin.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- + + +class EditCollection(object): + """ Mixin for Plex objects that can have collections. """ + + def addCollection(self, collections): + """ Add a collection tag(s). + + Parameters: + collections (list): List of strings. + """ + self._edit_tags('collection', collections) + + def removeCollection(self, collections): + """ Remove a collection tag(s). + + Parameters: + collections (list): List of strings. + """ + self._edit_tags('collection', collections, remove=True) + + +class EditCountry(object): + """ Mixin for Plex objects that can have countries. """ + + def addCountry(self, countries): + """ Add a country tag(s). + + Parameters: + countries (list): List of strings. + """ + self._edit_tags('country', countries) + + def removeCountry(self, countries): + """ Remove a country tag(s). + + Parameters: + countries (list): List of strings. + """ + self._edit_tags('country', countries, remove=True) + + +class EditDirector(object): + """ Mixin for Plex objects that can have directors. """ + + def addDirector(self, directors): + """ Add a director tag(s). + + Parameters: + directors (list): List of strings. + """ + self._edit_tags('director', directors) + + def removeDirector(self, directors): + """ Remove a director tag(s). + + Parameters: + directors (list): List of strings. + """ + self._edit_tags('director', directors, remove=True) + + +class EditGenre(object): + """ Mixin for Plex objects that can have genres. """ + + def addGenre(self, genres): + """ Add a genre tag(s). + + Parameters: + genres (list): List of strings. + """ + self._edit_tags('genre', genres) + + def removeGenre(self, genres): + """ Remove a genre tag(s). + + Parameters: + genres (list): List of strings. + """ + self._edit_tags('genre', genres, remove=True) + + +class EditLabel(object): + """ Mixin for Plex objects that can have labels. """ + + def addLabel(self, labels): + """ Add a label tag(s). + + Parameters: + labels (list): List of strings. + """ + self._edit_tags('label', labels) + + def removeLabel(self, labels): + """ Remove a label tag(s). + + Parameters: + labels (list): List of strings. + """ + self._edit_tags('label', labels, remove=True) + + +class EditMood(object): + """ Mixin for Plex objects that can have moods. """ + + def addMood(self, moods): + """ Add a mood tag(s). + + Parameters: + moods (list): List of strings. + """ + self._edit_tags('mood', moods) + + def removeMood(self, moods): + """ Remove a mood tag(s). + + Parameters: + moods (list): List of strings. + """ + self._edit_tags('mood', moods, remove=True) + + +class EditProducer(object): + """ Mixin for Plex objects that can have producers. """ + + def addProducer(self, producers): + """ Add a producer tag(s). + + Parameters: + producers (list): List of strings. + """ + self._edit_tags('producer', producers) + + def removeProducer(self, producers): + """ Remove a producer tag(s). + + Parameters: + producers (list): List of strings. + """ + self._edit_tags('producer', producers, remove=True) + + +class EditSimilarArtist(object): + """ Mixin for Plex objects that can have similar artists. """ + + def addSimilarArtist(self, artists): + """ Add a similar artist tag(s). + + Parameters: + artists (list): List of strings. + """ + self._edit_tags('similar', artists) + + def removeSimilarArtist(self, artists): + """ Remove a similar artist tag(s). + + Parameters: + artists (list): List of strings. + """ + self._edit_tags('similar', artists, remove=True) + + +class EditStyle(object): + """ Mixin for Plex objects that can have styles. """ + + def addStyle(self, styles): + """ Add a style tag(s). + + Parameters: + styles (list): List of strings. + """ + self._edit_tags('style', styles) + + def removeStyle(self, styles): + """ Remove a style tag(s). + + Parameters: + styles (list): List of strings. + """ + self._edit_tags('style', styles, remove=True) + + +class EditTag(object): + """ Mixin for Plex objects that can have tags. """ + + def addTag(self, tags): + """ Add a tag(s). + + Parameters: + tags (list): List of strings. + """ + self._edit_tags('tag', tags) + + def removeTag(self, tags): + """ Remove a tag(s). + + Parameters: + tags (list): List of strings. + """ + self._edit_tags('tag', tags, remove=True) + + +class EditWriter(object): + """ Mixin for Plex objects that can have writers. """ + + def addWriter(self, writers): + """ Add a writer tag(s). + + Parameters: + writers (list): List of strings. + """ + self._edit_tags('writer', writers) + + def removeWriter(self, writers): + """ Remove a writer tag(s). + + Parameters: + writers (list): List of strings. + """ + self._edit_tags('writer', writers, remove=True) diff --git a/plexapi/photo.py b/plexapi/photo.py index 49d640bc..cb09ce44 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -4,6 +4,7 @@ from urllib.parse import quote_plus from plexapi import media, utils, video from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest +from plexapi.mixin import EditTag @utils.registerPlexObject @@ -136,7 +137,7 @@ class Photoalbum(PlexPartialObject): @utils.registerPlexObject -class Photo(PlexPartialObject, Playable): +class Photo(PlexPartialObject, Playable, EditTag): """ Represents a single Photo. Attributes: diff --git a/plexapi/video.py b/plexapi/video.py index 0a184b57..1055ea3b 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,6 +5,7 @@ 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.mixin import EditCollection, EditCountry, EditDirector, EditGenre, EditLabel, EditProducer, EditWriter class Video(PlexPartialObject): @@ -259,7 +260,7 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Playable, Video): +class Movie(Playable, Video, EditCollection, EditCountry, EditDirector, EditGenre, EditLabel, EditProducer, EditWriter): """ Represents a single Movie. Attributes: @@ -383,7 +384,7 @@ class Movie(Playable, Video): @utils.registerPlexObject -class Show(Video): +class Show(Video, EditCollection, EditGenre, EditLabel): """ Represents a single Show (including all seasons and episodes). Attributes: @@ -707,7 +708,7 @@ class Season(Video): @utils.registerPlexObject -class Episode(Playable, Video): +class Episode(Playable, Video, EditDirector, EditWriter): """ Represents a single Shows Episode. Attributes: From cfc5bdae26aa5eda801549fa1f05bab302ad95b7 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 14:32:02 -0800 Subject: [PATCH 06/96] Photo plural tags attribute --- plexapi/photo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plexapi/photo.py b/plexapi/photo.py index cb09ce44..86e017df 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -164,7 +164,7 @@ class Photo(PlexPartialObject, Playable, EditTag): parentTitle (str): Name of the photo album for the photo. ratingKey (int): Unique key identifying the photo. summary (str): Summary of the photo. - tag (List<:class:`~plexapi.media.Tag`>): List of tag objects. + tags (List<:class:`~plexapi.media.Tag`>): List of tag objects. thumb (str): URL to thumbnail image (/library/metadata//thumb/). title (str): Name of the photo. titleSort (str): Title to use when sorting (defaults to title). @@ -200,7 +200,7 @@ class Photo(PlexPartialObject, Playable, EditTag): self.parentTitle = data.attrib.get('parentTitle') self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.summary = data.attrib.get('summary') - self.tag = self.findItems(data, media.Tag) + self.tags = self.findItems(data, media.Tag) self.thumb = data.attrib.get('thumb') self.title = data.attrib.get('title') self.titleSort = data.attrib.get('titleSort', self.title) From 1b378d3f1c95675745df1f003915609413f48fe3 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 14:36:38 -0800 Subject: [PATCH 07/96] Add helper function to get the plural tag --- plexapi/base.py | 4 ++-- plexapi/utils.py | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 814a4d94..295f502d 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus, urlencode from plexapi import log, utils from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported -from plexapi.utils import tag_helper +from plexapi.utils import get_plural_attr, tag_helper DONT_RELOAD_FOR_KEYS = ['key', 'session'] OPERATORS = { @@ -462,7 +462,7 @@ class PlexPartialObject(PlexObject): """ if not isinstance(items, list): items = [items] - value = getattr(self, tag + 's') + value = getattr(self, get_plural_attr(tag)) existing_cols = [t.tag for t in value if t and remove is False] d = tag_helper(tag, existing_cols + items, locked, remove) self.edit(**d) diff --git a/plexapi/utils.py b/plexapi/utils.py index 8579ca6c..c58dea2b 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -335,6 +335,15 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 return fullpath +def get_plural_attr(tag): + if tag == 'country': + return 'countries' + elif tag == 'similar': + return 'similar' + else: + return tag + 's' + + def tag_helper(tag, items, locked=True, remove=False): """ Simple tag helper for editing a object. """ if not isinstance(items, list): From deda4e1b2b4e68652b27a50db5aff1f244fd5223 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 14:40:02 -0800 Subject: [PATCH 08/96] Add mixin tag tests --- tests/test_audio.py | 26 ++++++++++++++++--- tests/test_library.py | 5 ++++ tests/test_mixin.py | 58 +++++++++++++++++++++++++++++++++++++++++++ tests/test_video.py | 30 ++++++++++++++-------- 4 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 tests/test_mixin.py diff --git a/tests/test_audio.py b/tests/test_audio.py index e14bc06b..cba78383 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -2,6 +2,7 @@ from datetime import datetime from . import conftest as utils +from . import test_mixin def test_audio_Artist_attr(artist): @@ -63,6 +64,15 @@ def test_audio_Artist_albums(artist): assert len(albums) == 1 and albums[0].title == "Layers" +def test_audio_Artist_mixin_tags(artist): + test_mixin.edit_collection(artist) + test_mixin.edit_country(artist) + test_mixin.edit_genre(artist) + test_mixin.edit_mood(artist) + test_mixin.edit_similar_artist(artist) + test_mixin.edit_style(artist) + + def test_audio_Album_attrs(album): assert utils.is_datetime(album.addedAt) assert isinstance(album.genres, list) @@ -211,6 +221,14 @@ def test_audio_Album_artist(album): artist.title == "Broke For Free" +def test_audio_Album_mixin_tags(album): + test_mixin.edit_collection(album) + test_mixin.edit_genre(album) + test_mixin.edit_label(album) + test_mixin.edit_mood(album) + test_mixin.edit_style(album) + + def test_audio_Track_attrs(album): track = album.get("As Colourful As Ever").reload() assert utils.is_datetime(track.addedAt) @@ -328,6 +346,10 @@ def test_audio_Track_artist(album, artist): assert tracks[0].artist() == artist +def test_audio_Track_mixin_tags(track): + test_mixin.edit_mood(track) + + def test_audio_Audio_section(artist, album, track): assert artist.section() assert album.section() @@ -348,7 +370,3 @@ def test_audio_album_download(monkeydownload, album, tmpdir): def test_audio_Artist_download(monkeydownload, artist, tmpdir): f = artist.download(savepath=str(tmpdir)) assert len(f) == 1 - - -def test_audio_Album_label(album, patched_http_call): - album.addLabel("YO") diff --git a/tests/test_library.py b/tests/test_library.py index 83404f8f..b6f13ea3 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -4,6 +4,7 @@ import pytest from plexapi.exceptions import NotFound from . import conftest as utils +from . import test_mixin def test_library_Library_section(plex): @@ -313,6 +314,10 @@ def test_library_Collection_items(collection): assert len(items) == 1 +def test_library_Collection_mixin_tags(collection): + test_mixin.edit_label(collection) + + def test_search_with_weird_a(plex): ep_title = "Coup de Grâce" result_root = plex.search(ep_title) diff --git a/tests/test_mixin.py b/tests/test_mixin.py new file mode 100644 index 00000000..2fe6a1b6 --- /dev/null +++ b/tests/test_mixin.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +TEST_MIXIN_TAG = "Test Tag" + + +def _mixin_test_tag(obj, attr, tag_method): + add_tag_method = getattr(obj, 'add' + tag_method) + remove_tag_method = getattr(obj, 'remove' + tag_method) + assert TEST_MIXIN_TAG not in [tag.tag for tag in getattr(obj, attr)] + add_tag_method(TEST_MIXIN_TAG) + obj.reload() + assert TEST_MIXIN_TAG in [tag.tag for tag in getattr(obj, attr)] + remove_tag_method(TEST_MIXIN_TAG) + obj.reload() + assert getattr(obj, attr) not in [tag.tag for tag in getattr(obj, attr)] + + +def edit_collection(obj): + _mixin_test_tag(obj, 'collections', 'Collection') + + +def edit_country(obj): + _mixin_test_tag(obj, 'countries', 'Country') + + +def edit_director(obj): + _mixin_test_tag(obj, 'directors', 'Director') + + +def edit_genre(obj): + _mixin_test_tag(obj, 'genres', 'Genre') + + +def edit_label(obj): + _mixin_test_tag(obj, 'labels', 'Label') + + +def edit_mood(obj): + _mixin_test_tag(obj, 'moods', 'Mood') + + +def edit_producer(obj): + _mixin_test_tag(obj, 'producers', 'Producer') + + +def edit_similar_artist(obj): + _mixin_test_tag(obj, 'similar', 'SimilarArtist') + + +def edit_style(obj): + _mixin_test_tag(obj, 'styles', 'Style') + + +def edit_tag(obj): + _mixin_test_tag(obj, 'tags', 'Tag') + + +def edit_writer(obj): + _mixin_test_tag(obj, 'writers', 'Writer') diff --git a/tests/test_video.py b/tests/test_video.py index 5eb508a5..3214e8eb 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -8,6 +8,7 @@ import pytest from plexapi.exceptions import BadRequest, NotFound from . import conftest as utils +from . import test_mixin def test_video_Movie(movies, movie): @@ -39,16 +40,14 @@ def test_video_Movie_merge(movie, patched_http_call): movie.merge(1337) -def test_video_Movie_addCollection(movie): - labelname = "Random_label" - org_collection = [tag.tag for tag in movie.collections if tag] - assert labelname not in org_collection - movie.addCollection(labelname) - movie.reload() - assert labelname in [tag.tag for tag in movie.collections if tag] - movie.removeCollection(labelname) - movie.reload() - assert labelname not in [tag.tag for tag in movie.collections if tag] +def test_video_Movie_mixin_tags(movie): + test_mixin.edit_collection(movie) + test_mixin.edit_country(movie) + test_mixin.edit_director(movie) + test_mixin.edit_genre(movie) + test_mixin.edit_label(movie) + test_mixin.edit_producer(movie) + test_mixin.edit_writer(movie) def test_video_Movie_getStreamURL(movie, account): @@ -730,6 +729,12 @@ def test_video_Show_section(show): assert section.title == "TV Shows" +def test_video_Show_mixin_tags(show): + test_mixin.edit_collection(show) + test_mixin.edit_genre(show) + test_mixin.edit_label(show) + + def test_video_Episode(show): episode = show.episode("Winter Is Coming") assert episode == show.episode(season=1, episode=1) @@ -820,6 +825,11 @@ def test_video_Episode_attrs(episode): assert part.accessible +def test_video_Episode_mixin_tags(episode): + test_mixin.edit_director(episode) + test_mixin.edit_writer(episode) + + def test_video_Season(show): seasons = show.seasons() assert len(seasons) == 2 From dd8373176e4ce083b8ca9f5b08e3bcfc0b50e80d Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 14:40:22 -0800 Subject: [PATCH 09/96] Update test collection fixture --- tests/conftest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 204cf7a1..8db421f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -222,14 +222,14 @@ def movie(movies): @pytest.fixture() -def collection(plex): +def collection(movies): try: - return plex.library.section("Movies").collections()[0] + return movies.collections()[0] except IndexError: - movie = plex.library.section("Movies").get("Elephants Dream") + movie = movies.get("Elephants Dream") movie.addCollection(["marvel"]) - n = plex.library.section("Movies").reload() + n = movies.reload() return n.collections()[0] From c1d294b39c6cf2d58b133be9fd08394b7f3d2397 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 14:49:40 -0800 Subject: [PATCH 10/96] Remove old todo comment --- plexapi/base.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 1e552c0f..fb93f3de 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -677,15 +677,6 @@ class PlexPartialObject(PlexObject): data = key + '?' + urlencode(params) self._server.query(data, method=self._server._session.put) - # 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. From f13d0bfe3bcd25885a930535897622b0c49c2466 Mon Sep 17 00:00:00 2001 From: Hellowlol Date: Mon, 4 Jan 2021 21:30:42 +0100 Subject: [PATCH 11/96] Change class name of the mixin --- plexapi/audio.py | 3 ++- plexapi/base.py | 13 ------------- plexapi/mixins.py | 17 +++++++++++++++++ plexapi/video.py | 5 +++-- 4 files changed, 22 insertions(+), 16 deletions(-) create mode 100644 plexapi/mixins.py diff --git a/plexapi/audio.py b/plexapi/audio.py index 53f7d9bd..2c253b8e 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -4,6 +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 SplitMergeAble class Audio(PlexPartialObject): @@ -123,7 +124,7 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio): +class Artist(Audio, SplitMergeAble): """ Represents a single Artist. Attributes: diff --git a/plexapi/base.py b/plexapi/base.py index 4d9f397f..245d04e2 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -739,19 +739,6 @@ class Playable(object): for part in item.parts: yield part - def split(self): - """Split a duplicate.""" - key = '%s/split' % self.key - return self._server.query(key, method=self._server._session.put) - - def merge(self, ratingKeys): - """Merge duplicate items.""" - if not isinstance(ratingKeys, list): - ratingKeys = str(ratingKeys).split(",") - - key = '%s/merge?ids=%s' % (self.key, ','.join(ratingKeys)) - return self._server.query(key, method=self._server._session.put) - def unmatch(self): """Unmatch a media file.""" key = '%s/unmatch' % self.key diff --git a/plexapi/mixins.py b/plexapi/mixins.py new file mode 100644 index 00000000..8ac4412e --- /dev/null +++ b/plexapi/mixins.py @@ -0,0 +1,17 @@ + + +class SplitMergeAble: + """ Mixin for something that that we can split and merge.""" + + def split(self): + """Split a duplicate.""" + key = '/library/metadata/%s/split' % self.ratingKey + return self._server.query(key, method=self._server._session.put) + + def merge(self, ratingKeys): + """Merge duplicate items.""" + if not isinstance(ratingKeys, list): + ratingKeys = str(ratingKeys).split(",") + + key = '%s/merge?ids=%s' % (self.key, ','.join(ratingKeys)) + return self._server.query(key, method=self._server._session.put) diff --git a/plexapi/video.py b/plexapi/video.py index 0a184b57..7d9f614b 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,6 +5,7 @@ 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 SplitMergeAble class Video(PlexPartialObject): @@ -259,7 +260,7 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Playable, Video): +class Movie(Playable, Video, SplitMergeAble): """ Represents a single Movie. Attributes: @@ -383,7 +384,7 @@ class Movie(Playable, Video): @utils.registerPlexObject -class Show(Video): +class Show(Video, SplitMergeAble): """ Represents a single Show (including all seasons and episodes). Attributes: From c23d9635f56933f4308fa99ab385f7bf88cd503c Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 15:03:23 -0800 Subject: [PATCH 12/96] Rename to mixins --- plexapi/audio.py | 2 +- plexapi/library.py | 2 +- plexapi/{mixin.py => mixins.py} | 0 plexapi/photo.py | 2 +- plexapi/video.py | 2 +- tests/test_audio.py | 32 ++++++++++++------------- tests/test_library.py | 6 ++--- tests/{test_mixin.py => test_mixins.py} | 24 +++++++++---------- tests/test_video.py | 32 ++++++++++++------------- 9 files changed, 51 insertions(+), 51 deletions(-) rename plexapi/{mixin.py => mixins.py} (100%) rename tests/{test_mixin.py => test_mixins.py} (57%) diff --git a/plexapi/audio.py b/plexapi/audio.py index a8052922..2866426c 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.mixin import EditCollection, EditCountry, EditGenre, EditLabel, EditMood, EditSimilarArtist, EditStyle +from plexapi.mixins import EditCollection, EditCountry, EditGenre, EditLabel, EditMood, EditSimilarArtist, EditStyle class Audio(PlexPartialObject): diff --git a/plexapi/library.py b/plexapi/library.py index 860a7bc8..36ea426b 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -4,7 +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.mixin import EditLabel +from plexapi.mixins import EditLabel from plexapi.settings import Setting from plexapi.utils import deprecated diff --git a/plexapi/mixin.py b/plexapi/mixins.py similarity index 100% rename from plexapi/mixin.py rename to plexapi/mixins.py diff --git a/plexapi/photo.py b/plexapi/photo.py index 86e017df..ab88841b 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -4,7 +4,7 @@ from urllib.parse import quote_plus from plexapi import media, utils, video from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest -from plexapi.mixin import EditTag +from plexapi.mixins import EditTag @utils.registerPlexObject diff --git a/plexapi/video.py b/plexapi/video.py index 1055ea3b..6972f6fa 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ 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.mixin import EditCollection, EditCountry, EditDirector, EditGenre, EditLabel, EditProducer, EditWriter +from plexapi.mixins import EditCollection, EditCountry, EditDirector, EditGenre, EditLabel, EditProducer, EditWriter class Video(PlexPartialObject): diff --git a/tests/test_audio.py b/tests/test_audio.py index cba78383..13ab8066 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -2,7 +2,7 @@ from datetime import datetime from . import conftest as utils -from . import test_mixin +from . import test_mixins def test_audio_Artist_attr(artist): @@ -64,13 +64,13 @@ def test_audio_Artist_albums(artist): assert len(albums) == 1 and albums[0].title == "Layers" -def test_audio_Artist_mixin_tags(artist): - test_mixin.edit_collection(artist) - test_mixin.edit_country(artist) - test_mixin.edit_genre(artist) - test_mixin.edit_mood(artist) - test_mixin.edit_similar_artist(artist) - test_mixin.edit_style(artist) +def test_audio_Artist_mixins_tags(artist): + test_mixins.edit_collection(artist) + test_mixins.edit_country(artist) + test_mixins.edit_genre(artist) + test_mixins.edit_mood(artist) + test_mixins.edit_similar_artist(artist) + test_mixins.edit_style(artist) def test_audio_Album_attrs(album): @@ -221,12 +221,12 @@ def test_audio_Album_artist(album): artist.title == "Broke For Free" -def test_audio_Album_mixin_tags(album): - test_mixin.edit_collection(album) - test_mixin.edit_genre(album) - test_mixin.edit_label(album) - test_mixin.edit_mood(album) - test_mixin.edit_style(album) +def test_audio_Album_mixins_tags(album): + test_mixins.edit_collection(album) + test_mixins.edit_genre(album) + test_mixins.edit_label(album) + test_mixins.edit_mood(album) + test_mixins.edit_style(album) def test_audio_Track_attrs(album): @@ -346,8 +346,8 @@ def test_audio_Track_artist(album, artist): assert tracks[0].artist() == artist -def test_audio_Track_mixin_tags(track): - test_mixin.edit_mood(track) +def test_audio_Track_mixins_tags(track): + test_mixins.edit_mood(track) def test_audio_Audio_section(artist, album, track): diff --git a/tests/test_library.py b/tests/test_library.py index b6f13ea3..b7949387 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -4,7 +4,7 @@ import pytest from plexapi.exceptions import NotFound from . import conftest as utils -from . import test_mixin +from . import test_mixins def test_library_Library_section(plex): @@ -314,8 +314,8 @@ def test_library_Collection_items(collection): assert len(items) == 1 -def test_library_Collection_mixin_tags(collection): - test_mixin.edit_label(collection) +def test_library_Collection_mixins_tags(collection): + test_mixins.edit_label(collection) def test_search_with_weird_a(plex): diff --git a/tests/test_mixin.py b/tests/test_mixins.py similarity index 57% rename from tests/test_mixin.py rename to tests/test_mixins.py index 2fe6a1b6..2440cacc 100644 --- a/tests/test_mixin.py +++ b/tests/test_mixins.py @@ -2,7 +2,7 @@ TEST_MIXIN_TAG = "Test Tag" -def _mixin_test_tag(obj, attr, tag_method): +def _test_mixins_tag(obj, attr, tag_method): add_tag_method = getattr(obj, 'add' + tag_method) remove_tag_method = getattr(obj, 'remove' + tag_method) assert TEST_MIXIN_TAG not in [tag.tag for tag in getattr(obj, attr)] @@ -15,44 +15,44 @@ def _mixin_test_tag(obj, attr, tag_method): def edit_collection(obj): - _mixin_test_tag(obj, 'collections', 'Collection') + _test_mixins_tag(obj, 'collections', 'Collection') def edit_country(obj): - _mixin_test_tag(obj, 'countries', 'Country') + _test_mixins_tag(obj, 'countries', 'Country') def edit_director(obj): - _mixin_test_tag(obj, 'directors', 'Director') + _test_mixins_tag(obj, 'directors', 'Director') def edit_genre(obj): - _mixin_test_tag(obj, 'genres', 'Genre') + _test_mixins_tag(obj, 'genres', 'Genre') def edit_label(obj): - _mixin_test_tag(obj, 'labels', 'Label') + _test_mixins_tag(obj, 'labels', 'Label') def edit_mood(obj): - _mixin_test_tag(obj, 'moods', 'Mood') + _test_mixins_tag(obj, 'moods', 'Mood') def edit_producer(obj): - _mixin_test_tag(obj, 'producers', 'Producer') + _test_mixins_tag(obj, 'producers', 'Producer') def edit_similar_artist(obj): - _mixin_test_tag(obj, 'similar', 'SimilarArtist') + _test_mixins_tag(obj, 'similar', 'SimilarArtist') def edit_style(obj): - _mixin_test_tag(obj, 'styles', 'Style') + _test_mixins_tag(obj, 'styles', 'Style') def edit_tag(obj): - _mixin_test_tag(obj, 'tags', 'Tag') + _test_mixins_tag(obj, 'tags', 'Tag') def edit_writer(obj): - _mixin_test_tag(obj, 'writers', 'Writer') + _test_mixins_tag(obj, 'writers', 'Writer') diff --git a/tests/test_video.py b/tests/test_video.py index 3214e8eb..26046fe8 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -8,7 +8,7 @@ import pytest from plexapi.exceptions import BadRequest, NotFound from . import conftest as utils -from . import test_mixin +from . import test_mixins def test_video_Movie(movies, movie): @@ -40,14 +40,14 @@ def test_video_Movie_merge(movie, patched_http_call): movie.merge(1337) -def test_video_Movie_mixin_tags(movie): - test_mixin.edit_collection(movie) - test_mixin.edit_country(movie) - test_mixin.edit_director(movie) - test_mixin.edit_genre(movie) - test_mixin.edit_label(movie) - test_mixin.edit_producer(movie) - test_mixin.edit_writer(movie) +def test_video_Movie_mixins_tags(movie): + test_mixins.edit_collection(movie) + test_mixins.edit_country(movie) + test_mixins.edit_director(movie) + test_mixins.edit_genre(movie) + test_mixins.edit_label(movie) + test_mixins.edit_producer(movie) + test_mixins.edit_writer(movie) def test_video_Movie_getStreamURL(movie, account): @@ -729,10 +729,10 @@ def test_video_Show_section(show): assert section.title == "TV Shows" -def test_video_Show_mixin_tags(show): - test_mixin.edit_collection(show) - test_mixin.edit_genre(show) - test_mixin.edit_label(show) +def test_video_Show_mixins_tags(show): + test_mixins.edit_collection(show) + test_mixins.edit_genre(show) + test_mixins.edit_label(show) def test_video_Episode(show): @@ -825,9 +825,9 @@ def test_video_Episode_attrs(episode): assert part.accessible -def test_video_Episode_mixin_tags(episode): - test_mixin.edit_director(episode) - test_mixin.edit_writer(episode) +def test_video_Episode_mixins_tags(episode): + test_mixins.edit_director(episode) + test_mixins.edit_writer(episode) def test_video_Season(show): From 3e1e2434a7136233f5114650d07b15d07a4ad463 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 15:13:22 -0800 Subject: [PATCH 13/96] Clean up SplitMerge mixin --- plexapi/audio.py | 4 ++-- plexapi/base.py | 2 +- plexapi/mixins.py | 17 +++++++++++------ plexapi/video.py | 6 +++--- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 2c253b8e..29e2a368 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 SplitMergeAble +from plexapi.mixins import SplitMerge class Audio(PlexPartialObject): @@ -124,7 +124,7 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio, SplitMergeAble): +class Artist(Audio, SplitMerge): """ Represents a single Artist. Attributes: diff --git a/plexapi/base.py b/plexapi/base.py index 245d04e2..037408f1 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -113,7 +113,7 @@ class PlexObject(object): def _isChildOf(self, **kwargs): """ Returns True if this object is a child of the given attributes. This will search the parent objects all the way to the top. - + Parameters: **kwargs (dict): The attributes and values to search for in the parent objects. See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`. diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 8ac4412e..c536b0fb 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -1,17 +1,22 @@ +# -*- coding: utf-8 -*- -class SplitMergeAble: - """ Mixin for something that that we can split and merge.""" +class SplitMerge(object): + """ Mixin for Plex objects that can be split and merged.""" def split(self): - """Split a duplicate.""" + """ Split duplicated Plex object into separate objects. """ key = '/library/metadata/%s/split' % self.ratingKey return self._server.query(key, method=self._server._session.put) def merge(self, ratingKeys): - """Merge duplicate items.""" + """ Merge other Plex objects into the current object. + + Parameters: + ratingKeys (list): A list of rating keys to merge. + """ if not isinstance(ratingKeys, list): - ratingKeys = str(ratingKeys).split(",") + ratingKeys = str(ratingKeys).split(',') - key = '%s/merge?ids=%s' % (self.key, ','.join(ratingKeys)) + key = '%s/merge?ids=%s' % (self.key, ','.join([str(r) for r in ratingKeys])) return self._server.query(key, method=self._server._session.put) diff --git a/plexapi/video.py b/plexapi/video.py index 7d9f614b..405901db 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ 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 SplitMergeAble +from plexapi.mixins import SplitMerge class Video(PlexPartialObject): @@ -260,7 +260,7 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Playable, Video, SplitMergeAble): +class Movie(Playable, Video, SplitMerge): """ Represents a single Movie. Attributes: @@ -384,7 +384,7 @@ class Movie(Playable, Video, SplitMergeAble): @utils.registerPlexObject -class Show(Video, SplitMergeAble): +class Show(Video, SplitMerge): """ Represents a single Show (including all seasons and episodes). Attributes: From 25ab16502ee58ea0cffc302d1e0223def8b934f0 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 15:18:28 -0800 Subject: [PATCH 14/96] Move unmatch and match to mixins --- plexapi/audio.py | 6 +-- plexapi/base.py | 89 ------------------------------------------- plexapi/mixins.py | 97 +++++++++++++++++++++++++++++++++++++++++++++++ plexapi/video.py | 6 +-- 4 files changed, 103 insertions(+), 95 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 29e2a368..8c8b3a9d 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 SplitMerge +from plexapi.mixins import SplitMerge, UnmatchMatch class Audio(PlexPartialObject): @@ -124,7 +124,7 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio, SplitMerge): +class Artist(Audio, SplitMerge, UnmatchMatch): """ Represents a single Artist. Attributes: @@ -227,7 +227,7 @@ class Artist(Audio, SplitMerge): @utils.registerPlexObject -class Album(Audio): +class Album(Audio, UnmatchMatch): """ Represents a single Album. Attributes: diff --git a/plexapi/base.py b/plexapi/base.py index 037408f1..72ed6de6 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -573,95 +573,6 @@ class PlexPartialObject(PlexObject): """ Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ art.select() - def unmatch(self): - """ Unmatches metadata match from object. """ - key = '/library/metadata/%s/unmatch' % self.ratingKey - self._server.query(key, method=self._server._session.put) - - def matches(self, agent=None, title=None, year=None, language=None): - """ Return list of (:class:`~plexapi.media.SearchResult`) metadata matches. - - Parameters: - agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) - title (str): Title of item to search for - year (str): Year of item to search in - language (str) : Language of item to search in - - Examples: - 1. video.matches() - 2. video.matches(title="something", year=2020) - 3. video.matches(title="something") - 4. video.matches(year=2020) - 5. video.matches(title="something", year="") - 6. video.matches(title="", year=2020) - 7. video.matches(title="", year="") - - 1. The default behaviour in Plex Web = no params in plexapi - 2. Both title and year specified by user - 3. Year automatically filled in - 4. Title automatically filled in - 5. Explicitly searches for title with blank year - 6. Explicitly searches for blank title with year - 7. I don't know what the user is thinking... return the same result as 1 - - For 2 to 7, the agent and language is automatically filled in - """ - key = '/library/metadata/%s/matches' % self.ratingKey - params = {'manual': 1} - - if agent and not any([title, year, language]): - params['language'] = self.section().language - params['agent'] = utils.getAgentIdentifier(self.section(), agent) - else: - if any(x is not None for x in [agent, title, year, language]): - if title is None: - params['title'] = self.title - else: - params['title'] = title - - if year is None: - params['year'] = self.year - else: - params['year'] = year - - params['language'] = language or self.section().language - - if agent is None: - params['agent'] = self.section().agent - else: - params['agent'] = utils.getAgentIdentifier(self.section(), agent) - - key = key + '?' + urlencode(params) - data = self._server.query(key, method=self._server._session.get) - return self.findItems(data, initpath=key) - - def fixMatch(self, searchResult=None, auto=False, agent=None): - """ Use match result to update show metadata. - - Parameters: - auto (bool): True uses first match from matches - False allows user to provide the match - searchResult (:class:`~plexapi.media.SearchResult`): Search result from - ~plexapi.base.matches() - agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) - """ - key = '/library/metadata/%s/match' % self.ratingKey - if auto: - autoMatch = self.matches(agent=agent) - if autoMatch: - searchResult = autoMatch[0] - else: - raise NotFound('No matches found using this agent: (%s:%s)' % (agent, autoMatch)) - elif not searchResult: - raise NotFound('fixMatch() requires either auto=True or ' - 'searchResult=:class:`~plexapi.media.SearchResult`.') - - params = {'guid': searchResult.guid, - 'name': searchResult.name} - - data = key + '?' + urlencode(params) - self._server.query(data, method=self._server._session.put) - # The photo tag cant be built atm. TODO # def arts(self): # part = '%s/arts' % self.key diff --git a/plexapi/mixins.py b/plexapi/mixins.py index c536b0fb..20d4d611 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -1,4 +1,8 @@ # -*- coding: utf-8 -*- +from urllib.parse import urlencode + +from plexapi import utils +from plexapi.exceptions import NotFound class SplitMerge(object): @@ -20,3 +24,96 @@ class SplitMerge(object): key = '%s/merge?ids=%s' % (self.key, ','.join([str(r) for r in ratingKeys])) return self._server.query(key, method=self._server._session.put) + + +class UnmatchMatch(object): + """ Mixin for Plex objects that can be unmatched and matched.""" + + def unmatch(self): + """ Unmatches metadata match from object. """ + key = '/library/metadata/%s/unmatch' % self.ratingKey + self._server.query(key, method=self._server._session.put) + + def matches(self, agent=None, title=None, year=None, language=None): + """ Return list of (:class:`~plexapi.media.SearchResult`) metadata matches. + + Parameters: + agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) + title (str): Title of item to search for + year (str): Year of item to search in + language (str) : Language of item to search in + + Examples: + 1. video.matches() + 2. video.matches(title="something", year=2020) + 3. video.matches(title="something") + 4. video.matches(year=2020) + 5. video.matches(title="something", year="") + 6. video.matches(title="", year=2020) + 7. video.matches(title="", year="") + + 1. The default behaviour in Plex Web = no params in plexapi + 2. Both title and year specified by user + 3. Year automatically filled in + 4. Title automatically filled in + 5. Explicitly searches for title with blank year + 6. Explicitly searches for blank title with year + 7. I don't know what the user is thinking... return the same result as 1 + + For 2 to 7, the agent and language is automatically filled in + """ + key = '/library/metadata/%s/matches' % self.ratingKey + params = {'manual': 1} + + if agent and not any([title, year, language]): + params['language'] = self.section().language + params['agent'] = utils.getAgentIdentifier(self.section(), agent) + else: + if any(x is not None for x in [agent, title, year, language]): + if title is None: + params['title'] = self.title + else: + params['title'] = title + + if year is None: + params['year'] = self.year + else: + params['year'] = year + + params['language'] = language or self.section().language + + if agent is None: + params['agent'] = self.section().agent + else: + params['agent'] = utils.getAgentIdentifier(self.section(), agent) + + key = key + '?' + urlencode(params) + data = self._server.query(key, method=self._server._session.get) + return self.findItems(data, initpath=key) + + def fixMatch(self, searchResult=None, auto=False, agent=None): + """ Use match result to update show metadata. + + Parameters: + auto (bool): True uses first match from matches + False allows user to provide the match + searchResult (:class:`~plexapi.media.SearchResult`): Search result from + ~plexapi.base.matches() + agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) + """ + key = '/library/metadata/%s/match' % self.ratingKey + if auto: + autoMatch = self.matches(agent=agent) + if autoMatch: + searchResult = autoMatch[0] + else: + raise NotFound('No matches found using this agent: (%s:%s)' % (agent, autoMatch)) + elif not searchResult: + raise NotFound('fixMatch() requires either auto=True or ' + 'searchResult=:class:`~plexapi.media.SearchResult`.') + + params = {'guid': searchResult.guid, + 'name': searchResult.name} + + data = key + '?' + urlencode(params) + self._server.query(data, method=self._server._session.put) diff --git a/plexapi/video.py b/plexapi/video.py index 405901db..fb4c0cd9 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ 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 SplitMerge +from plexapi.mixins import SplitMerge, UnmatchMatch class Video(PlexPartialObject): @@ -260,7 +260,7 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Playable, Video, SplitMerge): +class Movie(Playable, Video, SplitMerge, UnmatchMatch): """ Represents a single Movie. Attributes: @@ -384,7 +384,7 @@ class Movie(Playable, Video, SplitMerge): @utils.registerPlexObject -class Show(Video, SplitMerge): +class Show(Video, SplitMerge, UnmatchMatch): """ Represents a single Show (including all seasons and episodes). Attributes: From 5a4d564fd3b480987048061c75f535ef3760be5f Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 15:29:20 -0800 Subject: [PATCH 15/96] Move poster and art to a mixin --- plexapi/audio.py | 5 ++-- plexapi/base.py | 56 ---------------------------------------- plexapi/library.py | 3 ++- plexapi/mixins.py | 62 +++++++++++++++++++++++++++++++++++++++++++++ plexapi/playlist.py | 3 ++- plexapi/video.py | 11 ++++---- 6 files changed, 75 insertions(+), 65 deletions(-) create mode 100644 plexapi/mixins.py diff --git a/plexapi/audio.py b/plexapi/audio.py index 53f7d9bd..27ccfc7b 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -4,6 +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 PosterArt class Audio(PlexPartialObject): @@ -123,7 +124,7 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio): +class Artist(Audio, PosterArt): """ Represents a single Artist. Attributes: @@ -226,7 +227,7 @@ class Artist(Audio): @utils.registerPlexObject -class Album(Audio): +class Album(Audio, PosterArt): """ Represents a single Album. Attributes: diff --git a/plexapi/base.py b/plexapi/base.py index 1c2e81ad..d4fdf923 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -535,62 +535,6 @@ class PlexPartialObject(PlexObject): """ return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey) - def posters(self): - """ Returns list of available :class:`~plexapi.media.Poster` objects. """ - from plexapi.media import Poster - return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=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() - - def arts(self): - """ Returns list of available :class:`~plexapi.media.Art` objects. """ - from plexapi.media import Art - return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=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() - def unmatch(self): """ Unmatches metadata match from object. """ key = '/library/metadata/%s/unmatch' % self.ratingKey diff --git a/plexapi/library.py b/plexapi/library.py index 9ffe7b8a..765b23e1 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 PosterArt from plexapi.settings import Setting from plexapi.utils import deprecated @@ -1525,7 +1526,7 @@ class FirstCharacter(PlexObject): @utils.registerPlexObject -class Collections(PlexPartialObject): +class Collections(PlexPartialObject, PosterArt): """ Represents a single Collection. Attributes: diff --git a/plexapi/mixins.py b/plexapi/mixins.py new file mode 100644 index 00000000..9dad003b --- /dev/null +++ b/plexapi/mixins.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from urllib.parse import quote_plus + +from plexapi import media + + +class PosterArt(object): + """ Mixin for Plex objects that can have posters and artwork.""" + + 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() + + 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() diff --git a/plexapi/playlist.py b/plexapi/playlist.py index bf901c5d..15d4488d 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 PosterArt from plexapi.playqueue import PlayQueue from plexapi.utils import cast, toDatetime @utils.registerPlexObject -class Playlist(PlexPartialObject, Playable): +class Playlist(PlexPartialObject, Playable, PosterArt): """ Represents a single Playlist. Attributes: diff --git a/plexapi/video.py b/plexapi/video.py index 0a184b57..81c36d3b 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,6 +5,7 @@ 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 PosterArt class Video(PlexPartialObject): @@ -259,7 +260,7 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Playable, Video): +class Movie(Video, Playable, PosterArt): """ Represents a single Movie. Attributes: @@ -383,7 +384,7 @@ class Movie(Playable, Video): @utils.registerPlexObject -class Show(Video): +class Show(Video, PosterArt): """ Represents a single Show (including all seasons and episodes). Attributes: @@ -581,7 +582,7 @@ class Show(Video): @utils.registerPlexObject -class Season(Video): +class Season(Video, PosterArt): """ Represents a single Show Season (including all episodes). Attributes: @@ -707,7 +708,7 @@ class Season(Video): @utils.registerPlexObject -class Episode(Playable, Video): +class Episode(Video, Playable, PosterArt): """ Represents a single Shows Episode. Attributes: @@ -830,7 +831,7 @@ class Episode(Playable, Video): @utils.registerPlexObject -class Clip(Playable, Video): +class Clip(Video, Playable): """Represents a single Clip. Attributes: From 22bc55a74e2c9a94ac472ef03793c4f09b1c0e52 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 19:44:36 -0800 Subject: [PATCH 16/96] Rename tag plural helper function --- plexapi/base.py | 4 ++-- plexapi/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 295f502d..0ff0417b 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus, urlencode from plexapi import log, utils from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported -from plexapi.utils import get_plural_attr, tag_helper +from plexapi.utils import tag_plural, tag_helper DONT_RELOAD_FOR_KEYS = ['key', 'session'] OPERATORS = { @@ -462,7 +462,7 @@ class PlexPartialObject(PlexObject): """ if not isinstance(items, list): items = [items] - value = getattr(self, get_plural_attr(tag)) + value = getattr(self, tag_plural(tag)) existing_cols = [t.tag for t in value if t and remove is False] d = tag_helper(tag, existing_cols + items, locked, remove) self.edit(**d) diff --git a/plexapi/utils.py b/plexapi/utils.py index c58dea2b..c6021e18 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -335,7 +335,7 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 return fullpath -def get_plural_attr(tag): +def tag_plural(tag): if tag == 'country': return 'countries' elif tag == 'similar': From 43a54d556da1e04a033eb04ad8a1b7d9905bdb82 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 21:28:27 -0800 Subject: [PATCH 17/96] Fix photo album key --- plexapi/photo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/photo.py b/plexapi/photo.py index 49d640bc..1e72f1cc 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -44,7 +44,7 @@ class Photoalbum(PlexPartialObject): self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') self.index = utils.cast(int, data.attrib.get('index')) - self.key = data.attrib.get('key', '') + self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 self.librarySectionID = data.attrib.get('librarySectionID') self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') From 5c41bf01acd0af3addb401f4b0314bc46a19326a Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 25 Jan 2021 09:14:25 -0800 Subject: [PATCH 18/96] Add thumbUrl and artUrl properties to collections --- plexapi/library.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/plexapi/library.py b/plexapi/library.py index e27df987..ce0e0ec6 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -1595,7 +1595,17 @@ class Collections(PlexPartialObject): @deprecated('use "items" instead') def children(self): return self.fetchItems(self.key) - + + @property + def thumbUrl(self): + """ Return the thumbnail url for the collection.""" + return self._server.url(self.thumb, includeToken=True) if self.thumb else None + + @property + def artUrl(self): + """ Return the art url for the collection.""" + return self._server.url(self.art, includeToken=True) if self.art else None + def item(self, title): """ Returns the item in the collection that matches the specified title. From 688bca92ac6c7e4ad3d7a6e7ca7703dff253958e Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 25 Jan 2021 09:20:19 -0800 Subject: [PATCH 19/96] Add tests for collection thumbUrl and artUrl --- tests/test_library.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_library.py b/tests/test_library.py index 3958a8e3..b081df14 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -313,6 +313,16 @@ def test_library_Collection_items(collection): assert len(items) == 1 +def test_library_Collection_thumbUrl(collection): + assert utils.SERVER_BASEURL in collection.thumbUrl + assert "/library/collections/" in collection.thumbUrl + assert "/composite/" in collection.thumbUrl + + +def test_library_Collection_artUrl(collection): + assert collection.artUrl is None # Collections don't have default art + + def test_search_with_weird_a(plex): ep_title = "Coup de Grâce" result_root = plex.search(ep_title) From 5b4add3cb7abc5c321963d368ce6d0021368643b Mon Sep 17 00:00:00 2001 From: Steffen Fredriksen Date: Wed, 27 Jan 2021 22:37:56 +0100 Subject: [PATCH 20/96] increase the timeout. (#634) * Lets try to increate the timeout. * fix typo --- tests/conftest.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5ac6eda9..9a7cec5c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -107,8 +107,15 @@ def pytest_runtest_setup(item): # --------------------------------- -def get_account(): - return MyPlexAccount() +@pytest.fixture(scope="session") +def sess(): + session = requests.Session() + session.request = partial(session.request, timeout=120) + return session + + +def get_account(sess): + return MyPlexAccount(session=sess) @pytest.fixture(scope="session") @@ -152,14 +159,14 @@ def mocked_account(requests_mock): @pytest.fixture(scope="session") -def plex(request): +def plex(request, sess): assert SERVER_BASEURL, "Required SERVER_BASEURL not specified." - session = requests.Session() + if request.param == TEST_AUTHENTICATED: token = get_account().authenticationToken else: token = None - return PlexServer(SERVER_BASEURL, token, session=session) + return PlexServer(SERVER_BASEURL, token, session=sess) @pytest.fixture(scope="session") @@ -168,7 +175,7 @@ def sync_device(account_synctarget): device = account_synctarget.device(clientId=SYNC_DEVICE_IDENTIFIER) except NotFound: device = createMyPlexDevice(SYNC_DEVICE_HEADERS, account_synctarget) - + assert device assert "sync-target" in device.provides return device From 4a2086a798568569eee0e66ea803ad5d6caac3b3 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 30 Jan 2021 16:22:48 -0800 Subject: [PATCH 21/96] Remove episode split test --- tests/test_video.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_video.py b/tests/test_video.py index 5eb508a5..7a26ade0 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -531,10 +531,6 @@ def test_video_Show(show): assert show.title == "Game of Thrones" -def test_video_Episode_split(episode, patched_http_call): - episode.split() - - def test_video_Episode_unmatch(episode, patched_http_call): episode.unmatch() From 1bcd3549be05e3f6983887dd11ea87fb786f1604 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 30 Jan 2021 16:25:08 -0800 Subject: [PATCH 22/96] Fix library timeline test queue size --- tests/test_library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_library.py b/tests/test_library.py index b7949387..5ea37f86 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -361,6 +361,6 @@ def test_library_section_timeline(plex): assert tl.mediaTagVersion > 1 assert tl.thumb == "/:/resources/movie.png" assert tl.title1 == "Movies" - assert tl.updateQueueSize == 0 + assert utils.is_int(tl.updateQueueSize, gte=0) assert tl.viewGroup == "secondary" assert tl.viewMode == 65592 From 66e47068d93ccdd16ff9959f614e66ac2ef998b4 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 1 Feb 2021 21:59:11 -0500 Subject: [PATCH 23/96] Fix session param in tests (#652) * Fix session param in tests * Remove get_account helper * Remove 'account' from fixtures to avoid skipping tests --- tests/conftest.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9a7cec5c..40073e61 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,17 +114,13 @@ def sess(): return session -def get_account(sess): - return MyPlexAccount(session=sess) - - @pytest.fixture(scope="session") -def account(): +def account(sess): if SERVER_TOKEN: - return get_account() + return MyPlexAccount(session=sess) assert MYPLEX_USERNAME, "Required MYPLEX_USERNAME not specified." assert MYPLEX_PASSWORD, "Required MYPLEX_PASSWORD not specified." - return get_account() + return MyPlexAccount(session=sess) @pytest.fixture(scope="session") @@ -163,7 +159,7 @@ def plex(request, sess): assert SERVER_BASEURL, "Required SERVER_BASEURL not specified." if request.param == TEST_AUTHENTICATED: - token = get_account().authenticationToken + token = MyPlexAccount(session=sess).authenticationToken else: token = None return PlexServer(SERVER_BASEURL, token, session=sess) From a263f49b10ff4694c506727a50b3fbb0123bf2c6 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 1 Feb 2021 18:59:34 -0800 Subject: [PATCH 24/96] Fix sorting of resource connections (#653) * Fix sorting of resource connections * Update resource connect doc strings * flake8 single variable name connection --- plexapi/myplex.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 09c5caa7..b2190c74 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -947,31 +947,38 @@ class MyPlexResource(PlexObject): def connect(self, ssl=None, timeout=None): """ Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object. Often times there is more than one address specified for a server or client. - This function will prioritize local connections before remote and HTTPS before HTTP. + This function will prioritize local connections before remote or relay and HTTPS before HTTP. After trying to connect to all available addresses for this resource and assuming at least one connection was successful, the PlexServer object is built and returned. Parameters: - ssl (optional): Set True to only connect to HTTPS connections. Set False to + ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to only connect to HTTP connections. Set None (default) to connect to any HTTP or HTTPS connection. + timeout (int, optional): The timeout in seconds to attempt each connection. Raises: :exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. """ - # Sort connections from (https, local) to (http, remote) - # Only check non-local connections unless we own the resource - connections = sorted(self.connections, key=lambda c: c.local, reverse=True) - owned_or_unowned_non_local = lambda x: self.owned or (not self.owned and not x.local) - https = [c.uri for c in connections if owned_or_unowned_non_local(c)] - http = [c.httpuri for c in connections if owned_or_unowned_non_local(c)] - cls = PlexServer if 'server' in self.provides else PlexClient - # Force ssl, no ssl, or any (default) - if ssl is True: connections = https - elif ssl is False: connections = http - else: connections = https + http + # Keys in the order we want the connections to be sorted + locations = ['local', 'remote', 'relay'] + schemes = ['https', 'http'] + connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations} + for connection in self.connections: + # Only check non-local connections unless we own the resource + if self.owned or (not self.owned and not connection.local): + location = 'relay' if connection.relay else ('local' if connection.local else 'remote') + connections_dict[location]['http'].append(connection.httpuri) + connections_dict[location]['https'].append(connection.uri) + if ssl is True: schemes.remove('http') + elif ssl is False: schemes.remove('https') + connections = [] + for location in locations: + for scheme in schemes: + connections.extend(connections_dict[location][scheme]) # Try connecting to all known resource connections in parellel, but # only return the first server (in order) that provides a response. + cls = PlexServer if 'server' in self.provides else PlexClient listargs = [[cls, url, self.accessToken, timeout] for url in connections] log.debug('Testing %s resource connections..', len(listargs)) results = utils.threaded(_connect, listargs) From 9c0bb00bdb2e2886b59837281e13de058566ca0a Mon Sep 17 00:00:00 2001 From: Jason Lawrence Date: Tue, 2 Feb 2021 15:49:09 -0600 Subject: [PATCH 25/96] Bump to 4.3.1 --- plexapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/__init__.py b/plexapi/__init__.py index 2f222236..133641b4 100644 --- a/plexapi/__init__.py +++ b/plexapi/__init__.py @@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH) # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '4.3.0' +VERSION = '4.3.1' TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool) From 68045feba2507d231a5f93ed60ec3d75dbe0e372 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 4 Feb 2021 18:04:08 -0800 Subject: [PATCH 26/96] Fix _isChildOf when weakref is dead --- plexapi/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/base.py b/plexapi/base.py index 4d9f397f..2e8659ae 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -119,7 +119,7 @@ class PlexObject(object): See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`. """ obj = self - while obj._parent is not None: + while obj._parent is not None and obj._parent() is not None: obj = obj._parent() if obj._checkAttrs(obj._data, **kwargs): return True From a7f5d16aee0423a63583432aa36eb997b89ff1fa Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 4 Feb 2021 18:16:53 -0800 Subject: [PATCH 27/96] Add test to check episode parent weakref --- tests/test_video.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_video.py b/tests/test_video.py index e60b2848..0799ff0d 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -739,6 +739,16 @@ def test_video_Episode_history(episode): episode.markUnwatched() +def test_video_Episode_parent_weakref(show): + season = show.season(season=1) + episode = season.episode(episode=1) + assert episode._parent is not None + assert episode._parent() == season + episode = show.season(season=1).episode(episode=1) + assert episode._parent is not None + assert episode._parent() is None + + # Analyze seems to fail intermittently @pytest.mark.xfail def test_video_Episode_analyze(tvshows): From e7772c6f6fb33b50790014bef6ba30b382139fa6 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 4 Feb 2021 18:56:21 -0800 Subject: [PATCH 28/96] Fix episode's parentKey and parentRatingKey when season's are hidden --- plexapi/base.py | 1 + plexapi/video.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/plexapi/base.py b/plexapi/base.py index 4d9f397f..4dd642e0 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -47,6 +47,7 @@ class PlexObject(object): self._data = data self._initpath = initpath or self.key self._parent = weakref.ref(parent) if parent else None + self._details_key = None if data is not None: self._loadData(data) self._details_key = self._buildDetailsKey() diff --git a/plexapi/video.py b/plexapi/video.py index 508dc5a4..afc450a8 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -738,6 +738,7 @@ class Episode(Playable, Video): parentThumb (str): URL to season thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the season for the episode. rating (float): Episode rating (7.9; 9.8; 8.1). + skipParent (bool): True if the show's seasons are set to hidden. viewOffset (int): View offset in milliseconds. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. year (int): Year episode was released. @@ -774,10 +775,23 @@ class Episode(Playable, Video): self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.rating = utils.cast(float, data.attrib.get('rating')) + self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.writers = self.findItems(data, media.Writer) self.year = utils.cast(int, data.attrib.get('year')) + # If seasons are hidden, parentKey and parentRatingKey are missing from the XML response. + # https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553 + if self.skipParent and not self.parentRatingKey: + # Parse the parentRatingKey from the parentThumb + if self.parentThumb.startswith('/library/metadata/'): + self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3]) + # Get the parentRatingKey from the season's ratingKey + if not self.parentRatingKey and self.grandparentRatingKey: + self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey + if self.parentRatingKey: + self.parentKey = '/library/metadata/%s' % self.parentRatingKey + def __repr__(self): return '<%s>' % ':'.join([p for p in [ self.__class__.__name__, From 28a8436dc00f3dff07d25adc2368bfc8857ef07e Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 4 Feb 2021 19:03:29 -0800 Subject: [PATCH 29/96] Add test for episode attributes with hidden seasons --- tests/test_video.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_video.py b/tests/test_video.py index e60b2848..77a4d2e1 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -739,6 +739,21 @@ def test_video_Episode_history(episode): episode.markUnwatched() +def test_video_Episode_hidden_season(episode): + assert episode.skipParent is False + assert episode.parentRatingKey + assert episode.parentKey + assert episode.seasonNumber + show = episode.show() + show.editAdvanced(flattenSeasons=1) + episode.reload() + assert episode.skipParent is True + assert episode.parentRatingKey + assert episode.parentKey + assert episode.seasonNumber + show.defaultAdvanced() + + # Analyze seems to fail intermittently @pytest.mark.xfail def test_video_Episode_analyze(tvshows): @@ -765,6 +780,7 @@ def test_video_Episode_attrs(episode): assert episode.rating >= 7.7 assert utils.is_int(episode.ratingKey) assert episode._server._baseurl == utils.SERVER_BASEURL + assert episode.skipParent is False assert utils.is_string(episode.summary, gte=100) assert utils.is_metadata(episode.thumb, contains="/thumb/") assert episode.title == "Winter Is Coming" From 68b77b67b8bbe621a98730fdb45db5ed58a38597 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 6 Feb 2021 20:19:11 -0800 Subject: [PATCH 30/96] Separate art and poster mixin --- plexapi/audio.py | 6 ++--- plexapi/library.py | 4 +-- plexapi/mixins.py | 62 ++++++++++++++++++++++++--------------------- plexapi/playlist.py | 4 +-- plexapi/video.py | 10 ++++---- 5 files changed, 45 insertions(+), 41 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 27ccfc7b..d781efc3 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 PosterArt +from plexapi.mixins import ArtMixin, PosterMixin class Audio(PlexPartialObject): @@ -124,7 +124,7 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio, PosterArt): +class Artist(Audio, ArtMixin, PosterMixin): """ Represents a single Artist. Attributes: @@ -227,7 +227,7 @@ class Artist(Audio, PosterArt): @utils.registerPlexObject -class Album(Audio, PosterArt): +class Album(Audio, ArtMixin, PosterMixin): """ Represents a single Album. Attributes: diff --git a/plexapi/library.py b/plexapi/library.py index 765b23e1..6fa8ccba 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -4,7 +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 PosterArt +from plexapi.mixins import ArtMixin, PosterMixin from plexapi.settings import Setting from plexapi.utils import deprecated @@ -1526,7 +1526,7 @@ class FirstCharacter(PlexObject): @utils.registerPlexObject -class Collections(PlexPartialObject, PosterArt): +class Collections(PlexPartialObject, ArtMixin, PosterMixin): """ Represents a single Collection. Attributes: diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 9dad003b..37c7cb07 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -4,35 +4,8 @@ from urllib.parse import quote_plus from plexapi import media -class PosterArt(object): - """ Mixin for Plex objects that can have posters and artwork.""" - - 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 ArtMixin(object): + """ Mixin for Plex objects that can have artwork.""" def arts(self): """ Returns list of available :class:`~plexapi.media.Art` objects. """ @@ -60,3 +33,34 @@ class PosterArt(object): 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() diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 15d4488d..399d55aa 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -5,13 +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 PosterArt +from plexapi.mixins import ArtMixin, PosterMixin from plexapi.playqueue import PlayQueue from plexapi.utils import cast, toDatetime @utils.registerPlexObject -class Playlist(PlexPartialObject, Playable, PosterArt): +class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): """ Represents a single Playlist. Attributes: diff --git a/plexapi/video.py b/plexapi/video.py index 81c36d3b..352b2a1f 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ 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 PosterArt +from plexapi.mixins import ArtMixin, PosterMixin class Video(PlexPartialObject): @@ -260,7 +260,7 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Video, Playable, PosterArt): +class Movie(Video, Playable, ArtMixin, PosterMixin): """ Represents a single Movie. Attributes: @@ -384,7 +384,7 @@ class Movie(Video, Playable, PosterArt): @utils.registerPlexObject -class Show(Video, PosterArt): +class Show(Video, ArtMixin, PosterMixin): """ Represents a single Show (including all seasons and episodes). Attributes: @@ -582,7 +582,7 @@ class Show(Video, PosterArt): @utils.registerPlexObject -class Season(Video, PosterArt): +class Season(Video, ArtMixin, PosterMixin): """ Represents a single Show Season (including all episodes). Attributes: @@ -708,7 +708,7 @@ class Season(Video, PosterArt): @utils.registerPlexObject -class Episode(Video, Playable, PosterArt): +class Episode(Video, Playable, ArtMixin, PosterMixin): """ Represents a single Shows Episode. Attributes: From 9c1ac7981df96d52c7ddef74f545d6c3d971f33b Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 6 Feb 2021 20:21:49 -0800 Subject: [PATCH 31/96] Rename mixins --- plexapi/audio.py | 8 ++++---- plexapi/library.py | 4 ++-- plexapi/mixins.py | 20 ++++++++++---------- plexapi/photo.py | 4 ++-- plexapi/video.py | 8 ++++---- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 2866426c..1a5b3c21 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 EditCollection, EditCountry, EditGenre, EditLabel, EditMood, EditSimilarArtist, EditStyle +from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin class Audio(PlexPartialObject): @@ -124,7 +124,7 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio, EditCollection, EditCountry, EditGenre, EditMood, EditSimilarArtist, EditStyle): +class Artist(Audio, CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin): """ Represents a single Artist. Attributes: @@ -227,7 +227,7 @@ class Artist(Audio, EditCollection, EditCountry, EditGenre, EditMood, EditSimila @utils.registerPlexObject -class Album(Audio, EditCollection, EditGenre, EditLabel, EditMood, EditStyle): +class Album(Audio, CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin): """ Represents a single Album. Attributes: @@ -333,7 +333,7 @@ class Album(Audio, EditCollection, EditGenre, EditLabel, EditMood, EditStyle): @utils.registerPlexObject -class Track(Audio, Playable, EditMood): +class Track(Audio, Playable, MoodMixin): """ Represents a single Track. Attributes: diff --git a/plexapi/library.py b/plexapi/library.py index 36ea426b..d972db61 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -4,7 +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 EditLabel +from plexapi.mixins import LabelMixin from plexapi.settings import Setting from plexapi.utils import deprecated @@ -1526,7 +1526,7 @@ class FirstCharacter(PlexObject): @utils.registerPlexObject -class Collections(PlexPartialObject, EditLabel): +class Collections(PlexPartialObject, LabelMixin): """ Represents a single Collection. Attributes: diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 1e887208..98eef805 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -class EditCollection(object): +class CollectionMixin(object): """ Mixin for Plex objects that can have collections. """ def addCollection(self, collections): @@ -21,7 +21,7 @@ class EditCollection(object): self._edit_tags('collection', collections, remove=True) -class EditCountry(object): +class CountryMixin(object): """ Mixin for Plex objects that can have countries. """ def addCountry(self, countries): @@ -41,7 +41,7 @@ class EditCountry(object): self._edit_tags('country', countries, remove=True) -class EditDirector(object): +class DirectorMixin(object): """ Mixin for Plex objects that can have directors. """ def addDirector(self, directors): @@ -61,7 +61,7 @@ class EditDirector(object): self._edit_tags('director', directors, remove=True) -class EditGenre(object): +class GenreMixin(object): """ Mixin for Plex objects that can have genres. """ def addGenre(self, genres): @@ -81,7 +81,7 @@ class EditGenre(object): self._edit_tags('genre', genres, remove=True) -class EditLabel(object): +class LabelMixin(object): """ Mixin for Plex objects that can have labels. """ def addLabel(self, labels): @@ -101,7 +101,7 @@ class EditLabel(object): self._edit_tags('label', labels, remove=True) -class EditMood(object): +class MoodMixin(object): """ Mixin for Plex objects that can have moods. """ def addMood(self, moods): @@ -121,7 +121,7 @@ class EditMood(object): self._edit_tags('mood', moods, remove=True) -class EditProducer(object): +class ProducerMixin(object): """ Mixin for Plex objects that can have producers. """ def addProducer(self, producers): @@ -141,7 +141,7 @@ class EditProducer(object): self._edit_tags('producer', producers, remove=True) -class EditSimilarArtist(object): +class SimilarArtistMixin(object): """ Mixin for Plex objects that can have similar artists. """ def addSimilarArtist(self, artists): @@ -161,7 +161,7 @@ class EditSimilarArtist(object): self._edit_tags('similar', artists, remove=True) -class EditStyle(object): +class StyleMixin(object): """ Mixin for Plex objects that can have styles. """ def addStyle(self, styles): @@ -181,7 +181,7 @@ class EditStyle(object): self._edit_tags('style', styles, remove=True) -class EditTag(object): +class TagMixin(object): """ Mixin for Plex objects that can have tags. """ def addTag(self, tags): diff --git a/plexapi/photo.py b/plexapi/photo.py index ab88841b..40f904e1 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -4,7 +4,7 @@ from urllib.parse import quote_plus from plexapi import media, utils, video from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest -from plexapi.mixins import EditTag +from plexapi.mixins import TagMixin @utils.registerPlexObject @@ -137,7 +137,7 @@ class Photoalbum(PlexPartialObject): @utils.registerPlexObject -class Photo(PlexPartialObject, Playable, EditTag): +class Photo(PlexPartialObject, Playable, TagMixin): """ Represents a single Photo. Attributes: diff --git a/plexapi/video.py b/plexapi/video.py index 6972f6fa..5064de21 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ 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 EditCollection, EditCountry, EditDirector, EditGenre, EditLabel, EditProducer, EditWriter +from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, EditWriter class Video(PlexPartialObject): @@ -260,7 +260,7 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Playable, Video, EditCollection, EditCountry, EditDirector, EditGenre, EditLabel, EditProducer, EditWriter): +class Movie(Playable, Video, CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, EditWriter): """ Represents a single Movie. Attributes: @@ -384,7 +384,7 @@ class Movie(Playable, Video, EditCollection, EditCountry, EditDirector, EditGenr @utils.registerPlexObject -class Show(Video, EditCollection, EditGenre, EditLabel): +class Show(Video, CollectionMixin, GenreMixin, LabelMixin): """ Represents a single Show (including all seasons and episodes). Attributes: @@ -708,7 +708,7 @@ class Season(Video): @utils.registerPlexObject -class Episode(Playable, Video, EditDirector, EditWriter): +class Episode(Playable, Video, DirectorMixin, EditWriter): """ Represents a single Shows Episode. Attributes: From 21f29f4373b9e50bf7f92a9f5b5df3a1fecc42a3 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 6 Feb 2021 20:23:05 -0800 Subject: [PATCH 32/96] Rename mixins --- plexapi/audio.py | 6 +++--- plexapi/mixins.py | 4 ++-- plexapi/video.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 8c8b3a9d..ba697d47 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 SplitMerge, UnmatchMatch +from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin class Audio(PlexPartialObject): @@ -124,7 +124,7 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio, SplitMerge, UnmatchMatch): +class Artist(Audio, SplitMergeMixin, UnmatchMatchMixin): """ Represents a single Artist. Attributes: @@ -227,7 +227,7 @@ class Artist(Audio, SplitMerge, UnmatchMatch): @utils.registerPlexObject -class Album(Audio, UnmatchMatch): +class Album(Audio, UnmatchMatchMixin): """ Represents a single Album. Attributes: diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 20d4d611..b36cdec6 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -5,7 +5,7 @@ from plexapi import utils from plexapi.exceptions import NotFound -class SplitMerge(object): +class SplitMergeMixin(object): """ Mixin for Plex objects that can be split and merged.""" def split(self): @@ -26,7 +26,7 @@ class SplitMerge(object): return self._server.query(key, method=self._server._session.put) -class UnmatchMatch(object): +class UnmatchMatchMixin(object): """ Mixin for Plex objects that can be unmatched and matched.""" def unmatch(self): diff --git a/plexapi/video.py b/plexapi/video.py index fb4c0cd9..023ccbd6 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ 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 SplitMerge, UnmatchMatch +from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin class Video(PlexPartialObject): @@ -260,7 +260,7 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Playable, Video, SplitMerge, UnmatchMatch): +class Movie(Playable, Video, SplitMergeMixin, UnmatchMatchMixin): """ Represents a single Movie. Attributes: @@ -384,7 +384,7 @@ class Movie(Playable, Video, SplitMerge, UnmatchMatch): @utils.registerPlexObject -class Show(Video, SplitMerge, UnmatchMatch): +class Show(Video, SplitMergeMixin, UnmatchMatchMixin): """ Represents a single Show (including all seasons and episodes). Attributes: From 04a07b3e7d3396be704c6fa0a019d3b655afa0a1 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 6 Feb 2021 20:31:07 -0800 Subject: [PATCH 33/96] Check obj is not None for _isChildOf --- plexapi/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 2e8659ae..b14bd9e0 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -119,9 +119,9 @@ class PlexObject(object): See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`. """ obj = self - while obj._parent is not None and obj._parent() is not None: + while obj and obj._parent is not None: obj = obj._parent() - if obj._checkAttrs(obj._data, **kwargs): + if obj and obj._checkAttrs(obj._data, **kwargs): return True return False From 6842a8d1daf8d0fe8a86a18ab52d882dfecd4fd0 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 6 Feb 2021 20:35:47 -0800 Subject: [PATCH 34/96] Fix mixin tag test --- tests/test_mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 2440cacc..575cbdc0 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -11,7 +11,7 @@ def _test_mixins_tag(obj, attr, tag_method): assert TEST_MIXIN_TAG in [tag.tag for tag in getattr(obj, attr)] remove_tag_method(TEST_MIXIN_TAG) obj.reload() - assert getattr(obj, attr) not in [tag.tag for tag in getattr(obj, attr)] + assert TEST_MIXIN_TAG not in [tag.tag for tag in getattr(obj, attr)] def edit_collection(obj): From a073f930dcbf94405cfc3e9d32e5570e698346be Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 6 Feb 2021 21:33:27 -0800 Subject: [PATCH 35/96] Fix capitalization on doc string --- plexapi/media.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plexapi/media.py b/plexapi/media.py index 00007896..169f6433 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -708,10 +708,10 @@ class Collection(MediaTag): @utils.registerPlexObject class Label(MediaTag): - """ Represents a single label media tag. + """ Represents a single Label media tag. Attributes: - TAG (str): 'label' + TAG (str): 'Label' FILTER (str): 'label' """ TAG = 'Label' @@ -720,10 +720,10 @@ class Label(MediaTag): @utils.registerPlexObject class Tag(MediaTag): - """ Represents a single tag media tag. + """ Represents a single Tag media tag. Attributes: - TAG (str): 'tag' + TAG (str): 'Tag' FILTER (str): 'tag' """ TAG = 'Tag' From ab13523b175c7b7da23ce5582efbf2c04b937d0b Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 6 Feb 2021 21:36:21 -0800 Subject: [PATCH 36/96] Don't refresh after editing a tag --- plexapi/base.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 0ff0417b..5a63d8f9 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -463,10 +463,9 @@ class PlexPartialObject(PlexObject): if not isinstance(items, list): items = [items] value = getattr(self, tag_plural(tag)) - existing_cols = [t.tag for t in value if t and remove is False] - d = tag_helper(tag, existing_cols + items, locked, remove) - self.edit(**d) - self.refresh() + existing_tags = [t.tag for t in value if t and remove is False] + tag_edits = tag_helper(tag, existing_tags + items, locked, remove) + self.edit(**tag_edits) def refresh(self): """ Refreshing a Library or individual item causes the metadata for the item to be From 39192544272870d5135a0cc1a814c8ef9dad3c28 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 7 Feb 2021 10:17:29 -0800 Subject: [PATCH 37/96] Workaround #660 for reloading object in mixins tag test --- tests/test_mixins.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 575cbdc0..c427cac2 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -10,7 +10,8 @@ def _test_mixins_tag(obj, attr, tag_method): obj.reload() assert TEST_MIXIN_TAG in [tag.tag for tag in getattr(obj, attr)] remove_tag_method(TEST_MIXIN_TAG) - obj.reload() + # obj.reload() + obj = obj._server.fetchItem(obj.ratingKey) # Workaround for issue #660 assert TEST_MIXIN_TAG not in [tag.tag for tag in getattr(obj, attr)] From d87547e2f9d6a580eb7d9ddd9ffc3adaf183d746 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 7 Feb 2021 10:54:03 -0800 Subject: [PATCH 38/96] Rename writer mixin --- plexapi/mixins.py | 2 +- plexapi/video.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 19be1f98..7c65e632 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -381,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/video.py b/plexapi/video.py index 5a89855b..3f1fd9bb 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -6,7 +6,7 @@ from plexapi import library, media, settings, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound from plexapi.mixins import ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin -from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, EditWriter +from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin class Video(PlexPartialObject): @@ -262,7 +262,7 @@ class Video(PlexPartialObject): @utils.registerPlexObject class Movie(Video, Playable, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, - CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, EditWriter): + CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin): """ Represents a single Movie. Attributes: @@ -714,7 +714,7 @@ class Season(Video, ArtMixin, PosterMixin): @utils.registerPlexObject class Episode(Video, Playable, ArtMixin, PosterMixin, - DirectorMixin, EditWriter): + DirectorMixin, WriterMixin): """ Represents a single Shows Episode. Attributes: From a221b226cd2e0e385b15b84306ae5869415c86da Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 7 Feb 2021 14:50:28 -0800 Subject: [PATCH 39/96] Explicitly specify the Playable attrs that should not be overwritten --- plexapi/base.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index a736a4ce..2d40a4b3 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -58,8 +58,8 @@ class PlexObject(object): return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p]) def __setattr__(self, attr, value): - # Don't overwrite an attr with None or [] unless it's a private variable - if value not in [None, []] or attr.startswith('_') or attr not in self.__dict__: + # Don't overwrite an attr with None unless it's a private variable + if value is not None or attr.startswith('_') or attr not in self.__dict__: self.__dict__[attr] = value def _clean(self, value): @@ -573,6 +573,13 @@ class Playable(object): playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items). """ + def __setattr__(self, attr, value): + # Don't overwrite session specific attr with [] + session_attrs = ('usernames', 'players', 'transcodeSessions', 'session') + if attr in session_attrs and value == []: + value = getattr(self, attr, []) + PlexObject.__setattr__(self, attr, value) + def _loadData(self, data): self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) # session self.usernames = self.listAttrs(data, 'title', etag='User') # session From 46e902e125b851889ae77c90b0287a272d9575ff Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 7 Feb 2021 15:31:45 -0800 Subject: [PATCH 40/96] Don't reload for Playable session keys --- plexapi/base.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 2d40a4b3..8a8dc9e4 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -7,7 +7,8 @@ from plexapi import log, utils from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported from plexapi.utils import tag_plural, tag_helper -DONT_RELOAD_FOR_KEYS = ['key', 'session'] +DONT_RELOAD_FOR_KEYS = {'key', 'session'} +DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'} OPERATORS = { 'exact': lambda v, q: v == q, 'iexact': lambda v, q: v.lower() == q.lower(), @@ -58,6 +59,9 @@ class PlexObject(object): return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p]) def __setattr__(self, attr, value): + # Don't overwrite session specific attr with [] + if attr in DONT_OVERWRITE_SESSION_KEYS and value == []: + value = getattr(self, attr, []) # Don't overwrite an attr with None unless it's a private variable if value is not None or attr.startswith('_') or attr not in self.__dict__: self.__dict__[attr] = value @@ -385,6 +389,7 @@ class PlexPartialObject(PlexObject): value = super(PlexPartialObject, self).__getattribute__(attr) # Check a few cases where we dont want to reload if attr in DONT_RELOAD_FOR_KEYS: return value + if attr in DONT_OVERWRITE_SESSION_KEYS: return value if attr.startswith('_'): return value if value not in (None, []): return value if self.isFullObject(): return value @@ -573,13 +578,6 @@ class Playable(object): playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items). """ - def __setattr__(self, attr, value): - # Don't overwrite session specific attr with [] - session_attrs = ('usernames', 'players', 'transcodeSessions', 'session') - if attr in session_attrs and value == []: - value = getattr(self, attr, []) - PlexObject.__setattr__(self, attr, value) - def _loadData(self, data): self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) # session self.usernames = self.listAttrs(data, 'title', etag='User') # session From 57147bd3864a0308053948e2a91bb0c928e27266 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 7 Feb 2021 10:17:29 -0800 Subject: [PATCH 41/96] Revert "Workaround #660 for reloading object in mixins tag test" This reverts commit 39192544272870d5135a0cc1a814c8ef9dad3c28. --- tests/test_mixins.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index c427cac2..575cbdc0 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -10,8 +10,7 @@ def _test_mixins_tag(obj, attr, tag_method): obj.reload() assert TEST_MIXIN_TAG in [tag.tag for tag in getattr(obj, attr)] remove_tag_method(TEST_MIXIN_TAG) - # obj.reload() - obj = obj._server.fetchItem(obj.ratingKey) # Workaround for issue #660 + obj.reload() assert TEST_MIXIN_TAG not in [tag.tag for tag in getattr(obj, attr)] From cc62f5093c30b26b0faeadd4e8acb3fca5f6f529 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 10 Feb 2021 21:05:21 -0800 Subject: [PATCH 42/96] Add originalTitle attribute to show --- plexapi/video.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plexapi/video.py b/plexapi/video.py index c2957b1e..6524f339 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -407,6 +407,7 @@ class Show(Video, SplitMergeMixin, UnmatchMatchMixin, leafCount (int): Number of items in the show view. locations (List): List of folder paths where the show is found on disk. originallyAvailableAt (datetime): Datetime the show was released. + originalTitle (str): The original title of the show. rating (float): Show rating (7.9; 9.8; 8.1). roles (List<:class:`~plexapi.media.Role`>): List of role objects. similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. @@ -434,6 +435,7 @@ class Show(Video, SplitMergeMixin, UnmatchMatchMixin, self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.locations = self.listAttrs(data, 'path', etag='Location') self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.originalTitle = data.attrib.get('originalTitle') self.rating = utils.cast(float, data.attrib.get('rating')) self.roles = self.findItems(data, media.Role) self.similar = self.findItems(data, media.Similar) From 10af1ce65dccbf9590ed300b51e37a171b096d0d Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 10 Feb 2021 21:03:10 -0800 Subject: [PATCH 43/96] Update test for show originalTitle attribute --- tests/test_video.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_video.py b/tests/test_video.py index 87c3014c..cc637b22 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -566,6 +566,7 @@ def test_video_Show_attrs(show): assert len(show.locations) == 1 assert len(show.locations[0]) >= 10 assert utils.is_datetime(show.originallyAvailableAt) + assert show.originalTitle == "" assert show.rating >= 8.0 assert utils.is_int(show.ratingKey) assert sorted([i.tag for i in show.roles])[:4] == [ From e468cba61fdd71b2b5068e3f0154610f4b8a4b50 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 11 Feb 2021 09:16:04 -0800 Subject: [PATCH 44/96] Replace quotes for consistency --- tests/test_mixins.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 575cbdc0..a32e8c3e 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -3,8 +3,8 @@ TEST_MIXIN_TAG = "Test Tag" def _test_mixins_tag(obj, attr, tag_method): - add_tag_method = getattr(obj, 'add' + tag_method) - remove_tag_method = getattr(obj, 'remove' + tag_method) + add_tag_method = getattr(obj, "add" + tag_method) + remove_tag_method = getattr(obj, "remove" + tag_method) assert TEST_MIXIN_TAG not in [tag.tag for tag in getattr(obj, attr)] add_tag_method(TEST_MIXIN_TAG) obj.reload() @@ -15,44 +15,44 @@ def _test_mixins_tag(obj, attr, tag_method): def edit_collection(obj): - _test_mixins_tag(obj, 'collections', 'Collection') + _test_mixins_tag(obj, "collections", "Collection") def edit_country(obj): - _test_mixins_tag(obj, 'countries', 'Country') + _test_mixins_tag(obj, "countries", "Country") def edit_director(obj): - _test_mixins_tag(obj, 'directors', 'Director') + _test_mixins_tag(obj, "directors", "Director") def edit_genre(obj): - _test_mixins_tag(obj, 'genres', 'Genre') + _test_mixins_tag(obj, "genres", "Genre") def edit_label(obj): - _test_mixins_tag(obj, 'labels', 'Label') + _test_mixins_tag(obj, "labels", "Label") def edit_mood(obj): - _test_mixins_tag(obj, 'moods', 'Mood') + _test_mixins_tag(obj, "moods", "Mood") def edit_producer(obj): - _test_mixins_tag(obj, 'producers', 'Producer') + _test_mixins_tag(obj, "producers", "Producer") def edit_similar_artist(obj): - _test_mixins_tag(obj, 'similar', 'SimilarArtist') + _test_mixins_tag(obj, "similar", "SimilarArtist") def edit_style(obj): - _test_mixins_tag(obj, 'styles', 'Style') + _test_mixins_tag(obj, "styles", "Style") def edit_tag(obj): - _test_mixins_tag(obj, 'tags', 'Tag') + _test_mixins_tag(obj, "tags", "Tag") def edit_writer(obj): - _test_mixins_tag(obj, 'writers', 'Writer') + _test_mixins_tag(obj, "writers", "Writer") From 28b1f9e9a623075b9fe7474b6090ea55c3048c72 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 11 Feb 2021 09:28:15 -0800 Subject: [PATCH 45/96] Add ability to lock/unlock fields when adding/removing tags --- plexapi/base.py | 10 ++--- plexapi/mixins.py | 110 +++++++++++++++++++++++++++------------------- 2 files changed, 71 insertions(+), 49 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 5a63d8f9..d52359f3 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -452,13 +452,13 @@ class PlexPartialObject(PlexObject): self._server.query(part, method=self._server._session.put) def _edit_tags(self, tag, items, locked=True, remove=False): - """ Helper to edit and refresh a tags. + """ Helper to edit tags. Parameters: - tag (str): tag name - items (list): list of tags to add - locked (bool): lock this field. - remove (bool): If this is active remove the tags in items. + tag (str): Tag name. + items (list): List of tags to add. + locked (bool): True to lock the field. + remove (bool): True to remove the tags in items. """ if not isinstance(items, list): items = [items] diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 98eef805..fa1bf3fd 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -4,218 +4,240 @@ class CollectionMixin(object): """ Mixin for Plex objects that can have collections. """ - def addCollection(self, collections): + def addCollection(self, collections, locked=True): """ Add a collection tag(s). Parameters: collections (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('collection', collections) + self._edit_tags('collection', collections, locked=locked) - def removeCollection(self, collections): + def removeCollection(self, collections, locked=True): """ Remove a collection tag(s). Parameters: collections (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('collection', collections, remove=True) + self._edit_tags('collection', collections, locked=locked, remove=True) class CountryMixin(object): """ Mixin for Plex objects that can have countries. """ - def addCountry(self, countries): + def addCountry(self, countries, locked=True): """ Add a country tag(s). Parameters: countries (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('country', countries) + self._edit_tags('country', countries, locked=locked) - def removeCountry(self, countries): + def removeCountry(self, countries, locked=True): """ Remove a country tag(s). Parameters: countries (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('country', countries, remove=True) + self._edit_tags('country', countries, locked=locked, remove=True) class DirectorMixin(object): """ Mixin for Plex objects that can have directors. """ - def addDirector(self, directors): + def addDirector(self, directors, locked=True): """ Add a director tag(s). Parameters: directors (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('director', directors) + self._edit_tags('director', directors, locked=locked) - def removeDirector(self, directors): + def removeDirector(self, directors, locked=True): """ Remove a director tag(s). Parameters: directors (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('director', directors, remove=True) + self._edit_tags('director', directors, locked=locked, remove=True) class GenreMixin(object): """ Mixin for Plex objects that can have genres. """ - def addGenre(self, genres): + def addGenre(self, genres, locked=True): """ Add a genre tag(s). Parameters: genres (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('genre', genres) + self._edit_tags('genre', genres, locked=locked) - def removeGenre(self, genres): + def removeGenre(self, genres, locked=True): """ Remove a genre tag(s). Parameters: genres (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('genre', genres, remove=True) + self._edit_tags('genre', genres, locked=locked, remove=True) class LabelMixin(object): """ Mixin for Plex objects that can have labels. """ - def addLabel(self, labels): + def addLabel(self, labels, locked=True): """ Add a label tag(s). Parameters: labels (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('label', labels) + self._edit_tags('label', labels, locked=locked) - def removeLabel(self, labels): + def removeLabel(self, labels, locked=True): """ Remove a label tag(s). Parameters: labels (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('label', labels, remove=True) + self._edit_tags('label', labels, locked=locked, remove=True) class MoodMixin(object): """ Mixin for Plex objects that can have moods. """ - def addMood(self, moods): + def addMood(self, moods, locked=True): """ Add a mood tag(s). Parameters: moods (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('mood', moods) + self._edit_tags('mood', moods, locked=locked) - def removeMood(self, moods): + def removeMood(self, moods, locked=True): """ Remove a mood tag(s). Parameters: moods (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('mood', moods, remove=True) + self._edit_tags('mood', moods, locked=locked, remove=True) class ProducerMixin(object): """ Mixin for Plex objects that can have producers. """ - def addProducer(self, producers): + def addProducer(self, producers, locked=True): """ Add a producer tag(s). Parameters: producers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('producer', producers) + self._edit_tags('producer', producers, locked=locked) - def removeProducer(self, producers): + def removeProducer(self, producers, locked=True): """ Remove a producer tag(s). Parameters: producers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('producer', producers, remove=True) + self._edit_tags('producer', producers, locked=locked, remove=True) class SimilarArtistMixin(object): """ Mixin for Plex objects that can have similar artists. """ - def addSimilarArtist(self, artists): + def addSimilarArtist(self, artists, locked=True): """ Add a similar artist tag(s). Parameters: artists (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('similar', artists) + self._edit_tags('similar', artists, locked=locked) - def removeSimilarArtist(self, artists): + def removeSimilarArtist(self, artists, locked=True): """ Remove a similar artist tag(s). Parameters: artists (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('similar', artists, remove=True) + self._edit_tags('similar', artists, locked=locked, remove=True) class StyleMixin(object): """ Mixin for Plex objects that can have styles. """ - def addStyle(self, styles): + def addStyle(self, styles, locked=True): """ Add a style tag(s). Parameters: styles (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('style', styles) + self._edit_tags('style', styles, locked=locked) - def removeStyle(self, styles): + def removeStyle(self, styles, locked=True): """ Remove a style tag(s). Parameters: styles (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('style', styles, remove=True) + self._edit_tags('style', styles, locked=locked, remove=True) class TagMixin(object): """ Mixin for Plex objects that can have tags. """ - def addTag(self, tags): + def addTag(self, tags, locked=True): """ Add a tag(s). Parameters: tags (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('tag', tags) + self._edit_tags('tag', tags, locked=locked) - def removeTag(self, tags): + def removeTag(self, tags, locked=True): """ Remove a tag(s). Parameters: tags (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('tag', tags, remove=True) + self._edit_tags('tag', tags, locked=locked, remove=True) class EditWriter(object): """ Mixin for Plex objects that can have writers. """ - def addWriter(self, writers): + def addWriter(self, writers, locked=True): """ Add a writer tag(s). Parameters: writers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('writer', writers) + self._edit_tags('writer', writers, locked=locked) - def removeWriter(self, writers): + def removeWriter(self, writers, locked=True): """ Remove a writer tag(s). Parameters: writers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('writer', writers, remove=True) + self._edit_tags('writer', writers, locked=locked, remove=True) From c585ec6bbfd652c1f916679f78521c08bf368bba Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 14:28:54 -0800 Subject: [PATCH 46/96] Fix show originalTitle in test --- tests/test_video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_video.py b/tests/test_video.py index cc637b22..0b7264c3 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -566,7 +566,7 @@ def test_video_Show_attrs(show): assert len(show.locations) == 1 assert len(show.locations[0]) >= 10 assert utils.is_datetime(show.originallyAvailableAt) - assert show.originalTitle == "" + assert show.originalTitle is None assert show.rating >= 8.0 assert utils.is_int(show.ratingKey) assert sorted([i.tag for i in show.roles])[:4] == [ From 7fedd01371ae42d82561520921dbef6467cb8474 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 14:52:33 -0800 Subject: [PATCH 47/96] Test locking/unlocking fields when adding/removing tags --- plexapi/utils.py | 9 +++++++++ tests/test_mixins.py | 12 +++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/plexapi/utils.py b/plexapi/utils.py index c6021e18..a56d87ab 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -335,6 +335,15 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 return fullpath +def tag_singular(tag): + if tag == 'countries': + return 'country' + elif tag == 'similar': + return 'similar' + else: + return tag[:-1] + + def tag_plural(tag): if tag == 'country': return 'countries' diff --git a/tests/test_mixins.py b/tests/test_mixins.py index a32e8c3e..dc21cf76 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -1,17 +1,27 @@ # -*- coding: utf-8 -*- +from plexapi.utils import tag_singular + TEST_MIXIN_TAG = "Test Tag" def _test_mixins_tag(obj, attr, tag_method): add_tag_method = getattr(obj, "add" + tag_method) remove_tag_method = getattr(obj, "remove" + tag_method) + field_name = tag_singular(attr) + # Check tag is not present to begin with assert TEST_MIXIN_TAG not in [tag.tag for tag in getattr(obj, attr)] + # Add tag and lock the field add_tag_method(TEST_MIXIN_TAG) obj.reload() + field = [f for f in obj.fields if f.name == field_name] assert TEST_MIXIN_TAG in [tag.tag for tag in getattr(obj, attr)] - remove_tag_method(TEST_MIXIN_TAG) + assert field and field[0].locked + # Remove tag and unlock to field to restore the clean state + remove_tag_method(TEST_MIXIN_TAG, locked=False) obj.reload() + field = [f for f in obj.fields if f.name == field_name] assert TEST_MIXIN_TAG not in [tag.tag for tag in getattr(obj, attr)] + assert not field def edit_collection(obj): From c675858d778df1b1b237b91ecc7a549f2cd06a28 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 14:59:28 -0800 Subject: [PATCH 48/96] Clean mixin tag test --- tests/test_mixins.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index dc21cf76..c0ff9e44 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -8,20 +8,25 @@ def _test_mixins_tag(obj, attr, tag_method): add_tag_method = getattr(obj, "add" + tag_method) remove_tag_method = getattr(obj, "remove" + tag_method) field_name = tag_singular(attr) + _tags = lambda: [t.tag for t in getattr(obj, attr)] + _fields = lambda: [f for f in obj.fields if f.name == field_name] # Check tag is not present to begin with - assert TEST_MIXIN_TAG not in [tag.tag for tag in getattr(obj, attr)] + tags = _tags() + assert TEST_MIXIN_TAG not in tags # Add tag and lock the field add_tag_method(TEST_MIXIN_TAG) obj.reload() - field = [f for f in obj.fields if f.name == field_name] - assert TEST_MIXIN_TAG in [tag.tag for tag in getattr(obj, attr)] - assert field and field[0].locked + tags = _tags() + fields = _fields() + assert TEST_MIXIN_TAG in tags + assert fields and fields[0].locked # Remove tag and unlock to field to restore the clean state remove_tag_method(TEST_MIXIN_TAG, locked=False) obj.reload() - field = [f for f in obj.fields if f.name == field_name] - assert TEST_MIXIN_TAG not in [tag.tag for tag in getattr(obj, attr)] - assert not field + tags = _tags() + fields = _fields() + assert TEST_MIXIN_TAG not in tags + assert not fields def edit_collection(obj): From b960e2f7a5ec5ac1096ff139db02f31b718c911f Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 15:08:49 -0800 Subject: [PATCH 49/96] Remove unmatch from base PlexObject --- plexapi/base.py | 5 ----- tests/test_video.py | 4 ---- 2 files changed, 9 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 92c00bf9..e0022e2e 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -580,11 +580,6 @@ class Playable(object): for part in item.parts: yield part - def unmatch(self): - """Unmatch a media file.""" - key = '%s/unmatch' % self.key - return self._server.query(key, method=self._server._session.put) - def play(self, client): """ Start playback on the specified client. diff --git a/tests/test_video.py b/tests/test_video.py index 5e14cae8..e3f42175 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -567,10 +567,6 @@ def test_video_Show(show): assert show.title == "Game of Thrones" -def test_video_Episode_unmatch(episode, patched_http_call): - episode.unmatch() - - def test_video_Episode_updateProgress(episode, patched_http_call): episode.updateProgress(10 * 60 * 1000) # 10 minutes. From 427d90bd3bdba7cf1638a93686c75df7d0bfa70b Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 15:59:57 -0800 Subject: [PATCH 50/96] Add mixins tests for all objects with art and posters --- tests/test_audio.py | 10 +++++++ tests/test_library.py | 5 ++++ tests/test_mixins.py | 42 ++++++++++++++++++++++++++++ tests/test_video.py | 65 ++++++++++++++----------------------------- 4 files changed, 78 insertions(+), 44 deletions(-) diff --git a/tests/test_audio.py b/tests/test_audio.py index 13ab8066..22f98a4e 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -64,6 +64,11 @@ def test_audio_Artist_albums(artist): assert len(albums) == 1 and albums[0].title == "Layers" +def test_audio_Artist_mixins_images(artist): + test_mixins.edit_art(artist) + test_mixins.edit_poster(artist) + + def test_audio_Artist_mixins_tags(artist): test_mixins.edit_collection(artist) test_mixins.edit_country(artist) @@ -221,6 +226,11 @@ def test_audio_Album_artist(album): artist.title == "Broke For Free" +def test_audio_Album_mixins_images(album): + test_mixins.edit_art(album) + test_mixins.edit_poster(album) + + def test_audio_Album_mixins_tags(album): test_mixins.edit_collection(album) test_mixins.edit_genre(album) diff --git a/tests/test_library.py b/tests/test_library.py index b8602def..83dc1469 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -334,6 +334,11 @@ def test_library_Collection_art(collection): assert not arts # Collection has no default art +def test_library_Collection_mixins_images(collection): + test_mixins.edit_art(collection) + test_mixins.edit_poster(collection) + + def test_library_Collection_mixins_tags(collection): test_mixins.edit_label(collection) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index c0ff9e44..a69eef4a 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- from plexapi.utils import tag_singular +from . import conftest as utils + TEST_MIXIN_TAG = "Test Tag" +CUTE_CAT_SHA1 = "9f7003fc401761d8e0b0364d428b2dab2f789dbb" def _test_mixins_tag(obj, attr, tag_method): @@ -71,3 +74,42 @@ def edit_tag(obj): def edit_writer(obj): _test_mixins_tag(obj, "writers", "Writer") + + +def _test_mixins_art_poster(obj, attr): + cap_attr = attr[:-1].capitalize() + get_img_method = getattr(obj, attr) + set_img_method = getattr(obj, "set" + cap_attr) + upload_img_method = getattr(obj, "upload" + cap_attr) + images = get_img_method() + if images: + image = images[0] + assert len(image.key) >= 10 + if not image.ratingKey.startswith(("default://", "media://", "upload://")): + assert image.provider + assert len(image.ratingKey) >= 10 + assert utils.is_bool(image.selected) + assert len(image.thumb) >= 10 + if len(images) >= 2: + # Select a different image + set_img_method(images[1]) + images = get_img_method() + assert images[0].selected is False + assert images[1].selected is True + # Test upload image from file + upload_img_method(filepath=utils.STUB_IMAGE_PATH) + images = get_img_method() + file_image = [ + i for i in images + if i.ratingKey.startswith('upload://') and i.ratingKey.endswith(CUTE_CAT_SHA1) + ] + assert file_image + set_img_method(images[0]) # Reset to default image + + +def edit_art(obj): + _test_mixins_art_poster(obj, 'arts') + + +def edit_poster(obj): + _test_mixins_art_poster(obj, 'posters') diff --git a/tests/test_video.py b/tests/test_video.py index e3f42175..1f4e857b 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -40,6 +40,11 @@ def test_video_Movie_merge(movie, patched_http_call): movie.merge(1337) +def test_video_Movie_mixins_images(movie): + test_mixins.edit_art(movie) + test_mixins.edit_poster(movie) + + def test_video_Movie_mixins_tags(movie): test_mixins.edit_collection(movie) test_mixins.edit_country(movie) @@ -495,50 +500,6 @@ 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() @@ -759,6 +720,11 @@ def test_video_Show_section(show): assert section.title == "TV Shows" +def test_video_Show_mixins_images(show): + test_mixins.edit_art(show) + test_mixins.edit_poster(show) + + def test_video_Show_mixins_tags(show): test_mixins.edit_collection(show) test_mixins.edit_genre(show) @@ -881,6 +847,11 @@ def test_video_Episode_attrs(episode): assert part.accessible +def test_video_Episode_mixins_images(episode): + #test_mixins.edit_art(episode) # Uploading episode artwork is broken in Plex + test_mixins.edit_poster(episode) + + def test_video_Episode_mixins_tags(episode): test_mixins.edit_director(episode) test_mixins.edit_writer(episode) @@ -965,6 +936,12 @@ def test_video_Season_episodes(show): assert len(episodes) >= 1 +def test_video_Season_mixins_images(show): + season = show.season(season=1) + test_mixins.edit_art(season) + test_mixins.edit_poster(season) + + def test_that_reload_return_the_same_object(plex): # we want to check this that all the urls are correct movie_library_search = plex.library.section("Movies").search("Elephants Dream")[0] From 1cc60b435874ac3a4e6e320e96b7471928762874 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 16:11:50 -0800 Subject: [PATCH 51/96] Add banner mixin for shows --- plexapi/media.py | 18 +++++++++++------- plexapi/mixins.py | 39 +++++++++++++++++++++++++++++++++++---- plexapi/video.py | 4 ++-- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/plexapi/media.py b/plexapi/media.py index 9c247d68..735bbe1b 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -807,8 +807,8 @@ class Style(MediaTag): FILTER = 'style' -class BasePosterArt(PlexObject): - """ Base class for all Poster and Art objects. +class BaseImage(PlexObject): + """ Base class for all Art, Banner, and Poster objects. Attributes: TAG (str): 'Photo' @@ -837,14 +837,18 @@ class BasePosterArt(PlexObject): pass -class Poster(BasePosterArt): - """ Represents a single Poster object. """ - - -class Art(BasePosterArt): +class Art(BaseImage): """ Represents a single Art object. """ +class Banner(BaseImage): + """ Represents a single Banner object. """ + + +class Poster(BaseImage): + """ Represents a single Poster object. """ + + @utils.registerPlexObject class Producer(MediaTag): """ Represents a single Producer media tag. diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 6faabeff..88e8aa46 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -6,14 +6,14 @@ from plexapi.exceptions import NotFound class ArtMixin(object): - """ Mixin for Plex objects that can have artwork.""" + """ Mixin for Plex objects that can have background 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. + """ Upload a background artwork from a url or filepath. Parameters: url (str): The full URL to the image to upload. @@ -28,7 +28,7 @@ class ArtMixin(object): self._server.query(key, method=self._server._session.post, data=data) def setArt(self, art): - """ Set the artwork for a Plex object. + """ Set the background artwork for a Plex object. Parameters: art (:class:`~plexapi.media.Art`): The art object to select. @@ -36,6 +36,37 @@ class ArtMixin(object): art.select() +class BannerMixin(object): + """ Mixin for Plex objects that can have banners.""" + + def banners(self): + """ Returns list of available :class:`~plexapi.media.Banner` objects. """ + return self.fetchItems('/library/metadata/%s/banners' % self.ratingKey, 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. + """ + if url: + key = '/library/metadata/%s/banners?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/banners?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setBanner(self, banner): + """ Set the banner for a Plex object. + + Parameters: + banner (:class:`~plexapi.media.Banner`): The banner object to select. + """ + banner.select() + + class PosterMixin(object): """ Mixin for Plex objects that can have posters.""" @@ -44,7 +75,7 @@ class PosterMixin(object): 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. + """ Upload a poster from a url or filepath. Parameters: url (str): The full URL to the image to upload. diff --git a/plexapi/video.py b/plexapi/video.py index 98a24c7d..3ca41126 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ 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 ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin @@ -388,7 +388,7 @@ class Movie(Video, Playable, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatc @utils.registerPlexObject -class Show(Video, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, +class Show(Video, ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, CollectionMixin, GenreMixin, LabelMixin): """ Represents a single Show (including all seasons and episodes). From 00a20b2c9271183815cc7b58397a2a6da18a567b Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 16:12:06 -0800 Subject: [PATCH 52/96] Add banner mixin to show tests --- tests/test_mixins.py | 4 ++++ tests/test_video.py | 1 + 2 files changed, 5 insertions(+) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index a69eef4a..2b5068be 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -111,5 +111,9 @@ def edit_art(obj): _test_mixins_art_poster(obj, 'arts') +def edit_banner(obj): + _test_mixins_art_poster(obj, 'banners') + + def edit_poster(obj): _test_mixins_art_poster(obj, 'posters') diff --git a/tests/test_video.py b/tests/test_video.py index 1f4e857b..a9e1737e 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -722,6 +722,7 @@ def test_video_Show_section(show): def test_video_Show_mixins_images(show): test_mixins.edit_art(show) + test_mixins.edit_banner(show) test_mixins.edit_poster(show) From 29e7374de3cad05f21fe3a2b791215fb79da70e4 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 16:21:12 -0800 Subject: [PATCH 53/96] Rename mixins image test --- tests/test_mixins.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 2b5068be..e7de281a 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -76,7 +76,7 @@ def edit_writer(obj): _test_mixins_tag(obj, "writers", "Writer") -def _test_mixins_art_poster(obj, attr): +def _test_mixins_image(obj, attr): cap_attr = attr[:-1].capitalize() get_img_method = getattr(obj, attr) set_img_method = getattr(obj, "set" + cap_attr) @@ -108,12 +108,12 @@ def _test_mixins_art_poster(obj, attr): def edit_art(obj): - _test_mixins_art_poster(obj, 'arts') + _test_mixins_image(obj, 'arts') def edit_banner(obj): - _test_mixins_art_poster(obj, 'banners') + _test_mixins_image(obj, 'banners') def edit_poster(obj): - _test_mixins_art_poster(obj, 'posters') + _test_mixins_image(obj, 'posters') From f5cd5277d8c44494a30292467c1ab0a72a712949 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 16:34:38 -0800 Subject: [PATCH 54/96] Fix mixins image test reset default image --- tests/test_mixins.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index e7de281a..27bd552c 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -83,6 +83,7 @@ def _test_mixins_image(obj, attr): upload_img_method = getattr(obj, "upload" + cap_attr) images = get_img_method() if images: + default_image = images[0] image = images[0] assert len(image.key) >= 10 if not image.ratingKey.startswith(("default://", "media://", "upload://")): @@ -96,6 +97,8 @@ def _test_mixins_image(obj, attr): images = get_img_method() assert images[0].selected is False assert images[1].selected is True + else: + default_image = None # Test upload image from file upload_img_method(filepath=utils.STUB_IMAGE_PATH) images = get_img_method() @@ -104,7 +107,9 @@ def _test_mixins_image(obj, attr): if i.ratingKey.startswith('upload://') and i.ratingKey.endswith(CUTE_CAT_SHA1) ] assert file_image - set_img_method(images[0]) # Reset to default image + # Reset to default image + if default_image: + set_img_method(default_image) def edit_art(obj): From bbafaee1cf6ec6dadee0ddb814fe6bda2ba6c071 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 17:10:11 -0800 Subject: [PATCH 55/96] Update art and thumb tests --- tests/conftest.py | 12 ++++++++++ tests/test_audio.py | 55 +++++++++++++++++++++++++++++++++++---------- tests/test_video.py | 53 ++++++++++++++++++++++++++++++++----------- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5f676fe7..84e88eea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -377,10 +377,22 @@ def is_string(value, gte=1): return isinstance(value, str) and len(value) >= gte +def is_art(key): + return is_metadata(key, contains="/art/") + + def is_thumb(key): return is_metadata(key, contains="/thumb/") +def is_artUrl(url): + return url.startswith(SERVER_BASEURL) and "/library/metadata/" in url and "/art/" in url + + +def is_thumbUrl(url): + return url.startswith(SERVER_BASEURL) and "/library/metadata/" in url and "/thumb/" in url + + def wait_until(condition_function, delay=0.25, timeout=1, *args, **kwargs): start = time.time() ready = condition_function(*args, **kwargs) diff --git a/tests/test_audio.py b/tests/test_audio.py index 22f98a4e..95b6844e 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -8,6 +8,11 @@ from . import test_mixins def test_audio_Artist_attr(artist): artist.reload() assert utils.is_datetime(artist.addedAt) + if artist.art: + assert utils.is_art(artist.art) + assert utils.is_artUrl(artist.artUrl) + else: + assert artist.artUrl is None if artist.countries: assert "United States of America" in [i.tag for i in artist.countries] #assert "Electronic" in [i.tag for i in artist.genres] @@ -24,6 +29,11 @@ def test_audio_Artist_attr(artist): assert isinstance(artist.similar, list) if artist.summary: assert "Alias" in artist.summary + if artist.thumb: + assert utils.is_thumb(artist.thumb) + assert utils.is_thumbUrl(artist.thumbUrl) + else: + assert artist.thumbUrl is None assert artist.title == "Broke For Free" assert artist.titleSort == "Broke For Free" assert artist.type == "artist" @@ -80,6 +90,11 @@ def test_audio_Artist_mixins_tags(artist): def test_audio_Album_attrs(album): assert utils.is_datetime(album.addedAt) + if album.art: + assert utils.is_art(album.art) + assert utils.is_artUrl(album.artUrl) + else: + assert album.artUrl is None assert isinstance(album.genres, list) assert album.index == 1 assert utils.is_metadata(album._initpath) @@ -90,21 +105,23 @@ def test_audio_Album_attrs(album): assert utils.is_metadata(album.parentKey) assert utils.is_int(album.parentRatingKey) if album.parentThumb: - assert utils.is_metadata(album.parentThumb, contains="/thumb/") + assert utils.is_thumb(album.parentThumb) assert album.parentTitle == "Broke For Free" assert album.ratingKey >= 1 assert album._server._baseurl == utils.SERVER_BASEURL assert album.studio == "[no label]" assert album.summary == "" if album.thumb: - assert utils.is_metadata(album.thumb, contains="/thumb/") + assert utils.is_thumb(album.thumb) + assert utils.is_thumbUrl(album.thumbUrl) + else: + assert album.thumbUrl is None assert album.title == "Layers" assert album.titleSort == "Layers" assert album.type == "album" assert utils.is_datetime(album.updatedAt) assert utils.is_int(album.viewCount, gte=0) assert album.year in (2012,) - assert album.artUrl is None def test_audio_Album_history(album): @@ -133,14 +150,14 @@ def test_audio_Album_tracks(album): assert utils.is_metadata(track.parentKey) assert utils.is_int(track.parentRatingKey) if track.parentThumb: - assert utils.is_metadata(track.parentThumb, contains="/thumb/") + assert utils.is_thumb(track.parentThumb) assert track.parentTitle == "Layers" # assert track.ratingCount == 9 # Flaky assert utils.is_int(track.ratingKey) assert track._server._baseurl == utils.SERVER_BASEURL assert track.summary == "" if track.thumb: - assert utils.is_metadata(track.thumb, contains="/thumb/") + assert utils.is_thumb(track.thumb) assert track.title == "As Colourful as Ever" assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions @@ -156,6 +173,11 @@ def test_audio_Album_track(album, track=None): track2 = album.track(track=1) assert track == track2 assert utils.is_datetime(track.addedAt) + if track.art: + assert utils.is_art(track.art) + assert utils.is_artUrl(track.artUrl) + else: + assert track.artUrl is None assert utils.is_int(track.duration) assert utils.is_metadata(track.grandparentKey) assert utils.is_int(track.grandparentRatingKey) @@ -172,14 +194,17 @@ def test_audio_Album_track(album, track=None): assert utils.is_metadata(track.parentKey) assert utils.is_int(track.parentRatingKey) if track.parentThumb: - assert utils.is_metadata(track.parentThumb, contains="/thumb/") + assert utils.is_thumb(track.parentThumb) assert track.parentTitle == "Layers" # assert track.ratingCount == 9 assert utils.is_int(track.ratingKey) assert track._server._baseurl == utils.SERVER_BASEURL assert track.summary == "" if track.thumb: - assert utils.is_metadata(track.thumb, contains="/thumb/") + assert utils.is_thumb(track.thumb) + assert utils.is_thumbUrl(track.thumbUrl) + else: + assert track.thumbUrl is None assert track.title == "As Colourful as Ever" assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions @@ -212,7 +237,6 @@ def test_audio_Album_track(album, track=None): assert utils.is_part(part.key) assert part._server._baseurl == utils.SERVER_BASEURL assert part.size == 3761053 - assert track.artUrl is None def test_audio_Album_get(album): @@ -242,14 +266,18 @@ def test_audio_Album_mixins_tags(album): def test_audio_Track_attrs(album): track = album.get("As Colourful As Ever").reload() assert utils.is_datetime(track.addedAt) - assert track.art is None + if track.art: + assert utils.is_art(track.art) + assert utils.is_artUrl(track.artUrl) + else: + assert track.artUrl is None assert track.chapterSource is None assert utils.is_int(track.duration) assert track.grandparentArt is None assert utils.is_metadata(track.grandparentKey) assert utils.is_int(track.grandparentRatingKey) if track.grandparentThumb: - assert utils.is_metadata(track.grandparentThumb, contains="/thumb/") + assert utils.is_thumb(track.grandparentThumb) assert track.grandparentTitle == "Broke For Free" assert track.guid.startswith("mbid://") or track.guid.startswith("plex://track/") assert int(track.index) == 1 @@ -268,7 +296,7 @@ def test_audio_Track_attrs(album): assert utils.is_metadata(track.parentKey) assert utils.is_int(track.parentRatingKey) if track.parentThumb: - assert utils.is_metadata(track.parentThumb, contains="/thumb/") + assert utils.is_thumb(track.parentThumb) assert track.parentTitle == "Layers" assert track.playlistItemID is None assert track.primaryExtraKey is None @@ -278,7 +306,10 @@ def test_audio_Track_attrs(album): assert track.sessionKey is None assert track.summary == "" if track.thumb: - assert utils.is_metadata(track.thumb, contains="/thumb/") + assert utils.is_thumb(track.thumb) + assert utils.is_thumbUrl(track.thumbUrl) + else: + assert track.thumbUrl is None assert track.title == "As Colourful as Ever" assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions diff --git a/tests/test_video.py b/tests/test_video.py index a9e1737e..e9903952 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -157,8 +157,11 @@ def test_video_Movie_attrs(movies): assert len(movie.locations) == 1 assert len(movie.locations[0]) >= 10 assert utils.is_datetime(movie.addedAt) - assert utils.is_metadata(movie.art) - assert movie.artUrl + if movie.art: + assert utils.is_art(movie.art) + assert utils.is_artUrl(movie.artUrl) + else: + assert movie.artUrl is None assert float(movie.rating) >= 6.4 assert movie.ratingImage == 'rottentomatoes://image.rating.ripe' assert movie.audienceRating >= 8.5 @@ -199,7 +202,11 @@ def test_video_Movie_attrs(movies): assert movie.studio == "Nina Paley" assert utils.is_string(movie.summary, gte=100) assert movie.tagline == "The Greatest Break-Up Story Ever Told" - assert utils.is_thumb(movie.thumb) + if movie.thumb: + assert utils.is_thumb(movie.thumb) + assert utils.is_thumbUrl(movie.thumbUrl) + else: + assert movie.thumbUrl is None assert movie.title == "Sita Sings the Blues" assert movie.titleSort == "Sita Sings the Blues" assert not movie.transcodeSessions @@ -547,7 +554,11 @@ def test_video_Episode_stop(episode, mocker, patched_http_call): def test_video_Show_attrs(show): assert utils.is_datetime(show.addedAt) - assert utils.is_metadata(show.art, contains="/art/") + if show.art: + assert utils.is_art(show.art) + assert utils.is_artUrl(show.artUrl) + else: + assert show.artUrl is None assert utils.is_metadata(show.banner, contains="/banner/") assert utils.is_int(show.childCount) assert show.contentRating in utils.CONTENTRATINGS @@ -586,7 +597,11 @@ def test_video_Show_attrs(show): assert show.studio == "HBO" assert utils.is_string(show.summary, gte=100) assert utils.is_metadata(show.theme, contains="/theme/") - assert utils.is_metadata(show.thumb, contains="/thumb/") + if show.thumb: + assert utils.is_thumb(show.thumb) + assert utils.is_thumbUrl(show.thumbUrl) + else: + assert show.thumbUrl is None assert show.title == "Game of Thrones" assert show.titleSort == "Game of Thrones" assert show.type == "show" @@ -681,12 +696,6 @@ def test_video_Episode_download(monkeydownload, tmpdir, episode): assert len(with_sceen_size) == 1 -def test_video_Show_thumbUrl(show): - assert utils.SERVER_BASEURL in show.thumbUrl - assert "/library/metadata/" in show.thumbUrl - assert "/thumb/" in show.thumbUrl - - # Analyze seems to fail intermittently @pytest.mark.xfail def test_video_Show_analyze(show): @@ -782,6 +791,11 @@ def test_video_Episode_analyze(tvshows): def test_video_Episode_attrs(episode): assert utils.is_datetime(episode.addedAt) + if episode.art: + assert utils.is_art(episode.art) + assert utils.is_artUrl(episode.artUrl) + else: + assert episode.artUrl is None assert episode.contentRating in utils.CONTENTRATINGS if len(episode.directors): assert [i.tag for i in episode.directors] == ["Tim Van Patten"] @@ -801,7 +815,11 @@ def test_video_Episode_attrs(episode): assert episode._server._baseurl == utils.SERVER_BASEURL assert episode.skipParent is False assert utils.is_string(episode.summary, gte=100) - assert utils.is_metadata(episode.thumb, contains="/thumb/") + if episode.thumb: + assert utils.is_thumb(episode.thumb) + assert utils.is_thumbUrl(episode.thumbUrl) + else: + assert episode.thumbUrl is None assert episode.title == "Winter Is Coming" assert episode.titleSort == "Winter Is Coming" assert not episode.transcodeSessions @@ -876,6 +894,11 @@ def test_video_Season_history(show): def test_video_Season_attrs(show): season = show.season("Season 1") assert utils.is_datetime(season.addedAt) + if season.art: + assert utils.is_art(season.art) + assert utils.is_artUrl(season.artUrl) + else: + assert season.artUrl is None assert season.index == 1 assert utils.is_metadata(season._initpath) assert utils.is_metadata(season.key) @@ -888,7 +911,11 @@ def test_video_Season_attrs(show): assert utils.is_int(season.ratingKey) assert season._server._baseurl == utils.SERVER_BASEURL assert season.summary == "" - assert utils.is_metadata(season.thumb, contains="/thumb/") + if season.thumb: + assert utils.is_thumb(season.thumb) + assert utils.is_thumbUrl(season.thumbUrl) + else: + assert season.thumbUrl is None assert season.title == "Season 1" assert season.titleSort == "Season 1" assert season.type == "season" From 8478ae6e6254e4fc94ea7493f23d5499534d78aa Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 19:03:19 -0800 Subject: [PATCH 56/96] Add banner url to show tests --- plexapi/audio.py | 8 ++++++-- plexapi/video.py | 9 ++++++++- tests/conftest.py | 8 ++++++++ tests/test_audio.py | 7 ++++++- tests/test_video.py | 15 +++++++++++++-- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 2cbd82d6..0bb04941 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -69,13 +69,17 @@ class Audio(PlexPartialObject): @property def thumbUrl(self): - """ Return url to for the thumbnail image. """ + """ Return the first first thumbnail url starting on + the most specific thumbnail for that item. + """ key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') return self._server.url(key, includeToken=True) if key else None @property def artUrl(self): - """ Return the first art url starting on the most specific for that item.""" + """ Return the first first art url starting on + the most specific art for that item. + """ art = self.firstAttr('art', 'grandparentArt') return self._server.url(art, includeToken=True) if art else None diff --git a/plexapi/video.py b/plexapi/video.py index 3ca41126..9d3b6a44 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -76,7 +76,9 @@ class Video(PlexPartialObject): @property def artUrl(self): - """ Return the first first art url starting on the most specific for that item.""" + """ Return the first first art url starting on + the most specific art for that item. + """ art = self.firstAttr('art', 'grandparentArt') return self._server.url(art, includeToken=True) if art else None @@ -458,6 +460,11 @@ class Show(Video, ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMa """ Returns True if the show is fully watched. """ return bool(self.viewedLeafCount == self.leafCount) + @property + def bannerUrl(self): + """ Return the banner url for the show.""" + return self._server.url(self.banner, includeToken=True) if self.banner else None + def preferences(self): """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ items = [] diff --git a/tests/conftest.py b/tests/conftest.py index 84e88eea..c0d8bf44 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -381,6 +381,10 @@ 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/") @@ -389,6 +393,10 @@ def is_artUrl(url): return url.startswith(SERVER_BASEURL) and "/library/metadata/" in url and "/art/" in url +def is_bannerUrl(url): + return url.startswith(SERVER_BASEURL) and "/library/metadata/" in url and "/banner/" in url + + def is_thumbUrl(url): return url.startswith(SERVER_BASEURL) and "/library/metadata/" in url and "/thumb/" in url diff --git a/tests/test_audio.py b/tests/test_audio.py index 95b6844e..f854165f 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -179,8 +179,12 @@ def test_audio_Album_track(album, track=None): else: assert track.artUrl is None assert utils.is_int(track.duration) + if track.grandparentArt: + assert utils.is_art(track.grandparentArt) assert utils.is_metadata(track.grandparentKey) assert utils.is_int(track.grandparentRatingKey) + if track.grandparentThumb: + assert utils.is_thumb(track.grandparentThumb) assert track.grandparentTitle == "Broke For Free" assert int(track.index) == 1 assert utils.is_metadata(track._initpath) @@ -273,7 +277,8 @@ def test_audio_Track_attrs(album): assert track.artUrl is None assert track.chapterSource is None assert utils.is_int(track.duration) - assert track.grandparentArt is None + if track.grandparentArt: + assert utils.is_art(track.grandparengrandparentArt) assert utils.is_metadata(track.grandparentKey) assert utils.is_int(track.grandparentRatingKey) if track.grandparentThumb: diff --git a/tests/test_video.py b/tests/test_video.py index e9903952..8b25f87a 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -559,7 +559,11 @@ def test_video_Show_attrs(show): assert utils.is_artUrl(show.artUrl) else: assert show.artUrl is None - assert utils.is_metadata(show.banner, contains="/banner/") + if show.banner: + assert utils.is_banner(show.banner) + assert utils.is_bannerUrl(show.bannerUrl) + else: + assert show.bannerUrl is None assert utils.is_int(show.childCount) assert show.contentRating in utils.CONTENTRATINGS assert utils.is_int(show.duration, gte=1600000) @@ -800,6 +804,10 @@ def test_video_Episode_attrs(episode): if len(episode.directors): assert [i.tag for i in episode.directors] == ["Tim Van Patten"] assert utils.is_int(episode.duration, gte=120000) + if episode.grandparentArt: + assert utils.is_art(episode.grandparentArt) + if episode.grandparentThumb: + assert utils.is_thumb(episode.grandparentThumb) assert episode.grandparentTitle == "Game of Thrones" assert episode.index == 1 assert utils.is_metadata(episode._initpath) @@ -809,7 +817,8 @@ def test_video_Episode_attrs(episode): assert utils.is_int(episode.parentIndex) assert utils.is_metadata(episode.parentKey) assert utils.is_int(episode.parentRatingKey) - assert utils.is_metadata(episode.parentThumb, contains="/thumb/") + if episode.parentThumb: + assert utils.is_thumb(episode.parentThumb) assert episode.rating >= 7.7 assert utils.is_int(episode.ratingKey) assert episode._server._baseurl == utils.SERVER_BASEURL @@ -907,6 +916,8 @@ def test_video_Season_attrs(show): assert season.listType == "video" assert utils.is_metadata(season.parentKey) assert utils.is_int(season.parentRatingKey) + if season.parentThumb: + assert utils.is_thumb(season.parentThumb) assert season.parentTitle == "Game of Thrones" assert utils.is_int(season.ratingKey) assert season._server._baseurl == utils.SERVER_BASEURL From 8915134b6bd582c5ede0895a7e9a31602017036a Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 19:11:53 -0800 Subject: [PATCH 57/96] Fix typo in track test --- tests/test_audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_audio.py b/tests/test_audio.py index f854165f..6dcbeba7 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -278,7 +278,7 @@ def test_audio_Track_attrs(album): assert track.chapterSource is None assert utils.is_int(track.duration) if track.grandparentArt: - assert utils.is_art(track.grandparengrandparentArt) + assert utils.is_art(track.grandparentArt) assert utils.is_metadata(track.grandparentKey) assert utils.is_int(track.grandparentRatingKey) if track.grandparentThumb: From c41f89bf9b1fa307fd7bb859fe360de456c99689 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 19:12:18 -0800 Subject: [PATCH 58/96] Factor out artUrl, thumbUrl, bannerUrl to mixins --- plexapi/audio.py | 16 ---------------- plexapi/library.py | 10 ---------- plexapi/mixins.py | 18 ++++++++++++++++++ plexapi/photo.py | 2 +- plexapi/video.py | 21 --------------------- 5 files changed, 19 insertions(+), 48 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 0bb04941..c9c6923e 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -67,22 +67,6 @@ class Audio(PlexPartialObject): self.userRating = utils.cast(float, data.attrib.get('userRating', 0)) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) - @property - def thumbUrl(self): - """ Return the first first thumbnail url starting on - the most specific thumbnail for that item. - """ - key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') - return self._server.url(key, includeToken=True) if key else None - - @property - def artUrl(self): - """ Return the first first art url starting on - the most specific art for that item. - """ - art = self.firstAttr('art', 'grandparentArt') - return self._server.url(art, includeToken=True) if art else None - def url(self, part): """ Returns the full URL for the audio item. Typically used for getting a specific track. """ return self._server.url(part, includeToken=True) if part else None diff --git a/plexapi/library.py b/plexapi/library.py index 8a6413f6..e9f70b9b 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -1598,16 +1598,6 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): def children(self): return self.fetchItems(self.key) - @property - def thumbUrl(self): - """ Return the thumbnail url for the collection.""" - return self._server.url(self.thumb, includeToken=True) if self.thumb else None - - @property - def artUrl(self): - """ Return the art url for the collection.""" - return self._server.url(self.art, includeToken=True) if self.art else None - def item(self, title): """ Returns the item in the collection that matches the specified title. diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 88e8aa46..d0deecb4 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -8,6 +8,12 @@ from plexapi.exceptions import NotFound class ArtMixin(object): """ Mixin for Plex objects that can have background artwork.""" + @property + def artUrl(self): + """ Return the art url for the Plex object.""" + art = self.firstAttr('art', 'grandparentArt') + return self._server.url(art, includeToken=True) if art else None + def arts(self): """ Returns list of available :class:`~plexapi.media.Art` objects. """ return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=media.Art) @@ -39,6 +45,12 @@ class ArtMixin(object): class BannerMixin(object): """ Mixin for Plex objects that can have banners.""" + @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 + def banners(self): """ Returns list of available :class:`~plexapi.media.Banner` objects. """ return self.fetchItems('/library/metadata/%s/banners' % self.ratingKey, cls=media.Banner) @@ -70,6 +82,12 @@ class BannerMixin(object): class PosterMixin(object): """ Mixin for Plex objects that can have posters.""" + @property + def thumbUrl(self): + """ Return the thumb url for the Plex object.""" + thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') + return self._server.url(thumb, includeToken=True) if thumb else None + def posters(self): """ Returns list of available :class:`~plexapi.media.Poster` objects. """ return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=media.Poster) diff --git a/plexapi/photo.py b/plexapi/photo.py index eb286751..6fb16071 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -210,7 +210,7 @@ class Photo(PlexPartialObject, Playable, TagMixin): @property def thumbUrl(self): - """Return URL for the thumbnail image.""" + """ Return the thumb url for the photo.""" key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') return self._server.url(key, includeToken=True) if key else None diff --git a/plexapi/video.py b/plexapi/video.py index 9d3b6a44..c9e83af7 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -66,22 +66,6 @@ class Video(PlexPartialObject): """ Returns True if this video is watched. """ return bool(self.viewCount > 0) if self.viewCount else False - @property - def thumbUrl(self): - """ Return the first first thumbnail url starting on - the most specific thumbnail for that item. - """ - thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') - return self._server.url(thumb, includeToken=True) if thumb else None - - @property - def artUrl(self): - """ Return the first first art url starting on - the most specific art for that item. - """ - art = self.firstAttr('art', 'grandparentArt') - return self._server.url(art, includeToken=True) if art else None - def url(self, part): """ Returns the full url for something. Typically used for getting a specific image. """ return self._server.url(part, includeToken=True) if part else None @@ -460,11 +444,6 @@ class Show(Video, ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMa """ Returns True if the show is fully watched. """ return bool(self.viewedLeafCount == self.leafCount) - @property - def bannerUrl(self): - """ Return the banner url for the show.""" - return self._server.url(self.banner, includeToken=True) if self.banner else None - def preferences(self): """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ items = [] From 9fa51cee2ac3a46b32d14db2d584f3e0c977ca08 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 19:38:09 -0800 Subject: [PATCH 59/96] Subclass image url mixins --- plexapi/audio.py | 4 ++-- plexapi/mixins.py | 26 +++++++++++++++++++------- plexapi/photo.py | 12 +++--------- plexapi/video.py | 4 ++-- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index c9c6923e..6f052e31 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 ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin @@ -324,7 +324,7 @@ class Album(Audio, ArtMixin, PosterMixin, UnmatchMatchMixin, @utils.registerPlexObject -class Track(Audio, Playable, MoodMixin): +class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, MoodMixin): """ Represents a single Track. Attributes: diff --git a/plexapi/mixins.py b/plexapi/mixins.py index d0deecb4..eed30c8e 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -5,15 +5,19 @@ from plexapi import media, utils from plexapi.exceptions import NotFound -class ArtMixin(object): - """ Mixin for Plex objects that can have background artwork.""" - +class ArtUrlMixin(object): + """ Mixin for Plex objects that can have a background artwork url.""" + @property def artUrl(self): """ Return the art url for the Plex object.""" art = self.firstAttr('art', 'grandparentArt') return self._server.url(art, includeToken=True) if art else None + +class ArtMixin(ArtUrlMixin): + """ Mixin for Plex objects that can have background 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) @@ -42,8 +46,8 @@ class ArtMixin(object): art.select() -class BannerMixin(object): - """ Mixin for Plex objects that can have banners.""" +class BannerUrlMixin(object): + """ Mixin for Plex objects that can have a banner url.""" @property def bannerUrl(self): @@ -51,6 +55,10 @@ class BannerMixin(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('/library/metadata/%s/banners' % self.ratingKey, cls=media.Banner) @@ -79,8 +87,8 @@ class BannerMixin(object): banner.select() -class PosterMixin(object): - """ Mixin for Plex objects that can have posters.""" +class PosterUrlMixin(object): + """ Mixin for Plex objects that can have a poster url.""" @property def thumbUrl(self): @@ -88,6 +96,10 @@ class PosterMixin(object): thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') return self._server.url(thumb, includeToken=True) if thumb else None + +class PosterMixin(PosterUrlMixin): + """ 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) diff --git a/plexapi/photo.py b/plexapi/photo.py index 6fb16071..398cd7da 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -4,11 +4,11 @@ from urllib.parse import quote_plus from plexapi import media, utils, video from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest -from plexapi.mixins import TagMixin +from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, TagMixin @utils.registerPlexObject -class Photoalbum(PlexPartialObject): +class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin): """ Represents a single Photoalbum (collection of photos). Attributes: @@ -137,7 +137,7 @@ class Photoalbum(PlexPartialObject): @utils.registerPlexObject -class Photo(PlexPartialObject, Playable, TagMixin): +class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin): """ Represents a single Photo. Attributes: @@ -208,12 +208,6 @@ class Photo(PlexPartialObject, Playable, TagMixin): self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.year = utils.cast(int, data.attrib.get('year')) - @property - def thumbUrl(self): - """ Return the thumb url for the photo.""" - key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') - return self._server.url(key, includeToken=True) if key else None - def photoalbum(self): """ Return the photo's :class:`~plexapi.photo.Photoalbum`. """ return self.fetchItem(self.parentKey) diff --git a/plexapi/video.py b/plexapi/video.py index c9e83af7..fd85e1bc 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ 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 ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin @@ -839,7 +839,7 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, @utils.registerPlexObject -class Clip(Video, Playable): +class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): """Represents a single Clip. Attributes: From b0780aaec8b1bb2878020ff87caa1c250cd4ccea Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 19:58:03 -0800 Subject: [PATCH 60/96] Add Playlist thumb alias to composite --- plexapi/base.py | 2 +- plexapi/playlist.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/plexapi/base.py b/plexapi/base.py index e0022e2e..627e33df 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -232,7 +232,7 @@ class PlexObject(object): def firstAttr(self, *attrs): """ Return the first attribute in attrs that is not None. """ for attr in attrs: - value = self.__dict__.get(attr) + value = getattr(self, attr, None) if value is not None: return value diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 399d55aa..36179dc5 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -63,6 +63,11 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): for item in self.items(): yield item + @property + def thumb(self): + """ Alias to self.composite. """ + return self.composite + @property def metadataType(self): if self.isVideo: From 32f00e653aef4c161cc413353015d190990a4dfb Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 19:58:18 -0800 Subject: [PATCH 61/96] Add alias posterUrl to thumbUrl --- plexapi/mixins.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plexapi/mixins.py b/plexapi/mixins.py index eed30c8e..18820e71 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -96,6 +96,11 @@ class PosterUrlMixin(object): thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') return self._server.url(thumb, includeToken=True) if thumb else None + @property + def posterUrl(self): + """ Alias to self.thumbUrl.""" + return self.thumbUrl + class PosterMixin(PosterUrlMixin): """ Mixin for Plex objects that can have posters.""" From bc8e42bbff849852bce619e5645dbdcbae330ae7 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 20:02:14 -0800 Subject: [PATCH 62/96] Add simple test for posterUrl alias --- tests/test_library.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_library.py b/tests/test_library.py index 83dc1469..e871a601 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -318,6 +318,7 @@ def test_library_Collection_thumbUrl(collection): assert utils.SERVER_BASEURL in collection.thumbUrl assert "/library/collections/" in collection.thumbUrl assert "/composite/" in collection.thumbUrl + assert collection.thumbUrl == collection.posterUrl def test_library_Collection_artUrl(collection): From aa3c37e5b5e233f6705c3b0661dd3c4867d0ab83 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 20:06:49 -0800 Subject: [PATCH 63/96] Clean doc strings --- plexapi/mixins.py | 24 ++++++++++++------------ plexapi/video.py | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/plexapi/mixins.py b/plexapi/mixins.py index 18820e71..4aa6fbcb 100644 --- a/plexapi/mixins.py +++ b/plexapi/mixins.py @@ -6,17 +6,17 @@ from plexapi.exceptions import NotFound class ArtUrlMixin(object): - """ Mixin for Plex objects that can have a background artwork url.""" + """ Mixin for Plex objects that can have a background artwork url. """ @property def artUrl(self): - """ Return the art url for the Plex object.""" + """ Return the art url for the Plex object. """ art = self.firstAttr('art', 'grandparentArt') return self._server.url(art, includeToken=True) if art else None class ArtMixin(ArtUrlMixin): - """ Mixin for Plex objects that can have background artwork.""" + """ Mixin for Plex objects that can have background artwork. """ def arts(self): """ Returns list of available :class:`~plexapi.media.Art` objects. """ @@ -47,17 +47,17 @@ class ArtMixin(ArtUrlMixin): class BannerUrlMixin(object): - """ Mixin for Plex objects that can have a banner url.""" + """ Mixin for Plex objects that can have a banner url. """ @property def bannerUrl(self): - """ Return the banner url for the Plex object.""" + """ 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.""" + """ Mixin for Plex objects that can have banners. """ def banners(self): """ Returns list of available :class:`~plexapi.media.Banner` objects. """ @@ -88,22 +88,22 @@ class BannerMixin(BannerUrlMixin): class PosterUrlMixin(object): - """ Mixin for Plex objects that can have a poster url.""" + """ Mixin for Plex objects that can have a poster url. """ @property def thumbUrl(self): - """ Return the thumb url for the Plex object.""" + """ Return the thumb url for the Plex object. """ thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') return self._server.url(thumb, includeToken=True) if thumb else None @property def posterUrl(self): - """ Alias to self.thumbUrl.""" + """ Alias to self.thumbUrl. """ return self.thumbUrl class PosterMixin(PosterUrlMixin): - """ Mixin for Plex objects that can have posters.""" + """ Mixin for Plex objects that can have posters. """ def posters(self): """ Returns list of available :class:`~plexapi.media.Poster` objects. """ @@ -134,7 +134,7 @@ class PosterMixin(PosterUrlMixin): class SplitMergeMixin(object): - """ Mixin for Plex objects that can be split and merged.""" + """ Mixin for Plex objects that can be split and merged. """ def split(self): """ Split duplicated Plex object into separate objects. """ @@ -155,7 +155,7 @@ class SplitMergeMixin(object): class UnmatchMatchMixin(object): - """ Mixin for Plex objects that can be unmatched and matched.""" + """ Mixin for Plex objects that can be unmatched and matched. """ def unmatch(self): """ Unmatches metadata match from object. """ diff --git a/plexapi/video.py b/plexapi/video.py index fd85e1bc..cc03e673 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -840,7 +840,7 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, @utils.registerPlexObject class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): - """Represents a single Clip. + """ Represents a single Clip. Attributes: TAG (str): 'Video' @@ -862,7 +862,7 @@ class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): METADATA_TYPE = 'clip' def _loadData(self, data): - """Load attribute values from Plex XML response.""" + """ Load attribute values from Plex XML response. """ Video._loadData(self, data) Playable._loadData(self, data) self._data = data From c537db61cfc54b3b51d9e72efdfc85e5dc9b0ba0 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 21:19:42 -0800 Subject: [PATCH 64/96] Factor out mixins image url tests --- tests/conftest.py | 12 ------------ tests/test_audio.py | 37 +++++++++++++------------------------ tests/test_library.py | 13 ++----------- tests/test_mixins.py | 22 ++++++++++++++++++++++ tests/test_video.py | 34 +++++++--------------------------- 5 files changed, 44 insertions(+), 74 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c0d8bf44..ae16a5a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -389,18 +389,6 @@ def is_thumb(key): return is_metadata(key, contains="/thumb/") -def is_artUrl(url): - return url.startswith(SERVER_BASEURL) and "/library/metadata/" in url and "/art/" in url - - -def is_bannerUrl(url): - return url.startswith(SERVER_BASEURL) and "/library/metadata/" in url and "/banner/" in url - - -def is_thumbUrl(url): - return url.startswith(SERVER_BASEURL) and "/library/metadata/" in url and "/thumb/" in url - - def wait_until(condition_function, delay=0.25, timeout=1, *args, **kwargs): start = time.time() ready = condition_function(*args, **kwargs) diff --git a/tests/test_audio.py b/tests/test_audio.py index 6dcbeba7..b54249a4 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -10,9 +10,6 @@ def test_audio_Artist_attr(artist): assert utils.is_datetime(artist.addedAt) if artist.art: assert utils.is_art(artist.art) - assert utils.is_artUrl(artist.artUrl) - else: - assert artist.artUrl is None if artist.countries: assert "United States of America" in [i.tag for i in artist.countries] #assert "Electronic" in [i.tag for i in artist.genres] @@ -31,9 +28,6 @@ def test_audio_Artist_attr(artist): assert "Alias" in artist.summary if artist.thumb: assert utils.is_thumb(artist.thumb) - assert utils.is_thumbUrl(artist.thumbUrl) - else: - assert artist.thumbUrl is None assert artist.title == "Broke For Free" assert artist.titleSort == "Broke For Free" assert artist.type == "artist" @@ -77,6 +71,8 @@ def test_audio_Artist_albums(artist): def test_audio_Artist_mixins_images(artist): test_mixins.edit_art(artist) test_mixins.edit_poster(artist) + test_mixins.attr_artUrl(artist) + test_mixins.attr_posterUrl(artist) def test_audio_Artist_mixins_tags(artist): @@ -92,9 +88,6 @@ def test_audio_Album_attrs(album): assert utils.is_datetime(album.addedAt) if album.art: assert utils.is_art(album.art) - assert utils.is_artUrl(album.artUrl) - else: - assert album.artUrl is None assert isinstance(album.genres, list) assert album.index == 1 assert utils.is_metadata(album._initpath) @@ -113,9 +106,6 @@ def test_audio_Album_attrs(album): assert album.summary == "" if album.thumb: assert utils.is_thumb(album.thumb) - assert utils.is_thumbUrl(album.thumbUrl) - else: - assert album.thumbUrl is None assert album.title == "Layers" assert album.titleSort == "Layers" assert album.type == "album" @@ -138,8 +128,12 @@ def test_audio_Album_tracks(album): tracks = album.tracks() track = tracks[0] assert len(tracks) == 1 + if track.grandparentArt: + assert utils.is_art(track.grandparentArt) assert utils.is_metadata(track.grandparentKey) assert utils.is_int(track.grandparentRatingKey) + if track.grandparentThumb: + assert utils.is_thumb(track.grandparentThumb) assert track.grandparentTitle == "Broke For Free" assert track.index == 1 assert utils.is_metadata(track._initpath) @@ -175,9 +169,6 @@ def test_audio_Album_track(album, track=None): assert utils.is_datetime(track.addedAt) if track.art: assert utils.is_art(track.art) - assert utils.is_artUrl(track.artUrl) - else: - assert track.artUrl is None assert utils.is_int(track.duration) if track.grandparentArt: assert utils.is_art(track.grandparentArt) @@ -206,9 +197,6 @@ def test_audio_Album_track(album, track=None): assert track.summary == "" if track.thumb: assert utils.is_thumb(track.thumb) - assert utils.is_thumbUrl(track.thumbUrl) - else: - assert track.thumbUrl is None assert track.title == "As Colourful as Ever" assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions @@ -257,6 +245,8 @@ def test_audio_Album_artist(album): def test_audio_Album_mixins_images(album): test_mixins.edit_art(album) test_mixins.edit_poster(album) + test_mixins.attr_artUrl(album) + test_mixins.attr_posterUrl(album) def test_audio_Album_mixins_tags(album): @@ -272,9 +262,6 @@ def test_audio_Track_attrs(album): assert utils.is_datetime(track.addedAt) if track.art: assert utils.is_art(track.art) - assert utils.is_artUrl(track.artUrl) - else: - assert track.artUrl is None assert track.chapterSource is None assert utils.is_int(track.duration) if track.grandparentArt: @@ -312,9 +299,6 @@ def test_audio_Track_attrs(album): assert track.summary == "" if track.thumb: assert utils.is_thumb(track.thumb) - assert utils.is_thumbUrl(track.thumbUrl) - else: - assert track.thumbUrl is None assert track.title == "As Colourful as Ever" assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions @@ -392,6 +376,11 @@ def test_audio_Track_artist(album, artist): assert tracks[0].artist() == artist +def test_audio_Track_mixins_images(track): + test_mixins.attr_artUrl(track) + test_mixins.attr_posterUrl(track) + + def test_audio_Track_mixins_tags(track): test_mixins.edit_mood(track) diff --git a/tests/test_library.py b/tests/test_library.py index e871a601..d31368a8 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -314,17 +314,6 @@ def test_library_Collection_items(collection): assert len(items) == 1 -def test_library_Collection_thumbUrl(collection): - assert utils.SERVER_BASEURL in collection.thumbUrl - assert "/library/collections/" in collection.thumbUrl - assert "/composite/" in collection.thumbUrl - assert collection.thumbUrl == collection.posterUrl - - -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 @@ -338,6 +327,8 @@ def test_library_Collection_art(collection): def test_library_Collection_mixins_images(collection): test_mixins.edit_art(collection) test_mixins.edit_poster(collection) + test_mixins.attr_artUrl(collection) + test_mixins.attr_posterUrl(collection) def test_library_Collection_mixins_tags(collection): diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 27bd552c..b1af5307 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -122,3 +122,25 @@ def edit_banner(obj): def edit_poster(obj): _test_mixins_image(obj, 'posters') + + +def _test_mixins_imageUrl(obj, attr): + url = getattr(obj, attr + 'Url') + if getattr(obj, attr): + assert url.startswith(utils.SERVER_BASEURL) + assert "/library/metadata/" in url + assert attr in url + else: + assert url is None + + +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, 'poster') diff --git a/tests/test_video.py b/tests/test_video.py index 8b25f87a..4eba01b0 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -159,9 +159,6 @@ def test_video_Movie_attrs(movies): assert utils.is_datetime(movie.addedAt) if movie.art: assert utils.is_art(movie.art) - assert utils.is_artUrl(movie.artUrl) - else: - assert movie.artUrl is None assert float(movie.rating) >= 6.4 assert movie.ratingImage == 'rottentomatoes://image.rating.ripe' assert movie.audienceRating >= 8.5 @@ -204,9 +201,6 @@ def test_video_Movie_attrs(movies): assert movie.tagline == "The Greatest Break-Up Story Ever Told" if movie.thumb: assert utils.is_thumb(movie.thumb) - assert utils.is_thumbUrl(movie.thumbUrl) - else: - assert movie.thumbUrl is None assert movie.title == "Sita Sings the Blues" assert movie.titleSort == "Sita Sings the Blues" assert not movie.transcodeSessions @@ -556,14 +550,8 @@ def test_video_Show_attrs(show): assert utils.is_datetime(show.addedAt) if show.art: assert utils.is_art(show.art) - assert utils.is_artUrl(show.artUrl) - else: - assert show.artUrl is None if show.banner: assert utils.is_banner(show.banner) - assert utils.is_bannerUrl(show.bannerUrl) - else: - assert show.bannerUrl is None assert utils.is_int(show.childCount) assert show.contentRating in utils.CONTENTRATINGS assert utils.is_int(show.duration, gte=1600000) @@ -603,9 +591,6 @@ def test_video_Show_attrs(show): assert utils.is_metadata(show.theme, contains="/theme/") if show.thumb: assert utils.is_thumb(show.thumb) - assert utils.is_thumbUrl(show.thumbUrl) - else: - assert show.thumbUrl is None assert show.title == "Game of Thrones" assert show.titleSort == "Game of Thrones" assert show.type == "show" @@ -737,6 +722,9 @@ def test_video_Show_mixins_images(show): test_mixins.edit_art(show) test_mixins.edit_banner(show) test_mixins.edit_poster(show) + test_mixins.attr_artUrl(show) + test_mixins.attr_bannerUrl(show) + test_mixins.attr_posterUrl(show) def test_video_Show_mixins_tags(show): @@ -797,9 +785,6 @@ def test_video_Episode_attrs(episode): assert utils.is_datetime(episode.addedAt) if episode.art: assert utils.is_art(episode.art) - assert utils.is_artUrl(episode.artUrl) - else: - assert episode.artUrl is None assert episode.contentRating in utils.CONTENTRATINGS if len(episode.directors): assert [i.tag for i in episode.directors] == ["Tim Van Patten"] @@ -826,9 +811,6 @@ def test_video_Episode_attrs(episode): assert utils.is_string(episode.summary, gte=100) if episode.thumb: assert utils.is_thumb(episode.thumb) - assert utils.is_thumbUrl(episode.thumbUrl) - else: - assert episode.thumbUrl is None assert episode.title == "Winter Is Coming" assert episode.titleSort == "Winter Is Coming" assert not episode.transcodeSessions @@ -878,6 +860,8 @@ def test_video_Episode_attrs(episode): def test_video_Episode_mixins_images(episode): #test_mixins.edit_art(episode) # Uploading episode artwork is broken in Plex test_mixins.edit_poster(episode) + test_mixins.attr_artUrl(episode) + test_mixins.attr_posterUrl(episode) def test_video_Episode_mixins_tags(episode): @@ -905,9 +889,6 @@ def test_video_Season_attrs(show): assert utils.is_datetime(season.addedAt) if season.art: assert utils.is_art(season.art) - assert utils.is_artUrl(season.artUrl) - else: - assert season.artUrl is None assert season.index == 1 assert utils.is_metadata(season._initpath) assert utils.is_metadata(season.key) @@ -924,9 +905,6 @@ def test_video_Season_attrs(show): assert season.summary == "" if season.thumb: assert utils.is_thumb(season.thumb) - assert utils.is_thumbUrl(season.thumbUrl) - else: - assert season.thumbUrl is None assert season.title == "Season 1" assert season.titleSort == "Season 1" assert season.type == "season" @@ -979,6 +957,8 @@ def test_video_Season_mixins_images(show): season = show.season(season=1) test_mixins.edit_art(season) test_mixins.edit_poster(season) + test_mixins.attr_artUrl(season) + test_mixins.attr_posterUrl(season) def test_that_reload_return_the_same_object(plex): From 9fde559147a5450eb242e890d57f7616f362351b Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 21:20:27 -0800 Subject: [PATCH 65/96] Remove redundant audio tests --- tests/test_audio.py | 95 --------------------------------------------- 1 file changed, 95 deletions(-) diff --git a/tests/test_audio.py b/tests/test_audio.py index b54249a4..6377c935 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -126,39 +126,7 @@ def test_audio_Track_history(track): def test_audio_Album_tracks(album): tracks = album.tracks() - track = tracks[0] assert len(tracks) == 1 - if track.grandparentArt: - assert utils.is_art(track.grandparentArt) - assert utils.is_metadata(track.grandparentKey) - assert utils.is_int(track.grandparentRatingKey) - if track.grandparentThumb: - assert utils.is_thumb(track.grandparentThumb) - assert track.grandparentTitle == "Broke For Free" - assert track.index == 1 - assert utils.is_metadata(track._initpath) - assert utils.is_metadata(track.key) - assert track.listType == "audio" - assert track.originalTitle in (None, "Broke For Free") - # assert utils.is_int(track.parentIndex) - assert utils.is_metadata(track.parentKey) - assert utils.is_int(track.parentRatingKey) - if track.parentThumb: - assert utils.is_thumb(track.parentThumb) - assert track.parentTitle == "Layers" - # assert track.ratingCount == 9 # Flaky - assert utils.is_int(track.ratingKey) - assert track._server._baseurl == utils.SERVER_BASEURL - assert track.summary == "" - if track.thumb: - assert utils.is_thumb(track.thumb) - assert track.title == "As Colourful as Ever" - assert track.titleSort == "As Colourful as Ever" - assert not track.transcodeSessions - assert track.type == "track" - assert utils.is_datetime(track.updatedAt) - assert utils.is_int(track.viewCount, gte=0) - assert track.viewOffset == 0 def test_audio_Album_track(album, track=None): @@ -166,69 +134,6 @@ def test_audio_Album_track(album, track=None): track = track or album.track("As Colourful As Ever") track2 = album.track(track=1) assert track == track2 - assert utils.is_datetime(track.addedAt) - if track.art: - assert utils.is_art(track.art) - assert utils.is_int(track.duration) - if track.grandparentArt: - assert utils.is_art(track.grandparentArt) - assert utils.is_metadata(track.grandparentKey) - assert utils.is_int(track.grandparentRatingKey) - if track.grandparentThumb: - assert utils.is_thumb(track.grandparentThumb) - assert track.grandparentTitle == "Broke For Free" - assert int(track.index) == 1 - assert utils.is_metadata(track._initpath) - assert utils.is_metadata(track.key) - assert track.listType == "audio" - # Assign 0 track.media - media = track.media[0] - assert track.originalTitle in (None, "As Colourful As Ever") - # Fix me - assert utils.is_int(track.parentIndex) - assert utils.is_metadata(track.parentKey) - assert utils.is_int(track.parentRatingKey) - if track.parentThumb: - assert utils.is_thumb(track.parentThumb) - assert track.parentTitle == "Layers" - # assert track.ratingCount == 9 - assert utils.is_int(track.ratingKey) - assert track._server._baseurl == utils.SERVER_BASEURL - assert track.summary == "" - if track.thumb: - assert utils.is_thumb(track.thumb) - assert track.title == "As Colourful as Ever" - assert track.titleSort == "As Colourful as Ever" - assert not track.transcodeSessions - assert track.type == "track" - assert utils.is_datetime(track.updatedAt) - assert utils.is_int(track.viewCount, gte=0) - assert track.viewOffset == 0 - assert media.aspectRatio is None - assert media.audioChannels == 2 - assert media.audioCodec == "mp3" - assert media.bitrate == 128 - assert media.container == "mp3" - assert utils.is_int(media.duration) - assert media.height in (None, 1080) - assert utils.is_int(media.id, gte=1) - assert utils.is_metadata(media._initpath) - assert media.optimizedForStreaming in (None, True) - # Assign 0 media.parts - part = media.parts[0] - assert media._server._baseurl == utils.SERVER_BASEURL - assert media.videoCodec is None - assert media.videoFrameRate is None - assert media.videoResolution is None - assert media.width is None - assert part.container == "mp3" - assert utils.is_int(part.duration) - assert part.file.endswith(".mp3") - assert utils.is_int(part.id) - assert utils.is_metadata(part._initpath) - assert utils.is_part(part.key) - assert part._server._baseurl == utils.SERVER_BASEURL - assert part.size == 3761053 def test_audio_Album_get(album): From 0fa6f33c470a4b6782974bc7af7857e0fe5fac00 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 21:32:28 -0800 Subject: [PATCH 66/96] Fix mixins poster url attr typo --- tests/test_mixins.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index b1af5307..29fb776f 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -130,6 +130,8 @@ def _test_mixins_imageUrl(obj, attr): assert url.startswith(utils.SERVER_BASEURL) assert "/library/metadata/" in url assert attr in url + if attr == 'thumb': + assert getattr(obj, 'posterUrl') == url else: assert url is None @@ -143,4 +145,4 @@ def attr_bannerUrl(obj): def attr_posterUrl(obj): - _test_mixins_imageUrl(obj, 'poster') + _test_mixins_imageUrl(obj, 'thumb') From 43e9685fba652070d6ec003575814cd6aecce91f Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 21:40:08 -0800 Subject: [PATCH 67/96] Fix collections image url mixins test --- tests/test_mixins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 29fb776f..f601a2f3 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -128,8 +128,8 @@ def _test_mixins_imageUrl(obj, attr): url = getattr(obj, attr + 'Url') if getattr(obj, attr): assert url.startswith(utils.SERVER_BASEURL) - assert "/library/metadata/" in url - assert attr in url + assert "/library/metadata/" in url or "/library/collections/" in url + assert attr in url or "composite" in url if attr == 'thumb': assert getattr(obj, 'posterUrl') == url else: From 6d4203049209efad63027c8f5940f72868b8ab94 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 21:56:28 -0800 Subject: [PATCH 68/96] Move Release to server module --- plexapi/base.py | 14 -------------- plexapi/server.py | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 92c00bf9..4fd37a53 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -662,17 +662,3 @@ class Playable(object): key %= (self.ratingKey, self.key, time, state, durationStr) self._server.query(key) self.reload() - - -@utils.registerPlexObject -class Release(PlexObject): - TAG = 'Release' - key = '/updater/status' - - def _loadData(self, data): - self.download_key = data.attrib.get('key') - self.version = data.attrib.get('version') - self.added = data.attrib.get('added') - self.fixed = data.attrib.get('fixed') - self.downloadURL = data.attrib.get('downloadURL') - self.state = data.attrib.get('state') diff --git a/plexapi/server.py b/plexapi/server.py index 42c92fdc..7011cb81 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -787,6 +787,20 @@ class Activity(PlexObject): self.uuid = data.attrib.get('uuid') +@utils.registerPlexObject +class Release(PlexObject): + TAG = 'Release' + key = '/updater/status' + + def _loadData(self, data): + self.download_key = data.attrib.get('key') + self.version = data.attrib.get('version') + self.added = data.attrib.get('added') + self.fixed = data.attrib.get('fixed') + self.downloadURL = data.attrib.get('downloadURL') + self.state = data.attrib.get('state') + + class SystemAccount(PlexObject): """ Represents a single system account. From 08bdab255b6b0529f4382d1ce4f5efa86dfb008d Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 21:59:11 -0800 Subject: [PATCH 69/96] Rename server checkForUpdate to camelCase * Add deprecation warning to check_for_update --- plexapi/server.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plexapi/server.py b/plexapi/server.py index 7011cb81..4337b911 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -15,7 +15,7 @@ from plexapi.media import Conversion, Optimized from plexapi.playlist import Playlist from plexapi.playqueue import PlayQueue from plexapi.settings import Settings -from plexapi.utils import cast +from plexapi.utils import cast, deprecated from requests.status_codes import _codes as codes # Need these imports to populate utils.PLEXOBJECTS @@ -374,7 +374,11 @@ class PlexServer(PlexObject): filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack) return filepath + @deprecated('use "checkForUpdate" instead') def check_for_update(self, force=True, download=False): + return self.checkForUpdate() + + def checkForUpdate(self, force=True, download=False): """ Returns a :class:`~plexapi.base.Release` object containing release info. Parameters: @@ -390,7 +394,7 @@ class PlexServer(PlexObject): def isLatest(self): """ Check if the installed version of PMS is the latest. """ - release = self.check_for_update(force=True) + release = self.checkForUpdate(force=True) return release is None def installUpdate(self): @@ -398,7 +402,7 @@ class PlexServer(PlexObject): # We can add this but dunno how useful this is since it sometimes # requires user action using a gui. part = '/updater/apply' - release = self.check_for_update(force=True, download=True) + release = self.checkForUpdate(force=True, download=True) if release and release.version != self.version: # figure out what method this is.. return self.query(part, method=self._session.put) From 5803af930cad78a370fb6d03f3e2d4b0f84600f5 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 22:33:03 -0800 Subject: [PATCH 70/96] Move collections to a new module --- plexapi/collection.py | 165 +++++++++++++++++++++++++++++++++++++++++ plexapi/library.py | 166 +----------------------------------------- plexapi/server.py | 3 +- 3 files changed, 168 insertions(+), 166 deletions(-) create mode 100644 plexapi/collection.py diff --git a/plexapi/collection.py b/plexapi/collection.py new file mode 100644 index 00000000..37641bf1 --- /dev/null +++ b/plexapi/collection.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +from plexapi import media, utils +from plexapi.base import PlexPartialObject +from plexapi.exceptions import BadRequest +from plexapi.mixins import ArtMixin, PosterMixin +from plexapi.mixins import LabelMixin +from plexapi.settings import Setting +from plexapi.utils import deprecated + + +@utils.registerPlexObject +class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): + """ Represents a single Collection. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'collection' + addedAt (datetime): Datetime the collection was added to the library. + art (str): URL to artwork image (/library/metadata//art/). + artBlurHash (str): BlurHash string for artwork image. + childCount (int): Number of items in the collection. + collectionMode (str): How the items in the collection are displayed. + collectionSort (str): How to sort the items in the 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). + index (int): Plex index number for the collection. + key (str): API URL (/library/metadata/). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + maxYear (int): Maximum year for the items in the collection. + minYear (int): Minimum year for the items in the collection. + ratingKey (int): Unique key identifying the collection. + subtype (str): Media type of the items in the collection (movie, show, artist, or album). + summary (str): Summary of the collection. + thumb (str): URL to thumbnail image (/library/metadata//thumb/). + thumbBlurHash (str): BlurHash string for thumbnail image. + title (str): Name of the collection. + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'collection' + updatedAt (datatime): Datetime the collection was updated. + """ + + TAG = 'Directory' + TYPE = 'collection' + + def _loadData(self, data): + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.art = data.attrib.get('art') + self.artBlurHash = data.attrib.get('artBlurHash') + self.childCount = utils.cast(int, data.attrib.get('childCount')) + self.collectionMode = utils.cast(int, data.attrib.get('collectionMode', '-1')) + self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0')) + self.contentRating = data.attrib.get('contentRating') + self.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') + 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) + self.librarySectionID = data.attrib.get('librarySectionID') + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.maxYear = utils.cast(int, data.attrib.get('maxYear')) + self.minYear = utils.cast(int, data.attrib.get('minYear')) + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.subtype = data.attrib.get('subtype') + self.summary = data.attrib.get('summary') + self.thumb = data.attrib.get('thumb') + self.thumbBlurHash = data.attrib.get('thumbBlurHash') + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort', self.title) + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + + @property + @deprecated('use "items" instead') + def children(self): + return self.fetchItems(self.key) + + @property + def thumbUrl(self): + """ Return the thumbnail url for the collection.""" + return self._server.url(self.thumb, includeToken=True) if self.thumb else None + + @property + def artUrl(self): + """ Return the art url for the collection.""" + return self._server.url(self.art, includeToken=True) if self.art else None + + def item(self, title): + """ Returns the item in the collection that matches the specified title. + + Parameters: + title (str): Title of the item to return. + """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, title__iexact=title) + + def items(self): + """ Returns a list of all items in the collection. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key) + + def get(self, title): + """ Alias to :func:`~plexapi.library.Collection.item`. """ + return self.item(title) + + def __len__(self): + return self.childCount + + def _preferences(self): + """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ + items = [] + data = self._server.query(self._details_key) + for item in data.iter('Setting'): + items.append(Setting(data=item, server=self._server)) + + return items + + def modeUpdate(self, mode=None): + """ Update Collection Mode + + Parameters: + mode: default (Library default) + hide (Hide Collection) + hideItems (Hide Items in this Collection) + showItems (Show this Collection and its Items) + Example: + + collection = 'plexapi.library.Collections' + collection.updateMode(mode="hide") + """ + mode_dict = {'default': -1, + 'hide': 0, + 'hideItems': 1, + 'showItems': 2} + key = mode_dict.get(mode) + if key is None: + raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict))) + part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key) + return self._server.query(part, method=self._server._session.put) + + def sortUpdate(self, sort=None): + """ Update Collection Sorting + + Parameters: + sort: realease (Order Collection by realease dates) + alpha (Order Collection alphabetically) + custom (Custom collection order) + + Example: + + colleciton = 'plexapi.library.Collections' + collection.updateSort(mode="alpha") + """ + sort_dict = {'release': 0, + 'alpha': 1, + 'custom': 2} + key = sort_dict.get(sort) + if key is None: + raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) + part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) + return self._server.query(part, method=self._server._session.put) diff --git a/plexapi/library.py b/plexapi/library.py index 8a6413f6..85e4a144 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -2,10 +2,8 @@ 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.base import OPERATORS, PlexObject 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,168 +1525,6 @@ class FirstCharacter(PlexObject): self.title = data.attrib.get('title') -@utils.registerPlexObject -class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): - """ Represents a single Collection. - - Attributes: - TAG (str): 'Directory' - TYPE (str): 'collection' - addedAt (datetime): Datetime the collection was added to the library. - art (str): URL to artwork image (/library/metadata//art/). - artBlurHash (str): BlurHash string for artwork image. - childCount (int): Number of items in the collection. - collectionMode (str): How the items in the collection are displayed. - collectionSort (str): How to sort the items in the 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). - index (int): Plex index number for the collection. - key (str): API URL (/library/metadata/). - labels (List<:class:`~plexapi.media.Label`>): List of label objects. - librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. - librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. - librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. - maxYear (int): Maximum year for the items in the collection. - minYear (int): Minimum year for the items in the collection. - ratingKey (int): Unique key identifying the collection. - subtype (str): Media type of the items in the collection (movie, show, artist, or album). - summary (str): Summary of the collection. - thumb (str): URL to thumbnail image (/library/metadata//thumb/). - thumbBlurHash (str): BlurHash string for thumbnail image. - title (str): Name of the collection. - titleSort (str): Title to use when sorting (defaults to title). - type (str): 'collection' - updatedAt (datatime): Datetime the collection was updated. - """ - - TAG = 'Directory' - TYPE = 'collection' - - def _loadData(self, data): - self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) - self.art = data.attrib.get('art') - self.artBlurHash = data.attrib.get('artBlurHash') - self.childCount = utils.cast(int, data.attrib.get('childCount')) - self.collectionMode = data.attrib.get('collectionMode') - self.collectionSort = data.attrib.get('collectionSort') - self.contentRating = data.attrib.get('contentRating') - self.fields = self.findItems(data, media.Field) - self.guid = data.attrib.get('guid') - 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) - self.librarySectionID = data.attrib.get('librarySectionID') - self.librarySectionKey = data.attrib.get('librarySectionKey') - self.librarySectionTitle = data.attrib.get('librarySectionTitle') - self.maxYear = utils.cast(int, data.attrib.get('maxYear')) - self.minYear = utils.cast(int, data.attrib.get('minYear')) - self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) - self.subtype = data.attrib.get('subtype') - self.summary = data.attrib.get('summary') - self.thumb = data.attrib.get('thumb') - self.thumbBlurHash = data.attrib.get('thumbBlurHash') - self.title = data.attrib.get('title') - self.titleSort = data.attrib.get('titleSort', self.title) - self.type = data.attrib.get('type') - self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) - - @property - @deprecated('use "items" instead') - def children(self): - return self.fetchItems(self.key) - - @property - def thumbUrl(self): - """ Return the thumbnail url for the collection.""" - return self._server.url(self.thumb, includeToken=True) if self.thumb else None - - @property - def artUrl(self): - """ Return the art url for the collection.""" - return self._server.url(self.art, includeToken=True) if self.art else None - - def item(self, title): - """ Returns the item in the collection that matches the specified title. - - Parameters: - title (str): Title of the item to return. - """ - key = '/library/metadata/%s/children' % self.ratingKey - return self.fetchItem(key, title__iexact=title) - - def items(self): - """ Returns a list of all items in the collection. """ - key = '/library/metadata/%s/children' % self.ratingKey - return self.fetchItems(key) - - def get(self, title): - """ Alias to :func:`~plexapi.library.Collection.item`. """ - return self.item(title) - - def __len__(self): - return self.childCount - - def _preferences(self): - """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ - items = [] - data = self._server.query(self._details_key) - for item in data.iter('Setting'): - items.append(Setting(data=item, server=self._server)) - - return items - - def delete(self): - part = '/library/metadata/%s' % self.ratingKey - return self._server.query(part, method=self._server._session.delete) - - def modeUpdate(self, mode=None): - """ Update Collection Mode - - Parameters: - mode: default (Library default) - hide (Hide Collection) - hideItems (Hide Items in this Collection) - showItems (Show this Collection and its Items) - Example: - - collection = 'plexapi.library.Collections' - collection.updateMode(mode="hide") - """ - mode_dict = {'default': '-1', - 'hide': '0', - 'hideItems': '1', - 'showItems': '2'} - key = mode_dict.get(mode) - if key is None: - raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict))) - part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key) - return self._server.query(part, method=self._server._session.put) - - def sortUpdate(self, sort=None): - """ Update Collection Sorting - - Parameters: - sort: realease (Order Collection by realease dates) - alpha (Order Collection Alphabetically) - - Example: - - colleciton = 'plexapi.library.Collections' - collection.updateSort(mode="alpha") - """ - sort_dict = {'release': '0', - 'alpha': '1'} - key = sort_dict.get(sort) - if key is None: - raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) - part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) - return self._server.query(part, method=self._server._session.put) - - # def edit(self, **kwargs): - # TODO - - @utils.registerPlexObject class Path(PlexObject): """ Represents a single directory Path. diff --git a/plexapi/server.py b/plexapi/server.py index 42c92fdc..d3423c57 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -19,7 +19,8 @@ from plexapi.utils import cast from requests.status_codes import _codes as codes # Need these imports to populate utils.PLEXOBJECTS -from plexapi import audio as _audio # noqa: F401; noqa: F401 +from plexapi import audio as _audio # noqa: F401 +from plexapi import collection as _collection # noqa: F401 from plexapi import media as _media # noqa: F401 from plexapi import photo as _photo # noqa: F401 from plexapi import playlist as _playlist # noqa: F401 From e84e922c9332caa1579e972500dede41ed01e592 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 22:33:38 -0800 Subject: [PATCH 71/96] Move collection tests to new module --- tests/test_collection.py | 111 +++++++++++++++++++++++++++++++++++++++ tests/test_library.py | 80 ---------------------------- 2 files changed, 111 insertions(+), 80 deletions(-) create mode 100644 tests/test_collection.py diff --git a/tests/test_collection.py b/tests/test_collection.py new file mode 100644 index 00000000..272da550 --- /dev/null +++ b/tests/test_collection.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +from . import conftest as utils +from . import test_mixins + + +def test_Collection_attrs(collection): + assert utils.is_datetime(collection.addedAt) + assert collection.art is None + assert collection.artBlurHash is None + assert collection.childCount == 1 + assert collection.collectionMode == -1 + assert collection.collectionSort == 0 + assert collection.contentRating + assert not collection.fields + assert collection.guid.startswith("collection://") + assert utils.is_int(collection.index) + assert collection.key.startswith("/library/collections/") + assert not collection.labels + assert utils.is_int(collection.librarySectionID) + assert collection.librarySectionKey == "/library/sections/%s" % collection.librarySectionID + assert collection.librarySectionTitle == "Movies" + assert collection.maxYear is None + assert collection.minYear is None + assert utils.is_int(collection.ratingKey) + assert collection.subtype == "movie" + assert collection.summary == "" + assert collection.thumb.startswith("/library/collections/%s/composite" % collection.ratingKey) + assert collection.thumbBlurHash is None + assert collection.title == "marvel" + assert collection.titleSort == collection.title + assert collection.type == "collection" + assert utils.is_datetime(collection.updatedAt) + + +def test_Collection_modeUpdate(collection): + mode_dict = {"default": "-1", "hide": "0", "hideItems": "1", "showItems": "2"} + for key, value in mode_dict.items(): + collection.modeUpdate(key) + collection.reload() + assert collection.collectionMode == value + + +def test_Colletion_sortAlpha(collection): + collection.sortUpdate(sort="alpha") + collection.reload() + assert collection.collectionSort == "1" + + +def test_Colletion_sortRelease(collection): + collection.sortUpdate(sort="release") + collection.reload() + assert collection.collectionSort == "0" + + +def test_Colletion_edit(collection): + edits = {'titleSort.value': 'New Title Sort', 'titleSort.locked': 1} + collectionTitleSort = collection.titleSort + collection.edit(**edits) + collection.reload() + for field in collection.fields: + if field.name == 'titleSort': + assert collection.titleSort == 'New Title Sort' + assert field.locked is True + collection.edit(**{'titleSort.value': collectionTitleSort, 'titleSort.locked': 0}) + + +def test_Collection_delete(movies, movie): + delete_collection = 'delete_collection' + movie.addCollection(delete_collection) + collections = movies.collections(title=delete_collection) + assert len(collections) == 1 + collections[0].delete() + collections = movies.collections(title=delete_collection) + assert len(collections) == 0 + + +def test_Collection_item(collection): + item1 = collection.item("Elephants Dream") + assert item1.title == "Elephants Dream" + item2 = collection.get("Elephants Dream") + assert item2.title == "Elephants Dream" + assert item1 == item2 + + +def test_Collection_items(collection): + items = collection.items() + assert len(items) == 1 + + +def test_Collection_thumbUrl(collection): + assert utils.SERVER_BASEURL in collection.thumbUrl + assert "/library/collections/" in collection.thumbUrl + assert "/composite/" in collection.thumbUrl + + +def test_Collection_artUrl(collection): + assert collection.artUrl is None # Collections don't have default art + + +def test_Collection_posters(collection): + posters = collection.posters() + assert posters + + +def test_Collection_art(collection): + arts = collection.arts() + assert not arts # Collection has no default art + + +def test_Collection_mixins_tags(collection): + test_mixins.edit_label(collection) diff --git a/tests/test_library.py b/tests/test_library.py index b8602def..7e5a91af 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -4,7 +4,6 @@ import pytest from plexapi.exceptions import NotFound from . import conftest as utils -from . import test_mixins def test_library_Library_section(plex): @@ -259,85 +258,6 @@ def test_library_editAdvanced_default(movies): assert str(setting.value) == str(setting.default) -def test_library_Collection_modeUpdate(collection): - mode_dict = {"default": "-1", "hide": "0", "hideItems": "1", "showItems": "2"} - for key, value in mode_dict.items(): - collection.modeUpdate(key) - collection.reload() - assert collection.collectionMode == value - - -def test_library_Colletion_sortAlpha(collection): - collection.sortUpdate(sort="alpha") - collection.reload() - assert collection.collectionSort == "1" - - -def test_library_Colletion_sortRelease(collection): - collection.sortUpdate(sort="release") - collection.reload() - assert collection.collectionSort == "0" - - -def test_library_Colletion_edit(collection): - edits = {'titleSort.value': 'New Title Sort', 'titleSort.locked': 1} - collectionTitleSort = collection.titleSort - collection.edit(**edits) - collection.reload() - for field in collection.fields: - if field.name == 'titleSort': - assert collection.titleSort == 'New Title Sort' - assert field.locked is True - collection.edit(**{'titleSort.value': collectionTitleSort, 'titleSort.locked': 0}) - - -def test_library_Collection_delete(movies, movie): - delete_collection = 'delete_collection' - movie.addCollection(delete_collection) - collections = movies.collections(title=delete_collection) - assert len(collections) == 1 - collections[0].delete() - collections = movies.collections(title=delete_collection) - assert len(collections) == 0 - - -def test_library_Collection_item(collection): - item1 = collection.item("Elephants Dream") - assert item1.title == "Elephants Dream" - item2 = collection.get("Elephants Dream") - assert item2.title == "Elephants Dream" - assert item1 == item2 - - -def test_library_Collection_items(collection): - items = collection.items() - assert len(items) == 1 - - -def test_library_Collection_thumbUrl(collection): - assert utils.SERVER_BASEURL in collection.thumbUrl - assert "/library/collections/" in collection.thumbUrl - assert "/composite/" in collection.thumbUrl - - -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) - - def test_search_with_weird_a(plex): ep_title = "Coup de Grâce" result_root = plex.search(ep_title) From 88dbee7508fce27c02f2b9e06517affaaf383712 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 22:41:17 -0800 Subject: [PATCH 72/96] Fix flake8 --- plexapi/audio.py | 3 ++- plexapi/video.py | 3 ++- tests/test_audio.py | 2 -- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 6f052e31..caecfbe7 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -4,7 +4,8 @@ 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 ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin +from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin diff --git a/plexapi/video.py b/plexapi/video.py index cc03e673..e32deca1 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +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 ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin +from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin diff --git a/tests/test_audio.py b/tests/test_audio.py index 6377c935..d6a0d24b 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -1,6 +1,4 @@ # -*- coding: utf-8 -*- -from datetime import datetime - from . import conftest as utils from . import test_mixins From dd0236eb9a4408954d399c6bcc5eab493fede655 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 22:43:44 -0800 Subject: [PATCH 73/96] Fix collections tests --- tests/test_collection.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_collection.py b/tests/test_collection.py index 272da550..d540636f 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -19,8 +19,8 @@ def test_Collection_attrs(collection): assert utils.is_int(collection.librarySectionID) assert collection.librarySectionKey == "/library/sections/%s" % collection.librarySectionID assert collection.librarySectionTitle == "Movies" - assert collection.maxYear is None - assert collection.minYear is None + assert utils.is_int(collection.maxYear) + assert utils.is_int(collection.minYear) assert utils.is_int(collection.ratingKey) assert collection.subtype == "movie" assert collection.summary == "" @@ -33,7 +33,7 @@ def test_Collection_attrs(collection): def test_Collection_modeUpdate(collection): - mode_dict = {"default": "-1", "hide": "0", "hideItems": "1", "showItems": "2"} + mode_dict = {"default": -1, "hide": 0, "hideItems": 1, "showItems": 2} for key, value in mode_dict.items(): collection.modeUpdate(key) collection.reload() @@ -43,13 +43,13 @@ def test_Collection_modeUpdate(collection): def test_Colletion_sortAlpha(collection): collection.sortUpdate(sort="alpha") collection.reload() - assert collection.collectionSort == "1" + assert collection.collectionSort == 1 def test_Colletion_sortRelease(collection): collection.sortUpdate(sort="release") collection.reload() - assert collection.collectionSort == "0" + assert collection.collectionSort == 0 def test_Colletion_edit(collection): From 529d16cbabde2981260d0856ff2e629ecc77ad93 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 22:54:31 -0800 Subject: [PATCH 74/96] Update collections mode and sort tests --- tests/test_collection.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/test_collection.py b/tests/test_collection.py index d540636f..f86d368a 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +import pytest +from plexapi.exceptions import BadRequest + from . import conftest as utils from . import test_mixins @@ -35,21 +38,21 @@ def test_Collection_attrs(collection): def test_Collection_modeUpdate(collection): mode_dict = {"default": -1, "hide": 0, "hideItems": 1, "showItems": 2} for key, value in mode_dict.items(): - collection.modeUpdate(key) + collection.modeUpdate(mode=key) collection.reload() assert collection.collectionMode == value + with pytest.raises(BadRequest): + collection.modeUpdate(mode="bad-mode") -def test_Colletion_sortAlpha(collection): - collection.sortUpdate(sort="alpha") - collection.reload() - assert collection.collectionSort == 1 - - -def test_Colletion_sortRelease(collection): - collection.sortUpdate(sort="release") - collection.reload() - assert collection.collectionSort == 0 +def test_Colletion_sortUpdate(collection): + sort_dict = {'release': 0, 'alpha': 1, 'custom': 2} + for key, value in sort_dict.items(): + collection.sortUpdate(sort="alpha") + collection.reload() + assert collection.collectionSort == value + with pytest.raises(BadRequest): + collection.sortUpdate(sort="bad-sort") def test_Colletion_edit(collection): From 92d10bac17bb0336acb12fc31d4318d7146ab507 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 22:58:48 -0800 Subject: [PATCH 75/96] Add photos mixins tests --- tests/conftest.py | 5 +++++ tests/test_photo.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index ae16a5a1..059d9ffd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -276,6 +276,11 @@ def photoalbum(photos): return photos.get("photo_album1") +@pytest.fixture() +def photo(photoalbum): + return photoalbum.photo("photo1") + + @pytest.fixture() def subtitle(): mopen = mock_open() diff --git a/tests/test_photo.py b/tests/test_photo.py index b4eadb98..ebe85ff7 100644 --- a/tests/test_photo.py +++ b/tests/test_photo.py @@ -1,3 +1,7 @@ +# -*- coding: utf-8 -*- +from . import test_mixins + + def test_photo_Photoalbum(photoalbum): assert len(photoalbum.albums()) == 3 assert len(photoalbum.photos()) == 3 @@ -5,3 +9,14 @@ def test_photo_Photoalbum(photoalbum): assert len(cats_in_bed.photos()) == 7 a_pic = cats_in_bed.photo("photo7") assert a_pic + + +def test_photo_Photoalbum_mixins_images(photoalbum): + test_mixins.edit_art(photoalbum) + test_mixins.edit_poster(photoalbum) + test_mixins.attr_artUrl(photoalbum) + test_mixins.attr_posterUrl(photoalbum) + + +def test_photo_Photo_mixins_tags(photo): + test_mixins.edit_tag(photo) From 94c0362376946cb6c4a8ed2ef0889214aab14ce1 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 22:59:42 -0800 Subject: [PATCH 76/96] Replace quotes for consistency --- tests/test_collection.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_collection.py b/tests/test_collection.py index f86d368a..0922b31e 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -46,7 +46,7 @@ def test_Collection_modeUpdate(collection): def test_Colletion_sortUpdate(collection): - sort_dict = {'release': 0, 'alpha': 1, 'custom': 2} + sort_dict = {"release": 0, "alpha": 1, "custom": 2} for key, value in sort_dict.items(): collection.sortUpdate(sort="alpha") collection.reload() @@ -56,19 +56,19 @@ def test_Colletion_sortUpdate(collection): def test_Colletion_edit(collection): - edits = {'titleSort.value': 'New Title Sort', 'titleSort.locked': 1} + edits = {"titleSort.value": "New Title Sort", "titleSort.locked": 1} collectionTitleSort = collection.titleSort collection.edit(**edits) collection.reload() for field in collection.fields: - if field.name == 'titleSort': - assert collection.titleSort == 'New Title Sort' + if field.name == "titleSort": + assert collection.titleSort == "New Title Sort" assert field.locked is True - collection.edit(**{'titleSort.value': collectionTitleSort, 'titleSort.locked': 0}) + collection.edit(**{"titleSort.value": collectionTitleSort, "titleSort.locked": 0}) def test_Collection_delete(movies, movie): - delete_collection = 'delete_collection' + delete_collection = "delete_collection" movie.addCollection(delete_collection) collections = movies.collections(title=delete_collection) assert len(collections) == 1 From 031eb787112d9ba44d03cf6865092df6d2391c3b Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 23:05:32 -0800 Subject: [PATCH 77/96] Fix photoalbum mixins image test --- tests/test_mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index f601a2f3..cb7549b6 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -86,7 +86,7 @@ def _test_mixins_image(obj, attr): default_image = images[0] image = images[0] assert len(image.key) >= 10 - if not image.ratingKey.startswith(("default://", "media://", "upload://")): + if not image.ratingKey.startswith(("default://", "id://", "media://", "upload://")): assert image.provider assert len(image.ratingKey) >= 10 assert utils.is_bool(image.selected) From 8631fdaf362703149862c83e2b82801a9fc3d619 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 23:06:34 -0800 Subject: [PATCH 78/96] Fix collection sort update test --- tests/test_collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_collection.py b/tests/test_collection.py index 0922b31e..058108ab 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -48,7 +48,7 @@ def test_Collection_modeUpdate(collection): def test_Colletion_sortUpdate(collection): sort_dict = {"release": 0, "alpha": 1, "custom": 2} for key, value in sort_dict.items(): - collection.sortUpdate(sort="alpha") + collection.sortUpdate(sort=key) collection.reload() assert collection.collectionSort == value with pytest.raises(BadRequest): From eb68b6446facd16a9981f9ba774aa92d8b597207 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 23:27:18 -0800 Subject: [PATCH 79/96] Test delete collection with a different movie --- tests/test_collection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_collection.py b/tests/test_collection.py index 058108ab..b3609b3c 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -67,8 +67,9 @@ def test_Colletion_edit(collection): collection.edit(**{"titleSort.value": collectionTitleSort, "titleSort.locked": 0}) -def test_Collection_delete(movies, movie): +def test_Collection_delete(movies): delete_collection = "delete_collection" + movie = movies.get("Sita Sings the Blues") movie.addCollection(delete_collection) collections = movies.collections(title=delete_collection) assert len(collections) == 1 From 094bfcef43db2489b22fbabf93dadbddec65f35d Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 23:41:17 -0800 Subject: [PATCH 80/96] Pytest collection explicit collection name --- tests/conftest.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5f676fe7..2039a077 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -234,13 +234,11 @@ def movie(movies): @pytest.fixture() def collection(movies): try: - return movies.collections()[0] + return movies.collections(title="marvel")[0] except IndexError: movie = movies.get("Elephants Dream") - movie.addCollection(["marvel"]) - - n = movies.reload() - return n.collections()[0] + movie.addCollection("marvel") + return movies.collections(title="marvel")[0] @pytest.fixture() From 18e2f15b576620befac60fbb8f763842dd44652d Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 23:48:58 -0800 Subject: [PATCH 81/96] Reset collection mode and sort after test --- tests/test_collection.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_collection.py b/tests/test_collection.py index b3609b3c..04b10195 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -43,9 +43,10 @@ def test_Collection_modeUpdate(collection): assert collection.collectionMode == value with pytest.raises(BadRequest): collection.modeUpdate(mode="bad-mode") + collection.modeUpdate("default") -def test_Colletion_sortUpdate(collection): +def test_Collection_sortUpdate(collection): sort_dict = {"release": 0, "alpha": 1, "custom": 2} for key, value in sort_dict.items(): collection.sortUpdate(sort=key) @@ -53,9 +54,10 @@ def test_Colletion_sortUpdate(collection): assert collection.collectionSort == value with pytest.raises(BadRequest): collection.sortUpdate(sort="bad-sort") + collection.sortUpdate("release") -def test_Colletion_edit(collection): +def test_Collection_edit(collection): edits = {"titleSort.value": "New Title Sort", "titleSort.locked": 1} collectionTitleSort = collection.titleSort collection.edit(**edits) From 858fb18f7f3c204a429e7400031c787dfccb8012 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 14 Feb 2021 23:54:17 -0800 Subject: [PATCH 82/96] Change movie for collection delete test --- tests/test_collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_collection.py b/tests/test_collection.py index 04b10195..18f93caa 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -71,7 +71,7 @@ def test_Collection_edit(collection): def test_Collection_delete(movies): delete_collection = "delete_collection" - movie = movies.get("Sita Sings the Blues") + movie = movies.get("Sintel") movie.addCollection(delete_collection) collections = movies.collections(title=delete_collection) assert len(collections) == 1 From 8af8ed9d1ae039748e00d89381b139e7e4025a74 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 15 Feb 2021 16:51:37 -0800 Subject: [PATCH 83/96] Fix deprecation warnings --- plexapi/library.py | 4 ++-- plexapi/utils.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/plexapi/library.py b/plexapi/library.py index 8a6413f6..51006b06 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -838,7 +838,7 @@ class LibrarySection(PlexObject): """ return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1) - @deprecated('use "collections" (plural) instead') + @deprecated('use "collections" (plural) instead', stacklevel=2) def collection(self, **kwargs): return self.collections() @@ -1596,7 +1596,7 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): @property @deprecated('use "items" instead') def children(self): - return self.fetchItems(self.key) + return self.items() @property def thumbUrl(self): diff --git a/plexapi/utils.py b/plexapi/utils.py index a56d87ab..03ff8efb 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -21,7 +21,6 @@ except ImportError: tqdm = None log = logging.getLogger('plexapi') -warnings.simplefilter('default', category=DeprecationWarning) # Search Types - Plex uses these to filter specific media types when searching. # Library Types - Populated at runtime @@ -467,7 +466,7 @@ def base64str(text): return base64.b64encode(text.encode('utf-8')).decode('utf-8') -def deprecated(message): +def deprecated(message, stacklevel=3): def decorator(func): """This is a decorator which can be used to mark functions as deprecated. It will result in a warning being emitted @@ -475,7 +474,7 @@ def deprecated(message): @functools.wraps(func) def wrapper(*args, **kwargs): msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message) - warnings.warn(msg, category=DeprecationWarning, stacklevel=3) + warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel) log.warning(msg) return func(*args, **kwargs) return wrapper From 110fb2e94c5c374d3c61f5208cb88c8853d7039a Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 15 Feb 2021 16:57:16 -0800 Subject: [PATCH 84/96] Flip default warning stack level --- plexapi/library.py | 4 ++-- plexapi/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plexapi/library.py b/plexapi/library.py index 51006b06..e72e4d86 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -838,7 +838,7 @@ class LibrarySection(PlexObject): """ return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1) - @deprecated('use "collections" (plural) instead', stacklevel=2) + @deprecated('use "collections" (plural) instead') def collection(self, **kwargs): return self.collections() @@ -1594,7 +1594,7 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) @property - @deprecated('use "items" instead') + @deprecated('use "items" instead', stacklevel=3) def children(self): return self.items() diff --git a/plexapi/utils.py b/plexapi/utils.py index 03ff8efb..e4225941 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -466,7 +466,7 @@ def base64str(text): return base64.b64encode(text.encode('utf-8')).decode('utf-8') -def deprecated(message, stacklevel=3): +def deprecated(message, stacklevel=2): def decorator(func): """This is a decorator which can be used to mark functions as deprecated. It will result in a warning being emitted From 72244f558d2030fd44ca2e1adf93706b86bdf24d Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 15 Feb 2021 17:04:50 -0800 Subject: [PATCH 85/96] Fix deprecated collection.children to items --- plexapi/collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/collection.py b/plexapi/collection.py index 37641bf1..4a07a20a 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -77,7 +77,7 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): @property @deprecated('use "items" instead') def children(self): - return self.fetchItems(self.key) + return self.items() @property def thumbUrl(self): From 61dc373061c3aeac14b86ae9499d5fae3c956fba Mon Sep 17 00:00:00 2001 From: Sascha Montellese Date: Tue, 16 Feb 2021 20:45:10 +0100 Subject: [PATCH 86/96] Fix gdm.GDM.find_by_content_type() (#668) * Remove unused last_scan attribute of gdm.GDM * Add missing documentation of entries attribute to gdm.GDM * Fix gdm.GDM.find_by_content_type() --- plexapi/gdm.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plexapi/gdm.py b/plexapi/gdm.py index 9610bb0d..b2214e9e 100644 --- a/plexapi/gdm.py +++ b/plexapi/gdm.py @@ -13,11 +13,14 @@ import struct class GDM: - """Base class to discover GDM services.""" + """Base class to discover GDM services. + + Atrributes: + entries (List): List of server and/or client data discovered. + """ def __init__(self): self.entries = [] - self.last_scan = None def scan(self, scan_for_clients=False): """Scan the network.""" @@ -35,7 +38,7 @@ class GDM: """Return a list of entries that match the content_type.""" self.scan() return [entry for entry in self.entries - if value in entry['data']['Content_Type']] + if value in entry['data']['Content-Type']] def find_by_data(self, values): """Return a list of entries that match the search parameters.""" From ee81ebcf93e25516c9b6fdbd40f9fe0a23b1075e Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 24 Feb 2021 09:07:40 -0800 Subject: [PATCH 87/96] Rename collection tests --- tests/test_collection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_collection.py b/tests/test_collection.py index 38d81053..dbb0d64f 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -93,22 +93,22 @@ def test_Collection_items(collection): assert len(items) == 1 -def test_library_Collection_posters(collection): +def test_Collection_posters(collection): posters = collection.posters() assert posters -def test_library_Collection_art(collection): +def test_Collection_art(collection): arts = collection.arts() assert not arts # Collection has no default art -def test_library_Collection_mixins_images(collection): +def test_Collection_mixins_images(collection): test_mixins.edit_art(collection) test_mixins.edit_poster(collection) test_mixins.attr_artUrl(collection) test_mixins.attr_posterUrl(collection) -def test_library_Collection_mixins_tags(collection): +def test_Collection_mixins_tags(collection): test_mixins.edit_label(collection) From 12cf146ace917b079f580a1393015f7189b5e29b Mon Sep 17 00:00:00 2001 From: Shubhendra Singh Chauhan Date: Wed, 24 Feb 2021 23:25:53 +0530 Subject: [PATCH 88/96] fix: code quality issues (#670) * Remove unnecessary use of comprehension * Remove unnecessary comprehension * Use literal syntax instead of function calls to create data structure * Pass string format arguments as logging method parameters * Remove unused imports * Remove unnecessary generator * Refactor `if` expression * fixed typo Co-authored-by: jjlawren * Update tests/test_audio.py Co-authored-by: jjlawren --- plexapi/alert.py | 2 +- plexapi/base.py | 4 ++-- plexapi/library.py | 2 +- plexapi/settings.py | 4 ++-- plexapi/utils.py | 2 +- tests/test_history.py | 6 ------ tests/test_video.py | 2 +- tools/plex-backupwatched.py | 4 ++-- 8 files changed, 10 insertions(+), 16 deletions(-) diff --git a/plexapi/alert.py b/plexapi/alert.py index bf6e5394..9e0310fd 100644 --- a/plexapi/alert.py +++ b/plexapi/alert.py @@ -84,4 +84,4 @@ class AlertListener(threading.Thread): This is to support compatibility with current and previous releases of websocket-client. """ err = args[-1] - log.error('AlertListener Error: %s' % err) + log.error('AlertListener Error: %s', err) diff --git a/plexapi/base.py b/plexapi/base.py index 9c3446be..96dbad60 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -397,7 +397,7 @@ class PlexPartialObject(PlexObject): clsname = self.__class__.__name__ title = self.__dict__.get('title', self.__dict__.get('name')) objname = "%s '%s'" % (clsname, title) if title else clsname - log.debug("Reloading %s for attr '%s'" % (objname, attr)) + log.debug("Reloading %s for attr '%s'", objname, attr) # Reload and return the value self.reload() return super(PlexPartialObject, self).__getattribute__(attr) @@ -501,7 +501,7 @@ class PlexPartialObject(PlexObject): return self._server.query(self.key, method=self._server._session.delete) except BadRequest: # pragma: no cover log.error('Failed to delete %s. This could be because you ' - 'havnt allowed items to be deleted' % self.key) + 'have not allowed items to be deleted', self.key) raise def history(self, maxresults=9999999, mindate=None): diff --git a/plexapi/library.py b/plexapi/library.py index 85e4a144..206eb118 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -723,7 +723,7 @@ class LibrarySection(PlexObject): result = set() choices = self.listChoices(category, libtype) lookup = {c.title.lower(): unquote(unquote(c.key)) for c in choices} - allowed = set(c.key for c in choices) + allowed = {c.key for c in choices} for item in value: item = str((item.id or item.tag) if isinstance(item, media.MediaTag) else item).lower() # find most logical choice(s) to use in url diff --git a/plexapi/settings.py b/plexapi/settings.py index 8416f871..734cc119 100644 --- a/plexapi/settings.py +++ b/plexapi/settings.py @@ -44,7 +44,7 @@ class Settings(PlexObject): def all(self): """ Returns a list of all :class:`~plexapi.settings.Setting` objects available. """ - return list(v for id, v in sorted(self._settings.items())) + return [v for id, v in sorted(self._settings.items())] def get(self, id): """ Return the :class:`~plexapi.settings.Setting` object with the specified id. """ @@ -102,7 +102,7 @@ class Setting(PlexObject): group (str): Group name this setting is categorized as. enumValues (list,dict): List or dictionary of valis values for this setting. """ - _bool_cast = lambda x: True if x == 'true' or x == '1' else False + _bool_cast = lambda x: bool(x == 'true' or x == '1') _bool_str = lambda x: str(x).lower() TYPES = { 'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str}, diff --git a/plexapi/utils.py b/plexapi/utils.py index e4225941..be07672e 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -176,7 +176,7 @@ def threaded(callback, listargs): threads[-1].setDaemon(True) threads[-1].start() while not job_is_done_event.is_set(): - if all([not t.is_alive() for t in threads]): + if all(not t.is_alive() for t in threads): break time.sleep(0.05) diff --git a/tests/test_history.py b/tests/test_history.py index af5c65fe..bc23eb1b 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,10 +1,4 @@ # -*- coding: utf-8 -*- -from datetime import datetime - -import pytest -from plexapi.exceptions import BadRequest, NotFound - -from . import conftest as utils def test_history_Movie(movie): diff --git a/tests/test_video.py b/tests/test_video.py index 4eba01b0..a2e61701 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -134,7 +134,7 @@ def test_video_Movie_upload_select_remove_subtitle(movie, subtitle): assert subname in subtitles subtitleSelection = movie.subtitleStreams()[0] - parts = [part for part in movie.iterParts()] + parts = list(movie.iterParts()) parts[0].setDefaultSubtitleStream(subtitleSelection) movie.reload() diff --git a/tools/plex-backupwatched.py b/tools/plex-backupwatched.py index 5307e312..4cacd116 100755 --- a/tools/plex-backupwatched.py +++ b/tools/plex-backupwatched.py @@ -51,7 +51,7 @@ def _iter_items(section): def backup_watched(plex, opts): """ Backup watched status to the specified filepath. """ - data = defaultdict(lambda: dict()) + data = defaultdict(lambda: {}) for section in _iter_sections(plex, opts): print('Fetching watched status for %s..' % section.title) skey = section.title.lower() @@ -70,7 +70,7 @@ def restore_watched(plex, opts): with open(opts.filepath, 'r') as handle: source = json.load(handle) # Find the differences - differences = defaultdict(lambda: dict()) + differences = defaultdict(lambda: {}) for section in _iter_sections(plex, opts): print('Finding differences in %s..' % section.title) skey = section.title.lower() From 2cde3a11b4c5476c709b2029ef39627c45be73ee Mon Sep 17 00:00:00 2001 From: Jason Lawrence Date: Wed, 24 Feb 2021 12:44:15 -0600 Subject: [PATCH 89/96] Bump to 4.4.0 --- plexapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/__init__.py b/plexapi/__init__.py index 133641b4..86e21077 100644 --- a/plexapi/__init__.py +++ b/plexapi/__init__.py @@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH) # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '4.3.1' +VERSION = '4.4.0' TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool) From 92490a2cdb79bdfefc2ddd7e36b83586a6ab286e Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 26 Feb 2021 22:51:22 -0800 Subject: [PATCH 90/96] Update sharing doc strings --- plexapi/myplex.py | 62 +++++++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index b2190c74..84970daf 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -237,19 +237,21 @@ class MyPlexAccount(PlexObject): """ Share library content with the specified user. Parameters: - user (str): MyPlexUser, username, email of the user to be added. - server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. - sections ([Section]): Library sections, names or ids to be shared (default None). - [Section] must be defined in order to update shared sections. + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be added. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. allowChannels (Bool): Set True to allow user to utilize installed channels. filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of - values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of - values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. - ex: {'label':['foo']} + ex: `{'label':['foo']}` """ username = user.username if isinstance(user, MyPlexUser) else user machineId = server.machineIdentifier if isinstance(server, PlexServer) else server @@ -275,18 +277,21 @@ class MyPlexAccount(PlexObject): """ Share library content with the specified user. Parameters: - user (str): MyPlexUser, username, email of the user to be added. - server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. - sections ([Section]): Library sections, names or ids to be shared (default None shares all sections). + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be added. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. allowChannels (Bool): Set True to allow user to utilize installed channels. filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of - values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of - values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. - ex: {'label':['foo']} + ex: `{'label':['foo']}` """ machineId = server.machineIdentifier if isinstance(server, PlexServer) else server sectionIds = self._getSectionIds(server, sections) @@ -321,18 +326,21 @@ class MyPlexAccount(PlexObject): """ Share library content with the specified user. Parameters: - user (str): MyPlexUser, username, email of the user to be added. - server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. - sections ([Section]): Library sections, names or ids to be shared (default None shares all sections). + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be added. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. allowChannels (Bool): Set True to allow user to utilize installed channels. filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of - values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of - values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. - ex: {'label':['foo']} + ex: `{'label':['foo']}` """ headers = {'Content-Type': 'application/json'} # If user already exists, carry over sections and settings. @@ -392,20 +400,22 @@ class MyPlexAccount(PlexObject): """ Update the specified user's share settings. Parameters: - user (str): MyPlexUser, username, email of the user to be added. - server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. - sections: ([Section]): Library sections, names or ids to be shared (default None). - [Section] must be defined in order to update shared sections. + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be updated. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. removeSections (Bool): Set True to remove all shares. Supersedes sections. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. allowChannels (Bool): Set True to allow user to utilize installed channels. filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of - values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of - values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. - ex: {'label':['foo']} + ex: `{'label':['foo']}` """ # Update friend servers response_filters = '' From 8bba39989bbe5c053d2b426e6e1f9b23851c9785 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 26 Feb 2021 23:01:00 -0800 Subject: [PATCH 91/96] Add tagline attribute to show --- plexapi/video.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plexapi/video.py b/plexapi/video.py index e32deca1..563969ff 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -399,6 +399,7 @@ class Show(Video, ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMa roles (List<:class:`~plexapi.media.Role`>): List of role objects. similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment). + tagline (str): Show tag line. theme (str): URL to theme resource (/library/metadata//theme/). viewedLeafCount (int): Number of items marked as played in the show view. year (int): Year the show was released. @@ -427,6 +428,7 @@ class Show(Video, ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMa self.roles = self.findItems(data, media.Role) self.similar = self.findItems(data, media.Similar) self.studio = data.attrib.get('studio') + self.tagline = data.attrib.get('tagline') self.theme = data.attrib.get('theme') self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) From 75e3d4c53c3ff3878c3f8f7397895369a2d2dbcb Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 26 Feb 2021 23:02:38 -0800 Subject: [PATCH 92/96] Add show tag line to tests --- tests/test_video.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_video.py b/tests/test_video.py index a2e61701..c537473f 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -588,6 +588,7 @@ def test_video_Show_attrs(show): assert show._server._baseurl == utils.SERVER_BASEURL assert show.studio == "HBO" assert utils.is_string(show.summary, gte=100) + assert show.tagline is None assert utils.is_metadata(show.theme, contains="/theme/") if show.thumb: assert utils.is_thumb(show.thumb) From 3701e02f0ae221b65000204d2c7aaaa49c226d07 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 27 Feb 2021 19:34:36 -0800 Subject: [PATCH 93/96] Add mixins to docs --- docs/index.rst | 1 - docs/modules/mixins.rst | 7 +++++++ docs/toc.rst | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 docs/modules/mixins.rst diff --git a/docs/index.rst b/docs/index.rst index 767bff57..5cf149fd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,6 @@ Table of Contents ================= .. include:: toc.rst -.. automodule:: myplex Usage & Contributions --------------------- diff --git a/docs/modules/mixins.rst b/docs/modules/mixins.rst new file mode 100644 index 00000000..d8e534b3 --- /dev/null +++ b/docs/modules/mixins.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +Mixins :modname:`plexapi.mixins` +-------------------------------- +.. automodule:: plexapi.mixins + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/toc.rst b/docs/toc.rst index b75f850e..47713ed5 100644 --- a/docs/toc.rst +++ b/docs/toc.rst @@ -20,6 +20,7 @@ modules/gdm modules/library modules/media + modules/mixins modules/myplex modules/photo modules/playlist From 8e43ba32496ddea329ba60cd3d652b0995125237 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 27 Feb 2021 19:34:47 -0800 Subject: [PATCH 94/96] Add collection to docs --- docs/modules/collection.rst | 7 +++++++ docs/toc.rst | 1 + 2 files changed, 8 insertions(+) create mode 100644 docs/modules/collection.rst diff --git a/docs/modules/collection.rst b/docs/modules/collection.rst new file mode 100644 index 00000000..07ff7eb5 --- /dev/null +++ b/docs/modules/collection.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +Collection :modname:`plexapi.collection` +---------------------------------------- +.. automodule:: plexapi.collection + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/toc.rst b/docs/toc.rst index 47713ed5..a0363ceb 100644 --- a/docs/toc.rst +++ b/docs/toc.rst @@ -15,6 +15,7 @@ modules/audio modules/base modules/client + modules/collection modules/config modules/exceptions modules/gdm From 10495809e770f2adf8c77b7cc9be77a966de0a5c Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:54:19 -0800 Subject: [PATCH 95/96] Fix season watched and unwatched --- plexapi/video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index 563969ff..e215a2f6 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -678,11 +678,11 @@ class Season(Video, ArtMixin, PosterMixin): def watched(self): """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ - return self.episodes(watched=True) + return self.episodes(viewCount__gt=0) def unwatched(self): """ Returns list of unwatched :class:`~plexapi.video.Episode` objects. """ - return self.episodes(watched=False) + return self.episodes(viewCount=0) def download(self, savepath=None, keep_original_name=False, **kwargs): """ Download video files to specified directory. From bdec69e4a2c8871e79c2ccc3d167a2f28696267e Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:54:43 -0800 Subject: [PATCH 96/96] Update show/season watched/unwatched tests --- tests/test_video.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/test_video.py b/tests/test_video.py index c537473f..1573dafc 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -611,17 +611,21 @@ def test_video_Show_history(show): def test_video_Show_watched(tvshows): show = tvshows.get("The 100") - show.episodes()[0].markWatched() + episode = show.episodes()[0] + episode.markWatched() watched = show.watched() assert len(watched) == 1 and watched[0].title == "Pilot" + episode.markUnwatched() def test_video_Show_unwatched(tvshows): show = tvshows.get("The 100") episodes = show.episodes() - episodes[0].markWatched() + episode = episodes[0] + episode.markWatched() unwatched = show.unwatched() assert len(unwatched) == len(episodes) - 1 + episode.markUnwatched() def test_video_Show_settings(show): @@ -885,6 +889,25 @@ def test_video_Season_history(show): season.markUnwatched() +def test_video_Season_watched(tvshows): + season = tvshows.get("The 100").season(1) + episode = season.episode(1) + episode.markWatched() + watched = season.watched() + assert len(watched) == 1 and watched[0].title == "Pilot" + episode.markUnwatched() + + +def test_video_Season_unwatched(tvshows): + season = tvshows.get("The 100").season(1) + episodes = season.episodes() + episode = episodes[0] + episode.markWatched() + unwatched = season.unwatched() + assert len(unwatched) == len(episodes) - 1 + episode.markUnwatched() + + def test_video_Season_attrs(show): season = show.season("Season 1") assert utils.is_datetime(season.addedAt)