mirror of
https://github.com/19JVJeffery/tiddl-gui.git
synced 2026-10-22 10:52:16 -05:00
Agent-Logs-Url: https://github.com/19JVJeffery/tiddl-gui/sessions/515ffd40-be76-458c-a57d-95bdc4d6d84c Co-authored-by: 19JVJeffery <94115228+19JVJeffery@users.noreply.github.com>
277 lines
9.2 KiB
JavaScript
277 lines
9.2 KiB
JavaScript
/**
|
||
* 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 });
|
||
}
|