From 5432876a88c3221e1c20c723497a0cac7a01f1ff Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Mon, 20 Aug 2018 00:03:19 +0100 Subject: [PATCH 01/33] [sync] initial commit --- plexapi/media.py | 5 +++ plexapi/myplex.py | 11 ++++++- plexapi/sync.py | 77 +++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/plexapi/media.py b/plexapi/media.py index 81f7d6f9c..a8dd973bd 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -92,6 +92,11 @@ def _loadData(self, data): 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) def _buildStreams(self, data): diff --git a/plexapi/myplex.py b/plexapi/myplex.py index bec46876e..2aecb981b 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -3,7 +3,7 @@ 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 from plexapi import log, logfilter, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest, NotFound @@ -11,6 +11,7 @@ from plexapi.compat import ElementTree from plexapi.library import LibrarySection from plexapi.server import PlexServer +from plexapi.sync import SyncList from plexapi.utils import joinArgs @@ -378,6 +379,14 @@ def optOut(self, playback=None, library=None): url = 'https://plex.tv/api/v2/user/privacy' return self.query(url, method=self._session.put, params=params) + def syncItems(self, clientId=None): + if clientId is None: + clientId = X_PLEX_IDENTIFIER + + data = self.query(SyncList.key.format(clientId=clientId)) + + return SyncList(self, data) + class MyPlexUser(PlexObject): """ This object represents non-signed in users such as friends and linked diff --git a/plexapi/sync.py b/plexapi/sync.py index 8ca725202..a7a870797 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -1,31 +1,53 @@ # -*- coding: utf-8 -*- import requests -from plexapi import utils +import plexapi from plexapi.exceptions import NotFound +from plexapi.base import PlexObject -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) +def init(replace_provides=False): + if replace_provides or not plexapi.X_PLEX_PROVIDES: + plexapi.X_PLEX_PROVIDES = 'sync-target' + else: + 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 + plexapi.BASE_HEADERS['X-Plex-Model'] = '8,4' + plexapi.BASE_HEADERS['X-Plex-Vendor'] = 'Apple' + + +class SyncItem(PlexObject): + TAG = 'SyncItem' + + def __init__(self, server, data, initpath=None, clientIdentifier=None): + super().__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.machineIdentifier = data.find('Server').get('machineIdentifier') - self.status = data.find('Status').attrib.copy() + self.status = Status(self._server, data.find('Status')) self.MediaSettings = data.find('MediaSettings').attrib.copy() self.policy = data.find('Policy').attrib.copy() self.location = data.find('Location').attrib.copy() def server(self): - server = list(filter(lambda x: x.machineIdentifier == self.machineIdentifier, self._servers)) + server = list(filter(lambda x: x.clientIdentifier == self.machineIdentifier, self._server.resources())) if 0 == len(server): raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier) return server[0] @@ -35,8 +57,37 @@ def getMedia(self): key = '/sync/items/%s' % self.id return server.fetchItems(key) - def markAsDone(self, sync_id): + def markAsDone(self): server = self.server().connect() url = '/sync/%s/%s/files/%s/downloaded' % ( - self._device.clientIdentifier, server.machineIdentifier, sync_id) + self.clientIdentifier, server.machineIdentifier, self.id) server.query(url, method=requests.put) + + +class SyncList(PlexObject): + key = 'https://plex.tv/devices/{clientId}/sync_items' + TAG = 'SyncList' + + def _loadData(self, data): + self._data = data + self.clientId = data.attrib.get('clientIdentifier') + + for elem in data: + if elem.tag == 'SyncItems': + self.items = self.findItems(elem, SyncItem) + + +class Status(PlexObject): + TAG = 'Status' + + def _loadData(self, data): + self._data = data + self.failureCode = data.attrib.get('failureCode') + self.failure = data.attrib.get('failure') + self.state = data.attrib.get('complete') + self.itemsCount = plexapi.utils.cast(int, data.attrib.get('itemsCount')) + self.itemsCompleteCount = plexapi.utils.cast(int, data.attrib.get('itemsCompleteCount')) + self.totalSize = plexapi.utils.cast(int, data.attrib.get('totalSize')) + self.itemsDownloadedCount = plexapi.utils.cast(int, data.attrib.get('itemsDownloadedCount')) + self.itemsReadyCount = plexapi.utils.cast(int, data.attrib.get('itemsReadyCount')) + self.itemsSuccessfulCount = plexapi.utils.cast(int, data.attrib.get('itemsSuccessfulCount')) From edfe9d5262bd13821dd6d326b922d4ad66f5238c Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Mon, 20 Aug 2018 08:34:55 +0100 Subject: [PATCH 02/33] fix populating of `state` field in sync.Status --- plexapi/sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index a7a870797..a41d36395 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -84,7 +84,7 @@ def _loadData(self, data): self._data = data self.failureCode = data.attrib.get('failureCode') self.failure = data.attrib.get('failure') - self.state = data.attrib.get('complete') + self.state = data.attrib.get('state') self.itemsCount = plexapi.utils.cast(int, data.attrib.get('itemsCount')) self.itemsCompleteCount = plexapi.utils.cast(int, data.attrib.get('itemsCompleteCount')) self.totalSize = plexapi.utils.cast(int, data.attrib.get('totalSize')) From 05bd2254ed7b230b7465d122788cc433c7faaa23 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Fri, 24 Aug 2018 16:15:02 +0100 Subject: [PATCH 03/33] [connection] add posibliity to return first established connection faster --- plexapi/__init__.py | 1 + plexapi/myplex.py | 10 ++++++---- plexapi/utils.py | 13 ++++++++----- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/plexapi/__init__.py b/plexapi/__init__.py index 48d280606..454eef8fe 100644 --- a/plexapi/__init__.py +++ b/plexapi/__init__.py @@ -17,6 +17,7 @@ VERSION = '3.0.6' 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') diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 2aecb981b..facd9f56f 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -3,7 +3,7 @@ import requests import time from requests.status_codes import _codes as codes -from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER +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 @@ -601,7 +601,7 @@ def connect(self, ssl=None, timeout=None): else: connections = https + http # Try connecting to all known resource connections in parellel, but # only return the first server (in order) that provides a response. - listargs = [[cls, url, self.accessToken, timeout] for url in connections] + listargs = [[cls, url, self.accessToken, timeout, X_PLEX_ENABLE_FAST_CONNECT] for url in connections] log.info('Testing %s resource connections..', len(listargs)) results = utils.threaded(_connect, listargs) return _chooseConnection('Resource', self.name, results) @@ -696,7 +696,7 @@ def connect(self, timeout=None): :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] + listargs = [[cls, url, self.token, timeout, X_PLEX_ENABLE_FAST_CONNECT] for url in self.connections] log.info('Testing %s device connections..', len(listargs)) results = utils.threaded(_connect, listargs) return _chooseConnection('Device', self.name, results) @@ -707,7 +707,7 @@ def delete(self): self._server.query(key, self._server._session.delete) -def _connect(cls, url, token, timeout, results, i): +def _connect(cls, url, token, timeout, fast, results, i, connected_event=None): """ Connects to the specified cls with url and token. Stores the connection information to results[i] in a threadsafe way. """ @@ -716,6 +716,8 @@ def _connect(cls, url, token, timeout, results, i): device = cls(baseurl=url, token=token, timeout=timeout) runtime = int(time.time() - starttime) results[i] = (url, token, device, runtime) + if fast and connected_event: + connected_event.set() except Exception as err: runtime = int(time.time() - starttime) log.error('%s: %s', url, err) diff --git a/plexapi/utils.py b/plexapi/utils.py index f83fa858a..1aa763d2d 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -7,7 +7,7 @@ import zipfile from datetime import datetime from getpass import getpass -from threading import Thread +import threading from tqdm import tqdm from plexapi import compat from plexapi.exceptions import NotFound @@ -152,15 +152,18 @@ def threaded(callback, listargs): listargs (list): List of lists; \*args to pass each thread. """ threads, results = [], [] + connected_event = threading.Event() for args in listargs: args += [results, len(results)] results.append(None) - threads.append(Thread(target=callback, args=args)) + threads.append(threading.Thread(target=callback, args=args, kwargs=dict(connected_event=connected_event))) threads[-1].setDaemon(True) threads[-1].start() - for thread in threads: - thread.join() - return results + while not connected_event.is_set(): + if all([not t.is_alive() for t in threads]): + break + time.sleep(0.05) + return list(filter(lambda r: r is not None, results)) def toDatetime(value, format=None): From 3f9673ccd2e76b07605fedf9c362ec900b4216c9 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Fri, 24 Aug 2018 16:16:05 +0100 Subject: [PATCH 04/33] [base] add timeout argument to PlexObject.fetchItems() --- plexapi/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plexapi/base.py b/plexapi/base.py index 7aeb98598..dbe802be0 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -144,7 +144,8 @@ def fetchItems(self, ekey, cls=None, **kwargs): and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details on how this is used. """ - data = self._server.query(ekey) + timeout = kwargs.pop('timeout', None) + data = self._server.query(ekey, timeout=timeout) return self.findItems(data, cls, ekey, **kwargs) def findItems(self, data, cls=None, initpath=None, **kwargs): From 1fa3dc459881e2562f1f1ba2dd888afe14235571 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Fri, 24 Aug 2018 16:17:24 +0100 Subject: [PATCH 05/33] [sync] add timeout arg to SyncItem.getMedia() When you have multiple media within one SyncItem it takes a lot of time to get all the info for this media (on my machine it takes about a second for each movie). --- plexapi/sync.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index a41d36395..c97e3deb2 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -52,10 +52,10 @@ def server(self): raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier) return server[0] - def getMedia(self): + def getMedia(self, timeout=None): server = self.server().connect() key = '/sync/items/%s' % self.id - return server.fetchItems(key) + return server.fetchItems(key, timeout=timeout) def markAsDone(self): server = self.server().connect() From 6051a8a005d6341dcbf3ee98478ce0d7f9ca4aff Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Fri, 24 Aug 2018 16:19:15 +0100 Subject: [PATCH 06/33] [sync] fix marking media as downloaded --- plexapi/sync.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index c97e3deb2..e2cef596b 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -2,7 +2,7 @@ import requests import plexapi from plexapi.exceptions import NotFound -from plexapi.base import PlexObject +from plexapi.base import PlexObject, Playable def init(replace_provides=False): @@ -57,11 +57,9 @@ def getMedia(self, timeout=None): key = '/sync/items/%s' % self.id return server.fetchItems(key, timeout=timeout) - def markAsDone(self): - server = self.server().connect() - url = '/sync/%s/%s/files/%s/downloaded' % ( - self.clientIdentifier, server.machineIdentifier, self.id) - server.query(url, method=requests.put) + def markDownloaded(self, media: Playable): + url = '/sync/%s/item/%s/downloaded' % (self.clientIdentifier, media.ratingKey) + media._server.query(url, method=requests.put) class SyncList(PlexObject): From 5ec0a42154bc13eefac67af32e0a19b6dd70d25f Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Fri, 24 Aug 2018 16:20:25 +0100 Subject: [PATCH 07/33] [sync] pass clientIdentifier to created SyncItem() --- plexapi/sync.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index e2cef596b..2ff436af3 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -69,10 +69,13 @@ class SyncList(PlexObject): def _loadData(self, data): self._data = data self.clientId = data.attrib.get('clientIdentifier') + self.items = [] for elem in data: if elem.tag == 'SyncItems': - self.items = self.findItems(elem, SyncItem) + for sync_item in elem: + item = SyncItem(self._server, sync_item, clientIdentifier=self.clientId) + self.items.append(item) class Status(PlexObject): From 1f11bdf7422e607020871459e4a4a7f5c05ca0af Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Fri, 24 Aug 2018 16:20:48 +0100 Subject: [PATCH 08/33] [sync] override __repr__() for sync.Status --- plexapi/sync.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plexapi/sync.py b/plexapi/sync.py index 2ff436af3..c40ecaa11 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -92,3 +92,12 @@ def _loadData(self, data): self.itemsDownloadedCount = plexapi.utils.cast(int, data.attrib.get('itemsDownloadedCount')) self.itemsReadyCount = plexapi.utils.cast(int, data.attrib.get('itemsReadyCount')) self.itemsSuccessfulCount = plexapi.utils.cast(int, data.attrib.get('itemsSuccessfulCount')) + + def __repr__(self): + return str(dict( + itemsCount=self.itemsCount, + itemsCompleteCount=self.itemsCompleteCount, + itemsDownloadedCount=self.itemsDownloadedCount, + itemsReadyCount=self.itemsReadyCount, + itemsSuccessfulCount=self.itemsSuccessfulCount + )) From cd2849e08acc305d026448b5358cf00835123363 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Fri, 24 Aug 2018 16:43:34 +0100 Subject: [PATCH 09/33] fix after @mikes-nasuni`s review --- plexapi/base.py | 3 +-- plexapi/myplex.py | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index dbe802be0..c5665f5c2 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -139,12 +139,11 @@ def fetchItem(self, ekey, cls=None, **kwargs): clsname = cls.__name__ if cls else 'None' raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs)) - def fetchItems(self, ekey, cls=None, **kwargs): + def fetchItems(self, ekey, cls=None, timeout=None, **kwargs): """ Load the specified key to find and build all items with the specified tag and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details on how this is used. """ - timeout = kwargs.pop('timeout', None) data = self._server.query(ekey, timeout=timeout) return self.findItems(data, cls, ekey, **kwargs) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index facd9f56f..66fc137ed 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -601,7 +601,7 @@ def connect(self, ssl=None, timeout=None): else: connections = https + http # Try connecting to all known resource connections in parellel, but # only return the first server (in order) that provides a response. - listargs = [[cls, url, self.accessToken, timeout, X_PLEX_ENABLE_FAST_CONNECT] for url in connections] + listargs = [[cls, url, self.accessToken, timeout] for url in connections] log.info('Testing %s resource connections..', len(listargs)) results = utils.threaded(_connect, listargs) return _chooseConnection('Resource', self.name, results) @@ -696,7 +696,7 @@ def connect(self, timeout=None): :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, X_PLEX_ENABLE_FAST_CONNECT] for url in self.connections] + listargs = [[cls, url, self.token, timeout] for url in self.connections] log.info('Testing %s device connections..', len(listargs)) results = utils.threaded(_connect, listargs) return _chooseConnection('Device', self.name, results) @@ -707,7 +707,7 @@ def delete(self): self._server.query(key, self._server._session.delete) -def _connect(cls, url, token, timeout, fast, results, i, connected_event=None): +def _connect(cls, url, token, timeout, results, i, connected_event=None): """ Connects to the specified cls with url and token. Stores the connection information to results[i] in a threadsafe way. """ @@ -716,7 +716,7 @@ def _connect(cls, url, token, timeout, fast, results, i, connected_event=None): device = cls(baseurl=url, token=token, timeout=timeout) runtime = int(time.time() - starttime) results[i] = (url, token, device, runtime) - if fast and connected_event: + if X_PLEX_ENABLE_FAST_CONNECT and connected_event: connected_event.set() except Exception as err: runtime = int(time.time() - starttime) From afbf45c180aa542392ae04e787e21fd09d04ae9a Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Fri, 24 Aug 2018 17:18:06 +0100 Subject: [PATCH 10/33] fix python2 compatibility --- plexapi/sync.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index c40ecaa11..e5104ac4a 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -57,7 +57,13 @@ def getMedia(self, timeout=None): key = '/sync/items/%s' % self.id return server.fetchItems(key, timeout=timeout) - def markDownloaded(self, media: Playable): + def markDownloaded(self, media): + """ + Mark the file as downloaded within current SyncItem + + :param media: + :type media: base.Playable + """ url = '/sync/%s/item/%s/downloaded' % (self.clientIdentifier, media.ratingKey) media._server.query(url, method=requests.put) From 34296c7002d1dae0919cac669d6b6f7fa5910e86 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Fri, 24 Aug 2018 17:25:07 +0100 Subject: [PATCH 11/33] get rid of sync.init() --- plexapi/sync.py | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index e5104ac4a..dbb562202 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -2,28 +2,33 @@ import requests import plexapi from plexapi.exceptions import NotFound -from plexapi.base import PlexObject, Playable +from plexapi.base import PlexObject +""" +You can work with Mobile Sync on other devices straight away, but if you'd like to use your app as sync-target you +need to init some variables. -def init(replace_provides=False): - if replace_provides or not plexapi.X_PLEX_PROVIDES: +Example: + def init_sync(): + import plexapi plexapi.X_PLEX_PROVIDES = 'sync-target' - else: - 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 - plexapi.BASE_HEADERS['X-Plex-Model'] = '8,4' - plexapi.BASE_HEADERS['X-Plex-Vendor'] = 'Apple' + 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 + plexapi.BASE_HEADERS['X-Plex-Model'] = '8,4' + plexapi.BASE_HEADERS['X-Plex-Vendor'] = 'Apple' + + 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` +""" class SyncItem(PlexObject): From 57f68acabdd0c7b115cef4001f055739af23219f Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Fri, 24 Aug 2018 17:25:13 +0100 Subject: [PATCH 12/33] use list comprehension --- plexapi/sync.py | 2 +- plexapi/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index dbb562202..796e3a3e6 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -52,7 +52,7 @@ def _loadData(self, data): self.location = data.find('Location').attrib.copy() def server(self): - server = list(filter(lambda x: x.clientIdentifier == self.machineIdentifier, self._server.resources())) + server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier] if 0 == len(server): raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier) return server[0] diff --git a/plexapi/utils.py b/plexapi/utils.py index 1aa763d2d..83b2385c3 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -163,7 +163,7 @@ def threaded(callback, listargs): if all([not t.is_alive() for t in threads]): break time.sleep(0.05) - return list(filter(lambda r: r is not None, results)) + return [r for r in results if r is not None] def toDatetime(value, format=None): From 59af01d90fb0d14d63505143683e319c0ed5a098 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Sat, 25 Aug 2018 01:23:52 +0100 Subject: [PATCH 13/33] remove timeout from PlexObject.fetchItems() --- plexapi/base.py | 4 ++-- plexapi/sync.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index c5665f5c2..7aeb98598 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -139,12 +139,12 @@ def fetchItem(self, ekey, cls=None, **kwargs): clsname = cls.__name__ if cls else 'None' raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs)) - def fetchItems(self, ekey, cls=None, timeout=None, **kwargs): + def fetchItems(self, ekey, cls=None, **kwargs): """ Load the specified key to find and build all items with the specified tag and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details on how this is used. """ - data = self._server.query(ekey, timeout=timeout) + data = self._server.query(ekey) return self.findItems(data, cls, ekey, **kwargs) def findItems(self, data, cls=None, initpath=None, **kwargs): diff --git a/plexapi/sync.py b/plexapi/sync.py index 796e3a3e6..048c18acd 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -57,10 +57,10 @@ def server(self): raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier) return server[0] - def getMedia(self, timeout=None): + def getMedia(self): server = self.server().connect() key = '/sync/items/%s' % self.id - return server.fetchItems(key, timeout=timeout) + return server.fetchItems(key) def markDownloaded(self, media): """ From 5860e2c6d0eabb4de226fc1ded83667dd1d6cd13 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Tue, 28 Aug 2018 20:19:08 +0100 Subject: [PATCH 14/33] fix SyncItem under python 2.7 --- plexapi/sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index 048c18acd..c37f76d2e 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -35,7 +35,7 @@ class SyncItem(PlexObject): TAG = 'SyncItem' def __init__(self, server, data, initpath=None, clientIdentifier=None): - super().__init__(server, data, initpath) + super(SyncItem, self).__init__(server, data, initpath) self.clientIdentifier = clientIdentifier def _loadData(self, data): From 9383ed0e4ac94b6b7030f71cbdf10c17b72b923f Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Tue, 28 Aug 2018 20:21:28 +0100 Subject: [PATCH 15/33] fix __doc__ in sync module --- plexapi/sync.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index c37f76d2e..3dd95b529 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -1,9 +1,4 @@ # -*- coding: utf-8 -*- -import requests -import plexapi -from plexapi.exceptions import NotFound -from plexapi.base import PlexObject - """ You can work with Mobile Sync on other devices straight away, but if you'd like to use your app as sync-target you need to init some variables. @@ -30,6 +25,11 @@ def init_sync(): to explicitly specify that your app supports `sync-target` """ +import requests +import plexapi +from plexapi.exceptions import NotFound +from plexapi.base import PlexObject + class SyncItem(PlexObject): TAG = 'SyncItem' From 7fb616e24742e58fa39c75108cbb287485e819e7 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Sat, 1 Sep 2018 20:34:03 +0100 Subject: [PATCH 16/33] revert myplex._connect() back to it`s original state --- plexapi/__init__.py | 1 - plexapi/myplex.py | 6 ++---- plexapi/utils.py | 13 +++++-------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/plexapi/__init__.py b/plexapi/__init__.py index 454eef8fe..48d280606 100644 --- a/plexapi/__init__.py +++ b/plexapi/__init__.py @@ -17,7 +17,6 @@ VERSION = '3.0.6' 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') diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 66fc137ed..2aecb981b 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -3,7 +3,7 @@ import requests import time from requests.status_codes import _codes as codes -from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER, X_PLEX_ENABLE_FAST_CONNECT +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER from plexapi import log, logfilter, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest, NotFound @@ -707,7 +707,7 @@ def delete(self): self._server.query(key, self._server._session.delete) -def _connect(cls, url, token, timeout, results, i, connected_event=None): +def _connect(cls, url, token, timeout, results, i): """ Connects to the specified cls with url and token. Stores the connection information to results[i] in a threadsafe way. """ @@ -716,8 +716,6 @@ def _connect(cls, url, token, timeout, results, i, connected_event=None): 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 connected_event: - connected_event.set() except Exception as err: runtime = int(time.time() - starttime) log.error('%s: %s', url, err) diff --git a/plexapi/utils.py b/plexapi/utils.py index 83b2385c3..f83fa858a 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -7,7 +7,7 @@ import zipfile from datetime import datetime from getpass import getpass -import threading +from threading import Thread from tqdm import tqdm from plexapi import compat from plexapi.exceptions import NotFound @@ -152,18 +152,15 @@ def threaded(callback, listargs): listargs (list): List of lists; \*args to pass each thread. """ threads, results = [], [] - connected_event = threading.Event() for args in listargs: args += [results, len(results)] results.append(None) - threads.append(threading.Thread(target=callback, args=args, kwargs=dict(connected_event=connected_event))) + threads.append(Thread(target=callback, args=args)) threads[-1].setDaemon(True) threads[-1].start() - while not connected_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] + for thread in threads: + thread.join() + return results def toDatetime(value, format=None): From 2f534dd709cc214bbdea2fb8b917380bde7f4061 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Sat, 1 Sep 2018 21:49:28 +0100 Subject: [PATCH 17/33] improve sync docs --- plexapi/sync.py | 120 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 13 deletions(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index 3dd95b529..352113a00 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- """ -You can work with Mobile Sync on other devices straight away, but if you'd like to use your app as sync-target you -need to init some variables. +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 -Example: def init_sync(): import plexapi plexapi.X_PLEX_PROVIDES = 'sync-target' @@ -21,8 +22,8 @@ def init_sync(): plexapi.BASE_HEADERS['X-Plex-Model'] = '8,4' plexapi.BASE_HEADERS['X-Plex-Vendor'] = 'Apple' - 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` +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 @@ -32,6 +33,30 @@ def init_sync(): 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 + 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): unknown + """ TAG = 'SyncItem' def __init__(self, server, data, initpath=None, clientIdentifier=None): @@ -45,35 +70,47 @@ def _loadData(self, data): 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 = Status(self._server, data.find('Status')) - self.MediaSettings = data.find('MediaSettings').attrib.copy() - self.policy = data.find('Policy').attrib.copy() + self.status = Status(self._server, data.find(Status.TAG)) + self.mediaSettings = MediaSettings(self._server, data.find(MediaSettings.TAG)) + self.policy = Policy(self._server, data.find(Policy.TAG)) self.location = data.find('Location').attrib.copy() def server(self): + """ Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. + """ server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier] if 0 == len(server): 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 markDownloaded(self, media): - """ - Mark the file as downloaded within current SyncItem + """ Mark the file as downloaded (by the nature of Plex it will be marked as downloaded within + any SyncItem where it presented). - :param media: - :type media: base.Playable + 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) 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' @@ -90,6 +127,19 @@ def _loadData(self, data): class Status(PlexObject): + """ 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` + """ TAG = 'Status' def _loadData(self, data): @@ -105,10 +155,54 @@ def _loadData(self, data): self.itemsSuccessfulCount = plexapi.utils.cast(int, data.attrib.get('itemsSuccessfulCount')) def __repr__(self): - return str(dict( + return '<%s>:%s' % (self.__class__.__name__, dict( itemsCount=self.itemsCount, itemsCompleteCount=self.itemsCompleteCount, itemsDownloadedCount=self.itemsDownloadedCount, itemsReadyCount=self.itemsReadyCount, itemsSuccessfulCount=self.itemsSuccessfulCount )) + + +class MediaSettings(PlexObject): + """ Transcoding settings used for all media within :class:`~plexapi.sync.SyncItem`. + + Attributes: + audioBoost (int): unknown + maxVideoBitrate (int): unknown + musicBitrate (int): unknown + photoQuality (int): unknown + photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`) + videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`) + subtitleSize (int): unknown + videoQuality (int): unknown + """ + TAG = 'MediaSettings' + + def _loadData(self, data): + self._data = data + self.audioBoost = plexapi.utils.cast(int, data.attrib.get('audioBoost')) + self.maxVideoBitrate = plexapi.utils.cast(int, data.attrib.get('maxVideoBitrate')) + self.musicBitrate = plexapi.utils.cast(int, data.attrib.get('musicBitrate')) + self.photoQuality = plexapi.utils.cast(int, data.attrib.get('photoQuality')) + self.photoResolution = data.attrib.get('photoResolution') + self.videoResolution = data.attrib.get('videoResolution') + self.subtitleSize = plexapi.utils.cast(int, data.attrib.get('subtitleSize')) + self.videoQuality = plexapi.utils.cast(int, data.attrib.get('videoQuality')) + + +class Policy(PlexObject): + """ Policy of syncing the media. + + Attributes: + scope (str): can be `count` or `all` + value (int): valid only when `scope=count`, means amount of media to sync + unwatched (bool): True means disallow to sync watched media + """ + TAG = 'Policy' + + def _loadData(self, data): + self._data = data + self.scope = data.attrib.get('scope') + self.value = plexapi.utils.cast(int, data.attrib.get('value')) + self.unwatched = plexapi.utils.cast(bool, data.attrib.get('unwatched')) From 10226e18aac5711c569dfd060a0a5ad13834b7bd Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Sun, 2 Sep 2018 01:43:16 +0100 Subject: [PATCH 18/33] get rid of PlexObjects where not needed --- plexapi/sync.py | 98 ++++++++++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index 352113a00..4e3ee5045 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -28,7 +28,7 @@ def init_sync(): import requests import plexapi -from plexapi.exceptions import NotFound +from plexapi.exceptions import NotFound, BadRequest from plexapi.base import PlexObject @@ -72,10 +72,10 @@ def _loadData(self, data): self.metadataType = data.attrib.get('metadataType') self.contentType = data.attrib.get('contentType') self.machineIdentifier = data.find('Server').get('machineIdentifier') - self.status = Status(self._server, data.find(Status.TAG)) - self.mediaSettings = MediaSettings(self._server, data.find(MediaSettings.TAG)) - self.policy = Policy(self._server, data.find(Policy.TAG)) - 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): """ Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. @@ -126,7 +126,7 @@ def _loadData(self, data): self.items.append(item) -class Status(PlexObject): +class Status(object): """ Represents a current status of specific :class:`~plexapi.sync.SyncItem`. Attributes: @@ -140,21 +140,21 @@ class Status(PlexObject): totalSize (int): total size in bytes of complete items itemsSuccessfulCount (int): unknown, in my experience it always was equal to `itemsCompleteCount` """ - TAG = 'Status' - def _loadData(self, data): - self._data = data - self.failureCode = data.attrib.get('failureCode') - self.failure = data.attrib.get('failure') - self.state = data.attrib.get('state') - self.itemsCount = plexapi.utils.cast(int, data.attrib.get('itemsCount')) - self.itemsCompleteCount = plexapi.utils.cast(int, data.attrib.get('itemsCompleteCount')) - self.totalSize = plexapi.utils.cast(int, data.attrib.get('totalSize')) - self.itemsDownloadedCount = plexapi.utils.cast(int, data.attrib.get('itemsDownloadedCount')) - self.itemsReadyCount = plexapi.utils.cast(int, data.attrib.get('itemsReadyCount')) - self.itemsSuccessfulCount = plexapi.utils.cast(int, data.attrib.get('itemsSuccessfulCount')) - - def __repr__(self): + 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, @@ -164,34 +164,49 @@ def __repr__(self): )) -class MediaSettings(PlexObject): +class MediaSettings(object): """ Transcoding settings used for all media within :class:`~plexapi.sync.SyncItem`. Attributes: audioBoost (int): unknown - maxVideoBitrate (int): unknown + maxVideoBitrate (str): unknown, may be empty musicBitrate (int): unknown photoQuality (int): unknown photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`) - videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`) - subtitleSize (int): unknown + videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty) + subtitleSize (str): unknown, usually equals to 0, but sometimes empty string videoQuality (int): unknown """ - TAG = 'MediaSettings' - def _loadData(self, data): - self._data = data - self.audioBoost = plexapi.utils.cast(int, data.attrib.get('audioBoost')) - self.maxVideoBitrate = plexapi.utils.cast(int, data.attrib.get('maxVideoBitrate')) - self.musicBitrate = plexapi.utils.cast(int, data.attrib.get('musicBitrate')) - self.photoQuality = plexapi.utils.cast(int, data.attrib.get('photoQuality')) - self.photoResolution = data.attrib.get('photoResolution') - self.videoResolution = data.attrib.get('videoResolution') - self.subtitleSize = plexapi.utils.cast(int, data.attrib.get('subtitleSize')) - self.videoQuality = plexapi.utils.cast(int, data.attrib.get('videoQuality')) + def __init__(self, maxVideoBitrate, videoQuality, videoResolution, audioBoost=100, musicBitrate=192, + photoQuality=74, photoResolution='1920x1080', subtitleSize=''): + self.audioBoost = plexapi.utils.cast(int, audioBoost) + self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) + self.musicBitrate = plexapi.utils.cast(int, musicBitrate) + self.photoQuality = plexapi.utils.cast(int, photoQuality) + self.photoResolution = photoResolution + self.videoResolution = videoResolution + self.subtitleSize = subtitleSize + self.videoQuality = plexapi.utils.cast(int, videoQuality) + + @staticmethod + def create(video_quality): + """ Create a :class:`~MediaSettings` object, based on provided video quality value + + Raises: + :class:`plexapi.exceptions.BadRequest` when provided unknown video quality + """ + if video_quality == VIDEO_QUALITY_ORIGINAL: + return MediaSettings('', '', '') + elif video_quality < len(VIDEO_QUALITIES['bitrate']): + return MediaSettings(VIDEO_QUALITIES['bitrate'][video_quality], + VIDEO_QUALITIES['videoQuality'][video_quality], + VIDEO_QUALITIES['videoResolution'][video_quality]) + else: + raise BadRequest('Unexpected video quality') -class Policy(PlexObject): +class Policy(object): """ Policy of syncing the media. Attributes: @@ -199,10 +214,9 @@ class Policy(PlexObject): value (int): valid only when `scope=count`, means amount of media to sync unwatched (bool): True means disallow to sync watched media """ - TAG = 'Policy' - def _loadData(self, data): - self._data = data - self.scope = data.attrib.get('scope') - self.value = plexapi.utils.cast(int, data.attrib.get('value')) - self.unwatched = plexapi.utils.cast(bool, data.attrib.get('unwatched')) + def __init__(self, scope, unwatched, value=0): + self.scope = scope + self.unwatched = plexapi.utils.cast(bool, unwatched) + self.value = plexapi.utils.cast(int, value) + From 12977248188ae28c35342a84384cef6ec9fbd9bb Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Sun, 2 Sep 2018 01:43:44 +0100 Subject: [PATCH 19/33] add X-Plex-Sync-Version=2 to headers --- plexapi/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plexapi/config.py b/plexapi/config.py index 20f9a96e6..47eebd8bf 100644 --- a/plexapi/config.py +++ b/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', } From 2be3f5c556b7cfdd4d590d44c14bd59c3a2d52d8 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Sun, 2 Sep 2018 01:46:15 +0100 Subject: [PATCH 20/33] add sync() method into Video, LibrarySection and MyPlexAccount --- plexapi/library.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++ plexapi/myplex.py | 45 +++++++++++++++++++++++++++++++++++++++- plexapi/sync.py | 19 +++++++++++++++++ plexapi/video.py | 28 +++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) diff --git a/plexapi/library.py b/plexapi/library.py index b42f14168..24122c916 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -543,6 +543,53 @@ def _cleanSearchSort(self, sort): raise BadRequest('Unknown sort dir: %s' % sdir) return '%s:%s' % (lookup[scol], sdir) + def sync(self, client, policy, media_settings, title=None, sort=None, libtype=None, **kwargs): + """ Add current library section as sync item for specified device. + See description of :func:`plexapi.library.LibraySection.search()` for details about filtering / sorting. + + 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() + movies = srv.library.section('Movies') + policy = Policy('count', True, 1) + media_settings = MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p) + movies.sync(target, policy, media_settings, title='Next best movie', sort='rating:desc') + + """ + from plexapi.sync import SyncItem + + 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 = media_settings + + return myplex.sync(client, sync_item) + class MovieSection(LibrarySection): """ Represents a :class:`~plexapi.library.LibrarySection` section containing movies. @@ -564,6 +611,8 @@ class MovieSection(LibrarySection): '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. """ @@ -587,6 +636,8 @@ class ShowSection(LibrarySection): '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. """ diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 2aecb981b..fbfa80930 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -11,7 +11,7 @@ from plexapi.compat import ElementTree from plexapi.library import LibrarySection from plexapi.server import PlexServer -from plexapi.sync import SyncList +from plexapi.sync import SyncList, SyncItem from plexapi.utils import joinArgs @@ -380,6 +380,12 @@ def optOut(self, playback=None, library=None): return self.query(url, method=self._session.put, params=params) def syncItems(self, clientId=None): + """ Returns an instance of :class:`~plexapi.sync.SyncItems` for specified client + + Arguments: + clientId (str): an identifier of a client for which you need to get SyncItems. Would be set to current + id if None. + """ if clientId is None: clientId = X_PLEX_IDENTIFIER @@ -387,6 +393,43 @@ def syncItems(self, clientId=None): return SyncList(self, data) + def sync(self, client, sync_item): + """ Adds specified sync item for the client + + Arguments: + client (:class:`~plexapi.myplex.MyPlexDevice`): pass + sync_item (:class:`~plexapi.sync.SyncItem`): pass + """ + 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) + class MyPlexUser(PlexObject): """ This object represents non-signed in users such as friends and linked diff --git a/plexapi/sync.py b/plexapi/sync.py index 4e3ee5045..8ea0bdd56 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -220,3 +220,22 @@ def __init__(self, scope, unwatched, value=0): self.unwatched = plexapi.utils.cast(bool, unwatched) self.value = plexapi.utils.cast(int, value) + +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 diff --git a/plexapi/video.py b/plexapi/video.py index 2d308510c..107930258 100644 --- a/plexapi/video.py +++ b/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,28 @@ def markUnwatched(self): self._server.query(key) self.reload() + def sync(self, client, policy, media_settings, title=None): + """ Add current video as sync item for specified device. + """ + + from plexapi.sync import SyncItem + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self._pretty_filename() + 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 + sync_item.mediaSettings = media_settings + + return myplex.sync(client, sync_item) + @utils.registerPlexObject class Movie(Playable, Video): @@ -116,6 +139,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') @@ -236,6 +260,7 @@ class Show(Video): """ TAG = 'Directory' TYPE = 'show' + METADATA_TYPE = 'episode' def __iter__(self): for season in self.seasons(): @@ -363,6 +388,7 @@ class Season(Video): """ TAG = 'Directory' TYPE = 'season' + METADATA_TYPE = 'episode' def __iter__(self): for episode in self.episodes(): @@ -482,6 +508,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') From ef95e01d2cc7eaea5814b68c08bdcac78fc9fb89 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Mon, 3 Sep 2018 01:01:16 +0100 Subject: [PATCH 21/33] add SyncItem.delete() --- plexapi/sync.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plexapi/sync.py b/plexapi/sync.py index 8ea0bdd56..14fb62cf7 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -102,6 +102,11 @@ def markDownloaded(self, media): url = '/sync/%s/item/%s/downloaded' % (self.clientIdentifier, media.ratingKey) media._server.query(url, method=requests.put) + def delete(self): + 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 From e69df4d34078b5c1df5fc0c57cb14040ff8f53bb Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Mon, 3 Sep 2018 01:01:32 +0100 Subject: [PATCH 22/33] add sync.Policy.create() --- plexapi/sync.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plexapi/sync.py b/plexapi/sync.py index 14fb62cf7..08cb9da97 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -225,6 +225,16 @@ def __init__(self, scope, unwatched, value=0): self.unwatched = plexapi.utils.cast(bool, unwatched) self.value = plexapi.utils.cast(int, value) + @staticmethod + def create(limit=None, unwatched=False): + 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], From 4590adc258f231bf3b25a3f4447cbb942ef17c5a Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Mon, 3 Sep 2018 01:02:02 +0100 Subject: [PATCH 23/33] use self._default_sync_title instead of _prettyfilename as default title --- plexapi/video.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/plexapi/video.py b/plexapi/video.py index 107930258..3c0db424d 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -78,6 +78,9 @@ def markUnwatched(self): self._server.query(key) self.reload() + def _default_sync_title(self): + return self._prettyfilename() + def sync(self, client, policy, media_settings, title=None): """ Add current video as sync item for specified device. """ @@ -86,7 +89,7 @@ def sync(self, client, policy, media_settings, title=None): myplex = self._server.myPlexAccount() sync_item = SyncItem(self._server, None) - sync_item.title = title if title else self._pretty_filename() + sync_item.title = title if title else self._default_sync_title() sync_item.rootTitle = self.title sync_item.contentType = self.listType sync_item.metadataType = self.METADATA_TYPE @@ -371,6 +374,9 @@ def download(self, savepath=None, keep_orginal_name=False, **kwargs): filepaths += episode.download(savepath, keep_orginal_name, **kwargs) return filepaths + def _default_sync_title(self): + return self.title + @utils.registerPlexObject class Season(Video): @@ -472,6 +478,9 @@ def download(self, savepath=None, keep_orginal_name=False, **kwargs): filepaths += episode.download(savepath, keep_orginal_name, **kwargs) return filepaths + def _default_sync_title(self): + return self.title + @utils.registerPlexObject class Episode(Playable, Video): From a46a4aa88355a4a56cac5ec3a38999052ad724fe Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Mon, 3 Sep 2018 01:02:33 +0100 Subject: [PATCH 24/33] let the tests begin --- tests/conftest.py | 27 ++++++++++++++++++++++ tests/test_sync.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/test_sync.py diff --git a/tests/conftest.py b/tests/conftest.py index d351e3b5f..c731695bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,6 +74,13 @@ def account(): # return MyPlexAccount(MYPLEX_USERNAME, MYPLEX_PASSWORD) +@pytest.fixture() +def account_synctarget(): + assert 'sync-target' in plexapi.X_PLEX_PROVIDES + assert 'sync-target' in plexapi.BASE_HEADERS['X-Plex-Provides'] + return plex().myPlexAccount() + + @pytest.fixture(scope='session') def plex(): assert SERVER_BASEURL, 'Required SERVER_BASEURL not specified.' @@ -82,6 +89,26 @@ def plex(): return PlexServer(SERVER_BASEURL, SERVER_TOKEN, session=session) +@pytest.fixture() +def device(account): + d = None + for device in account.devices(): + if device.clientIdentifier == plexapi.X_PLEX_IDENTIFIER: + d = device + break + + assert d + return d + + +@pytest.fixture() +def clear_sync_device(device, account_synctarget): + sync_items = account_synctarget.syncItems(device.clientIdentifier) + for item in sync_items.items: + item.delete() + return device + + @pytest.fixture def fresh_plex(): return PlexServer diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 000000000..5e0db9a61 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,57 @@ +from time import sleep + +from plexapi.sync import Policy, MediaSettings, VIDEO_QUALITY_3_MBPS_720p + + +MAX_RETRIES = 3 + + +def get_new_synclist(account, client_id, old_sync_items=[]): + retry = 0 + old_ids = [i.id for i in old_sync_items] + while retry < MAX_RETRIES: + retry += 1 + sync_items = account.syncItems(client_id) + new_ids = [i.id for i in sync_items.items] + if new_ids != old_ids: + return sync_items + sleep(0.5) + assert False, 'Unable to get updated SyncItems' + + +def test_current_device_got_sync_target(account_synctarget, device): + assert 'sync-target' in device.provides + + +def test_delete_sync_item(account_synctarget, clear_sync_device, movie): + movie.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) + for item in sync_items.items: + item.delete() + sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier, sync_items.items) + assert not sync_items.items + + +def test_add_movie_to_sync(account_synctarget, clear_sync_device, movie): + movie.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) + assert sync_items.items[0].getMedia()[0].ratingKey == movie.ratingKey + + +def test_add_show_to_sync(account_synctarget, clear_sync_device, show): + show.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) + assert sync_items.items[0].getMedia()[0].ratingKey == show.ratingKey + + +def test_add_season_to_sync(account_synctarget, clear_sync_device, show): + season = show.season('Season 1') + season.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) + assert sync_items.items[0].getMedia()[0].ratingKey == season.ratingKey + + +def test_add_episode_to_sync(account_synctarget, clear_sync_device, episode): + episode.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) + assert sync_items.items[0].getMedia()[0].ratingKey == episode.ratingKey From f9c881b950d2035e5cf0f00165409c4bc2e3183e Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Mon, 3 Sep 2018 10:34:46 +0100 Subject: [PATCH 25/33] add items for refreshing synclists to PlexServer --- plexapi/server.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plexapi/server.py b/plexapi/server.py index 849b4c690..bddb0588a 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -422,6 +422,16 @@ def url(self, key, includeToken=None): return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token) return '%s%s' % (self._baseurl, key) + def refreshSynclist(self): + return self.query('/sync/refreshSynclists', self._session.put) + + def refreshContent(self): + return self.query('/sync/refreshContent', self._session.put) + + def refreshSync(self): + self.refreshSynclist() + self.refreshContent() + class Account(PlexObject): """ Contains the locally cached MyPlex account information. The properties provided don't From a81177289f475081ac7472163468032537768baf Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Mon, 3 Sep 2018 10:35:06 +0100 Subject: [PATCH 26/33] fix sync tests --- tests/conftest.py | 9 +++++++-- tests/test_sync.py | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c731695bd..8903e6f7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,8 +76,12 @@ def account(): @pytest.fixture() def account_synctarget(): - assert 'sync-target' in plexapi.X_PLEX_PROVIDES + assert 'sync-target' in plexapi.X_PLEX_PROVIDES, 'You have to set env var ' \ + 'PLEXAPI_HEADER_PROVIDES=sync-target,controller' assert 'sync-target' in plexapi.BASE_HEADERS['X-Plex-Provides'] + assert 'iOS' == plexapi.X_PLEX_PLATFORM, 'You have to set env var PLEXAPI_HEADER_PLATORM=iOS' + assert '11.4.1' == plexapi.X_PLEX_PLATFORM_VERSION, 'You have to set env var PLEXAPI_HEADER_PLATFORM_VERSION=11.4.1' + assert 'iPhone' == plexapi.X_PLEX_DEVICE, 'You have to set env var PLEXAPI_HEADER_DEVICE=iPhone' return plex().myPlexAccount() @@ -102,10 +106,11 @@ def device(account): @pytest.fixture() -def clear_sync_device(device, account_synctarget): +def clear_sync_device(device, account_synctarget, plex): sync_items = account_synctarget.syncItems(device.clientIdentifier) for item in sync_items.items: item.delete() + plex.refreshSync() return device diff --git a/tests/test_sync.py b/tests/test_sync.py index 5e0db9a61..82c0625ad 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -25,6 +25,7 @@ def test_current_device_got_sync_target(account_synctarget, device): def test_delete_sync_item(account_synctarget, clear_sync_device, movie): movie.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + movie._server.refreshSync() sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) for item in sync_items.items: item.delete() @@ -33,25 +34,49 @@ def test_delete_sync_item(account_synctarget, clear_sync_device, movie): def test_add_movie_to_sync(account_synctarget, clear_sync_device, movie): - movie.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + new_item = movie.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + movie._server.refreshSync() sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) - assert sync_items.items[0].getMedia()[0].ratingKey == movie.ratingKey + new_item_in_myplex = None + for sync_item in sync_items.items: + if sync_item.id == new_item.id: + new_item_in_myplex = sync_item + assert new_item_in_myplex + assert new_item_in_myplex.getMedia()[0].ratingKey == movie.ratingKey def test_add_show_to_sync(account_synctarget, clear_sync_device, show): - show.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + new_item = show.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + show._server.refreshSync() sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) - assert sync_items.items[0].getMedia()[0].ratingKey == show.ratingKey + new_item_in_myplex = None + for sync_item in sync_items.items: + if sync_item.id == new_item.id: + new_item_in_myplex = sync_item + assert new_item_in_myplex + + episode = show.episodes(viewCount=0, limit=1)[0] + + assert new_item_in_myplex.getMedia()[0].ratingKey == episode.ratingKey def test_add_season_to_sync(account_synctarget, clear_sync_device, show): season = show.season('Season 1') - season.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + new_item = season.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + season._server.refreshSync() sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) - assert sync_items.items[0].getMedia()[0].ratingKey == season.ratingKey + new_item_in_myplex = None + for sync_item in sync_items.items: + if sync_item.id == new_item.id: + new_item_in_myplex = sync_item + assert new_item_in_myplex + + episode = season.episodes(viewCount=0, limit=1)[0] + assert sync_items.items[0].getMedia()[0].ratingKey == episode.ratingKey def test_add_episode_to_sync(account_synctarget, clear_sync_device, episode): episode.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + episode._server.refreshSync() sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) assert sync_items.items[0].getMedia()[0].ratingKey == episode.ratingKey From 9971435758f91c2e737456334116cd9784ceb800 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Tue, 4 Sep 2018 00:26:14 +0100 Subject: [PATCH 27/33] sync for everybody! --- plexapi/audio.py | 42 ++++++ plexapi/library.py | 71 +++++++++- plexapi/myplex.py | 52 +++++-- plexapi/photo.py | 31 +++++ plexapi/playlist.py | 70 +++++++++- plexapi/server.py | 3 + plexapi/sync.py | 101 ++++++++++---- plexapi/video.py | 36 +++-- tests/conftest.py | 2 +- tests/test_sync.py | 329 ++++++++++++++++++++++++++++++++++++-------- 10 files changed, 625 insertions(+), 112 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 6f6c0d986..a2e2d518b 100644 --- a/plexapi/audio.py +++ b/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,38 @@ def url(self, part): """ 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): + return self.title + + def sync(self, bitrate, client=None, clientId=None, limit=None, title=None): + """ Add current video as sync item for specified device. + + Parameters: + bitrate (int): TODO + client (:class:`~plexapi.myplex.MyPlexDevice`): TODO + clientId (str): TODO + limit (int): TODO + title (str): TODO + """ + + 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): @@ -225,6 +261,9 @@ def download(self, savepath=None, keep_orginal_name=False, **kwargs): filepaths += track.download(savepath, keep_orginal_name, **kwargs) return filepaths + def _defaultSyncTitle(self): + return '%s - %s' % (self.parentTitle, self.title) + @utils.registerPlexObject class Track(Audio, Playable): @@ -302,3 +341,6 @@ def album(self): def artist(self): """ Return this track's :class:`~plexapi.audio.Artist`. """ return self.fetchItem(self.grandparentKey) + + def _defaultSyncTitle(self): + return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) diff --git a/plexapi/library.py b/plexapi/library.py index 24122c916..47447c93e 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -543,10 +543,20 @@ def _cleanSearchSort(self, sort): raise BadRequest('Unknown sort dir: %s' % sdir) return '%s:%s' % (lookup[scol], sdir) - def sync(self, client, policy, media_settings, title=None, sort=None, libtype=None, **kwargs): + 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.LibraySection.search()` for details about filtering / sorting. + Parameters: + policy (:class:`~plexapi.sync.Policy`): TODO + mediaSettings (:class:`~plexapi.sync.MediaSettings`): TODO + client (:class:`~plexapi.myplex.MyPlexDevice`): TODO + clientId (str): TODO + title (str): TODO + sort (str): TODO + libtype (str): TODO + Example: .. code-block:: python @@ -586,9 +596,9 @@ def sync(self, client, policy, media_settings, title=None, sort=None, libtype=No sync_item.location = 'library://%s/directory/%s' % (self.uuid, quote_plus(key + utils.joinArgs(args))) sync_item.policy = policy - sync_item.mediaSettings = media_settings + sync_item.mediaSettings = mediaSettings - return myplex.sync(client, sync_item) + return myplex.sync(client=client, clientId=clientId, sync_item=sync_item) class MovieSection(LibrarySection): @@ -618,6 +628,19 @@ 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): + """ TODO + + Parameters: + videoQuality (int): TODO + limit (int): TODO + unwatched (bool): TODO + """ + from .sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) + kwargs['policy'] = Policy.create(limit, unwatched) + return super(MovieSection, self).sync(**kwargs) + class ShowSection(LibrarySection): """ Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows. @@ -659,6 +682,19 @@ 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): + """ TODO + + Parameters: + videoQuality (int): TODO + limit (int): TODO + unwatched (bool): TODO + """ + from .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. @@ -676,6 +712,9 @@ class MusicSection(LibrarySection): 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 @@ -697,6 +736,18 @@ 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): + """ TODO + + Parameters: + bitrate (int): TODO + limit (int): TODO + """ + from .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. @@ -712,6 +763,8 @@ class PhotoSection(LibrarySection): 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. """ @@ -723,6 +776,18 @@ def searchPhotos(self, title, **kwargs): key = '/library/sections/%s/all?type=13' % self.key return self.fetchItems(key, title=title) + def sync(self, resolution, limit=None, **kwargs): + """ TODO + + Parameters: + resolution (str): TODO + limit (int): TODO + """ + from .sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createPhoto(resolution) + kwargs['policy'] = Policy.create(limit) + return super(PhotoSection, self).sync(**kwargs) + class FilterChoice(PlexObject): """ Represents a single filter choice. These objects are gathered when using filters diff --git a/plexapi/myplex.py b/plexapi/myplex.py index fbfa80930..6fd1ceefb 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -379,27 +379,48 @@ def optOut(self, playback=None, library=None): url = 'https://plex.tv/api/v2/user/privacy' return self.query(url, method=self._session.put, params=params) - def syncItems(self, clientId=None): - """ Returns an instance of :class:`~plexapi.sync.SyncItems` for specified client + def syncItems(self, client=None, clientId=None): + """ Returns an instance of :class:`~plexapi.sync.SyncList` for specified client - Arguments: - clientId (str): an identifier of a client for which you need to get SyncItems. Would be set to current - id if None. + Parameters: + client (:class:`~plexapi.myplex.MyPlexDevice`): TODO + clientId (str): an identifier of a client for which you need to get SyncItems + + If both `client` and `clientId` provided the client would be preferred. + If neither `client` nor `clientId` provided the clientId would be set to :ref:`identifier header`. """ - if clientId is None: + 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, client, sync_item): + def sync(self, sync_item, client=None, clientId=None): """ Adds specified sync item for the client - Arguments: - client (:class:`~plexapi.myplex.MyPlexDevice`): pass - sync_item (:class:`~plexapi.sync.SyncItem`): pass + Parameters: + client (:class:`~plexapi.myplex.MyPlexDevice`): TODO + clientId (str): TODO + sync_item (:class:`~plexapi.sync.SyncItem`): TODO + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem """ + 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') @@ -749,6 +770,17 @@ def delete(self): key = 'https://plex.tv/devices/%s.xml' % self.id self._server.query(key, self._server._session.delete) + def syncItems(self): + """ TODO + + Returns: + :class:`~plexapi.sync.SyncList` + """ + 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): """ Connects to the specified cls with url and token. Stores the connection diff --git a/plexapi/photo.py b/plexapi/photo.py index 50db79f56..032afd9f4 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -2,6 +2,7 @@ from plexapi import media, utils from plexapi.base import PlexPartialObject from plexapi.exceptions import NotFound +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. """ @@ -123,3 +125,32 @@ def photoalbum(self): def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """ return self._server.library.sectionByID(self.photoalbum().librarySectionID) + + def sync(self, resolution, client=None, clientId=None, limit=None, title=None): + """ Add current video as sync item for specified device. + + Parameters: + resolution (string): TODO + client (:class:`~plexapi.myplex.MyPlexDevice`): TODO + clientId (str): TODO + limit (int): TODO + title (str): TODO + """ + + 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._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.createPhoto(resolution) + + return myplex.sync(sync_item, client=client, clientId=clientId) diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 06e18ffa5..6ba85f98b 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -1,9 +1,10 @@ # -*- 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.playqueue import PlayQueue from plexapi.utils import cast, toDatetime +from plexapi.compat import quote_plus @utils.registerPlexObject @@ -32,11 +33,35 @@ def _loadData(self, data): 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()) @@ -132,3 +157,46 @@ def copyToUser(self, user): # 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 video as sync item for specified device. + + Parameters: + videoQuality (int): TODO + photoResolution (str): TODO + audioBitrate (int): TODO + client (:class:`~plexapi.myplex.MyPlexDevice`): TODO + clientId (str): TODO + limit (int): TODO + unwatched (bool): TODO + title (str): TODO + + Raises: + :class:`~plexapi.exceptions.BadRequest`: when playlist is not allowed to sync + """ + + 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) + + return myplex.sync(sync_item, client=client, clientId=clientId) diff --git a/plexapi/server.py b/plexapi/server.py index bddb0588a..92c78d28d 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -423,12 +423,15 @@ def url(self, key, includeToken=None): return '%s%s' % (self._baseurl, key) def refreshSynclist(self): + """ TODO """ return self.query('/sync/refreshSynclists', self._session.put) def refreshContent(self): + """ TODO """ return self.query('/sync/refreshContent', self._session.put) def refreshSync(self): + """ TODO """ self.refreshSynclist() self.refreshContent() diff --git a/plexapi/sync.py b/plexapi/sync.py index 08cb9da97..63774b33e 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -27,9 +27,10 @@ def init_sync(): """ import requests + import plexapi -from plexapi.exceptions import NotFound, BadRequest from plexapi.base import PlexObject +from plexapi.exceptions import NotFound, BadRequest class SyncItem(PlexObject): @@ -39,6 +40,7 @@ class SyncItem(PlexObject): 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 @@ -78,16 +80,14 @@ def _loadData(self, data): self.location = data.find('Location').attrib.get('uri', '') def server(self): - """ Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. - """ + """ Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. """ server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier] - if 0 == len(server): + 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. - """ + """ 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) @@ -103,6 +103,7 @@ def markDownloaded(self, media): media._server.query(url, method=requests.put) def delete(self): + """ TODO """ url = SyncList.key.format(clientId=self.clientIdentifier) url += '/' + str(self.id) self._server.query(url, self._server._session.delete) @@ -124,11 +125,10 @@ def _loadData(self, data): self.clientId = data.attrib.get('clientIdentifier') self.items = [] - for elem in data: - if elem.tag == 'SyncItems': - for sync_item in elem: - item = SyncItem(self._server, sync_item, clientIdentifier=self.clientId) - self.items.append(item) + for elem in data.iter('SyncItems'): + for sync_item in elem: + item = SyncItem(self._server, sync_item, clientIdentifier=self.clientId) + self.items.append(item) class Status(object): @@ -158,8 +158,7 @@ def __init__(self, itemsCount, itemsCompleteCount, state, totalSize, itemsDownlo self.itemsCompleteCount = plexapi.utils.cast(int, itemsCompleteCount) self.itemsCount = plexapi.utils.cast(int, itemsCount) - -def __repr__(self): + def __repr__(self): return '<%s>:%s' % (self.__class__.__name__, dict( itemsCount=self.itemsCount, itemsCompleteCount=self.itemsCompleteCount, @@ -183,8 +182,8 @@ class MediaSettings(object): videoQuality (int): unknown """ - def __init__(self, maxVideoBitrate, videoQuality, videoResolution, audioBoost=100, musicBitrate=192, - photoQuality=74, photoResolution='1920x1080', subtitleSize=''): + 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) self.musicBitrate = plexapi.utils.cast(int, musicBitrate) @@ -195,28 +194,56 @@ def __init__(self, maxVideoBitrate, videoQuality, videoResolution, audioBoost=10 self.videoQuality = plexapi.utils.cast(int, videoQuality) @staticmethod - def create(video_quality): - """ Create a :class:`~MediaSettings` object, based on provided video quality value + def createVideo(videoQuality): + """ Returns a :class:`~MediaSettings` object, based on provided video quality value - Raises: - :class:`plexapi.exceptions.BadRequest` when provided unknown video quality + 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 video_quality == VIDEO_QUALITY_ORIGINAL: + if videoQuality == VIDEO_QUALITY_ORIGINAL: return MediaSettings('', '', '') - elif video_quality < len(VIDEO_QUALITIES['bitrate']): - return MediaSettings(VIDEO_QUALITIES['bitrate'][video_quality], - VIDEO_QUALITIES['videoQuality'][video_quality], - VIDEO_QUALITIES['videoResolution'][video_quality]) + 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:`~MediaSettings` object, based on provided music quality value + + Parameters: + bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values + """ + return MediaSettings(musicBitrate=bitrate) + + @staticmethod + def createPhoto(resolution): + """ Returns a :class:`~MediaSettings` object, based on provided photo quality value + + Parameters: + resolution (str): maximum allowed resolution for synchronized photos, better use one of PHOTO_QUALITY_* + values + + 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. + """ Policy of syncing the media (how many items to sync and process watched media or not) Attributes: - scope (str): can be `count` or `all` - value (int): valid only when `scope=count`, means amount of media to sync + 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 """ @@ -238,7 +265,8 @@ def create(limit=None, unwatched=False): 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"], + '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], } @@ -254,3 +282,20 @@ def create(limit=None, unwatched=False): 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/plexapi/video.py b/plexapi/video.py index 3c0db424d..9dd67d73d 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -78,18 +78,27 @@ def markUnwatched(self): self._server.query(key) self.reload() - def _default_sync_title(self): - return self._prettyfilename() + def _defaultSyncTitle(self): + """ TODO """ + return self.title - def sync(self, client, policy, media_settings, title=None): + def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None): """ Add current video as sync item for specified device. + + Parameters: + videoQuality (int): TODO + client (:class:`~plexapi.myplex.MyPlexDevice`): TODO + clientId (str): TODO + limit (int): TODO + unwatched (bool): TODO + title (str): TODO """ - from plexapi.sync import 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._default_sync_title() + 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 @@ -98,10 +107,10 @@ def sync(self, client, policy, media_settings, title=None): section = self._server.library.sectionByID(self.librarySectionID) sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key)) - sync_item.policy = policy - sync_item.mediaSettings = media_settings + sync_item.policy = Policy.create(limit, unwatched) + sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) - return myplex.sync(client, sync_item) + return myplex.sync(sync_item, client=client, clientId=clientId) @utils.registerPlexObject @@ -374,9 +383,6 @@ def download(self, savepath=None, keep_orginal_name=False, **kwargs): filepaths += episode.download(savepath, keep_orginal_name, **kwargs) return filepaths - def _default_sync_title(self): - return self.title - @utils.registerPlexObject class Season(Video): @@ -478,8 +484,9 @@ def download(self, savepath=None, keep_orginal_name=False, **kwargs): filepaths += episode.download(savepath, keep_orginal_name, **kwargs) return filepaths - def _default_sync_title(self): - return self.title + def _defaultSyncTitle(self): + """ TODO """ + return '%s - %s' % (self.parentTitle, self.title) @utils.registerPlexObject @@ -595,3 +602,6 @@ def season(self): def show(self): """" Return this episodes :func:`~plexapi.video.Show`.. """ return self.fetchItem(self.grandparentKey) + + def _defaultSyncTitle(self): + return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title) diff --git a/tests/conftest.py b/tests/conftest.py index 8903e6f7f..4b491eca2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -107,7 +107,7 @@ def device(account): @pytest.fixture() def clear_sync_device(device, account_synctarget, plex): - sync_items = account_synctarget.syncItems(device.clientIdentifier) + sync_items = account_synctarget.syncItems(clientId=device.clientIdentifier) for item in sync_items.items: item.delete() plex.refreshSync() diff --git a/tests/test_sync.py b/tests/test_sync.py index 82c0625ad..1d449a9cc 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1,82 +1,299 @@ -from time import sleep +from time import sleep, time -from plexapi.sync import Policy, MediaSettings, VIDEO_QUALITY_3_MBPS_720p +import pytest +from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p, AUDIO_BITRATE_192_KBPS, PHOTO_QUALITY_MEDIUM MAX_RETRIES = 3 -def get_new_synclist(account, client_id, old_sync_items=[]): - retry = 0 - old_ids = [i.id for i in old_sync_items] - while retry < MAX_RETRIES: - retry += 1 - sync_items = account.syncItems(client_id) - new_ids = [i.id for i in sync_items.items] - if new_ids != old_ids: - return sync_items +def ensure_sync_item(device, sync_item, timeout=3): + start = time() + while time() - start < timeout: + sync_list = device.syncItems() + for item in sync_list.items: + if item.id == sync_item.id: + return item sleep(0.5) - assert False, 'Unable to get updated SyncItems' + assert False, 'Failed to ensure that required sync_item is exist' -def test_current_device_got_sync_target(account_synctarget, device): - assert 'sync-target' in device.provides +def ensure_sync_item_missing(device, sync_item, timeout=3): + start = time() + ret = None + while time() - start < timeout: + sync_list = device.syncItems() + for item in sync_list.items: + if item.id == sync_item.id: + ret = item -def test_delete_sync_item(account_synctarget, clear_sync_device, movie): - movie.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) + if ret: + sleep(0.5) + else: + break + + assert not ret, 'Failed to ensure that required sync_item is missing' + + +def test_current_device_got_sync_target(clear_sync_device): + assert 'sync-target' in clear_sync_device.provides + + +def test_add_movie_to_sync(clear_sync_device, movie): + new_item = movie.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) movie._server.refreshSync() - sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) + item = ensure_sync_item(clear_sync_device, new_item) + media_list = item.getMedia() + assert len(media_list) == 1 + assert media_list[0].ratingKey == movie.ratingKey + + +def test_delete_sync_item(clear_sync_device, movie): + new_item = movie.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + movie._server.refreshSync() + new_item_in_myplex = ensure_sync_item(clear_sync_device, new_item) + sync_items = clear_sync_device.syncItems() for item in sync_items.items: item.delete() - sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier, sync_items.items) - assert not sync_items.items + ensure_sync_item_missing(clear_sync_device, new_item_in_myplex) -def test_add_movie_to_sync(account_synctarget, clear_sync_device, movie): - new_item = movie.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) - movie._server.refreshSync() - sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) - new_item_in_myplex = None - for sync_item in sync_items.items: - if sync_item.id == new_item.id: - new_item_in_myplex = sync_item - assert new_item_in_myplex - assert new_item_in_myplex.getMedia()[0].ratingKey == movie.ratingKey +def test_add_show_to_sync(clear_sync_device, show): + new_item = show.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + show._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + episodes = show.episodes() + media_list = item.getMedia() + assert len(episodes) == len(media_list) + assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] + + +def test_add_season_to_sync(clear_sync_device, show): + season = show.season('Season 1') + new_item = season.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + season._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + episodes = season.episodes() + media_list = item.getMedia() + assert len(episodes) == len(media_list) + assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] + + +def test_add_episode_to_sync(clear_sync_device, episode): + new_item = episode.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + episode._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + media_list = item.getMedia() + assert 1 == len(media_list) + assert episode.ratingKey == media_list[0].ratingKey -def test_add_show_to_sync(account_synctarget, clear_sync_device, show): - new_item = show.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) +def test_limited_watched(clear_sync_device, show): + show.markUnwatched() + new_item = show.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, limit=5, unwatched=False) show._server.refreshSync() - sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) - new_item_in_myplex = None - for sync_item in sync_items.items: - if sync_item.id == new_item.id: - new_item_in_myplex = sync_item - assert new_item_in_myplex + item = ensure_sync_item(clear_sync_device, new_item) + episodes = show.episodes()[:5] + media_list = item.getMedia() + assert 5 == len(media_list) + assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] + episodes[0].markWatched() + show._server.refreshSync() + media_list = item.getMedia() + assert 5 == len(media_list) + assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] - episode = show.episodes(viewCount=0, limit=1)[0] - assert new_item_in_myplex.getMedia()[0].ratingKey == episode.ratingKey +def test_limited_unwatched(clear_sync_device, show): + show.markUnwatched() + new_item = show.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, limit=5, unwatched=True) + show._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + episodes = show.episodes(viewCount=0)[:5] + media_list = item.getMedia() + assert len(episodes) == len(media_list) + assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] + episodes[0].markWatched() + show._server.refreshSync() + episodes = show.episodes(viewCount=0)[:5] + media_list = item.getMedia() + assert len(episodes) == len(media_list) + assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] -def test_add_season_to_sync(account_synctarget, clear_sync_device, show): - season = show.season('Season 1') - new_item = season.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) - season._server.refreshSync() - sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) - new_item_in_myplex = None - for sync_item in sync_items.items: - if sync_item.id == new_item.id: - new_item_in_myplex = sync_item - assert new_item_in_myplex +def test_unlimited_and_watched(clear_sync_device, show): + show.markUnwatched() + new_item = show.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, unwatched=False) + show._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + episodes = show.episodes() + media_list = item.getMedia() + assert len(episodes) == len(media_list) + assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] + episodes[0].markWatched() + show._server.refreshSync() + episodes = show.episodes() + media_list = item.getMedia() + assert len(episodes) == len(media_list) + assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] + + +def test_unlimited_and_unwatched(clear_sync_device, show): + show.markUnwatched() + new_item = show.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, unwatched=True) + show._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + episodes = show.episodes(viewCount=0) + media_list = item.getMedia() + assert len(episodes) == len(media_list) + assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] + episodes[0].markWatched() + show._server.refreshSync() + episodes = show.episodes(viewCount=0) + media_list = item.getMedia() + assert len(episodes) == len(media_list) + assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] - episode = season.episodes(viewCount=0, limit=1)[0] - assert sync_items.items[0].getMedia()[0].ratingKey == episode.ratingKey +def test_add_music_artist_to_sync(clear_sync_device, artist): + new_item = artist.sync(AUDIO_BITRATE_192_KBPS, client=clear_sync_device) + artist._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + tracks = artist.tracks() + media_list = item.getMedia() + assert len(tracks) == len(media_list) + assert [t.ratingKey for t in tracks] == [m.ratingKey for m in media_list] -def test_add_episode_to_sync(account_synctarget, clear_sync_device, episode): - episode.sync(clear_sync_device, Policy.create(1, False), MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)) - episode._server.refreshSync() - sync_items = get_new_synclist(account_synctarget, clear_sync_device.clientIdentifier) - assert sync_items.items[0].getMedia()[0].ratingKey == episode.ratingKey + +def test_add_music_album_to_sync(clear_sync_device, album): + new_item = album.sync(AUDIO_BITRATE_192_KBPS, client=clear_sync_device) + album._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + tracks = album.tracks() + media_list = item.getMedia() + assert len(tracks) == len(media_list) + assert [t.ratingKey for t in tracks] == [m.ratingKey for m in media_list] + + +def test_add_music_track_to_sync(clear_sync_device, track): + new_item = track.sync(AUDIO_BITRATE_192_KBPS, client=clear_sync_device) + track._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + media_list = item.getMedia() + assert 1 == len(media_list) + assert track.ratingKey == media_list[0].ratingKey + + +def test_add_photo_to_sync(clear_sync_device, photos): + photo = photos.all()[0] + if not hasattr(photo, 'librarySectionID'): + pytest.skip('Photos are not ready for individual synchronization yet') + new_item = photo.sync(PHOTO_QUALITY_MEDIUM, client=clear_sync_device) + photo._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + media_list = item.getMedia() + assert 1 == len(media_list) + assert photo.ratingKey == media_list[0].ratingKey + + +def test_sync_entire_library_movies(clear_sync_device, movies): + new_item = movies.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + movies._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + section_content = movies.all() + media_list = item.getMedia() + assert len(section_content) == len(media_list) + assert [e.ratingKey for e in section_content] == [m.ratingKey for m in media_list] + + +def test_sync_entire_library_tvshows(clear_sync_device, tvshows): + new_item = tvshows.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + tvshows._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + section_content = tvshows.searchEpisodes() + media_list = item.getMedia() + assert len(section_content) == len(media_list) + assert [e.ratingKey for e in section_content] == [m.ratingKey for m in media_list] + + +def test_sync_entire_library_music(clear_sync_device, music): + new_item = music.sync(AUDIO_BITRATE_192_KBPS, client=clear_sync_device) + music._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + section_content = music.searchTracks() + media_list = item.getMedia() + assert len(section_content) == len(media_list) + assert [e.ratingKey for e in section_content] == [m.ratingKey for m in media_list] + + +def test_sync_entire_library_photos(clear_sync_device, photos): + new_item = photos.sync(PHOTO_QUALITY_MEDIUM, client=clear_sync_device) + photos._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + section_content = photos.all() + media_list = item.getMedia() + assert len(section_content) == len(media_list) + assert [e.ratingKey for e in section_content] == [m.ratingKey for m in media_list] + + +def test_playlist_movie_sync(plex, clear_sync_device, movies): + items = movies.all() + playlist = plex.createPlaylist('Sync: Movies', items) + new_item = playlist.sync(videoQuality=VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + playlist._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + media_list = item.getMedia() + assert len(items) == len(media_list) + assert [e.ratingKey for e in items] == [m.ratingKey for m in media_list] + playlist.delete() + + +def test_playlist_tvshow_sync(plex, clear_sync_device, show): + items = show.episodes() + playlist = plex.createPlaylist('Sync: TV Show', items) + new_item = playlist.sync(videoQuality=VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + playlist._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + media_list = item.getMedia() + assert len(items) == len(media_list) + assert [e.ratingKey for e in items] == [m.ratingKey for m in media_list] + playlist.delete() + + +def test_playlist_mixed_sync(plex, clear_sync_device, movie, episode): + items = [movie, episode] + playlist = plex.createPlaylist('Sync: Mixed', items) + new_item = playlist.sync(videoQuality=VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + playlist._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + media_list = item.getMedia() + assert len(items) == len(media_list) + assert [e.ratingKey for e in items] == [m.ratingKey for m in media_list] + playlist.delete() + + +def test_playlist_music_sync(plex, clear_sync_device, artist): + items = artist.tracks() + playlist = plex.createPlaylist('Sync: Music', items) + new_item = playlist.sync(audioBitrate=AUDIO_BITRATE_192_KBPS, client=clear_sync_device) + playlist._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + media_list = item.getMedia() + assert len(items) == len(media_list) + assert [e.ratingKey for e in items] == [m.ratingKey for m in media_list] + playlist.delete() + + +def test_playlist_photos_sync(plex, clear_sync_device, photos): + items = photos.all() + if not hasattr(items[0], 'librarySectionID'): + pytest.skip('Photos are not ready for individual synchronization yet') + playlist = plex.createPlaylist('Sync: Photos', items) + new_item = playlist.sync(photoResolution=PHOTO_QUALITY_MEDIUM, client=clear_sync_device) + playlist._server.refreshSync() + item = ensure_sync_item(clear_sync_device, new_item) + media_list = item.getMedia() + assert len(items) == len(media_list) + assert [e.ratingKey for e in items] == [m.ratingKey for m in media_list] + playlist.delete() From 5a147f8d27e4bbf00b33b8151891fb28bf0c46c1 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Tue, 4 Sep 2018 01:03:18 +0100 Subject: [PATCH 28/33] add TODO doctring for Audio._defaultSyncTitle() --- plexapi/audio.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plexapi/audio.py b/plexapi/audio.py index a2e2d518b..f703eb121 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -62,6 +62,7 @@ def url(self, part): return self._server.url(part, includeToken=True) if part else None def _defaultSyncTitle(self): + """ TODO """ return self.title def sync(self, bitrate, client=None, clientId=None, limit=None, title=None): @@ -262,6 +263,7 @@ def download(self, savepath=None, keep_orginal_name=False, **kwargs): return filepaths def _defaultSyncTitle(self): + """ TODO """ return '%s - %s' % (self.parentTitle, self.title) @@ -343,4 +345,5 @@ def artist(self): return self.fetchItem(self.grandparentKey) def _defaultSyncTitle(self): + """ TODO """ return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) From 98d93d3278f709b7ec444f805bc0cc5e9f642fa6 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Tue, 4 Sep 2018 01:05:59 +0100 Subject: [PATCH 29/33] SyncItems tag may be presented only once, there is no need for loop --- plexapi/sync.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plexapi/sync.py b/plexapi/sync.py index 63774b33e..fe701745f 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -125,8 +125,9 @@ def _loadData(self, data): self.clientId = data.attrib.get('clientIdentifier') self.items = [] - for elem in data.iter('SyncItems'): - for sync_item in elem: + 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) From 24c974f674a3360ba78076e39d3d09820b6b4043 Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Tue, 4 Sep 2018 01:07:11 +0100 Subject: [PATCH 30/33] add more TODO docstrings --- plexapi/sync.py | 1 + plexapi/video.py | 1 + 2 files changed, 2 insertions(+) diff --git a/plexapi/sync.py b/plexapi/sync.py index fe701745f..451e6dbc8 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -255,6 +255,7 @@ def __init__(self, scope, unwatched, value=0): @staticmethod def create(limit=None, unwatched=False): + """ TODO """ scope = 'all' if limit is None: limit = 0 diff --git a/plexapi/video.py b/plexapi/video.py index 9dd67d73d..490fce02d 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -604,4 +604,5 @@ def show(self): return self.fetchItem(self.grandparentKey) def _defaultSyncTitle(self): + """ TODO """ return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title) From e82e7d7e443c642d720f648223706d326ab5a05f Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Tue, 4 Sep 2018 21:20:51 +0100 Subject: [PATCH 31/33] hello docs --- plexapi/audio.py | 25 ++++--- plexapi/library.py | 157 +++++++++++++++++++++++++++++++++++++------- plexapi/myplex.py | 34 ++++++---- plexapi/photo.py | 19 ++++-- plexapi/playlist.py | 32 ++++++--- plexapi/server.py | 8 ++- plexapi/sync.py | 115 +++++++++++++++++--------------- plexapi/video.py | 27 +++++--- 8 files changed, 288 insertions(+), 129 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index f703eb121..53f9d6088 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -62,18 +62,25 @@ def url(self, part): return self._server.url(part, includeToken=True) if part else None def _defaultSyncTitle(self): - """ TODO """ + """ 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 video as sync item for specified device. + """ 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): TODO - client (:class:`~plexapi.myplex.MyPlexDevice`): TODO - clientId (str): TODO - limit (int): TODO - title (str): TODO + 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 @@ -263,7 +270,7 @@ def download(self, savepath=None, keep_orginal_name=False, **kwargs): return filepaths def _defaultSyncTitle(self): - """ TODO """ + """ Returns str, default title for a new syncItem. """ return '%s - %s' % (self.parentTitle, self.title) @@ -345,5 +352,5 @@ def artist(self): return self.fetchItem(self.grandparentKey) def _defaultSyncTitle(self): - """ TODO """ + """ Returns str, default title for a new syncItem. """ return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) diff --git a/plexapi/library.py b/plexapi/library.py index 47447c93e..d84921ec9 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -546,16 +546,32 @@ def _cleanSearchSort(self, sort): 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.LibraySection.search()` for details about filtering / sorting. + 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`): TODO - mediaSettings (:class:`~plexapi.sync.MediaSettings`): TODO - client (:class:`~plexapi.myplex.MyPlexDevice`): TODO - clientId (str): TODO - title (str): TODO - sort (str): TODO - libtype (str): TODO + 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: @@ -568,14 +584,17 @@ def sync(self, policy, mediaSettings, client=None, clientId=None, title=None, so target = c.device('Plex Client') sync_items_wd = c.syncItems(target.clientIdentifier) srv = c.resource('Server Name').connect() - movies = srv.library.section('Movies') - policy = Policy('count', True, 1) + section = srv.library.section('Movies') + policy = Policy('count', unwatched=True, value=1) media_settings = MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p) - movies.sync(target, policy, media_settings, title='Next best movie', sort='rating:desc') + 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) @@ -629,12 +648,34 @@ def collection(self, **kwargs): return self.search(libtype='collection', **kwargs) def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): - """ TODO + """ 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): TODO - limit (int): TODO - unwatched (bool): TODO + 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 .sync import Policy, MediaSettings kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) @@ -683,12 +724,34 @@ def collection(self, **kwargs): return self.search(libtype='collection', **kwargs) def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): - """ TODO + """ 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): TODO - limit (int): TODO - unwatched (bool): TODO + 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 .sync import Policy, MediaSettings kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) @@ -737,11 +800,33 @@ def collection(self, **kwargs): return self.search(libtype='collection', **kwargs) def sync(self, bitrate, limit=None, **kwargs): - """ TODO + """ 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): TODO - limit (int): TODO + 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 .sync import Policy, MediaSettings kwargs['mediaSettings'] = MediaSettings.createMusic(bitrate) @@ -777,11 +862,33 @@ def searchPhotos(self, title, **kwargs): return self.fetchItems(key, title=title) def sync(self, resolution, limit=None, **kwargs): - """ TODO + """ 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): TODO - limit (int): TODO + 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 .sync import Policy, MediaSettings kwargs['mediaSettings'] = MediaSettings.createPhoto(resolution) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 6fd1ceefb..5e4c2e1ed 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -290,7 +290,7 @@ def updateFriend(self, user, server, sections=None, removeSections=False, allowS 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. @@ -380,14 +380,14 @@ def optOut(self, playback=None, library=None): return self.query(url, method=self._session.put, params=params) def syncItems(self, client=None, clientId=None): - """ Returns an instance of :class:`~plexapi.sync.SyncList` for specified client + """ Returns an instance of :class:`plexapi.sync.SyncList` for specified client. Parameters: - client (:class:`~plexapi.myplex.MyPlexDevice`): TODO - clientId (str): an identifier of a client for which you need to get SyncItems + 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 :ref:`identifier header`. + If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier. """ if client: clientId = client.clientIdentifier @@ -399,15 +399,23 @@ def syncItems(self, client=None, clientId=None): return SyncList(self, data) def sync(self, sync_item, client=None, clientId=None): - """ Adds specified sync item for the client + """ 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`): TODO - clientId (str): TODO - sync_item (:class:`~plexapi.sync.SyncItem`): TODO + 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 + :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 @@ -771,10 +779,10 @@ def delete(self): self._server.query(key, self._server._session.delete) def syncItems(self): - """ TODO + """ Returns an instance of :class:`plexapi.sync.SyncList` for current device. - Returns: - :class:`~plexapi.sync.SyncList` + 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') diff --git a/plexapi/photo.py b/plexapi/photo.py index 032afd9f4..9563b9bf0 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -127,14 +127,21 @@ def section(self): return self._server.library.sectionByID(self.photoalbum().librarySectionID) def sync(self, resolution, client=None, clientId=None, limit=None, title=None): - """ Add current video as sync item for specified device. + """ Add current photo as sync item for specified device. + See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions. Parameters: - resolution (string): TODO - client (:class:`~plexapi.myplex.MyPlexDevice`): TODO - clientId (str): TODO - limit (int): TODO - title (str): TODO + 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 diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 6ba85f98b..9a1dbcf80 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -160,20 +160,30 @@ def copyToUser(self, user): def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None, unwatched=False, title=None): - """ Add current video as sync item for specified device. + """ Add current playlist as sync item for specified device. + See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions. Parameters: - videoQuality (int): TODO - photoResolution (str): TODO - audioBitrate (int): TODO - client (:class:`~plexapi.myplex.MyPlexDevice`): TODO - clientId (str): TODO - limit (int): TODO - unwatched (bool): TODO - title (str): TODO + 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.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: @@ -198,5 +208,7 @@ def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, clien 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/plexapi/server.py b/plexapi/server.py index 92c78d28d..bf1438eb0 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -423,15 +423,17 @@ def url(self, key, includeToken=None): return '%s%s' % (self._baseurl, key) def refreshSynclist(self): - """ TODO """ + """ Force PMS to download new SyncList from Plex.tv. """ return self.query('/sync/refreshSynclists', self._session.put) def refreshContent(self): - """ TODO """ + """ Force PMS to refresh content for known SyncLists. """ return self.query('/sync/refreshContent', self._session.put) def refreshSync(self): - """ TODO """ + """ 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() diff --git a/plexapi/sync.py b/plexapi/sync.py index 451e6dbc8..61c9c7860 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -19,8 +19,6 @@ def init_sync(): 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 - plexapi.BASE_HEADERS['X-Plex-Model'] = '8,4' - plexapi.BASE_HEADERS['X-Plex-Vendor'] = 'Apple' 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`. @@ -39,25 +37,25 @@ class SyncItem(PlexObject): 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 + 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 + 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` + * 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 + 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): unknown + 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' @@ -80,7 +78,7 @@ def _loadData(self, data): self.location = data.find('Location').attrib.get('uri', '') def server(self): - """ Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. """ + """ 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) @@ -97,13 +95,13 @@ def markDownloaded(self, media): any SyncItem where it presented). Parameters: - media (base.Playable): the media to be marked as downloaded + 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): - """ TODO """ + """ Removes current SyncItem """ url = SyncList.key.format(clientId=self.clientIdentifier) url += '/' + str(self.id) self._server.query(url, self._server._session.delete) @@ -114,8 +112,8 @@ class SyncList(PlexObject): items from different servers. Attributes: - clientId (str): an identifier of the client - items (List<:class:`~plexapi.sync.SyncItem`>): list of registered items to sync + 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' @@ -136,15 +134,16 @@ 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` + 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, @@ -173,36 +172,36 @@ class MediaSettings(object): """ Transcoding settings used for all media within :class:`~plexapi.sync.SyncItem`. Attributes: - audioBoost (int): unknown - maxVideoBitrate (str): unknown, may be empty - musicBitrate (int): unknown - photoQuality (int): unknown - 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 (str): unknown, usually equals to 0, but sometimes empty string - videoQuality (int): unknown + 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) - self.musicBitrate = plexapi.utils.cast(int, musicBitrate) - self.photoQuality = plexapi.utils.cast(int, photoQuality) + 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) + self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else '' @staticmethod def createVideo(videoQuality): - """ Returns a :class:`~MediaSettings` object, based on provided video quality value + """ 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 + 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 + :class:`plexapi.exceptions.BadRequest` when provided unknown video quality. """ if videoQuality == VIDEO_QUALITY_ORIGINAL: return MediaSettings('', '', '') @@ -215,23 +214,24 @@ def createVideo(videoQuality): @staticmethod def createMusic(bitrate): - """ Returns a :class:`~MediaSettings` object, based on provided music quality value + """ 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 + 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:`~MediaSettings` object, based on provided photo quality value + """ Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided photo quality value. Parameters: - resolution (str): maximum allowed resolution for synchronized photos, better use one of PHOTO_QUALITY_* - values + 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 + :class:`plexapi.exceptions.BadRequest` when provided unknown video quality. """ if resolution in PHOTO_QUALITIES: return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution) @@ -240,12 +240,12 @@ def createPhoto(resolution): class Policy(object): - """ Policy of syncing the media (how many items to sync and process watched media or not) + """ 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 + 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): @@ -255,7 +255,16 @@ def __init__(self, scope, unwatched, value=0): @staticmethod def create(limit=None, unwatched=False): - """ TODO """ + """ 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 diff --git a/plexapi/video.py b/plexapi/video.py index 490fce02d..728c8bd00 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -79,19 +79,26 @@ def markUnwatched(self): self.reload() def _defaultSyncTitle(self): - """ TODO """ + """ Returns str, default title for a new syncItem. """ return self.title def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None): - """ Add current video as sync item for specified device. + """ 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): TODO - client (:class:`~plexapi.myplex.MyPlexDevice`): TODO - clientId (str): TODO - limit (int): TODO - unwatched (bool): TODO - title (str): TODO + 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 @@ -485,7 +492,7 @@ def download(self, savepath=None, keep_orginal_name=False, **kwargs): return filepaths def _defaultSyncTitle(self): - """ TODO """ + """ Returns str, default title for a new syncItem. """ return '%s - %s' % (self.parentTitle, self.title) @@ -604,5 +611,5 @@ def show(self): return self.fetchItem(self.grandparentKey) def _defaultSyncTitle(self): - """ TODO """ + """ Returns str, default title for a new syncItem. """ return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title) From ee867f5acaf152310e957bfc9b8c6958cfbafd5a Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Thu, 6 Sep 2018 13:54:13 +0100 Subject: [PATCH 32/33] remove relative import --- plexapi/library.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plexapi/library.py b/plexapi/library.py index d84921ec9..71380d015 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -677,7 +677,7 @@ def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): title='Next best movie', sort='rating:desc') """ - from .sync import Policy, MediaSettings + from plexapi.sync import Policy, MediaSettings kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) kwargs['policy'] = Policy.create(limit, unwatched) return super(MovieSection, self).sync(**kwargs) @@ -753,7 +753,7 @@ def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): title='Next unwatched episode') """ - from .sync import Policy, MediaSettings + from plexapi.sync import Policy, MediaSettings kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) kwargs['policy'] = Policy.create(limit, unwatched) return super(ShowSection, self).sync(**kwargs) @@ -828,7 +828,7 @@ def sync(self, bitrate, limit=None, **kwargs): title='New music') """ - from .sync import Policy, MediaSettings + from plexapi.sync import Policy, MediaSettings kwargs['mediaSettings'] = MediaSettings.createMusic(bitrate) kwargs['policy'] = Policy.create(limit) return super(MusicSection, self).sync(**kwargs) @@ -890,7 +890,7 @@ def sync(self, resolution, limit=None, **kwargs): title='Fresh photos') """ - from .sync import Policy, MediaSettings + from plexapi.sync import Policy, MediaSettings kwargs['mediaSettings'] = MediaSettings.createPhoto(resolution) kwargs['policy'] = Policy.create(limit) return super(PhotoSection, self).sync(**kwargs) From 462a16de5a92d0e9be9038d2447d8b599deb5f2a Mon Sep 17 00:00:00 2001 From: Andrey Yantsen Date: Thu, 6 Sep 2018 13:54:18 +0100 Subject: [PATCH 33/33] remove unused variable from tests/test_sync.py --- tests/test_sync.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_sync.py b/tests/test_sync.py index 1d449a9cc..a81398d88 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -4,8 +4,6 @@ from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p, AUDIO_BITRATE_192_KBPS, PHOTO_QUALITY_MEDIUM -MAX_RETRIES = 3 - def ensure_sync_item(device, sync_item, timeout=3): start = time()