Create separate PlexSession objects (#931)

* Add base findItem method

* Create new PlexSession objects

* PlexSession objects are unique from their base media objects. These objects are built from `/status/sessions` instead of `/library/metadata/<ratingKey>`. This allows reloading data from sessions without overwriting them.

* Add some media and client attributes for sessions

* Add separater property to return the PlexSession user object

* This speeds up building the `PlexSession` object since it doesn't need to lookup the user.
* The user object is also cached for future lookups.

* Remove PlexSession attributes from tests

* Never auto reload a PlexSession object

* Rename PlexSession.usernames for backwards compatibility

Co-authored-by: jjlawren <jjlawren@users.noreply.github.com>

* Don't cache myPlexAccount in PlexSession

* Move session stop method to PlexSession

Co-authored-by: jjlawren <jjlawren@users.noreply.github.com>
This commit is contained in:
JonnyWong16 2022-07-20 20:03:20 -07:00 committed by GitHub
parent d09cc47562
commit 925f573ced
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 212 additions and 55 deletions

View file

@ -3,7 +3,7 @@ import os
from urllib.parse import quote_plus
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.base import Playable, PlexPartialObject, PlexSession
from plexapi.exceptions import BadRequest
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
@ -46,7 +46,6 @@ class Audio(PlexPartialObject):
userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars).
viewCount (int): Count of times the item was played.
"""
METADATA_TYPE = 'track'
def _loadData(self, data):
@ -468,3 +467,16 @@ class Track(
def _getWebURL(self, base=None):
""" Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey)
@utils.registerPlexObject
class TrackSession(PlexSession, Track):
""" Represents a single Track session
loaded from :func:`~plexapi.server.PlexServer.sessions`.
"""
_SESSIONTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Track._loadData(self, data)
PlexSession._loadData(self, data)

View file

@ -1,15 +1,14 @@
# -*- coding: utf-8 -*-
import re
import weakref
from urllib.parse import quote_plus, urlencode
from urllib.parse import urlencode
from xml.etree import ElementTree
from plexapi import log, utils
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
USER_DONT_RELOAD_FOR_KEYS = set()
_DONT_RELOAD_FOR_KEYS = {'key', 'session'}
_DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'}
_DONT_RELOAD_FOR_KEYS = {'key'}
OPERATORS = {
'exact': lambda v, q: v == q,
'iexact': lambda v, q: v.lower() == q.lower(),
@ -63,10 +62,6 @@ class PlexObject:
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p])
def __setattr__(self, attr, value):
# Don't overwrite session specific attr with []
if attr in _DONT_OVERWRITE_SESSION_KEYS and value == []:
value = getattr(self, attr, [])
overwriteNone = self.__dict__.get('_overwriteNone')
# Don't overwrite an attr with None unless it's a private variable or overwrite None is True
if value is not None or attr.startswith('_') or attr not in self.__dict__ or overwriteNone:
@ -90,6 +85,8 @@ class PlexObject:
# cls is not specified, try looking it up in PLEXOBJECTS
etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type')))
ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag
if initpath == '/status/sessions':
ehash = '%s.%s' % (ehash, 'session')
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
if ecls is not None:
@ -171,14 +168,16 @@ class PlexObject:
raise BadRequest('ekey was not provided')
if isinstance(ekey, int):
ekey = '/library/metadata/%s' % ekey
data = self._server.query(ekey)
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
for elem in data:
if self._checkAttrs(elem, **kwargs):
item = self._buildItem(elem, cls, ekey)
if librarySectionID:
item.librarySectionID = librarySectionID
return item
item = self.findItem(data, cls, ekey, **kwargs)
if item:
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
if librarySectionID:
item.librarySectionID = librarySectionID
return item
clsname = cls.__name__ if cls else 'None'
raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs))
@ -256,15 +255,16 @@ class PlexObject:
fetchItem(ekey, Media__Part__file__startswith="D:\\Movies")
"""
url_kw = {}
if container_start is not None:
url_kw["X-Plex-Container-Start"] = container_start
if container_size is not None:
url_kw["X-Plex-Container-Size"] = container_size
if ekey is None:
raise BadRequest('ekey was not provided')
data = self._server.query(ekey, params=url_kw)
params = {}
if container_start is not None:
params["X-Plex-Container-Start"] = container_start
if container_size is not None:
params["X-Plex-Container-Size"] = container_size
data = self._server.query(ekey, params=params)
items = self.findItems(data, cls, ekey, **kwargs)
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
@ -273,6 +273,25 @@ class PlexObject:
item.librarySectionID = librarySectionID
return items
def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs):
""" Load the specified data to find and build the first items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
"""
# filter on cls attrs if specified
if cls and cls.TAG and 'tag' not in kwargs:
kwargs['etag'] = cls.TAG
if cls and cls.TYPE and 'type' not in kwargs:
kwargs['type'] = cls.TYPE
# rtag to iter on a specific root tag
if rtag:
data = next(data.iter(rtag), [])
# loop through all data elements to find matches
for elem in data:
if self._checkAttrs(elem, **kwargs):
item = self._buildItemOrNone(elem, cls, initpath)
return item
def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs):
""" Load the specified data to find and build all items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
@ -468,11 +487,11 @@ class PlexPartialObject(PlexObject):
value = super(PlexPartialObject, self).__getattribute__(attr)
# Check a few cases where we don't want to reload
if attr in _DONT_RELOAD_FOR_KEYS: return value
if attr in _DONT_OVERWRITE_SESSION_KEYS: return value
if attr in USER_DONT_RELOAD_FOR_KEYS: return value
if attr.startswith('_'): return value
if value not in (None, []): return value
if self.isFullObject(): return value
if isinstance(self, PlexSession): return value
if self._autoReload is False: return value
# Log the reload.
clsname = self.__class__.__name__
@ -655,12 +674,6 @@ class Playable:
Albums which are all not playable.
Attributes:
sessionKey (int): Active session key.
usernames (str): Username of the person playing this item (for active sessions).
players (:class:`~plexapi.client.PlexClient`): Client objects playing this item (for active sessions).
session (:class:`~plexapi.media.Session`): Session object, for a playing media file.
transcodeSessions (:class:`~plexapi.media.TranscodeSession`): Transcode Session object
if item is being transcoded (None otherwise).
viewedAt (datetime): Datetime item was last viewed (history).
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
@ -669,11 +682,6 @@ class Playable:
"""
def _loadData(self, data):
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) # session
self.usernames = self.listAttrs(data, 'title', etag='User') # session
self.players = self.findItems(data, etag='Player') # session
self.transcodeSessions = self.findItems(data, etag='TranscodeSession') # session
self.session = self.findItems(data, etag='Session') # session
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
self.accountID = utils.cast(int, data.attrib.get('accountID')) # history
self.deviceID = utils.cast(int, data.attrib.get('deviceID')) # history
@ -774,11 +782,6 @@ class Playable:
return filepaths
def stop(self, reason=''):
""" Stop playback for a media item. """
key = '/status/sessions/terminate?sessionId=%s&reason=%s' % (self.session[0].id, quote_plus(reason))
return self._server.query(key)
def updateProgress(self, time, state='stopped'):
""" Set the watched progress for this video.
@ -814,6 +817,88 @@ class Playable:
self._reload(_overwriteNone=False)
class PlexSession(object):
""" This is a general place to store functions specific to media that is a Plex Session.
Attributes:
live (bool): True if this is a live tv session.
player (:class:`~plexapi.client.PlexClient`): PlexClient object for the session.
session (:class:`~plexapi.media.Session`): Session object for the session
if the session is using bandwidth (None otherwise).
sessionKey (int): The session key for the session.
transcodeSession (:class:`~plexapi.media.TranscodeSession`): TranscodeSession object
if item is being transcoded (None otherwise).
"""
def _loadData(self, data):
self.live = utils.cast(bool, data.attrib.get('live', '0'))
self.player = self.findItem(data, etag='Player')
self.session = self.findItem(data, etag='Session')
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey'))
self.transcodeSession = self.findItem(data, etag='TranscodeSession')
user = data.find('User')
self._username = user.attrib.get('title')
self._userId = utils.cast(int, user.attrib.get('id'))
self._user = None # Cache for user object
# For backwards compatibility
self.players = [self.player] if self.player else []
self.sessions = [self.session] if self.session else []
self.transcodeSessions = [self.transcodeSession] if self.transcodeSession else []
self.usernames = [self._username] if self._username else []
@property
def user(self):
""" Returns the :class:`~plexapi.myplex.MyPlexAccount` object (for admin)
or :class:`~plexapi.myplex.MyPlexUser` object (for users) for this session.
"""
if self._user is None:
myPlexAccount = self._server.myPlexAccount()
if self._userId == 1:
self._user = myPlexAccount
else:
self._user = myPlexAccount.user(self._username)
return self._user
def reload(self):
""" Reload the data for the session.
Note: This will return the object as-is if the session is no longer active.
"""
return self._reload()
def _reload(self, _autoReload=False, **kwargs):
""" Perform the actual reload. """
# Do not auto reload sessions
if _autoReload:
return self
key = self._initpath
data = self._server.query(key)
for elem in data:
if elem.attrib.get('sessionKey') == str(self.sessionKey):
self._loadData(elem)
break
return self
def source(self):
""" Return the source media object for the session. """
return self.fetchItem(self._details_key)
def stop(self, reason=''):
""" Stop playback for the session.
Parameters:
reason (str): Message displayed to the user for stopping playback.
"""
params = {
'sessionId': self.session.id,
'reason': reason,
}
key = '/status/sessions/terminate'
return self._server.query(key, params=params)
class MediaContainer(PlexObject):
""" Represents a single MediaContainer.

View file

@ -136,12 +136,15 @@ class PlexClient(PlexObject):
# Add this in next breaking release.
# if self._initpath == 'status/sessions':
self.device = data.attrib.get('device') # session
self.profile = data.attrib.get('profile') # session
self.model = data.attrib.get('model') # session
self.state = data.attrib.get('state') # session
self.vendor = data.attrib.get('vendor') # session
self.version = data.attrib.get('version') # session
self.local = utils.cast(bool, data.attrib.get('local', 0))
self.address = data.attrib.get('address') # session
self.local = utils.cast(bool, data.attrib.get('local', 0)) # session
self.relayed = utils.cast(bool, data.attrib.get('relayed', 0)) # session
self.secure = utils.cast(bool, data.attrib.get('secure', 0)) # session
self.address = data.attrib.get('address') # session
self.remotePublicAddress = data.attrib.get('remotePublicAddress')
self.userID = data.attrib.get('userID')

View file

@ -64,6 +64,7 @@ class Media(PlexObject):
self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming'))
self.parts = self.findItems(data, MediaPart)
self.proxyType = utils.cast(int, data.attrib.get('proxyType'))
self.selected = utils.cast(bool, data.attrib.get('selected'))
self.target = data.attrib.get('target')
self.title = data.attrib.get('title')
self.videoCodec = data.attrib.get('videoCodec')
@ -71,6 +72,7 @@ class Media(PlexObject):
self.videoProfile = data.attrib.get('videoProfile')
self.videoResolution = data.attrib.get('videoResolution')
self.width = utils.cast(int, data.attrib.get('width'))
self.uuid = data.attrib.get('uuid')
if self._isChildOf(etag='Photo'):
self.aperture = data.attrib.get('aperture')
@ -146,7 +148,9 @@ class MediaPart(PlexObject):
self.key = data.attrib.get('key')
self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming'))
self.packetLength = utils.cast(int, data.attrib.get('packetLength'))
self.protocol = data.attrib.get('protocol')
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.selected = utils.cast(bool, data.attrib.get('selected'))
self.size = utils.cast(int, data.attrib.get('size'))
self.streams = self._buildStreams(data)
self.syncItemId = utils.cast(int, data.attrib.get('syncItemId'))
@ -239,15 +243,17 @@ class MediaPartStream(PlexObject):
self._data = data
self.bitrate = utils.cast(int, data.attrib.get('bitrate'))
self.codec = data.attrib.get('codec')
self.decision = data.attrib.get('decision')
self.default = utils.cast(bool, data.attrib.get('default'))
self.displayTitle = data.attrib.get('displayTitle')
self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
self.key = data.attrib.get('key')
self.id = utils.cast(int, data.attrib.get('id'))
self.index = utils.cast(int, data.attrib.get('index', '-1'))
self.key = data.attrib.get('key')
self.language = data.attrib.get('language')
self.languageCode = data.attrib.get('languageCode')
self.languageTag = data.attrib.get('languageTag')
self.location = data.attrib.get('location')
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.selected = utils.cast(bool, data.attrib.get('selected', '0'))
self.streamType = utils.cast(int, data.attrib.get('streamType'))

View file

@ -3,7 +3,7 @@ import os
from urllib.parse import quote_plus
from plexapi import media, utils, video
from plexapi.base import Playable, PlexPartialObject
from plexapi.base import Playable, PlexPartialObject, PlexSession
from plexapi.exceptions import BadRequest
from plexapi.mixins import (
RatingMixin,
@ -291,3 +291,16 @@ class Photo(
def _getWebURL(self, base=None):
""" Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1)
@utils.registerPlexObject
class PhotoSession(PlexSession, Photo):
""" Represents a single Photo session
loaded from :func:`~plexapi.server.PlexServer.sessions`.
"""
_SESSIONTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Photo._loadData(self, data)
PlexSession._loadData(self, data)

View file

@ -61,6 +61,8 @@ def registerPlexObject(cls):
"""
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG
if getattr(cls, '_SESSIONTYPE', None):
ehash = '%s.%s' % (ehash, 'session')
if ehash in PLEXOBJECTS:
raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' %
(cls.__name__, cls.TAG, etype, PLEXOBJECTS[ehash].__name__))

View file

@ -3,7 +3,7 @@ import os
from urllib.parse import quote_plus, urlencode
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.base import Playable, PlexPartialObject, PlexSession
from plexapi.exceptions import BadRequest
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
@ -980,3 +980,42 @@ class Extra(Clip):
def _prettyfilename(self):
""" Returns a filename for use in download. """
return '%s (%s)' % (self.title, self.subtype)
@utils.registerPlexObject
class MovieSession(PlexSession, Movie):
""" Represents a single Movie session
loaded from :func:`~plexapi.server.PlexServer.sessions`.
"""
_SESSIONTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Movie._loadData(self, data)
PlexSession._loadData(self, data)
@utils.registerPlexObject
class EpisodeSession(PlexSession, Episode):
""" Represents a single Episode session
loaded from :func:`~plexapi.server.PlexServer.sessions`.
"""
_SESSIONTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Episode._loadData(self, data)
PlexSession._loadData(self, data)
@utils.registerPlexObject
class ClipSession(PlexSession, Clip):
""" Represents a single Clip session
loaded from :func:`~plexapi.server.PlexServer.sessions`.
"""
_SESSIONTYPE = True
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Clip._loadData(self, data)
PlexSession._loadData(self, data)

View file

@ -306,14 +306,12 @@ def test_audio_Track_attrs(album):
assert track.ratingCount is None or utils.is_int(track.ratingCount)
assert utils.is_int(track.ratingKey)
assert track._server._baseurl == utils.SERVER_BASEURL
assert track.sessionKey is None
assert track.skipCount is None
assert track.summary == ""
if track.thumb:
assert utils.is_thumb(track.thumb)
assert track.title == "As Colourful as Ever"
assert track.titleSort == "As Colourful as Ever"
assert not track.transcodeSessions
assert track.type == "track"
assert utils.is_datetime(track.updatedAt)
assert utils.is_int(track.viewCount, gte=0)

View file

@ -87,7 +87,6 @@ def test_video_Movie_attrs(movies):
assert utils.is_metadata(movie.primaryExtraKey)
assert movie.ratingKey >= 1
assert movie._server._baseurl == utils.SERVER_BASEURL
assert movie.sessionKey is None
assert movie.studio == "Nina Paley"
assert utils.is_string(movie.summary, gte=100)
assert movie.tagline == "The Greatest Break-Up Story Ever Told."
@ -96,7 +95,6 @@ def test_video_Movie_attrs(movies):
assert utils.is_thumb(movie.thumb)
assert movie.title == "Sita Sings the Blues"
assert movie.titleSort == "Sita Sings the Blues"
assert not movie.transcodeSessions
assert movie.type == "movie"
assert movie.updatedAt > datetime(2017, 1, 1)
assert movie.useOriginalTitle == -1
@ -1074,11 +1072,13 @@ def test_video_Episode_updateTimeline(episode, patched_http_call):
) # 2 minutes.
def test_video_Episode_stop(episode, mocker, patched_http_call):
mocker.patch.object(
episode, "session", return_value=list(mocker.MagicMock(id="hello"))
)
episode.stop(reason="It's past bedtime!")
def test_video_Episode(show):
episode = show.episode("Winter Is Coming")
assert episode == show.episode(season=1, episode=1)
with pytest.raises(BadRequest):
show.episode()
with pytest.raises(NotFound):
show.episode(season=1337, episode=1337)
def test_video_Episode_history(episode):
@ -1173,7 +1173,6 @@ def test_video_Episode_attrs(episode):
assert utils.is_thumb(episode.thumb)
assert episode.title == "Winter Is Coming"
assert episode.titleSort == "Winter Is Coming"
assert not episode.transcodeSessions
assert episode.type == "episode"
assert utils.is_datetime(episode.updatedAt)
assert episode.userRating is None