Files
tiddl-gui/web/js/config.js

331 lines
15 KiB
JavaScript

/**
* tiddl-web — configuration
*
* Credentials mirror the ones embedded in the Python CLI (already public in
* the open-source repository). They are stored the same way — as a single
* base-64 string that decodes to "clientId;clientSecret".
*
* Also exports proxyFetch — a unified helper that routes an HTTP request
* through the configured CORS proxy, with an optional direct-first fallback.
* Using proxyFetch instead of raw fetch gives every caller automatic timeouts
* and retry back-off via the underlying robustFetch primitive.
*/
import { robustFetch, API_TIMEOUT_MS, isNetworkError } from "./http.js";
const _raw = atob(
"NE4zbjZRMXg5NUxMNUs3cDtvS09YZkpXMzcxY1g2eGFaMFB5aGdHTkJkTkxsQlpkNEFLS1lvdWdNamlrPQ=="
);
const [DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET] = _raw.split(";");
export const AUTH_URL = "https://auth.tidal.com/v1/oauth2";
export const API_URL = "https://api.tidal.com/v1";
// ─── Credentials ─────────────────────────────────────────────────────────────
export function getClientId() {
return localStorage.getItem("tiddl_client_id") || DEFAULT_CLIENT_ID;
}
export function getClientSecret() {
return localStorage.getItem("tiddl_client_secret") || DEFAULT_CLIENT_SECRET;
}
export function setClientId(v) {
if (v) localStorage.setItem("tiddl_client_id", v);
else localStorage.removeItem("tiddl_client_id");
}
export function setClientSecret(v) {
if (v) localStorage.setItem("tiddl_client_secret", v);
else localStorage.removeItem("tiddl_client_secret");
}
// ─── CORS proxy ───────────────────────────────────────────────────────────────
/** CORS proxy prefix — every request to Tidal is sent through this URL. */
const DEFAULT_CORS_PROXY = "https://corsproxy.io/?url=";
function isCorsproxyHost(proxy) {
try {
const host = new URL(proxy).hostname.toLowerCase();
return host === "corsproxy.io" || host.endsWith(".corsproxy.io");
} catch {
return false;
}
}
function normalizeCorsProxy(v) {
const raw = String(v ?? "").trim();
if (!raw) return DEFAULT_CORS_PROXY;
const candidate = /^corsproxy\.io(?:\/.*)?$/i.test(raw) ? `https://${raw}` : raw;
if (isCorsproxyHost(candidate)) {
// Accept common forms and normalize to a working prefix.
try {
const parsed = new URL(candidate);
const params = new URLSearchParams(parsed.search);
params.delete("url");
const query = params.toString();
const prefix = `${parsed.origin}${parsed.pathname}`;
return query
? `${prefix}?${query}&url=`
: `${prefix}?url=`;
} catch {
return DEFAULT_CORS_PROXY;
}
}
return raw;
}
export function getCorsProxy() {
const saved = localStorage.getItem("tiddl_cors_proxy");
return normalizeCorsProxy(saved);
}
export function setCorsProxy(v) {
const raw = String(v ?? "").trim();
if (!raw) {
localStorage.removeItem("tiddl_cors_proxy");
return;
}
localStorage.setItem("tiddl_cors_proxy", normalizeCorsProxy(raw));
}
/** Wrap a target URL with the configured CORS proxy. */
export function proxied(url) {
const proxy = getCorsProxy();
// corsproxy.io expects the URL to be encoded as a query param
if (isCorsproxyHost(proxy)) {
return proxy + encodeURIComponent(url);
}
// fallback: just append raw
return proxy + url;
}
// ─── Appearance ───────────────────────────────────────────────────────────────
/** "dark" | "light" | "system" */
export function getTheme() {
return localStorage.getItem("tiddl_theme") || "system";
}
export function setTheme(v) {
localStorage.setItem("tiddl_theme", v);
}
/** Hex accent colour, e.g. "#4fd08c" */
export function getAccentColor() {
return localStorage.getItem("tiddl_accent") || "#4fd08c";
}
export function setAccentColor(v) {
localStorage.setItem("tiddl_accent", v);
}
/** "full" | "reduced" | "minimal" | "classic" */
export function getUiEffects() {
return localStorage.getItem("tiddl_ui_effects") || "full";
}
export function setUiEffects(v) {
localStorage.setItem("tiddl_ui_effects", v);
}
// ─── Download settings ────────────────────────────────────────────────────────
function getSetting(key, def) {
const v = localStorage.getItem(key);
return v === null ? def : v;
}
function setSetting(key, v) {
localStorage.setItem(key, String(v));
}
export function getTrackQuality() { return getSetting("tiddl_track_quality", "HIGH"); }
export function setTrackQuality(v) { setSetting("tiddl_track_quality", v); }
export function getVideoQuality() { return getSetting("tiddl_video_quality", "fhd"); }
export function setVideoQuality(v) { setSetting("tiddl_video_quality", v); }
export function getThreadsCount() { return parseInt(getSetting("tiddl_threads", "4"), 10); }
export function setThreadsCount(v) { setSetting("tiddl_threads", v); }
export function getSkipExisting() { return getSetting("tiddl_skip_existing", "true") === "true"; }
export function setSkipExisting(v) { setSetting("tiddl_skip_existing", v ? "true" : "false"); }
export function getSinglesFilter() { return getSetting("tiddl_singles_filter", "none"); }
export function setSinglesFilter(v) { setSetting("tiddl_singles_filter", v); }
export function getVideosFilter() { return getSetting("tiddl_videos_filter", "none"); }
export function setVideosFilter(v) { setSetting("tiddl_videos_filter", v); }
export function getUpdateMtime() { return getSetting("tiddl_update_mtime", "false") === "true"; }
export function setUpdateMtime(v) { setSetting("tiddl_update_mtime", v ? "true" : "false"); }
export function getRewriteMetadata() { return getSetting("tiddl_rewrite_metadata", "false") === "true"; }
export function setRewriteMetadata(v) { setSetting("tiddl_rewrite_metadata", v ? "true" : "false"); }
// ─── Metadata settings ────────────────────────────────────────────────────────
export function getMetadataEnable() { return getSetting("tiddl_meta_enable", "true") === "true"; }
export function setMetadataEnable(v) { setSetting("tiddl_meta_enable", v ? "true" : "false"); }
export function getMetadataLyrics() { return getSetting("tiddl_meta_lyrics", "false") === "true"; }
export function setMetadataLyrics(v) { setSetting("tiddl_meta_lyrics", v ? "true" : "false"); }
export function getLyricsTimestamps() { return getSetting("tiddl_lyrics_timestamps", "true") === "true"; }
export function setLyricsTimestamps(v) { setSetting("tiddl_lyrics_timestamps", v ? "true" : "false"); }
export function getMetadataCover() { return getSetting("tiddl_meta_cover", "false") === "true"; }
export function setMetadataCover(v) { setSetting("tiddl_meta_cover", v ? "true" : "false"); }
export function getMetadataAlbumReview() { return getSetting("tiddl_meta_album_review", "false") === "true"; }
export function setMetadataAlbumReview(v){ setSetting("tiddl_meta_album_review", v ? "true" : "false"); }
// ─── Cover art settings ───────────────────────────────────────────────────────
export function getCoverSave() { return getSetting("tiddl_cover_save", "false") === "true"; }
export function setCoverSave(v) { setSetting("tiddl_cover_save", v ? "true" : "false"); }
export function getCoverSize() { return parseInt(getSetting("tiddl_cover_size", "1280"), 10); }
export function setCoverSize(v) { setSetting("tiddl_cover_size", v); }
export function getCoverAllowed() { return JSON.parse(getSetting("tiddl_cover_allowed", "[]")); }
export function setCoverAllowed(v) { setSetting("tiddl_cover_allowed", JSON.stringify(v)); }
// ─── M3U settings ─────────────────────────────────────────────────────────────
export function getM3uSave() { return getSetting("tiddl_m3u_save", "false") === "true"; }
export function setM3uSave(v) { setSetting("tiddl_m3u_save", v ? "true" : "false"); }
export function getM3uAllowed() { return JSON.parse(getSetting("tiddl_m3u_allowed", '["album","mix","playlist"]')); }
export function setM3uAllowed(v) { setSetting("tiddl_m3u_allowed", JSON.stringify(v)); }
// ─── Advanced mode ────────────────────────────────────────────────────────────
export function getAdvancedMode() { return getSetting("tiddl_advanced_mode", "false") === "true"; }
export function setAdvancedMode(v) { setSetting("tiddl_advanced_mode", v ? "true" : "false"); }
// ─── Experimental: all-qualities mode ────────────────────────────────────────
/** When enabled, every queue item is downloaded in all four quality tiers. */
export function getAllQualitiesMode() { return getSetting("tiddl_all_qualities", "false") === "true"; }
export function setAllQualitiesMode(v) { setSetting("tiddl_all_qualities", v ? "true" : "false"); }
// ─── Preferred format ─────────────────────────────────────────────────────────
/**
* "m4a" — use the quality tier as-is (LOW/HIGH produce AAC/M4A).
* "flac" — automatically upgrade LOW/HIGH quality to LOSSLESS so downloads
* are always in FLAC format.
*/
export function getPreferredFormat() { return getSetting("tiddl_preferred_format", "m4a"); }
export function setPreferredFormat(v) { setSetting("tiddl_preferred_format", v); }
/** Labels for all quality tiers. */
export const QUALITY_LABELS = {
LOW: "Low", HIGH: "High", LOSSLESS: "HiFi", HI_RES_LOSSLESS: "Max",
};
/** Detailed option text for quality tier selects. */
export const QUALITY_DESCRIPTIONS = {
LOW: "Low — 96 kbps · M4A",
HIGH: "High — 320 kbps · M4A",
LOSSLESS: "HiFi — FLAC 16-bit",
HI_RES_LOSSLESS: "Max — FLAC 24-bit",
};
/** Available quality tiers, highest first. */
export const QUALITY_STANDARD = ["HI_RES_LOSSLESS", "LOSSLESS", "HIGH", "LOW"];
// ─── Search history ───────────────────────────────────────────────────────────
const SEARCH_HISTORY_KEY = "tiddl_search_history";
const SEARCH_HISTORY_MAX = 12;
export function loadSearchHistory() {
try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || "[]"); }
catch { return []; }
}
export function saveToSearchHistory(q) {
if (!q?.trim()) return;
const h = loadSearchHistory().filter(x => x !== q.trim());
h.unshift(q.trim());
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(h.slice(0, SEARCH_HISTORY_MAX)));
}
export function clearSearchHistory() {
localStorage.removeItem(SEARCH_HISTORY_KEY);
}
// ─── File path templates ──────────────────────────────────────────────────────
const TEMPLATE_DEFAULTS = {
default: "{album.artist}/{album.title}/{item.title}",
track: "",
video: "videos/{item.title}",
album: "artists/{album.artist}/{album.title}/{item.title}",
playlist: "{playlist.title}/{playlist.index}. {item.artist} - {item.title}",
mix: "mixes/{mix_id}/{item.artist} - {item.title}",
};
export function getTemplate(type) {
const v = localStorage.getItem(`tiddl_tpl_${type}`);
return v !== null ? v : (TEMPLATE_DEFAULTS[type] ?? "");
}
export function setTemplate(type, v) {
localStorage.setItem(`tiddl_tpl_${type}`, v);
}
export { TEMPLATE_DEFAULTS };
// ─── Unified direct / proxy request helper ────────────────────────────────────
/**
* Make an HTTP request, optionally routing it through the CORS proxy.
*
* `strategy` controls which URL(s) are tried and in which order:
* "proxy-only" — always use the proxy (safest for Tidal API calls)
* "direct-only" — never use the proxy
* "direct-then-proxy" — try direct first; fall back to proxy on network error
* "proxy-then-direct" — try proxy first; fall back to direct on network error
*
* When there is no proxy configured the strategy degrades to "direct-only".
*
* The returned `{ res, usedProxy }` object contains the raw Response and a
* boolean indicating which path succeeded, so callers can adapt their retry
* strategy (e.g. segment token binding in download.js).
*
* @param {string} url Target URL (before proxying).
* @param {RequestInit} [init] Fetch options (headers, body, method …).
* @param {object} [opts]
* @param {string} [opts.strategy="proxy-only"]
* @param {number} [opts.timeoutMs=API_TIMEOUT_MS]
* @param {number} [opts.retries=2]
* @returns {Promise<{ res: Response, usedProxy: boolean }>}
*/
export async function proxyFetch(url, init = {}, {
strategy = "proxy-only",
timeoutMs = API_TIMEOUT_MS,
retries = 2,
} = {}) {
const proxyUrl = proxied(url);
const hasProxy = proxyUrl !== url;
const fetchOne = async (useProxy) => {
const target = useProxy ? proxyUrl : url;
const res = await robustFetch(target, init, { timeoutMs, retries });
return { res, usedProxy: useProxy };
};
const _isNetworkFailure = isNetworkError;
// When no proxy is configured every strategy falls through to direct.
if (!hasProxy) return fetchOne(false);
if (strategy === "proxy-only") return fetchOne(true);
if (strategy === "direct-only") return fetchOne(false);
// Fallback strategies: try primary, catch network errors, try secondary.
const primaryIsProxy = strategy === "proxy-then-direct";
try {
return await fetchOne(primaryIsProxy);
} catch (err) {
if (!_isNetworkFailure(err)) throw err;
return fetchOne(!primaryIsProxy);
}
}