diff --git a/plexapi/audio.py b/plexapi/audio.py index 10ba97689..a7693e2fd 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -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 @@ -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')) diff --git a/plexapi/base.py b/plexapi/base.py index 4e6d59d8b..fcae1903c 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -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 @@ -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 = { @@ -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 @@ -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 @@ -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. @@ -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: `` (<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. diff --git a/plexapi/photo.py b/plexapi/photo.py index 8737d814c..5d2ff39c3 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -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 @@ -149,7 +150,7 @@ def metadataDirectory(self): @utils.registerPlexObject class Photo( - PlexPartialObject, Playable, + Playable, RatingMixin, ArtUrlMixin, PosterUrlMixin, PhotoEditMixins @@ -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')) diff --git a/plexapi/playable.py b/plexapi/playable.py new file mode 100644 index 000000000..180e86c53 --- /dev/null +++ b/plexapi/playable.py @@ -0,0 +1,168 @@ +import re +from urllib.parse import urlencode + +from plexapi import media, utils +from plexapi.base import PlexPartialObject +from plexapi.exceptions import Unsupported + + +class Playable(PlexPartialObject): + """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")) + self.playQueueItemID = utils.cast(int, data.attrib.get("playQueueItemID")) + self.media = self.findItems(data, media.Media) + self.duration = utils.cast(int, data.attrib.get("duration")) + + # @abstractmethod + def _prettyfilename(self): + """Returns a pretty filename for this media item.""" + + 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 diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 14ef88edb..eeb535726 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -4,16 +4,17 @@ from urllib.parse import quote_plus, unquote from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection, MusicSection from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin, PlaylistEditMixins +from plexapi.playable import Playable + from plexapi.utils import deprecated @utils.registerPlexObject class Playlist( - PlexPartialObject, Playable, + Playable, SmartFilterMixin, ArtMixin, PosterMixin, PlaylistEditMixins diff --git a/plexapi/video.py b/plexapi/video.py index e95b12ffb..c7e8f50dc 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus 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, @@ -13,6 +13,7 @@ MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins, WatchlistMixin ) +from plexapi.playable import Playable class Video(PlexPartialObject, PlayedUnplayedMixin): @@ -378,7 +379,6 @@ def _loadData(self, data): self.labels = self.findItems(data, media.Label) self.languageOverride = data.attrib.get('languageOverride') self.markers = self.findItems(data, media.Marker) - self.media = self.findItems(data, media.Media) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originalTitle = data.attrib.get('originalTitle') self.primaryExtraKey = data.attrib.get('primaryExtraKey') @@ -905,7 +905,6 @@ def _loadData(self, data): self.index = utils.cast(int, data.attrib.get('index')) self.labels = self.findItems(data, media.Label) self.markers = self.findItems(data, media.Marker) - 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')) @@ -1090,7 +1089,6 @@ def _loadData(self, data): 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 = utils.toDatetime( data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))