second commit

This commit is contained in:
2024-12-27 22:31:23 +09:00
parent 2353324570
commit 10a0f110ca
8819 changed files with 1307198 additions and 28 deletions

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2023- The Tidalapi Developers
from .album import Album # noqa: F401
from .artist import Artist, Role # noqa: F401
from .genre import Genre # noqa: F401
from .media import Quality, Track, Video, VideoQuality # noqa: F401
from .mix import Mix, MixV2 # noqa: F401
from .page import Page # noqa: F401
from .playlist import Playlist, UserPlaylist # noqa: F401
from .request import Requests # noqa: F401
from .session import Config, Session # noqa: F401
from .user import ( # noqa: F401
Favorites,
FetchedUser,
LoggedInUser,
PlaylistCreator,
User,
)
__version__ = "0.8.2"

View File

@ -0,0 +1,345 @@
# -*- 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/>.
import copy
import functools
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional, Union, cast
import dateutil.parser
from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound, TooManyRequests
from tidalapi.types import JsonObj
if TYPE_CHECKING:
from tidalapi.artist import Artist
from tidalapi.media import Track, Video
from tidalapi.page import Page
from tidalapi.session import Session
DEFAULT_ALBUM_IMG = "0dfd3368-3aa1-49a3-935f-10ffb39803c0"
class Album:
"""Contains information about a TIDAL album.
If the album is created from a media object, this object will only contain the id,
name, cover and video cover. TIDAL does this to reduce the network load.
"""
id: Optional[int] = -1
name: Optional[str] = None
cover = None
video_cover = None
type = None
duration: Optional[int] = -1
available: Optional[bool] = False
ad_supported_ready: Optional[bool] = False
dj_ready: Optional[bool] = False
allow_streaming: Optional[bool] = False
premium_streaming_only: Optional[bool] = False
num_tracks: Optional[int] = -1
num_videos: Optional[int] = -1
num_volumes: Optional[int] = -1
tidal_release_date: Optional[datetime] = None
release_date: Optional[datetime] = None
copyright = None
version = None
explicit: Optional[bool] = True
universal_product_number: Optional[int] = -1
popularity: Optional[int] = -1
user_date_added: Optional[datetime] = None
audio_quality: Optional[str] = ""
audio_modes: Optional[List[str]] = [""]
media_metadata_tags: Optional[List[str]] = [""]
artist: Optional["Artist"] = None
artists: Optional[List["Artist"]] = None
# Direct URL to https://listen.tidal.com/album/<album_id>
listen_url: str = ""
# Direct URL to https://tidal.com/browse/album/<album_id>
share_url: str = ""
def __init__(self, session: "Session", album_id: Optional[str]):
self.session = session
self.request = session.request
self.artist = session.artist()
self.id = album_id
if self.id:
try:
request = self.request.request("GET", "albums/%s" % self.id)
except ObjectNotFound:
raise ObjectNotFound("Album not found")
except TooManyRequests:
raise TooManyRequests("Album unavailable")
else:
self.request.map_json(request.json(), parse=self.parse)
def parse(
self,
json_obj: JsonObj,
artist: Optional["Artist"] = None,
artists: Optional[List["Artist"]] = None,
) -> "Album":
if artists is None:
artists = self.session.parse_artists(json_obj["artists"])
# Sometimes the artist field is not filled, an example is 140196345
if "artist" not in json_obj:
artist = artists[0]
elif artist is None:
artist = self.session.parse_artist(json_obj["artist"])
self.id = json_obj["id"]
self.name = json_obj["title"]
self.cover = json_obj["cover"]
self.video_cover = json_obj["videoCover"]
self.duration = json_obj.get("duration")
self.available = json_obj.get("streamReady")
self.ad_supported_ready = json_obj.get("adSupportedStreamReady")
self.dj_ready = json_obj.get("djReady")
self.allow_streaming = json_obj.get("allowStreaming")
self.premium_streaming_only = json_obj.get("premiumStreamingOnly")
self.num_tracks = json_obj.get("numberOfTracks")
self.num_videos = json_obj.get("numberOfVideos")
self.num_volumes = json_obj.get("numberOfVolumes")
self.copyright = json_obj.get("copyright")
self.version = json_obj.get("version")
self.explicit = json_obj.get("explicit")
self.universal_product_number = json_obj.get("upc")
self.popularity = json_obj.get("popularity")
self.type = json_obj.get("type")
# Certain fields may not be available
self.audio_quality = json_obj.get("audioQuality")
self.audio_modes = json_obj.get("audioModes")
if "mediaMetadata" in json_obj:
self.media_metadata_tags = json_obj.get("mediaMetadata")["tags"]
self.artist = artist
self.artists = artists
release_date = json_obj.get("releaseDate")
self.release_date = (
dateutil.parser.isoparse(release_date) if release_date else None
)
tidal_release_date = json_obj.get("streamStartDate")
self.tidal_release_date = (
dateutil.parser.isoparse(tidal_release_date) if tidal_release_date 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
)
self.listen_url = f"{self.session.config.listen_base_url}/album/{self.id}"
self.share_url = f"{self.session.config.share_base_url}/album/{self.id}"
return copy.copy(self)
@property
def year(self) -> Optional[int]:
"""Get the year using :class:`available_release_date`
:return: An :any:`python:int` containing the year the track was released
"""
return self.available_release_date.year if self.available_release_date else None
@property
def available_release_date(self) -> Optional[datetime]:
"""Get the release date if it's available, otherwise get the day it was released
on TIDAL.
:return: A :any:`python:datetime.datetime` object with the release date, or the
tidal release date, can be None
"""
if self.release_date:
return self.release_date
if self.tidal_release_date:
return self.tidal_release_date
return None
def tracks(
self,
limit: Optional[int] = None,
offset: int = 0,
sparse_album: bool = False,
) -> List["Track"]:
"""Returns the tracks in classes album.
:param limit: The amount of items you want returned.
:param offset: The position of the first item you want to include.
:param sparse_album: Provide a sparse track.album, containing only essential attributes from track JSON
False: Populate the track.album attributes from the parent Album object (self)
:return: A list of the :class:`Tracks <.Track>` in the album.
"""
params = {"limit": limit, "offset": offset}
if sparse_album:
parse_track_callable = self.session.parse_track
else:
# Parse tracks attributes but provide the Album object directly from self
parse_track_callable = functools.partial(
self.session.parse_track, album=self
)
tracks = self.request.map_request(
"albums/%s/tracks" % self.id, params, parse=parse_track_callable
)
assert isinstance(tracks, list)
return cast(List["Track"], tracks)
def items(
self, limit: int = 100, offset: int = 0, sparse_album: bool = False
) -> List[Union["Track", "Video"]]:
"""Gets the first 'limit' tracks and videos in the album from TIDAL.
:param limit: The number of items you want to retrieve
:param offset: The index you want to start retrieving items from
:param sparse_album: Provide a sparse track.album, containing only essential attributes from track JSON
False: Populate the track.album attributes from the parent Album object (self)
:return: A list of :class:`Tracks<.Track>` and :class:`Videos`<.Video>`
"""
params = {"offset": offset, "limit": limit}
if sparse_album:
parse_media_callable = self.session.parse_media
else:
# Parse tracks attributes but provide the Album object directly from self
parse_media_callable = functools.partial(
self.session.parse_media, album=self
)
items = self.request.map_request(
"albums/%s/items" % self.id, params=params, parse=parse_media_callable
)
assert isinstance(items, list)
return cast(List[Union["Track", "Video"]], items)
def image(self, dimensions: int = 320, default: str = DEFAULT_ALBUM_IMG) -> str:
"""A url to an album image cover.
:param dimensions: The width and height that you want from the image
:param default: An optional default image to serve if one is not available
:return: A url to the image.
Valid resolutions: 80x80, 160x160, 320x320, 640x640, 1280x1280
"""
if dimensions not in [80, 160, 320, 640, 1280]:
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))
if not self.cover:
return self.session.config.image_url % (
default.replace("-", "/"),
dimensions,
dimensions,
)
else:
return self.session.config.image_url % (
self.cover.replace("-", "/"),
dimensions,
dimensions,
)
def video(self, dimensions: int) -> str:
"""Creates a url to an mp4 video cover for the album.
Valid resolutions: 80x80, 160x160, 320x320, 640x640, 1280x1280
:param dimensions: The width an height of the video
:type dimensions: int
:return: A url to an mp4 of the video cover.
"""
if not self.video_cover:
raise AttributeError("This album does not have a video cover.")
if dimensions not in [80, 160, 320, 640, 1280]:
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))
return self.session.config.video_url % (
self.video_cover.replace("-", "/"),
dimensions,
dimensions,
)
def page(self) -> "Page":
"""
Retrieve the album page as seen on https://listen.tidal.com/album/$id
:return: A :class:`Page` containing the different categories, e.g. similar artists and albums
"""
return self.session.page.get("pages/album", params={"albumId": self.id})
def similar(self) -> List["Album"]:
"""Retrieve albums similar to the current one. MetadataNotAvailable is raised,
when no similar albums exist.
:return: A :any:`list` of similar albums
"""
try:
request = self.request.request("GET", "albums/%s/similar" % self.id)
except ObjectNotFound:
raise MetadataNotAvailable("No similar albums exist for this album")
except TooManyRequests:
raise TooManyRequests("Similar artists unavailable")
else:
albums = self.request.map_json(
request.json(), parse=self.session.parse_album
)
assert isinstance(albums, list)
return cast(List["Album"], albums)
def review(self) -> str:
"""Retrieve the album review.
:return: A :class:`str` containing the album review, with wimp links
:raises: :class:`requests.HTTPError` if there isn't a review yet
"""
review = self.request.request("GET", "albums/%s/review" % self.id).json()[
"text"
]
assert isinstance(review, str)
return review
def get_audio_resolution(self, individual_tracks: bool = False) -> [[int, int]]:
"""Retrieve the audio resolution (bit rate + sample rate) for the album track(s)
This function assumes that all album tracks use the same audio resolution.
Some albums may consist of tracks with multiple audio resolution(s).
The audio resolution can therefore be fetched for individual tracks by setting the `all_tracks` argument accordingly.
WARNING: For individual tracks, many additional requests are needed. Handle with care!
:param individual_tracks: Fetch individual track resolutions
:type individual_tracks: bool
:return: A :class:`tuple` containing the (bit_rate, sample_rate) for one or more tracks
"""
if individual_tracks:
# Return for individual tracks
return [res.get_stream().get_audio_resolution() for res in self.tracks()]
else:
# Return for first track only
return [self.tracks()[0].get_stream().get_audio_resolution()]

View File

@ -0,0 +1,294 @@
# -*- 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 information and functions related to TIDAL artists."""
import copy
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, List, Mapping, Optional, Union, cast
from warnings import warn
import dateutil.parser
from typing_extensions import NoReturn
from tidalapi.exceptions import ObjectNotFound, TooManyRequests
from tidalapi.types import JsonObj
if TYPE_CHECKING:
from tidalapi.album import Album
from tidalapi.media import Track, Video
from tidalapi.page import Page
from tidalapi.session import Session
DEFAULT_ARTIST_IMG = "1e01cdb6-f15d-4d8b-8440-a047976c1cac"
class Artist:
id: Optional[int] = -1
name: Optional[str] = None
roles: Optional[List["Role"]] = None
role: Optional["Role"] = None
picture: Optional[str] = None
user_date_added: Optional[datetime] = None
bio: Optional[str] = None
# Direct URL to https://listen.tidal.com/artist/<artist_id>
listen_url: str = ""
# Direct URL to https://tidal.com/browse/artist/<artist_id>
share_url: str = ""
def __init__(self, session: "Session", artist_id: Optional[str]):
"""Initialize the :class:`Artist` object, given a TIDAL artist ID :param
session: The current TIDAL :class:`Session` :param str artist_id: TIDAL artist
ID :raises: Raises :class:`exceptions.ObjectNotFound`"""
self.session = session
self.request = self.session.request
self.id = artist_id
if self.id:
try:
request = self.request.request("GET", "artists/%s" % self.id)
except ObjectNotFound:
raise ObjectNotFound("Artist not found")
except TooManyRequests:
raise TooManyRequests("Artist unavailable")
else:
self.request.map_json(request.json(), parse=self.parse_artist)
def parse_artist(self, json_obj: JsonObj) -> "Artist":
"""Parses a TIDAL artist, replaces the current :class:`Artist` object. Made for
use within the python tidalapi module.
:param json_obj: :class:`JsonObj` containing the artist metadata
:return: Returns a copy of the :class:`Artist` object
"""
self.id = json_obj["id"]
self.name = json_obj["name"]
# Artists do not have roles as playlist creators.
self.roles = None
self.role = None
if json_obj.get("type") or json_obj.get("artistTypes"):
roles: List["Role"] = []
for role in json_obj.get("artistTypes", [json_obj.get("type")]):
roles.append(Role(role))
self.roles = roles
self.role = roles[0]
# Get artist picture or use default
self.picture = json_obj.get("picture")
if self.picture is None:
self.picture = DEFAULT_ARTIST_IMG
user_date_added = json_obj.get("dateAdded")
self.user_date_added = (
dateutil.parser.isoparse(user_date_added) if user_date_added else None
)
self.listen_url = f"{self.session.config.listen_base_url}/artist/{self.id}"
self.share_url = f"{self.session.config.share_base_url}/artist/{self.id}"
return copy.copy(self)
def parse_artists(self, json_obj: List[JsonObj]) -> List["Artist"]:
"""Parses a list of TIDAL artists, returns a list of :class:`Artist` objects
Made for use within the python tidalapi module.
:param List[JsonObj] json_obj: List of :class:`JsonObj` containing the artist metadata for each artist
:return: Returns a list of :class:`Artist` objects
"""
return list(map(self.parse_artist, json_obj))
def _get_albums(
self, params: Optional[Mapping[str, Union[int, str, None]]] = None
) -> List["Album"]:
return cast(
List["Album"],
self.request.map_request(
f"artists/{self.id}/albums", params, parse=self.session.parse_album
),
)
def get_albums(self, limit: Optional[int] = None, offset: int = 0) -> List["Album"]:
"""Queries TIDAL for the artists albums.
:return: A list of :class:`Albums<tidalapi.album.Album>`
"""
params = {"limit": limit, "offset": offset}
return self._get_albums(params)
def get_albums_ep_singles(
self, limit: Optional[int] = None, offset: int = 0
) -> List["Album"]:
warn(
"This method is deprecated an will be removed in a future release. Use instead `get_ep_singles`",
DeprecationWarning,
stacklevel=2,
)
return self.get_ep_singles(limit=limit, offset=offset)
def get_ep_singles(
self, limit: Optional[int] = None, offset: int = 0
) -> List["Album"]:
"""Queries TIDAL for the artists extended plays and singles.
:return: A list of :class:`Albums <tidalapi.album.Album>`
"""
params = {"filter": "EPSANDSINGLES", "limit": limit, "offset": offset}
return self._get_albums(params)
def get_albums_other(
self, limit: Optional[int] = None, offset: int = 0
) -> List["Album"]:
warn(
"This method is deprecated an will be removed in a future release. Use instead `get_other`",
DeprecationWarning,
stacklevel=2,
)
return self.get_other(limit=limit, offset=offset)
def get_other(self, limit: Optional[int] = None, offset: int = 0) -> List["Album"]:
"""Queries TIDAL for albums the artist has appeared on as a featured artist.
:return: A list of :class:`Albums <tidalapi.album.Album>`
"""
params = {"filter": "COMPILATIONS", "limit": limit, "offset": offset}
return self._get_albums(params)
def get_top_tracks(
self, limit: Optional[int] = None, offset: int = 0
) -> List["Track"]:
"""Queries TIDAL for the artists tracks, sorted by popularity.
:return: A list of :class:`Tracks <tidalapi.media.Track>`
"""
params = {"limit": limit, "offset": offset}
return cast(
List["Track"],
self.request.map_request(
f"artists/{self.id}/toptracks",
params=params,
parse=self.session.parse_track,
),
)
def get_videos(self, limit: Optional[int] = None, offset: int = 0) -> List["Video"]:
"""Queries tidal for the artists videos.
:return: A list of :class:`Videos <tidalapi.media.Video>`
"""
params = {"limit": limit, "offset": offset}
return cast(
List["Video"],
self.request.map_request(
f"artists/{self.id}/videos",
params=params,
parse=self.session.parse_video,
),
)
def get_bio(self) -> str:
"""Queries TIDAL for the artists biography.
:return: A string containing the bio, as well as identifiers to other TIDAL
objects inside the bio.
"""
return cast(
str, self.request.request("GET", f"artists/{self.id}/bio").json()["text"]
)
def get_similar(self) -> List["Artist"]:
"""Queries TIDAL for similar artists.
:return: A list of :class:`Artists <tidalapi.artist.Artist>`
"""
return cast(
List["Artist"],
self.request.map_request(
f"artists/{self.id}/similar", parse=self.session.parse_artist
),
)
def get_radio(self) -> List["Track"]:
"""Queries TIDAL for the artist radio, which is a mix of tracks that are similar
to what the artist makes.
:return: A list of :class:`Tracks <tidalapi.media.Track>`
"""
params = {"limit": 100}
return cast(
List["Track"],
self.request.map_request(
f"artists/{self.id}/radio",
params=params,
parse=self.session.parse_track,
),
)
def items(self) -> List[NoReturn]:
"""The artist page does not supply any items. This only exists for symmetry with
other model types.
:return: An empty list.
"""
return []
def image(self, dimensions: int = 320) -> str:
"""A url to an artist picture.
:param dimensions: The width and height that you want from the image
:type dimensions: int
:return: A url to the image.
Valid resolutions: 160x160, 320x320, 480x480, 750x750
"""
if dimensions not in [160, 320, 480, 750]:
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))
if not self.picture:
json = self.request.request("GET", f"artists/{self.id}").json()
self.picture = json.get("picture")
if not self.picture:
raise ValueError("No image available")
return self.session.config.image_url % (
self.picture.replace("-", "/"),
dimensions,
dimensions,
)
def page(self) -> "Page":
"""
Retrieve the artist page as seen on https://listen.tidal.com/artist/$id
:return: A :class:`.Page` containing all the categories from the page, e.g. tracks, influencers and credits
"""
return self.session.page.get("pages/artist", params={"artistId": self.id})
class Role(Enum):
"""An Enum with different roles an artist can have."""
main = "MAIN"
featured = "FEATURED"
contributor = "CONTRIBUTOR"
artist = "ARTIST"

View File

@ -0,0 +1,46 @@
class AuthenticationError(Exception):
pass
class AssetNotAvailable(Exception):
pass
class TooManyRequests(Exception):
pass
class URLNotAvailable(Exception):
pass
class StreamNotAvailable(Exception):
pass
class MetadataNotAvailable(Exception):
pass
class ObjectNotFound(Exception):
pass
class UnknownManifestFormat(Exception):
pass
class ManifestDecodeError(Exception):
pass
class MPDNotAvailableError(Exception):
pass
class InvalidISRC(Exception):
pass
class InvalidUPC(Exception):
pass

View File

@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2023- The Tidalapi Developers
# Copyright (C) 2019-2020 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/>.
""""""
import copy
from typing import TYPE_CHECKING, Any, List, Optional, cast
from tidalapi.types import JsonObj
if TYPE_CHECKING:
from tidalapi.session import Session, TypeRelation
class Genre:
name: str = ""
path: str = ""
playlists: bool = False
artists: bool = False
albums: bool = False
tracks: bool = False
videos: bool = False
image: str = ""
def __init__(self, session: "Session"):
self.session = session
self.requests = session.request
def parse_genre(self, json_obj: JsonObj) -> "Genre":
self.name = json_obj["name"]
self.path = json_obj["path"]
self.playlists = json_obj["hasPlaylists"]
self.artists = json_obj["hasArtists"]
self.albums = json_obj["hasAlbums"]
self.tracks = json_obj["hasTracks"]
self.videos = json_obj["hasVideos"]
image_path = json_obj["image"].replace("-", "/")
self.image = f"http://resources.wimpmusic.com/images/{image_path}/460x306.jpg"
return copy.copy(self)
def parse_genres(self, json_obj: List[JsonObj]) -> List["Genre"]:
return list(map(self.parse_genre, json_obj))
def get_genres(self) -> List["Genre"]:
return self.parse_genres(self.requests.request("GET", "genres").json())
def items(self, model: List[Optional[Any]]) -> List[Optional[Any]]:
"""Gets the current genre's items of the specified type :param model: The
tidalapi model you want returned.
See :class:`Genre`
:return:
"""
type_relations: "TypeRelation" = next(
x for x in self.session.type_conversions if x.type == model
)
name = type_relations.identifier
parse = type_relations.parse
if getattr(self, name):
location = f"genres/{self.path}/{name}"
return cast(
List[Optional[Any]], self.requests.map_request(location, parse=parse)
)
raise TypeError("This genre does not contain {0}".format(name))

View File

@ -0,0 +1,888 @@
# -*- 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 information about various media types.
Classes: :class:`Media`, :class:`Track`, :class:`Video`
"""
from __future__ import annotations
import copy
from abc import abstractmethod
from datetime import datetime, timedelta
from enum import Enum
from typing import TYPE_CHECKING, List, Optional, Union, cast
import dateutil.parser
if TYPE_CHECKING:
import tidalapi
import base64
import json
from isodate import parse_duration
from mpegdash.parser import MPEGDASHParser
from tidalapi.album import Album
from tidalapi.exceptions import (
ManifestDecodeError,
MetadataNotAvailable,
MPDNotAvailableError,
ObjectNotFound,
StreamNotAvailable,
TooManyRequests,
UnknownManifestFormat,
URLNotAvailable,
)
from tidalapi.types import JsonObj
class Quality(str, Enum):
low_96k: str = "LOW"
low_320k: str = "HIGH"
high_lossless: str = "LOSSLESS"
hi_res_lossless: str = "HI_RES_LOSSLESS"
default: str = low_320k
def __str__(self) -> str:
return self.value
class VideoQuality(str, Enum):
high: str = "HIGH"
medium: str = "MEDIUM"
low: str = "LOW"
audio_only: str = "AUDIO_ONLY"
default: str = high
def __str__(self) -> str:
return self.value
class AudioMode(str, Enum):
stereo: str = "STEREO"
dolby_atmos: str = "DOLBY_ATMOS"
def __str__(self) -> str:
return self.value
class MediaMetadataTags(str, Enum):
hi_res_lossless: str = "HIRES_LOSSLESS"
lossless: str = "LOSSLESS"
dolby_atmos: str = "DOLBY_ATMOS"
def __str__(self) -> str:
return self.value
class AudioExtensions(str, Enum):
FLAC: str = ".flac"
M4A: str = ".m4a"
MP4: str = ".mp4"
def __str__(self) -> str:
return self.value
class VideoExtensions(str, Enum):
TS: str = ".ts"
def __str__(self) -> str:
return self.value
class ManifestMimeType(str, Enum):
# EMU: str = "application/vnd.tidal.emu"
# APPL: str = "application/vnd.apple.mpegurl"
MPD: str = "application/dash+xml"
BTS: str = "application/vnd.tidal.bts"
VIDEO: str = "video/mp2t"
def __str__(self) -> str:
return self.value
class Codec(str, Enum):
MP3: str = "MP3"
AAC: str = "AAC"
MP4A: str = "MP4A"
FLAC: str = "FLAC"
Atmos: str = "EAC3"
AC4: str = "AC4"
LowResCodecs: [str] = [MP3, AAC, MP4A]
PremiumCodecs: [str] = [Atmos, AC4]
HQCodecs: [str] = PremiumCodecs + [FLAC]
def __str__(self) -> str:
return self.value
class MimeType(str, Enum):
audio_mpeg = "audio/mpeg"
audio_mp3 = "audio/mp3"
audio_mp4 = "audio/mp4"
audio_m4a = "audio/m4a"
audio_flac = "audio/flac"
audio_xflac = "audio/x-flac"
audio_eac3 = "audio/eac3"
audio_ac4 = "audio/mp4"
audio_m3u8 = "audio/mpegurl"
video_mp4 = "video/mp4"
video_m3u8 = "video/mpegurl"
audio_map = {
Codec.MP3: audio_mp3,
Codec.AAC: audio_m4a,
Codec.MP4A: audio_m4a,
Codec.FLAC: audio_xflac,
Codec.Atmos: audio_eac3,
Codec.AC4: audio_ac4,
}
def __str__(self) -> str:
return self.value
@staticmethod
def from_audio_codec(codec):
return MimeType.audio_map.get(codec, MimeType.audio_m4a)
@staticmethod
def is_flac(mime_type):
return (
True if mime_type in [MimeType.audio_flac, MimeType.audio_xflac] else False
)
class Media:
"""Base class for generic media, specifically :class:`Track` and :class:`Video`
This class includes data used by both of the subclasses, and a function to parse
both of them.
The date_added attribute is only relevant for playlists. For the release date of the
actual media, use the release date of the album.
"""
id: Optional[int] = -1
name: Optional[str] = None
duration: Optional[int] = -1
available: bool = True
tidal_release_date: Optional[datetime] = None
user_date_added: Optional[datetime] = None
track_num: int = -1
volume_num: int = 1
explicit: bool = False
popularity: int = -1
artist: Optional["tidalapi.artist.Artist"] = None
#: For the artist credit page
artist_roles = None
artists: Optional[List["tidalapi.artist.Artist"]] = None
album: Optional["tidalapi.album.Album"] = None
type: Optional[str] = None
# Direct URL to media https://listen.tidal.com/track/<id> or https://listen.tidal.com/browse/album/<album_id>/track/<track_id>
listen_url: str = ""
# Direct URL to media https://tidal.com/browse/track/<id>
share_url: str = ""
def __init__(
self, session: "tidalapi.session.Session", media_id: Optional[str] = None
):
self.session = session
self.requests = self.session.request
self.album = session.album()
self.id = media_id
if self.id is not None:
self._get(self.id)
@abstractmethod
def _get(self, media_id: str) -> Media:
raise NotImplementedError(
"You are not supposed to use the media class directly."
)
def parse(self, json_obj: JsonObj, album: Optional[Album] = None) -> None:
"""Assigns all :param json_obj:
:param json_obj: The JSON object to parse
:param album: The (optional) album to use, instead of parsing the JSON object
:return:
"""
artists = self.session.parse_artists(json_obj["artists"])
# Sometimes the artist field is not filled, example: 62300893
if "artist" in json_obj:
artist = self.session.parse_artist(json_obj["artist"])
else:
artist = artists[0]
if album is None and json_obj["album"]:
album = self.session.album().parse(json_obj["album"], artist, artists)
self.album = album
self.id = json_obj["id"]
self.name = json_obj["title"]
self.duration = json_obj["duration"]
self.available = bool(json_obj["streamReady"])
# Removed media does not have a release date.
self.tidal_release_date = None
release_date = json_obj.get("streamStartDate")
self.tidal_release_date = (
dateutil.parser.isoparse(release_date) if release_date else None
)
# When getting items from playlists they have a date added attribute, same with
# favorites.
user_date_added = json_obj.get("dateAdded")
self.user_date_added = (
dateutil.parser.isoparse(user_date_added) if user_date_added else None
)
self.track_num = json_obj["trackNumber"]
self.volume_num = json_obj["volumeNumber"]
self.explicit = bool(json_obj["explicit"])
self.popularity = json_obj["popularity"]
self.artist = artist
self.artists = artists
self.type = json_obj.get("type")
self.artist_roles = json_obj.get("artistRoles")
def parse_media(
self, json_obj: JsonObj, album: Optional[Album] = None
) -> Union["Track", "Video"]:
"""Selects the media type when checking lists that can contain both.
:param json_obj: The json containing the media
:param album: The (optional) album to use, instead of parsing the JSON object
:return: Returns a new Video or Track object.
"""
if json_obj.get("type") is None or json_obj["type"] == "Track":
return Track(self.session).parse_track(json_obj, album)
# There are other types like Event, Live, and Video which match the video class
return Video(self.session).parse_video(json_obj, album)
class Track(Media):
"""An object containing information about a track."""
replay_gain = None
peak = None
isrc = None
audio_quality: Optional[str] = None
audio_modes: Optional[List[str]] = None
version = None
full_name: Optional[str] = None
copyright = None
media_metadata_tags = None
def parse_track(self, json_obj: JsonObj, album: Optional[Album] = None) -> Track:
Media.parse(self, json_obj, album)
self.replay_gain = json_obj["replayGain"]
# Tracks from the pages endpoints might not actually exist
if "peak" in json_obj and "isrc" in json_obj:
self.peak = json_obj["peak"]
self.isrc = json_obj["isrc"]
self.copyright = json_obj["copyright"]
self.audio_quality = json_obj["audioQuality"]
self.audio_modes = json_obj["audioModes"]
self.version = json_obj["version"]
self.media_metadata_tags = json_obj["mediaMetadata"]["tags"]
if self.version is not None:
self.full_name = f"{json_obj['title']} ({json_obj['version']})"
else:
self.full_name = json_obj["title"]
# Generate share URLs from track ID and album (if it exists)
if self.album:
self.listen_url = f"{self.session.config.listen_base_url}/album/{self.album.id}/track/{self.id}"
else:
self.listen_url = f"{self.session.config.listen_base_url}/track/{self.id}"
self.share_url = f"{self.session.config.share_base_url}/track/{self.id}"
return copy.copy(self)
def _get(self, media_id: str) -> "Track":
"""Returns information about a track, and also replaces the track used to call
this function.
:param media_id: TIDAL's identifier of the track
:return: A :class:`Track` object containing all the information about the track
"""
try:
request = self.requests.request("GET", "tracks/%s" % media_id)
except ObjectNotFound:
raise ObjectNotFound("Track not found or unavailable")
except TooManyRequests:
raise TooManyRequests("Track unavailable")
else:
json_obj = request.json()
track = self.requests.map_json(json_obj, parse=self.parse_track)
assert not isinstance(track, list)
return cast("Track", track)
def get_url(self) -> str:
"""Retrieves the URL for a track.
:return: A `str` object containing the direct track URL
:raises: A :class:`exceptions.URLNotAvailable` if no URL is available for this track
"""
if self.session.is_pkce:
raise URLNotAvailable(
"Track URL not available with quality:'{}'".format(
self.session.config.quality
)
)
params = {
"urlusagemode": "STREAM",
"audioquality": self.session.config.quality,
"assetpresentation": "FULL",
}
try:
request = self.requests.request(
"GET", "tracks/%s/urlpostpaywall" % self.id, params
)
except ObjectNotFound:
raise URLNotAvailable("URL not available for this track")
except TooManyRequests:
raise TooManyRequests("URL Unavailable")
else:
json_obj = request.json()
return cast(str, json_obj["urls"][0])
def lyrics(self) -> "Lyrics":
"""Retrieves the lyrics for a song.
:return: A :class:`Lyrics` object containing the lyrics
:raises: A :class:`exceptions.MetadataNotAvailable` if there aren't any lyrics
"""
try:
request = self.requests.request("GET", "tracks/%s/lyrics" % self.id)
except ObjectNotFound:
raise MetadataNotAvailable("No lyrics exists for this track")
except TooManyRequests:
raise TooManyRequests("Lyrics unavailable")
else:
json_obj = request.json()
lyrics = self.requests.map_json(json_obj, parse=Lyrics().parse)
assert not isinstance(lyrics, list)
return cast("Lyrics", lyrics)
def get_track_radio(self, limit: int = 100) -> List["Track"]:
"""Queries TIDAL for the track radio, which is a mix of tracks that are similar
to this track.
:return: A list of :class:`Tracks <tidalapi.media.Track>`
:raises: A :class:`exceptions.MetadataNotAvailable` if no track radio is available
"""
params = {"limit": limit}
try:
request = self.requests.request(
"GET", "tracks/%s/radio" % self.id, params=params
)
except ObjectNotFound:
raise MetadataNotAvailable("Track radio not available for this track")
except TooManyRequests:
raise TooManyRequests("Track radio unavailable)")
else:
json_obj = request.json()
tracks = self.requests.map_json(json_obj, parse=self.session.parse_track)
assert isinstance(tracks, list)
return cast(List["Track"], tracks)
def get_stream(self) -> "Stream":
"""Retrieves the track streaming object, allowing for audio transmission.
:return: A :class:`Stream` object which holds audio file properties and
parameters needed for streaming via `MPEG-DASH` protocol.
:raises: A :class:`exceptions.StreamNotAvailable` if there is no stream available for this track
"""
params = {
"playbackmode": "STREAM",
"audioquality": self.session.config.quality,
"assetpresentation": "FULL",
}
try:
request = self.requests.request(
"GET", "tracks/%s/playbackinfopostpaywall" % self.id, params
)
except ObjectNotFound:
raise StreamNotAvailable("Stream not available for this track")
except TooManyRequests:
raise TooManyRequests("Stream unavailable")
else:
json_obj = request.json()
stream = self.requests.map_json(json_obj, parse=Stream().parse)
assert not isinstance(stream, list)
return cast("Stream", stream)
@property
def is_hi_res_lossless(self) -> bool:
try:
if (
self.media_metadata_tags
and MediaMetadataTags.hi_res_lossless in self.media_metadata_tags
):
return True
except:
pass
return False
@property
def is_lossless(self) -> bool:
try:
if (
self.media_metadata_tags
and MediaMetadataTags.lossless in self.media_metadata_tags
):
return True
except:
pass
return False
@property
def is_dolby_atmos(self) -> bool:
try:
return True if AudioMode.dolby_atmos in self.audio_modes else False
except:
return False
class Stream:
"""An object that stores the audio file properties and parameters needed for
streaming via `MPEG-DASH` protocol.
The `manifest` attribute holds the MPD file content encoded in base64.
"""
track_id: int = -1
audio_mode: str = AudioMode.stereo # STEREO, DOLBY_ATMOS
audio_quality: str = Quality.low_320k # LOW, HIGH, LOSSLESS, HI_RES_LOSSLESS
manifest_mime_type: str = ""
manifest_hash: str = ""
manifest: str = ""
asset_presentation: str = "FULL"
album_replay_gain: float = 1.0
album_peak_amplitude: float = 1.0
track_replay_gain: float = 1.0
track_peak_amplitude: float = 1.0
bit_depth: int = 16
sample_rate: int = 44100
def parse(self, json_obj: JsonObj) -> "Stream":
self.track_id = json_obj.get("trackId")
self.audio_mode = json_obj.get("audioMode")
self.audio_quality = json_obj.get("audioQuality")
self.manifest_mime_type = json_obj.get("manifestMimeType")
self.manifest_hash = json_obj.get("manifestHash")
self.manifest = json_obj.get("manifest")
# Use default values for gain, amplitude if unavailable
self.album_replay_gain = json_obj.get("albumReplayGain", 1.0)
self.album_peak_amplitude = json_obj.get("albumPeakAmplitude", 1.0)
self.track_replay_gain = json_obj.get("trackReplayGain", 1.0)
self.track_peak_amplitude = json_obj.get("trackPeakAmplitude", 1.0)
# Bit depth, Sample rate not available for low,hi_res quality modes. Assuming 16bit/44100Hz
self.bit_depth = json_obj.get("bitDepth", 16)
self.sample_rate = json_obj.get("sampleRate", 44100)
return copy.copy(self)
def get_audio_resolution(self):
return self.bit_depth, self.sample_rate
def get_stream_manifest(self) -> "StreamManifest":
return StreamManifest(self)
def get_manifest_data(self) -> str:
try:
# Stream Manifest is base64 encoded.
return base64.b64decode(self.manifest).decode("utf-8")
except:
raise ManifestDecodeError
@property
def is_mpd(self) -> bool:
return True if ManifestMimeType.MPD in self.manifest_mime_type else False
@property
def is_bts(self) -> bool:
return True if ManifestMimeType.BTS in self.manifest_mime_type else False
class StreamManifest:
"""An object containing a parsed StreamManifest."""
manifest: str = None
manifest_mime_type: str = None
manifest_parsed: str = None
codecs: str = None # MP3, AAC, FLAC, ALAC, MQA, EAC3, AC4, MHA1
encryption_key = None
encryption_type = None
sample_rate: int = 44100
urls: [str] = []
mime_type: MimeType = MimeType.audio_mpeg
file_extension = None
dash_info: DashInfo = None
def __init__(self, stream: Stream):
self.manifest = stream.manifest
self.manifest_mime_type = stream.manifest_mime_type
if stream.is_mpd:
# See https://ottverse.com/structure-of-an-mpeg-dash-mpd/ for more details
self.dash_info = DashInfo.from_mpd(stream.get_manifest_data())
self.urls = self.dash_info.urls
# MPD reports mp4a codecs slightly differently when compared to BTS. Both will be interpreted as MP4A
if "flac" in self.dash_info.codecs:
self.codecs = Codec.FLAC
elif "mp4a.40.5" in self.dash_info.codecs:
# LOW 96K: "mp4a.40.5"
self.codecs = Codec.MP4A
elif "mp4a.40.2" in self.dash_info.codecs:
# LOW 320k "mp4a.40.2"
self.codecs = Codec.MP4A
else:
self.codecs = self.dash_info.codecs
self.mime_type = self.dash_info.mime_type
self.sample_rate = self.dash_info.audio_sampling_rate
# TODO: Handle encryption key.
self.encryption_type = "NONE"
self.encryption_key = None
elif stream.is_bts:
# Stream Manifest is base64 encoded.
self.manifest_parsed = stream.get_manifest_data()
# JSON string to object.
stream_manifest = json.loads(self.manifest_parsed)
# TODO: Handle more than one download URL
self.urls = stream_manifest["urls"]
# Codecs can be interpreted directly when using BTS
self.codecs = stream_manifest["codecs"].upper().split(".")[0]
self.mime_type = stream_manifest["mimeType"]
self.encryption_type = stream_manifest["encryptionType"]
self.encryption_key = (
stream_manifest.get("keyId") if self.encryption_type else None
)
else:
raise UnknownManifestFormat
self.file_extension = self.get_file_extension(self.urls[0], self.codecs)
def get_urls(self) -> [str]:
return self.urls
def get_hls(self) -> str:
if self.is_mpd:
return self.dash_info.get_hls()
else:
raise MPDNotAvailableError("HLS stream requires MPD MetaData")
def get_codecs(self) -> str:
return self.codecs
def get_sampling_rate(self) -> int:
return self.dash_info.audio_sampling_rate
@staticmethod
def get_mimetype(stream_codec, stream_url: Optional[str] = None) -> str:
if stream_codec:
return MimeType.from_audio_codec(stream_codec)
if not stream_url:
return MimeType.audio_m4a
else:
if AudioExtensions.FLAC in stream_url:
return MimeType.audio_xflac
elif AudioExtensions.MP4 in stream_url:
return MimeType.audio_m4a
@staticmethod
def get_file_extension(stream_url: str, stream_codec: Optional[str] = None) -> str:
if AudioExtensions.FLAC in stream_url:
# If the file extension within the URL is '*.flac', this is simply a FLAC file.
result: str = AudioExtensions.FLAC
elif AudioExtensions.MP4 in stream_url:
# MPEG-4 is simply a container format for different audio / video encoded lines, like FLAC, AAC, M4A etc.
# '*.m4a' is usually used as file extension, if the container contains only audio lines
# See https://en.wikipedia.org/wiki/MP4_file_format
result: str = AudioExtensions.M4A
elif VideoExtensions.TS in stream_url:
# Video are streamed as '*.ts' files by TIDAL.
result: str = VideoExtensions.TS
else:
# If everything fails it might be an '*.mp4' file
result: str = AudioExtensions.MP4
return result
@property
def is_encrypted(self) -> bool:
return True if self.encryption_key else False
@property
def is_mpd(self) -> bool:
return True if ManifestMimeType.MPD in self.manifest_mime_type else False
@property
def is_bts(self) -> bool:
return True if ManifestMimeType.BTS in self.manifest_mime_type else False
class DashInfo:
"""An object containing the decoded MPEG-DASH / MPD manifest."""
duration: datetime = timedelta()
content_type: str = "audio"
mime_type: MimeType = MimeType.audio_ac4
codecs: str = Codec.FLAC
first_url: str = ""
media_url: str = ""
timescale: int = 44100
audio_sampling_rate: int = 44100
chunk_size: int = -1
last_chunk_size: int = -1
urls: [str] = [""]
@staticmethod
def from_stream(stream) -> "DashInfo":
try:
if stream.is_mpd and not stream.is_encrypted:
return DashInfo(stream.get_manifest_data())
except:
raise ManifestDecodeError
@staticmethod
def from_mpd(mpd_manifest) -> "DashInfo":
try:
return DashInfo(mpd_manifest)
except:
raise ManifestDecodeError
def __init__(self, mpd_xml):
mpd = MPEGDASHParser.parse(
mpd_xml.split("<?xml version='1.0' encoding='UTF-8'?>")[1]
)
self.duration = parse_duration(mpd.media_presentation_duration)
self.content_type = mpd.periods[0].adaptation_sets[0].content_type
self.mime_type = mpd.periods[0].adaptation_sets[0].mime_type
self.codecs = mpd.periods[0].adaptation_sets[0].representations[0].codecs
self.first_url = (
mpd.periods[0]
.adaptation_sets[0]
.representations[0]
.segment_templates[0]
.initialization
)
self.media_url = (
mpd.periods[0]
.adaptation_sets[0]
.representations[0]
.segment_templates[0]
.media
)
# self.startNumber = mpd.periods[0].adaptation_sets[0].representations[0].segment_templates[0].start_number
self.timescale = (
mpd.periods[0]
.adaptation_sets[0]
.representations[0]
.segment_templates[0]
.timescale
)
self.audio_sampling_rate = int(
mpd.periods[0].adaptation_sets[0].representations[0].audio_sampling_rate
)
self.chunk_size = (
mpd.periods[0]
.adaptation_sets[0]
.representations[0]
.segment_templates[0]
.segment_timelines[0]
.Ss[0]
.d
)
# self.chunkcount = mpd.periods[0].adaptation_sets[0].representations[0].segment_templates[0].segment_timelines[0].Ss[0].r + 1
self.last_chunk_size = (
mpd.periods[0]
.adaptation_sets[0]
.representations[0]
.segment_templates[0]
.segment_timelines[0]
.Ss[-1] # Always use last element in segment timeline.
.d
)
self.urls = self.get_urls(mpd)
@staticmethod
def get_urls(mpd) -> list[str]:
# min segments count; i.e. .initialization + the very first of .media;
# See https://developers.broadpeak.io/docs/foundations-dash
segments_count = 1 + 1
for s in (
mpd.periods[0]
.adaptation_sets[0]
.representations[0]
.segment_templates[0]
.segment_timelines[0]
.Ss
):
segments_count += s.r if s.r else 1
# Populate segment urls.
segment_template = (
mpd.periods[0]
.adaptation_sets[0]
.representations[0]
.segment_templates[0]
.media
)
stream_urls: list[str] = []
for index in range(segments_count):
stream_urls.append(segment_template.replace("$Number$", str(index)))
return stream_urls
def get_hls(self) -> str:
hls = "#EXTM3U\n"
hls += "#EXT-X-TARGETDURATION:%s\n" % int(self.duration.seconds)
hls += "#EXT-X-VERSION:3\n"
items = self.urls
chunk_duration = "#EXTINF:%0.3f,\n" % (
float(self.chunk_size) / float(self.timescale)
)
hls += "\n".join(chunk_duration + item for item in items[0:-1])
chunk_duration = "#EXTINF:%0.3f,\n" % (
float(self.last_chunk_size) / float(self.timescale)
)
hls += "\n" + chunk_duration + items[-1] + "\n"
hls += "#EXT-X-ENDLIST\n"
return hls
class Lyrics:
"""An object containing lyrics for a track."""
track_id: int = -1
provider: str = ""
provider_track_id: int = -1
provider_lyrics_id: int = -1
text: str = ""
#: Contains timestamps as well
subtitles: str = ""
right_to_left: bool = False
def parse(self, json_obj: JsonObj) -> "Lyrics":
self.track_id = json_obj["trackId"]
self.provider = json_obj["lyricsProvider"]
self.provider_track_id = json_obj["providerCommontrackId"]
self.provider_lyrics_id = json_obj["providerLyricsId"]
self.text = json_obj["lyrics"]
self.subtitles = json_obj["subtitles"]
self.right_to_left = bool(json_obj["isRightToLeft"])
return copy.copy(self)
class Video(Media):
"""An object containing information about a video."""
release_date: Optional[datetime] = None
video_quality: Optional[str] = None
cover: Optional[str] = None
def parse_video(self, json_obj: JsonObj, album: Optional[Album] = None) -> Video:
Media.parse(self, json_obj, album)
release_date = json_obj.get("releaseDate")
self.release_date = (
dateutil.parser.isoparse(release_date) if release_date else None
)
self.cover = json_obj["imageId"]
# Videos found in the /pages endpoints don't have quality
self.video_quality = json_obj.get("quality")
# Generate share URLs from track ID and artist (if it exists)
if self.artist:
self.listen_url = f"{self.session.config.listen_base_url}/artist/{self.artist.id}/video/{self.id}"
else:
self.listen_url = f"{self.session.config.listen_base_url}/video/{self.id}"
self.share_url = f"{self.session.config.share_base_url}/video/{self.id}"
return copy.copy(self)
def _get(self, media_id: str) -> Video:
"""Returns information about the video, and replaces the object used to call
this function.
:param media_id: TIDAL's identifier of the video
:return: A :class:`Video` object containing all the information about the video.
:raises: A :class:`exceptions.ObjectNotFound` if video is not found or unavailable
"""
try:
request = self.requests.request("GET", "videos/%s" % self.id)
except ObjectNotFound:
raise ObjectNotFound("Video not found or unavailable")
except TooManyRequests:
raise TooManyRequests("Video unavailable")
else:
json_obj = request.json()
video = self.requests.map_json(json_obj, parse=self.parse_video)
assert not isinstance(video, list)
return cast("Video", video)
def get_url(self) -> str:
"""Retrieves the URL to the m3u8 video playlist.
:return: A `str` object containing the direct video URL
:raises: A :class:`exceptions.URLNotAvailable` if no URL is available for this video
"""
params = {
"urlusagemode": "STREAM",
"videoquality": self.session.config.video_quality,
"assetpresentation": "FULL",
}
try:
request = self.requests.request(
"GET", "videos/%s/urlpostpaywall" % self.id, params
)
except ObjectNotFound:
raise URLNotAvailable("URL not available for this video")
except TooManyRequests:
raise TooManyRequests("URL unavailable)")
else:
json_obj = request.json()
return cast(str, json_obj["urls"][0])
def image(self, width: int = 1080, height: int = 720) -> str:
if (width, height) not in [(160, 107), (480, 320), (750, 500), (1080, 720)]:
raise ValueError("Invalid resolution {} x {}".format(width, height))
if not self.cover:
raise AttributeError("No cover image")
return self.session.config.image_url % (
self.cover.replace("-", "/"),
width,
height,
)

View File

@ -0,0 +1,291 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023- The Tidalapi Developers
# Copyright (C) 2022 morguldir
#
# 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 functions relating to TIDAL mixes."""
from __future__ import annotations
import copy
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, List, Optional, Union
import dateutil.parser
from tidalapi.exceptions import ObjectNotFound, TooManyRequests
from tidalapi.types import JsonObj
if TYPE_CHECKING:
from tidalapi.media import Track, Video
from tidalapi.session import Session
class MixType(Enum):
"""An enum to track all the different types of mixes."""
welcome_mix = "WELCOME_MIX"
video_daily = "VIDEO_DAILY_MIX"
daily = "DAILY_MIX"
discovery = "DISCOVERY_MIX"
new_release = "NEW_RELEASE_MIX"
track = "TRACK_MIX"
artist = "ARTIST_MIX"
songwriter = "SONGWRITER_MIX"
producter = "PRODUCER_MIX"
history_alltime = "HISTORY_ALLTIME_MIX"
history_monthly = "HISTORY_MONTHLY_MIX"
history_yearly = "HISTORY_YEARLY_MIX"
@dataclass
class ImageResponse:
small: str
medium: str
large: str
class Mix:
"""A mix from TIDAL, e.g. the listen.tidal.com/view/pages/my_collection_my_mixes.
These get used for many things, like artist/track radio's, recommendations, and
historical plays
"""
id: str = ""
title: str = ""
sub_title: str = ""
sharing_images = None
mix_type: Optional[MixType] = None
content_behaviour: str = ""
short_subtitle: str = ""
images: Optional[ImageResponse] = None
_retrieved = False
_items: Optional[List[Union["Video", "Track"]]] = None
def __init__(self, session: Session, mix_id: Optional[str]):
self.session = session
self.request = session.request
if mix_id is not None:
self.get(mix_id)
def get(self, mix_id: Optional[str] = None) -> "Mix":
"""Returns information about a mix, and also replaces the mix object used to
call this function.
:param mix_id: TIDAL's identifier of the mix
:return: A :class:`Mix` object containing all the information about the mix
"""
if mix_id is None:
mix_id = self.id
params = {"mixId": mix_id, "deviceType": "BROWSER"}
try:
request = self.request.request("GET", "pages/mix", params=params)
except ObjectNotFound:
raise ObjectNotFound("Mix not found")
except TooManyRequests:
raise TooManyRequests("Mix unavailable")
else:
result = self.session.parse_page(request.json())
assert not isinstance(result, list)
if len(result.categories) <= 1:
# An empty page with no mixes was returned. Assume that the selected mix was not available
raise ObjectNotFound("Mix not found")
else:
self._retrieved = True
self.__dict__.update(result.categories[0].__dict__)
self._items = result.categories[1].items
return self
def parse(self, json_obj: JsonObj) -> "Mix":
"""Parse a mix into a :class:`Mix`, replaces the calling object.
:param json_obj: The json of a mix to be parsed
:return: A copy of the parsed mix
"""
self.id = json_obj["id"]
self.title = json_obj["title"]
self.sub_title = json_obj["subTitle"]
self.sharing_images = json_obj["sharingImages"]
self.mix_type = MixType(json_obj["mixType"])
self.content_behaviour = json_obj["contentBehavior"]
self.short_subtitle = json_obj["shortSubtitle"]
images = json_obj["images"]
self.images = ImageResponse(
small=images["SMALL"]["url"],
medium=images["MEDIUM"]["url"],
large=images["LARGE"]["url"],
)
return copy.copy(self)
def items(self) -> List[Union["Video", "Track"]]:
"""Returns all the items in the mix, retrieves them with :class:`get` as well if
not already done.
:return: A :class:`list` of videos and/or tracks from the mix
"""
if not self._retrieved:
self.get(self.id)
if not self._items:
raise ValueError("Retrieved items missing")
return self._items
def image(self, dimensions: int = 320) -> str:
"""A URL to a Mix picture.
:param dimensions: The width and height the requested image should be
:type dimensions: int
:return: A url to the image
Original sizes: 320x320, 640x640, 1500x1500
"""
if not self.images:
raise ValueError("No images present.")
if dimensions == 320:
return self.images.small
elif dimensions == 640:
return self.images.medium
elif dimensions == 1500:
return self.images.large
raise ValueError(f"Invalid resolution {dimensions} x {dimensions}")
@dataclass
class TextInfo:
text: str
color: str
class MixV2:
"""A mix from TIDALs v2 api endpoint."""
date_added: Optional[datetime] = None
title: Optional[str] = None
id: Optional[str] = None
mix_type: Optional[MixType] = None
images: Optional[ImageResponse] = None
detail_images: Optional[ImageResponse] = None
master = False
title_text_info: Optional[TextInfo] = None
sub_title_text_info: Optional[TextInfo] = None
sub_title: Optional[str] = None
updated: Optional[datetime] = None
_retrieved = False
_items: Optional[List[Union["Video", "Track"]]] = None
def __init__(self, session: Session, mix_id: str):
self.session = session
self.request = session.request
if mix_id is not None:
self.get(mix_id)
def get(self, mix_id: Optional[str] = None) -> "MixV2":
"""Returns information about a mix, and also replaces the mix object used to
call this function.
:param mix_id: TIDAL's identifier of the mix
:return: A :class:`Mix` object containing all the information about the mix
"""
if mix_id is None:
mix_id = self.id
params = {"mixId": mix_id, "deviceType": "BROWSER"}
try:
request = self.request.request("GET", "pages/mix", params=params)
except ObjectNotFound:
raise ObjectNotFound("Mix not found")
except TooManyRequests:
raise TooManyRequests("Mix unavailable")
else:
result = self.session.parse_page(request.json())
assert not isinstance(result, list)
if len(result.categories) <= 1:
# An empty page with no mixes was returned. Assume that the selected mix was not available
raise ObjectNotFound("Mix not found")
else:
self._retrieved = True
self.__dict__.update(result.categories[0].__dict__)
self._items = result.categories[1].items
return self
def parse(self, json_obj: JsonObj) -> "MixV2":
"""Parse a mix into a :class:`MixV2`, replaces the calling object.
:param json_obj: The json of a mix to be parsed
:return: A copy of the parsed mix
"""
date_added = json_obj.get("dateAdded")
self.date_added = dateutil.parser.isoparse(date_added) if date_added else None
self.title = json_obj["title"]
self.id = json_obj["id"]
self.title = json_obj["title"]
self.mix_type = MixType(json_obj["mixType"])
images = json_obj["images"]
self.images = ImageResponse(
small=images["SMALL"]["url"],
medium=images["MEDIUM"]["url"],
large=images["LARGE"]["url"],
)
detail_images = json_obj["detailImages"]
self.detail_images = ImageResponse(
small=detail_images["SMALL"]["url"],
medium=detail_images["MEDIUM"]["url"],
large=detail_images["LARGE"]["url"],
)
self.master = json_obj["master"]
title_text_info = json_obj["titleTextInfo"]
self.title_text_info = TextInfo(
text=title_text_info["text"],
color=title_text_info["color"],
)
sub_title_text_info = json_obj["subTitleTextInfo"]
self.sub_title_text_info = TextInfo(
text=sub_title_text_info["text"],
color=sub_title_text_info["color"],
)
self.sub_title = json_obj["subTitle"]
updated = json_obj.get("updated")
self.date_added = dateutil.parser.isoparse(updated) if date_added else None
return copy.copy(self)
def image(self, dimensions: int = 320) -> str:
"""A URL to a Mix picture.
:param dimensions: The width and height the requested image should be
:type dimensions: int
:return: A url to the image
Original sizes: 320x320, 640x640, 1500x1500
"""
if not self.images:
raise ValueError("No images present.")
if dimensions == 320:
return self.images.small
elif dimensions == 640:
return self.images.medium
elif dimensions == 1500:
return self.images.large
raise ValueError(f"Invalid resolution {dimensions} x {dimensions}")

View File

@ -0,0 +1,410 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2023- The Tidalapi Developers
# Copyright (C) 2021-2022 morguldir
#
# 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/>.
"""
Module for parsing TIDAL's pages format found at https://listen.tidal.com/v1/pages
"""
import copy
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterator,
List,
Optional,
Union,
cast,
)
from tidalapi.types import JsonObj
if TYPE_CHECKING:
from tidalapi.album import Album
from tidalapi.artist import Artist
from tidalapi.media import Track, Video
from tidalapi.mix import Mix
from tidalapi.playlist import Playlist, UserPlaylist
from tidalapi.request import Requests
from tidalapi.session import Session
PageCategories = Union[
"Album",
"PageLinks",
"FeaturedItems",
"ItemList",
"TextBlock",
"LinkList",
"Mix",
]
AllCategories = Union["Artist", PageCategories]
class Page:
"""
A page from the https://listen.tidal.com/view/pages/ endpoint
The :class:`categories` field will the most complete information
However it is an iterable that goes through all the visible items on the page as well, in the natural reading order
"""
title: str = ""
categories: Optional[List["AllCategories"]] = None
_categories_iter: Optional[Iterator["AllCategories"]] = None
_items_iter: Optional[Iterator[Callable[..., Any]]] = None
page_category: "PageCategory"
request: "Requests"
def __init__(self, session: "Session", title: str):
self.request = session.request
self.categories = None
self.title = title
self.page_category = PageCategory(session)
def __iter__(self) -> "Page":
if self.categories is None:
raise AttributeError("No categories found")
self._categories_iter = iter(self.categories)
self._category = next(self._categories_iter)
self._items_iter = iter(cast(List[Callable[..., Any]], self._category.items))
return self
def __next__(self) -> Callable[..., Any]:
if self._items_iter is None:
return StopIteration
try:
item = next(self._items_iter)
except StopIteration:
if self._categories_iter is None:
raise AttributeError("No categories found")
self._category = next(self._categories_iter)
self._items_iter = iter(
cast(List[Callable[..., Any]], self._category.items)
)
return self.__next__()
return item
def next(self) -> Callable[..., Any]:
return self.__next__()
def parse(self, json_obj: JsonObj) -> "Page":
"""Goes through everything in the page, and gets the title and adds all the rows
to the categories field :param json_obj: The json to be parsed :return: A copy
of the Page that you can use to browse all the items."""
self.title = json_obj["title"]
self.categories = []
for row in json_obj["rows"]:
page_item = self.page_category.parse(row["modules"][0])
self.categories.append(page_item)
return copy.copy(self)
def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> "Page":
"""Retrieve a page from the specified endpoint, overwrites the calling page.
:param params: Parameter to retrieve the page with
:param endpoint: The endpoint you want to retrieve
:return: A copy of the new :class:`.Page` at the requested endpoint
"""
url = endpoint
if params is None:
params = {}
if "deviceType" not in params:
params["deviceType"] = "BROWSER"
json_obj = self.request.request("GET", url, params=params).json()
return self.parse(json_obj)
@dataclass
class More:
api_path: str
title: str
@classmethod
def parse(cls, json_obj: JsonObj) -> Optional["More"]:
show_more = json_obj.get("showMore")
if show_more is None:
return None
else:
return cls(api_path=show_more["apiPath"], title=show_more["title"])
class PageCategory:
type = None
title: Optional[str] = None
description: Optional[str] = ""
request: "Requests"
_more: Optional[More] = None
def __init__(self, session: "Session"):
self.session = session
self.request = session.request
self.item_types: Dict[str, Callable[..., Any]] = {
"ALBUM_LIST": self.session.parse_album,
"ARTIST_LIST": self.session.parse_artist,
"TRACK_LIST": self.session.parse_track,
"PLAYLIST_LIST": self.session.parse_playlist,
"VIDEO_LIST": self.session.parse_video,
"MIX_LIST": self.session.parse_mix,
}
def parse(self, json_obj: JsonObj) -> AllCategories:
result = None
category_type = json_obj["type"]
if category_type in ("PAGE_LINKS_CLOUD", "PAGE_LINKS"):
category: PageCategories = PageLinks(self.session)
elif category_type in ("FEATURED_PROMOTIONS", "MULTIPLE_TOP_PROMOTIONS"):
category = FeaturedItems(self.session)
elif category_type in self.item_types.keys():
category = ItemList(self.session)
elif category_type == "MIX_HEADER":
return self.session.parse_mix(json_obj["mix"])
elif category_type == "ARTIST_HEADER":
result = self.session.parse_artist(json_obj["artist"])
result.bio = json_obj["bio"]
return ItemHeader(result)
elif category_type == "ALBUM_HEADER":
return ItemHeader(self.session.parse_album(json_obj["album"]))
elif category_type == "HIGHLIGHT_MODULE":
category = ItemList(self.session)
elif category_type == "MIXED_TYPES_LIST":
category = ItemList(self.session)
elif category_type == "TEXT_BLOCK":
category = TextBlock(self.session)
elif category_type in ("ITEM_LIST_WITH_ROLES", "ALBUM_ITEMS"):
category = ItemList(self.session)
elif category_type == "ARTICLE_LIST":
json_obj["items"] = json_obj["pagedList"]["items"]
category = LinkList(self.session)
elif category_type == "SOCIAL":
json_obj["items"] = json_obj["socialProfiles"]
category = LinkList(self.session)
else:
raise NotImplementedError(f"PageType {category_type} not implemented")
return category.parse(json_obj)
def show_more(self) -> Optional[Page]:
"""Get the full list of items on their own :class:`.Page` from a
:class:`.PageCategory`
:return: A :class:`.Page` more of the items in the category, None if there aren't any
"""
api_path = self._more.api_path if self._more else None
return (
Page(self.session, self._more.title).get(api_path)
if api_path and self._more
else None
)
class FeaturedItems(PageCategory):
"""Items that have been featured by TIDAL."""
items: Optional[List["PageItem"]] = None
def __init__(self, session: "Session"):
super().__init__(session)
def parse(self, json_obj: JsonObj) -> "FeaturedItems":
self.items = []
self.title = json_obj["title"]
self.description = json_obj["description"]
for item in json_obj["items"]:
self.items.append(PageItem(self.session, item))
return self
class PageLinks(PageCategory):
"""A list of :class:`.PageLink` to other parts of TIDAL."""
items: Optional[List["PageLink"]] = None
def parse(self, json_obj: JsonObj) -> "PageLinks":
"""Parse the list of links from TIDAL.
:param json_obj: The json to be parsed
:return: A copy of this page category containing the links in the items field
"""
self._more = More.parse(json_obj)
self.title = json_obj["title"]
self.items = []
for item in json_obj["pagedList"]["items"]:
self.items.append(PageLink(self.session, item))
return copy.copy(self)
class ItemList(PageCategory):
"""A list of items from TIDAL, can be a list of mixes, for example, or a list of
playlists and mixes in some cases."""
items: Optional[List[Any]] = None
def parse(self, json_obj: JsonObj) -> "ItemList":
"""Parse a list of items on TIDAL from the pages endpoints.
:param json_obj: The json from TIDAL to be parsed
:return: A copy of the ItemList with a list of items
"""
self._more = More.parse(json_obj)
self.title = json_obj["title"]
item_type = json_obj["type"]
list_key = "pagedList"
session: Optional["Session"] = None
parse: Optional[Callable[..., Any]] = None
if item_type in self.item_types.keys():
parse = self.item_types[item_type]
elif item_type == "HIGHLIGHT_MODULE":
session = self.session
# Unwrap subtitle, maybe add a field for it later
json_obj[list_key] = {"items": [x["item"] for x in json_obj["highlights"]]}
elif item_type in ("MIXED_TYPES_LIST", "ALBUM_ITEMS"):
session = self.session
elif item_type == "ITEM_LIST_WITH_ROLES":
for item in json_obj[list_key]["items"]:
item["item"]["artistRoles"] = item["roles"]
session = self.session
else:
raise NotImplementedError("PageType {} not implemented".format(item_type))
self.items = self.request.map_json(json_obj[list_key], parse, session)
return copy.copy(self)
class PageLink:
"""A Link to another :class:`.Page` on TIDAL, Call get() to retrieve the Page."""
title: str
icon = None
image_id = None
def __init__(self, session: "Session", json_obj: JsonObj):
self.session = session
self.request = session.request
self.title = json_obj["title"]
self.icon = json_obj["icon"]
self.api_path = cast(str, json_obj["apiPath"])
self.image_id = json_obj["imageId"]
def get(self) -> "Page":
"""Requests the linked page from TIDAL :return: A :class:`Page` at the
api_path."""
return cast(
"Page",
self.request.map_request(
self.api_path,
params={"deviceType": "DESKTOP"},
parse=self.session.parse_page,
),
)
class PageItem:
"""An Item from a :class:`.PageCategory` from the /pages endpoint, call get() to
retrieve the actual item."""
header: str = ""
short_header: str = ""
short_sub_header: str = ""
image_id: str = ""
type: str = ""
artifact_id: str = ""
text: str = ""
featured: bool = False
session: "Session"
def __init__(self, session: "Session", json_obj: JsonObj):
self.session = session
self.request = session.request
self.header = json_obj["header"]
self.short_header = json_obj["shortHeader"]
self.short_sub_header = json_obj["shortSubHeader"]
self.image_id = json_obj["imageId"]
self.type = json_obj["type"]
self.artifact_id = json_obj["artifactId"]
self.text = json_obj["text"]
self.featured = bool(json_obj["featured"])
def get(self) -> Union["Artist", "Playlist", "Track", "UserPlaylist", "Video"]:
"""Retrieve the PageItem with the artifact_id matching the type.
:return: The fully parsed item, e.g. :class:`.Playlist`, :class:`.Video`, :class:`.Track`
"""
if self.type == "PLAYLIST":
return self.session.playlist(self.artifact_id)
elif self.type == "VIDEO":
return self.session.video(self.artifact_id)
elif self.type == "TRACK":
return self.session.track(self.artifact_id)
elif self.type == "ARTIST":
return self.session.artist(self.artifact_id)
elif self.type == "ALBUM":
return self.session.album(self.artifact_id)
raise NotImplementedError(f"PageItem type {self.type} not implemented")
class TextBlock(object):
"""A block of text, with a named icon, which seems to be left up to the
application."""
text: str = ""
icon: str = ""
items: Optional[List[str]] = None
def __init__(self, session: "Session"):
self.session = session
def parse(self, json_obj: JsonObj) -> "TextBlock":
self.text = json_obj["text"]
self.icon = json_obj["icon"]
self.items = [self.text]
return copy.copy(self)
class LinkList(PageCategory):
"""A list of items containing links, e.g. social links or articles."""
items: Optional[List[Any]] = None
title: Optional[str] = None
description: Optional[str] = None
def parse(self, json_obj: JsonObj) -> "LinkList":
self.items = json_obj["items"]
self.title = json_obj["title"]
self.description = json_obj["description"]
return copy.copy(self)
class ItemHeader(object):
"""Single item in a "category" of the page."""
items: Optional[List[Any]] = None
def __init__(self, item: Any):
self.items = [item]

View File

@ -0,0 +1,732 @@
# -*- 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

View File

@ -0,0 +1,275 @@
# -*- 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 functions relating to TIDAL api requests."""
import json
import logging
from typing import (
TYPE_CHECKING,
Any,
Callable,
List,
Literal,
Mapping,
MutableMapping,
Optional,
Union,
cast,
)
from urllib.parse import urljoin
import requests
from tidalapi.exceptions import ObjectNotFound, TooManyRequests
from tidalapi.types import JsonObj
log = logging.getLogger(__name__)
Params = Mapping[str, Union[str, int, None]]
Methods = Literal["GET", "POST", "PUT", "DELETE"]
if TYPE_CHECKING:
from tidalapi.session import Session
class Requests(object):
"""A class for handling api requests to TIDAL."""
user_agent: str
# Latest error response that can be returned and parsed after request has been completed
latest_err_response: requests.Response
def __init__(self, session: "Session"):
# More Android User-Agents here: https://user-agents.net/browsers/android
self.user_agent = "Mozilla/5.0 (Linux; Android 12; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36"
self.session = session
self.config = session.config
self.latest_err_response = requests.Response()
def basic_request(
self,
method: Methods,
path: str,
params: Optional[Params] = None,
data: Optional[JsonObj] = None,
headers: Optional[MutableMapping[str, str]] = None,
base_url: Optional[str] = None,
) -> requests.Response:
request_params = {
"sessionId": self.session.session_id,
"countryCode": self.session.country_code,
"limit": self.config.item_limit,
}
if params:
# Don't update items with a none value, as we prefer a default value.
# requests also does not support them.
not_none = filter(lambda item: item[1] is not None, params.items())
request_params.update(not_none)
if not headers:
headers = {}
if "User-Agent" not in headers:
headers["User-Agent"] = self.user_agent
if self.session.token_type and self.session.access_token is not None:
headers["authorization"] = (
self.session.token_type + " " + self.session.access_token
)
if base_url is None:
base_url = self.session.config.api_v1_location
url = urljoin(base_url, path)
request = self.session.request_session.request(
method, url, params=request_params, data=data, headers=headers
)
refresh_token = self.session.refresh_token
if not request.ok and refresh_token:
json_resp = None
try:
json_resp = request.json()
except json.decoder.JSONDecodeError:
pass
if json_resp and json_resp.get("userMessage", "").startswith(
"The token has expired."
):
log.debug("The access token has expired, trying to refresh it.")
refreshed = self.session.token_refresh(refresh_token)
if refreshed:
request = self.basic_request(method, url, params, data, headers)
else:
log.debug("HTTP error on %d", request.status_code)
log.debug("Response text\n%s", request.text)
return request
def request(
self,
method: Methods,
path: str,
params: Optional[Params] = None,
data: Optional[JsonObj] = None,
headers: Optional[MutableMapping[str, str]] = None,
base_url: Optional[str] = None,
) -> requests.Response:
"""Method for tidal requests.
Not meant for use outside of this library.
:param method: The type of request to make
:param path: The TIDAL api endpoint you want to use.
:param params: The parameters you want to supply with the request.
:param data: The data you want to supply with the request.
:param headers: The headers you want to include with the request
:param base_url: The base url to use for the request
:return: The json data at specified api endpoint.
"""
request = self.basic_request(method, path, params, data, headers, base_url)
log.debug("request: %s", request.request.url)
try:
request.raise_for_status()
except Exception as e:
log.info("Request resulted in exception {}".format(e))
self.latest_err_response = request
if request.content:
resp = request.json()
# Make sure request response contains the detailed error message
if "errors" in resp:
log.debug("Request response: '%s'", resp["errors"][0]["detail"])
elif "userMessage" in resp:
log.debug("Request response: '%s'", resp["userMessage"])
else:
log.debug("Request response: '%s'", json.dumps(resp))
if request.status_code and request.status_code == 404:
raise ObjectNotFound
elif request.status_code and request.status_code == 429:
raise TooManyRequests
else:
# raise last error, usually HTTPError
raise
return request
def get_latest_err_response(self) -> dict:
"""Get the latest request Response that resulted in an Exception :return: The
request Response that resulted in the Exception, returned as a dict An empty
dict will be returned, if no response was returned."""
if self.latest_err_response.content:
return self.latest_err_response.json()
else:
return {}
def get_latest_err_response_str(self) -> str:
"""Get the latest request response message as a string :return: The contents of
the (detailed) error response Response, returned as a string An empty str will
be returned, if no response was returned."""
if self.latest_err_response.content:
resp = self.latest_err_response.json()
return resp["errors"][0]["detail"]
else:
return ""
def map_request(
self,
url: str,
params: Optional[Params] = None,
parse: Optional[Callable[..., Any]] = None,
) -> Any:
"""Returns the data about object(s) at the specified url, with the method
specified in the parse argument.
Not meant for use outside of this library
:param url: TIDAL api endpoint that contains the data
:param params: TIDAL parameters to use when getting the data
:param parse: (Optional) The method used to parse the data at the url. If not
set, jsonObj will be returned
:return: The object(s) at the url, with the same type as the class of the parse
method.
"""
json_obj = self.request("GET", url, params).json()
if parse:
return self.map_json(json_obj, parse=parse)
else:
return json_obj
@classmethod
def map_json(
cls,
json_obj: JsonObj,
parse: Optional[Callable[..., Any]] = None,
session: Optional["Session"] = None,
) -> Any:
items = json_obj.get("items")
if items is None:
# Not a collection of items, so returning a single object
if parse is None:
raise ValueError("A parser must be supplied")
return parse(json_obj)
if len(items) > 0 and "item" in items[0]:
# Move created date into the item json data like it is done for playlists tracks.
if "created" in items[0]:
for item in items:
item["item"]["dateAdded"] = item["created"]
lists: List[Any] = []
for item in items:
if session is not None:
parse = cast(
Callable[..., Any],
session.convert_type(
cast(str, item["type"]).lower() + "s", output="parse"
),
)
if parse is None:
raise ValueError("A parser must be supplied")
lists.append(parse(item["item"]))
return lists
if parse is None:
raise ValueError("A parser must be supplied")
return list(map(parse, items))
def get_items(self, url: str, parse: Callable[..., Any]) -> List[Any]:
"""Returns a list of items, used when there are over a 100 items, but TIDAL
doesn't always allow more specifying a higher limit.
Not meant for use outside of this library.
:param url: TIDAL api endpoint where you get the objects.
:param parse: The method that parses the data in the url
item_List: List[Any] = []
"""
params = {"offset": 0, "limit": 100}
remaining = 100
item_list: List[Any] = []
while remaining == 100:
items = self.map_request(url, params=params, parse=parse)
remaining = len(items)
params["offset"] += 100
item_list.extend(items or [])
return item_list

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2023- The Tidalapi Developers
from typing import Any, Dict
JsonObj = Dict[str, Any]

View File

@ -0,0 +1,554 @@
# -*- 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 classes and functions related to tidal users.
:class:`User` is a class with user information.
:class:`Favorites` is class with a users favorites.
"""
from __future__ import annotations
from copy import copy
from typing import TYPE_CHECKING, List, Optional, Union, cast
from urllib.parse import urljoin
from tidalapi.exceptions import ObjectNotFound
from tidalapi.types import JsonObj
if TYPE_CHECKING:
from tidalapi.album import Album
from tidalapi.artist import Artist
from tidalapi.media import Track, Video
from tidalapi.mix import MixV2
from tidalapi.playlist import Folder, Playlist, UserPlaylist
from tidalapi.session import Session
def list_validate(lst):
if isinstance(lst, str):
lst = [lst]
if len(lst) == 0:
raise ValueError("An empty list was provided.")
return lst
class User:
"""A class containing various information about a TIDAL user.
The attributes of this class are pretty varied. ID is the only attribute you can
rely on being set. If you initialized a specific user, you will get id, first_name,
last_name, and picture_id. If parsed as a playlist creator, you will get an ID and a
name, if the creator isn't an artist, name will be 'user'. If the parsed user is the
one logged in, for example in session.user, you will get the remaining attributes,
and id.
"""
id: Optional[int] = -1
def __init__(self, session: "Session", user_id: Optional[int]):
self.id = user_id
self.session = session
self.request = session.request
self.playlist = session.playlist()
self.folder = session.folder()
def factory(self) -> Union["LoggedInUser", "FetchedUser", "PlaylistCreator"]:
return cast(
Union["LoggedInUser", "FetchedUser", "PlaylistCreator"],
self.request.map_request("users/%s" % self.id, parse=self.parse),
)
def parse(
self, json_obj: JsonObj
) -> Union["LoggedInUser", "FetchedUser", "PlaylistCreator"]:
if "username" in json_obj:
user: Union[LoggedInUser, FetchedUser, PlaylistCreator] = LoggedInUser(
self.session, json_obj["id"]
)
elif "firstName" in json_obj:
user = FetchedUser(self.session, json_obj["id"])
elif json_obj:
user = PlaylistCreator(self.session, json_obj["id"])
# When searching TIDAL does not show up as a creator in the json data.
else:
user = PlaylistCreator(self.session, 0)
return user.parse(json_obj)
class FetchedUser(User):
first_name: Optional[str] = None
last_name: Optional[str] = None
picture_id: Optional[str] = None
def parse(self, json_obj: JsonObj) -> "FetchedUser":
self.id = json_obj["id"]
self.first_name = json_obj["firstName"]
self.last_name = json_obj["lastName"]
self.picture_id = json_obj.get("picture", None)
return copy(self)
def image(self, dimensions: int) -> str:
if dimensions not in [100, 210, 600]:
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))
if self.picture_id is None:
raise AttributeError("No picture available")
return self.session.config.image_url % (
self.picture_id.replace("-", "/"),
dimensions,
dimensions,
)
class LoggedInUser(FetchedUser):
username: Optional[str] = None
email: Optional[str] = None
profile_metadata: Optional[JsonObj] = None
def __init__(self, session: "Session", user_id: Optional[int]):
super(LoggedInUser, self).__init__(session, user_id)
assert self.id is not None, "User is not logged in"
self.favorites = Favorites(session, self.id)
def parse(self, json_obj: JsonObj) -> "LoggedInUser":
super(LoggedInUser, self).parse(json_obj)
self.username = json_obj["username"]
self.email = json_obj["email"]
self.profile_metadata = json_obj
return copy(self)
def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]:
"""Get the (personal) playlists created by the user.
:return: Returns a list of :class:`~tidalapi.playlist.Playlist` objects containing the playlists.
"""
return cast(
List[Union["Playlist", "UserPlaylist"]],
self.request.map_request(
"users/%s/playlists" % self.id, parse=self.playlist.parse_factory
),
)
def playlist_folders(
self, offset: int = 0, limit: int = 50, parent_folder_id: str = "root"
) -> List["Folder"]:
"""Get a list of folders created by the user.
:param offset: The amount of items you want returned.
:param limit: The index of the first item you want included.
:param parent_folder_id: Parent folder ID. Default: 'root' playlist folder
:return: Returns a list of :class:`~tidalapi.playlist.Folder` objects containing the Folders.
"""
params = {
"folderId": parent_folder_id,
"offset": offset,
"limit": limit,
"order": "NAME",
"includeOnly": "FOLDER",
}
endpoint = "my-collection/playlists/folders"
return cast(
List["Folder"],
self.session.request.map_request(
url=urljoin(
self.session.config.api_v2_location,
endpoint,
),
params=params,
parse=self.session.parse_folder,
),
)
def public_playlists(
self, offset: int = 0, limit: int = 50
) -> List[Union["Playlist", "UserPlaylist"]]:
"""Get the (public) playlists created by the user.
:param limit: The index of the first item you want included.
:param offset: The amount of items you want returned.
:return: List of public playlists.
"""
params = {"limit": limit, "offset": offset}
endpoint = "user-playlists/%s/public" % self.id
json_obj = self.request.request(
"GET", endpoint, base_url=self.session.config.api_v2_location, params=params
).json()
# The response contains both playlists and user details (followInfo, profile) but we will discard the latter.
playlists = {"items": []}
for index, item in enumerate(json_obj["items"]):
if item["playlist"]:
playlists["items"].append(item["playlist"])
return cast(
List[Union["Playlist", "UserPlaylist"]],
self.request.map_json(playlists, parse=self.playlist.parse_factory),
)
def playlist_and_favorite_playlists(
self, offset: int = 0, limit: int = 50
) -> List[Union["Playlist", "UserPlaylist"]]:
"""Get the playlists created by the user, and the playlists favorited by the
user. This function is limited to 50 by TIDAL, requiring pagination.
:return: Returns a list of :class:`~tidalapi.playlist.Playlist` objects containing the playlists.
"""
params = {"limit": limit, "offset": offset}
endpoint = "users/%s/playlistsAndFavoritePlaylists" % self.id
json_obj = self.request.request("GET", endpoint, params=params).json()
# This endpoint sorts them into favorited and created playlists, but we already do that when parsing them.
for index, item in enumerate(json_obj["items"]):
item["playlist"]["dateAdded"] = item["created"]
json_obj["items"][index] = item["playlist"]
return cast(
List[Union["Playlist", "UserPlaylist"]],
self.request.map_json(json_obj, parse=self.playlist.parse_factory),
)
def create_playlist(
self, title: str, description: str, parent_id: str = "root"
) -> "UserPlaylist":
"""Create a playlist in the specified parent folder.
:param title: Playlist title
:param description: Playlist description
:param parent_id: Parent folder ID. Default: 'root' playlist folder
:return: Returns an object of :class:`~tidalapi.playlist.UserPlaylist` containing the newly created playlist
"""
params = {"name": title, "description": description, "folderId": parent_id}
endpoint = "my-collection/playlists/folders/create-playlist"
json_obj = self.request.request(
method="PUT",
path=endpoint,
base_url=self.session.config.api_v2_location,
params=params,
).json()
json = json_obj.get("data")
if json and json.get("uuid"):
playlist = self.session.playlist().parse(json)
return playlist.factory()
else:
raise ObjectNotFound("Playlist not found after creation")
def create_folder(self, title: str, parent_id: str = "root") -> "Folder":
"""Create folder in the specified parent folder.
:param title: Folder title
:param parent_id: Folder parent ID. Default: 'root' playlist folder
:return: Returns an object of :class:`~tidalapi.playlist.Folder` containing the newly created object
"""
params = {"name": title, "folderId": parent_id}
endpoint = "my-collection/playlists/folders/create-folder"
json_obj = self.request.request(
method="PUT",
path=endpoint,
base_url=self.session.config.api_v2_location,
params=params,
).json()
if json_obj and json_obj.get("data"):
return self.request.map_json(json_obj, parse=self.folder.parse)
else:
raise ObjectNotFound("Folder not found after creation")
class PlaylistCreator(User):
name: Optional[str] = None
def parse(self, json_obj: JsonObj) -> "PlaylistCreator":
if self.id == 0 or self.session.user is None:
self.name = "TIDAL"
elif "name" in json_obj:
self.name = json_obj["name"]
elif self.id == self.session.user.id:
self.name = "me"
else:
self.name = "user"
return copy(self)
class Favorites:
"""An object containing a users favourites."""
def __init__(self, session: "Session", user_id: int):
self.session = session
self.requests = session.request
self.base_url = f"users/{user_id}/favorites"
self.v2_base_url = "favorites"
def add_album(self, album_id: str) -> bool:
"""Adds an album to the users favorites.
:param album_id: TIDAL's identifier of the album.
:return: A boolean indicating whether the request was successful or not.
"""
return self.requests.request(
"POST", f"{self.base_url}/albums", data={"albumId": album_id}
).ok
def add_artist(self, artist_id: str) -> bool:
"""Adds an artist to the users favorites.
:param artist_id: TIDAL's identifier of the artist
:return: A boolean indicating whether the request was successful or not.
"""
return self.requests.request(
"POST", f"{self.base_url}/artists", data={"artistId": artist_id}
).ok
def add_playlist(self, playlist_id: str) -> bool:
"""Adds a playlist to the users favorites.
:param playlist_id: TIDAL's identifier of the playlist.
:return: A boolean indicating whether the request was successful or not.
"""
return self.requests.request(
"POST", f"{self.base_url}/playlists", data={"uuids": playlist_id}
).ok
def add_track(self, track_id: str) -> bool:
"""Adds a track to the users favorites.
:param track_id: TIDAL's identifier of the track.
:return: A boolean indicating whether the request was successful or not.
"""
return self.requests.request(
"POST", f"{self.base_url}/tracks", data={"trackId": track_id}
).ok
def add_track_by_isrc(self, isrc: str) -> bool:
"""Adds a track to the users favorites, using isrc.
:param isrc: The ISRC of the track to be added
:return: True, if successful.
"""
try:
track = self.session.get_tracks_by_isrc(isrc)
if track:
# Add the first track in the list
track_id = str(track[0].id)
return self.requests.request(
"POST", f"{self.base_url}/tracks", data={"trackId": track_id}
).ok
else:
return False
except ObjectNotFound:
return False
def add_video(self, video_id: str) -> bool:
"""Adds a video to the users favorites.
:param video_id: TIDAL's identifier of the video.
:return: A boolean indicating whether the request was successful or not.
"""
params = {"limit": "100"}
return self.requests.request(
"POST",
f"{self.base_url}/videos",
data={"videoIds": video_id},
params=params,
).ok
def remove_artist(self, artist_id: str) -> bool:
"""Removes a track from the users favorites.
:param artist_id: TIDAL's identifier of the artist.
:return: A boolean indicating whether the request was successful or not.
"""
return self.requests.request(
"DELETE", f"{self.base_url}/artists/{artist_id}"
).ok
def remove_album(self, album_id: str) -> bool:
"""Removes an album from the users favorites.
:param album_id: TIDAL's identifier of the album
:return: A boolean indicating whether the request was successful or not.
"""
return self.requests.request("DELETE", f"{self.base_url}/albums/{album_id}").ok
def remove_playlist(self, playlist_id: str) -> bool:
"""Removes a playlist from the users favorites.
:param playlist_id: TIDAL's identifier of the playlist.
:return: A boolean indicating whether the request was successful or not.
"""
return self.requests.request(
"DELETE", f"{self.base_url}/playlists/{playlist_id}"
).ok
def remove_track(self, track_id: str) -> bool:
"""Removes a track from the users favorites.
:param track_id: TIDAL's identifier of the track.
:return: A boolean indicating whether the request was successful or not.
"""
return self.requests.request("DELETE", f"{self.base_url}/tracks/{track_id}").ok
def remove_video(self, video_id: str) -> bool:
"""Removes a video from the users favorites.
:param video_id: TIDAL's identifier of the video.
:return: A boolean indicating whether the request was successful or not.
"""
return self.requests.request("DELETE", f"{self.base_url}/videos/{video_id}").ok
def remove_folders_playlists(self, trns: [str], type: str = "folder") -> bool:
"""Removes one or more folders or playlists from the users favourites, using the
v2 endpoint.
:param trns: List of folder (or playlist) trns to be deleted
:param type: Type of trn: as string, either `folder` or `playlist`. Default `folder`
:return: A boolean indicating whether theś request was successful or not.
"""
if type not in ("playlist", "folder"):
raise ValueError("Invalid trn value used for playlist/folder endpoint")
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:{type}:{trn}")
params = {"trns": ",".join(trns_full)}
endpoint = "my-collection/playlists/folders/remove"
return self.requests.request(
method="PUT",
path=endpoint,
base_url=self.session.config.api_v2_location,
params=params,
).ok
def artists(self, limit: Optional[int] = None, offset: int = 0) -> List["Artist"]:
"""Get the users favorite artists.
:return: A :class:`list` of :class:`~tidalapi.artist.Artist` objects containing the favorite artists.
"""
params = {"limit": limit, "offset": offset}
return cast(
List["Artist"],
self.requests.map_request(
f"{self.base_url}/artists",
params=params,
parse=self.session.parse_artist,
),
)
def albums(self, limit: Optional[int] = None, offset: int = 0) -> List["Album"]:
"""Get the users favorite albums.
:return: A :class:`list` of :class:`~tidalapi.album.Album` objects containing the favorite albums.
"""
params = {"limit": limit, "offset": offset}
return cast(
List["Album"],
self.requests.map_request(
f"{self.base_url}/albums", params=params, parse=self.session.parse_album
),
)
def playlists(
self, limit: Optional[int] = None, offset: int = 0
) -> List["Playlist"]:
"""Get the users favorite playlists.
:return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite playlists.
"""
params = {"limit": limit, "offset": offset}
return cast(
List["Playlist"],
self.requests.map_request(
f"{self.base_url}/playlists",
params=params,
parse=self.session.parse_playlist,
),
)
def tracks(
self,
limit: Optional[int] = None,
offset: int = 0,
order: str = "NAME",
order_direction: str = "ASC",
) -> List["Track"]:
"""Get the users favorite tracks.
:param limit: Optional; The amount of items you want returned.
:param offset: The index of the first item you want included.
:param order: A :class:`str` describing the ordering type when returning the user favorite tracks. eg.: "NAME, "DATE"
:param order_direction: A :class:`str` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
:return: A :class:`list` of :class:`~tidalapi.media.Track` objects containing all of the favorite tracks.
"""
params = {
"limit": limit,
"offset": offset,
"order": order,
"orderDirection": order_direction,
}
return cast(
List["Track"],
self.requests.map_request(
f"{self.base_url}/tracks", params=params, parse=self.session.parse_track
),
)
def videos(self) -> List["Video"]:
"""Get the users favorite videos.
:return: A :class:`list` of :class:`~tidalapi.media.Video` objects containing all the favorite videos
"""
return cast(
List["Video"],
self.requests.get_items(
f"{self.base_url}/videos", parse=self.session.parse_media
),
)
def mixes(self, limit: Optional[int] = 50, offset: int = 0) -> List["MixV2"]:
"""Get the users favorite mixes & radio.
:return: A :class:`list` of :class:`~tidalapi.media.Mix` objects containing the user favourite mixes & radio
"""
params = {"limit": limit, "offset": offset}
return cast(
List["MixV2"],
self.requests.map_request(
url=urljoin(
self.session.config.api_v2_location, f"{self.v2_base_url}/mixes"
),
params=params,
parse=self.session.parse_v2_mix,
),
)