mirror of
https://github.com/19JVJeffery/tiddl-gui.git
synced 2026-10-22 02:42:16 -05:00
Agent-Logs-Url: https://github.com/19JVJeffery/tiddl-gui/sessions/cf57e19a-e64d-4765-8566-e1adceb12b55 Co-authored-by: 19JVJeffery <94115228+19JVJeffery@users.noreply.github.com>
283 lines
9.3 KiB
JavaScript
283 lines
9.3 KiB
JavaScript
/**
|
||
* tiddl-web — Tidal API client
|
||
*
|
||
* Thin wrapper around api.tidal.com/v1 — mirrors the Python TidalAPI class.
|
||
*/
|
||
|
||
import { API_URL, proxied } from "./config.js";
|
||
import { getValidToken, loadAuth, refreshToken } from "./auth.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}`;
|
||
|
||
async function doRequest(token, useProxy = true) {
|
||
const targetUrl = useProxy ? proxied(url) : url;
|
||
const res = await fetch(targetUrl, {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
Accept: "application/json",
|
||
},
|
||
credentials: "omit",
|
||
});
|
||
let json = {};
|
||
try {
|
||
json = await res.json();
|
||
} catch {
|
||
json = {};
|
||
}
|
||
return { res, json, usedProxy: useProxy };
|
||
}
|
||
|
||
async function doPreferredRequest(token) {
|
||
if (!preferDirect) return doRequest(token, true);
|
||
try {
|
||
return await doRequest(token, false);
|
||
} catch (err) {
|
||
if (!allowProxyFallback) throw err;
|
||
const msg = String(err?.message || "").toLowerCase();
|
||
const shouldFallbackToProxy = err instanceof TypeError
|
||
|| /failed to fetch|networkerror|network error|load failed|cors|cross-origin/.test(msg);
|
||
if (!shouldFallbackToProxy) throw err;
|
||
// Some environments block direct API CORS requests; fall back to proxy.
|
||
return doRequest(token, true);
|
||
}
|
||
}
|
||
|
||
let token = await getValidToken();
|
||
if (!token) throw new Error("Not authenticated");
|
||
|
||
let { res, json, usedProxy } = await doPreferredRequest(token);
|
||
if (!res.ok && (res.status === 401 || res.status === 403)) {
|
||
try {
|
||
const refreshed = await refreshToken();
|
||
token = refreshed?.token;
|
||
if (token) {
|
||
({ res, json, usedProxy } = await doPreferredRequest(token));
|
||
}
|
||
} catch {
|
||
// Keep original error handling 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 });
|
||
}
|