Skip to content

Commit

Permalink
Merge pull request #530 from pkkid/reviews_extras
Browse files Browse the repository at this point in the history
Add movie reviews and extras, and account online media source options
  • Loading branch information
JonnyWong16 authored Jun 7, 2021
2 parents 90c1e46 + 67b3fc6 commit b2f018b
Show file tree
Hide file tree
Showing 7 changed files with 238 additions and 4 deletions.
33 changes: 32 additions & 1 deletion plexapi/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', '')
Expand Down Expand Up @@ -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/<augmentationKey>).
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'))
1 change: 0 additions & 1 deletion plexapi/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
27 changes: 27 additions & 0 deletions plexapi/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions plexapi/myplex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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')
51 changes: 49 additions & 2 deletions plexapi/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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'
Expand All @@ -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')
Expand All @@ -908,3 +937,21 @@ def locations(self):
List<str> 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)
26 changes: 26 additions & 0 deletions tests/test_myplex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
Expand Down
45 changes: 45 additions & 0 deletions tests/test_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down

0 comments on commit b2f018b

Please sign in to comment.