python-plexapi/plexapi/playlist.py

467 lines
20 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
import re
from urllib.parse import quote_plus, unquote
2020-05-12 21:15:16 +00:00
from plexapi import media, 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
from plexapi.mixins import ArtMixin, PosterMixin, SmartFilterMixin
2017-02-07 06:58:29 +00:00
from plexapi.playqueue import PlayQueue
from plexapi.utils import deprecated
2016-02-03 18:07:53 +00:00
@utils.registerPlexObject
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin):
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>)
content (str): The filter URI string for smart playlists.
2020-12-24 06:25:10 +00:00
duration (int): Duration of the playlist in milliseconds.
durationInSeconds (int): Duration of the playlist in seconds.
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
2020-12-24 06:25:10 +00:00
guid (str): Plex GUID for the playlist (com.plexapp.agents.none://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX).
icon (str): Icon URI string for smart playlists.
2020-12-24 06:25:10 +00:00
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.
"""
TAG = 'Playlist'
2016-02-03 18:07:53 +00:00
TYPE = 'playlist'
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Playable._loadData(self, data)
2021-05-27 02:45:12 +00:00
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
2017-02-04 17:43:50 +00:00
self.composite = data.attrib.get('composite') # url to thumbnail
self.content = data.attrib.get('content')
2021-05-27 02:45:12 +00:00
self.duration = utils.cast(int, data.attrib.get('duration'))
self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds'))
self.fields = self.findItems(data, media.Field)
2017-02-04 17:43:50 +00:00
self.guid = data.attrib.get('guid')
self.icon = data.attrib.get('icon')
2020-12-24 06:25:10 +00:00
self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50
2021-05-27 02:45:12 +00:00
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
2017-02-04 17:43:50 +00:00
self.playlistType = data.attrib.get('playlistType')
2021-05-27 02:45:12 +00:00
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.smart = utils.cast(bool, data.attrib.get('smart'))
2017-02-04 17:43:50 +00:00
self.summary = data.attrib.get('summary')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
2021-05-27 02:45:12 +00:00
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
2017-10-09 13:58:44 +00:00
self._items = None # cache for self.items
self._section = None # cache for self.section
self._filters = None # cache for self.filters
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())
def __iter__(self): # pragma: no cover
for item in self.items():
yield item
2021-05-28 03:09:49 +00:00
def __contains__(self, other): # pragma: no cover
return any(i.key == other.key for i in self.items())
def __getitem__(self, key): # pragma: no cover
return self.items()[key]
2021-02-15 03:58:03 +00:00
@property
def thumb(self):
""" Alias to self.composite. """
return self.composite
2018-09-08 15:25:16 +00:00
@property
def metadataType(self):
2021-05-28 05:59:14 +00:00
""" Returns the type of metadata in the playlist (movie, track, or photo). """
2018-09-08 15:25:16 +00:00
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):
2021-05-28 05:59:14 +00:00
""" Returns True if this is a video playlist. """
2018-09-08 15:25:16 +00:00
return self.playlistType == 'video'
@property
def isAudio(self):
2021-05-28 05:59:14 +00:00
""" Returns True if this is an audio playlist. """
2018-09-08 15:25:16 +00:00
return self.playlistType == 'audio'
@property
def isPhoto(self):
2021-05-28 05:59:14 +00:00
""" Returns True if this is a photo playlist. """
2018-09-08 15:25:16 +00:00
return self.playlistType == 'photo'
def _getPlaylistItemID(self, item):
""" Match an item to a playlist item and return the item playlistItemID. """
for _item in self.items():
if _item.ratingKey == item.ratingKey:
return _item.playlistItemID
raise NotFound('Item with title "%s" not found in the playlist' % item.title)
def filters(self):
""" Returns the search filter dict for smart playlist.
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
to get the list of items.
"""
if self.smart and self._filters is None:
self._filters = self._parseFilters(self.content)
return self._filters
def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to.
2017-10-05 20:24:49 +00:00
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to get the section for a regular playlist.
:class:`plexapi.exceptions.Unsupported`: When unable to determine the library section.
"""
if not self.smart:
raise BadRequest('Regular playlists are not associated with a library.')
if self._section is None:
# Try to parse the library section from the content URI string
match = re.search(r'/library/sections/(\d+)/all', unquote(self.content or ''))
if match:
sectionKey = int(match.group(1))
self._section = self._server.library.sectionByID(sectionKey)
return self._section
# Try to get the library section from the first item in the playlist
if self.items():
self._section = self.items()[0].section()
return self._section
raise Unsupported('Unable to determine the library section')
return self._section
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.
2021-05-28 05:59:14 +00:00
Raises:
:class:`plexapi.exceptions.NotFound`: When the item is not found in the playlist.
2020-12-24 06:32:48 +00:00
"""
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
def items(self):
""" Returns a list of all items in the playlist. """
2017-10-05 20:24:49 +00:00
if self._items is None:
key = '%s/items' % self.key
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):
""" Add items to the playlist.
Parameters:
2021-05-30 00:58:55 +00:00
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to be added to the playlist.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to add items to a smart playlist.
"""
if self.smart:
raise BadRequest('Cannot add items to a smart playlist.')
if items and not isinstance(items, (list, tuple)):
2016-04-11 03:49:23 +00:00
items = [items]
2016-04-11 03:49:23 +00:00
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))
2017-02-02 14:09:34 +00:00
ratingKeys = ','.join(ratingKeys)
2021-05-30 01:22:27 +00:00
uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys)
key = '%s/items%s' % (self.key, utils.joinArgs({
'uri': uri
2016-04-11 03:49:23 +00:00
}))
self._server.query(key, method=self._server._session.put)
2016-04-11 03:49:23 +00:00
@deprecated('use "removeItems" instead', stacklevel=3)
2016-04-11 03:49:23 +00:00
def removeItem(self, item):
self.removeItems(item)
def removeItems(self, items):
""" Remove items from the playlist.
Parameters:
2021-05-30 00:58:55 +00:00
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to be removed from the playlist.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to remove items from a smart playlist.
:class:`plexapi.exceptions.NotFound`: When the item does not exist in the playlist.
"""
if self.smart:
raise BadRequest('Cannot remove items from a smart playlist.')
if items and not isinstance(items, (list, tuple)):
items = [items]
for item in items:
playlistItemID = self._getPlaylistItemID(item)
key = '%s/items/%s' % (self.key, playlistItemID)
self._server.query(key, method=self._server._session.delete)
2016-04-11 03:49:23 +00:00
def moveItem(self, item, after=None):
""" Move an item to a new position in the playlist.
Parameters:
2021-05-30 00:58:55 +00:00
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to be moved in the playlist.
after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to move the item after in the playlist.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart playlist.
:class:`plexapi.exceptions.NotFound`: When the item or item after does not exist in the playlist.
"""
if self.smart:
raise BadRequest('Cannot move items in a smart playlist.')
playlistItemID = self._getPlaylistItemID(item)
key = '%s/items/%s/move' % (self.key, playlistItemID)
2017-01-02 21:06:40 +00:00
if after:
afterPlaylistItemID = self._getPlaylistItemID(after)
key += '?after=%s' % afterPlaylistItemID
self._server.query(key, method=self._server._session.put)
def updateFilters(self, limit=None, sort=None, filters=None, **kwargs):
""" Update the filters for a smart playlist.
Parameters:
limit (int): Limit the number of items in the playlist.
sort (str or list, optional): A string of comma separated sort fields
or a list of sort fields in the format ``column:dir``.
2021-05-30 00:58:55 +00:00
See :func:`~plexapi.library.LibrarySection.search` for more info.
filters (dict): A dictionary of advanced filters.
2021-05-30 00:58:55 +00:00
See :func:`~plexapi.library.LibrarySection.search` for more info.
**kwargs (dict): Additional custom filters to apply to the search results.
2021-05-30 00:58:55 +00:00
See :func:`~plexapi.library.LibrarySection.search` for more info.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying update filters for a regular playlist.
"""
if not self.smart:
raise BadRequest('Cannot update filters for a regular playlist.')
section = self.section()
searchKey = section._buildSearchKey(
sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs)
2021-05-30 01:22:27 +00:00
uri = '%s%s' % (self._server._uriRoot(), searchKey)
key = '%s/items%s' % (self.key, utils.joinArgs({
'uri': uri
}))
self._server.query(key, method=self._server._session.put)
2016-12-21 13:17:28 +00:00
def _edit(self, **kwargs):
""" Actually edit the playlist. """
key = '%s%s' % (self.key, utils.joinArgs(kwargs))
self._server.query(key, method=self._server._session.put)
2016-04-11 03:49:23 +00:00
def edit(self, title=None, summary=None):
""" Edit the playlist.
Parameters:
title (str, optional): The title of the playlist.
summary (str, optional): The summary of the playlist.
"""
args = {}
2021-05-30 00:58:55 +00:00
if title:
args['title'] = title
2021-05-30 00:58:55 +00:00
if summary:
args['summary'] = summary
self._edit(**args)
2016-12-21 13:17:28 +00:00
2016-04-11 03:49:23 +00:00
def delete(self):
""" Delete the playlist. """
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):
""" Returns a new :class:`~plexapi.playqueue.PlayQueue` from the playlist. """
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):
""" Create a regular playlist. """
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]
listType = items[0].listType
2016-04-11 03:49:23 +00:00
ratingKeys = []
for item in items:
if item.listType != listType: # pragma: no cover
raise BadRequest('Can not mix media types when building a playlist.')
2016-12-21 13:17:28 +00:00
ratingKeys.append(str(item.ratingKey))
ratingKeys = ','.join(ratingKeys)
2021-05-30 01:22:27 +00:00
uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys)
key = '/playlists%s' % utils.joinArgs({
'uri': uri,
'type': listType,
2016-04-11 03:49:23 +00:00
'title': title,
'smart': 0
})
data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key)
2017-07-17 14:11:03 +00:00
2018-11-16 22:47:49 +00:00
@classmethod
def _createSmart(cls, server, title, section, limit=None, libtype=None, sort=None, filters=None, **kwargs):
""" Create a smart playlist. """
2018-11-16 22:47:49 +00:00
if not isinstance(section, LibrarySection):
section = server.library.section(section)
libtype = libtype or section.METADATA_TYPE
searchKey = section._buildSearchKey(
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs)
2021-05-30 01:22:27 +00:00
uri = '%s%s' % (server._uriRoot(), searchKey)
2018-11-16 22:47:49 +00:00
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)
@classmethod
def create(cls, server, title, section=None, items=None, smart=False, limit=None,
libtype=None, sort=None, filters=None, **kwargs):
""" Create a playlist.
Parameters:
server (:class:`~plexapi.server.PlexServer`): Server to create the playlist on.
title (str): Title of the playlist.
section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists only,
the library section to create the playlist in.
2021-05-30 00:58:55 +00:00
items (List): Regular playlists only, list of :class:`~plexapi.audio.Audio`,
:class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the playlist.
smart (bool): True to create a smart playlist. Default False.
limit (int): Smart playlists only, limit the number of items in the playlist.
libtype (str): Smart playlists only, the specific type of content to filter
(movie, show, season, episode, artist, album, track, photoalbum, photo).
sort (str or list, optional): Smart playlists only, a string of comma separated sort fields
or a list of sort fields in the format ``column:dir``.
2021-05-30 00:58:55 +00:00
See :func:`~plexapi.library.LibrarySection.search` for more info.
filters (dict): Smart playlists only, a dictionary of advanced filters.
2021-05-30 00:58:55 +00:00
See :func:`~plexapi.library.LibrarySection.search` for more info.
**kwargs (dict): Smart playlists only, additional custom filters to apply to the
2021-05-30 00:58:55 +00:00
search results. See :func:`~plexapi.library.LibrarySection.search` for more info.
Raises:
:class:`plexapi.exceptions.BadRequest`: When no items are included to create the playlist.
:class:`plexapi.exceptions.BadRequest`: When mixing media types in the playlist.
Returns:
:class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist.
"""
if smart:
return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs)
else:
return cls._create(server, title, items)
def copyToUser(self, user):
""" Copy playlist to another user account.
Parameters:
user (str): Username, email or user id of the user to copy the playlist to.
"""
userServer = self._server.switchUser(user)
return self.create(server=userServer, title=self.title, items=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 the playlist as a sync item for the 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.
Improvements in tests process (#297) * lets begin * skip plexpass tests if there is not plexpass on account * test new myplex attrubutes * bootstrap: proper photos organisation * fix rest of photos tests * fix myplex new attributes test * fix music bootstrap by setting agent to lastfm * fix sync tests * increase bootstrap timeout * remove timeout from .travis.yml * do not create playlist-style photoalbums in plex-bootstraptest.py * allow negative filtering in LibrarySection.search() * fix sync tests once again * use sendCrashReports in test_settings * fix test_settings * fix test_video * do not accept eula in bootstrap * fix PlexServer.isLatest() * add test against old version of PlexServer * fix MyPlexAccount.OutOut * add flag for one-time testing in Travis * fix test_library onDeck tests * fix more tests * use tqdm in plex-bootstraptest for media scanning progress * create sections one-by-one * update docs on AlertListener for timeline entries * fix plex-bootstraptest for server version 1.3.2 * display skip/xpass/xfail reasons * fix tests on 1.3 * wait for music to be fully processed in plex-bootstraptest * fix misplaced TEST_ACCOUNT_ONCE * fix test_myplex_users, not sure if in proper-way * add pytest-rerunfailures; mark test_myplex_optout as flaky * fix comment * Revert "add pytest-rerunfailures; mark test_myplex_optout as flaky" This reverts commit 580e4c95a758c92329d757eb2f3fc3bf44b26f09. * restart plex container on failure * add conftest.wait_until() and used where some retries are required * add more wait_until() usage in test_sync * fix managed user search * fix updating managed users in myplex * allow to add new servers to existent users * add new server to a shared user while bootstrapping * add some docs on testing process * perform few attemps when unable to get the claim token * unlock websocket-client in requirements_dev * fix docblock in tools/plex-teardowntest * do not hardcode mediapart size in test_video * remove cache:pip from travis * Revert "unlock websocket-client in requirements_dev" This reverts commit 0d536bd06dbdc4a4b869a1686f8cd008898859fe. * remove debug from server.py * improve webhook tests * fix type() check to isinstance() * remove excessive `else` branch due to Hellowlol advice * add `unknown` as allowed `myPlexMappingState` in test_server
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:
2021-05-30 00:58:55 +00:00
:class:`~plexapi.sync.SyncItem`: A new instance of the created sync item.
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)