diff --git a/plexapi/base.py b/plexapi/base.py index fa823fa7b..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', '') @@ -715,3 +715,34 @@ def updateTimeline(self, time, state='stopped', duration=None): key %= (self.ratingKey, self.key, time, state, durationStr) self._server.query(key) self.reload() + + +class MediaContainer(PlexObject): + """ Represents a single MediaContainer. + + 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' + + def _loadData(self, data): + self._data = data + self.allowSync = utils.cast(int, data.attrib.get('allowSync')) + self.augmentationKey = data.attrib.get('augmentationKey') + self.identifier = data.attrib.get('identifier') + 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')) 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 37c1cb7a3..3ca699780 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -862,6 +862,33 @@ class Guid(GuidTag): TAG = 'Guid' +@utils.registerPlexObject +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' + + 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/myplex.py b/plexapi/myplex.py index d2d4ec37d..55227948d 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -75,6 +75,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 + 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 @@ -690,6 +691,13 @@ def tidal(self): elem = ElementTree.fromstring(req.text) return self.findItems(elem) + def onlineMediaSources(self): + """ 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') + def link(self, pin): """ Link a device to the account using a pin code. @@ -1327,3 +1335,54 @@ def _chooseConnection(ctype, name, results): log.debug('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): + """ Represents a single AccountOptOut + '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, opt_out, or 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): + """ Sets the Online Media Sources option. + + Parameters: + option (str): see CHOICES + + Raises: + :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)) + 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') diff --git a/plexapi/video.py b/plexapi/video.py index 8c2ee8eb4..609eaffc3 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -85,6 +85,24 @@ def markUnwatched(self): key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey self._server.query(key) + 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. + """ + account = self._server.myPlexAccount() + 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') + augmentationKey = data.attrib.get('augmentationKey') + return self.fetchItems(augmentationKey) + def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ return self.title @@ -342,6 +360,16 @@ def _prettyfilename(self): # This is just for compat. return self.title + def reviews(self): + """ Returns a list of :class:`~plexapi.media.Review` objects. """ + 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. """ + data = self._server.query(self._details_key) + return self.findItems(data, Extra, rtag='Extras') + def hubs(self): """ Returns a list of :class:`~plexapi.library.Hub` objects. """ data = self._server.query(self._details_key) @@ -878,7 +906,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' @@ -888,11 +915,13 @@ 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') @@ -908,3 +937,21 @@ def locations(self): List of file paths where the clip is found on disk. """ 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). """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(Extra, self)._loadData(data) + parent = self._parent() + self.librarySectionID = parent.librarySectionID + self.librarySectionKey = parent.librarySectionKey + self.librarySectionTitle = parent.librarySectionTitle + + def _prettyfilename(self): + return '%s (%s)' % (self.title, self.subtype) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index 3c9555400..b6bcf7279 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -123,6 +123,32 @@ def enabled(): utils.wait_until(lambda: enabled() == (False, False)) +@pytest.mark.authenticated +def test_myplex_onlineMediaSources_optOut(account): + 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 onlineMediaSources[0]._updateOptOut('unknown') + + def test_myplex_inviteFriend_remove(account, plex, mocker): inv_user = "hellowlol" vid_filter = {"contentRating": ["G"], "label": ["foo"]} diff --git a/tests/test_video.py b/tests/test_video.py index b70b5647a..0e58186a1 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -563,6 +563,51 @@ 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 + + +@pytest.mark.authenticated +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: