From 4b908a8f2d8f7c87fa7fc17ef2ac24de9639de16 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Wed, 15 Jul 2020 14:37:46 -0400 Subject: [PATCH 01/55] create media.Review class --- plexapi/media.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/plexapi/media.py b/plexapi/media.py index 7a106232e..b1f095add 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -651,6 +651,26 @@ class Role(MediaTag): FILTER = 'role' +@utils.registerPlexObject +class Review(MediaTag): + """ Represents a single Review for a Movie. + + Attributes: + TAG (str): 'Review' + """ + TAG = 'Review' + + def _loadData(self, data): + self._data = data + self.id = cast(int, data.attrib.get('id', 0)) + self.filter = data.attrib.get('filter') + self.tag = data.attrib.get('tag') + self.text = data.attrib.get('text') + self.image = data.attrib.get('image') + self.link = data.attrib.get('link') + self.source = data.attrib.get('source') + + @utils.registerPlexObject class Similar(MediaTag): """ Represents a single Similar media tag. From c7f8b86ec511b2c58450deb5e64a4744eb7d668e Mon Sep 17 00:00:00 2001 From: blacktwin Date: Wed, 15 Jul 2020 14:41:09 -0400 Subject: [PATCH 02/55] add reviews method to video.Movie class --- plexapi/video.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plexapi/video.py b/plexapi/video.py index 5396d87fa..680a093bb 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -332,6 +332,15 @@ def _prettyfilename(self): # This is just for compat. return self.title + def reviews(self): + """ Returns a list of :class:`~plexapi.media.Review` objects. """ + items = [] + data = self._server.query(self.key + '?includeReviews=1') + for item in data.iter('Review'): + items.append(media.Review(data=item, server=self._server)) + + return items + def download(self, savepath=None, keep_original_name=False, **kwargs): """ Download video files to specified directory. From 7b364a5cb86ae0e707888dda3423eea6367b11df Mon Sep 17 00:00:00 2001 From: blacktwin Date: Wed, 15 Jul 2020 14:41:52 -0400 Subject: [PATCH 03/55] create Extra class in video --- plexapi/video.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/plexapi/video.py b/plexapi/video.py index 680a093bb..9ef2122de 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -793,3 +793,32 @@ def _loadData(self, data): self.title = data.attrib.get('title') self.type = data.attrib.get('type') self.year = data.attrib.get('year') + + +@utils.registerPlexObject +class Extra(Clip): + """ Represents a single Extra (trailer, behindTheScenes, etc). + + Attributes: + TAG (str): 'Extras' + """ + TAG = 'Extras' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.extraType = data.attrib.get('extraType') + self.index = data.attrib.get('index') + self.key = data.attrib.get('key', '') + self.media = self.findItems(data, media.Media) + self.originallyAvailableAt = utils.toDatetime( + data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + 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.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.year = data.attrib.get('year') From 3fcfe23d233f8a16b6b05b3d87eb998894416ed4 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Wed, 15 Jul 2020 14:42:23 -0400 Subject: [PATCH 04/55] add extras method to video.Movie class --- plexapi/video.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plexapi/video.py b/plexapi/video.py index 9ef2122de..ccaf19797 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -341,6 +341,16 @@ def reviews(self): return items + def extras(self): + """ Returns a list of :class:`~plexapi.video.Extra` objects. """ + items = [] + data = self._server.query(self._details_key) + for extra in data.iter('Extras'): + for video in extra.iter('Video'): + items.append(Extra(data=video, server=self._server)) + + return items + def download(self, savepath=None, keep_original_name=False, **kwargs): """ Download video files to specified directory. From 6f37a7b8c2abed9a5cda789a155bed63f4443192 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Wed, 15 Jul 2020 16:07:02 -0400 Subject: [PATCH 05/55] move hubs into video.Video --- plexapi/video.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index ccaf19797..68f73c47d 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -80,6 +80,12 @@ def markUnwatched(self): self._server.query(key) self.reload() + def hubs(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + data = self._server.query(self._details_key) + for item in data.iter('Related'): + return self.findItems(item, library.Hub) + def rate(self, rate): """ Rate video. """ key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rate) @@ -465,12 +471,6 @@ def preferences(self): return items - def hubs(self): - """ Returns a list of :class:`~plexapi.library.Hub` objects. """ - data = self._server.query(self._details_key) - for item in data.iter('Related'): - return self.findItems(item, library.Hub) - def onDeck(self): """ Returns shows On Deck :class:`~plexapi.video.Video` object. If show is unwatched, return will likely be the first episode. From 451b689c0d9be0017a1d201855b175b3ba643e57 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Wed, 15 Jul 2020 16:09:05 -0400 Subject: [PATCH 06/55] create MediaContainer class in base.py --- plexapi/base.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plexapi/base.py b/plexapi/base.py index 101a0b435..4b4b1fee6 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -766,3 +766,26 @@ def _loadData(self, data): self.fixed = data.attrib.get('fixed') self.downloadURL = data.attrib.get('downloadURL') self.state = data.attrib.get('state') + + +class MediaContainer(PlexObject): + """ Represents a single MediaContainer. + + Attributes: + TAG (str): 'MediaContainer' + + """ + TAG = 'MediaContainer' + + def _loadData(self, data): + self._data = data + self.allowSync = utils.cast(int, data.attrib.get('allowSync')) + self.librarySectionID = data.attrib.get('librarySectionID') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.librarySectionUUID = data.attrib.get('librarySectionUUID') + self.augmentationKey = data.attrib.get('augmentationKey') + self.identifier = data.attrib.get('identifier') + self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') + self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.size = utils.cast(int, data.attrib.get('size')) From c0454f6eb5419ab202545bcb7e72da960a74a381 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Wed, 15 Jul 2020 16:15:35 -0400 Subject: [PATCH 07/55] import MediaContainer from base --- plexapi/video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/video.py b/plexapi/video.py index 68f73c47d..c5888d59e 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -3,7 +3,7 @@ from urllib.parse import quote_plus, urlencode from plexapi import media, utils, settings, library -from plexapi.base import Playable, PlexPartialObject +from plexapi.base import Playable, PlexPartialObject, MediaContainer from plexapi.exceptions import BadRequest, NotFound From f36f54968162f9384da75e2202f1909ab12e3fdb Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 16 Jul 2020 05:27:44 -0400 Subject: [PATCH 08/55] video.Video hubs method correction --- plexapi/video.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index c5888d59e..f486375fa 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -82,9 +82,11 @@ def markUnwatched(self): def hubs(self): """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + items = [] data = self._server.query(self._details_key) - for item in data.iter('Related'): - return self.findItems(item, library.Hub) + for item in data.iter('Hub'): + items.append(library.Hub(data=item, server=self._server)) + return items def rate(self, rate): """ Rate video. """ From c46aa3b5bb0b627029c25861158ba703c6c27b56 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 16 Jul 2020 05:29:52 -0400 Subject: [PATCH 09/55] create augmentation method in video.Video --- plexapi/video.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/plexapi/video.py b/plexapi/video.py index f486375fa..cb92595c2 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -88,6 +88,19 @@ def hubs(self): items.append(library.Hub(data=item, server=self._server)) return items + def augmentation(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. + + augmentation returns hub items relating to online media sources + such as Tidal Music "Track From {item}" or "Soundtrack of {item}" + + """ + + data = self._server.query(self.key + '?asyncAugmentMetadata=1') + mediaContainer = MediaContainer(data=data, server=self._server) + augmentationKey = mediaContainer.augmentationKey + return self.fetchItems(augmentationKey) + def rate(self, rate): """ Rate video. """ key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rate) From fd89bacba244646f71f7b094ed788503a8452d2c Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 16 Jul 2020 20:59:27 -0400 Subject: [PATCH 10/55] add SETTINGS endpoint for user settings --- plexapi/myplex.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 5585c8852..e30d0a3aa 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -76,6 +76,7 @@ class MyPlexAccount(PlexObject): REQUESTS = 'https://plex.tv/api/invites/requests' # get SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data + SETTINGS = 'https://plex.tv/api/v2/user/{userUUID}/settings' # Hub sections VOD = 'https://vod.provider.plex.tv/' # get WEBSHOWS = 'https://webshows.provider.plex.tv/' # get From 50515730b08cf6e6f5e5bfab221c60a61975ef1c Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 16 Jul 2020 21:00:45 -0400 Subject: [PATCH 11/55] create onlineMediaSources method pulls in opt in/out status of Online Media Sources --- plexapi/myplex.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index e30d0a3aa..f3faaca70 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -684,6 +684,18 @@ def tidal(self): elem = ElementTree.fromstring(req.text) return self.findItems(elem) + def onlineMediaSources(self): + """ Returns an user account Online Media Sourcessettings :class:`~plexapi.myplex.AccountOptOut` + """ + services = [] + req = requests.get(self.SETTINGS.format(userUUID=self.uuid) + '/opt_outs', + headers={'X-Plex-Token': self._token, + 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER}) + elem = ElementTree.fromstring(req.text) + for item in elem.iter('optOut'): + services.append(AccountOptOut(data=item, server=self._server)) + + return services class MyPlexUser(PlexObject): """ This object represents non-signed in users such as friends and linked From 120dbc5f1c438c3ef521ad288978cb8d9456c3e2 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 16 Jul 2020 21:01:49 -0400 Subject: [PATCH 12/55] create AccountOptOut class --- plexapi/myplex.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index f3faaca70..c9d0e3358 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -1307,3 +1307,11 @@ def _chooseConnection(ctype, name, results): log.info('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token) return results[0] raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name)) + + +class AccountOptOut(PlexObject): + + TYPE = 'array' + def _loadData(self, data): + self.key = data.attrib.get('key') + self.value = data.attrib.get('value') From b917a331319e06f8fa392967a3c6e694ffb81d5a Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 16 Jul 2020 21:05:35 -0400 Subject: [PATCH 13/55] create settings method and myplex.AccountSettings class --- plexapi/myplex.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index c9d0e3358..a7b153472 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -697,6 +697,16 @@ def onlineMediaSources(self): return services + def settings(self): + """ Returns an user account settings :class:`~plexapi.myplex.AccountSettings` + """ + req = requests.get(self.SETTINGS.format(userUUID=self.uuid), + headers={'X-Plex-Token': self._token, + 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER}) + elem = ElementTree.fromstring(req.text) + for item in elem.iter('setting'): + return AccountSettings(data=item, server=self._server) + class MyPlexUser(PlexObject): """ This object represents non-signed in users such as friends and linked accounts. NOTE: This should not be confused with the :class:`~myplex.MyPlexAccount` @@ -1309,6 +1319,19 @@ def _chooseConnection(ctype, name, results): raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name)) +class AccountSettings(PlexObject): + + def _loadData(self, data): + self.id = data.attrib.get('id') + self.type = data.attrib.get('type') + self.value = eval(self.values(data.attrib.get('value'))) + self.hidden = data.attrib.get('hidden') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + + def values(self, value): + return value.replace(':false', ':False').replace(':true', ':True').replace(':null', ':None') + + class AccountOptOut(PlexObject): TYPE = 'array' From de58965a9d3982c6d7c51208d3afdd60324e8f11 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 16 Jul 2020 21:10:50 -0400 Subject: [PATCH 14/55] add check in augmentation method for Plex Pass or tidal opt-in update docstring for method --- plexapi/video.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plexapi/video.py b/plexapi/video.py index cb92595c2..c81ded8c5 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -93,9 +93,14 @@ def augmentation(self): augmentation returns hub items relating to online media sources such as Tidal Music "Track From {item}" or "Soundtrack of {item}" + Plex Pass and linked Tidal account are required """ - + account = self._server.myPlexAccount() + tidalOptOut = [service.value for service in account.onlineMediaSources() + if service.key.endswith('music')][0] + if account.subscriptionStatus != 'Active' or tidalOptOut == 'opt_out': + raise BadRequest('Requires Plex Pass and Tidal Music enabled.') data = self._server.query(self.key + '?asyncAugmentMetadata=1') mediaContainer = MediaContainer(data=data, server=self._server) augmentationKey = mediaContainer.augmentationKey From 998ed04a6c071063e5fe2e1bde640e5e694a3ae2 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 16 Jul 2020 21:21:26 -0400 Subject: [PATCH 15/55] update docstrings for AccountSettings and AccountOptOut --- plexapi/myplex.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index a7b153472..1f1a0fc5f 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -1320,6 +1320,17 @@ def _chooseConnection(ctype, name, results): class AccountSettings(PlexObject): + """ Represents a single Account Setting + 'https://plex.tv/api/v2/user/{userUUID}/settings' + + Attributes: + id (str): Unknown. "experience"? + type (str): "json" + value (dict): Lots of user server, library, + and other endpoints and settings + hidden (str): Unknown. Are these settings hidden? + updatedAt (datetime): Datetime last updated + """ def _loadData(self, data): self.id = data.attrib.get('id') @@ -1333,8 +1344,14 @@ def values(self, value): class AccountOptOut(PlexObject): + """ Represents a single AccountOptOut + 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' + + Attributes: + key (str): Online Media Source key + value (str): Online Media Source opt_in or opt_out + """ - TYPE = 'array' def _loadData(self, data): self.key = data.attrib.get('key') self.value = data.attrib.get('value') From 34e1ea344445265d4a2c4d99a6e4146e32613169 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Sun, 30 Aug 2020 01:11:26 -0400 Subject: [PATCH 16/55] master conflict resolution --- plexapi/video.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plexapi/video.py b/plexapi/video.py index c81ded8c5..835d68a2c 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -823,6 +823,13 @@ def _loadData(self, data): self.title = data.attrib.get('title') self.type = data.attrib.get('type') self.year = data.attrib.get('year') + self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + + def section(self): + """Return the :class:`~plexapi.library.LibrarySection` this item belongs to.""" + # Clip payloads currently do not contain 'librarySectionID'. + # Return None to avoid unnecessary attribute lookup attempts. + return None @utils.registerPlexObject From 1316d4ae43ac6c9c120f12a106d8b4d932dcfdc8 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Sun, 30 Aug 2020 01:18:26 -0400 Subject: [PATCH 17/55] spacing --- plexapi/myplex.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 1f1a0fc5f..7267973be 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -707,6 +707,7 @@ def settings(self): for item in elem.iter('setting'): return AccountSettings(data=item, server=self._server) + class MyPlexUser(PlexObject): """ This object represents non-signed in users such as friends and linked accounts. NOTE: This should not be confused with the :class:`~myplex.MyPlexAccount` From de83d245c8165528b7804e4687f4290c639bfa08 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Sun, 30 Aug 2020 02:02:07 -0400 Subject: [PATCH 18/55] add updateOptOut method for MyAccount.AccountOptOut class --- plexapi/myplex.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 7267973be..c53d829fc 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -688,12 +688,15 @@ def onlineMediaSources(self): """ Returns an user account Online Media Sourcessettings :class:`~plexapi.myplex.AccountOptOut` """ services = [] - req = requests.get(self.SETTINGS.format(userUUID=self.uuid) + '/opt_outs', + optOuts = self.SETTINGS.format(userUUID=self.uuid) + '/opt_outs' + req = requests.get(optOuts, headers={'X-Plex-Token': self._token, 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER}) elem = ElementTree.fromstring(req.text) for item in elem.iter('optOut'): - services.append(AccountOptOut(data=item, server=self._server)) + service = AccountOptOut(data=item, server=self._server) + service._initpath = optOuts + services.append(service) return services @@ -1353,6 +1356,15 @@ class AccountOptOut(PlexObject): value (str): Online Media Source opt_in or opt_out """ + CHOICES = ['opt_in', 'opt_out', 'opt_out_managed'] def _loadData(self, data): self.key = data.attrib.get('key') self.value = data.attrib.get('value') + + def updateOptOut(self, option): + if option not in self.CHOICES: + raise NotFound('%s not found in available choices: %s' % (option, self.CHOICES)) + if option == self.value: + raise BadRequest('OptOut option is already set to %s' % option) + url = self._initpath + '?key=%s&value=%s&' % (self.key, option) + self._server.query(url, method=self._server._session.post) From 2d08f2d1793108081c5e383b7d77689142ce5e01 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Sun, 30 Aug 2020 02:20:30 -0400 Subject: [PATCH 19/55] add test for opting out of a onlineMediaSource --- tests/test_myplex.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index d09e1ed1c..dd7851059 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -126,6 +126,20 @@ def enabled(): utils.wait_until(lambda: enabled() == (False, False)) +def test_myplex_onlineMediaSources_optOut(account): + mediaOptOut = account.onlineMediaSources()[0] + optOutValue = mediaOptOut.value + with pytest.raises(NotFound): + assert mediaOptOut.updateOptOut('what') + with pytest.raises(BadRequest): + assert mediaOptOut.updateOptOut(optOutValue) + choices = mediaOptOut.CHOICES + choices.remove(optOutValue) + mediaOptOut.updateOptOut(choices[0]) + assert account.onlineMediaSources()[0].value == choices[0] + mediaOptOut.updateOptOut(optOutValue) + + def test_myplex_inviteFriend_remove(account, plex, mocker): inv_user = "hellowlol" vid_filter = {"contentRating": ["G"], "label": ["foo"]} From aaa0a9758b8d09a0b22a8046806c8918f24db106 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Sun, 30 Aug 2020 02:35:48 -0400 Subject: [PATCH 20/55] add tests for presence of movie reviews and extras --- tests/test_video.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_video.py b/tests/test_video.py index 139031e09..12192c6c5 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -159,6 +159,8 @@ def test_video_Movie_attrs(movies): assert movie.duration >= 160000 assert movie.fields == [] assert movie.posters() + assert movie.reviews() + assert movie.extras() assert sorted([i.tag for i in movie.genres]) == [ "Animation", "Comedy", From bb9a1b25730335c044d9617e0e268ce6f81c0ae9 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 31 Aug 2020 21:12:36 -0400 Subject: [PATCH 21/55] flake fix --- plexapi/myplex.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 1b04500a6..922bfe9cb 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -1357,6 +1357,7 @@ class AccountOptOut(PlexObject): """ CHOICES = ['opt_in', 'opt_out', 'opt_out_managed'] + def _loadData(self, data): self.key = data.attrib.get('key') self.value = data.attrib.get('value') From 675cfd3f492ec13456d487c064db97fc3ba56985 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 31 Aug 2020 21:41:43 -0400 Subject: [PATCH 22/55] append original choice back in --- tests/test_myplex.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index dd7851059..2a73599f7 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -137,7 +137,8 @@ def test_myplex_onlineMediaSources_optOut(account): choices.remove(optOutValue) mediaOptOut.updateOptOut(choices[0]) assert account.onlineMediaSources()[0].value == choices[0] - mediaOptOut.updateOptOut(optOutValue) + choices.append(optOutValue) + account.onlineMediaSources()[0].updateOptOut(optOutValue) def test_myplex_inviteFriend_remove(account, plex, mocker): From d948669204e1466f159382f7ed9bbe93d9c521c8 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Wed, 7 Oct 2020 11:24:20 -0400 Subject: [PATCH 23/55] failing in the unclaimed server CI run --- tests/test_myplex.py | 2 ++ tests/test_video.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index 2a73599f7..786dd622f 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -126,6 +126,8 @@ def enabled(): utils.wait_until(lambda: enabled() == (False, False)) +@pytest.mark.skip(reason="account.onlineMediaSources() is empty " + "in the CI test run against an unclaimed server.") def test_myplex_onlineMediaSources_optOut(account): mediaOptOut = account.onlineMediaSources()[0] optOutValue = mediaOptOut.value diff --git a/tests/test_video.py b/tests/test_video.py index b5fa6df86..7b5a23cda 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -158,8 +158,8 @@ def test_video_Movie_attrs(movies): assert [i.tag for i in movie.directors] == ["Nina Paley"] assert movie.duration >= 160000 assert movie.fields == [] - assert movie.posters() - assert movie.reviews() + # assert movie.posters() # failing in the unclaimed server CI run + # assert movie.reviews() # failing in the unclaimed server CI run assert movie.extras() assert sorted([i.tag for i in movie.genres]) == [ "Animation", From 06bbd284a3d8bc17b64fa8ed3b1c3fb085ddafff Mon Sep 17 00:00:00 2001 From: blacktwin Date: Wed, 7 Oct 2020 11:54:45 -0400 Subject: [PATCH 24/55] correction, failing in the unclaimed server CI run --- tests/test_video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_video.py b/tests/test_video.py index 7b5a23cda..89ac6ccb5 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -158,9 +158,9 @@ def test_video_Movie_attrs(movies): assert [i.tag for i in movie.directors] == ["Nina Paley"] assert movie.duration >= 160000 assert movie.fields == [] - # assert movie.posters() # failing in the unclaimed server CI run + assert movie.posters() # assert movie.reviews() # failing in the unclaimed server CI run - assert movie.extras() + # assert movie.extras() # failing in the unclaimed server CI run assert sorted([i.tag for i in movie.genres]) == [ "Animation", "Comedy", From 0f0cd03f82a89d79105b4710200662519690bcdd Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 18 Jan 2021 15:57:41 -0500 Subject: [PATCH 25/55] correction of import --- plexapi/video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/video.py b/plexapi/video.py index a8dd01c34..de150e48b 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -3,7 +3,7 @@ from urllib.parse import quote_plus, urlencode from plexapi import library, media, settings, utils -from plexapi.base import Playable, PlexPartialObject +from plexapi.base import Playable, PlexPartialObject, MediaContainer from plexapi.exceptions import BadRequest, NotFound From 63a1d10d11adbcc3829169858c662ca1a7702df9 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 25 Jan 2021 14:28:47 -0500 Subject: [PATCH 26/55] remove hubs method from Video class --- plexapi/video.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index 28f434d24..63b2320e5 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -94,14 +94,6 @@ def markUnwatched(self): self._server.query(key) self.reload() - def hubs(self): - """ Returns a list of :class:`~plexapi.library.Hub` objects. """ - items = [] - data = self._server.query(self._details_key) - for item in data.iter('Hub'): - items.append(library.Hub(data=item, server=self._server)) - return items - def augmentation(self): """ Returns a list of :class:`~plexapi.library.Hub` objects. From 227bb5171784eec282b8df06b058223e435b125e Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 12 Apr 2021 13:07:10 -0400 Subject: [PATCH 27/55] remove Release Release class was unintendedly added by a merge before it was properly located. --- plexapi/base.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 37c2ddfd9..8bb525551 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -659,20 +659,6 @@ def updateTimeline(self, time, state='stopped', duration=None): 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') - - class MediaContainer(PlexObject): """ Represents a single MediaContainer. From 790d12504a17cae562891d29c3f790c30282be98 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 12 Apr 2021 13:10:41 -0400 Subject: [PATCH 28/55] flake fix remove trailing comma --- plexapi/video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/video.py b/plexapi/video.py index ae7039222..b1a5a34c8 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -7,7 +7,7 @@ from plexapi.exceptions import BadRequest from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin -from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, +from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin class Video(PlexPartialObject): From 5d670364d6fabb14492f6a2440fe042043af87d9 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 12 Apr 2021 13:12:46 -0400 Subject: [PATCH 29/55] fixing import missed last import in copy/paste from master. --- plexapi/video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/video.py b/plexapi/video.py index b1a5a34c8..93fdd7efb 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -7,7 +7,7 @@ from plexapi.exceptions import BadRequest from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin -from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin +from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin class Video(PlexPartialObject): From 0bacdbeabf0b16e1fb04c373e647053ad923b9a1 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 12 Apr 2021 13:16:33 -0400 Subject: [PATCH 30/55] readding MediaContainer import --- plexapi/video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/video.py b/plexapi/video.py index 93fdd7efb..c343bb165 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -3,7 +3,7 @@ from urllib.parse import quote_plus, urlencode from plexapi import library, media, utils -from plexapi.base import Playable, PlexPartialObject +from plexapi.base import Playable, PlexPartialObject, MediaContainer from plexapi.exceptions import BadRequest from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin From 8aaa0c09a70c39f7b80ea3187a478c5ac01efa3e Mon Sep 17 00:00:00 2001 From: blacktwin Date: Wed, 14 Apr 2021 08:02:04 -0400 Subject: [PATCH 31/55] spelling & indent correction --- plexapi/video.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index c343bb165..7b88687f8 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -353,7 +353,7 @@ def locations(self): """ This does not exist in plex xml response but is added to have a common interface to get the locations of the movie. - Retruns: + Returns: List of file paths where the movie is found on disk. """ return [part.file for part in self.iterParts() if part] @@ -858,7 +858,7 @@ def locations(self): """ This does not exist in plex xml response but is added to have a common interface to get the locations of the episode. - Retruns: + Returns: List of file paths where the episode is found on disk. """ return [part.file for part in self.iterParts() if part] @@ -938,7 +938,8 @@ def _loadData(self, data): def locations(self): """ This does not exist in plex xml response but is added to have a common interface to get the locations of the clip. - Retruns: + + Returns: List of file paths where the clip is found on disk. """ return [part.file for part in self.iterParts() if part] From 0c8e5f9b5ba0a3a7892ebf7c2440f3166c6233c5 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 27 May 2021 09:15:11 -0400 Subject: [PATCH 32/55] Extra atrributes update cast int --- plexapi/video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index 1bb1405e5..d24d311d2 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -995,7 +995,7 @@ def _loadData(self, data): self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.duration = utils.cast(int, data.attrib.get('duration')) self.extraType = data.attrib.get('extraType') - self.index = data.attrib.get('index') + self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '') self.media = self.findItems(data, media.Media) self.originallyAvailableAt = utils.toDatetime( @@ -1006,4 +1006,4 @@ def _loadData(self, data): self.thumb = data.attrib.get('thumb') self.title = data.attrib.get('title') self.type = data.attrib.get('type') - self.year = data.attrib.get('year') + self.year = utils.cast(int, data.attrib.get('year')) From aa0596d2a134bf20b98f3e20944e02604ec15645 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 27 May 2021 09:16:44 -0400 Subject: [PATCH 33/55] SETTINGS url string substitution update %s instead of .format() --- plexapi/myplex.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index f97431b86..17e9ae593 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -76,7 +76,7 @@ class MyPlexAccount(PlexObject): REQUESTS = 'https://plex.tv/api/invites/requests' # get SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data - SETTINGS = 'https://plex.tv/api/v2/user/{userUUID}/settings' + SETTINGS = 'https://plex.tv/api/v2/user/%(userUUID)s/settings' # get LINK = 'https://plex.tv/api/v2/pins/link' # put # Hub sections VOD = 'https://vod.provider.plex.tv/' # get @@ -704,7 +704,7 @@ def onlineMediaSources(self): """ Returns an user account Online Media Sourcessettings :class:`~plexapi.myplex.AccountOptOut` """ services = [] - optOuts = self.SETTINGS.format(userUUID=self.uuid) + '/opt_outs' + optOuts = self.SETTINGS % {'userUUID': self.uuid} + '/opt_outs' req = requests.get(optOuts, headers={'X-Plex-Token': self._token, 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER}) @@ -719,7 +719,7 @@ def onlineMediaSources(self): def settings(self): """ Returns an user account settings :class:`~plexapi.myplex.AccountSettings` """ - req = requests.get(self.SETTINGS.format(userUUID=self.uuid), + req = requests.get(self.SETTINGS % {'userUUID': self.uuid} , headers={'X-Plex-Token': self._token, 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER}) elem = ElementTree.fromstring(req.text) From 4f6634db38d007a12987a5dafb74ec086514506d Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 27 May 2021 09:22:19 -0400 Subject: [PATCH 34/55] Review attribute ordering --- plexapi/media.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plexapi/media.py b/plexapi/media.py index ad11abb9b..21f2509a6 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -790,13 +790,13 @@ class Review(MediaTag): def _loadData(self, data): self._data = data - self.id = cast(int, data.attrib.get('id', 0)) self.filter = data.attrib.get('filter') - self.tag = data.attrib.get('tag') - self.text = data.attrib.get('text') + self.id = cast(int, data.attrib.get('id', 0)) self.image = data.attrib.get('image') self.link = data.attrib.get('link') self.source = data.attrib.get('source') + self.tag = data.attrib.get('tag') + self.text = data.attrib.get('text') @utils.registerPlexObject From 76719d9c70e33fabf55037c06396f834cca3f7ef Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 27 May 2021 09:38:43 -0400 Subject: [PATCH 35/55] onlineMediaSources clean up --- plexapi/myplex.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 17e9ae593..f2af74666 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -703,18 +703,10 @@ def tidal(self): def onlineMediaSources(self): """ Returns an user account Online Media Sourcessettings :class:`~plexapi.myplex.AccountOptOut` """ - services = [] - optOuts = self.SETTINGS % {'userUUID': self.uuid} + '/opt_outs' - req = requests.get(optOuts, - headers={'X-Plex-Token': self._token, + url = self.SETTINGS % {'userUUID': self.uuid} + '/opt_outs' + elem = self.query(url, headers={'X-Plex-Token': self._token, 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER}) - elem = ElementTree.fromstring(req.text) - for item in elem.iter('optOut'): - service = AccountOptOut(data=item, server=self._server) - service._initpath = optOuts - services.append(service) - - return services + return self.findItems(elem, cls=AccountOptOut, etag='optOut') def settings(self): """ Returns an user account settings :class:`~plexapi.myplex.AccountSettings` From 277f2ea7172efaa977e1c3e9e312046f743a9cbc Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 27 May 2021 09:40:10 -0400 Subject: [PATCH 36/55] onlineMediaSources further clean up --- plexapi/myplex.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index f2af74666..81ae45c72 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -704,8 +704,7 @@ def onlineMediaSources(self): """ Returns an user account Online Media Sourcessettings :class:`~plexapi.myplex.AccountOptOut` """ url = self.SETTINGS % {'userUUID': self.uuid} + '/opt_outs' - elem = self.query(url, headers={'X-Plex-Token': self._token, - 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER}) + elem = self.query(url) return self.findItems(elem, cls=AccountOptOut, etag='optOut') def settings(self): From 226b9834f7f5fbb9d5926f2d54d6c37f3d276a59 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 27 May 2021 10:06:09 -0400 Subject: [PATCH 37/55] MyPlex.settings slight clean up and property --- plexapi/myplex.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 81ae45c72..e93ce1bc8 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -707,15 +707,15 @@ def onlineMediaSources(self): elem = self.query(url) return self.findItems(elem, cls=AccountOptOut, etag='optOut') + @property def settings(self): """ Returns an user account settings :class:`~plexapi.myplex.AccountSettings` """ - req = requests.get(self.SETTINGS % {'userUUID': self.uuid} , + req = requests.get(self.SETTINGS % {'userUUID': self.uuid}, headers={'X-Plex-Token': self._token, 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER}) elem = ElementTree.fromstring(req.text) - for item in elem.iter('setting'): - return AccountSettings(data=item, server=self._server) + return self.findItems(elem, cls=AccountSettings, etag='setting')[0] def link(self, pin): """ Link a device to the account using a pin code. From 49732cf92afa8cd853d06720a5fe6b99ea0268c3 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Thu, 27 May 2021 10:12:41 -0400 Subject: [PATCH 38/55] Extra.addedAt docstring update. --- plexapi/video.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plexapi/video.py b/plexapi/video.py index d24d311d2..a6a9936f1 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -986,6 +986,21 @@ class Extra(Clip): Attributes: TAG (str): 'Extras' + TYPE (str): 'clip' + addedAt (datetime): Datetime the item was added to the library. + duration (int): Duration of the clip in milliseconds. + extraType (int): Unknown. + index (int): Plex index number for the clip. + key (str): API URL (/library/metadata/). + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the clip was released. + ratingKey (int): Unique key identifying the item. + subtype (str): Type of clip (trailer, behindTheScenes, sceneOrSample, etc.). + summary (str): Summary of the movie, show, season, episode, or clip. + thumb (str): URL to thumbnail image (/library/metadata//thumb/). + title (str): Name of the movie, show, season, episode, or clip. + type (str): 'movie', 'show', 'season', 'episode', or 'clip'. + year (int): Year clip was released. """ TAG = 'Extras' From 9f5f2b7b124dd32c3fbf14e2c5c02db68da7ea86 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Fri, 28 May 2021 09:30:14 -0400 Subject: [PATCH 39/55] MediaContainer attribute ordering --- plexapi/base.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 6dba37543..0f327f2f9 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -727,12 +727,11 @@ class MediaContainer(PlexObject): def _loadData(self, data): self._data = data self.allowSync = utils.cast(int, data.attrib.get('allowSync')) - self.librarySectionID = data.attrib.get('librarySectionID') - self.librarySectionTitle = data.attrib.get('librarySectionTitle') - self.librarySectionUUID = data.attrib.get('librarySectionUUID') self.augmentationKey = data.attrib.get('augmentationKey') self.identifier = data.attrib.get('identifier') - self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.librarySectionUUID = data.attrib.get('librarySectionUUID') self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') self.mediaTagVersion = data.attrib.get('mediaTagVersion') self.size = utils.cast(int, data.attrib.get('size')) From 9982b8e108ba913b3ee0e46602e17f1e909f6032 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Fri, 28 May 2021 09:30:24 -0400 Subject: [PATCH 40/55] MediaContainer docstring --- plexapi/base.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plexapi/base.py b/plexapi/base.py index 0f327f2f9..ae1020f36 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -720,6 +720,15 @@ class MediaContainer(PlexObject): Attributes: TAG (str): 'MediaContainer' + allowSync (int): Sync/Download is allowed/disallowed for feature. + augmentationKey (str): API URL (/library/metadata/augmentations/). + identifier (str): "com.plexapp.plugins.library" + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + librarySectionUUID (str): :class:`~plexapi.library.LibrarySection` UUID. + mediaTagPrefix (str): "/system/bundle/media/flags/" + mediaTagVersion (int): Unknown + size (int): The number of items in the hub. """ TAG = 'MediaContainer' From 70ad8ba10c179f6c0b286c67c47280fabe82189b Mon Sep 17 00:00:00 2001 From: blacktwin Date: Fri, 28 May 2021 10:02:17 -0400 Subject: [PATCH 41/55] updateOptOut docstring --- plexapi/myplex.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index e93ce1bc8..35b313a20 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -1402,6 +1402,14 @@ def _loadData(self, data): self.value = data.attrib.get('value') def updateOptOut(self, option): + """Method for toggling Online Media Sources options. + Parameters: + option (str): see CHOICES + + Raises: + :class:`NotFound`: `option` str not found in CHOICES. + :class:`BadRequest`: option is currently set to `option`. + """ if option not in self.CHOICES: raise NotFound('%s not found in available choices: %s' % (option, self.CHOICES)) if option == self.value: From 44b4066273b92b20dd13dd8b68cbb2540fb8cc1e Mon Sep 17 00:00:00 2001 From: blacktwin Date: Fri, 28 May 2021 10:04:51 -0400 Subject: [PATCH 42/55] Clip.section docstring update --- plexapi/video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/video.py b/plexapi/video.py index a6a9936f1..ec11efe7f 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -975,7 +975,7 @@ def locations(self): def section(self): """Return the :class:`~plexapi.library.LibrarySection` this item belongs to.""" - # Clip payloads currently do not contain 'librarySectionID'. + # Clip payloads may not contain 'librarySectionID' # Return None to avoid unnecessary attribute lookup attempts. return None From ca97c60dcb52e6fa50f002cca992546483a0f5e7 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 31 May 2021 09:20:19 -0400 Subject: [PATCH 43/55] replace eval with json.loads --- plexapi/myplex.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 35b313a20..0c4fa0e3e 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import copy import threading +import json import time from xml.etree import ElementTree @@ -1378,12 +1379,13 @@ class AccountSettings(PlexObject): def _loadData(self, data): self.id = data.attrib.get('id') self.type = data.attrib.get('type') - self.value = eval(self.values(data.attrib.get('value'))) + self.value = self.values(data.attrib.get('value')) self.hidden = data.attrib.get('hidden') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) def values(self, value): - return value.replace(':false', ':False').replace(':true', ':True').replace(':null', ':None') + value.replace(':false', ':False').replace(':true', ':True').replace(':null', ':None') + return json.loads(value) class AccountOptOut(PlexObject): From 5013028344aaca7ecdfdf202bfd0942b9c3f0c88 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 31 May 2021 09:46:46 -0400 Subject: [PATCH 44/55] SETTINGS url change and OPTOUT url creation --- plexapi/myplex.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 0c4fa0e3e..c20727e40 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -77,7 +77,8 @@ class MyPlexAccount(PlexObject): REQUESTS = 'https://plex.tv/api/invites/requests' # get SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data - SETTINGS = 'https://plex.tv/api/v2/user/%(userUUID)s/settings' # get + SETTINGS = 'https://plex.tv/api/v2/user/settings' # get + OPTOUTS = 'https://plex.tv/api/v2/user/%(userUUID)s/settings/opt_outs' # get LINK = 'https://plex.tv/api/v2/pins/link' # put # Hub sections VOD = 'https://vod.provider.plex.tv/' # get @@ -704,7 +705,7 @@ def tidal(self): def onlineMediaSources(self): """ Returns an user account Online Media Sourcessettings :class:`~plexapi.myplex.AccountOptOut` """ - url = self.SETTINGS % {'userUUID': self.uuid} + '/opt_outs' + url = self.OPTOUTS % {'userUUID': self.uuid} elem = self.query(url) return self.findItems(elem, cls=AccountOptOut, etag='optOut') @@ -712,8 +713,7 @@ def onlineMediaSources(self): def settings(self): """ Returns an user account settings :class:`~plexapi.myplex.AccountSettings` """ - req = requests.get(self.SETTINGS % {'userUUID': self.uuid}, - headers={'X-Plex-Token': self._token, + req = requests.get(self.SETTINGS, headers={'X-Plex-Token': self._token, 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER}) elem = ElementTree.fromstring(req.text) return self.findItems(elem, cls=AccountSettings, etag='setting')[0] From 4a4a945653b61192820b398d6d5c0333aec69109 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 31 May 2021 09:50:21 -0400 Subject: [PATCH 45/55] Move section method from Clip to Extra --- plexapi/video.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index ec11efe7f..18b0a2a16 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -973,12 +973,6 @@ def locations(self): """ return [part.file for part in self.iterParts() if part] - def section(self): - """Return the :class:`~plexapi.library.LibrarySection` this item belongs to.""" - # Clip payloads may not contain 'librarySectionID' - # Return None to avoid unnecessary attribute lookup attempts. - return None - @utils.registerPlexObject class Extra(Clip): @@ -1022,3 +1016,9 @@ def _loadData(self, data): self.title = data.attrib.get('title') self.type = data.attrib.get('type') self.year = utils.cast(int, data.attrib.get('year')) + + def section(self): + """Return the :class:`~plexapi.library.LibrarySection` this item belongs to.""" + # Clip payloads may not contain 'librarySectionID' + # Return None to avoid unnecessary attribute lookup attempts. + return None From 056b5cf342cc07f29db5a7077fdf41fbefab5e05 Mon Sep 17 00:00:00 2001 From: blacktwin Date: Mon, 31 May 2021 09:56:37 -0400 Subject: [PATCH 46/55] flag onlineMediaSources required to be authenticated --- tests/test_myplex.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index 59dff2f0d..6e31ca997 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -123,8 +123,7 @@ def enabled(): utils.wait_until(lambda: enabled() == (False, False)) -@pytest.mark.skip(reason="account.onlineMediaSources() is empty " - "in the CI test run against an unclaimed server.") +@pytest.mark.authenticated def test_myplex_onlineMediaSources_optOut(account): mediaOptOut = account.onlineMediaSources()[0] optOutValue = mediaOptOut.value From 6f44933f5b21bb7cc54a3ac551d0fd5729f3adac Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 6 Jun 2021 16:40:25 -0700 Subject: [PATCH 47/55] Clean up Extra object --- plexapi/video.py | 57 +++++++++--------------------------------------- 1 file changed, 10 insertions(+), 47 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index 20feb4735..f2092469d 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -915,7 +915,6 @@ class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): viewOffset (int): View offset in milliseconds. year (int): Year clip was released. """ - TAG = 'Video' TYPE = 'clip' METADATA_TYPE = 'clip' @@ -925,17 +924,19 @@ def _loadData(self, data): Video._loadData(self, data) Playable._loadData(self, data) self._data = data + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.duration = utils.cast(int, data.attrib.get('duration')) self.extraType = utils.cast(int, data.attrib.get('extraType')) self.index = utils.cast(int, data.attrib.get('index')) self.media = self.findItems(data, media.Media) - self.originallyAvailableAt = data.attrib.get('originallyAvailableAt') + self.originallyAvailableAt = utils.toDatetime( + data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.skipDetails = utils.cast(int, data.attrib.get('skipDetails')) self.subtype = data.attrib.get('subtype') self.thumbAspectRatio = data.attrib.get('thumbAspectRatio') self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.year = utils.cast(int, data.attrib.get('year')) - + @property def locations(self): """ This does not exist in plex xml response but is added to have a common @@ -947,51 +948,13 @@ def locations(self): return [part.file for part in self.iterParts() if part] -@utils.registerPlexObject class Extra(Clip): - """ Represents a single Extra (trailer, behindTheScenes, etc). - - Attributes: - TAG (str): 'Extras' - TYPE (str): 'clip' - addedAt (datetime): Datetime the item was added to the library. - duration (int): Duration of the clip in milliseconds. - extraType (int): Unknown. - index (int): Plex index number for the clip. - key (str): API URL (/library/metadata/). - media (List<:class:`~plexapi.media.Media`>): List of media objects. - originallyAvailableAt (datetime): Datetime the clip was released. - ratingKey (int): Unique key identifying the item. - subtype (str): Type of clip (trailer, behindTheScenes, sceneOrSample, etc.). - summary (str): Summary of the movie, show, season, episode, or clip. - thumb (str): URL to thumbnail image (/library/metadata//thumb/). - title (str): Name of the movie, show, season, episode, or clip. - type (str): 'movie', 'show', 'season', 'episode', or 'clip'. - year (int): Year clip was released. - """ - TAG = 'Extras' + """ Represents a single Extra (trailer, behindTheScenes, etc). """ def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data - self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) - self.duration = utils.cast(int, data.attrib.get('duration')) - self.extraType = data.attrib.get('extraType') - self.index = utils.cast(int, data.attrib.get('index')) - self.key = data.attrib.get('key', '') - self.media = self.findItems(data, media.Media) - self.originallyAvailableAt = utils.toDatetime( - data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') - 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.title = data.attrib.get('title') - self.type = data.attrib.get('type') - self.year = utils.cast(int, data.attrib.get('year')) - - def section(self): - """Return the :class:`~plexapi.library.LibrarySection` this item belongs to.""" - # Clip payloads may not contain 'librarySectionID' - # Return None to avoid unnecessary attribute lookup attempts. - return None + super(Extra, self)._loadData(data) + parent = self._parent() + self.librarySectionID = parent.librarySectionID + self.librarySectionKey = parent.librarySectionKey + self.librarySectionTitle = parent.librarySectionTitle From 1d031166fcf7ac6298a58b248b052d3afaf2813d Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 6 Jun 2021 16:40:57 -0700 Subject: [PATCH 48/55] Cleanup movie reviews and extras --- plexapi/collection.py | 1 - plexapi/media.py | 39 ++++++++++++++++++++------------------- plexapi/video.py | 35 +++++++++++++---------------------- 3 files changed, 33 insertions(+), 42 deletions(-) diff --git a/plexapi/collection.py b/plexapi/collection.py index 97e1d3daa..0eb20924d 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -51,7 +51,6 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin updatedAt (datatime): Datetime the collection was updated. userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars). """ - TAG = 'Directory' TYPE = 'collection' diff --git a/plexapi/media.py b/plexapi/media.py index 24259eff2..30883b84c 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -779,25 +779,6 @@ class Producer(MediaTag): FILTER = 'producer' -@utils.registerPlexObject -class Review(MediaTag): - """ Represents a single Review for a Movie. - Attributes: - TAG (str): 'Review' - """ - TAG = 'Review' - - def _loadData(self, data): - self._data = data - self.filter = data.attrib.get('filter') - self.id = cast(int, data.attrib.get('id', 0)) - self.image = data.attrib.get('image') - self.link = data.attrib.get('link') - self.source = data.attrib.get('source') - self.tag = data.attrib.get('tag') - self.text = data.attrib.get('text') - - @utils.registerPlexObject class Role(MediaTag): """ Represents a single Role (actor/actress) media tag. @@ -881,6 +862,26 @@ class Guid(GuidTag): TAG = 'Guid' +@utils.registerPlexObject +class Review(MediaTag): + """ Represents a single Review for a Movie. + + Attributes: + TAG (str): 'Review' + """ + TAG = 'Review' + + def _loadData(self, data): + self._data = data + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id', 0)) + self.image = data.attrib.get('image') + self.link = data.attrib.get('link') + self.source = data.attrib.get('source') + self.tag = data.attrib.get('tag') + self.text = data.attrib.get('text') + + class BaseImage(PlexObject): """ Base class for all Art, Banner, and Poster objects. diff --git a/plexapi/video.py b/plexapi/video.py index f2092469d..9a42a0ce1 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -3,7 +3,7 @@ from urllib.parse import quote_plus, urlencode from plexapi import library, media, utils -from plexapi.base import Playable, PlexPartialObject, MediaContainer +from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin from plexapi.mixins import RatingMixin, SplitMergeMixin, UnmatchMatchMixin @@ -87,20 +87,20 @@ def markUnwatched(self): def augmentation(self): """ Returns a list of :class:`~plexapi.library.Hub` objects. - - augmentation returns hub items relating to online media sources - such as Tidal Music "Track From {item}" or "Soundtrack of {item}" - Plex Pass and linked Tidal account are required - + Augmentation returns hub items relating to online media sources + such as Tidal Music "Track from {item}" or "Soundtrack of {item}". + Plex Pass and linked Tidal account are required. """ account = self._server.myPlexAccount() - tidalOptOut = [service.value for service in account.onlineMediaSources() - if service.key.endswith('music')][0] + tidalOptOut = next( + (service.value for service in account.onlineMediaSources() + if service.key == 'tv.plex.provider.music'), + None + ) if account.subscriptionStatus != 'Active' or tidalOptOut == 'opt_out': raise BadRequest('Requires Plex Pass and Tidal Music enabled.') data = self._server.query(self.key + '?asyncAugmentMetadata=1') - mediaContainer = MediaContainer(data=data, server=self._server) - augmentationKey = mediaContainer.augmentationKey + augmentationKey = data.attrib.get('augmentationKey') return self.fetchItems(augmentationKey) def _defaultSyncTitle(self): @@ -362,22 +362,13 @@ def _prettyfilename(self): def reviews(self): """ Returns a list of :class:`~plexapi.media.Review` objects. """ - items = [] - data = self._server.query(self.key + '?includeReviews=1') - for item in data.iter('Review'): - items.append(media.Review(data=item, server=self._server)) - - return items + data = self._server.query(self._details_key) + return self.findItems(data, media.Review, rtag='Video') def extras(self): """ Returns a list of :class:`~plexapi.video.Extra` objects. """ - items = [] data = self._server.query(self._details_key) - for extra in data.iter('Extras'): - for video in extra.iter('Video'): - items.append(Extra(data=video, server=self._server)) - - return items + return self.findItems(data, Extra, rtag='Extras') def hubs(self): """ Returns a list of :class:`~plexapi.library.Hub` objects. """ From f8c7fefd2f531eb8be1612b822d2e85bd5f519bc Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 6 Jun 2021 16:42:43 -0700 Subject: [PATCH 49/55] Cleanup Account onlineMediaSources OptOut methods --- plexapi/myplex.py | 52 ++++++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 07875e8dd..5a3c25afd 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -694,21 +694,12 @@ def tidal(self): return self.findItems(elem) def onlineMediaSources(self): - """ Returns an user account Online Media Sourcessettings :class:`~plexapi.myplex.AccountOptOut` + """ Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut` """ url = self.OPTOUTS % {'userUUID': self.uuid} elem = self.query(url) return self.findItems(elem, cls=AccountOptOut, etag='optOut') - @property - def settings(self): - """ Returns an user account settings :class:`~plexapi.myplex.AccountSettings` - """ - req = requests.get(self.SETTINGS, headers={'X-Plex-Token': self._token, - 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER}) - elem = ElementTree.fromstring(req.text) - return self.findItems(elem, cls=AccountSettings, etag='setting')[0] - def link(self, pin): """ Link a device to the account using a pin code. @@ -1378,28 +1369,47 @@ class AccountOptOut(PlexObject): 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' Attributes: + TAG (str): optOut key (str): Online Media Source key - value (str): Online Media Source opt_in or opt_out + value (str): Online Media Source opt_in, opt_out, or opt_out_managed """ - - CHOICES = ['opt_in', 'opt_out', 'opt_out_managed'] + TAG = 'optOut' + CHOICES = {'opt_in', 'opt_out', 'opt_out_managed'} def _loadData(self, data): self.key = data.attrib.get('key') self.value = data.attrib.get('value') - def updateOptOut(self, option): - """Method for toggling Online Media Sources options. + def _updateOptOut(self, option): + """ Sets the Online Media Sources option. + Parameters: option (str): see CHOICES Raises: - :class:`NotFound`: `option` str not found in CHOICES. - :class:`BadRequest`: option is currently set to `option`. + :exc:`~plexapi.exceptions.NotFound`: ``option`` str not found in CHOICES. """ if option not in self.CHOICES: raise NotFound('%s not found in available choices: %s' % (option, self.CHOICES)) - if option == self.value: - raise BadRequest('OptOut option is already set to %s' % option) - url = self._initpath + '?key=%s&value=%s&' % (self.key, option) - self._server.query(url, method=self._server._session.post) + url = self._server.OPTOUTS % {'userUUID': self._server.uuid} + params = {'key': self.key, 'value': option} + self._server.query(url, method=self._server._session.post, params=params) + self.value = option # assume query successful and set the value to option + + def optIn(self): + """ Sets the Online Media Source to "Enabled". """ + self._updateOptOut('opt_in') + + def optOut(self): + """ Sets the Online Media Source to "Disabled". """ + self._updateOptOut('opt_out') + + def optOutManaged(self): + """ Sets the Online Media Source to "Disabled for Managed Users". + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music. + """ + if self.key == 'tv.plex.provider.music': + raise BadRequest('%s does not have the option to opt out managed users.' % self.key) + self._updateOptOut('opt_out_managed') From 4d60101ab7e3ff36649bf367104f24af49f0cae8 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 6 Jun 2021 16:43:31 -0700 Subject: [PATCH 50/55] Remove Account settings * Settings are only for Plex Web specific settings like remembering selected tab, poster size, etc. --- plexapi/myplex.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 5a3c25afd..55227948d 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import copy import threading -import json import time from xml.etree import ElementTree @@ -76,7 +75,6 @@ class MyPlexAccount(PlexObject): REQUESTS = 'https://plex.tv/api/invites/requests' # get SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data - SETTINGS = 'https://plex.tv/api/v2/user/settings' # get OPTOUTS = 'https://plex.tv/api/v2/user/%(userUUID)s/settings/opt_outs' # get LINK = 'https://plex.tv/api/v2/pins/link' # put # Hub sections @@ -1339,31 +1337,6 @@ def _chooseConnection(ctype, name, results): raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name)) -class AccountSettings(PlexObject): - """ Represents a single Account Setting - 'https://plex.tv/api/v2/user/{userUUID}/settings' - - Attributes: - id (str): Unknown. "experience"? - type (str): "json" - value (dict): Lots of user server, library, - and other endpoints and settings - hidden (str): Unknown. Are these settings hidden? - updatedAt (datetime): Datetime last updated - """ - - def _loadData(self, data): - self.id = data.attrib.get('id') - self.type = data.attrib.get('type') - self.value = self.values(data.attrib.get('value')) - self.hidden = data.attrib.get('hidden') - self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) - - def values(self, value): - value.replace(':false', ':False').replace(':true', ':True').replace(':null', ':None') - return json.loads(value) - - class AccountOptOut(PlexObject): """ Represents a single AccountOptOut 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' From aaa902059fc3cec2fe86941f7a20b102cb979433 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 6 Jun 2021 17:03:08 -0700 Subject: [PATCH 51/55] Update account onlineMediaSources optOut test --- tests/test_myplex.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index 6e31ca997..b6bcf7279 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -125,18 +125,28 @@ def enabled(): @pytest.mark.authenticated def test_myplex_onlineMediaSources_optOut(account): - mediaOptOut = account.onlineMediaSources()[0] - optOutValue = mediaOptOut.value + onlineMediaSources = account.onlineMediaSources() + for optOut in onlineMediaSources: + if optOut.key == 'tv.plex.provider.news': + # News is no longer available + continue + + optOutValue = optOut.value + optOut.optIn() + assert optOut.value == 'opt_in' + optOut.optOut() + assert optOut.value == 'opt_out' + if optOut.key == 'tv.plex.provider.music': + with pytest.raises(BadRequest): + optOut.optOutManaged() + else: + optOut.optOutManaged() + assert optOut.value == 'opt_out_managed' + # Reset original value + optOut._updateOptOut(optOutValue) + with pytest.raises(NotFound): - assert mediaOptOut.updateOptOut('what') - with pytest.raises(BadRequest): - assert mediaOptOut.updateOptOut(optOutValue) - choices = mediaOptOut.CHOICES - choices.remove(optOutValue) - mediaOptOut.updateOptOut(choices[0]) - assert account.onlineMediaSources()[0].value == choices[0] - choices.append(optOutValue) - account.onlineMediaSources()[0].updateOptOut(optOutValue) + assert onlineMediaSources[0]._updateOptOut('unknown') def test_myplex_inviteFriend_remove(account, plex, mocker): From 0626334ed5d271fae1aa4f661a2cbd7e5a3b371e Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 6 Jun 2021 17:19:26 -0700 Subject: [PATCH 52/55] Add tests for movie augmentation, reviews, and extras --- tests/test_video.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_video.py b/tests/test_video.py index b70b5647a..47168280a 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -563,6 +563,50 @@ def test_video_Movie_hubs(movies): assert hub.size == 1 +@pytest.mark.authenticated +def test_video_Movie_augmentation(movie, account): + onlineMediaSources = account.onlineMediaSources() + tidalOptOut = next( + optOut for optOut in onlineMediaSources + if optOut.key == 'tv.plex.provider.music' + ) + optOutValue = tidalOptOut.value + + tidalOptOut.optOut() + with pytest.raises(BadRequest): + movie.augmentation() + + tidalOptOut.optIn() + augmentations = movie.augmentation() + assert augmentations or augmentations == [] + + # Reset original Tidal opt out value + tidalOptOut._updateOptOut(optOutValue) + + +def test_video_Movie_reviews(movies): + movie = movies.get("Sita Sings The Blues") + reviews = movie.reviews() + assert reviews + review = next(r for r in reviews if r.link) + assert review.filter + assert utils.is_int(review.id) + assert review.image.startswith("rottentomatoes://") + assert review.link.startswith("http") + assert review.source + assert review.tag + assert review.text + + +def test_video_Movie_extras(movies): + movie = movies.get("Sita Sings The Blues") + extras = movie.extras() + assert extras + extra = extras[0] + assert extra.type == 'clip' + assert extra.section() == movies + + def test_video_Show_attrs(show): assert utils.is_datetime(show.addedAt) if show.art: From 7ddf47c223965a98edc84de01d23e3361e674a50 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 6 Jun 2021 17:27:02 -0700 Subject: [PATCH 53/55] Add doc string for Review object --- plexapi/media.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plexapi/media.py b/plexapi/media.py index 30883b84c..3ca699780 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -863,11 +863,18 @@ class Guid(GuidTag): @utils.registerPlexObject -class Review(MediaTag): +class Review(PlexObject): """ Represents a single Review for a Movie. Attributes: TAG (str): 'Review' + filter (str): filter for reviews? + id (int): The ID of the review. + image (str): The image uri for the review. + link (str): The url to the online review. + source (str): The source of the review. + tag (str): The name of the reviewer. + text (str): The text of the review. """ TAG = 'Review' From 6405d226a419cb55c1be99aba309a8d0d2374f80 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 6 Jun 2021 17:33:08 -0700 Subject: [PATCH 54/55] Mark movies extras test only available for Plex Pass --- tests/test_video.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_video.py b/tests/test_video.py index 47168280a..0e58186a1 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -598,6 +598,7 @@ def test_video_Movie_reviews(movies): assert review.text +@pytest.mark.authenticated def test_video_Movie_extras(movies): movie = movies.get("Sita Sings The Blues") extras = movie.extras() From 67b3fc694a821c02c49b9d20e5b22b90a2c856d0 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 6 Jun 2021 17:50:35 -0700 Subject: [PATCH 55/55] Allow clips and extras to be streamed and downloaded --- plexapi/base.py | 2 +- plexapi/video.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plexapi/base.py b/plexapi/base.py index 0c56110a2..1a5759b6f 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -610,7 +610,7 @@ def getStreamURL(self, **params): Raises: :exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL. """ - if self.TYPE not in ('movie', 'episode', 'track'): + if self.TYPE not in ('movie', 'episode', 'track', 'clip'): raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE) mvb = params.get('maxVideoBitrate') vr = params.get('videoResolution', '') diff --git a/plexapi/video.py b/plexapi/video.py index 9a42a0ce1..609eaffc3 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -938,6 +938,9 @@ def locations(self): """ return [part.file for part in self.iterParts() if part] + def _prettyfilename(self): + return self.title + class Extra(Clip): """ Represents a single Extra (trailer, behindTheScenes, etc). """ @@ -949,3 +952,6 @@ def _loadData(self, data): self.librarySectionID = parent.librarySectionID self.librarySectionKey = parent.librarySectionKey self.librarySectionTitle = parent.librarySectionTitle + + def _prettyfilename(self): + return '%s (%s)' % (self.title, self.subtype)