Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor Playable #1301

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions plexapi/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
from typing import Any, Dict, List, Optional, TypeVar

from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession
from plexapi.base import PlexPartialObject, PlexHistory, PlexSession
from plexapi.exceptions import BadRequest
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
ArtistEditMixins, AlbumEditMixins, TrackEditMixins
)
from plexapi.playable import Playable
from plexapi.playlist import Playlist


Expand Down Expand Up @@ -472,7 +473,6 @@ def _loadData(self, data):
self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guids = self.findItems(data, media.Guid)
self.labels = self.findItems(data, media.Label)
self.media = self.findItems(data, media.Media)
self.originalTitle = data.attrib.get('originalTitle')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
Expand Down
166 changes: 9 additions & 157 deletions plexapi/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import re
from typing import Optional, TypeVar
import weakref
from functools import cached_property
from urllib.parse import urlencode
Expand All @@ -8,6 +9,8 @@
from plexapi import CONFIG, X_PLEX_CONTAINER_SIZE, log, utils
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported

T = TypeVar('T')

USER_DONT_RELOAD_FOR_KEYS = set()
_DONT_RELOAD_FOR_KEYS = {'key'}
OPERATORS = {
Expand Down Expand Up @@ -40,9 +43,10 @@ class PlexObject:
initpath (str): Relative path requested when retrieving specified `data` (optional).
parent (:class:`~plexapi.base.PlexObject`): The parent object that this object is built from (optional).
"""
TAG = None # xml element tag
TYPE = None # xml element type
key = None # plex relative url
TAG: Optional[str] = None # xml element tag
TYPE: Optional[str] = None # xml element type
key: Optional[str] = None # plex relative url
ratingKey: Optional[int] = None

def __init__(self, server, data, initpath=None, parent=None):
self._server = server
Expand Down Expand Up @@ -82,7 +86,7 @@ def _clean(self, value):
value = value.replace('/devices/', '')
return value.replace(' ', '-')[:20]

def _buildItem(self, elem, cls=None, initpath=None):
def _buildItem(self, elem, cls: Optional[T] = None, initpath=None):
""" Factory function to build objects based on registered PLEXOBJECTS. """
# cls is specified, build the object and return
initpath = initpath or self._initpath
Expand Down Expand Up @@ -309,7 +313,7 @@ def fetchItem(self, ekey, cls=None, **kwargs):
clsname = cls.__name__ if cls else 'None'
raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}') from None

def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs):
def findItems(self, data, cls: Optional[T] = 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
on how this is used.
Expand Down Expand Up @@ -709,158 +713,6 @@ def playQueue(self, *args, **kwargs):
return PlayQueue.create(self._server, self, *args, **kwargs)


class Playable:
""" This is a general place to store functions specific to media that is Playable.
Things were getting mixed up a bit when dealing with Shows, Season, Artists,
Albums which are all not playable.

Attributes:
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items).
"""

def _loadData(self, data):
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue

def getStreamURL(self, **kwargs):
""" Returns a stream url that may be used by external applications such as VLC.

Parameters:
**kwargs (dict): optional parameters to manipulate the playback when accessing
the stream. A few known parameters include: maxVideoBitrate, videoResolution
offset, copyts, protocol, mediaIndex, partIndex, platform.

Raises:
:exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
"""
if self.TYPE not in ('movie', 'episode', 'track', 'clip'):
raise Unsupported(f'Fetching stream URL for {self.TYPE} is unsupported.')

mvb = kwargs.pop('maxVideoBitrate', None)
vr = kwargs.pop('videoResolution', '')
protocol = kwargs.pop('protocol', None)

params = {
'path': self.key,
'mediaIndex': kwargs.pop('mediaIndex', 0),
'partIndex': kwargs.pop('mediaIndex', 0),
'protocol': protocol,
'fastSeek': kwargs.pop('fastSeek', 1),
'copyts': kwargs.pop('copyts', 1),
'offset': kwargs.pop('offset', 0),
'maxVideoBitrate': max(mvb, 64) if mvb else None,
'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None,
'X-Plex-Platform': kwargs.pop('platform', 'Chrome')
}
params.update(kwargs)

# remove None values
params = {k: v for k, v in params.items() if v is not None}
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
ext = 'mpd' if protocol == 'dash' else 'm3u8'

return self._server.url(
f'/{streamtype}/:/transcode/universal/start.{ext}?{urlencode(params)}',
includeToken=True
)

def iterParts(self):
""" Iterates over the parts of this media item. """
for item in self.media:
for part in item.parts:
yield part

def play(self, client):
""" Start playback on the specified client.

Parameters:
client (:class:`~plexapi.client.PlexClient`): Client to start playing on.
"""
client.playMedia(self)

def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads the media item to the specified location. Returns a list of
filepaths that have been saved to disk.

Parameters:
savepath (str): Defaults to current working dir.
keep_original_name (bool): True to keep the original filename otherwise
a friendlier filename is generated. See filenames below.
**kwargs (dict): Additional options passed into :func:`~plexapi.audio.Track.getStreamURL`
to download a transcoded stream, otherwise the media item will be downloaded
as-is and saved to disk.

**Filenames**

* Movie: ``<title> (<year>)``
* Episode: ``<show title> - s00e00 - <episode title>``
* Track: ``<artist title> - <album title> - 00 - <track title>``
* Photo: ``<photoalbum title> - <photo/clip title>`` or ``<photo/clip title>``
"""
filepaths = []
parts = [i for i in self.iterParts() if i]

for part in parts:
if not keep_original_name:
filename = utils.cleanFilename(f'{self._prettyfilename()}.{part.container}')
else:
filename = part.file

if kwargs:
# So this seems to be a a lot slower but allows transcode.
download_url = self.getStreamURL(**kwargs)
else:
download_url = self._server.url(f'{part.key}?download=1')

filepath = utils.download(
download_url,
self._server._token,
filename=filename,
savepath=savepath,
session=self._server._session
)

if filepath:
filepaths.append(filepath)

return filepaths

def updateProgress(self, time, state='stopped'):
""" Set the watched progress for this video.

Note that setting the time to 0 will not work.
Use :func:`~plexapi.mixins.PlayedUnplayedMixin.markPlayed` or
:func:`~plexapi.mixins.PlayedUnplayedMixin.markUnplayed` to achieve
that goal.

Parameters:
time (int): milliseconds watched
state (string): state of the video, default 'stopped'
"""
key = f'/:/progress?key={self.ratingKey}&identifier=com.plexapp.plugins.library&time={time}&state={state}'
self._server.query(key)
return self

def updateTimeline(self, time, state='stopped', duration=None):
""" Set the timeline progress for this video.

Parameters:
time (int): milliseconds watched
state (string): state of the video, default 'stopped'
duration (int): duration of the item
"""
durationStr = '&duration='
if duration is not None:
durationStr = durationStr + str(duration)
else:
durationStr = durationStr + str(self.duration)
key = (f'/:/timeline?ratingKey={self.ratingKey}&key={self.key}&'
f'identifier=com.plexapp.plugins.library&time={int(time)}&state={state}{durationStr}')
self._server.query(key)
return self


class PlexSession(object):
""" This is a general place to store functions specific to media that is a Plex Session.

Expand Down
6 changes: 3 additions & 3 deletions plexapi/photo.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
from urllib.parse import quote_plus

from plexapi import media, utils, video
from plexapi.base import Playable, PlexPartialObject, PlexSession
from plexapi.base import PlexPartialObject, PlexSession
from plexapi.exceptions import BadRequest
from plexapi.mixins import (
RatingMixin,
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin,
PhotoalbumEditMixins, PhotoEditMixins
)
from plexapi.playable import Playable


@utils.registerPlexObject
Expand Down Expand Up @@ -149,7 +150,7 @@ def metadataDirectory(self):

@utils.registerPlexObject
class Photo(
PlexPartialObject, Playable,
Playable,
RatingMixin,
ArtUrlMixin, PosterUrlMixin,
PhotoEditMixins
Expand Down Expand Up @@ -209,7 +210,6 @@ def _loadData(self, data):
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'photo'
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
Expand Down