mirror of
https://github.com/pkkid/python-plexapi
synced 2024-11-10 06:04:15 +00:00
Sync support (#282)
* [sync] initial commit * fix populating of `state` field in sync.Status * [connection] add posibliity to return first established connection faster * [base] add timeout argument to PlexObject.fetchItems() * [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). * [sync] fix marking media as downloaded * [sync] pass clientIdentifier to created SyncItem() * [sync] override __repr__() for sync.Status * fix after @mikes-nasuni`s review * fix python2 compatibility * get rid of sync.init() * use list comprehension * remove timeout from PlexObject.fetchItems() * fix SyncItem under python 2.7 * fix __doc__ in sync module * revert myplex._connect() back to it`s original state * improve sync docs * get rid of PlexObjects where not needed * add X-Plex-Sync-Version=2 to headers * add sync() method into Video, LibrarySection and MyPlexAccount * add SyncItem.delete() * add sync.Policy.create() * use self._default_sync_title instead of _prettyfilename as default title * let the tests begin * add items for refreshing synclists to PlexServer * fix sync tests * sync for everybody! * add TODO doctring for Audio._defaultSyncTitle() * SyncItems tag may be presented only once, there is no need for loop * add more TODO docstrings * hello docs * remove relative import * remove unused variable from tests/test_sync.py
This commit is contained in:
parent
250d3538c6
commit
54b26fdc25
12 changed files with 1184 additions and 24 deletions
|
@ -1,6 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.compat import quote_plus
|
||||
|
||||
|
||||
class Audio(PlexPartialObject):
|
||||
|
@ -23,6 +24,9 @@ class Audio(PlexPartialObject):
|
|||
updatedAt (datatime): Datetime this item was updated.
|
||||
viewCount (int): Count of times this item was accessed.
|
||||
"""
|
||||
|
||||
METADATA_TYPE = 'track'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
|
@ -57,6 +61,46 @@ class Audio(PlexPartialObject):
|
|||
""" Returns the full URL for this audio item. Typically used for getting a specific track. """
|
||||
return self._server.url(part, includeToken=True) if part else None
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return self.title
|
||||
|
||||
def sync(self, bitrate, client=None, clientId=None, limit=None, title=None):
|
||||
""" Add current audio (artist, album or track) as sync item for specified device.
|
||||
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
|
||||
|
||||
Parameters:
|
||||
bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the
|
||||
module :mod:`plexapi.sync`.
|
||||
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
|
||||
:func:`plexapi.myplex.MyPlexAccount.sync`.
|
||||
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
|
||||
limit (int): maximum count of items to sync, unlimited if `None`.
|
||||
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
|
||||
generated from metadata of current media.
|
||||
|
||||
Returns:
|
||||
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
"""
|
||||
|
||||
from plexapi.sync import SyncItem, Policy, MediaSettings
|
||||
|
||||
myplex = self._server.myPlexAccount()
|
||||
sync_item = SyncItem(self._server, None)
|
||||
sync_item.title = title if title else self._defaultSyncTitle()
|
||||
sync_item.rootTitle = self.title
|
||||
sync_item.contentType = self.listType
|
||||
sync_item.metadataType = self.METADATA_TYPE
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
section = self._server.library.sectionByID(self.librarySectionID)
|
||||
|
||||
sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key))
|
||||
sync_item.policy = Policy.create(limit)
|
||||
sync_item.mediaSettings = MediaSettings.createMusic(bitrate)
|
||||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Artist(Audio):
|
||||
|
@ -225,6 +269,10 @@ class Album(Audio):
|
|||
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return '%s - %s' % (self.parentTitle, self.title)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Track(Audio, Playable):
|
||||
|
@ -302,3 +350,7 @@ class Track(Audio, Playable):
|
|||
def artist(self):
|
||||
""" Return this track's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.grandparentKey)
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title)
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -543,6 +543,82 @@ class LibrarySection(PlexObject):
|
|||
raise BadRequest('Unknown sort dir: %s' % sdir)
|
||||
return '%s:%s' % (lookup[scol], sdir)
|
||||
|
||||
def sync(self, policy, mediaSettings, client=None, clientId=None, title=None, sort=None, libtype=None,
|
||||
**kwargs):
|
||||
""" Add current library section as sync item for specified device.
|
||||
See description of :func:`~plexapi.library.LibrarySection.search()` for details about filtering / sorting
|
||||
and :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
|
||||
|
||||
Parameters:
|
||||
policy (:class:`plexapi.sync.Policy`): policy of syncing the media (how many items to sync and process
|
||||
watched media or not), generated automatically when method
|
||||
called on specific LibrarySection object.
|
||||
mediaSettings (:class:`plexapi.sync.MediaSettings`): Transcoding settings used for the media, generated
|
||||
automatically when method called on specific
|
||||
LibrarySection object.
|
||||
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
|
||||
:func:`plexapi.myplex.MyPlexAccount.sync`.
|
||||
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
|
||||
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
|
||||
generated from metadata of current media.
|
||||
sort (str): formatted as `column:dir`; column can be any of {`addedAt`, `originallyAvailableAt`,
|
||||
`lastViewedAt`, `titleSort`, `rating`, `mediaHeight`, `duration`}. dir can be `asc` or
|
||||
`desc`.
|
||||
libtype (str): Filter results to a specific libtype (`movie`, `show`, `episode`, `artist`, `album`,
|
||||
`track`).
|
||||
|
||||
Returns:
|
||||
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: when the library is not allowed to sync
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi import myplex
|
||||
from plexapi.sync import Policy, MediaSettings, VIDEO_QUALITY_3_MBPS_720p
|
||||
|
||||
c = myplex.MyPlexAccount()
|
||||
target = c.device('Plex Client')
|
||||
sync_items_wd = c.syncItems(target.clientIdentifier)
|
||||
srv = c.resource('Server Name').connect()
|
||||
section = srv.library.section('Movies')
|
||||
policy = Policy('count', unwatched=True, value=1)
|
||||
media_settings = MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)
|
||||
section.sync(target, policy, media_settings, title='Next best movie', sort='rating:desc')
|
||||
|
||||
"""
|
||||
from plexapi.sync import SyncItem
|
||||
|
||||
if not self.allowSync:
|
||||
raise BadRequest('The requested library is not allowed to sync')
|
||||
|
||||
args = {}
|
||||
for category, value in kwargs.items():
|
||||
args[category] = self._cleanSearchFilter(category, value, libtype)
|
||||
if sort is not None:
|
||||
args['sort'] = self._cleanSearchSort(sort)
|
||||
if libtype is not None:
|
||||
args['type'] = utils.searchType(libtype)
|
||||
|
||||
myplex = self._server.myPlexAccount()
|
||||
sync_item = SyncItem(self._server, None)
|
||||
sync_item.title = title if title else self.title
|
||||
sync_item.rootTitle = self.title
|
||||
sync_item.contentType = self.CONTENT_TYPE
|
||||
sync_item.metadataType = self.METADATA_TYPE
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
key = '/library/sections/%s/all' % self.key
|
||||
|
||||
sync_item.location = 'library://%s/directory/%s' % (self.uuid, quote_plus(key + utils.joinArgs(args)))
|
||||
sync_item.policy = policy
|
||||
sync_item.mediaSettings = mediaSettings
|
||||
|
||||
return myplex.sync(client=client, clientId=clientId, sync_item=sync_item)
|
||||
|
||||
|
||||
class MovieSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
||||
|
@ -564,11 +640,48 @@ 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. """
|
||||
return self.search(libtype='collection', **kwargs)
|
||||
|
||||
def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
|
||||
""" Add current Movie library section as sync item for specified device.
|
||||
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
|
||||
:func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions.
|
||||
|
||||
Parameters:
|
||||
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
|
||||
:mod:`plexapi.sync` module.
|
||||
limit (int): maximum count of movies to sync, unlimited if `None`.
|
||||
unwatched (bool): if `True` watched videos wouldn't be synced.
|
||||
|
||||
Returns:
|
||||
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi import myplex
|
||||
from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p
|
||||
|
||||
c = myplex.MyPlexAccount()
|
||||
target = c.device('Plex Client')
|
||||
sync_items_wd = c.syncItems(target.clientIdentifier)
|
||||
srv = c.resource('Server Name').connect()
|
||||
section = srv.library.section('Movies')
|
||||
section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True,
|
||||
title='Next best movie', sort='rating:desc')
|
||||
|
||||
"""
|
||||
from plexapi.sync import Policy, MediaSettings
|
||||
kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality)
|
||||
kwargs['policy'] = Policy.create(limit, unwatched)
|
||||
return super(MovieSection, self).sync(**kwargs)
|
||||
|
||||
|
||||
class ShowSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows.
|
||||
|
@ -587,6 +700,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. """
|
||||
|
@ -608,6 +723,41 @@ class ShowSection(LibrarySection):
|
|||
""" Returns a list of collections from this library section. """
|
||||
return self.search(libtype='collection', **kwargs)
|
||||
|
||||
def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
|
||||
""" Add current Show library section as sync item for specified device.
|
||||
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
|
||||
:func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions.
|
||||
|
||||
Parameters:
|
||||
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
|
||||
:mod:`plexapi.sync` module.
|
||||
limit (int): maximum count of episodes to sync, unlimited if `None`.
|
||||
unwatched (bool): if `True` watched videos wouldn't be synced.
|
||||
|
||||
Returns:
|
||||
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi import myplex
|
||||
from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p
|
||||
|
||||
c = myplex.MyPlexAccount()
|
||||
target = c.device('Plex Client')
|
||||
sync_items_wd = c.syncItems(target.clientIdentifier)
|
||||
srv = c.resource('Server Name').connect()
|
||||
section = srv.library.section('TV-Shows')
|
||||
section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True,
|
||||
title='Next unwatched episode')
|
||||
|
||||
"""
|
||||
from plexapi.sync import Policy, MediaSettings
|
||||
kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality)
|
||||
kwargs['policy'] = Policy.create(limit, unwatched)
|
||||
return super(ShowSection, self).sync(**kwargs)
|
||||
|
||||
|
||||
class MusicSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing music artists.
|
||||
|
@ -625,6 +775,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
|
||||
|
@ -646,6 +799,40 @@ class MusicSection(LibrarySection):
|
|||
""" Returns a list of collections from this library section. """
|
||||
return self.search(libtype='collection', **kwargs)
|
||||
|
||||
def sync(self, bitrate, limit=None, **kwargs):
|
||||
""" Add current Music library section as sync item for specified device.
|
||||
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
|
||||
:func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions.
|
||||
|
||||
Parameters:
|
||||
bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the
|
||||
module :mod:`plexapi.sync`.
|
||||
limit (int): maximum count of tracks to sync, unlimited if `None`.
|
||||
|
||||
Returns:
|
||||
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi import myplex
|
||||
from plexapi.sync import AUDIO_BITRATE_320_KBPS
|
||||
|
||||
c = myplex.MyPlexAccount()
|
||||
target = c.device('Plex Client')
|
||||
sync_items_wd = c.syncItems(target.clientIdentifier)
|
||||
srv = c.resource('Server Name').connect()
|
||||
section = srv.library.section('Music')
|
||||
section.sync(AUDIO_BITRATE_320_KBPS, client=target, limit=100, sort='addedAt:desc',
|
||||
title='New music')
|
||||
|
||||
"""
|
||||
from plexapi.sync import Policy, MediaSettings
|
||||
kwargs['mediaSettings'] = MediaSettings.createMusic(bitrate)
|
||||
kwargs['policy'] = Policy.create(limit)
|
||||
return super(MusicSection, self).sync(**kwargs)
|
||||
|
||||
|
||||
class PhotoSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
|
||||
|
@ -661,6 +848,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. """
|
||||
|
@ -672,6 +861,40 @@ class PhotoSection(LibrarySection):
|
|||
key = '/library/sections/%s/all?type=13' % self.key
|
||||
return self.fetchItems(key, title=title)
|
||||
|
||||
def sync(self, resolution, limit=None, **kwargs):
|
||||
""" Add current Music library section as sync item for specified device.
|
||||
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
|
||||
:func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions.
|
||||
|
||||
Parameters:
|
||||
resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the
|
||||
module :mod:`plexapi.sync`.
|
||||
limit (int): maximum count of tracks to sync, unlimited if `None`.
|
||||
|
||||
Returns:
|
||||
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi import myplex
|
||||
from plexapi.sync import PHOTO_QUALITY_HIGH
|
||||
|
||||
c = myplex.MyPlexAccount()
|
||||
target = c.device('Plex Client')
|
||||
sync_items_wd = c.syncItems(target.clientIdentifier)
|
||||
srv = c.resource('Server Name').connect()
|
||||
section = srv.library.section('Photos')
|
||||
section.sync(PHOTO_QUALITY_HIGH, client=target, limit=100, sort='addedAt:desc',
|
||||
title='Fresh photos')
|
||||
|
||||
"""
|
||||
from plexapi.sync import Policy, MediaSettings
|
||||
kwargs['mediaSettings'] = MediaSettings.createPhoto(resolution)
|
||||
kwargs['policy'] = Policy.create(limit)
|
||||
return super(PhotoSection, self).sync(**kwargs)
|
||||
|
||||
|
||||
class FilterChoice(PlexObject):
|
||||
""" Represents a single filter choice. These objects are gathered when using filters
|
||||
|
|
|
@ -92,6 +92,11 @@ class MediaPart(PlexObject):
|
|||
self.indexes = data.attrib.get('indexes')
|
||||
self.key = data.attrib.get('key')
|
||||
self.size = cast(int, data.attrib.get('size'))
|
||||
self.decision = data.attrib.get('decision')
|
||||
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
|
||||
self.syncItemId = cast(int, data.attrib.get('syncItemId'))
|
||||
self.syncState = data.attrib.get('syncState')
|
||||
self.videoProfile = data.attrib.get('videoProfile')
|
||||
self.streams = self._buildStreams(data)
|
||||
|
||||
def _buildStreams(self, data):
|
||||
|
|
|
@ -3,7 +3,7 @@ import copy
|
|||
import requests
|
||||
import time
|
||||
from requests.status_codes import _codes as codes
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER
|
||||
from plexapi import log, logfilter, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
@ -11,6 +11,7 @@ from plexapi.client import PlexClient
|
|||
from plexapi.compat import ElementTree
|
||||
from plexapi.library import LibrarySection
|
||||
from plexapi.server import PlexServer
|
||||
from plexapi.sync import SyncList, SyncItem
|
||||
from plexapi.utils import joinArgs
|
||||
|
||||
|
||||
|
@ -289,7 +290,7 @@ class MyPlexAccount(PlexObject):
|
|||
return response_servers, response_filters
|
||||
|
||||
def user(self, username):
|
||||
""" Returns the :class:`~myplex.MyPlexUser` that matches the email or username specified.
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexUser` that matches the email or username specified.
|
||||
|
||||
Parameters:
|
||||
username (str): Username, email or id of the user to return.
|
||||
|
@ -378,6 +379,86 @@ class MyPlexAccount(PlexObject):
|
|||
url = 'https://plex.tv/api/v2/user/privacy'
|
||||
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.
|
||||
|
||||
Parameters:
|
||||
client (:class:`~plexapi.myplex.MyPlexDevice`): a client to query SyncItems for.
|
||||
clientId (str): an identifier of a client to query SyncItems for.
|
||||
|
||||
If both `client` and `clientId` provided the client would be preferred.
|
||||
If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier.
|
||||
"""
|
||||
if client:
|
||||
clientId = client.clientIdentifier
|
||||
elif clientId is None:
|
||||
clientId = X_PLEX_IDENTIFIER
|
||||
|
||||
data = self.query(SyncList.key.format(clientId=clientId))
|
||||
|
||||
return SyncList(self, data)
|
||||
|
||||
def sync(self, sync_item, client=None, clientId=None):
|
||||
""" Adds specified sync item for the client. It's always easier to use methods defined directly in the media
|
||||
objects, e.g. :func:`plexapi.video.Video.sync`, :func:`plexapi.audio.Audio.sync`.
|
||||
|
||||
Parameters:
|
||||
client (:class:`~plexapi.myplex.MyPlexDevice`): a client for which you need to add SyncItem to.
|
||||
clientId (str): an identifier of a client for which you need to add SyncItem to.
|
||||
sync_item (:class:`plexapi.sync.SyncItem`): prepared SyncItem object with all fields set.
|
||||
|
||||
If both `client` and `clientId` provided the client would be preferred.
|
||||
If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier.
|
||||
|
||||
Returns:
|
||||
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest` when client with provided clientId wasn`t found.
|
||||
:class:`plexapi.exceptions.BadRequest` provided client doesn`t provides `sync-target`.
|
||||
"""
|
||||
if not client and not clientId:
|
||||
clientId = X_PLEX_IDENTIFIER
|
||||
|
||||
if not client:
|
||||
for device in self.devices():
|
||||
if device.clientIdentifier == clientId:
|
||||
client = device
|
||||
break
|
||||
|
||||
if not client:
|
||||
raise BadRequest('Unable to find client by clientId=%s', clientId)
|
||||
|
||||
if 'sync-target' not in client.provides:
|
||||
raise BadRequest('Received client doesn`t provides sync-target')
|
||||
|
||||
params = {
|
||||
'SyncItem[title]': sync_item.title,
|
||||
'SyncItem[rootTitle]': sync_item.rootTitle,
|
||||
'SyncItem[metadataType]': sync_item.metadataType,
|
||||
'SyncItem[machineIdentifier]': sync_item.machineIdentifier,
|
||||
'SyncItem[contentType]': sync_item.contentType,
|
||||
'SyncItem[Policy][scope]': sync_item.policy.scope,
|
||||
'SyncItem[Policy][unwatched]': str(int(sync_item.policy.unwatched)),
|
||||
'SyncItem[Policy][value]': str(sync_item.policy.value if hasattr(sync_item.policy, 'value') else 0),
|
||||
'SyncItem[Location][uri]': sync_item.location,
|
||||
'SyncItem[MediaSettings][audioBoost]': str(sync_item.mediaSettings.audioBoost),
|
||||
'SyncItem[MediaSettings][maxVideoBitrate]': str(sync_item.mediaSettings.maxVideoBitrate),
|
||||
'SyncItem[MediaSettings][musicBitrate]': str(sync_item.mediaSettings.musicBitrate),
|
||||
'SyncItem[MediaSettings][photoQuality]': str(sync_item.mediaSettings.photoQuality),
|
||||
'SyncItem[MediaSettings][photoResolution]': sync_item.mediaSettings.photoResolution,
|
||||
'SyncItem[MediaSettings][subtitleSize]': str(sync_item.mediaSettings.subtitleSize),
|
||||
'SyncItem[MediaSettings][videoQuality]': str(sync_item.mediaSettings.videoQuality),
|
||||
'SyncItem[MediaSettings][videoResolution]': sync_item.mediaSettings.videoResolution,
|
||||
}
|
||||
|
||||
url = SyncList.key.format(clientId=client.clientIdentifier)
|
||||
data = self.query(url, method=self._session.post, headers={
|
||||
'Content-type': 'x-www-form-urlencoded',
|
||||
}, params=params)
|
||||
|
||||
return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier)
|
||||
|
||||
|
||||
class MyPlexUser(PlexObject):
|
||||
""" This object represents non-signed in users such as friends and linked
|
||||
|
@ -697,6 +778,17 @@ class MyPlexDevice(PlexObject):
|
|||
key = 'https://plex.tv/devices/%s.xml' % self.id
|
||||
self._server.query(key, self._server._session.delete)
|
||||
|
||||
def syncItems(self):
|
||||
""" Returns an instance of :class:`plexapi.sync.SyncList` for current device.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest` when the device doesn`t provides `sync-target`.
|
||||
"""
|
||||
if 'sync-target' not in self.provides:
|
||||
raise BadRequest('Requested syncList for device which do not provides sync-target')
|
||||
|
||||
return self._server.syncItems(client=self)
|
||||
|
||||
|
||||
def _connect(cls, url, token, timeout, results, i):
|
||||
""" Connects to the specified cls with url and token. Stores the connection
|
||||
|
|
|
@ -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,39 @@ class Photo(PlexPartialObject):
|
|||
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 photo as sync item for specified device.
|
||||
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
|
||||
|
||||
Parameters:
|
||||
resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the
|
||||
module :mod:`plexapi.sync`.
|
||||
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
|
||||
:func:`plexapi.myplex.MyPlexAccount.sync`.
|
||||
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
|
||||
limit (int): maximum count of items to sync, unlimited if `None`.
|
||||
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
|
||||
generated from metadata of current photo.
|
||||
|
||||
Returns:
|
||||
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
"""
|
||||
|
||||
from plexapi.sync import SyncItem, Policy, MediaSettings
|
||||
|
||||
myplex = self._server.myPlexAccount()
|
||||
sync_item = SyncItem(self._server, None)
|
||||
sync_item.title = title if title else self.title
|
||||
sync_item.rootTitle = self.title
|
||||
sync_item.contentType = self.listType
|
||||
sync_item.metadataType = self.METADATA_TYPE
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
section = self._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)
|
||||
|
|
|
@ -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 @@ class Playlist(PlexPartialObject, Playable):
|
|||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
|
||||
self.allowSync = cast(bool, data.attrib.get('allowSync'))
|
||||
self._items = None # cache for self.items
|
||||
|
||||
def __len__(self): # pragma: no cover
|
||||
return len(self.items())
|
||||
|
||||
@property
|
||||
def metadataType(self):
|
||||
if self.isVideo:
|
||||
return 'movie'
|
||||
elif self.isAudio:
|
||||
return 'track'
|
||||
elif self.isPhoto:
|
||||
return 'photo'
|
||||
else:
|
||||
raise Unsupported('Unexpected playlist type')
|
||||
|
||||
@property
|
||||
def isVideo(self):
|
||||
return self.playlistType == 'video'
|
||||
|
||||
@property
|
||||
def isAudio(self):
|
||||
return self.playlistType == 'audio'
|
||||
|
||||
@property
|
||||
def isPhoto(self):
|
||||
return self.playlistType == 'photo'
|
||||
|
||||
def __contains__(self, other): # pragma: no cover
|
||||
return any(i.key == other.key for i in self.items())
|
||||
|
||||
|
@ -132,3 +157,58 @@ class Playlist(PlexPartialObject, Playable):
|
|||
# Login to your server using your friends credentials.
|
||||
user_server = PlexServer(self._server._baseurl, token)
|
||||
return self.create(user_server, self.title, self.items())
|
||||
|
||||
def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None,
|
||||
unwatched=False, title=None):
|
||||
""" Add current playlist as sync item for specified device.
|
||||
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
|
||||
|
||||
Parameters:
|
||||
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
|
||||
:mod:`plexapi.sync` module. Used only when playlist contains video.
|
||||
photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in
|
||||
the module :mod:`plexapi.sync`. Used only when playlist contains photos.
|
||||
audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the
|
||||
module :mod:`plexapi.sync`. Used only when playlist contains audio.
|
||||
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
|
||||
:func:`plexapi.myplex.MyPlexAccount.sync`.
|
||||
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
|
||||
limit (int): maximum count of items to sync, unlimited if `None`.
|
||||
unwatched (bool): if `True` watched videos wouldn't be synced.
|
||||
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
|
||||
generated from metadata of current photo.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: when playlist is not allowed to sync.
|
||||
:class:`plexapi.exceptions.Unsupported`: when playlist content is unsupported.
|
||||
|
||||
Returns:
|
||||
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
"""
|
||||
|
||||
if not self.allowSync:
|
||||
raise BadRequest('The playlist is not allowed to sync')
|
||||
|
||||
from plexapi.sync import SyncItem, Policy, MediaSettings
|
||||
|
||||
myplex = self._server.myPlexAccount()
|
||||
sync_item = SyncItem(self._server, None)
|
||||
sync_item.title = title if title else self.title
|
||||
sync_item.rootTitle = self.title
|
||||
sync_item.contentType = self.playlistType
|
||||
sync_item.metadataType = self.metadataType
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
sync_item.location = 'playlist:///%s' % quote_plus(self.guid)
|
||||
sync_item.policy = Policy.create(limit, unwatched)
|
||||
|
||||
if self.isVideo:
|
||||
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
|
||||
elif self.isAudio:
|
||||
sync_item.mediaSettings = MediaSettings.createMusic(audioBitrate)
|
||||
elif self.isPhoto:
|
||||
sync_item.mediaSettings = MediaSettings.createPhoto(photoResolution)
|
||||
else:
|
||||
raise Unsupported('Unsupported playlist content')
|
||||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
|
|
@ -422,6 +422,21 @@ class PlexServer(PlexObject):
|
|||
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
|
||||
return '%s%s' % (self._baseurl, key)
|
||||
|
||||
def refreshSynclist(self):
|
||||
""" Force PMS to download new SyncList from Plex.tv. """
|
||||
return self.query('/sync/refreshSynclists', self._session.put)
|
||||
|
||||
def refreshContent(self):
|
||||
""" Force PMS to refresh content for known SyncLists. """
|
||||
return self.query('/sync/refreshContent', self._session.put)
|
||||
|
||||
def refreshSync(self):
|
||||
""" Calls :func:`~plexapi.server.PlexServer.refreshSynclist` and
|
||||
:func:`~plexapi.server.PlexServer.refreshContent`, just like the Plex Web UI does when you click 'refresh'.
|
||||
"""
|
||||
self.refreshSynclist()
|
||||
self.refreshContent()
|
||||
|
||||
|
||||
class Account(PlexObject):
|
||||
""" Contains the locally cached MyPlex account information. The properties provided don't
|
||||
|
|
312
plexapi/sync.py
312
plexapi/sync.py
|
@ -1,42 +1,312 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
You can work with Mobile Sync on other devices straight away, but if you'd like to use your app as a `sync-target` (when
|
||||
you can set items to be synced to your app) you need to init some variables.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def init_sync():
|
||||
import plexapi
|
||||
plexapi.X_PLEX_PROVIDES = 'sync-target'
|
||||
plexapi.BASE_HEADERS['X-Plex-Sync-Version'] = '2'
|
||||
plexapi.BASE_HEADERS['X-Plex-Provides'] = plexapi.X_PLEX_PROVIDES
|
||||
|
||||
# mimic iPhone SE
|
||||
plexapi.X_PLEX_PLATFORM = 'iOS'
|
||||
plexapi.X_PLEX_PLATFORM_VERSION = '11.4.1'
|
||||
plexapi.X_PLEX_DEVICE = 'iPhone'
|
||||
|
||||
plexapi.BASE_HEADERS['X-Plex-Platform'] = plexapi.X_PLEX_PLATFORM
|
||||
plexapi.BASE_HEADERS['X-Plex-Platform-Version'] = plexapi.X_PLEX_PLATFORM_VERSION
|
||||
plexapi.BASE_HEADERS['X-Plex-Device'] = plexapi.X_PLEX_DEVICE
|
||||
|
||||
You have to fake platform/device/model because transcoding profiles are hardcoded in Plex, and you obviously have
|
||||
to explicitly specify that your app supports `sync-target`.
|
||||
"""
|
||||
|
||||
import requests
|
||||
from plexapi import utils
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
import plexapi
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import NotFound, BadRequest
|
||||
|
||||
|
||||
class SyncItem(object):
|
||||
""" Sync Item. This doesn't current work. """
|
||||
def __init__(self, device, data, servers=None):
|
||||
self._device = device
|
||||
self._servers = servers
|
||||
self._loadData(data)
|
||||
class SyncItem(PlexObject):
|
||||
"""
|
||||
Represents single sync item, for specified server and client. When you saying in the UI to sync "this" to "that"
|
||||
you're basically creating a sync item.
|
||||
|
||||
Attributes:
|
||||
id (int): unique id of the item.
|
||||
clientIdentifier (str): an identifier of Plex Client device, to which the item is belongs.
|
||||
machineIdentifier (str): the id of server which holds all this content.
|
||||
version (int): current version of the item. Each time you modify the item (e.g. by changing amount if media to
|
||||
sync) the new version is created.
|
||||
rootTitle (str): the title of library/media from which the sync item was created. E.g.:
|
||||
|
||||
* when you create an item for an episode 3 of season 3 of show Example, the value would be `Title of
|
||||
Episode 3`
|
||||
* when you create an item for a season 3 of show Example, the value would be `Season 3`
|
||||
* when you set to sync all your movies in library named "My Movies" to value would be `My Movies`.
|
||||
|
||||
title (str): the title which you've set when created the sync item.
|
||||
metadataType (str): the type of media which hides inside, can be `episode`, `movie`, etc.
|
||||
contentType (str): basic type of the content: `video` or `audio`.
|
||||
status (:class:`~plexapi.sync.Status`): current status of the sync.
|
||||
mediaSettings (:class:`~plexapi.sync.MediaSettings`): media transcoding settings used for the item.
|
||||
policy (:class:`~plexapi.sync.Policy`): the policy of which media to sync.
|
||||
location (str): plex-style library url with all required filters / sorting.
|
||||
"""
|
||||
TAG = 'SyncItem'
|
||||
|
||||
def __init__(self, server, data, initpath=None, clientIdentifier=None):
|
||||
super(SyncItem, self).__init__(server, data, initpath)
|
||||
self.clientIdentifier = clientIdentifier
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.version = utils.cast(int, data.attrib.get('version'))
|
||||
self.id = plexapi.utils.cast(int, data.attrib.get('id'))
|
||||
self.version = plexapi.utils.cast(int, data.attrib.get('version'))
|
||||
self.rootTitle = data.attrib.get('rootTitle')
|
||||
self.title = data.attrib.get('title')
|
||||
self.metadataType = data.attrib.get('metadataType')
|
||||
self.contentType = data.attrib.get('contentType')
|
||||
self.machineIdentifier = data.find('Server').get('machineIdentifier')
|
||||
self.status = data.find('Status').attrib.copy()
|
||||
self.MediaSettings = data.find('MediaSettings').attrib.copy()
|
||||
self.policy = data.find('Policy').attrib.copy()
|
||||
self.location = data.find('Location').attrib.copy()
|
||||
self.status = Status(**data.find('Status').attrib)
|
||||
self.mediaSettings = MediaSettings(**data.find('MediaSettings').attrib)
|
||||
self.policy = Policy(**data.find('Policy').attrib)
|
||||
self.location = data.find('Location').attrib.get('uri', '')
|
||||
|
||||
def server(self):
|
||||
server = list(filter(lambda x: x.machineIdentifier == self.machineIdentifier, self._servers))
|
||||
if 0 == len(server):
|
||||
""" Returns :class:`plexapi.myplex.MyPlexResource` with server of current item. """
|
||||
server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier]
|
||||
if len(server) == 0:
|
||||
raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier)
|
||||
return server[0]
|
||||
|
||||
def getMedia(self):
|
||||
""" Returns list of :class:`~plexapi.base.Playable` which belong to this sync item. """
|
||||
server = self.server().connect()
|
||||
key = '/sync/items/%s' % self.id
|
||||
return server.fetchItems(key)
|
||||
|
||||
def markAsDone(self, sync_id):
|
||||
server = self.server().connect()
|
||||
url = '/sync/%s/%s/files/%s/downloaded' % (
|
||||
self._device.clientIdentifier, server.machineIdentifier, sync_id)
|
||||
server.query(url, method=requests.put)
|
||||
def markDownloaded(self, media):
|
||||
""" Mark the file as downloaded (by the nature of Plex it will be marked as downloaded within
|
||||
any SyncItem where it presented).
|
||||
|
||||
Parameters:
|
||||
media (base.Playable): the media to be marked as downloaded.
|
||||
"""
|
||||
url = '/sync/%s/item/%s/downloaded' % (self.clientIdentifier, media.ratingKey)
|
||||
media._server.query(url, method=requests.put)
|
||||
|
||||
def delete(self):
|
||||
""" Removes current SyncItem """
|
||||
url = SyncList.key.format(clientId=self.clientIdentifier)
|
||||
url += '/' + str(self.id)
|
||||
self._server.query(url, self._server._session.delete)
|
||||
|
||||
|
||||
class SyncList(PlexObject):
|
||||
""" Represents a Mobile Sync state, specific for single client, within one SyncList may be presented
|
||||
items from different servers.
|
||||
|
||||
Attributes:
|
||||
clientId (str): an identifier of the client.
|
||||
items (List<:class:`~plexapi.sync.SyncItem`>): list of registered items to sync.
|
||||
"""
|
||||
key = 'https://plex.tv/devices/{clientId}/sync_items'
|
||||
TAG = 'SyncList'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.clientId = data.attrib.get('clientIdentifier')
|
||||
self.items = []
|
||||
|
||||
syncItems = data.find('SyncItems')
|
||||
if syncItems:
|
||||
for sync_item in syncItems.iter('SyncItem'):
|
||||
item = SyncItem(self._server, sync_item, clientIdentifier=self.clientId)
|
||||
self.items.append(item)
|
||||
|
||||
|
||||
class Status(object):
|
||||
""" Represents a current status of specific :class:`~plexapi.sync.SyncItem`.
|
||||
|
||||
Attributes:
|
||||
failureCode: unknown, never got one yet.
|
||||
failure: unknown.
|
||||
state (str): server-side status of the item, can be `completed`, `pending`, empty, and probably something
|
||||
else.
|
||||
itemsCount (int): total items count.
|
||||
itemsCompleteCount (int): count of transcoded and/or downloaded items.
|
||||
itemsDownloadedCount (int): count of downloaded items.
|
||||
itemsReadyCount (int): count of transcoded items, which can be downloaded.
|
||||
totalSize (int): total size in bytes of complete items.
|
||||
itemsSuccessfulCount (int): unknown, in my experience it always was equal to `itemsCompleteCount`.
|
||||
"""
|
||||
|
||||
def __init__(self, itemsCount, itemsCompleteCount, state, totalSize, itemsDownloadedCount, itemsReadyCount,
|
||||
itemsSuccessfulCount, failureCode, failure):
|
||||
self.itemsDownloadedCount = plexapi.utils.cast(int, itemsDownloadedCount)
|
||||
self.totalSize = plexapi.utils.cast(int, totalSize)
|
||||
self.itemsReadyCount = plexapi.utils.cast(int, itemsReadyCount)
|
||||
self.failureCode = failureCode
|
||||
self.failure = failure
|
||||
self.itemsSuccessfulCount = plexapi.utils.cast(int, itemsSuccessfulCount)
|
||||
self.state = state
|
||||
self.itemsCompleteCount = plexapi.utils.cast(int, itemsCompleteCount)
|
||||
self.itemsCount = plexapi.utils.cast(int, itemsCount)
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>:%s' % (self.__class__.__name__, dict(
|
||||
itemsCount=self.itemsCount,
|
||||
itemsCompleteCount=self.itemsCompleteCount,
|
||||
itemsDownloadedCount=self.itemsDownloadedCount,
|
||||
itemsReadyCount=self.itemsReadyCount,
|
||||
itemsSuccessfulCount=self.itemsSuccessfulCount
|
||||
))
|
||||
|
||||
|
||||
class MediaSettings(object):
|
||||
""" Transcoding settings used for all media within :class:`~plexapi.sync.SyncItem`.
|
||||
|
||||
Attributes:
|
||||
audioBoost (int): unknown.
|
||||
maxVideoBitrate (int|str): maximum bitrate for video, may be empty string.
|
||||
musicBitrate (int|str): maximum bitrate for music, may be an empty string.
|
||||
photoQuality (int): photo quality on scale 0 to 100.
|
||||
photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`).
|
||||
videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty).
|
||||
subtitleSize (int|str): unknown, usually equals to 0, may be empty string.
|
||||
videoQuality (int): video quality on scale 0 to 100.
|
||||
"""
|
||||
|
||||
def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100,
|
||||
musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=''):
|
||||
self.audioBoost = plexapi.utils.cast(int, audioBoost)
|
||||
self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else ''
|
||||
self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else ''
|
||||
self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else ''
|
||||
self.photoResolution = photoResolution
|
||||
self.videoResolution = videoResolution
|
||||
self.subtitleSize = subtitleSize
|
||||
self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else ''
|
||||
|
||||
@staticmethod
|
||||
def createVideo(videoQuality):
|
||||
""" Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided video quality value.
|
||||
|
||||
Parameters:
|
||||
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest` when provided unknown video quality.
|
||||
"""
|
||||
if videoQuality == VIDEO_QUALITY_ORIGINAL:
|
||||
return MediaSettings('', '', '')
|
||||
elif videoQuality < len(VIDEO_QUALITIES['bitrate']):
|
||||
return MediaSettings(VIDEO_QUALITIES['bitrate'][videoQuality],
|
||||
VIDEO_QUALITIES['videoQuality'][videoQuality],
|
||||
VIDEO_QUALITIES['videoResolution'][videoQuality])
|
||||
else:
|
||||
raise BadRequest('Unexpected video quality')
|
||||
|
||||
@staticmethod
|
||||
def createMusic(bitrate):
|
||||
""" Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided music quality value
|
||||
|
||||
Parameters:
|
||||
bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the
|
||||
module
|
||||
"""
|
||||
return MediaSettings(musicBitrate=bitrate)
|
||||
|
||||
@staticmethod
|
||||
def createPhoto(resolution):
|
||||
""" Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided photo quality value.
|
||||
|
||||
Parameters:
|
||||
resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the
|
||||
module.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest` when provided unknown video quality.
|
||||
"""
|
||||
if resolution in PHOTO_QUALITIES:
|
||||
return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution)
|
||||
else:
|
||||
raise BadRequest('Unexpected photo quality')
|
||||
|
||||
|
||||
class Policy(object):
|
||||
""" Policy of syncing the media (how many items to sync and process watched media or not).
|
||||
|
||||
Attributes:
|
||||
scope (str): type of limitation policy, can be `count` or `all`.
|
||||
value (int): amount of media to sync, valid only when `scope=count`.
|
||||
unwatched (bool): True means disallow to sync watched media.
|
||||
"""
|
||||
|
||||
def __init__(self, scope, unwatched, value=0):
|
||||
self.scope = scope
|
||||
self.unwatched = plexapi.utils.cast(bool, unwatched)
|
||||
self.value = plexapi.utils.cast(int, value)
|
||||
|
||||
@staticmethod
|
||||
def create(limit=None, unwatched=False):
|
||||
""" Creates a :class:`~plexapi.sync.Policy` object for provided options and automatically sets proper `scope`
|
||||
value.
|
||||
|
||||
Parameters:
|
||||
limit (int): limit items by count.
|
||||
unwatched (bool): if True then watched items wouldn't be synced.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.sync.Policy`.
|
||||
"""
|
||||
scope = 'all'
|
||||
if limit is None:
|
||||
limit = 0
|
||||
else:
|
||||
scope = 'count'
|
||||
|
||||
return Policy(scope, unwatched, limit)
|
||||
|
||||
|
||||
VIDEO_QUALITIES = {
|
||||
'bitrate': [64, 96, 208, 320, 720, 1500, 2e3, 3e3, 4e3, 8e3, 1e4, 12e3, 2e4],
|
||||
'videoResolution': ['220x128', '220x128', '284x160', '420x240', '576x320', '720x480', '1280x720', '1280x720',
|
||||
'1280x720', '1920x1080', '1920x1080', '1920x1080', '1920x1080'],
|
||||
'videoQuality': [10, 20, 30, 30, 40, 60, 60, 75, 100, 60, 75, 90, 100],
|
||||
}
|
||||
|
||||
VIDEO_QUALITY_0_2_MBPS = 2
|
||||
VIDEO_QUALITY_0_3_MBPS = 3
|
||||
VIDEO_QUALITY_0_7_MBPS = 4
|
||||
VIDEO_QUALITY_1_5_MBPS_480p = 5
|
||||
VIDEO_QUALITY_2_MBPS_720p = 6
|
||||
VIDEO_QUALITY_3_MBPS_720p = 7
|
||||
VIDEO_QUALITY_4_MBPS_720p = 8
|
||||
VIDEO_QUALITY_8_MBPS_1080p = 9
|
||||
VIDEO_QUALITY_10_MBPS_1080p = 10
|
||||
VIDEO_QUALITY_12_MBPS_1080p = 11
|
||||
VIDEO_QUALITY_20_MBPS_1080p = 12
|
||||
VIDEO_QUALITY_ORIGINAL = -1
|
||||
|
||||
AUDIO_BITRATE_96_KBPS = 96
|
||||
AUDIO_BITRATE_128_KBPS = 128
|
||||
AUDIO_BITRATE_192_KBPS = 192
|
||||
AUDIO_BITRATE_320_KBPS = 320
|
||||
|
||||
PHOTO_QUALITIES = {
|
||||
'720x480': 24,
|
||||
'1280x720': 49,
|
||||
'1920x1080': 74,
|
||||
'3840x2160': 99,
|
||||
}
|
||||
|
||||
PHOTO_QUALITY_HIGHEST = PHOTO_QUALITY_2160p = '3840x2160'
|
||||
PHOTO_QUALITY_HIGH = PHOTO_QUALITY_1080p = '1920x1080'
|
||||
PHOTO_QUALITY_MEDIUM = PHOTO_QUALITY_720p = '1280x720'
|
||||
PHOTO_QUALITY_LOW = PHOTO_QUALITY_480p = '720x480'
|
||||
|
|
|
@ -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,47 @@ class Video(PlexPartialObject):
|
|||
self._server.query(key)
|
||||
self.reload()
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" 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 (movie, tv-show, season or episode) as sync item for specified device.
|
||||
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
|
||||
|
||||
Parameters:
|
||||
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
|
||||
:mod:`plexapi.sync` module.
|
||||
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
|
||||
:func:`plexapi.myplex.MyPlexAccount.sync`.
|
||||
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
|
||||
limit (int): maximum count of items to sync, unlimited if `None`.
|
||||
unwatched (bool): if `True` watched videos wouldn't be synced.
|
||||
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
|
||||
generated from metadata of current media.
|
||||
|
||||
Returns:
|
||||
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
"""
|
||||
|
||||
from plexapi.sync import SyncItem, Policy, MediaSettings
|
||||
|
||||
myplex = self._server.myPlexAccount()
|
||||
sync_item = SyncItem(self._server, None)
|
||||
sync_item.title = title if title else self._defaultSyncTitle()
|
||||
sync_item.rootTitle = self.title
|
||||
sync_item.contentType = self.listType
|
||||
sync_item.metadataType = self.METADATA_TYPE
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
section = self._server.library.sectionByID(self.librarySectionID)
|
||||
|
||||
sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key))
|
||||
sync_item.policy = Policy.create(limit, unwatched)
|
||||
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
|
||||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Movie(Playable, Video):
|
||||
|
@ -116,6 +158,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 +279,7 @@ class Show(Video):
|
|||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'show'
|
||||
METADATA_TYPE = 'episode'
|
||||
|
||||
def __iter__(self):
|
||||
for season in self.seasons():
|
||||
|
@ -363,6 +407,7 @@ class Season(Video):
|
|||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'season'
|
||||
METADATA_TYPE = 'episode'
|
||||
|
||||
def __iter__(self):
|
||||
for episode in self.episodes():
|
||||
|
@ -446,6 +491,10 @@ class Season(Video):
|
|||
filepaths += episode.download(savepath, keep_orginal_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return '%s - %s' % (self.parentTitle, self.title)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Episode(Playable, Video):
|
||||
|
@ -482,6 +531,8 @@ class Episode(Playable, Video):
|
|||
"""
|
||||
TAG = 'Video'
|
||||
TYPE = 'episode'
|
||||
METADATA_TYPE = 'episode'
|
||||
|
||||
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
|
||||
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
|
||||
'&includeConcerts=1&includePreferences=1')
|
||||
|
@ -558,3 +609,7 @@ class Episode(Playable, Video):
|
|||
def show(self):
|
||||
"""" Return this episodes :func:`~plexapi.video.Show`.. """
|
||||
return self.fetchItem(self.grandparentKey)
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title)
|
||||
|
|
|
@ -74,6 +74,17 @@ def account():
|
|||
# return MyPlexAccount(MYPLEX_USERNAME, MYPLEX_PASSWORD)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def account_synctarget():
|
||||
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()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def plex():
|
||||
assert SERVER_BASEURL, 'Required SERVER_BASEURL not specified.'
|
||||
|
@ -82,6 +93,27 @@ 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, plex):
|
||||
sync_items = account_synctarget.syncItems(clientId=device.clientIdentifier)
|
||||
for item in sync_items.items:
|
||||
item.delete()
|
||||
plex.refreshSync()
|
||||
return device
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_plex():
|
||||
return PlexServer
|
||||
|
|
297
tests/test_sync.py
Normal file
297
tests/test_sync.py
Normal file
|
@ -0,0 +1,297 @@
|
|||
from time import sleep, time
|
||||
|
||||
import pytest
|
||||
|
||||
from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p, AUDIO_BITRATE_192_KBPS, PHOTO_QUALITY_MEDIUM
|
||||
|
||||
|
||||
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, 'Failed to ensure that required sync_item is exist'
|
||||
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
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()
|
||||
ensure_sync_item_missing(clear_sync_device, new_item_in_myplex)
|
||||
|
||||
|
||||
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_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()
|
||||
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]
|
||||
|
||||
|
||||
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_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]
|
||||
|
||||
|
||||
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_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()
|
Loading…
Reference in a new issue