2025-11-29 12:10:06 +01:00
|
|
|
import contextlib
|
|
|
|
|
import os
|
2024-03-22 17:31:33 +01:00
|
|
|
from collections.abc import Callable
|
|
|
|
|
|
2025-11-29 12:10:06 +01:00
|
|
|
import requests
|
2024-01-19 07:34:32 +01:00
|
|
|
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
|
2024-03-22 15:02:26 +01:00
|
|
|
from tidalapi.artist import Artist, Role
|
2024-04-20 11:28:38 +02:00
|
|
|
from tidalapi.media import MediaMetadataTags, Quality
|
2024-01-18 21:57:35 +01:00
|
|
|
from tidalapi.session import SearchTypes
|
2024-06-16 14:21:46 +02:00
|
|
|
from tidalapi.user import LoggedInUser
|
2024-01-13 11:57:11 +01:00
|
|
|
|
2024-05-24 10:01:01 +02:00
|
|
|
from tidal_dl_ng.constants import FAVORITES, MediaType
|
2024-02-26 07:27:53 +01:00
|
|
|
from tidal_dl_ng.helper.exceptions import MediaUnknown
|
2024-01-14 23:09:43 +01:00
|
|
|
|
2024-01-13 11:57:11 +01:00
|
|
|
|
2025-10-17 13:13:19 +02:00
|
|
|
def name_builder_artist(media: Track | Video | Album, delimiter: str = ", ") -> str:
|
|
|
|
|
"""Builds a string of artist names for a track, video, or album.
|
|
|
|
|
|
|
|
|
|
Returns a delimited string of all artist names associated with the given media.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
media (Track | Video | Album): The media object to extract artist names from.
|
|
|
|
|
delimiter (str, optional): The delimiter to use between artist names. Defaults to ", ".
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: A delimited string of artist names.
|
|
|
|
|
"""
|
2025-10-17 12:06:25 +02:00
|
|
|
return delimiter.join(artist.name for artist in media.artists)
|
2024-01-13 11:57:11 +01:00
|
|
|
|
|
|
|
|
|
2025-10-17 13:13:19 +02:00
|
|
|
def name_builder_album_artist(media: Track | Album, first_only: bool = False, delimiter: str = ", ") -> str:
|
|
|
|
|
"""Builds a string of main album artist names for a track or album.
|
|
|
|
|
|
|
|
|
|
Returns a delimited string of main artist names from the album, optionally including only the first main artist.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
media (Track | Album): The media object to extract artist names from.
|
|
|
|
|
first_only (bool, optional): If True, only the first main artist is included. Defaults to False.
|
|
|
|
|
delimiter (str, optional): The delimiter to use between artist names. Defaults to ", ".
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: A delimited string of main album artist names.
|
|
|
|
|
"""
|
2024-03-19 07:23:35 +01:00
|
|
|
artists_tmp: [str] = []
|
2024-03-19 14:44:16 +01:00
|
|
|
artists: [Artist] = media.album.artists if isinstance(media, Track) else media.artists
|
2024-03-19 07:23:35 +01:00
|
|
|
|
2024-03-19 14:44:16 +01:00
|
|
|
for artist in artists:
|
2024-03-19 07:23:35 +01:00
|
|
|
if Role.main in artist.roles:
|
|
|
|
|
artists_tmp.append(artist.name)
|
|
|
|
|
|
2025-10-26 10:33:07 -04:00
|
|
|
if first_only:
|
|
|
|
|
break
|
2025-06-29 21:42:39 +02:00
|
|
|
|
2025-10-17 12:06:25 +02:00
|
|
|
return delimiter.join(artists_tmp)
|
2024-03-19 07:23:35 +01:00
|
|
|
|
|
|
|
|
|
2024-11-15 09:53:21 +01:00
|
|
|
def name_builder_title(media: Track | Video | Mix | Playlist | Album | Video) -> str:
|
|
|
|
|
result: str = (
|
|
|
|
|
media.title if isinstance(media, Mix) else media.full_name if hasattr(media, "full_name") else media.name
|
|
|
|
|
)
|
2024-03-14 06:30:47 +01:00
|
|
|
|
|
|
|
|
return result
|
2024-01-13 11:57:11 +01:00
|
|
|
|
|
|
|
|
|
2025-02-28 10:55:41 +01:00
|
|
|
def name_builder_item(media: Track | Video) -> str:
|
2024-01-13 11:57:11 +01:00
|
|
|
return f"{name_builder_artist(media)} - {name_builder_title(media)}"
|
2024-01-14 23:09:43 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_tidal_media_id(url_or_id_media: str) -> str:
|
2025-09-07 18:27:30 +02:00
|
|
|
|
2024-01-14 23:09:43 +01:00
|
|
|
id_dirty = url_or_id_media.rsplit("/", 1)[-1]
|
|
|
|
|
id_media = id_dirty.rsplit("?", 1)[0]
|
|
|
|
|
|
|
|
|
|
return id_media
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_tidal_media_type(url_media: str) -> MediaType | bool:
|
|
|
|
|
result: MediaType | bool = False
|
|
|
|
|
url_split = url_media.split("/")[-2]
|
|
|
|
|
|
|
|
|
|
if len(url_split) > 1:
|
|
|
|
|
media_name = url_media.split("/")[-2]
|
|
|
|
|
|
|
|
|
|
if media_name == "track":
|
2024-01-16 06:33:23 +01:00
|
|
|
result = MediaType.TRACK
|
2024-01-14 23:09:43 +01:00
|
|
|
elif media_name == "video":
|
2024-01-16 06:33:23 +01:00
|
|
|
result = MediaType.VIDEO
|
2024-01-14 23:09:43 +01:00
|
|
|
elif media_name == "album":
|
2024-01-16 06:33:23 +01:00
|
|
|
result = MediaType.ALBUM
|
2024-01-14 23:09:43 +01:00
|
|
|
elif media_name == "playlist":
|
2024-01-16 06:33:23 +01:00
|
|
|
result = MediaType.PLAYLIST
|
2024-01-14 23:09:43 +01:00
|
|
|
elif media_name == "mix":
|
2024-01-16 06:33:23 +01:00
|
|
|
result = MediaType.MIX
|
2024-05-21 15:51:26 +02:00
|
|
|
elif media_name == "artist":
|
|
|
|
|
result = MediaType.ARTIST
|
2024-01-14 23:09:43 +01:00
|
|
|
|
|
|
|
|
return result
|
2024-01-18 21:57:35 +01:00
|
|
|
|
|
|
|
|
|
2025-09-07 18:27:30 +02:00
|
|
|
def url_ending_clean(url: str) -> str:
|
|
|
|
|
"""Checks if a link ends with "/u" or "?u" and removes that part.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
url (str): The URL to clean.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: The cleaned URL.
|
|
|
|
|
"""
|
|
|
|
|
return url[:-2] if url.endswith("/u") or url.endswith("?u") else url
|
|
|
|
|
|
|
|
|
|
|
2024-01-18 22:14:46 +01:00
|
|
|
def search_results_all(session: Session, needle: str, types_media: SearchTypes = None) -> dict[str, [SearchTypes]]:
|
2024-01-18 21:57:35 +01:00
|
|
|
limit: int = 300
|
|
|
|
|
offset: int = 0
|
|
|
|
|
done: bool = False
|
|
|
|
|
result: dict[str, [SearchTypes]] = {}
|
|
|
|
|
|
|
|
|
|
while not done:
|
|
|
|
|
tmp_result: dict[str, [SearchTypes]] = session.search(
|
|
|
|
|
query=needle, models=types_media, limit=limit, offset=offset
|
|
|
|
|
)
|
|
|
|
|
tmp_done: bool = True
|
|
|
|
|
|
|
|
|
|
for key, value in tmp_result.items():
|
|
|
|
|
# Append pagination results, if there are any
|
|
|
|
|
if offset == 0:
|
|
|
|
|
result = tmp_result
|
|
|
|
|
tmp_done = False
|
|
|
|
|
elif bool(value):
|
|
|
|
|
result[key] += value
|
|
|
|
|
tmp_done = False
|
|
|
|
|
|
|
|
|
|
# Next page
|
|
|
|
|
offset += limit
|
|
|
|
|
done = tmp_done
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2024-05-22 06:33:40 +02:00
|
|
|
def items_results_all(
|
2025-11-29 12:10:06 +01:00
|
|
|
session: Session, media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True
|
2024-05-22 06:33:40 +02:00
|
|
|
) -> [Track | Video | Album]:
|
2024-03-22 17:31:33 +01:00
|
|
|
result: [Track | Video | Album] = []
|
2024-01-18 21:57:35 +01:00
|
|
|
|
|
|
|
|
if isinstance(media_list, Mix):
|
|
|
|
|
result = media_list.items()
|
|
|
|
|
else:
|
2024-03-23 12:29:14 +01:00
|
|
|
func_get_items_media: [Callable] = []
|
|
|
|
|
|
2024-03-22 17:31:33 +01:00
|
|
|
if isinstance(media_list, Playlist | Album):
|
|
|
|
|
if videos_include:
|
2024-03-23 12:29:14 +01:00
|
|
|
func_get_items_media.append(media_list.items)
|
2024-03-22 17:31:33 +01:00
|
|
|
else:
|
2024-03-23 12:29:14 +01:00
|
|
|
func_get_items_media.append(media_list.tracks)
|
2025-11-29 12:10:06 +01:00
|
|
|
elif isinstance(media_list, Artist):
|
2024-03-23 12:29:14 +01:00
|
|
|
func_get_items_media.append(media_list.get_albums)
|
2024-05-22 06:33:40 +02:00
|
|
|
func_get_items_media.append(media_list.get_ep_singles)
|
2024-03-22 17:31:33 +01:00
|
|
|
|
2024-06-16 14:21:46 +02:00
|
|
|
result = paginate_results(func_get_items_media)
|
2024-01-18 21:57:35 +01:00
|
|
|
|
2024-05-22 06:33:40 +02:00
|
|
|
return result
|
2024-03-23 12:29:14 +01:00
|
|
|
|
2024-05-22 06:33:40 +02:00
|
|
|
|
|
|
|
|
def all_artist_album_ids(media_artist: Artist) -> [int | None]:
|
|
|
|
|
result: [int] = []
|
|
|
|
|
func_get_items_media: [Callable] = [media_artist.get_albums, media_artist.get_ep_singles]
|
2024-06-16 14:21:46 +02:00
|
|
|
albums: [Album] = paginate_results(func_get_items_media)
|
2024-05-22 06:33:40 +02:00
|
|
|
|
|
|
|
|
for album in albums:
|
|
|
|
|
result.append(album.id)
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2024-06-16 14:21:46 +02:00
|
|
|
def paginate_results(func_get_items_media: [Callable]) -> [Track | Video | Album | Playlist | UserPlaylist]:
|
2024-05-22 06:33:40 +02:00
|
|
|
result: [Track | Video | Album] = []
|
|
|
|
|
|
|
|
|
|
for func_media in func_get_items_media:
|
|
|
|
|
limit: int = 100
|
|
|
|
|
offset: int = 0
|
|
|
|
|
done: bool = False
|
|
|
|
|
|
2024-11-09 08:54:59 +01:00
|
|
|
if func_media.__func__ == LoggedInUser.playlist_and_favorite_playlists:
|
|
|
|
|
limit: int = 50
|
|
|
|
|
|
2024-05-22 06:33:40 +02:00
|
|
|
while not done:
|
2024-11-09 08:54:59 +01:00
|
|
|
tmp_result: [Track | Video | Album | Playlist | UserPlaylist] = func_media(limit=limit, offset=offset)
|
2024-05-22 06:33:40 +02:00
|
|
|
|
|
|
|
|
if bool(tmp_result):
|
|
|
|
|
result += tmp_result
|
|
|
|
|
# Get the next page in the next iteration.
|
|
|
|
|
offset += limit
|
|
|
|
|
else:
|
|
|
|
|
done = True
|
2024-01-18 21:57:35 +01:00
|
|
|
|
|
|
|
|
return result
|
2024-01-19 07:34:32 +01:00
|
|
|
|
|
|
|
|
|
2025-11-12 15:05:20 +01:00
|
|
|
def user_media_lists(session: Session) -> dict[str, list]:
|
|
|
|
|
"""Fetch user media lists using tidalapi's built-in pagination where available.
|
2024-01-19 07:34:32 +01:00
|
|
|
|
2025-11-12 15:05:20 +01:00
|
|
|
Returns a dictionary with 'playlists' and 'mixes' keys containing lists of media items.
|
|
|
|
|
For playlists, includes both Folder and Playlist objects at the root level.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
session (Session): TIDAL session object.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict[str, list]: Dictionary with 'playlists' (includes Folder and Playlist) and 'mixes' lists.
|
|
|
|
|
"""
|
|
|
|
|
# Use built-in pagination for playlists (root level only)
|
|
|
|
|
playlists = session.user.favorites.playlists_paginated()
|
|
|
|
|
|
|
|
|
|
# Fetch root-level folders manually (no paginated version available)
|
|
|
|
|
folders = []
|
|
|
|
|
offset = 0
|
|
|
|
|
limit = 50
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
batch = session.user.favorites.playlist_folders(limit=limit, offset=offset, parent_folder_id="root")
|
|
|
|
|
if not batch:
|
|
|
|
|
break
|
|
|
|
|
folders.extend(batch)
|
|
|
|
|
if len(batch) < limit:
|
|
|
|
|
break
|
|
|
|
|
offset += limit
|
|
|
|
|
|
|
|
|
|
# Combine folders and playlists
|
|
|
|
|
all_playlists = folders + playlists
|
|
|
|
|
|
|
|
|
|
# Get mixes
|
|
|
|
|
user_mixes = session.mixes().categories[0].items
|
|
|
|
|
|
|
|
|
|
return {"playlists": all_playlists, "mixes": user_mixes}
|
2024-02-26 07:27:53 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def instantiate_media(
|
|
|
|
|
session: Session,
|
|
|
|
|
media_type: type[MediaType.TRACK, MediaType.VIDEO, MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX],
|
|
|
|
|
id_media: str,
|
2024-05-22 06:33:40 +02:00
|
|
|
) -> Track | Video | Album | Playlist | Mix | Artist:
|
2024-03-13 06:48:11 +01:00
|
|
|
if media_type == MediaType.TRACK:
|
|
|
|
|
media = session.track(id_media, with_album=True)
|
|
|
|
|
elif media_type == MediaType.VIDEO:
|
|
|
|
|
media = session.video(id_media)
|
|
|
|
|
elif media_type == MediaType.ALBUM:
|
|
|
|
|
media = session.album(id_media)
|
|
|
|
|
elif media_type == MediaType.PLAYLIST:
|
|
|
|
|
media = session.playlist(id_media)
|
|
|
|
|
elif media_type == MediaType.MIX:
|
|
|
|
|
media = session.mix(id_media)
|
2024-05-21 15:51:26 +02:00
|
|
|
elif media_type == MediaType.ARTIST:
|
|
|
|
|
media = session.artist(id_media)
|
2024-03-13 06:48:11 +01:00
|
|
|
else:
|
2024-02-26 07:27:53 +01:00
|
|
|
raise MediaUnknown
|
|
|
|
|
|
|
|
|
|
return media
|
2024-04-20 11:28:38 +02:00
|
|
|
|
|
|
|
|
|
2024-04-21 15:58:31 +02:00
|
|
|
def quality_audio_highest(media: Track | Album) -> Quality:
|
|
|
|
|
quality: Quality
|
2024-04-20 11:28:38 +02:00
|
|
|
|
2025-11-29 12:10:06 +01:00
|
|
|
# media_metadata_tags may be missing (Mock objects) or None; use safe getter
|
|
|
|
|
tags = getattr(media, "media_metadata_tags", None)
|
|
|
|
|
try:
|
|
|
|
|
iterable_tags = set(tags) if tags is not None else set()
|
|
|
|
|
except Exception:
|
|
|
|
|
# If tags is a Mock or non-iterable, fall back to empty set
|
|
|
|
|
iterable_tags = set()
|
|
|
|
|
|
|
|
|
|
if MediaMetadataTags.hi_res_lossless in iterable_tags:
|
2024-04-20 11:28:38 +02:00
|
|
|
quality = Quality.hi_res_lossless
|
2025-11-29 12:10:06 +01:00
|
|
|
elif MediaMetadataTags.lossless in iterable_tags:
|
2024-04-20 11:28:38 +02:00
|
|
|
quality = Quality.high_lossless
|
|
|
|
|
else:
|
2025-11-29 12:10:06 +01:00
|
|
|
quality = getattr(media, "audio_quality", Quality.low_320k)
|
2024-04-20 11:28:38 +02:00
|
|
|
|
|
|
|
|
return quality
|
2024-05-24 09:31:37 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def favorite_function_factory(tidal, favorite_item: str):
|
|
|
|
|
function_name: str = FAVORITES[favorite_item]["function_name"]
|
|
|
|
|
function_list: Callable = getattr(tidal.session.user.favorites, function_name)
|
|
|
|
|
|
|
|
|
|
return function_list
|
2025-11-29 12:10:06 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_raw_media_json(
|
|
|
|
|
session: Session, media_type: str, media_id: str, country_code: str | None = None, extra_params: dict | None = None
|
|
|
|
|
) -> dict | None:
|
|
|
|
|
"""Fetch raw JSON for a media resource using tidalapi's session.request.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
session (Session): the tidalapi Session
|
|
|
|
|
media_type (str): 'tracks' or 'albums'
|
|
|
|
|
media_id (str): id of media
|
|
|
|
|
country_code (str | None): optional countryCode param
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict | None: parsed JSON or None if fetch fails
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
params = {}
|
|
|
|
|
# If caller didn't provide a country code, check environment variable
|
|
|
|
|
cc = country_code or os.environ.get("TIDAL_COUNTRY")
|
|
|
|
|
if cc:
|
|
|
|
|
params["countryCode"] = cc
|
|
|
|
|
# merge extra params if provided (do not overwrite existing keys unless provided)
|
|
|
|
|
if extra_params and isinstance(extra_params, dict):
|
|
|
|
|
for k, v in extra_params.items():
|
|
|
|
|
params[k] = v
|
|
|
|
|
|
|
|
|
|
# Use session.request.request to call the internal API endpoint
|
|
|
|
|
resp = session.request.request("GET", f"{media_type}/{media_id}", params=params)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
return resp.json()
|
|
|
|
|
except requests.exceptions.HTTPError:
|
|
|
|
|
return None # Silently ignore HTTP errors
|
|
|
|
|
except Exception:
|
|
|
|
|
return None # Silently ignore other errors
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_raw_track_and_album(
|
|
|
|
|
session: Session,
|
|
|
|
|
track_id: str,
|
|
|
|
|
country_code: str | None = None,
|
|
|
|
|
extra_params: dict | None = None,
|
|
|
|
|
) -> tuple[dict | None, dict | None]:
|
|
|
|
|
"""Convenience to fetch raw track JSON and its album JSON (if available).
|
|
|
|
|
|
|
|
|
|
Returns a tuple (track_json, album_json).
|
|
|
|
|
|
|
|
|
|
Uses TIDAL API parameters to fetch extended metadata.
|
|
|
|
|
Note: 'credits' and 'contributors' are NOT available in TIDAL API v2.
|
|
|
|
|
Available include values: albums, artists, genres, lyrics, owners, providers,
|
|
|
|
|
radio, shares, similarTracks, sourceFile, trackStatistics
|
|
|
|
|
"""
|
|
|
|
|
# Use valid include parameters according to TIDAL API v2 spec
|
|
|
|
|
# https://tidal-music.github.io/tidal-api-reference/
|
|
|
|
|
default_track_params = {
|
|
|
|
|
"include": "albums,artists,genres", # Valid parameters per API spec
|
|
|
|
|
}
|
|
|
|
|
merged_track_params = {**default_track_params, **(extra_params or {})}
|
|
|
|
|
|
|
|
|
|
track_json = fetch_raw_media_json(
|
|
|
|
|
session,
|
|
|
|
|
"tracks",
|
|
|
|
|
str(track_id),
|
|
|
|
|
country_code=country_code,
|
|
|
|
|
extra_params=merged_track_params,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
album_json = None
|
|
|
|
|
try:
|
|
|
|
|
if isinstance(track_json, dict):
|
|
|
|
|
album = track_json.get("album")
|
|
|
|
|
album_id = album.get("id") if isinstance(album, dict) else album
|
|
|
|
|
if album_id:
|
|
|
|
|
# Request extended album metadata
|
|
|
|
|
default_album_params = {
|
|
|
|
|
"include": "artists,genres", # Valid parameters for albums
|
|
|
|
|
}
|
|
|
|
|
merged_album_params = {**default_album_params, **(extra_params or {})}
|
|
|
|
|
|
|
|
|
|
album_json = fetch_raw_media_json(
|
|
|
|
|
session,
|
|
|
|
|
"albums",
|
|
|
|
|
str(album_id),
|
|
|
|
|
country_code=country_code,
|
|
|
|
|
extra_params=merged_album_params,
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
album_json = None
|
|
|
|
|
|
|
|
|
|
return track_json, album_json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_dict_contributors(raw_contributors: dict) -> dict[str, list[str]]:
|
|
|
|
|
"""Process contributors in dict format: role -> list[{name, ...}]."""
|
|
|
|
|
result: dict[str, list[str]] = {}
|
|
|
|
|
for role, people in raw_contributors.items():
|
|
|
|
|
if not isinstance(people, list):
|
|
|
|
|
continue
|
|
|
|
|
names: list[str] = []
|
|
|
|
|
for person in people:
|
|
|
|
|
if isinstance(person, dict):
|
|
|
|
|
name = person.get("name")
|
|
|
|
|
if isinstance(name, str) and name:
|
|
|
|
|
names.append(name)
|
|
|
|
|
if names:
|
|
|
|
|
result[role] = names
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_list_contributors(raw_contributors: list) -> dict[str, list[str]]:
|
|
|
|
|
"""Process contributors in list format: [{name, role, ...}, ...]."""
|
|
|
|
|
result: dict[str, list[str]] = {}
|
|
|
|
|
for person in raw_contributors:
|
|
|
|
|
if not isinstance(person, dict):
|
|
|
|
|
continue
|
|
|
|
|
name = person.get("name")
|
|
|
|
|
role = person.get("role")
|
|
|
|
|
if isinstance(name, str) and name and isinstance(role, str) and role:
|
|
|
|
|
result.setdefault(role, []).append(name)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_contributors(raw_contributors: object) -> dict[str, list[str]]:
|
|
|
|
|
"""Normalize various possible contributor JSON shapes into role -> list[str] names.
|
|
|
|
|
|
|
|
|
|
The TIDAL API has used at least two shapes historically:
|
|
|
|
|
- dict role -> list[ {"name": str, ...} ]
|
|
|
|
|
- list[ {"name": str, "role": str, ...} ]
|
|
|
|
|
|
|
|
|
|
We accept both and ignore malformed entries.
|
|
|
|
|
"""
|
|
|
|
|
if isinstance(raw_contributors, dict):
|
|
|
|
|
return _normalize_dict_contributors(raw_contributors)
|
|
|
|
|
if isinstance(raw_contributors, list):
|
|
|
|
|
return _normalize_list_contributors(raw_contributors)
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_bpm_from_track(track_json: dict) -> int | None:
|
|
|
|
|
"""Extract BPM from track JSON."""
|
|
|
|
|
bpm = track_json.get("bpm")
|
|
|
|
|
if isinstance(bpm, int | float):
|
|
|
|
|
return int(round(bpm))
|
|
|
|
|
if isinstance(bpm, str):
|
|
|
|
|
with contextlib.suppress(ValueError):
|
|
|
|
|
return int(round(float(bpm)))
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _process_credits_contributors(credits_list: list) -> dict[str, list[str]]:
|
|
|
|
|
"""Process credits API v2 format and return contributors by role."""
|
|
|
|
|
role_mapping = {
|
|
|
|
|
"producer": "producer",
|
|
|
|
|
"producers": "producer",
|
|
|
|
|
"composer": "composer",
|
|
|
|
|
"composers": "composer",
|
|
|
|
|
"lyricist": "lyricist",
|
|
|
|
|
"lyricists": "lyricist",
|
|
|
|
|
"writer": "composer",
|
|
|
|
|
"writers": "composer",
|
|
|
|
|
}
|
|
|
|
|
result: dict[str, list[str]] = {}
|
|
|
|
|
for credit in credits_list:
|
|
|
|
|
if not isinstance(credit, dict):
|
|
|
|
|
continue
|
|
|
|
|
credit_type = credit.get("type", "").lower()
|
|
|
|
|
contributors = credit.get("contributors", [])
|
|
|
|
|
role = role_mapping.get(credit_type, credit_type)
|
|
|
|
|
if isinstance(contributors, list):
|
|
|
|
|
for contributor in contributors:
|
|
|
|
|
if isinstance(contributor, dict):
|
|
|
|
|
name = contributor.get("name")
|
|
|
|
|
if name:
|
|
|
|
|
result.setdefault(role, []).append(name)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_track_contributors(track_json: dict) -> dict[str, list[str]]:
|
|
|
|
|
"""Extract contributors from track JSON."""
|
|
|
|
|
# Try credits first (API v2)
|
|
|
|
|
track_credits = track_json.get("credits")
|
|
|
|
|
if track_credits and isinstance(track_credits, list):
|
|
|
|
|
contributors = _process_credits_contributors(track_credits)
|
|
|
|
|
if contributors:
|
|
|
|
|
return contributors
|
|
|
|
|
# Fallback to old format
|
|
|
|
|
raw_contributors = track_json.get("contributors")
|
|
|
|
|
if raw_contributors:
|
|
|
|
|
return _normalize_contributors(raw_contributors)
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _process_genre_item(g: object) -> str | None:
|
|
|
|
|
"""Extract genre name from various formats."""
|
|
|
|
|
if isinstance(g, str) and g:
|
|
|
|
|
return g
|
|
|
|
|
if isinstance(g, dict):
|
|
|
|
|
name = g.get("name")
|
|
|
|
|
if isinstance(name, str) and name:
|
|
|
|
|
return name
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _deduplicate_genres(genres: list[str]) -> list[str]:
|
|
|
|
|
"""Deduplicate genres while preserving order."""
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
unique: list[str] = []
|
|
|
|
|
for g in genres:
|
|
|
|
|
if g not in seen:
|
|
|
|
|
seen.add(g)
|
|
|
|
|
unique.append(g)
|
|
|
|
|
return unique
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_album_label_genres(album_json: dict) -> tuple[str, list[str]]:
|
|
|
|
|
"""Extract label and genres from album JSON."""
|
|
|
|
|
# Label
|
|
|
|
|
label = album_json.get("label") or album_json.get("recordLabel")
|
|
|
|
|
label_str = label if isinstance(label, str) else ""
|
|
|
|
|
|
|
|
|
|
# Genres
|
|
|
|
|
raw_genres = album_json.get("genres") or album_json.get("genre")
|
|
|
|
|
genres: list[str] = []
|
|
|
|
|
|
|
|
|
|
if isinstance(raw_genres, list):
|
|
|
|
|
for g in raw_genres:
|
|
|
|
|
genre = _process_genre_item(g)
|
|
|
|
|
if genre:
|
|
|
|
|
genres.append(genre)
|
|
|
|
|
elif isinstance(raw_genres, str) and raw_genres:
|
|
|
|
|
genres.append(raw_genres)
|
|
|
|
|
else:
|
|
|
|
|
genre = _process_genre_item(raw_genres)
|
|
|
|
|
if genre:
|
|
|
|
|
genres.append(genre)
|
|
|
|
|
|
|
|
|
|
# Deduplicate while preserving order
|
|
|
|
|
if genres:
|
|
|
|
|
return label_str, _deduplicate_genres(genres)
|
|
|
|
|
return label_str, []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_album_contributors(album_json: dict) -> dict[str, list[str]]:
|
|
|
|
|
"""Extract contributors from album JSON."""
|
|
|
|
|
# Try credits first (API v2)
|
|
|
|
|
album_credits = album_json.get("credits")
|
|
|
|
|
if album_credits and isinstance(album_credits, list):
|
|
|
|
|
contributors = _process_credits_contributors(album_credits)
|
|
|
|
|
if contributors:
|
|
|
|
|
return contributors
|
|
|
|
|
# Fallback to old format
|
|
|
|
|
raw_contributors = album_json.get("contributors")
|
|
|
|
|
if raw_contributors:
|
|
|
|
|
return _normalize_contributors(raw_contributors)
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_track_and_album_extras(
|
|
|
|
|
track_json: dict | None,
|
|
|
|
|
album_json: dict | None,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Extract extra metadata from raw TIDAL JSON for a track and its album.
|
|
|
|
|
|
|
|
|
|
Returned dict keys (all optional, may be missing or empty):
|
|
|
|
|
- bpm: int | None
|
|
|
|
|
- label: str
|
|
|
|
|
- genres: list[str]
|
|
|
|
|
- contributors_by_role: dict[str, list[str]]
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
extras: dict = {
|
|
|
|
|
"bpm": None,
|
|
|
|
|
"label": "",
|
|
|
|
|
"genres": [],
|
|
|
|
|
"contributors_by_role": {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Extract from track
|
|
|
|
|
if isinstance(track_json, dict):
|
|
|
|
|
extras["bpm"] = _extract_bpm_from_track(track_json)
|
|
|
|
|
extras["contributors_by_role"] = _extract_track_contributors(track_json)
|
|
|
|
|
|
|
|
|
|
# Extract from album
|
|
|
|
|
if isinstance(album_json, dict):
|
|
|
|
|
label, genres = _extract_album_label_genres(album_json)
|
|
|
|
|
extras["label"] = label
|
|
|
|
|
extras["genres"] = genres
|
|
|
|
|
|
|
|
|
|
# If we did not get track-level contributors, try album-level
|
|
|
|
|
if not extras["contributors_by_role"]:
|
|
|
|
|
extras["contributors_by_role"] = _extract_album_contributors(album_json)
|
|
|
|
|
|
|
|
|
|
return extras
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_contributor_names(
|
|
|
|
|
contributors_by_role: dict[str, list[str]] | None,
|
|
|
|
|
role: str,
|
|
|
|
|
delimiter: str = ", ",
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Return a delimited string of contributor names for a given role.
|
|
|
|
|
|
|
|
|
|
If the role is not present or has no names, returns an empty string.
|
|
|
|
|
Role matching is case-insensitive.
|
|
|
|
|
"""
|
|
|
|
|
if not contributors_by_role:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
# Normalise keys to lowercase for robust lookups.
|
|
|
|
|
role_lc = role.lower()
|
|
|
|
|
for r, names in contributors_by_role.items():
|
|
|
|
|
if not isinstance(r, str):
|
|
|
|
|
continue
|
|
|
|
|
if r.lower() == role_lc and isinstance(names, list):
|
|
|
|
|
filtered = [n for n in names if isinstance(n, str) and n]
|
|
|
|
|
if filtered:
|
|
|
|
|
return delimiter.join(filtered)
|
|
|
|
|
|
|
|
|
|
return ""
|