/** * tiddl-web — main application controller */ const LOGIN_SUCCESS_REDIRECT_DELAY_MS = 800; import { getDeviceAuth, pollDeviceAuth, logout, isLoggedIn, loadAuth, saveAuth, } from "./auth.js"; import { search, getTrack, getArtist, getArtistAlbums, getArtistSingles, getAlbum, getPlaylist, getVideo, getUserFavoriteTracks, getUserFavoriteAlbums, getUserFavoritePlaylists, getUserPlaylists, getAllUserFavoriteTracks, getAllUserFavoriteAlbums, getAllUserFavoritePlaylists, getAllUserPlaylists, getAllAlbumItems, getAllPlaylistItems, } from "./api.js"; import { parseTidalInput, downloadTrack, downloadAlbum, downloadPlaylist, downloadMix, downloadArtistAlbums, } from "./download.js"; import { getTheme, getAccentColor, getTrackQuality, setTrackQuality, getAdvancedMode, getAllQualitiesMode, getPreferredFormat, QUALITY_LABELS, QUALITY_DESCRIPTIONS, QUALITY_STANDARD, loadSearchHistory, saveToSearchHistory, clearSearchHistory, } from "./config.js"; import { applyTheme, applyAccentColor, cycleTheme, initThemeUI, initAccentColorUI, initTemplateBuilders, loadSettingsForm, saveSettingsForm, initBrowserChrome, initUiEffectsUI, initExperimentalSettingUI, } from "./settings.js"; // ─── Constants ──────────────────────────────────────────────────────────────── // QUALITY_LABELS, QUALITY_STANDARD are imported from config.js /** Quality tiers ordered from highest to lowest, used to find the best quality in a collection. */ const QUALITY_ORDER = [...QUALITY_STANDARD]; /** Returns HTML for a small quality pill, or empty string when quality is unknown. */ function qualityPill(quality) { if (!quality || !QUALITY_LABELS[quality]) return ""; return `${escHtml(QUALITY_LABELS[quality])}`; } /** * Returns the best available quality key for a track or album object. * Tidal's `audioQuality` field can report `LOSSLESS` even for Hi-Res content; * the authoritative indicator is `mediaMetadata.tags` which includes * `"HIRES_LOSSLESS"` when the Max tier is available. */ function effectiveQuality(item) { if (!item) return null; const tags = item.mediaMetadata?.tags; if (Array.isArray(tags) && tags.includes("HIRES_LOSSLESS")) return "HI_RES_LOSSLESS"; return item.audioQuality || null; } /** Returns the display title for a track, appending the version in parentheses when present. */ function formatTrackTitle(track) { return track.version ? `${track.title} (${track.version})` : track.title; } /** Formats a duration in total seconds to M:SS or H:MM:SS. */ function formatDuration(totalSeconds) { const s = Math.round(totalSeconds || 0); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; if (h > 0) { return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; } return `${m}:${String(sec).padStart(2, "0")}`; } /** * File extensions that are binary formats where reading as UTF-8 text is * unreliable and Tidal URL extraction will likely fail. * - .doc / .docx / .odt: ZIP-compressed XML — content is not readable as text * - .pdf: binary object-stream format — text may be partially readable but not guaranteed */ const BINARY_OFFICE_EXTS = new Set(["doc", "docx", "odt", "pdf"]); // ─── Utility ───────────────────────────────────────────────────────────────── function $(id) { return document.getElementById(id); } function show(el) { if (el) el.classList.remove("hidden"); } function hide(el) { if (el) el.classList.add("hidden"); } function setHtml(el, html) { if (el) el.innerHTML = html; } function escHtml(str) { return String(str) .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function coverUrl(hash, size = 320) { return hash ? `https://resources.tidal.com/images/${hash.replace(/-/g, "/")}/${size}x${size}.jpg` : ""; } /** * Extract candidate Tidal resource tokens from arbitrary file text. * * Strategy: * 1. Strip RTF control sequences so URLs inside .rtf are exposed as plain text. * 2. Find all full Tidal HTTPS URLs via regex — handles JSON values, HTML href * attributes, YAML strings, XML content, Markdown links, etc. * 3. Find short resource IDs (e.g. "track/103805726") anywhere in the text. * 4. If neither regex found anything (truly plain-text files that may use bare * numbers or non-URL forms), fall back to per-line parsing — but only accept * lines that already look like a URL, a short ID, or a bare numeric track ID * to avoid flooding the activity log with warnings for every non-URL line. * * @param {string} rawText UTF-8 decoded file content * @param {string} ext Lowercase file extension without the dot * @returns {string[]} Deduplicated list of candidate token strings */ function extractTidalTokens(rawText, ext) { let text = rawText; // RTF: strip backslash control words, hex escapes, and braces to expose plain text if (ext === "rtf") { text = text .replace(/\\[a-z*]+-?\d*\s?/gi, " ") // control words like \rtf1, \fonttbl (allow any whitespace separator) .replace(/\\'[0-9a-f]{2}/gi, "") // hex character escapes like \'e9 .replace(/[{}\\]/g, " "); // braces and remaining backslashes } const tokens = new Set(); // ── 1. Full Tidal HTTPS URLs ──────────────────────────────────────────────── // Matches https://tidal.com/..., https://listen.tidal.com/..., etc. // Exclude braces so JSON object boundaries don't get consumed into the URL. const tidalUrlRe = /https?:\/\/(?:[a-z0-9-]+\.)*tidal\.com\/[^\s"'<>&,;)\]\\{}]+/gi; for (const m of text.matchAll(tidalUrlRe)) { const url = m[0].replace(/[.,;)>\]'"\\}]+$/, "").trim(); if (url) tokens.add(url); } // ── 2. Short resource IDs ─────────────────────────────────────────────────── // Matches track/123, album/abc-uuid, playlist/uuid, mix/id, video/id, artist/id. // Upper bound of 80 chars covers the longest expected Tidal IDs (playlist UUIDs // are 36 chars; using 80 gives headroom without matching unrelated path segments). const shortIdRe = /\b(track|album|playlist|mix|video|artist)\/([a-zA-Z0-9_-]{2,80})\b/gi; for (const m of text.matchAll(shortIdRe)) { tokens.add(`${m[1].toLowerCase()}/${m[2]}`); } // ── 3. Line-by-line fallback (plain .txt / .csv / etc.) ───────────────────── // Only runs when neither regex found anything, and only accepts lines that // look like a Tidal URL, a short resource ID, or a bare numeric track ID. // Lines longer than 500 characters are skipped — legitimate Tidal URLs and // resource IDs are always shorter; very long lines indicate non-URL content. if (tokens.size === 0) { for (const raw of text.split(/[\n\r,\t;|]+/)) { const line = raw.trim().replace(/^["'`([\]{}<>]+|["'`)\]}>.,;\\]+$/g, "").trim(); if (!line || line.length > 500) continue; if ( line.startsWith("http") || /^(track|album|playlist|mix|video|artist)\//i.test(line) || /^\d{4,12}$/.test(line) // bare numeric track ID ) { tokens.add(line); } } } return [...tokens]; } // ─── Log ───────────────────────────────────────────────────────────────────── function appendLog(msg, type = "info") { const log = $("download-log"); if (!log) return; const line = document.createElement("div"); line.className = `log-line log-${type}`; line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; log.appendChild(line); log.scrollTop = log.scrollHeight; } function clearLog() { const log = $("download-log"); if (log) log.innerHTML = ""; } // ─── Download status panel tabs ─────────────────────────────────────────────── function switchDlTab(tab) { const progressView = $("dl-progress-view"); const logView = $("dl-log-view"); const progressBtn = $("dl-tab-progress-btn"); const logBtn = $("dl-tab-log-btn"); const clearLogBtn = $("btn-clear-log"); const exportLogBtn = $("btn-export-log"); if (tab === "log") { progressView?.classList.add("hidden"); logView?.classList.remove("hidden"); progressBtn?.classList.remove("active"); logBtn?.classList.add("active"); clearLogBtn?.classList.remove("hidden"); exportLogBtn?.classList.remove("hidden"); logBtn?.setAttribute("aria-selected", "true"); progressBtn?.setAttribute("aria-selected", "false"); } else { logView?.classList.add("hidden"); progressView?.classList.remove("hidden"); logBtn?.classList.remove("active"); progressBtn?.classList.add("active"); clearLogBtn?.classList.add("hidden"); exportLogBtn?.classList.add("hidden"); progressBtn?.setAttribute("aria-selected", "true"); logBtn?.setAttribute("aria-selected", "false"); } } function exportLog() { const log = $("download-log"); if (!log) return; const lines = [...log.querySelectorAll(".log-line")].map((el) => el.textContent).join("\n"); if (!lines) return; const blob = new Blob([lines], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `tiddl-log-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.txt`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } // ─── Per-item progress list ─────────────────────────────────────────────────── function buildProgressList(items) { const list = $("dl-item-progress-list"); if (!list) return; list.innerHTML = items.map((item, idx) => { const thumbHtml = item.cover ? `` : `
`; const isMulti = ["album", "playlist", "mix", "artist"].includes(item.type); const expandBtn = isMulti ? `` : ""; return `
${thumbHtml}
${escHtml(item.title)}
Waiting\u2026
${expandBtn}
`; }).join(""); // Use event delegation on the list container to avoid per-button listeners list.addEventListener("click", (e) => { const btn = e.target.closest(".dl-item-expand-btn"); if (!btn) return; const idx = btn.dataset.expandIdx; const subList = $(`dl-item-sub-${idx}`); if (!subList) return; const expanded = btn.getAttribute("aria-expanded") === "true"; btn.setAttribute("aria-expanded", String(!expanded)); btn.classList.toggle("expanded", !expanded); subList.classList.toggle("hidden", expanded); }); } // ─── Per-item sub-progress rows ─────────────────────────────────────────────── function upsertSubItemRow(rowIdx, subIdx, title, done, total, status) { const subList = $(`dl-item-sub-${rowIdx}`); if (!subList) return; let subRow = $(`dl-sub-item-${rowIdx}-${subIdx}`); if (!subRow) { subRow = document.createElement("div"); subRow.id = `dl-sub-item-${rowIdx}-${subIdx}`; subRow.className = "dl-sub-item dl-sub-item--active"; subRow.innerHTML = `
${escHtml(title)}
`; subList.appendChild(subRow); } const pct = total > 0 ? Math.round((done / total) * 100) : 0; const barEl = $(`dl-sub-item-bar-${rowIdx}-${subIdx}`); const iconEl = $(`dl-sub-item-icon-${rowIdx}-${subIdx}`); const titleEl = $(`dl-sub-item-title-${rowIdx}-${subIdx}`); if (barEl) barEl.style.width = `${pct}%`; if (titleEl) titleEl.textContent = title; subRow.className = `dl-sub-item dl-sub-item--${status}`; if (iconEl) { iconEl.textContent = status === "done" ? "\u2713" : status === "failed" ? "\u2717" : ""; } } // ─── Nav ───────────────────────────────────────────────────────────────────── let _detailOriginTab = "search"; function activateTab(tabId) { document.querySelectorAll(".tab-btn").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === tabId) ); document.querySelectorAll(".tab-panel").forEach((panel) => panel.classList.toggle("hidden", panel.id !== `panel-${tabId}`) ); } function openDetailPanel(fromTab) { _detailOriginTab = fromTab || "search"; // Show detail panel without changing sidebar active state document.querySelectorAll(".tab-panel").forEach((p) => p.classList.toggle("hidden", p.id !== "panel-detail") ); // Keep origin tab highlighted document.querySelectorAll(".tab-btn").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === _detailOriginTab) ); const lbl = $("btn-detail-back-label"); if (lbl) { const names = { search: "Search results", library: "Library" }; lbl.textContent = names[_detailOriginTab] || "Back"; } } function backFromDetail() { activateTab(_detailOriginTab); } // ─── Auth UI ───────────────────────────────────────────────────────────────── function updateAuthBadge() { const badge = $("auth-badge"); const logoutBtn = $("btn-logout"); const loggedInView = $("auth-logged-in"); const loginView = $("auth-login-view"); if (!badge) return; if (isLoggedIn()) { const auth = loadAuth(); const username = auth?.username || "Logged in"; badge.textContent = username; badge.className = "auth-badge auth-badge--in"; show(logoutBtn); // Show logged-in view on Account tab if (loggedInView) { show(loggedInView); const nameEl = $("auth-account-username"); if (nameEl) nameEl.textContent = username; } if (loginView) hide(loginView); } else { badge.textContent = "Not logged in"; badge.className = "auth-badge auth-badge--out"; hide(logoutBtn); if (loggedInView) hide(loggedInView); if (loginView) show(loginView); } } let pollTimer = null; async function startLogin() { const loginBtn = $("btn-login"); const loginStatus = $("login-status"); loginBtn.disabled = true; setHtml(loginStatus, ' Requesting device code\u2026'); try { const deviceAuth = await getDeviceAuth(); const uri = `https://${deviceAuth.verificationUriComplete}`; setHtml(loginStatus, `

Open the link below and approve the request, then wait here.

${escHtml(uri)}` ); const endAt = Date.now() + deviceAuth.expiresIn * 1000; const poll = async () => { if (Date.now() >= endAt) { setHtml(loginStatus, 'Authentication expired. Try again.'); loginBtn.disabled = false; return; } try { const authResponse = await pollDeviceAuth(deviceAuth.deviceCode); saveAuth({ token: authResponse.access_token, refresh_token: authResponse.refresh_token, expires_at: Math.floor(Date.now() / 1000) + authResponse.expires_in, user_id: String(authResponse.user_id), country_code: authResponse.user?.countryCode || "US", username: authResponse.user?.username || authResponse.user?.email || "User", }); setHtml(loginStatus, '\u2713 Logged in successfully!'); loginBtn.disabled = false; updateAuthBadge(); sessionStorage.setItem("tiddl_session_welcome", "1"); setTimeout(() => { activateTab("library"); loadLibraryIfNeeded(); }, LOGIN_SUCCESS_REDIRECT_DELAY_MS); } catch (err) { if (err?.status === 400 && err?.error === "authorization_pending") { const secsLeft = Math.max(0, Math.round((endAt - Date.now()) / 1000)); const mins = Math.floor(secsLeft / 60); const secs = secsLeft % 60; setHtml(loginStatus, `

Waiting for approval\u2026 ${mins}:${String(secs).padStart(2, "0")}

${escHtml(uri)}` ); pollTimer = setTimeout(poll, deviceAuth.interval * 1000); } else if (err?.error === "expired_token") { setHtml(loginStatus, 'Device code expired. Try again.'); loginBtn.disabled = false; } else { const msg = err?.error_description || err?.message || JSON.stringify(err); setHtml(loginStatus, `Error: ${escHtml(msg)}`); loginBtn.disabled = false; } } }; pollTimer = setTimeout(poll, deviceAuth.interval * 1000); } catch (err) { const msg = err?.message || JSON.stringify(err); const isNetworkError = msg.toLowerCase().includes("failed to fetch") || msg.toLowerCase().includes("networkerror"); const hint = isNetworkError ? `
A browser extension or firewall may be blocking the CORS proxy. Try disabling extensions, or change the proxy in Settings \u2192 Advanced.` : ""; setHtml(loginStatus, `Failed to start login: ${escHtml(msg)}${hint}`); loginBtn.disabled = false; } } async function handleLogout() { if (pollTimer) clearTimeout(pollTimer); await logout(); updateAuthBadge(); setHtml($("login-status"), ""); activateTab("auth"); appendLog("Logged out.", "info"); } // ─── Quality ───────────────────────────────────────────────────────────────── /** Returns the available quality options. */ function visibleQualityOptions() { return QUALITY_STANDARD; } /** * Build