From c55c00a19e3a3ec397f3291877e9f6bb64bd3ff4 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Tue, 24 Mar 2020 22:10:43 -0700 Subject: [PATCH] Update PlexAPI to 3.3.0 --- lib/plexapi/__init__.py | 5 +- lib/plexapi/alert.py | 12 ++ lib/plexapi/audio.py | 64 ++++++- lib/plexapi/base.py | 38 +++- lib/plexapi/client.py | 45 +++-- lib/plexapi/compat.py | 70 ++++++++ lib/plexapi/config.py | 1 + lib/plexapi/library.py | 373 ++++++++++++++++++++++++++++++++++++++-- lib/plexapi/media.py | 63 +++++++ lib/plexapi/myplex.py | 287 ++++++++++++++++++++++++++++--- lib/plexapi/photo.py | 47 ++++- lib/plexapi/playlist.py | 142 ++++++++++++++- lib/plexapi/server.py | 83 +++++++-- lib/plexapi/settings.py | 9 +- lib/plexapi/sync.py | 312 ++++++++++++++++++++++++++++++--- lib/plexapi/utils.py | 42 +++-- lib/plexapi/video.py | 101 +++++++++-- 17 files changed, 1559 insertions(+), 135 deletions(-) diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py index 48d28060..119dc7ae 100644 --- a/lib/plexapi/__init__.py +++ b/lib/plexapi/__init__.py @@ -14,13 +14,14 @@ CONFIG = PlexConfig(CONFIG_PATH) # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '3.0.6' +VERSION = '3.3.0' TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) +X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool) # Plex Header Configuation X_PLEX_PROVIDES = CONFIG.get('header.provides', 'controller') -X_PLEX_PLATFORM = CONFIG.get('header.platorm', uname()[0]) +X_PLEX_PLATFORM = CONFIG.get('header.platform', CONFIG.get('header.platorm', uname()[0])) X_PLEX_PLATFORM_VERSION = CONFIG.get('header.platform_version', uname()[2]) X_PLEX_PRODUCT = CONFIG.get('header.product', PROJECT) X_PLEX_VERSION = CONFIG.get('header.version', VERSION) diff --git a/lib/plexapi/alert.py b/lib/plexapi/alert.py index dc1c76e1..2a19c6d8 100644 --- a/lib/plexapi/alert.py +++ b/lib/plexapi/alert.py @@ -12,6 +12,18 @@ class AlertListener(threading.Thread): alerts you must call .start() on the object once it's created. When calling `PlexServer.startAlertListener()`, the thread will be started for you. + Known `state`-values for timeline entries, with identifier=`com.plexapp.plugins.library`: + + :0: The item was created + :1: Reporting progress on item processing + :2: Matching the item + :3: Downloading the metadata + :4: Processing downloaded metadata + :5: The item processed + :9: The item deleted + + When metadata agent is not set for the library processing ends with state=1. + Parameters: server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to. callback (func): Callback function to call on recieved messages. The callback function diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index 6f6c0d98..d6826831 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from plexapi import media, utils from plexapi.base import Playable, PlexPartialObject +from plexapi.compat import quote_plus class Audio(PlexPartialObject): @@ -23,6 +24,9 @@ class Audio(PlexPartialObject): updatedAt (datatime): Datetime this item was updated. viewCount (int): Count of times this item was accessed. """ + + METADATA_TYPE = 'track' + def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data @@ -57,6 +61,46 @@ class Audio(PlexPartialObject): """ Returns the full URL for this audio item. Typically used for getting a specific track. """ return self._server.url(part, includeToken=True) if part else None + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return self.title + + def sync(self, bitrate, client=None, clientId=None, limit=None, title=None): + """ Add current audio (artist, album or track) as sync item for specified device. + See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions. + + Parameters: + bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the + module :mod:`plexapi.sync`. + client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current media. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + """ + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self._defaultSyncTitle() + sync_item.rootTitle = self.title + sync_item.contentType = self.listType + sync_item.metadataType = self.METADATA_TYPE + sync_item.machineIdentifier = self._server.machineIdentifier + + section = self._server.library.sectionByID(self.librarySectionID) + + sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key)) + sync_item.policy = Policy.create(limit) + sync_item.mediaSettings = MediaSettings.createMusic(bitrate) + + return myplex.sync(sync_item, client=client, clientId=clientId) + @utils.registerPlexObject class Artist(Audio): @@ -124,12 +168,12 @@ class Artist(Audio): """ Alias of :func:`~plexapi.audio.Artist.track`. """ return self.track(title) - def download(self, savepath=None, keep_orginal_name=False, **kwargs): + def download(self, savepath=None, keep_original_name=False, **kwargs): """ Downloads all tracks for this artist to the specified location. Parameters: savepath (str): Title of the track to return. - keep_orginal_name (bool): Set True to keep the original filename as stored in + keep_original_name (bool): Set True to keep the original filename as stored in the Plex server. False will create a new filename with the format " - ". kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will @@ -140,7 +184,7 @@ class Artist(Audio): filepaths = [] for album in self.albums(): for track in album.tracks(): - filepaths += track.download(savepath, keep_orginal_name, **kwargs) + filepaths += track.download(savepath, keep_original_name, **kwargs) return filepaths @@ -207,12 +251,12 @@ class Album(Audio): """ Return :func:`~plexapi.audio.Artist` of this album. """ return self.fetchItem(self.parentKey) - def download(self, savepath=None, keep_orginal_name=False, **kwargs): + def download(self, savepath=None, keep_original_name=False, **kwargs): """ Downloads all tracks for this artist to the specified location. Parameters: savepath (str): Title of the track to return. - keep_orginal_name (bool): Set True to keep the original filename as stored in + keep_original_name (bool): Set True to keep the original filename as stored in the Plex server. False will create a new filename with the format " - ". kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will @@ -222,9 +266,13 @@ class Album(Audio): """ filepaths = [] for track in self.tracks(): - filepaths += track.download(savepath, keep_orginal_name, **kwargs) + filepaths += track.download(savepath, keep_original_name, **kwargs) return filepaths + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return '%s - %s' % (self.parentTitle, self.title) + @utils.registerPlexObject class Track(Audio, Playable): @@ -302,3 +350,7 @@ class Track(Audio, Playable): def artist(self): """ Return this track's :class:`~plexapi.audio.Artist`. """ return self.fetchItem(self.grandparentKey) + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index f452e3e1..46a54581 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -6,6 +6,7 @@ from plexapi.compat import quote_plus, urlencode from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported from plexapi.utils import tag_helper +DONT_RELOAD_FOR_KEYS = ['key', 'session'] OPERATORS = { 'exact': lambda v, q: v == q, 'iexact': lambda v, q: v.lower() == q.lower(), @@ -145,7 +146,12 @@ class PlexObject(object): on how this is used. """ data = self._server.query(ekey) - return self.findItems(data, cls, ekey, **kwargs) + items = self.findItems(data, cls, ekey, **kwargs) + librarySectionID = data.attrib.get('librarySectionID') + if librarySectionID: + for item in items: + item.librarySectionID = librarySectionID + return items def findItems(self, data, cls=None, initpath=None, **kwargs): """ Load the specified data to find and build all items with the specified tag @@ -273,7 +279,8 @@ class PlexPartialObject(PlexObject): # Dragons inside.. :-/ value = super(PlexPartialObject, self).__getattribute__(attr) # Check a few cases where we dont want to reload - if attr == 'key' or attr.startswith('_'): return value + if attr in DONT_RELOAD_FOR_KEYS: return value + if attr.startswith('_'): return value if value not in (None, []): return value if self.isFullObject(): return value # Log the reload. @@ -447,6 +454,7 @@ class Playable(object): 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.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist def isFullObject(self): @@ -466,7 +474,7 @@ class Playable(object): offset, copyts, protocol, mediaIndex, platform. Raises: - Unsupported: When the item doesn't support fetching a stream URL. + :class:`plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL. """ if self.TYPE not in ('movie', 'episode', 'track'): raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE) @@ -514,13 +522,13 @@ class Playable(object): """ client.playMedia(self) - def download(self, savepath=None, keep_orginal_name=False, **kwargs): + def download(self, savepath=None, keep_original_name=False, **kwargs): """ Downloads this items media to the specified location. Returns a list of filepaths that have been saved to disk. Parameters: savepath (str): Title of the track to return. - keep_orginal_name (bool): Set True to keep the original filename as stored in + keep_original_name (bool): Set True to keep the original filename as stored in the Plex server. False will create a new filename with the format " - ". kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will @@ -532,7 +540,7 @@ class Playable(object): locations = [i for i in self.iterParts() if i] for location in locations: filename = location.file - if keep_orginal_name is False: + if keep_original_name is False: filename = '%s.%s' % (self._prettyfilename(), location.container) # So this seems to be a alot slower but allows transcode. if kwargs: @@ -565,6 +573,24 @@ class Playable(object): time, state) self._server.query(key) self.reload() + + 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 = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s' + key %= (self.ratingKey, self.key, time, state, durationStr) + self._server.query(key) + self.reload() @utils.registerPlexObject diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index 77185e30..cd17c7b6 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +import time import requests from requests.status_codes import _codes as codes @@ -70,6 +70,7 @@ class PlexClient(PlexObject): self._session = session or server_session or requests.Session() self._proxyThroughServer = False self._commandId = 0 + self._last_call = 0 if not any([data, initpath, baseurl, token]): self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433') self._token = logfilter.add_secret(CONFIG.get('auth.client_token')) @@ -139,7 +140,7 @@ class PlexClient(PlexObject): value (bool): Enable or disable proxying (optional, default True). Raises: - :class:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server. + :class:`plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server. """ if server: self._server = server @@ -177,22 +178,39 @@ class PlexClient(PlexObject): **params (dict): Additional GET parameters to include with the command. Raises: - :class:`~plexapi.exceptions.Unsupported`: When we detect the client - doesn't support this capability. + :class:`plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability. """ command = command.strip('/') controller = command.split('/')[0] + headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier} if controller not in self.protocolCapabilities: log.debug('Client %s doesnt support %s controller.' 'What your trying might not work' % (self.title, controller)) + proxy = self._proxyThroughServer if proxy is None else proxy + query = self._server.query if proxy else self.query + + # Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244 + t = time.time() + if t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'): + url = '/player/timeline/poll?wait=0&commandID=%s' % self._nextCommandId() + query(url, headers=headers) + self._last_call = t + params['commandID'] = self._nextCommandId() key = '/player/%s%s' % (command, utils.joinArgs(params)) - headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier} - proxy = self._proxyThroughServer if proxy is None else proxy - if proxy: - return self._server.query(key, headers=headers) - return self.query(key, headers=headers) + + try: + return query(key, headers=headers) + except ElementTree.ParseError: + # Workaround for players which don't return valid XML on successful commands + # - Plexamp: `b'OK'` + if self.product in ( + 'Plexamp', + 'Plex for Android (TV)', + ): + return + raise def url(self, key, includeToken=False): """ Build a URL string with proper token argument. Token will be appended to the URL @@ -272,7 +290,7 @@ class PlexClient(PlexObject): **params (dict): Additional GET parameters to include with the command. Raises: - :class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. + :class:`plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. """ if not self._server: raise Unsupported('A server must be specified before using this command.') @@ -440,15 +458,16 @@ class PlexClient(PlexObject): also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands Raises: - :class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. + :class:`plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. """ if not self._server: raise Unsupported('A server must be specified before using this command.') server_url = media._server._baseurl.split(':') + server_port = server_url[-1].strip('/') if self.product != 'OpenPHT': try: - self.sendCommand('timeline/subscribe', port=server_url[1].strip('/'), protocol='http') + self.sendCommand('timeline/subscribe', port=server_port, protocol='http') except: # noqa: E722 # some clients dont need or like this and raises http 400. # We want to include the exception in the log, @@ -459,7 +478,7 @@ class PlexClient(PlexObject): self.sendCommand('playback/playMedia', **dict({ 'machineIdentifier': self._server.machineIdentifier, 'address': server_url[1].strip('/'), - 'port': server_url[-1], + 'port': server_port, 'offset': offset, 'key': media.key, 'token': media._server._token, diff --git a/lib/plexapi/compat.py b/lib/plexapi/compat.py index 07b749d5..4a163ed1 100644 --- a/lib/plexapi/compat.py +++ b/lib/plexapi/compat.py @@ -2,6 +2,7 @@ # Python 2/3 compatability # Always try Py3 first import os +import sys from sys import version_info ustr = str @@ -43,6 +44,11 @@ try: except ImportError: from xml.etree import ElementTree +try: + from unittest.mock import patch, MagicMock +except ImportError: + from mock import patch, MagicMock + def makedirs(name, mode=0o777, exist_ok=False): """ Mimicks os.makedirs() from Python 3. """ @@ -51,3 +57,67 @@ def makedirs(name, mode=0o777, exist_ok=False): except OSError: if not os.path.isdir(name) or not exist_ok: raise + + +def which(cmd, mode=os.F_OK | os.X_OK, path=None): + """Given a command, mode, and a PATH string, return the path which + conforms to the given mode on the PATH, or None if there is no such + file. + + `mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result + of os.environ.get("PATH"), or can be overridden with a custom search + path. + + Copied from https://hg.python.org/cpython/file/default/Lib/shutil.py + """ + # Check that a given file can be accessed with the correct mode. + # Additionally check that `file` is not a directory, as on Windows + # directories pass the os.access check. + def _access_check(fn, mode): + return (os.path.exists(fn) and os.access(fn, mode) + and not os.path.isdir(fn)) + + # If we're given a path with a directory part, look it up directly rather + # than referring to PATH directories. This includes checking relative to the + # current directory, e.g. ./script + if os.path.dirname(cmd): + if _access_check(cmd, mode): + return cmd + return None + + if path is None: + path = os.environ.get("PATH", os.defpath) + if not path: + return None + path = path.split(os.pathsep) + + if sys.platform == "win32": + # The current directory takes precedence on Windows. + if not os.curdir in path: + path.insert(0, os.curdir) + + # PATHEXT is necessary to check on Windows. + pathext = os.environ.get("PATHEXT", "").split(os.pathsep) + # See if the given file matches any of the expected path extensions. + # This will allow us to short circuit when given "python.exe". + # If it does match, only test that one, otherwise we have to try + # others. + if any(cmd.lower().endswith(ext.lower()) for ext in pathext): + files = [cmd] + else: + files = [cmd + ext for ext in pathext] + else: + # On other platforms you don't have things like PATHEXT to tell you + # what file suffixes are executable, so just pass on cmd as-is. + files = [cmd] + + seen = set() + for dir in path: + normdir = os.path.normcase(dir) + if not normdir in seen: + seen.add(normdir) + for thefile in files: + name = os.path.join(dir, thefile) + if _access_check(name, mode): + return name + return None diff --git a/lib/plexapi/config.py b/lib/plexapi/config.py index 20f9a96e..47eebd8b 100644 --- a/lib/plexapi/config.py +++ b/lib/plexapi/config.py @@ -60,4 +60,5 @@ def reset_base_headers(): 'X-Plex-Device': plexapi.X_PLEX_DEVICE, 'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME, 'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER, + 'X-Plex-Sync-Version': '2', } diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index 0afff3d3..1c3cbc30 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -377,9 +377,17 @@ class LibrarySection(PlexObject): key = '/library/sections/%s/all' % self.key return self.fetchItem(key, title__iexact=title) - def all(self, **kwargs): - """ Returns a list of media from this library section. """ - key = '/library/sections/%s/all' % self.key + def all(self, sort=None, **kwargs): + """ Returns a list of media from this library section. + + Parameters: + sort (string): The sort string + """ + sortStr = '' + if sort is not None: + sortStr = '?sort=' + sort + + key = '/library/sections/%s/all%s' % (self.key, sortStr) return self.fetchItems(key, **kwargs) def onDeck(self): @@ -443,7 +451,7 @@ class LibrarySection(PlexObject): **kwargs (dict): Additional kwargs to narrow down the choices. Raises: - :class:`~plexapi.exceptions.BadRequest`: Cannot include kwarg equal to specified category. + :class:`plexapi.exceptions.BadRequest`: Cannot include kwarg equal to specified category. """ # TODO: Should this be moved to base? if category in kwargs: @@ -470,8 +478,8 @@ class LibrarySection(PlexObject): libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist, album, track; optional). **kwargs (dict): Any of the available filters for the current library section. Partial string - matches allowed. Multiple matches OR together. All inputs will be compared with the - available options and a warning logged if the option does not appear valid. + matches allowed. Multiple matches OR together. Negative filtering also possible, just add an + exclamation mark to the end of filter name, e.g. `resolution!=1x1`. * unwatched: Display or hide unwatched content (True, False). [all] * duplicate: Display or hide duplicate items (True, False). [movie] @@ -486,6 +494,9 @@ class LibrarySection(PlexObject): * resolution: List of video resolutions to search within ([resolution_or_key, ...]). [movie] * studio: List of studios to search within ([studio_or_key, ...]). [music] * year: List of years to search within ([yyyy, ...]). [all] + + Raises: + :class:`plexapi.exceptions.BadRequest`: when applying unknown filter """ # cleanup the core arguments args = {} @@ -510,7 +521,10 @@ class LibrarySection(PlexObject): def _cleanSearchFilter(self, category, value, libtype=None): # check a few things before we begin - if category not in self.ALLOWED_FILTERS: + if category.endswith('!'): + if category[:-1] not in self.ALLOWED_FILTERS: + raise BadRequest('Unknown filter category: %s' % category[:-1]) + elif category not in self.ALLOWED_FILTERS: raise BadRequest('Unknown filter category: %s' % category) if category in self.BOOLEAN_FILTERS: return '1' if value else '0' @@ -543,6 +557,82 @@ class LibrarySection(PlexObject): raise BadRequest('Unknown sort dir: %s' % sdir) return '%s:%s' % (lookup[scol], sdir) + def sync(self, policy, mediaSettings, client=None, clientId=None, title=None, sort=None, libtype=None, + **kwargs): + """ Add current library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search()` for details about filtering / sorting + and :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions. + + Parameters: + policy (:class:`plexapi.sync.Policy`): policy of syncing the media (how many items to sync and process + watched media or not), generated automatically when method + called on specific LibrarySection object. + mediaSettings (:class:`plexapi.sync.MediaSettings`): Transcoding settings used for the media, generated + automatically when method called on specific + LibrarySection object. + client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`. + title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current media. + sort (str): formatted as `column:dir`; column can be any of {`addedAt`, `originallyAvailableAt`, + `lastViewedAt`, `titleSort`, `rating`, `mediaHeight`, `duration`}. dir can be `asc` or + `desc`. + libtype (str): Filter results to a specific libtype (`movie`, `show`, `episode`, `artist`, `album`, + `track`). + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + + Raises: + :class:`plexapi.exceptions.BadRequest`: when the library is not allowed to sync + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import Policy, MediaSettings, VIDEO_QUALITY_3_MBPS_720p + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Movies') + policy = Policy('count', unwatched=True, value=1) + media_settings = MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p) + section.sync(target, policy, media_settings, title='Next best movie', sort='rating:desc') + + """ + from plexapi.sync import SyncItem + + if not self.allowSync: + raise BadRequest('The requested library is not allowed to sync') + + args = {} + for category, value in kwargs.items(): + args[category] = self._cleanSearchFilter(category, value, libtype) + if sort is not None: + args['sort'] = self._cleanSearchSort(sort) + if libtype is not None: + args['type'] = utils.searchType(libtype) + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self.title + sync_item.rootTitle = self.title + sync_item.contentType = self.CONTENT_TYPE + sync_item.metadataType = self.METADATA_TYPE + sync_item.machineIdentifier = self._server.machineIdentifier + + key = '/library/sections/%s/all' % self.key + + sync_item.location = 'library://%s/directory/%s' % (self.uuid, quote_plus(key + utils.joinArgs(args))) + sync_item.policy = policy + sync_item.mediaSettings = mediaSettings + + return myplex.sync(client=client, clientId=clientId, sync_item=sync_item) + class MovieSection(LibrarySection): """ Represents a :class:`~plexapi.library.LibrarySection` section containing movies. @@ -559,11 +649,53 @@ class MovieSection(LibrarySection): """ ALLOWED_FILTERS = ('unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection', 'director', 'actor', 'country', 'studio', 'resolution', - 'guid', 'label') + 'guid', 'label', 'writer', 'producer', 'subtitleLanguage', 'audioLanguage', + 'lastViewedAt', 'viewCount', 'addedAt') ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating', 'mediaHeight', 'duration') TAG = 'Directory' TYPE = 'movie' + METADATA_TYPE = 'movie' + CONTENT_TYPE = 'video' + + def collection(self, **kwargs): + """ Returns a list of collections from this library section. """ + return self.search(libtype='collection', **kwargs) + + def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): + """ Add current Movie library section as sync item for specified device. + See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and + :func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`plexapi.sync` module. + limit (int): maximum count of movies to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Movies') + section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True, + title='Next best movie', sort='rating:desc') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) + kwargs['policy'] = Policy.create(limit, unwatched) + return super(MovieSection, self).sync(**kwargs) class ShowSection(LibrarySection): @@ -578,11 +710,17 @@ class ShowSection(LibrarySection): TYPE (str): 'show' """ ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection', - 'guid', 'duplicate', 'label') + 'guid', 'duplicate', 'label', 'show.title', 'show.year', 'show.userRating', + 'show.viewCount', 'show.lastViewedAt', 'show.actor', 'show.addedAt', 'episode.title', + 'episode.originallyAvailableAt', 'episode.resolution', 'episode.subtitleLanguage', + 'episode.unwatched', 'episode.addedAt', 'episode.userRating', 'episode.viewCount', + 'episode.lastViewedAt') ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort', 'rating', 'unwatched') TAG = 'Directory' TYPE = 'show' + METADATA_TYPE = 'episode' + CONTENT_TYPE = 'video' def searchShows(self, **kwargs): """ Search for a show. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ @@ -600,6 +738,45 @@ class ShowSection(LibrarySection): """ return self.search(sort='addedAt:desc', libtype=libtype, maxresults=maxresults) + def collection(self, **kwargs): + """ Returns a list of collections from this library section. """ + return self.search(libtype='collection', **kwargs) + + def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): + """ Add current Show library section as sync item for specified device. + See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and + :func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`plexapi.sync` module. + limit (int): maximum count of episodes to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('TV-Shows') + section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True, + title='Next unwatched episode') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) + kwargs['policy'] = Policy.create(limit, unwatched) + return super(ShowSection, self).sync(**kwargs) + class MusicSection(LibrarySection): """ Represents a :class:`~plexapi.library.LibrarySection` section containing music artists. @@ -612,11 +789,19 @@ class MusicSection(LibrarySection): TAG (str): 'Directory' TYPE (str): 'artist' """ - ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood') - ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort') + ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood', 'year', 'track.userRating', 'artist.title', + 'artist.userRating', 'artist.genre', 'artist.country', 'artist.collection', 'artist.addedAt', + 'album.title', 'album.userRating', 'album.genre', 'album.decade', 'album.collection', + 'album.viewCount', 'album.lastViewedAt', 'album.studio', 'album.addedAt', 'track.title', + 'track.userRating', 'track.viewCount', 'track.lastViewedAt', 'track.skipCount', + 'track.lastSkippedAt') + ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort', 'userRating') TAG = 'Directory' TYPE = 'artist' + CONTENT_TYPE = 'audio' + METADATA_TYPE = 'track' + def albums(self): """ Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ key = '/library/sections/%s/albums' % self.key @@ -634,31 +819,104 @@ class MusicSection(LibrarySection): """ Search for a track. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ return self.search(libtype='track', **kwargs) + def collection(self, **kwargs): + """ Returns a list of collections from this library section. """ + return self.search(libtype='collection', **kwargs) + + def sync(self, bitrate, limit=None, **kwargs): + """ Add current Music library section as sync item for specified device. + See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and + :func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions. + + Parameters: + bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the + module :mod:`plexapi.sync`. + limit (int): maximum count of tracks to sync, unlimited if `None`. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import AUDIO_BITRATE_320_KBPS + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Music') + section.sync(AUDIO_BITRATE_320_KBPS, client=target, limit=100, sort='addedAt:desc', + title='New music') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createMusic(bitrate) + kwargs['policy'] = Policy.create(limit) + return super(MusicSection, self).sync(**kwargs) + class PhotoSection(LibrarySection): """ Represents a :class:`~plexapi.library.LibrarySection` section containing photos. Attributes: ALLOWED_FILTERS (list): List of allowed search filters. ('all', 'iso', - 'make', 'lens', 'aperture', 'exposure') + 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution') ALLOWED_SORT (list): List of allowed sorting keys. ('addedAt') TAG (str): 'Directory' TYPE (str): 'photo' """ - ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure') + ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution', 'place', + 'originallyAvailableAt', 'addedAt', 'title', 'userRating') ALLOWED_SORT = ('addedAt',) TAG = 'Directory' TYPE = 'photo' + CONTENT_TYPE = 'photo' + METADATA_TYPE = 'photo' def searchAlbums(self, title, **kwargs): """ Search for an album. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ - key = '/library/sections/%s/all?type=14' % self.key - return self.fetchItems(key, title=title) + return self.search(libtype='photoalbum', title=title, **kwargs) def searchPhotos(self, title, **kwargs): """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ - key = '/library/sections/%s/all?type=13' % self.key - return self.fetchItems(key, title=title) + return self.search(libtype='photo', title=title, **kwargs) + + def sync(self, resolution, limit=None, **kwargs): + """ Add current Music library section as sync item for specified device. + See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and + :func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions. + + Parameters: + resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the + module :mod:`plexapi.sync`. + limit (int): maximum count of tracks to sync, unlimited if `None`. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import PHOTO_QUALITY_HIGH + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Photos') + section.sync(PHOTO_QUALITY_HIGH, client=target, limit=100, sort='addedAt:desc', + title='Fresh photos') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createPhoto(resolution) + kwargs['policy'] = Policy.create(limit) + return super(PhotoSection, self).sync(**kwargs) class FilterChoice(PlexObject): @@ -714,3 +972,84 @@ class Hub(PlexObject): def __len__(self): return self.size + + +@utils.registerPlexObject +class Collections(PlexObject): + + TAG = 'Directory' + TYPE = 'collection' + + def _loadData(self, data): + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.key = data.attrib.get('key') + self.type = data.attrib.get('type') + self.title = data.attrib.get('title') + self.subtype = data.attrib.get('subtype') + self.summary = data.attrib.get('summary') + self.index = utils.cast(int, data.attrib.get('index')) + self.thumb = data.attrib.get('thumb') + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.childCount = utils.cast(int, data.attrib.get('childCount')) + self.minYear = utils.cast(int, data.attrib.get('minYear')) + self.maxYear = utils.cast(int, data.attrib.get('maxYear')) + self.collectionMode = data.attrib.get('collectionMode') + self.collectionSort = data.attrib.get('collectionSort') + + @property + def children(self): + return self.fetchItems(self.key) + + def __len__(self): + return self.childCount + + def delete(self): + part = '/library/metadata/%s' % self.ratingKey + return self._server.query(part, method=self._server._session.delete) + + def modeUpdate(self, mode=None): + """ Update Collection Mode + + Parameters: + mode: default (Library default) + hide (Hide Collection) + hideItems (Hide Items in this Collection) + showItems (Show this Collection and its Items) + Example: + + collection = 'plexapi.library.Collections' + collection.updateMode(mode="hide") + """ + mode_dict = {'default': '-2', + 'hide': '0', + 'hideItems': '1', + 'showItems': '2'} + key = mode_dict.get(mode) + if key is None: + raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict))) + part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key) + return self._server.query(part, method=self._server._session.put) + + def sortUpdate(self, sort=None): + """ Update Collection Sorting + + Parameters: + sort: realease (Order Collection by realease dates) + alpha (Order Collection Alphabetically) + + Example: + + colleciton = 'plexapi.library.Collections' + collection.updateSort(mode="alpha") + """ + sort_dict = {'release': '0', + 'alpha': '1'} + key = sort_dict.get(sort) + if key is None: + raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) + part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) + return self._server.query(part, method=self._server._session.put) + + # def edit(self, **kwargs): + # TODO diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 81f7d6f9..cf1685ae 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -25,9 +25,12 @@ class Media(PlexObject): id (int): Plex ID of this media item (ex: 46184). has64bitOffsets (bool): True if video has 64 bit offsets (?). optimizedForStreaming (bool): True if video is optimized for streaming. + target (str): Media version target name. + title (str): Media version title. videoCodec (str): Video codec used within the video (ex: ac3). videoFrameRate (str): Video frame rate (ex: 24p). videoResolution (str): Video resolution (ex: sd). + videoProfile (str): Video profile (ex: high). width (int): Width of the video in pixels (ex: 608). parts (list<:class:`~plexapi.media.MediaPart`>): List of MediaParts in this video. """ @@ -46,8 +49,11 @@ class Media(PlexObject): self.id = cast(int, data.attrib.get('id')) self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets')) self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) + self.target = data.attrib.get('target') + self.title = data.attrib.get('title') self.videoCodec = data.attrib.get('videoCodec') self.videoFrameRate = data.attrib.get('videoFrameRate') + self.videoProfile = data.attrib.get('videoProfile') self.videoResolution = data.attrib.get('videoResolution') self.width = cast(int, data.attrib.get('width')) self.parts = self.findItems(data, MediaPart) @@ -79,6 +85,8 @@ class MediaPart(PlexObject): key (str): Key used to access this media part (ex: /library/parts/46618/1389985872/file.avi). size (int): Size of this file in bytes (ex: 733884416). streams (list<:class:`~plexapi.media.MediaPartStream`>): List of streams in this media part. + exists (bool): Determine if file exists + accessible (bool): Determine if file is accessible """ TAG = 'Part' @@ -92,7 +100,14 @@ class MediaPart(PlexObject): self.indexes = data.attrib.get('indexes') self.key = data.attrib.get('key') self.size = cast(int, data.attrib.get('size')) + self.decision = data.attrib.get('decision') + self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) + self.syncItemId = cast(int, data.attrib.get('syncItemId')) + self.syncState = data.attrib.get('syncState') + self.videoProfile = data.attrib.get('videoProfile') self.streams = self._buildStreams(data) + self.exists = cast(bool, data.attrib.get('exists')) + self.accessible = cast(bool, data.attrib.get('accessible')) def _buildStreams(self, data): streams = [] @@ -114,6 +129,35 @@ class MediaPart(PlexObject): """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """ return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE] + def setDefaultAudioStream(self, stream): + """ Set the default :class:`~plexapi.media.AudioStream` for this MediaPart. + + Parameters: + stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default + """ + if isinstance(stream, AudioStream): + key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream.id) + else: + key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream) + self._server.query(key, method=self._server._session.put) + + def setDefaultSubtitleStream(self, stream): + """ Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart. + + Parameters: + stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default. + """ + if isinstance(stream, SubtitleStream): + key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream.id) + else: + key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream) + self._server.query(key, method=self._server._session.put) + + def resetDefaultSubtitleStream(self): + """ Set default subtitle of this MediaPart to 'none'. """ + key = "/library/parts/%d?subtitleStreamID=0&allParts=1" % (self.id) + self._server.query(key, method=self._server._session.put) + class MediaPartStream(PlexObject): """ Base class for media streams. These consist of video, audio and subtitles. @@ -245,6 +289,7 @@ class SubtitleStream(MediaPartStream): Attributes: TAG (str): 'Stream' STREAMTYPE (int): 3 + forced (bool): True if this is a forced subtitle format (str): Subtitle format (ex: srt). key (str): Key of this subtitle stream (ex: /library/streams/212284). title (str): Title of this subtitle stream. @@ -255,6 +300,7 @@ class SubtitleStream(MediaPartStream): def _loadData(self, data): """ Load attribute values from Plex XML response. """ super(SubtitleStream, self)._loadData(data) + self.forced = cast(bool, data.attrib.get('forced', '0')) self.format = data.attrib.get('format') self.key = data.attrib.get('key') self.title = data.attrib.get('title') @@ -421,6 +467,23 @@ class Mood(MediaTag): FILTER = 'mood' +@utils.registerPlexObject +class Poster(PlexObject): + """ Represents a Poster. + + Attributes: + TAG (str): 'Photo' + """ + TAG = 'Photo' + + def _loadData(self, data): + self._data = data + self.key = data.attrib.get('key') + self.ratingKey = data.attrib.get('ratingKey') + self.selected = data.attrib.get('selected') + self.thumb = data.attrib.get('thumb') + + @utils.registerPlexObject class Producer(MediaTag): """ Represents a single Producer media tag. diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index bec46876..d3945fc0 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -3,7 +3,7 @@ import copy import requests import time from requests.status_codes import _codes as codes -from plexapi import BASE_HEADERS, CONFIG, TIMEOUT +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER, X_PLEX_ENABLE_FAST_CONNECT from plexapi import log, logfilter, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest, NotFound @@ -11,6 +11,7 @@ from plexapi.client import PlexClient from plexapi.compat import ElementTree from plexapi.library import LibrarySection from plexapi.server import PlexServer +from plexapi.sync import SyncList, SyncItem from plexapi.utils import joinArgs @@ -61,9 +62,12 @@ class MyPlexAccount(PlexObject): _session (obj): Requests session object used to access this client. """ FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data + HOMEUSERCREATE = 'https://plex.tv/api/home/users?title={title}' # post with data + EXISTINGUSER = 'https://plex.tv/api/home/users?invitedEmail={username}' # post with data FRIENDSERVERS = 'https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}' # put with data PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete + REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=0&server=1&home=0' # delete REQUESTED = 'https://plex.tv/api/invites/requested' # get REQUESTS = 'https://plex.tv/api/invites/requests' # get @@ -112,13 +116,31 @@ class MyPlexAccount(PlexObject): self.title = data.attrib.get('title') self.username = data.attrib.get('username') self.uuid = data.attrib.get('uuid') + subscription = data.find('subscription') + + self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active')) + self.subscriptionStatus = subscription.attrib.get('status') + self.subscriptionPlan = subscription.attrib.get('plan') + + self.subscriptionFeatures = [] + for feature in subscription.iter('feature'): + self.subscriptionFeatures.append(feature.attrib.get('id')) + + roles = data.find('roles') + self.roles = [] + if roles: + for role in roles.iter('role'): + self.roles.append(role.attrib.get('id')) + + entitlements = data.find('entitlements') + self.entitlements = [] + for entitlement in entitlements.iter('entitlement'): + self.entitlements.append(entitlement.attrib.get('id')) + # TODO: Fetch missing MyPlexAccount attributes - self.subscriptionActive = None # renamed on server - self.subscriptionStatus = None # renamed on server - self.subscriptionPlan = None # renmaed on server - self.subscriptionFeatures = None # renamed on server - self.roles = None - self.entitlements = None + self.profile_settings = None + self.services = None + self.joined_at = None def device(self, name): """ Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified. @@ -153,7 +175,6 @@ class MyPlexAccount(PlexObject): if response.status_code not in (200, 201, 204): # pragma: no cover codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None @@ -175,13 +196,14 @@ class MyPlexAccount(PlexObject): return [MyPlexResource(self, elem) for elem in data] def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, - allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): """ Share library content with the specified user. Parameters: user (str): MyPlexUser, username, email of the user to be added. server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. - sections ([Section]): Library sections, names or ids to be shared (default None shares all sections). + sections ([Section]): Library sections, names or ids to be shared (default None). + [Section] must be defined in order to update shared sections. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. allowChannels (Bool): Set True to allow user to utilize installed channels. @@ -211,6 +233,102 @@ class MyPlexAccount(PlexObject): url = self.FRIENDINVITE.format(machineId=machineId) return self.query(url, self._session.post, json=params, headers=headers) + def createHomeUser(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (str): MyPlexUser, username, email of the user to be added. + server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. + sections ([Section]): Library sections, names or ids to be shared (default None shares all sections). + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: {'label':['foo']} + """ + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(server, sections) + + headers = {'Content-Type': 'application/json'} + url = self.HOMEUSERCREATE.format(title=user) + # UserID needs to be created and referenced when adding sections + user_creation = self.query(url, self._session.post, headers=headers) + userIds = {} + for elem in user_creation.findall("."): + # Find userID + userIds['id'] = elem.attrib.get('id') + log.debug(userIds) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_id': userIds['id']}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + url = self.FRIENDINVITE.format(machineId=machineId) + library_assignment = self.query(url, self._session.post, json=params, headers=headers) + return user_creation, library_assignment + + def createExistingUser(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (str): MyPlexUser, username, email of the user to be added. + server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. + sections ([Section]): Library sections, names or ids to be shared (default None shares all sections). + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: {'label':['foo']} + """ + headers = {'Content-Type': 'application/json'} + # If user already exists, carry over sections and settings. + if isinstance(user, MyPlexUser): + username = user.username + elif user in [_user.username for _user in self.users()]: + username = self.user(user).username + else: + # If user does not already exists, treat request as new request and include sections and settings. + newUser = user + url = self.EXISTINGUSER.format(username=newUser) + user_creation = self.query(url, self._session.post, headers=headers) + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(server, sections) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_email': newUser}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + url = self.FRIENDINVITE.format(machineId=machineId) + library_assignment = self.query(url, self._session.post, json=params, headers=headers) + return user_creation, library_assignment + + url = self.EXISTINGUSER.format(username=username) + return self.query(url, self._session.post, headers=headers) + def removeFriend(self, user): """ Remove the specified user from all sharing. @@ -222,6 +340,16 @@ class MyPlexAccount(PlexObject): url = url.format(userId=user.id) return self.query(url, self._session.delete) + def removeHomeUser(self, user): + """ Remove the specified managed user from home. + + Parameters: + user (str): MyPlexUser, username, email of the user to be removed from home. + """ + user = self.user(user) + url = self.REMOVEHOMEUSER.format(userId=user.id) + return self.query(url, self._session.delete) + def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None, allowChannels=None, filterMovies=None, filterTelevision=None, filterMusic=None): """ Update the specified user's share settings. @@ -229,7 +357,8 @@ class MyPlexAccount(PlexObject): Parameters: user (str): MyPlexUser, username, email of the user to be added. server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. - sections: ([Section]): Library sections, names or ids to be shared (default None shares all sections). + sections: ([Section]): Library sections, names or ids to be shared (default None). + [Section] must be defined in order to update shared sections. removeSections (Bool): Set True to remove all shares. Supersedes sections. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. @@ -244,7 +373,7 @@ class MyPlexAccount(PlexObject): # Update friend servers response_filters = '' response_servers = '' - user = self.user(user.username if isinstance(user, MyPlexUser) else user) + user = user if isinstance(user, MyPlexUser) else self.user(user) machineId = server.machineIdentifier if isinstance(server, PlexServer) else server sectionIds = self._getSectionIds(machineId, sections) headers = {'Content-Type': 'application/json'} @@ -256,10 +385,10 @@ class MyPlexAccount(PlexObject): url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId) else: params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds, - "invited_id": user.id}} + 'invited_id': user.id}} url = self.FRIENDINVITE.format(machineId=machineId) # Remove share sections, add shares to user without shares, or update shares - if sectionIds: + if not user_servers or sectionIds: if removeSections is True: response_servers = self.query(url, self._session.delete, json=params, headers=headers) elif 'invited_id' in params.get('shared_server', ''): @@ -289,7 +418,7 @@ class MyPlexAccount(PlexObject): return response_servers, response_filters def user(self, username): - """ Returns the :class:`~myplex.MyPlexUser` that matches the email or username specified. + """ Returns the :class:`~plexapi.myplex.MyPlexUser` that matches the email or username specified. Parameters: username (str): Username, email or id of the user to return. @@ -357,7 +486,8 @@ class MyPlexAccount(PlexObject): def setWebhooks(self, urls): log.info('Setting webhooks: %s' % urls) - data = self.query(self.WEBHOOKS, self._session.post, data={'urls[]': urls}) + data = {'urls[]': urls} if len(urls) else {'urls': ''} + data = self.query(self.WEBHOOKS, self._session.post, data=data) self._webhooks = self.listAttrs(data, 'url', etag='webhook') return self._webhooks @@ -376,7 +506,99 @@ class MyPlexAccount(PlexObject): if library is not None: params['optOutLibraryStats'] = int(library) url = 'https://plex.tv/api/v2/user/privacy' - return self.query(url, method=self._session.put, params=params) + return self.query(url, method=self._session.put, data=params) + + def syncItems(self, client=None, clientId=None): + """ Returns an instance of :class:`plexapi.sync.SyncList` for specified client. + + Parameters: + client (:class:`~plexapi.myplex.MyPlexDevice`): a client to query SyncItems for. + clientId (str): an identifier of a client to query SyncItems for. + + If both `client` and `clientId` provided the client would be preferred. + If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier. + """ + if client: + clientId = client.clientIdentifier + elif clientId is None: + clientId = X_PLEX_IDENTIFIER + + data = self.query(SyncList.key.format(clientId=clientId)) + + return SyncList(self, data) + + def sync(self, sync_item, client=None, clientId=None): + """ Adds specified sync item for the client. It's always easier to use methods defined directly in the media + objects, e.g. :func:`plexapi.video.Video.sync`, :func:`plexapi.audio.Audio.sync`. + + Parameters: + client (:class:`~plexapi.myplex.MyPlexDevice`): a client for which you need to add SyncItem to. + clientId (str): an identifier of a client for which you need to add SyncItem to. + sync_item (:class:`plexapi.sync.SyncItem`): prepared SyncItem object with all fields set. + + If both `client` and `clientId` provided the client would be preferred. + If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + + Raises: + :class:`plexapi.exceptions.BadRequest`: when client with provided clientId wasn`t found. + :class:`plexapi.exceptions.BadRequest`: provided client doesn`t provides `sync-target`. + """ + if not client and not clientId: + clientId = X_PLEX_IDENTIFIER + + if not client: + for device in self.devices(): + if device.clientIdentifier == clientId: + client = device + break + + if not client: + raise BadRequest('Unable to find client by clientId=%s', clientId) + + if 'sync-target' not in client.provides: + raise BadRequest('Received client doesn`t provides sync-target') + + params = { + 'SyncItem[title]': sync_item.title, + 'SyncItem[rootTitle]': sync_item.rootTitle, + 'SyncItem[metadataType]': sync_item.metadataType, + 'SyncItem[machineIdentifier]': sync_item.machineIdentifier, + 'SyncItem[contentType]': sync_item.contentType, + 'SyncItem[Policy][scope]': sync_item.policy.scope, + 'SyncItem[Policy][unwatched]': str(int(sync_item.policy.unwatched)), + 'SyncItem[Policy][value]': str(sync_item.policy.value if hasattr(sync_item.policy, 'value') else 0), + 'SyncItem[Location][uri]': sync_item.location, + 'SyncItem[MediaSettings][audioBoost]': str(sync_item.mediaSettings.audioBoost), + 'SyncItem[MediaSettings][maxVideoBitrate]': str(sync_item.mediaSettings.maxVideoBitrate), + 'SyncItem[MediaSettings][musicBitrate]': str(sync_item.mediaSettings.musicBitrate), + 'SyncItem[MediaSettings][photoQuality]': str(sync_item.mediaSettings.photoQuality), + 'SyncItem[MediaSettings][photoResolution]': sync_item.mediaSettings.photoResolution, + 'SyncItem[MediaSettings][subtitleSize]': str(sync_item.mediaSettings.subtitleSize), + 'SyncItem[MediaSettings][videoQuality]': str(sync_item.mediaSettings.videoQuality), + 'SyncItem[MediaSettings][videoResolution]': sync_item.mediaSettings.videoResolution, + } + + url = SyncList.key.format(clientId=client.clientIdentifier) + data = self.query(url, method=self._session.post, headers={ + 'Content-type': 'x-www-form-urlencoded', + }, params=params) + + return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier) + + def claimToken(self): + """ Returns a str, a new "claim-token", which you can use to register your new Plex Server instance to your + account. + See: https://hub.docker.com/r/plexinc/pms-docker/, https://www.plex.tv/claim/ + """ + response = self._session.get('https://plex.tv/api/claim/token.json', headers=self._headers(), timeout=TIMEOUT) + if response.status_code not in (200, 201, 204): # pragma: no cover + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + return response.json()['token'] class MyPlexUser(PlexObject): @@ -405,6 +627,7 @@ class MyPlexUser(PlexObject): thumb (str): Link to the users avatar. title (str): Seems to be an aliad for username. username (str): User's username. + servers: Servers shared between user and friend """ TAG = 'User' key = 'https://plex.tv/api/users/' @@ -577,7 +800,7 @@ class MyPlexResource(PlexObject): HTTP or HTTPS connection. Raises: - :class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. + :class:`plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. """ # Sort connections from (https, local) to (http, remote) # Only check non-local connections unless we own the resource @@ -684,7 +907,7 @@ class MyPlexDevice(PlexObject): at least one connection was successful, the PlexClient object is built and returned. Raises: - :class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device. + :class:`plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device. """ cls = PlexServer if 'server' in self.provides else PlexClient listargs = [[cls, url, self.token, timeout] for url in self.connections] @@ -697,16 +920,40 @@ class MyPlexDevice(PlexObject): key = 'https://plex.tv/devices/%s.xml' % self.id self._server.query(key, self._server._session.delete) + def syncItems(self): + """ Returns an instance of :class:`plexapi.sync.SyncList` for current device. -def _connect(cls, url, token, timeout, results, i): + Raises: + :class:`plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`. + """ + if 'sync-target' not in self.provides: + raise BadRequest('Requested syncList for device which do not provides sync-target') + + return self._server.syncItems(client=self) + + +def _connect(cls, url, token, timeout, results, i, job_is_done_event=None): """ Connects to the specified cls with url and token. Stores the connection information to results[i] in a threadsafe way. + + Arguments: + cls: the class which is responsible for establishing connection, basically it's + :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` + url (str): url which should be passed as `baseurl` argument to cls.__init__() + token (str): authentication token which should be passed as `baseurl` argument to cls.__init__() + timeout (int): timeout which should be passed as `baseurl` argument to cls.__init__() + results (list): pre-filled list for results + i (int): index of current job, should be less than len(results) + job_is_done_event (:class:`~threading.Event`): is X_PLEX_ENABLE_FAST_CONNECT is True then the + event would be set as soon the connection is established """ starttime = time.time() try: device = cls(baseurl=url, token=token, timeout=timeout) runtime = int(time.time() - starttime) results[i] = (url, token, device, runtime) + if X_PLEX_ENABLE_FAST_CONNECT and job_is_done_event: + job_is_done_event.set() except Exception as err: runtime = int(time.time() - starttime) log.error('%s: %s', url, err) diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index 50db79f5..bf1383c3 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- from plexapi import media, utils from plexapi.base import PlexPartialObject -from plexapi.exceptions import NotFound +from plexapi.exceptions import NotFound, BadRequest +from plexapi.compat import quote_plus @utils.registerPlexObject @@ -96,6 +97,7 @@ class Photo(PlexPartialObject): """ TAG = 'Photo' TYPE = 'photo' + METADATA_TYPE = 'photo' def _loadData(self, data): """ Load attribute values from Plex XML response. """ @@ -122,4 +124,45 @@ class Photo(PlexPartialObject): def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """ - return self._server.library.sectionByID(self.photoalbum().librarySectionID) + if hasattr(self, 'librarySectionID'): + return self._server.library.sectionByID(self.librarySectionID) + elif self.parentKey: + return self._server.library.sectionByID(self.photoalbum().librarySectionID) + else: + raise BadRequest('Unable to get section for photo, can`t find librarySectionID') + + def sync(self, resolution, client=None, clientId=None, limit=None, title=None): + """ Add current photo as sync item for specified device. + See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions. + + Parameters: + resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the + module :mod:`plexapi.sync`. + client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current photo. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + """ + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self.title + sync_item.rootTitle = self.title + sync_item.contentType = self.listType + sync_item.metadataType = self.METADATA_TYPE + sync_item.machineIdentifier = self._server.machineIdentifier + + section = self.section() + + sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key)) + sync_item.policy = Policy.create(limit) + sync_item.mediaSettings = MediaSettings.createPhoto(resolution) + + return myplex.sync(sync_item, client=client, clientId=clientId) diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index 06e18ffa..a40665d8 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- from plexapi import utils from plexapi.base import PlexPartialObject, Playable -from plexapi.exceptions import BadRequest +from plexapi.exceptions import BadRequest, Unsupported +from plexapi.library import LibrarySection from plexapi.playqueue import PlayQueue from plexapi.utils import cast, toDatetime +from plexapi.compat import quote_plus @utils.registerPlexObject @@ -32,11 +34,35 @@ class Playlist(PlexPartialObject, Playable): self.title = data.attrib.get('title') self.type = data.attrib.get('type') self.updatedAt = toDatetime(data.attrib.get('updatedAt')) + self.allowSync = cast(bool, data.attrib.get('allowSync')) self._items = None # cache for self.items def __len__(self): # pragma: no cover return len(self.items()) + @property + def metadataType(self): + if self.isVideo: + return 'movie' + elif self.isAudio: + return 'track' + elif self.isPhoto: + return 'photo' + else: + raise Unsupported('Unexpected playlist type') + + @property + def isVideo(self): + return self.playlistType == 'video' + + @property + def isAudio(self): + return self.playlistType == 'audio' + + @property + def isPhoto(self): + return self.playlistType == 'photo' + def __contains__(self, other): # pragma: no cover return any(i.key == other.key for i in self.items()) @@ -102,9 +128,9 @@ class Playlist(PlexPartialObject, Playable): return PlayQueue.create(self._server, self, *args, **kwargs) @classmethod - def create(cls, server, title, items): + def _create(cls, server, title, items): """ Create a playlist. """ - if not isinstance(items, (list, tuple)): + if items and not isinstance(items, (list, tuple)): items = [items] ratingKeys = [] for item in items: @@ -122,6 +148,61 @@ class Playlist(PlexPartialObject, Playable): data = server.query(key, method=server._session.post)[0] return cls(server, data, initpath=key) + @classmethod + def create(cls, server, title, items=None, section=None, limit=None, smart=False, **kwargs): + """Create a playlist. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server your connected to. + title (str): Title of the playlist. + items (Iterable): Iterable of objects that should be in the playlist. + section (:class:`~plexapi.library.LibrarySection`, str): + limit (int): default None. + smart (bool): default False. + + **kwargs (dict): is passed to the filters. For a example see the search method. + + Returns: + :class:`plexapi.playlist.Playlist`: an instance of created Playlist. + """ + if smart: + return cls._createSmart(server, title, section, limit, **kwargs) + + else: + return cls._create(server, title, items) + + @classmethod + def _createSmart(cls, server, title, section, limit=None, **kwargs): + """ Create a Smart playlist. """ + + if not isinstance(section, LibrarySection): + section = server.library.section(section) + + sectionType = utils.searchType(section.type) + sectionId = section.key + uuid = section.uuid + uri = 'library://%s/directory//library/sections/%s/all?type=%s' % (uuid, + sectionId, + sectionType) + if limit: + uri = uri + '&limit=%s' % str(limit) + + for category, value in kwargs.items(): + sectionChoices = section.listChoices(category) + for choice in sectionChoices: + if str(choice.title).lower() == str(value).lower(): + uri = uri + '&%s=%s' % (category.lower(), str(choice.key)) + + uri = uri + '&sourceType=%s' % sectionType + key = '/playlists%s' % utils.joinArgs({ + 'uri': uri, + 'type': section.CONTENT_TYPE, + 'title': title, + 'smart': 1, + }) + data = server.query(key, method=server._session.post)[0] + return cls(server, data, initpath=key) + def copyToUser(self, user): """ Copy playlist to another user account. """ from plexapi.server import PlexServer @@ -132,3 +213,58 @@ class Playlist(PlexPartialObject, Playable): # Login to your server using your friends credentials. user_server = PlexServer(self._server._baseurl, token) return self.create(user_server, self.title, self.items()) + + def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None, + unwatched=False, title=None): + """ Add current playlist as sync item for specified device. + See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`plexapi.sync` module. Used only when playlist contains video. + photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in + the module :mod:`plexapi.sync`. Used only when playlist contains photos. + audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values + from the module :mod:`plexapi.sync`. Used only when playlist contains audio. + client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current photo. + + Raises: + :class:`plexapi.exceptions.BadRequest`: when playlist is not allowed to sync. + :class:`plexapi.exceptions.Unsupported`: when playlist content is unsupported. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + """ + + if not self.allowSync: + raise BadRequest('The playlist is not allowed to sync') + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self.title + sync_item.rootTitle = self.title + sync_item.contentType = self.playlistType + sync_item.metadataType = self.metadataType + sync_item.machineIdentifier = self._server.machineIdentifier + + sync_item.location = 'playlist:///%s' % quote_plus(self.guid) + sync_item.policy = Policy.create(limit, unwatched) + + if self.isVideo: + sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) + elif self.isAudio: + sync_item.mediaSettings = MediaSettings.createMusic(audioBitrate) + elif self.isPhoto: + sync_item.mediaSettings = MediaSettings.createPhoto(photoResolution) + else: + raise Unsupported('Unsupported playlist content') + + return myplex.sync(sync_item, client=client, clientId=clientId) diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index 849b4c69..741249ce 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import requests from requests.status_codes import _codes as codes -from plexapi import BASE_HEADERS, CONFIG, TIMEOUT +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE from plexapi import log, logfilter, utils from plexapi.alert import AlertListener from plexapi.base import PlexObject @@ -93,6 +93,7 @@ class PlexServer(PlexObject): def __init__(self, baseurl=None, token=None, session=None, timeout=None): self._baseurl = baseurl or CONFIG.get('auth.server_baseurl', 'http://localhost:32400') + self._baseurl = self._baseurl.rstrip('/') self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token')) self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' self._session = session or requests.Session() @@ -182,6 +183,18 @@ class PlexServer(PlexObject): data = self.query(Account.key) return Account(self, data) + def createToken(self, type='delegation', scope='all'): + """Create a temp access token for the server.""" + q = self.query('/security/token?type=%s&scope=%s' % (type, scope)) + return q.attrib.get('token') + + def systemAccounts(self): + """ Returns the :class:`~plexapi.server.SystemAccounts` objects this server contains. """ + accounts = [] + for elem in self.query('/accounts'): + accounts.append(SystemAccount(self, data=elem)) + return accounts + def myPlexAccount(self): """ Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same token to access this server. If you are not the owner of this PlexServer @@ -232,7 +245,7 @@ class PlexServer(PlexObject): name (str): Name of the client to return. Raises: - :class:`~plexapi.exceptions.NotFound`: Unknown client name + :class:`plexapi.exceptions.NotFound`: Unknown client name """ for client in self.clients(): if client and client.title == name: @@ -240,14 +253,14 @@ class PlexServer(PlexObject): raise NotFound('Unknown client name: %s' % name) - def createPlaylist(self, title, items): + def createPlaylist(self, title, items=None, section=None, limit=None, smart=None, **kwargs): """ Creates and returns a new :class:`~plexapi.playlist.Playlist`. Parameters: title (str): Title of the playlist to be created. items (list): List of media items to include in the playlist. """ - return Playlist.create(self, title, items) + return Playlist.create(self, title, items=items, limit=limit, section=section, smart=smart, **kwargs) def createPlayQueue(self, item, **kwargs): """ Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`. @@ -290,12 +303,14 @@ class PlexServer(PlexObject): part = '/updater/check?download=%s' % (1 if download else 0) if force: self.query(part, method=self._session.put) - return self.fetchItem('/updater/status') + releases = self.fetchItems('/updater/status') + if len(releases): + return releases[0] def isLatest(self): """ Check if the installed version of PMS is the latest. """ release = self.check_for_update(force=True) - return bool(release.version == self.version) + return release is None def installUpdate(self): """ Install the newest version of Plex Media Server. """ @@ -307,9 +322,29 @@ class PlexServer(PlexObject): # figure out what method this is.. return self.query(part, method=self._session.put) - def history(self): - """ Returns a list of media items from watched history. """ - return self.fetchItems('/status/sessions/history/all') + def history(self, maxresults=9999999, mindate=None): + """ Returns a list of media items from watched history. If there are many results, they will + be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only + looking for the first results, it would be wise to set the maxresults option to that + amount so this functions doesn't iterate over all results on the server. + + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. This really helps speed + up the result listing. For example: datetime.now() - timedelta(days=7) + """ + results, subresults = [], '_init' + args = {'sort': 'viewedAt:desc'} + if mindate: + args['viewedAt>'] = int(mindate.timestamp()) + args['X-Plex-Container-Start'] = 0 + args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults) + while subresults and maxresults > len(results): + key = '/status/sessions/history/all%s' % utils.joinArgs(args) + subresults = self.fetchItems(key) + results += subresults[:maxresults - len(results)] + args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] + return results def playlists(self): """ Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """ @@ -324,7 +359,7 @@ class PlexServer(PlexObject): title (str): Title of the playlist to return. Raises: - :class:`~plexapi.exceptions.NotFound`: Invalid playlist title + :class:`plexapi.exceptions.NotFound`: Invalid playlist title """ return self.fetchItem('/playlists', title=title) @@ -391,7 +426,7 @@ class PlexServer(PlexObject): callback (func): Callback function to call on recieved messages. raises: - :class:`~plexapi.exception.Unsupported`: Websocket-client not installed. + :class:`plexapi.exception.Unsupported`: Websocket-client not installed. """ notifier = AlertListener(self, callback) notifier.start() @@ -422,6 +457,21 @@ class PlexServer(PlexObject): return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token) return '%s%s' % (self._baseurl, key) + def refreshSynclist(self): + """ Force PMS to download new SyncList from Plex.tv. """ + return self.query('/sync/refreshSynclists', self._session.put) + + def refreshContent(self): + """ Force PMS to refresh content for known SyncLists. """ + return self.query('/sync/refreshContent', self._session.put) + + def refreshSync(self): + """ Calls :func:`~plexapi.server.PlexServer.refreshSynclist` and + :func:`~plexapi.server.PlexServer.refreshContent`, just like the Plex Web UI does when you click 'refresh'. + """ + self.refreshSynclist() + self.refreshContent() + class Account(PlexObject): """ Contains the locally cached MyPlex account information. The properties provided don't @@ -469,3 +519,14 @@ class Account(PlexObject): self.subscriptionFeatures = utils.toList(data.attrib.get('subscriptionFeatures')) self.subscriptionActive = cast(bool, data.attrib.get('subscriptionActive')) self.subscriptionState = data.attrib.get('subscriptionState') + + +class SystemAccount(PlexObject): + """ Minimal api to list system accounts. """ + key = '/accounts' + + def _loadData(self, data): + self._data = data + self.accountID = cast(int, data.attrib.get('id')) + self.accountKey = data.attrib.get('key') + self.name = data.attrib.get('name') diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py index 9f85ebdc..0bbc70c8 100644 --- a/lib/plexapi/settings.py +++ b/lib/plexapi/settings.py @@ -99,13 +99,14 @@ class Setting(PlexObject): group (str): Group name this setting is categorized as. enumValues (list,dict): List or dictionary of valis values for this setting. """ - _bool_cast = lambda x: True if x == 'true' else False + _bool_cast = lambda x: True if x == 'true' or x == '1' else False _bool_str = lambda x: str(x).lower() + _str = lambda x: str(x).encode('utf-8') TYPES = { 'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str}, - 'double': {'type': float, 'cast': float, 'tostr': string_type}, - 'int': {'type': int, 'cast': int, 'tostr': string_type}, - 'text': {'type': string_type, 'cast': string_type, 'tostr': string_type}, + 'double': {'type': float, 'cast': float, 'tostr': _str}, + 'int': {'type': int, 'cast': int, 'tostr': _str}, + 'text': {'type': string_type, 'cast': _str, 'tostr': _str}, } def _loadData(self, data): diff --git a/lib/plexapi/sync.py b/lib/plexapi/sync.py index 8ca72520..0f739860 100644 --- a/lib/plexapi/sync.py +++ b/lib/plexapi/sync.py @@ -1,42 +1,312 @@ # -*- coding: utf-8 -*- +""" +You can work with Mobile Sync on other devices straight away, but if you'd like to use your app as a `sync-target` (when +you can set items to be synced to your app) you need to init some variables. + +.. code-block:: python + + def init_sync(): + import plexapi + plexapi.X_PLEX_PROVIDES = 'sync-target' + plexapi.BASE_HEADERS['X-Plex-Sync-Version'] = '2' + plexapi.BASE_HEADERS['X-Plex-Provides'] = plexapi.X_PLEX_PROVIDES + + # mimic iPhone SE + plexapi.X_PLEX_PLATFORM = 'iOS' + plexapi.X_PLEX_PLATFORM_VERSION = '11.4.1' + plexapi.X_PLEX_DEVICE = 'iPhone' + + plexapi.BASE_HEADERS['X-Plex-Platform'] = plexapi.X_PLEX_PLATFORM + plexapi.BASE_HEADERS['X-Plex-Platform-Version'] = plexapi.X_PLEX_PLATFORM_VERSION + plexapi.BASE_HEADERS['X-Plex-Device'] = plexapi.X_PLEX_DEVICE + +You have to fake platform/device/model because transcoding profiles are hardcoded in Plex, and you obviously have +to explicitly specify that your app supports `sync-target`. +""" + import requests -from plexapi import utils -from plexapi.exceptions import NotFound + +import plexapi +from plexapi.base import PlexObject +from plexapi.exceptions import NotFound, BadRequest -class SyncItem(object): - """ Sync Item. This doesn't current work. """ - def __init__(self, device, data, servers=None): - self._device = device - self._servers = servers - self._loadData(data) +class SyncItem(PlexObject): + """ + Represents single sync item, for specified server and client. When you saying in the UI to sync "this" to "that" + you're basically creating a sync item. + + Attributes: + id (int): unique id of the item. + clientIdentifier (str): an identifier of Plex Client device, to which the item is belongs. + machineIdentifier (str): the id of server which holds all this content. + version (int): current version of the item. Each time you modify the item (e.g. by changing amount if media to + sync) the new version is created. + rootTitle (str): the title of library/media from which the sync item was created. E.g.: + + * when you create an item for an episode 3 of season 3 of show Example, the value would be `Title of + Episode 3` + * when you create an item for a season 3 of show Example, the value would be `Season 3` + * when you set to sync all your movies in library named "My Movies" to value would be `My Movies`. + + title (str): the title which you've set when created the sync item. + metadataType (str): the type of media which hides inside, can be `episode`, `movie`, etc. + contentType (str): basic type of the content: `video` or `audio`. + status (:class:`~plexapi.sync.Status`): current status of the sync. + mediaSettings (:class:`~plexapi.sync.MediaSettings`): media transcoding settings used for the item. + policy (:class:`~plexapi.sync.Policy`): the policy of which media to sync. + location (str): plex-style library url with all required filters / sorting. + """ + TAG = 'SyncItem' + + def __init__(self, server, data, initpath=None, clientIdentifier=None): + super(SyncItem, self).__init__(server, data, initpath) + self.clientIdentifier = clientIdentifier def _loadData(self, data): self._data = data - self.id = utils.cast(int, data.attrib.get('id')) - self.version = utils.cast(int, data.attrib.get('version')) + self.id = plexapi.utils.cast(int, data.attrib.get('id')) + self.version = plexapi.utils.cast(int, data.attrib.get('version')) self.rootTitle = data.attrib.get('rootTitle') self.title = data.attrib.get('title') self.metadataType = data.attrib.get('metadataType') + self.contentType = data.attrib.get('contentType') self.machineIdentifier = data.find('Server').get('machineIdentifier') - self.status = data.find('Status').attrib.copy() - self.MediaSettings = data.find('MediaSettings').attrib.copy() - self.policy = data.find('Policy').attrib.copy() - self.location = data.find('Location').attrib.copy() + self.status = Status(**data.find('Status').attrib) + self.mediaSettings = MediaSettings(**data.find('MediaSettings').attrib) + self.policy = Policy(**data.find('Policy').attrib) + self.location = data.find('Location').attrib.get('uri', '') def server(self): - server = list(filter(lambda x: x.machineIdentifier == self.machineIdentifier, self._servers)) - if 0 == len(server): + """ Returns :class:`plexapi.myplex.MyPlexResource` with server of current item. """ + server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier] + if len(server) == 0: raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier) return server[0] def getMedia(self): + """ Returns list of :class:`~plexapi.base.Playable` which belong to this sync item. """ server = self.server().connect() key = '/sync/items/%s' % self.id return server.fetchItems(key) - def markAsDone(self, sync_id): - server = self.server().connect() - url = '/sync/%s/%s/files/%s/downloaded' % ( - self._device.clientIdentifier, server.machineIdentifier, sync_id) - server.query(url, method=requests.put) + def markDownloaded(self, media): + """ Mark the file as downloaded (by the nature of Plex it will be marked as downloaded within + any SyncItem where it presented). + + Parameters: + media (base.Playable): the media to be marked as downloaded. + """ + url = '/sync/%s/item/%s/downloaded' % (self.clientIdentifier, media.ratingKey) + media._server.query(url, method=requests.put) + + def delete(self): + """ Removes current SyncItem """ + url = SyncList.key.format(clientId=self.clientIdentifier) + url += '/' + str(self.id) + self._server.query(url, self._server._session.delete) + + +class SyncList(PlexObject): + """ Represents a Mobile Sync state, specific for single client, within one SyncList may be presented + items from different servers. + + Attributes: + clientId (str): an identifier of the client. + items (List<:class:`~plexapi.sync.SyncItem`>): list of registered items to sync. + """ + key = 'https://plex.tv/devices/{clientId}/sync_items' + TAG = 'SyncList' + + def _loadData(self, data): + self._data = data + self.clientId = data.attrib.get('clientIdentifier') + self.items = [] + + syncItems = data.find('SyncItems') + if syncItems: + for sync_item in syncItems.iter('SyncItem'): + item = SyncItem(self._server, sync_item, clientIdentifier=self.clientId) + self.items.append(item) + + +class Status(object): + """ Represents a current status of specific :class:`~plexapi.sync.SyncItem`. + + Attributes: + failureCode: unknown, never got one yet. + failure: unknown. + state (str): server-side status of the item, can be `completed`, `pending`, empty, and probably something + else. + itemsCount (int): total items count. + itemsCompleteCount (int): count of transcoded and/or downloaded items. + itemsDownloadedCount (int): count of downloaded items. + itemsReadyCount (int): count of transcoded items, which can be downloaded. + totalSize (int): total size in bytes of complete items. + itemsSuccessfulCount (int): unknown, in my experience it always was equal to `itemsCompleteCount`. + """ + + def __init__(self, itemsCount, itemsCompleteCount, state, totalSize, itemsDownloadedCount, itemsReadyCount, + itemsSuccessfulCount, failureCode, failure): + self.itemsDownloadedCount = plexapi.utils.cast(int, itemsDownloadedCount) + self.totalSize = plexapi.utils.cast(int, totalSize) + self.itemsReadyCount = plexapi.utils.cast(int, itemsReadyCount) + self.failureCode = failureCode + self.failure = failure + self.itemsSuccessfulCount = plexapi.utils.cast(int, itemsSuccessfulCount) + self.state = state + self.itemsCompleteCount = plexapi.utils.cast(int, itemsCompleteCount) + self.itemsCount = plexapi.utils.cast(int, itemsCount) + + def __repr__(self): + return '<%s>:%s' % (self.__class__.__name__, dict( + itemsCount=self.itemsCount, + itemsCompleteCount=self.itemsCompleteCount, + itemsDownloadedCount=self.itemsDownloadedCount, + itemsReadyCount=self.itemsReadyCount, + itemsSuccessfulCount=self.itemsSuccessfulCount + )) + + +class MediaSettings(object): + """ Transcoding settings used for all media within :class:`~plexapi.sync.SyncItem`. + + Attributes: + audioBoost (int): unknown. + maxVideoBitrate (int|str): maximum bitrate for video, may be empty string. + musicBitrate (int|str): maximum bitrate for music, may be an empty string. + photoQuality (int): photo quality on scale 0 to 100. + photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`). + videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty). + subtitleSize (int|str): unknown, usually equals to 0, may be empty string. + videoQuality (int): video quality on scale 0 to 100. + """ + + def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100, + musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=''): + self.audioBoost = plexapi.utils.cast(int, audioBoost) + self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else '' + self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else '' + self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else '' + self.photoResolution = photoResolution + self.videoResolution = videoResolution + self.subtitleSize = subtitleSize + self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else '' + + @staticmethod + def createVideo(videoQuality): + """ Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided video quality value. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module. + + Raises: + :class:`plexapi.exceptions.BadRequest`: when provided unknown video quality. + """ + if videoQuality == VIDEO_QUALITY_ORIGINAL: + return MediaSettings('', '', '') + elif videoQuality < len(VIDEO_QUALITIES['bitrate']): + return MediaSettings(VIDEO_QUALITIES['bitrate'][videoQuality], + VIDEO_QUALITIES['videoQuality'][videoQuality], + VIDEO_QUALITIES['videoResolution'][videoQuality]) + else: + raise BadRequest('Unexpected video quality') + + @staticmethod + def createMusic(bitrate): + """ Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided music quality value + + Parameters: + bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the + module + """ + return MediaSettings(musicBitrate=bitrate) + + @staticmethod + def createPhoto(resolution): + """ Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided photo quality value. + + Parameters: + resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the + module. + + Raises: + :class:`plexapi.exceptions.BadRequest` when provided unknown video quality. + """ + if resolution in PHOTO_QUALITIES: + return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution) + else: + raise BadRequest('Unexpected photo quality') + + +class Policy(object): + """ Policy of syncing the media (how many items to sync and process watched media or not). + + Attributes: + scope (str): type of limitation policy, can be `count` or `all`. + value (int): amount of media to sync, valid only when `scope=count`. + unwatched (bool): True means disallow to sync watched media. + """ + + def __init__(self, scope, unwatched, value=0): + self.scope = scope + self.unwatched = plexapi.utils.cast(bool, unwatched) + self.value = plexapi.utils.cast(int, value) + + @staticmethod + def create(limit=None, unwatched=False): + """ Creates a :class:`~plexapi.sync.Policy` object for provided options and automatically sets proper `scope` + value. + + Parameters: + limit (int): limit items by count. + unwatched (bool): if True then watched items wouldn't be synced. + + Returns: + :class:`~plexapi.sync.Policy`. + """ + scope = 'all' + if limit is None: + limit = 0 + else: + scope = 'count' + + return Policy(scope, unwatched, limit) + + +VIDEO_QUALITIES = { + 'bitrate': [64, 96, 208, 320, 720, 1500, 2e3, 3e3, 4e3, 8e3, 1e4, 12e3, 2e4], + 'videoResolution': ['220x128', '220x128', '284x160', '420x240', '576x320', '720x480', '1280x720', '1280x720', + '1280x720', '1920x1080', '1920x1080', '1920x1080', '1920x1080'], + 'videoQuality': [10, 20, 30, 30, 40, 60, 60, 75, 100, 60, 75, 90, 100], +} + +VIDEO_QUALITY_0_2_MBPS = 2 +VIDEO_QUALITY_0_3_MBPS = 3 +VIDEO_QUALITY_0_7_MBPS = 4 +VIDEO_QUALITY_1_5_MBPS_480p = 5 +VIDEO_QUALITY_2_MBPS_720p = 6 +VIDEO_QUALITY_3_MBPS_720p = 7 +VIDEO_QUALITY_4_MBPS_720p = 8 +VIDEO_QUALITY_8_MBPS_1080p = 9 +VIDEO_QUALITY_10_MBPS_1080p = 10 +VIDEO_QUALITY_12_MBPS_1080p = 11 +VIDEO_QUALITY_20_MBPS_1080p = 12 +VIDEO_QUALITY_ORIGINAL = -1 + +AUDIO_BITRATE_96_KBPS = 96 +AUDIO_BITRATE_128_KBPS = 128 +AUDIO_BITRATE_192_KBPS = 192 +AUDIO_BITRATE_320_KBPS = 320 + +PHOTO_QUALITIES = { + '720x480': 24, + '1280x720': 49, + '1920x1080': 74, + '3840x2160': 99, +} + +PHOTO_QUALITY_HIGHEST = PHOTO_QUALITY_2160p = '3840x2160' +PHOTO_QUALITY_HIGH = PHOTO_QUALITY_1080p = '1920x1080' +PHOTO_QUALITY_MEDIUM = PHOTO_QUALITY_720p = '1280x720' +PHOTO_QUALITY_LOW = PHOTO_QUALITY_480p = '720x480' diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index b523eaeb..e8ff989d 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -7,15 +7,18 @@ import time import zipfile from datetime import datetime from getpass import getpass -from threading import Thread +from threading import Thread, Event from tqdm import tqdm from plexapi import compat from plexapi.exceptions import NotFound +log = logging.getLogger('plexapi') + # Search Types - Plex uses these to filter specific media types when searching. # Library Types - Populated at runtime -SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, - 'artist': 8, 'album': 9, 'track': 10, 'photo': 14} +SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7, + 'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14, + 'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'userPlaylistItem': 1001} PLEXOBJECTS = {} @@ -129,10 +132,10 @@ def searchType(libtype): """ Returns the integer value of the library string type. Parameters: - libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track) - + libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track, + collection) Raises: - NotFound: Unknown libtype + :class:`plexapi.exceptions.NotFound`: Unknown libtype """ libtype = compat.ustr(libtype) if libtype in [compat.ustr(v) for v in SEARCHTYPES.values()]: @@ -144,22 +147,26 @@ def searchType(libtype): def threaded(callback, listargs): """ Returns the result of for each set of \*args in listargs. Each call - to is called concurrently in their own separate threads. Parameters: callback (func): Callback function to apply to each set of \*args. listargs (list): List of lists; \*args to pass each thread. """ threads, results = [], [] + job_is_done_event = Event() for args in listargs: args += [results, len(results)] results.append(None) - threads.append(Thread(target=callback, args=args)) + threads.append(Thread(target=callback, args=args, kwargs=dict(job_is_done_event=job_is_done_event))) threads[-1].setDaemon(True) threads[-1].start() - for thread in threads: - thread.join() - return results + while not job_is_done_event.is_set(): + if all([not t.is_alive() for t in threads]): + break + time.sleep(0.05) + + return [r for r in results if r is not None] def toDatetime(value, format=None): @@ -171,8 +178,17 @@ def toDatetime(value, format=None): """ if value and value is not None: if format: - value = datetime.strptime(value, format) + try: + value = datetime.strptime(value, format) + except ValueError: + log.info('Failed to parse %s to datetime, defaulting to None', value) + return None else: + # https://bugs.python.org/issue30684 + # And platform support for before epoch seems to be flaky. + # TODO check for others errors too. + if int(value) <= 0: + value = 86400 value = datetime.fromtimestamp(int(value)) return value @@ -242,8 +258,6 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 >>> download(a_episode.getStreamURL(), a_episode.location) /path/to/file """ - - from plexapi import log # fetch the data to be saved session = session or requests.Session() headers = {'X-Plex-Token': token} diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 2d308510..fe044218 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -2,6 +2,7 @@ from plexapi import media, utils from plexapi.exceptions import BadRequest, NotFound from plexapi.base import Playable, PlexPartialObject +from plexapi.compat import quote_plus class Video(PlexPartialObject): @@ -77,6 +78,59 @@ class Video(PlexPartialObject): self._server.query(key) self.reload() + def rate(self, rate): + """ Rate video. """ + key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rate) + + self._server.query(key) + self.reload() + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return self.title + + def posters(self): + """ Returns list of available poster objects. :class:`~plexapi.media.Poster`:""" + + return self.fetchItems('%s/posters' % self.key, cls=media.Poster) + + def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None): + """ Add current video (movie, tv-show, season or episode) as sync item for specified device. + See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`plexapi.sync` module. + client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current media. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + """ + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self._defaultSyncTitle() + sync_item.rootTitle = self.title + sync_item.contentType = self.listType + sync_item.metadataType = self.METADATA_TYPE + sync_item.machineIdentifier = self._server.machineIdentifier + + section = self._server.library.sectionByID(self.librarySectionID) + + sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key)) + sync_item.policy = Policy.create(limit, unwatched) + sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) + + return myplex.sync(sync_item, client=client, clientId=clientId) + @utils.registerPlexObject class Movie(Playable, Video): @@ -116,6 +170,7 @@ class Movie(Playable, Video): """ TAG = 'Video' TYPE = 'movie' + METADATA_TYPE = 'movie' _include = ('?checkFiles=1&includeExtras=1&includeRelated=1' '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' '&includeConcerts=1&includePreferences=1') @@ -181,12 +236,12 @@ class Movie(Playable, Video): # This is just for compat. return self.title - def download(self, savepath=None, keep_orginal_name=False, **kwargs): + def download(self, savepath=None, keep_original_name=False, **kwargs): """ Download video files to specified directory. Parameters: savepath (str): Defaults to current working dir. - keep_orginal_name (bool): True to keep the original file name otherwise + keep_original_name (bool): True to keep the original file name otherwise a friendlier is generated. **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`. """ @@ -194,7 +249,7 @@ class Movie(Playable, Video): locations = [i for i in self.iterParts() if i] for location in locations: name = location.file - if not keep_orginal_name: + if not keep_original_name: title = self.title.replace(' ', '.') name = '%s.%s' % (title, location.container) if kwargs is not None: @@ -219,6 +274,7 @@ class Show(Video): banner (str): Key to banner artwork (/library/metadata//art/) childCount (int): Unknown. contentRating (str) Content rating (PG-13; NR; TV-G). + collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs. duration (int): Duration of show in milliseconds. guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en). index (int): Plex index (?) @@ -236,6 +292,7 @@ class Show(Video): """ TAG = 'Directory' TYPE = 'show' + METADATA_TYPE = 'episode' def __iter__(self): for season in self.seasons(): @@ -250,6 +307,7 @@ class Show(Video): self.banner = data.attrib.get('banner') self.childCount = utils.cast(int, data.attrib.get('childCount')) self.contentRating = data.attrib.get('contentRating') + self.collections = self.findItems(data, media.Collection) self.duration = utils.cast(int, data.attrib.get('duration')) self.guid = data.attrib.get('guid') self.index = data.attrib.get('index') @@ -279,7 +337,7 @@ class Show(Video): def seasons(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Season` objects. """ - key = '/library/metadata/%s/children' % self.ratingKey + key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey return self.fetchItems(key, **kwargs) def season(self, title=None): @@ -288,9 +346,9 @@ class Show(Video): Parameters: title (str or int): Title or Number of the season to return. """ - if isinstance(title, int): - title = 'Season %s' % title key = '/library/metadata/%s/children' % self.ratingKey + if isinstance(title, int): + return self.fetchItem(key, etag='Directory', index__iexact=str(title)) return self.fetchItem(key, etag='Directory', title__iexact=title) def episodes(self, **kwargs): @@ -307,13 +365,13 @@ class Show(Video): episode (int): Episode number (default:None; required if title not specified). Raises: - BadRequest: If season and episode is missing. - NotFound: If the episode is missing. + :class:`plexapi.exceptions.BadRequest`: If season and episode is missing. + :class:`plexapi.exceptions.NotFound`: If the episode is missing. """ if title: key = '/library/metadata/%s/allLeaves' % self.ratingKey return self.fetchItem(key, title__iexact=title) - elif season and episode: + elif season is not None and episode: results = [i for i in self.episodes() if i.seasonNumber == season and i.index == episode] if results: return results[0] @@ -332,18 +390,18 @@ class Show(Video): """ Alias to :func:`~plexapi.video.Show.episode()`. """ return self.episode(title, season, episode) - def download(self, savepath=None, keep_orginal_name=False, **kwargs): + def download(self, savepath=None, keep_original_name=False, **kwargs): """ Download video files to specified directory. Parameters: savepath (str): Defaults to current working dir. - keep_orginal_name (bool): True to keep the original file name otherwise + keep_original_name (bool): True to keep the original file name otherwise a friendlier is generated. **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`. """ filepaths = [] for episode in self.episodes(): - filepaths += episode.download(savepath, keep_orginal_name, **kwargs) + filepaths += episode.download(savepath, keep_original_name, **kwargs) return filepaths @@ -363,6 +421,7 @@ class Season(Video): """ TAG = 'Directory' TYPE = 'season' + METADATA_TYPE = 'episode' def __iter__(self): for episode in self.episodes(): @@ -414,7 +473,7 @@ class Season(Video): key = '/library/metadata/%s/children' % self.ratingKey if title: return self.fetchItem(key, title=title) - return self.fetchItem(key, seasonNumber=self.index, index=episode) + return self.fetchItem(key, parentIndex=self.index, index=episode) def get(self, title=None, episode=None): """ Alias to :func:`~plexapi.video.Season.episode()`. """ @@ -432,20 +491,24 @@ class Season(Video): """ Returns list of unwatched :class:`~plexapi.video.Episode` objects. """ return self.episodes(watched=False) - def download(self, savepath=None, keep_orginal_name=False, **kwargs): + def download(self, savepath=None, keep_original_name=False, **kwargs): """ Download video files to specified directory. Parameters: savepath (str): Defaults to current working dir. - keep_orginal_name (bool): True to keep the original file name otherwise + keep_original_name (bool): True to keep the original file name otherwise a friendlier is generated. **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`. """ filepaths = [] for episode in self.episodes(): - filepaths += episode.download(savepath, keep_orginal_name, **kwargs) + filepaths += episode.download(savepath, keep_original_name, **kwargs) return filepaths + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return '%s - %s' % (self.parentTitle, self.title) + @utils.registerPlexObject class Episode(Playable, Video): @@ -482,6 +545,8 @@ class Episode(Playable, Video): """ TAG = 'Video' TYPE = 'episode' + METADATA_TYPE = 'episode' + _include = ('?checkFiles=1&includeExtras=1&includeRelated=1' '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' '&includeConcerts=1&includePreferences=1') @@ -558,3 +623,7 @@ class Episode(Playable, Video): def show(self): """" Return this episodes :func:`~plexapi.video.Show`.. """ return self.fetchItem(self.grandparentKey) + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title)