2016-03-22 03:52:58 +00:00
|
|
|
# -*- coding: utf-8 -*-
|
2020-05-12 21:15:16 +00:00
|
|
|
from urllib.parse import quote_plus
|
|
|
|
|
2016-03-22 03:52:58 +00:00
|
|
|
from plexapi import utils
|
2020-05-12 21:15:16 +00:00
|
|
|
from plexapi.base import Playable, PlexPartialObject
|
2020-12-24 06:32:48 +00:00
|
|
|
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
2018-11-16 22:47:49 +00:00
|
|
|
from plexapi.library import LibrarySection
|
2021-01-24 23:29:20 +00:00
|
|
|
from plexapi.mixins import PosterArt
|
2017-02-07 06:58:29 +00:00
|
|
|
from plexapi.playqueue import PlayQueue
|
2016-02-03 18:07:53 +00:00
|
|
|
from plexapi.utils import cast, toDatetime
|
|
|
|
|
|
|
|
|
2017-02-13 02:55:55 +00:00
|
|
|
@utils.registerPlexObject
|
2021-01-24 23:29:20 +00:00
|
|
|
class Playlist(PlexPartialObject, Playable, PosterArt):
|
2020-12-24 06:25:10 +00:00
|
|
|
""" Represents a single Playlist.
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
TAG (str): 'Playlist'
|
|
|
|
TYPE (str): 'playlist'
|
|
|
|
addedAt (datetime): Datetime the playlist was added to the server.
|
|
|
|
allowSync (bool): True if you allow syncing playlists.
|
|
|
|
composite (str): URL to composite image (/playlist/<ratingKey>/composite/<compositeid>)
|
|
|
|
duration (int): Duration of the playlist in milliseconds.
|
|
|
|
durationInSeconds (int): Duration of the playlist in seconds.
|
|
|
|
guid (str): Plex GUID for the playlist (com.plexapp.agents.none://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX).
|
|
|
|
key (str): API URL (/playlist/<ratingkey>).
|
|
|
|
leafCount (int): Number of items in the playlist view.
|
|
|
|
playlistType (str): 'audio', 'video', or 'photo'
|
|
|
|
ratingKey (int): Unique key identifying the playlist.
|
|
|
|
smart (bool): True if the playlist is a smart playlist.
|
|
|
|
summary (str): Summary of the playlist.
|
|
|
|
title (str): Name of the playlist.
|
|
|
|
type (str): 'playlist'
|
|
|
|
updatedAt (datatime): Datetime the playlist was updated.
|
2017-02-14 04:32:27 +00:00
|
|
|
"""
|
2017-02-13 02:55:55 +00:00
|
|
|
TAG = 'Playlist'
|
2016-02-03 18:07:53 +00:00
|
|
|
TYPE = 'playlist'
|
|
|
|
|
|
|
|
def _loadData(self, data):
|
2017-02-14 04:32:27 +00:00
|
|
|
""" Load attribute values from Plex XML response. """
|
2016-04-08 02:48:45 +00:00
|
|
|
Playable._loadData(self, data)
|
2017-02-04 17:43:50 +00:00
|
|
|
self.addedAt = toDatetime(data.attrib.get('addedAt'))
|
2020-12-24 06:25:10 +00:00
|
|
|
self.allowSync = cast(bool, data.attrib.get('allowSync'))
|
2017-02-04 17:43:50 +00:00
|
|
|
self.composite = data.attrib.get('composite') # url to thumbnail
|
|
|
|
self.duration = cast(int, data.attrib.get('duration'))
|
|
|
|
self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds'))
|
|
|
|
self.guid = data.attrib.get('guid')
|
2020-12-24 06:25:10 +00:00
|
|
|
self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50
|
2017-02-04 17:43:50 +00:00
|
|
|
self.leafCount = cast(int, data.attrib.get('leafCount'))
|
|
|
|
self.playlistType = data.attrib.get('playlistType')
|
|
|
|
self.ratingKey = cast(int, data.attrib.get('ratingKey'))
|
|
|
|
self.smart = cast(bool, data.attrib.get('smart'))
|
|
|
|
self.summary = data.attrib.get('summary')
|
|
|
|
self.title = data.attrib.get('title')
|
|
|
|
self.type = data.attrib.get('type')
|
|
|
|
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
|
2017-10-09 13:58:44 +00:00
|
|
|
self._items = None # cache for self.items
|
2017-10-05 20:24:49 +00:00
|
|
|
|
2017-10-25 22:01:42 +00:00
|
|
|
def __len__(self): # pragma: no cover
|
2017-10-05 20:24:49 +00:00
|
|
|
return len(self.items())
|
|
|
|
|
2020-08-11 17:11:53 +00:00
|
|
|
def __iter__(self): # pragma: no cover
|
|
|
|
for item in self.items():
|
|
|
|
yield item
|
|
|
|
|
2018-09-08 15:25:16 +00:00
|
|
|
@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'
|
|
|
|
|
2017-10-25 22:01:42 +00:00
|
|
|
def __contains__(self, other): # pragma: no cover
|
2017-10-05 20:24:49 +00:00
|
|
|
return any(i.key == other.key for i in self.items())
|
|
|
|
|
2017-10-25 22:01:42 +00:00
|
|
|
def __getitem__(self, key): # pragma: no cover
|
2017-10-05 20:24:49 +00:00
|
|
|
return self.items()[key]
|
2016-02-03 18:07:53 +00:00
|
|
|
|
2020-12-24 06:32:48 +00:00
|
|
|
def item(self, title):
|
|
|
|
""" Returns the item in the playlist that matches the specified title.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
title (str): Title of the item to return.
|
|
|
|
"""
|
|
|
|
for item in self.items():
|
2020-12-24 17:21:29 +00:00
|
|
|
if item.title.lower() == title.lower():
|
2020-12-24 06:32:48 +00:00
|
|
|
return item
|
2020-12-24 17:21:29 +00:00
|
|
|
raise NotFound('Item with title "%s" not found in the playlist' % title)
|
2020-12-24 06:32:48 +00:00
|
|
|
|
2016-03-22 03:52:58 +00:00
|
|
|
def items(self):
|
2017-02-06 04:52:10 +00:00
|
|
|
""" Returns a list of all items in the playlist. """
|
2017-10-05 20:24:49 +00:00
|
|
|
if self._items is None:
|
2020-12-24 17:21:29 +00:00
|
|
|
key = '/playlists/%s/items' % self.ratingKey
|
2017-10-05 20:24:49 +00:00
|
|
|
items = self.fetchItems(key)
|
|
|
|
self._items = items
|
|
|
|
return self._items
|
2016-12-21 13:17:28 +00:00
|
|
|
|
2020-12-24 06:32:48 +00:00
|
|
|
def get(self, title):
|
|
|
|
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """
|
|
|
|
return self.item(title)
|
|
|
|
|
2016-04-11 03:49:23 +00:00
|
|
|
def addItems(self, items):
|
2017-02-14 04:32:27 +00:00
|
|
|
""" Add items to a playlist. """
|
2016-04-11 03:49:23 +00:00
|
|
|
if not isinstance(items, (list, tuple)):
|
|
|
|
items = [items]
|
|
|
|
ratingKeys = []
|
|
|
|
for item in items:
|
2017-10-25 22:01:42 +00:00
|
|
|
if item.listType != self.playlistType: # pragma: no cover
|
2017-02-20 05:37:00 +00:00
|
|
|
raise BadRequest('Can not mix media types when building a playlist: %s and %s' %
|
|
|
|
(self.playlistType, item.listType))
|
2017-01-09 14:21:54 +00:00
|
|
|
ratingKeys.append(str(item.ratingKey))
|
2016-04-12 02:43:21 +00:00
|
|
|
uuid = items[0].section().uuid
|
2017-02-02 14:09:34 +00:00
|
|
|
ratingKeys = ','.join(ratingKeys)
|
2017-02-07 06:20:49 +00:00
|
|
|
key = '%s/items%s' % (self.key, utils.joinArgs({
|
|
|
|
'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys)
|
2016-04-11 03:49:23 +00:00
|
|
|
}))
|
2017-10-09 13:57:37 +00:00
|
|
|
result = self._server.query(key, method=self._server._session.put)
|
|
|
|
self.reload()
|
|
|
|
return result
|
2016-04-11 03:49:23 +00:00
|
|
|
|
|
|
|
def removeItem(self, item):
|
2017-02-14 04:32:27 +00:00
|
|
|
""" Remove a file from a playlist. """
|
2017-02-07 06:20:49 +00:00
|
|
|
key = '%s/items/%s' % (self.key, item.playlistItemID)
|
2017-10-09 13:57:37 +00:00
|
|
|
result = self._server.query(key, method=self._server._session.delete)
|
|
|
|
self.reload()
|
|
|
|
return result
|
2016-04-11 03:49:23 +00:00
|
|
|
|
|
|
|
def moveItem(self, item, after=None):
|
2017-02-14 04:32:27 +00:00
|
|
|
""" Move a to a new position in playlist. """
|
2017-02-07 06:20:49 +00:00
|
|
|
key = '%s/items/%s/move' % (self.key, item.playlistItemID)
|
2017-01-02 21:06:40 +00:00
|
|
|
if after:
|
2017-02-07 06:20:49 +00:00
|
|
|
key += '?after=%s' % after.playlistItemID
|
2017-10-09 13:57:37 +00:00
|
|
|
result = self._server.query(key, method=self._server._session.put)
|
|
|
|
self.reload()
|
|
|
|
return result
|
2016-12-21 13:17:28 +00:00
|
|
|
|
2016-04-11 03:49:23 +00:00
|
|
|
def edit(self, title=None, summary=None):
|
2017-02-14 04:32:27 +00:00
|
|
|
""" Edit playlist. """
|
2017-02-20 05:37:00 +00:00
|
|
|
key = '/library/metadata/%s%s' % (self.ratingKey, utils.joinArgs({'title': title, 'summary': summary}))
|
2017-10-11 20:40:10 +00:00
|
|
|
result = self._server.query(key, method=self._server._session.put)
|
2017-10-09 13:57:37 +00:00
|
|
|
self.reload()
|
|
|
|
return result
|
2016-12-21 13:17:28 +00:00
|
|
|
|
2016-04-11 03:49:23 +00:00
|
|
|
def delete(self):
|
2017-02-14 04:32:27 +00:00
|
|
|
""" Delete playlist. """
|
2017-02-09 04:29:17 +00:00
|
|
|
return self._server.query(self.key, method=self._server._session.delete)
|
2016-12-21 13:17:28 +00:00
|
|
|
|
2017-02-07 06:58:29 +00:00
|
|
|
def playQueue(self, *args, **kwargs):
|
|
|
|
""" Create a playqueue from this playlist. """
|
2017-02-09 04:13:54 +00:00
|
|
|
return PlayQueue.create(self._server, self, *args, **kwargs)
|
2017-02-07 06:58:29 +00:00
|
|
|
|
2016-04-11 03:49:23 +00:00
|
|
|
@classmethod
|
2018-11-16 22:47:49 +00:00
|
|
|
def _create(cls, server, title, items):
|
2017-02-14 04:32:27 +00:00
|
|
|
""" Create a playlist. """
|
2020-07-23 23:31:27 +00:00
|
|
|
if not items:
|
|
|
|
raise BadRequest('Must include items to add when creating new playlist')
|
|
|
|
|
2018-11-16 22:47:49 +00:00
|
|
|
if items and not isinstance(items, (list, tuple)):
|
2016-04-11 03:49:23 +00:00
|
|
|
items = [items]
|
|
|
|
ratingKeys = []
|
|
|
|
for item in items:
|
2017-10-25 22:01:42 +00:00
|
|
|
if item.listType != items[0].listType: # pragma: no cover
|
2016-04-11 03:49:23 +00:00
|
|
|
raise BadRequest('Can not mix media types when building a playlist')
|
2016-12-21 13:17:28 +00:00
|
|
|
ratingKeys.append(str(item.ratingKey))
|
2016-04-12 02:43:21 +00:00
|
|
|
ratingKeys = ','.join(ratingKeys)
|
2016-04-13 03:52:47 +00:00
|
|
|
uuid = items[0].section().uuid
|
2017-02-07 06:20:49 +00:00
|
|
|
key = '/playlists%s' % utils.joinArgs({
|
2016-04-12 02:43:21 +00:00
|
|
|
'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys),
|
|
|
|
'type': items[0].listType,
|
2016-04-11 03:49:23 +00:00
|
|
|
'title': title,
|
|
|
|
'smart': 0
|
|
|
|
})
|
2017-02-09 04:29:17 +00:00
|
|
|
data = server.query(key, method=server._session.post)[0]
|
2017-02-07 06:20:49 +00:00
|
|
|
return cls(server, data, initpath=key)
|
2017-07-17 14:11:03 +00:00
|
|
|
|
2018-11-16 22:47:49 +00:00
|
|
|
@classmethod
|
|
|
|
def create(cls, server, title, items=None, section=None, limit=None, smart=False, **kwargs):
|
|
|
|
"""Create a playlist.
|
|
|
|
|
|
|
|
Parameters:
|
2018-11-16 23:06:33 +00:00
|
|
|
server (:class:`~plexapi.server.PlexServer`): Server your connected to.
|
2018-11-16 22:47:49 +00:00
|
|
|
title (str): Title of the playlist.
|
2018-11-16 23:06:33 +00:00
|
|
|
items (Iterable): Iterable of objects that should be in the playlist.
|
2018-12-04 21:00:58 +00:00
|
|
|
section (:class:`~plexapi.library.LibrarySection`, str):
|
2018-11-16 23:06:33 +00:00
|
|
|
limit (int): default None.
|
|
|
|
smart (bool): default False.
|
2018-11-16 22:47:49 +00:00
|
|
|
|
2018-11-16 23:33:56 +00:00
|
|
|
**kwargs (dict): is passed to the filters. For a example see the search method.
|
2020-07-23 23:31:27 +00:00
|
|
|
|
|
|
|
Raises:
|
|
|
|
:class:`plexapi.exceptions.BadRequest`: when no items are included in create request.
|
2018-11-16 22:47:49 +00:00
|
|
|
|
2018-11-16 23:33:56 +00:00
|
|
|
Returns:
|
2020-11-23 03:06:30 +00:00
|
|
|
:class:`~plexapi.playlist.Playlist`: an instance of created Playlist.
|
2018-11-16 22:47:49 +00:00
|
|
|
"""
|
|
|
|
if smart:
|
|
|
|
return cls._createSmart(server, title, section, limit, **kwargs)
|
|
|
|
|
|
|
|
else:
|
|
|
|
return cls._create(server, title, items)
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def _createSmart(cls, server, title, section, limit=None, **kwargs):
|
|
|
|
""" Create a Smart playlist. """
|
|
|
|
|
|
|
|
if not isinstance(section, LibrarySection):
|
|
|
|
section = server.library.section(section)
|
|
|
|
|
|
|
|
sectionType = utils.searchType(section.type)
|
|
|
|
sectionId = section.key
|
|
|
|
uuid = section.uuid
|
|
|
|
uri = 'library://%s/directory//library/sections/%s/all?type=%s' % (uuid,
|
|
|
|
sectionId,
|
|
|
|
sectionType)
|
|
|
|
if limit:
|
|
|
|
uri = uri + '&limit=%s' % str(limit)
|
|
|
|
|
|
|
|
for category, value in kwargs.items():
|
|
|
|
sectionChoices = section.listChoices(category)
|
|
|
|
for choice in sectionChoices:
|
2018-12-04 21:00:58 +00:00
|
|
|
if str(choice.title).lower() == str(value).lower():
|
2018-11-16 22:47:49 +00:00
|
|
|
uri = uri + '&%s=%s' % (category.lower(), str(choice.key))
|
|
|
|
|
|
|
|
uri = uri + '&sourceType=%s' % sectionType
|
|
|
|
key = '/playlists%s' % utils.joinArgs({
|
|
|
|
'uri': uri,
|
|
|
|
'type': section.CONTENT_TYPE,
|
|
|
|
'title': title,
|
|
|
|
'smart': 1,
|
|
|
|
})
|
|
|
|
data = server.query(key, method=server._session.post)[0]
|
|
|
|
return cls(server, data, initpath=key)
|
|
|
|
|
2017-08-11 19:14:32 +00:00
|
|
|
def copyToUser(self, user):
|
|
|
|
""" Copy playlist to another user account. """
|
2017-07-17 14:11:03 +00:00
|
|
|
from plexapi.server import PlexServer
|
|
|
|
myplex = self._server.myPlexAccount()
|
|
|
|
user = myplex.user(user)
|
|
|
|
# Get the token for your machine.
|
|
|
|
token = user.get_token(self._server.machineIdentifier)
|
|
|
|
# Login to your server using your friends credentials.
|
|
|
|
user_server = PlexServer(self._server._baseurl, token)
|
|
|
|
return self.create(user_server, self.title, self.items())
|
2018-09-08 15:25:16 +00:00
|
|
|
|
|
|
|
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.
|
2020-11-23 03:06:30 +00:00
|
|
|
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
2018-09-08 15:25:16 +00:00
|
|
|
|
|
|
|
Parameters:
|
|
|
|
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
|
2020-11-23 03:06:30 +00:00
|
|
|
:mod:`~plexapi.sync` module. Used only when playlist contains video.
|
2018-09-08 15:25:16 +00:00
|
|
|
photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in
|
2020-11-23 03:06:30 +00:00
|
|
|
the module :mod:`~plexapi.sync`. Used only when playlist contains photos.
|
2018-09-14 18:03:23 +00:00
|
|
|
audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values
|
2020-11-23 03:06:30 +00:00
|
|
|
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`.
|
2018-09-08 15:25:16 +00:00
|
|
|
limit (int): maximum count of items to sync, unlimited if `None`.
|
|
|
|
unwatched (bool): if `True` watched videos wouldn't be synced.
|
2020-11-23 03:06:30 +00:00
|
|
|
title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be
|
2018-09-08 15:25:16 +00:00
|
|
|
generated from metadata of current photo.
|
|
|
|
|
|
|
|
Raises:
|
2021-01-03 00:44:18 +00:00
|
|
|
:exc:`~plexapi.exceptions.BadRequest`: When playlist is not allowed to sync.
|
|
|
|
:exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported.
|
2018-09-08 15:25:16 +00:00
|
|
|
|
|
|
|
Returns:
|
2020-11-23 03:06:30 +00:00
|
|
|
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
|
2018-09-08 15:25:16 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
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)
|