733 lines
26 KiB
Python
733 lines
26 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
# Copyright (C) 2023- The Tidalapi Developers
|
|
# Copyright (C) 2019-2022 morguldir
|
|
# Copyright (C) 2014 Thomas Amland
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Lesser General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Lesser General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Lesser General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
"""A module containing things related to TIDAL playlists."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING, List, Optional, Sequence, Union, cast
|
|
|
|
from tidalapi.exceptions import ObjectNotFound, TooManyRequests
|
|
from tidalapi.types import JsonObj
|
|
from tidalapi.user import LoggedInUser
|
|
|
|
if TYPE_CHECKING:
|
|
from tidalapi.artist import Artist
|
|
from tidalapi.media import Track, Video
|
|
from tidalapi.session import Session
|
|
from tidalapi.user import User
|
|
|
|
import dateutil.parser
|
|
|
|
|
|
def list_validate(lst):
|
|
if isinstance(lst, str):
|
|
lst = [lst]
|
|
if len(lst) == 0:
|
|
raise ValueError("An empty list was provided.")
|
|
return lst
|
|
|
|
|
|
class Playlist:
|
|
"""An object containing various data about a playlist and methods to work with
|
|
them."""
|
|
|
|
id: Optional[str] = None
|
|
trn: Optional[str] = None
|
|
name: Optional[str] = None
|
|
num_tracks: int = -1
|
|
num_videos: int = -1
|
|
creator: Optional[Union["Artist", "User"]] = None
|
|
description: Optional[str] = None
|
|
duration: int = -1
|
|
last_updated: Optional[datetime] = None
|
|
created: Optional[datetime] = None
|
|
type = None
|
|
public: Optional[bool] = False
|
|
popularity: Optional[int] = None
|
|
promoted_artists: Optional[List["Artist"]] = None
|
|
last_item_added_at: Optional[datetime] = None
|
|
picture: Optional[str] = None
|
|
square_picture: Optional[str] = None
|
|
user_date_added: Optional[datetime] = None
|
|
_etag: Optional[str] = None
|
|
|
|
# Direct URL to https://listen.tidal.com/playlist/<playlist_id>
|
|
listen_url: str = ""
|
|
# Direct URL to https://tidal.com/browse/playlist/<playlist_id>
|
|
share_url: str = ""
|
|
|
|
def __init__(self, session: "Session", playlist_id: Optional[str]):
|
|
self.id = playlist_id
|
|
self.session = session
|
|
self.request = session.request
|
|
self._base_url = "playlists/%s"
|
|
if playlist_id:
|
|
try:
|
|
request = self.request.request("GET", self._base_url % self.id)
|
|
except ObjectNotFound:
|
|
raise ObjectNotFound("Playlist not found")
|
|
except TooManyRequests:
|
|
raise TooManyRequests("Playlist unavailable")
|
|
else:
|
|
self._etag = request.headers["etag"]
|
|
self.parse(request.json())
|
|
|
|
def parse(self, json_obj: JsonObj) -> "Playlist":
|
|
"""Parses a playlist from tidal, replaces the current playlist object.
|
|
|
|
:param json_obj: Json data returned from api.tidal.com containing a playlist
|
|
:return: Returns a copy of the original :exc: 'Playlist': object
|
|
"""
|
|
self.id = json_obj["uuid"]
|
|
self.trn = f"trn:playlist:{self.id}"
|
|
self.name = json_obj["title"]
|
|
self.num_tracks = int(json_obj["numberOfTracks"])
|
|
self.num_videos = int(json_obj["numberOfVideos"])
|
|
self.description = json_obj["description"]
|
|
self.duration = int(json_obj["duration"])
|
|
|
|
# These can be missing on from the /pages endpoints
|
|
last_updated = json_obj.get("lastUpdated")
|
|
self.last_updated = (
|
|
dateutil.parser.isoparse(last_updated) if last_updated else None
|
|
)
|
|
created = json_obj.get("created")
|
|
self.created = dateutil.parser.isoparse(created) if created else None
|
|
public = json_obj.get("publicPlaylist")
|
|
self.public = None if public is None else bool(public)
|
|
popularity = json_obj.get("popularity")
|
|
self.popularity = int(popularity) if popularity else None
|
|
|
|
self.type = json_obj["type"]
|
|
self.picture = json_obj["image"]
|
|
self.square_picture = json_obj["squareImage"]
|
|
|
|
promoted_artists = json_obj["promotedArtists"]
|
|
self.promoted_artists = (
|
|
self.session.parse_artists(promoted_artists) if promoted_artists else None
|
|
)
|
|
|
|
last_item_added_at = json_obj.get("lastItemAddedAt")
|
|
self.last_item_added_at = (
|
|
dateutil.parser.isoparse(last_item_added_at) if last_item_added_at else None
|
|
)
|
|
|
|
user_date_added = json_obj.get("dateAdded")
|
|
self.user_date_added = (
|
|
dateutil.parser.isoparse(user_date_added) if user_date_added else None
|
|
)
|
|
|
|
creator = json_obj.get("creator")
|
|
if self.type == "ARTIST" and creator and creator.get("id"):
|
|
self.creator = self.session.parse_artist(creator)
|
|
else:
|
|
self.creator = self.session.parse_user(creator) if creator else None
|
|
|
|
self.listen_url = f"{self.session.config.listen_base_url}/playlist/{self.id}"
|
|
self.share_url = f"{self.session.config.share_base_url}/playlist/{self.id}"
|
|
|
|
return copy.copy(self)
|
|
|
|
def factory(self) -> Union["Playlist", "UserPlaylist"]:
|
|
if (
|
|
self.id
|
|
and self.creator
|
|
and isinstance(self.session.user, LoggedInUser)
|
|
and self.creator.id == self.session.user.id
|
|
):
|
|
return UserPlaylist(self.session, self.id)
|
|
|
|
return self
|
|
|
|
def parse_factory(self, json_obj: JsonObj) -> "Playlist":
|
|
self.parse(json_obj)
|
|
return copy.copy(self.factory())
|
|
|
|
def tracks(self, limit: Optional[int] = None, offset: int = 0) -> List["Track"]:
|
|
"""Gets the playlists' tracks from TIDAL.
|
|
|
|
:param limit: The amount of items you want returned.
|
|
:param offset: The index of the first item you want included.
|
|
:return: A list of :class:`Tracks <.Track>`
|
|
"""
|
|
params = {"limit": limit, "offset": offset}
|
|
request = self.request.request(
|
|
"GET", self._base_url % self.id + "/tracks", params=params
|
|
)
|
|
self._etag = request.headers["etag"]
|
|
return list(
|
|
self.request.map_json(
|
|
json_obj=request.json(), parse=self.session.parse_track
|
|
)
|
|
)
|
|
|
|
def items(self, limit: int = 100, offset: int = 0) -> List[Union["Track", "Video"]]:
|
|
"""Fetches up to the first 100 items, including tracks and videos.
|
|
|
|
:param limit: The amount of items you want, up to 100.
|
|
:param offset: The index of the first item you want returned
|
|
:return: A list of :class:`Tracks<.Track>` and :class:`Videos<.Video>`
|
|
"""
|
|
params = {"limit": limit, "offset": offset}
|
|
request = self.request.request(
|
|
"GET", self._base_url % self.id + "/items", params=params
|
|
)
|
|
self._etag = request.headers["etag"]
|
|
return list(
|
|
self.request.map_json(request.json(), parse=self.session.parse_media)
|
|
)
|
|
|
|
def image(self, dimensions: int = 480, wide_fallback: bool = True) -> str:
|
|
"""A URL to a playlist picture.
|
|
|
|
:param dimensions: The width and height that want from the image
|
|
:type dimensions: int
|
|
:param wide_fallback: Use wide image as fallback if no square cover picture exists
|
|
:type wide_fallback: bool
|
|
:return: A url to the image
|
|
|
|
Original sizes: 160x160, 320x320, 480x480, 640x640, 750x750, 1080x1080
|
|
"""
|
|
|
|
if dimensions not in [160, 320, 480, 640, 750, 1080]:
|
|
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))
|
|
if self.square_picture:
|
|
return self.session.config.image_url % (
|
|
self.square_picture.replace("-", "/"),
|
|
dimensions,
|
|
dimensions,
|
|
)
|
|
elif self.picture and wide_fallback:
|
|
return self.wide_image()
|
|
else:
|
|
raise AttributeError("No picture available")
|
|
|
|
def wide_image(self, width: int = 1080, height: int = 720) -> str:
|
|
"""Create a url to a wider playlist image.
|
|
|
|
:param width: The width of the image
|
|
:param height: The height of the image
|
|
:return: Returns a url to the image with the specified resolution
|
|
|
|
Valid sizes: 160x107, 480x320, 750x500, 1080x720
|
|
"""
|
|
|
|
if (width, height) not in [(160, 107), (480, 320), (750, 500), (1080, 720)]:
|
|
raise ValueError("Invalid resolution {} x {}".format(width, height))
|
|
if self.picture is None:
|
|
raise AttributeError("No picture available")
|
|
return self.session.config.image_url % (
|
|
self.picture.replace("-", "/"),
|
|
width,
|
|
height,
|
|
)
|
|
|
|
|
|
class Folder:
|
|
"""An object containing various data about a folder and methods to work with
|
|
them."""
|
|
|
|
trn: Optional[str] = None
|
|
id: Optional[str] = None
|
|
parent_folder_id: Optional[str] = None
|
|
name: Optional[str] = None
|
|
parent: Optional[str] = None # TODO Determine the correct type of the parent
|
|
added: Optional[datetime] = None
|
|
created: Optional[datetime] = None
|
|
last_modified: Optional[datetime] = None
|
|
total_number_of_items: int = 0
|
|
|
|
# Direct URL to https://listen.tidal.com/folder/<folder_id>
|
|
listen_url: str = ""
|
|
|
|
def __init__(
|
|
self,
|
|
session: "Session",
|
|
folder_id: Optional[str],
|
|
parent_folder_id: str = "root",
|
|
):
|
|
self.id = folder_id
|
|
self.parent_folder_id = parent_folder_id
|
|
self.session = session
|
|
self.request = session.request
|
|
self.playlist = session.playlist()
|
|
self._endpoint = "my-collection/playlists/folders"
|
|
if folder_id:
|
|
# Go through all available folders and see if the requested folder exists
|
|
try:
|
|
params = {
|
|
"folderId": parent_folder_id,
|
|
"offset": 0,
|
|
"limit": 50,
|
|
"order": "NAME",
|
|
"includeOnly": "FOLDER",
|
|
}
|
|
request = self.request.request(
|
|
"GET",
|
|
self._endpoint,
|
|
base_url=self.session.config.api_v2_location,
|
|
params=params,
|
|
)
|
|
for item in request.json().get("items"):
|
|
if item["data"].get("id") == folder_id:
|
|
self.parse(item)
|
|
return
|
|
raise ObjectNotFound
|
|
except ObjectNotFound:
|
|
raise ObjectNotFound(f"Folder not found")
|
|
except TooManyRequests:
|
|
raise TooManyRequests("Folder unavailable")
|
|
|
|
def _reparse(self) -> None:
|
|
params = {
|
|
"folderId": self.parent_folder_id,
|
|
"offset": 0,
|
|
"limit": 50,
|
|
"order": "NAME",
|
|
"includeOnly": "FOLDER",
|
|
}
|
|
request = self.request.request(
|
|
"GET",
|
|
self._endpoint,
|
|
base_url=self.session.config.api_v2_location,
|
|
params=params,
|
|
)
|
|
for item in request.json().get("items"):
|
|
if item["data"].get("id") == self.id:
|
|
self.parse(item)
|
|
return
|
|
|
|
def parse(self, json_obj: JsonObj) -> "Folder":
|
|
"""Parses a folder from tidal, replaces the current folder object.
|
|
|
|
:param json_obj: Json data returned from api.tidal.com containing a folder
|
|
:return: Returns a copy of the original :class:`Folder` object
|
|
"""
|
|
self.trn = json_obj.get("trn")
|
|
self.id = json_obj["data"].get("id")
|
|
self.name = json_obj.get("name")
|
|
self.parent = json_obj.get("parent")
|
|
added = json_obj.get("addedAt")
|
|
created = json_obj["data"].get("createdAt")
|
|
last_modified = json_obj["data"].get("lastModifiedAt")
|
|
self.added = dateutil.parser.isoparse(added) if added else None
|
|
self.created = dateutil.parser.isoparse(created) if added else None
|
|
self.last_modified = dateutil.parser.isoparse(last_modified) if added else None
|
|
self.total_number_of_items = json_obj["data"].get("totalNumberOfItems")
|
|
|
|
self.listen_url = f"{self.session.config.listen_base_url}/folder/{self.id}"
|
|
|
|
return copy.copy(self)
|
|
|
|
def rename(self, name: str) -> bool:
|
|
"""Rename the selected folder.
|
|
|
|
:param name: The name to be used for the folder
|
|
:return: True, if operation was successful.
|
|
"""
|
|
params = {"trn": self.trn, "name": name}
|
|
endpoint = "my-collection/playlists/folders/rename"
|
|
res = self.request.request(
|
|
"PUT",
|
|
endpoint,
|
|
base_url=self.session.config.api_v2_location,
|
|
params=params,
|
|
)
|
|
self._reparse()
|
|
return res.ok
|
|
|
|
def remove(self) -> bool:
|
|
"""Remove the selected folder.
|
|
|
|
:return: True, if operation was successful.
|
|
"""
|
|
params = {"trns": self.trn}
|
|
endpoint = "my-collection/playlists/folders/remove"
|
|
return self.request.request(
|
|
"PUT",
|
|
endpoint,
|
|
base_url=self.session.config.api_v2_location,
|
|
params=params,
|
|
).ok
|
|
|
|
def items(
|
|
self, offset: int = 0, limit: int = 50
|
|
) -> List[Union["Playlist", "UserPlaylist"]]:
|
|
"""Return the items in the folder.
|
|
|
|
:param offset: Optional; The index of the first item to be returned. Default: 0
|
|
:param limit: Optional; The amount of items you want returned. Default: 50
|
|
:return: Returns a list of :class:`Playlist` or :class:`UserPlaylist` objects
|
|
"""
|
|
params = {
|
|
"folderId": self.id,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"order": "NAME",
|
|
"includeOnly": "PLAYLIST",
|
|
}
|
|
endpoint = "my-collection/playlists/folders"
|
|
json_obj = self.request.request(
|
|
"GET",
|
|
endpoint,
|
|
base_url=self.session.config.api_v2_location,
|
|
params=params,
|
|
).json()
|
|
# Generate a dict of Playlist items from the response data
|
|
if json_obj.get("items"):
|
|
playlists = {"items": [item["data"] for item in json_obj.get("items")]}
|
|
return cast(
|
|
List[Union["Playlist", "UserPlaylist"]],
|
|
self.request.map_json(playlists, parse=self.playlist.parse_factory),
|
|
)
|
|
else:
|
|
return []
|
|
|
|
def add_items(self, trns: [str]):
|
|
"""Convenience method to add items to the current folder.
|
|
|
|
:param trns: List of playlist trns to be added to the current folder
|
|
:return: True, if operation was successful.
|
|
"""
|
|
self.move_items_to_folder(trns, self.id)
|
|
|
|
def move_items_to_root(self, trns: [str]):
|
|
"""Convenience method to move items from the current folder to the root folder.
|
|
|
|
:param trns: List of playlist trns to be moved from the current folder
|
|
:return: True, if operation was successful.
|
|
"""
|
|
self.move_items_to_folder(trns, folder="root")
|
|
|
|
def move_items_to_folder(self, trns: [str], folder: str = None):
|
|
"""Move item(s) in one folder to another folder.
|
|
|
|
:param trns: List of playlist trns to be moved.
|
|
:param folder: Destination folder. Default: Use the current folder
|
|
:return: True, if operation was successful.
|
|
"""
|
|
trns = list_validate(trns)
|
|
# Make sure all trns has the correct type prepended to it
|
|
trns_full = []
|
|
for trn in trns:
|
|
if "trn:" in trn:
|
|
trns_full.append(trn)
|
|
else:
|
|
trns_full.append(f"trn:playlist:{trn}")
|
|
if not folder:
|
|
folder = self.id
|
|
params = {"folderId": folder, "trns": ",".join(trns_full)}
|
|
endpoint = "my-collection/playlists/folders/move"
|
|
res = self.request.request(
|
|
"PUT",
|
|
endpoint,
|
|
base_url=self.session.config.api_v2_location,
|
|
params=params,
|
|
)
|
|
self._reparse()
|
|
return res.ok
|
|
|
|
|
|
class UserPlaylist(Playlist):
|
|
def _reparse(self) -> None:
|
|
"""Re-Read Playlist to get ETag."""
|
|
request = self.request.request("GET", self._base_url % self.id)
|
|
self._etag = request.headers["etag"]
|
|
self.request.map_json(request.json(), parse=self.parse)
|
|
|
|
def edit(
|
|
self, title: Optional[str] = None, description: Optional[str] = None
|
|
) -> bool:
|
|
"""Edit UserPlaylist title & description.
|
|
|
|
:param title: Playlist title
|
|
:param description: Playlist title.
|
|
:return: True, if successful.
|
|
"""
|
|
if not title:
|
|
title = self.name
|
|
if not description:
|
|
description = self.description
|
|
|
|
data = {"title": title, "description": description}
|
|
return self.request.request("POST", self._base_url % self.id, data=data).ok
|
|
|
|
def delete_by_id(self, media_ids: List[str]) -> bool:
|
|
"""Delete one or more items from the UserPlaylist.
|
|
|
|
:param media_ids: Lists of Media IDs to remove.
|
|
:return: True, if successful.
|
|
"""
|
|
media_ids = list_validate(media_ids)
|
|
# Generate list of track indices of tracks found in the list of media_ids.
|
|
track_ids = [str(track.id) for track in self.tracks()]
|
|
matching_indices = [i for i, item in enumerate(track_ids) if item in media_ids]
|
|
return self.remove_by_indices(matching_indices)
|
|
|
|
def add(
|
|
self,
|
|
media_ids: List[str],
|
|
allow_duplicates: bool = False,
|
|
position: int = -1,
|
|
limit: int = 100,
|
|
) -> List[int]:
|
|
"""Add one or more items to the UserPlaylist.
|
|
|
|
:param media_ids: List of Media IDs to add.
|
|
:param allow_duplicates: Allow adding duplicate items
|
|
:param position: Insert items at a specific position.
|
|
Default: insert at the end of the playlist
|
|
:param limit: Maximum number of items to add
|
|
:return: List of media IDs that has been added
|
|
"""
|
|
media_ids = list_validate(media_ids)
|
|
# Insert items at a specific index
|
|
if position < 0 or position > self.num_tracks:
|
|
position = self.num_tracks
|
|
data = {
|
|
"onArtifactNotFound": "SKIP",
|
|
"trackIds": ",".join(map(str, media_ids)),
|
|
"toIndex": position,
|
|
"onDupes": "ADD" if allow_duplicates else "SKIP",
|
|
}
|
|
params = {"limit": limit}
|
|
headers = {"If-None-Match": self._etag} if self._etag else None
|
|
res = self.request.request(
|
|
"POST",
|
|
self._base_url % self.id + "/items",
|
|
params=params,
|
|
data=data,
|
|
headers=headers,
|
|
)
|
|
self._reparse()
|
|
# Respond with the added item IDs:
|
|
added_items = res.json().get("addedItemIds")
|
|
if added_items:
|
|
return added_items
|
|
else:
|
|
return []
|
|
|
|
def merge(
|
|
self, playlist: str, allow_duplicates: bool = False, allow_missing: bool = True
|
|
) -> List[int]:
|
|
"""Add (merge) items from a playlist with the current playlist.
|
|
|
|
:param playlist: Playlist UUID to be merged in the current playlist
|
|
:param allow_duplicates: If true, duplicate tracks are allowed. Otherwise,
|
|
tracks will be skipped.
|
|
:param allow_missing: If true, missing tracks are allowed. Otherwise, exception
|
|
will be thrown
|
|
:return: List of items that has been added from the playlist
|
|
"""
|
|
data = {
|
|
"fromPlaylistUuid": str(playlist),
|
|
"onArtifactNotFound": "SKIP" if allow_missing else "FAIL",
|
|
"onDupes": "ADD" if allow_duplicates else "SKIP",
|
|
}
|
|
headers = {"If-None-Match": self._etag} if self._etag else None
|
|
res = self.request.request(
|
|
"POST",
|
|
self._base_url % self.id + "/items",
|
|
data=data,
|
|
headers=headers,
|
|
)
|
|
self._reparse()
|
|
# Respond with the added item IDs:
|
|
added_items = res.json().get("addedItemIds")
|
|
if added_items:
|
|
return added_items
|
|
else:
|
|
return []
|
|
|
|
def add_by_isrc(
|
|
self,
|
|
isrc: str,
|
|
allow_duplicates: bool = False,
|
|
position: int = -1,
|
|
) -> bool:
|
|
"""Add an item to a playlist, using the track ISRC.
|
|
|
|
:param isrc: The ISRC of the track to be added
|
|
:param allow_duplicates: Allow adding duplicate items
|
|
:param position: Insert items at a specific position.
|
|
Default: insert at the end of the playlist
|
|
:return: True, if successful.
|
|
"""
|
|
if not isinstance(isrc, str):
|
|
isrc = str(isrc)
|
|
try:
|
|
track = self.session.get_tracks_by_isrc(isrc)
|
|
if track:
|
|
# Add the first track in the list
|
|
track_id = track[0].id
|
|
added = self.add(
|
|
[str(track_id)],
|
|
allow_duplicates=allow_duplicates,
|
|
position=position,
|
|
)
|
|
if track_id in added:
|
|
return True
|
|
else:
|
|
return False
|
|
else:
|
|
return False
|
|
except ObjectNotFound:
|
|
return False
|
|
|
|
def move_by_id(self, media_id: str, position: int) -> bool:
|
|
"""Move an item to a new position, by media ID.
|
|
|
|
:param media_id: The index of the item to be moved
|
|
:param position: The new position of the item
|
|
:return: True, if successful.
|
|
"""
|
|
if not isinstance(media_id, str):
|
|
media_id = str(media_id)
|
|
track_ids = [str(track.id) for track in self.tracks()]
|
|
try:
|
|
index = track_ids.index(media_id)
|
|
if index is not None and index < self.num_tracks:
|
|
return self.move_by_indices([index], position)
|
|
except ValueError:
|
|
return False
|
|
|
|
def move_by_index(self, index: int, position: int) -> bool:
|
|
"""Move a single item to a new position.
|
|
|
|
:param index: The index of the item to be moved
|
|
:param position: The new position/offset of the item
|
|
:return: True, if successful.
|
|
"""
|
|
if not isinstance(index, int):
|
|
raise ValueError
|
|
return self.move_by_indices([index], position)
|
|
|
|
def move_by_indices(self, indices: Sequence[int], position: int) -> bool:
|
|
"""Move one or more items to a new position.
|
|
|
|
:param indices: List containing indices to move.
|
|
:param position: The new position/offset of the item(s)
|
|
:return: True, if successful.
|
|
"""
|
|
# Move item to a new position
|
|
if position < 0 or position >= self.num_tracks:
|
|
position = self.num_tracks
|
|
data = {
|
|
"toIndex": position,
|
|
}
|
|
headers = {"If-None-Match": self._etag} if self._etag else None
|
|
track_index_string = ",".join([str(x) for x in indices])
|
|
res = self.request.request(
|
|
"POST",
|
|
(self._base_url + "/items/%s") % (self.id, track_index_string),
|
|
data=data,
|
|
headers=headers,
|
|
)
|
|
self._reparse()
|
|
return res.ok
|
|
|
|
def remove_by_id(self, media_id: str) -> bool:
|
|
"""Remove a single item from the playlist, using the media ID.
|
|
|
|
:param media_id: Media ID to remove.
|
|
:return: True, if successful.
|
|
"""
|
|
if not isinstance(media_id, str):
|
|
media_id = str(media_id)
|
|
track_ids = [str(track.id) for track in self.tracks()]
|
|
try:
|
|
index = track_ids.index(media_id)
|
|
if index is not None and index < self.num_tracks:
|
|
return self.remove_by_index(index)
|
|
except ValueError:
|
|
return False
|
|
|
|
def remove_by_index(self, index: int) -> bool:
|
|
"""Remove a single item from the UserPlaylist, using item index.
|
|
|
|
:param index: Media index to remove
|
|
:return: True, if successful.
|
|
"""
|
|
return self.remove_by_indices([index])
|
|
|
|
def remove_by_indices(self, indices: Sequence[int]) -> bool:
|
|
"""Remove one or more items from the UserPlaylist, using list of indices.
|
|
|
|
:param indices: List containing indices to remove.
|
|
:return: True, if successful.
|
|
"""
|
|
headers = {"If-None-Match": self._etag} if self._etag else None
|
|
track_index_string = ",".join([str(x) for x in indices])
|
|
res = self.request.request(
|
|
"DELETE",
|
|
(self._base_url + "/items/%s") % (self.id, track_index_string),
|
|
headers=headers,
|
|
)
|
|
self._reparse()
|
|
return res.ok
|
|
|
|
def clear(self, chunk_size: int = 50) -> bool:
|
|
"""Clear UserPlaylist.
|
|
|
|
:param chunk_size: Number of items to remove per request
|
|
:return: True, if successful.
|
|
"""
|
|
while self.num_tracks:
|
|
indices = range(min(self.num_tracks, chunk_size))
|
|
if not self.remove_by_indices(indices):
|
|
return False
|
|
return True
|
|
|
|
def set_playlist_public(self):
|
|
"""Set UserPlaylist as Public.
|
|
|
|
:return: True, if successful.
|
|
"""
|
|
res = self.request.request(
|
|
"PUT",
|
|
base_url=self.session.config.api_v2_location,
|
|
path=(self._base_url + "/set-public") % self.id,
|
|
)
|
|
self.public = True
|
|
self._reparse()
|
|
return res.ok
|
|
|
|
def set_playlist_private(self):
|
|
"""Set UserPlaylist as Private.
|
|
|
|
:return: True, if successful.
|
|
"""
|
|
res = self.request.request(
|
|
"PUT",
|
|
base_url=self.session.config.api_v2_location,
|
|
path=(self._base_url + "/set-private") % self.id,
|
|
)
|
|
self.public = False
|
|
self._reparse()
|
|
return res.ok
|
|
|
|
def delete(self) -> bool:
|
|
"""Delete UserPlaylist.
|
|
|
|
:return: True, if successful.
|
|
"""
|
|
return self.request.request("DELETE", path="playlists/%s" % self.id).ok
|