diff --git a/plexapi/audio.py b/plexapi/audio.py index 6f6c0d986..53f9d6088 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from plexapi import media, utils from plexapi.base import Playable, PlexPartialObject +from plexapi.compat import quote_plus class Audio(PlexPartialObject): @@ -23,6 +24,9 @@ class Audio(PlexPartialObject): updatedAt (datatime): Datetime this item was updated. viewCount (int): Count of times this item was accessed. """ + + METADATA_TYPE = 'track' + def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data @@ -57,6 +61,46 @@ def url(self, part): """ Returns the full URL for this audio item. Typically used for getting a specific track. """ return self._server.url(part, includeToken=True) if part else None + def _defaultSyncTitle(self): + """ 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 @@ def download(self, savepath=None, keep_orginal_name=False, **kwargs): 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 @@ def album(self): def artist(self): """ Return this track's :class:`~plexapi.audio.Artist`. """ return self.fetchItem(self.grandparentKey) + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) diff --git a/plexapi/config.py b/plexapi/config.py index 20f9a96e6..47eebd8bf 100644 --- a/plexapi/config.py +++ b/plexapi/config.py @@ -60,4 +60,5 @@ def reset_base_headers(): 'X-Plex-Device': plexapi.X_PLEX_DEVICE, 'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME, 'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER, + 'X-Plex-Sync-Version': '2', } diff --git a/plexapi/library.py b/plexapi/library.py index b42f14168..71380d015 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -543,6 +543,82 @@ def _cleanSearchSort(self, sort): 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 @@ def collection(self, **kwargs): """ Returns a list of collections from this library section. """ return self.search(libtype='collection', **kwargs) + def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): + """ Add current Show library section as sync item for specified device. + See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and + :func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`plexapi.sync` module. + limit (int): maximum count of episodes to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('TV-Shows') + section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True, + title='Next unwatched episode') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) + kwargs['policy'] = Policy.create(limit, unwatched) + return super(ShowSection, self).sync(**kwargs) + class MusicSection(LibrarySection): """ Represents a :class:`~plexapi.library.LibrarySection` section containing music artists. @@ -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 @@ def collection(self, **kwargs): """ Returns a list of collections from this library section. """ return self.search(libtype='collection', **kwargs) + def sync(self, bitrate, limit=None, **kwargs): + """ Add current Music library section as sync item for specified device. + See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and + :func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions. + + Parameters: + bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the + module :mod:`plexapi.sync`. + limit (int): maximum count of tracks to sync, unlimited if `None`. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import AUDIO_BITRATE_320_KBPS + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Music') + section.sync(AUDIO_BITRATE_320_KBPS, client=target, limit=100, sort='addedAt:desc', + title='New music') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createMusic(bitrate) + kwargs['policy'] = Policy.create(limit) + return super(MusicSection, self).sync(**kwargs) + class PhotoSection(LibrarySection): """ Represents a :class:`~plexapi.library.LibrarySection` section containing photos. @@ -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 @@ def searchPhotos(self, title, **kwargs): key = '/library/sections/%s/all?type=13' % self.key return self.fetchItems(key, title=title) + def sync(self, resolution, limit=None, **kwargs): + """ 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 diff --git a/plexapi/media.py b/plexapi/media.py index 81f7d6f9c..a8dd973bd 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -92,6 +92,11 @@ def _loadData(self, data): self.indexes = data.attrib.get('indexes') self.key = data.attrib.get('key') self.size = cast(int, data.attrib.get('size')) + self.decision = data.attrib.get('decision') + self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) + self.syncItemId = cast(int, data.attrib.get('syncItemId')) + self.syncState = data.attrib.get('syncState') + self.videoProfile = data.attrib.get('videoProfile') self.streams = self._buildStreams(data) def _buildStreams(self, data): diff --git a/plexapi/myplex.py b/plexapi/myplex.py index bec46876e..5e4c2e1ed 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -3,7 +3,7 @@ import requests import time from requests.status_codes import _codes as codes -from plexapi import BASE_HEADERS, CONFIG, TIMEOUT +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER from plexapi import log, logfilter, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest, NotFound @@ -11,6 +11,7 @@ from plexapi.compat import ElementTree from plexapi.library import LibrarySection from plexapi.server import PlexServer +from plexapi.sync import SyncList, SyncItem from plexapi.utils import joinArgs @@ -289,7 +290,7 @@ def updateFriend(self, user, server, sections=None, removeSections=False, allowS return response_servers, response_filters def user(self, username): - """ Returns the :class:`~myplex.MyPlexUser` that matches the email or username specified. + """ Returns the :class:`~plexapi.myplex.MyPlexUser` that matches the email or username specified. Parameters: username (str): Username, email or id of the user to return. @@ -378,6 +379,86 @@ def optOut(self, playback=None, library=None): url = 'https://plex.tv/api/v2/user/privacy' return self.query(url, method=self._session.put, params=params) + def syncItems(self, 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 @@ def delete(self): 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 diff --git a/plexapi/photo.py b/plexapi/photo.py index 50db79f56..9563b9bf0 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -2,6 +2,7 @@ from plexapi import media, utils from plexapi.base import PlexPartialObject from plexapi.exceptions import NotFound +from plexapi.compat import quote_plus @utils.registerPlexObject @@ -96,6 +97,7 @@ class Photo(PlexPartialObject): """ TAG = 'Photo' TYPE = 'photo' + METADATA_TYPE = 'photo' def _loadData(self, data): """ Load attribute values from Plex XML response. """ @@ -123,3 +125,39 @@ def photoalbum(self): def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """ return self._server.library.sectionByID(self.photoalbum().librarySectionID) + + def sync(self, resolution, client=None, clientId=None, limit=None, title=None): + """ Add current 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) diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 06e18ffa5..9a1dbcf80 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- from plexapi import utils from plexapi.base import PlexPartialObject, Playable -from plexapi.exceptions import BadRequest +from plexapi.exceptions import BadRequest, Unsupported from plexapi.playqueue import PlayQueue from plexapi.utils import cast, toDatetime +from plexapi.compat import quote_plus @utils.registerPlexObject @@ -32,11 +33,35 @@ def _loadData(self, data): self.title = data.attrib.get('title') self.type = data.attrib.get('type') self.updatedAt = toDatetime(data.attrib.get('updatedAt')) + self.allowSync = cast(bool, data.attrib.get('allowSync')) self._items = None # cache for self.items def __len__(self): # pragma: no cover return len(self.items()) + @property + def metadataType(self): + if self.isVideo: + return 'movie' + elif self.isAudio: + return 'track' + elif self.isPhoto: + return 'photo' + else: + raise Unsupported('Unexpected playlist type') + + @property + def isVideo(self): + return self.playlistType == 'video' + + @property + def isAudio(self): + return self.playlistType == 'audio' + + @property + def isPhoto(self): + return self.playlistType == 'photo' + def __contains__(self, other): # pragma: no cover return any(i.key == other.key for i in self.items()) @@ -132,3 +157,58 @@ def copyToUser(self, user): # Login to your server using your friends credentials. user_server = PlexServer(self._server._baseurl, token) return self.create(user_server, self.title, self.items()) + + def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None, + unwatched=False, title=None): + """ Add current playlist as sync item for specified device. + See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`plexapi.sync` module. Used only when playlist contains video. + photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in + the module :mod:`plexapi.sync`. Used only when playlist contains photos. + audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the + module :mod:`plexapi.sync`. Used only when playlist contains audio. + client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current photo. + + Raises: + :class:`plexapi.exceptions.BadRequest`: when playlist is not allowed to sync. + :class:`plexapi.exceptions.Unsupported`: when playlist content is unsupported. + + Returns: + :class:`plexapi.sync.SyncItem`: an instance of created syncItem. + """ + + if not self.allowSync: + raise BadRequest('The playlist is not allowed to sync') + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self.title + sync_item.rootTitle = self.title + sync_item.contentType = self.playlistType + sync_item.metadataType = self.metadataType + sync_item.machineIdentifier = self._server.machineIdentifier + + sync_item.location = 'playlist:///%s' % quote_plus(self.guid) + sync_item.policy = Policy.create(limit, unwatched) + + if self.isVideo: + sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) + elif self.isAudio: + sync_item.mediaSettings = MediaSettings.createMusic(audioBitrate) + elif self.isPhoto: + sync_item.mediaSettings = MediaSettings.createPhoto(photoResolution) + else: + raise Unsupported('Unsupported playlist content') + + return myplex.sync(sync_item, client=client, clientId=clientId) diff --git a/plexapi/server.py b/plexapi/server.py index 849b4c690..bf1438eb0 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -422,6 +422,21 @@ def url(self, key, includeToken=None): return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token) return '%s%s' % (self._baseurl, key) + def refreshSynclist(self): + """ 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 diff --git a/plexapi/sync.py b/plexapi/sync.py index 8ca725202..61c9c7860 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -1,42 +1,312 @@ # -*- coding: utf-8 -*- +""" +You can work with Mobile Sync on other devices straight away, but if you'd like to use your app as a `sync-target` (when +you can set items to be synced to your app) you need to init some variables. + +.. code-block:: python + + def init_sync(): + import plexapi + plexapi.X_PLEX_PROVIDES = 'sync-target' + plexapi.BASE_HEADERS['X-Plex-Sync-Version'] = '2' + plexapi.BASE_HEADERS['X-Plex-Provides'] = plexapi.X_PLEX_PROVIDES + + # mimic iPhone SE + plexapi.X_PLEX_PLATFORM = 'iOS' + plexapi.X_PLEX_PLATFORM_VERSION = '11.4.1' + plexapi.X_PLEX_DEVICE = 'iPhone' + + plexapi.BASE_HEADERS['X-Plex-Platform'] = plexapi.X_PLEX_PLATFORM + plexapi.BASE_HEADERS['X-Plex-Platform-Version'] = plexapi.X_PLEX_PLATFORM_VERSION + plexapi.BASE_HEADERS['X-Plex-Device'] = plexapi.X_PLEX_DEVICE + +You have to fake platform/device/model because transcoding profiles are hardcoded in Plex, and you obviously have +to explicitly specify that your app supports `sync-target`. +""" + import requests -from plexapi import utils -from plexapi.exceptions import NotFound + +import plexapi +from plexapi.base import PlexObject +from plexapi.exceptions import NotFound, BadRequest -class SyncItem(object): - """ Sync Item. This doesn't current work. """ - def __init__(self, device, data, servers=None): - self._device = device - self._servers = servers - self._loadData(data) +class SyncItem(PlexObject): + """ + Represents single sync item, for specified server and client. When you saying in the UI to sync "this" to "that" + you're basically creating a sync item. + + Attributes: + id (int): unique id of the item. + clientIdentifier (str): an identifier of Plex Client device, to which the item is belongs. + machineIdentifier (str): the id of server which holds all this content. + version (int): current version of the item. Each time you modify the item (e.g. by changing amount if media to + sync) the new version is created. + rootTitle (str): the title of library/media from which the sync item was created. E.g.: + + * when you create an item for an episode 3 of season 3 of show Example, the value would be `Title of + Episode 3` + * when you create an item for a season 3 of show Example, the value would be `Season 3` + * when you set to sync all your movies in library named "My Movies" to value would be `My Movies`. + + title (str): the title which you've set when created the sync item. + metadataType (str): the type of media which hides inside, can be `episode`, `movie`, etc. + contentType (str): basic type of the content: `video` or `audio`. + status (:class:`~plexapi.sync.Status`): current status of the sync. + mediaSettings (:class:`~plexapi.sync.MediaSettings`): media transcoding settings used for the item. + policy (:class:`~plexapi.sync.Policy`): the policy of which media to sync. + location (str): plex-style library url with all required filters / sorting. + """ + TAG = 'SyncItem' + + def __init__(self, server, data, initpath=None, clientIdentifier=None): + super(SyncItem, self).__init__(server, data, initpath) + self.clientIdentifier = clientIdentifier def _loadData(self, data): self._data = data - self.id = utils.cast(int, data.attrib.get('id')) - self.version = utils.cast(int, data.attrib.get('version')) + self.id = plexapi.utils.cast(int, data.attrib.get('id')) + self.version = plexapi.utils.cast(int, data.attrib.get('version')) self.rootTitle = data.attrib.get('rootTitle') self.title = data.attrib.get('title') self.metadataType = data.attrib.get('metadataType') + self.contentType = data.attrib.get('contentType') self.machineIdentifier = data.find('Server').get('machineIdentifier') - self.status = data.find('Status').attrib.copy() - self.MediaSettings = data.find('MediaSettings').attrib.copy() - self.policy = data.find('Policy').attrib.copy() - self.location = data.find('Location').attrib.copy() + self.status = Status(**data.find('Status').attrib) + self.mediaSettings = MediaSettings(**data.find('MediaSettings').attrib) + self.policy = Policy(**data.find('Policy').attrib) + self.location = data.find('Location').attrib.get('uri', '') def server(self): - server = list(filter(lambda x: x.machineIdentifier == self.machineIdentifier, self._servers)) - if 0 == len(server): + """ Returns :class:`plexapi.myplex.MyPlexResource` with server of current item. """ + server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier] + if len(server) == 0: raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier) return server[0] def getMedia(self): + """ Returns list of :class:`~plexapi.base.Playable` which belong to this sync item. """ server = self.server().connect() key = '/sync/items/%s' % self.id return server.fetchItems(key) - def markAsDone(self, sync_id): - server = self.server().connect() - url = '/sync/%s/%s/files/%s/downloaded' % ( - self._device.clientIdentifier, server.machineIdentifier, sync_id) - server.query(url, method=requests.put) + def markDownloaded(self, media): + """ Mark the file as downloaded (by the nature of Plex it will be marked as downloaded within + any SyncItem where it presented). + + Parameters: + media (base.Playable): the media to be marked as downloaded. + """ + url = '/sync/%s/item/%s/downloaded' % (self.clientIdentifier, media.ratingKey) + media._server.query(url, method=requests.put) + + def delete(self): + """ Removes current SyncItem """ + url = SyncList.key.format(clientId=self.clientIdentifier) + url += '/' + str(self.id) + self._server.query(url, self._server._session.delete) + + +class SyncList(PlexObject): + """ Represents a Mobile Sync state, specific for single client, within one SyncList may be presented + items from different servers. + + Attributes: + clientId (str): an identifier of the client. + items (List<:class:`~plexapi.sync.SyncItem`>): list of registered items to sync. + """ + key = 'https://plex.tv/devices/{clientId}/sync_items' + TAG = 'SyncList' + + def _loadData(self, data): + self._data = data + self.clientId = data.attrib.get('clientIdentifier') + self.items = [] + + syncItems = data.find('SyncItems') + if syncItems: + for sync_item in syncItems.iter('SyncItem'): + item = SyncItem(self._server, sync_item, clientIdentifier=self.clientId) + self.items.append(item) + + +class Status(object): + """ Represents a current status of specific :class:`~plexapi.sync.SyncItem`. + + Attributes: + failureCode: unknown, never got one yet. + failure: unknown. + state (str): server-side status of the item, can be `completed`, `pending`, empty, and probably something + else. + itemsCount (int): total items count. + itemsCompleteCount (int): count of transcoded and/or downloaded items. + itemsDownloadedCount (int): count of downloaded items. + itemsReadyCount (int): count of transcoded items, which can be downloaded. + totalSize (int): total size in bytes of complete items. + itemsSuccessfulCount (int): unknown, in my experience it always was equal to `itemsCompleteCount`. + """ + + def __init__(self, itemsCount, itemsCompleteCount, state, totalSize, itemsDownloadedCount, itemsReadyCount, + itemsSuccessfulCount, failureCode, failure): + self.itemsDownloadedCount = plexapi.utils.cast(int, itemsDownloadedCount) + self.totalSize = plexapi.utils.cast(int, totalSize) + self.itemsReadyCount = plexapi.utils.cast(int, itemsReadyCount) + self.failureCode = failureCode + self.failure = failure + self.itemsSuccessfulCount = plexapi.utils.cast(int, itemsSuccessfulCount) + self.state = state + self.itemsCompleteCount = plexapi.utils.cast(int, itemsCompleteCount) + self.itemsCount = plexapi.utils.cast(int, itemsCount) + + def __repr__(self): + return '<%s>:%s' % (self.__class__.__name__, dict( + itemsCount=self.itemsCount, + itemsCompleteCount=self.itemsCompleteCount, + itemsDownloadedCount=self.itemsDownloadedCount, + itemsReadyCount=self.itemsReadyCount, + itemsSuccessfulCount=self.itemsSuccessfulCount + )) + + +class MediaSettings(object): + """ Transcoding settings used for all media within :class:`~plexapi.sync.SyncItem`. + + Attributes: + audioBoost (int): unknown. + maxVideoBitrate (int|str): maximum bitrate for video, may be empty string. + musicBitrate (int|str): maximum bitrate for music, may be an empty string. + photoQuality (int): photo quality on scale 0 to 100. + photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`). + videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty). + subtitleSize (int|str): unknown, usually equals to 0, may be empty string. + videoQuality (int): video quality on scale 0 to 100. + """ + + def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100, + musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=''): + self.audioBoost = plexapi.utils.cast(int, audioBoost) + self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else '' + self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else '' + self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else '' + self.photoResolution = photoResolution + self.videoResolution = videoResolution + self.subtitleSize = subtitleSize + self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else '' + + @staticmethod + def createVideo(videoQuality): + """ Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided video quality value. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module. + + Raises: + :class:`plexapi.exceptions.BadRequest` when provided unknown video quality. + """ + if videoQuality == VIDEO_QUALITY_ORIGINAL: + return MediaSettings('', '', '') + elif videoQuality < len(VIDEO_QUALITIES['bitrate']): + return MediaSettings(VIDEO_QUALITIES['bitrate'][videoQuality], + VIDEO_QUALITIES['videoQuality'][videoQuality], + VIDEO_QUALITIES['videoResolution'][videoQuality]) + else: + raise BadRequest('Unexpected video quality') + + @staticmethod + def createMusic(bitrate): + """ Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided music quality value + + Parameters: + bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the + module + """ + return MediaSettings(musicBitrate=bitrate) + + @staticmethod + def createPhoto(resolution): + """ Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided photo quality value. + + Parameters: + resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the + module. + + Raises: + :class:`plexapi.exceptions.BadRequest` when provided unknown video quality. + """ + if resolution in PHOTO_QUALITIES: + return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution) + else: + raise BadRequest('Unexpected photo quality') + + +class Policy(object): + """ Policy of syncing the media (how many items to sync and process watched media or not). + + Attributes: + scope (str): type of limitation policy, can be `count` or `all`. + value (int): amount of media to sync, valid only when `scope=count`. + unwatched (bool): True means disallow to sync watched media. + """ + + def __init__(self, scope, unwatched, value=0): + self.scope = scope + self.unwatched = plexapi.utils.cast(bool, unwatched) + self.value = plexapi.utils.cast(int, value) + + @staticmethod + def create(limit=None, unwatched=False): + """ Creates a :class:`~plexapi.sync.Policy` object for provided options and automatically sets proper `scope` + value. + + Parameters: + limit (int): limit items by count. + unwatched (bool): if True then watched items wouldn't be synced. + + Returns: + :class:`~plexapi.sync.Policy`. + """ + scope = 'all' + if limit is None: + limit = 0 + else: + scope = 'count' + + return Policy(scope, unwatched, limit) + + +VIDEO_QUALITIES = { + 'bitrate': [64, 96, 208, 320, 720, 1500, 2e3, 3e3, 4e3, 8e3, 1e4, 12e3, 2e4], + 'videoResolution': ['220x128', '220x128', '284x160', '420x240', '576x320', '720x480', '1280x720', '1280x720', + '1280x720', '1920x1080', '1920x1080', '1920x1080', '1920x1080'], + 'videoQuality': [10, 20, 30, 30, 40, 60, 60, 75, 100, 60, 75, 90, 100], +} + +VIDEO_QUALITY_0_2_MBPS = 2 +VIDEO_QUALITY_0_3_MBPS = 3 +VIDEO_QUALITY_0_7_MBPS = 4 +VIDEO_QUALITY_1_5_MBPS_480p = 5 +VIDEO_QUALITY_2_MBPS_720p = 6 +VIDEO_QUALITY_3_MBPS_720p = 7 +VIDEO_QUALITY_4_MBPS_720p = 8 +VIDEO_QUALITY_8_MBPS_1080p = 9 +VIDEO_QUALITY_10_MBPS_1080p = 10 +VIDEO_QUALITY_12_MBPS_1080p = 11 +VIDEO_QUALITY_20_MBPS_1080p = 12 +VIDEO_QUALITY_ORIGINAL = -1 + +AUDIO_BITRATE_96_KBPS = 96 +AUDIO_BITRATE_128_KBPS = 128 +AUDIO_BITRATE_192_KBPS = 192 +AUDIO_BITRATE_320_KBPS = 320 + +PHOTO_QUALITIES = { + '720x480': 24, + '1280x720': 49, + '1920x1080': 74, + '3840x2160': 99, +} + +PHOTO_QUALITY_HIGHEST = PHOTO_QUALITY_2160p = '3840x2160' +PHOTO_QUALITY_HIGH = PHOTO_QUALITY_1080p = '1920x1080' +PHOTO_QUALITY_MEDIUM = PHOTO_QUALITY_720p = '1280x720' +PHOTO_QUALITY_LOW = PHOTO_QUALITY_480p = '720x480' diff --git a/plexapi/video.py b/plexapi/video.py index 2d308510c..728c8bd00 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -2,6 +2,7 @@ from plexapi import media, utils from plexapi.exceptions import BadRequest, NotFound from plexapi.base import Playable, PlexPartialObject +from plexapi.compat import quote_plus class Video(PlexPartialObject): @@ -77,6 +78,47 @@ def markUnwatched(self): 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 @@ def download(self, savepath=None, keep_orginal_name=False, **kwargs): 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 @@ def season(self): 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) diff --git a/tests/conftest.py b/tests/conftest.py index d351e3b5f..4b491eca2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 000000000..a81398d88 --- /dev/null +++ b/tests/test_sync.py @@ -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()