Files

277 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* tiddl-web — Tidal API client
*
* Thin wrapper around api.tidal.com/v1 — mirrors the Python TidalAPI class.
*
* All requests now use proxyFetch (config.js) so they benefit from:
* - AbortController-based timeouts (API_TIMEOUT_MS, default 30 s)
* - Automatic retry with exponential back-off (×2 extra attempts)
* - Consistent error normalization via TiddlError
*/
import { API_URL, proxyFetch } from "./config.js";
import { getValidToken, loadAuth, refreshToken } from "./auth.js";
import { API_TIMEOUT_MS } from "./http.js";
/**
* Safety guard for paginated endpoints.
* Prevents infinite loops if an upstream API/proxy keeps repeating pages or offsets.
* 2000 pages × 50 items/page = 100,000 items, which is far above typical
* real-world library sizes while still guaranteeing termination.
*/
const MAX_PAGINATION_PAGES = 2000;
async function apiFetch(endpoint, params = {}, options = {}) {
const {
preferDirect = false,
allowProxyFallback = true,
includeRequestMeta = false,
} = options;
const auth = loadAuth();
const defaultParams = { countryCode: auth?.country_code || "US" };
const merged = { ...defaultParams, ...params };
const qs = new URLSearchParams(merged).toString();
const url = `${API_URL}/${endpoint}?${qs}`;
// Map the caller's preference flags to a proxyFetch strategy string.
const strategy = preferDirect
? (allowProxyFallback ? "direct-then-proxy" : "direct-only")
: "proxy-only";
async function doRequest(token) {
const { res, usedProxy } = await proxyFetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
credentials: "omit",
}, { strategy, timeoutMs: API_TIMEOUT_MS, retries: 2 });
let json = {};
try { json = await res.json(); } catch { /* malformed or empty response body */ }
return { res, json, usedProxy };
}
let token = await getValidToken();
if (!token) throw new Error("Not authenticated");
let { res, json, usedProxy } = await doRequest(token);
// Auto-refresh the access token on 401 / 403 and retry once.
if (!res.ok && (res.status === 401 || res.status === 403)) {
try {
const refreshed = await refreshToken();
token = refreshed?.token;
if (token) ({ res, json, usedProxy } = await doRequest(token));
} catch {
/* keep original error below */
}
}
if (!res.ok) {
const msg = json?.userMessage || json?.error_description || `HTTP ${res.status}`;
throw new Error(msg);
}
if (includeRequestMeta) {
return { data: json, requestMeta: { usedProxy: Boolean(usedProxy) } };
}
return json;
}
// ─── Resources ─────────────────────────────────────────────────────────────
export async function getTrack(id) {
return apiFetch(`tracks/${id}`);
}
export async function getAlbum(id) {
return apiFetch(`albums/${id}`);
}
export async function getAlbumItems(id, limit = 100, offset = 0) {
return apiFetch(`albums/${id}/items`, { limit, offset });
}
export async function getArtist(id) {
return apiFetch(`artists/${id}`);
}
export async function getArtistAlbums(id, limit = 50, offset = 0) {
return apiFetch(`artists/${id}/albums`, { limit, offset, filter: "ALBUMS" });
}
export async function getArtistSingles(id, limit = 50, offset = 0) {
return apiFetch(`artists/${id}/albums`, { limit, offset, filter: "EPSANDSINGLES" });
}
export async function getPlaylist(uuid) {
return apiFetch(`playlists/${uuid}`);
}
export async function getPlaylistItems(uuid, limit = 100, offset = 0) {
return apiFetch(`playlists/${uuid}/items`, { limit, offset });
}
export async function getMixItems(mixId, limit = 100, offset = 0) {
return apiFetch(`mixes/${mixId}/items`, { limit, offset });
}
export async function getVideo(id) {
return apiFetch(`videos/${id}`);
}
export async function search(query, limit = 20) {
return apiFetch("search", { query, limit });
}
export async function getFavorites() {
const auth = loadAuth();
if (!auth?.user_id) throw new Error("No user id");
return apiFetch(`users/${auth.user_id}/favorites/ids`);
}
export async function getUserFavoriteTracks(limit = 50, offset = 0) {
const auth = loadAuth();
if (!auth?.user_id) throw new Error("No user id");
return apiFetch(`users/${auth.user_id}/favorites/tracks`, { limit, offset });
}
export async function getUserFavoriteAlbums(limit = 50, offset = 0) {
const auth = loadAuth();
if (!auth?.user_id) throw new Error("No user id");
return apiFetch(`users/${auth.user_id}/favorites/albums`, { limit, offset });
}
export async function getUserFavoritePlaylists(limit = 50, offset = 0) {
const auth = loadAuth();
if (!auth?.user_id) throw new Error("No user id");
return apiFetch(`users/${auth.user_id}/favorites/playlists`, { limit, offset });
}
export async function getUserPlaylists(limit = 50, offset = 0) {
const auth = loadAuth();
if (!auth?.user_id) throw new Error("No user id");
return apiFetch(`users/${auth.user_id}/playlists`, { limit, offset });
}
// ─── Fetch-all helpers ─────────────────────────────────────────────────────
/**
* Fetch every page of a paginated Tidal endpoint.
* `fetchFn(limit, offset)` must return `{ items: [...], totalNumberOfItems: N }`.
* Pages are fetched sequentially to avoid rate-limiting.
*/
async function fetchAllItems(fetchFn, pageSize = 50) {
const first = await fetchFn(pageSize, 0);
const firstItems = Array.isArray(first.items) ? first.items : [];
const numericTotal = Number(first.totalNumberOfItems);
const hasKnownTotal = Number.isFinite(numericTotal) && numericTotal >= 0;
const total = hasKnownTotal ? numericTotal : null;
let items = firstItems.slice();
const pageSignature = (arr) => arr
.map((it) => `${it?.type ?? ""}:${it?.item?.id ?? it?.id ?? ""}`)
.join("|");
let lastSignature = pageSignature(firstItems);
let offset = items.length;
let pagesFetched = 0;
while ((!hasKnownTotal || items.length < total) && pagesFetched < MAX_PAGINATION_PAGES) {
pagesFetched += 1;
const page = await fetchFn(pageSize, offset);
const pageItems = Array.isArray(page.items) ? page.items : [];
if (!pageItems.length) break;
const signature = pageSignature(pageItems);
items = items.concat(pageItems);
const pageOffset = Number(page?.offset);
const pageLimit = Number(page?.limit);
const reportedNextOffset = Number.isFinite(pageOffset) && Number.isFinite(pageLimit) && pageLimit > 0
? pageOffset + pageLimit
: offset + pageItems.length;
const nextOffset = Math.max(offset + pageItems.length, reportedNextOffset);
// If the API returns the exact same page and does not advance offset,
// stop to avoid an infinite loop.
const stalledOffset = nextOffset <= offset;
if (stalledOffset) break;
lastSignature = signature;
offset = nextOffset;
if (!hasKnownTotal && pageItems.length < pageSize) break;
}
return hasKnownTotal ? items.slice(0, total) : items;
}
export async function getAllUserFavoriteTracks() {
return fetchAllItems((limit, offset) => getUserFavoriteTracks(limit, offset));
}
export async function getAllUserFavoriteAlbums() {
return fetchAllItems((limit, offset) => getUserFavoriteAlbums(limit, offset));
}
export async function getAllUserFavoritePlaylists() {
return fetchAllItems((limit, offset) => getUserFavoritePlaylists(limit, offset));
}
export async function getAllUserPlaylists() {
return fetchAllItems((limit, offset) => getUserPlaylists(limit, offset));
}
export async function getAllAlbumItems(id) {
return fetchAllItems((limit, offset) => getAlbumItems(id, limit, offset));
}
export async function getAllPlaylistItems(uuid) {
return fetchAllItems((limit, offset) => getPlaylistItems(uuid, limit, offset));
}
export async function getAllMixItems(mixId) {
return fetchAllItems((limit, offset) => getMixItems(mixId, limit, offset));
}
export async function getSession() {
return apiFetch("sessions");
}
// ─── Streams ───────────────────────────────────────────────────────────────
export async function getTrackLyrics(trackId) {
return apiFetch(`tracks/${trackId}/lyrics`);
}
export async function getTrackStream(trackId, quality = "HIGH", options = {}) {
const {
allowProxyFallback = true,
includeRequestMeta = false,
preferDirect = true,
playbackMode = "STREAM",
} = options;
return apiFetch(`tracks/${trackId}/playbackinfopostpaywall`, {
audioquality: quality,
playbackmode: playbackMode,
assetpresentation: "FULL",
}, { preferDirect, allowProxyFallback, includeRequestMeta });
}
export async function getVideoStream(videoId, quality = "HIGH", options = {}) {
const {
allowProxyFallback = true,
includeRequestMeta = false,
preferDirect = true,
playbackMode = "STREAM",
} = options;
return apiFetch(`videos/${videoId}/playbackinfopostpaywall`, {
videoquality: quality,
playbackmode: playbackMode,
assetpresentation: "FULL",
}, { preferDirect, allowProxyFallback, includeRequestMeta });
}