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:
Andrey Yantsen 2018-09-08 16:25:16 +01:00 committed by Hellowlol
parent 250d3538c6
commit 54b26fdc25
12 changed files with 1184 additions and 24 deletions

View file

@ -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)

View file

@ -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',
}

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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'

View file

@ -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)

View file

@ -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
View 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()