/** * tiddl-web — download module * * Parses TIDAL stream manifests, fetches audio/video segments, and * triggers a browser file-save. Mirrors tiddl/core/utils/parse.py and * tiddl/core/utils/download.py. */ import { proxied, getMetadataEnable, getMetadataCover, getCoverSave, getCoverAllowed, getCoverSize } from "./config.js"; import { getTrackStream, getVideoStream, getTrack, getAlbum, getAlbumItems, getPlaylist, getPlaylistItems, getMixItems, getArtistAlbums, getArtistSingles } from "./api.js"; /** Delay (ms) before revoking an object URL after triggering a download. */ const BLOB_URL_REVOKE_DELAY_MS = 1000; // ─── Manifest parsing ────────────────────────────────────────────────────── /** * Parse a `application/vnd.tidal.bts` (BTS JSON) manifest. * Returns { urls: string[], extension: string }. */ function parseBtsManifest(manifest) { const decoded = atob(manifest); const data = JSON.parse(decoded); let extension = ".m4a"; if (data.codecs === "flac") extension = ".flac"; return { urls: data.urls, extension, encryptionType: data.encryptionType }; } /** * Parse a `application/dash+xml` (MPEG-DASH) manifest. * Returns { urls: string[], extension: string }. */ function parseDashManifest(manifest) { const decoded = atob(manifest); const parser = new DOMParser(); const doc = parser.parseFromString(decoded, "application/xml"); const NS = "urn:mpeg:dash:schema:mpd:2011"; const segTpl = doc.getElementsByTagNameNS(NS, "SegmentTemplate")[0]; const urlTemplate = segTpl?.getAttribute("media") || ""; const timeline = doc.getElementsByTagNameNS(NS, "S"); let total = 0; for (const el of timeline) { total += 1; const r = el.getAttribute("r"); if (r) total += parseInt(r, 10); } const urls = []; // Segments are $Number$-indexed starting at 0 through `total` inclusive, // matching the Python parse_manifest_XML: range(0, total + 1). for (let i = 0; i <= total; i++) { urls.push(urlTemplate.replace("$Number$", String(i))); } // MPEG-DASH always delivers segments in ISO BMFF (fMP4) containers, // regardless of the audio codec. Use .m4a for all DASH streams. return { urls, extension: ".m4a", encryptionType: "NONE" }; } function parseStreamManifest(streamInfo) { const { manifest, manifestMimeType } = streamInfo; switch (manifestMimeType) { case "application/vnd.tidal.bts": return parseBtsManifest(manifest); case "application/dash+xml": return parseDashManifest(manifest); default: throw new Error(`Unsupported manifest type: ${manifestMimeType}`); } } // ─── Segment fetching ────────────────────────────────────────────────────── /** * Fetch a single segment URL, trying direct first and falling back to the * configured CORS proxy. * * CDN segment URLs (e.g. Amazon CloudFront / Akamai) carry their own * time-limited auth tokens and are often accessible directly from the browser. * Using a CORS proxy can cause 403s because some CDNs bind the token to the * originating IP, which changes when traffic is routed via a proxy server. * Direct fetch is therefore attempted first; the proxy is only used if the * direct request fails (e.g. due to a CORS restriction in the browser). */ async function fetchSegment(url) { // 1. Try direct (no proxy) — works for most CDN segments try { const res = await fetch(url); if (res.ok) return res; // Non-2xx from CDN — fall through to proxy attempt } catch { // Network / CORS error — fall through } // 2. Fall back to proxy const proxiedUrl = proxied(url); if (proxiedUrl === url) { // Proxy not configured; nothing more to try throw new Error(`Segment fetch failed (no proxy configured): ${url}`); } const res2 = await fetch(proxiedUrl); if (!res2.ok) { throw new Error(`Segment fetch failed: ${res2.status} ${url}`); } return res2; } /** * Fetch all segment URLs and concatenate them into a single Uint8Array. * Calls onProgress(downloaded, total) after each segment. */ async function fetchSegments(urls, onProgress) { const chunks = []; let downloaded = 0; const total = urls.length; for (const url of urls) { const res = await fetchSegment(url); const buf = await res.arrayBuffer(); chunks.push(new Uint8Array(buf)); downloaded++; if (onProgress) onProgress(downloaded, total); } // Concatenate const totalBytes = chunks.reduce((acc, c) => acc + c.byteLength, 0); const combined = new Uint8Array(totalBytes); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } return combined; } // ─── Browser save helper ─────────────────────────────────────────────────── function triggerDownload(data, filename, mimeType) { const blob = data instanceof Blob ? data : new Blob([data], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, BLOB_URL_REVOKE_DELAY_MS); } // ─── MIME type helper ────────────────────────────────────────────────────── function extToMime(ext) { switch (ext) { case ".flac": return "audio/flac"; case ".m4a": return "audio/mp4"; case ".mp4": return "video/mp4"; default: return "application/octet-stream"; } } // ─── Sanitize filename ──────────────────────────────────────────────────── /** Maximum filename length to stay within common filesystem limits. */ const MAX_FILENAME_LENGTH = 200; function sanitize(name) { return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim().slice(0, MAX_FILENAME_LENGTH); } // ─── Cover art helpers ───────────────────────────────────────────────────── /** * Fetch cover art JPEG bytes from the Tidal image CDN. * Tries a direct request first; falls back to the configured CORS proxy. * Returns null on any failure so callers can silently skip embedding. * * @param {string} coverHash UUID-style hash from track.album.cover, * e.g. "12345678-1234-1234-1234-123456789012" * @param {number} size Square image size in pixels (default 1280) * @returns {Promise} */ async function fetchCoverArt(coverHash, size = 1280) { if (!coverHash) return null; const url = `https://resources.tidal.com/images/${coverHash.replace(/-/g, "/")}/${size}x${size}.jpg`; try { let res = await fetch(url); if (!res.ok) { const proxiedUrl = proxied(url); if (proxiedUrl !== url) res = await fetch(proxiedUrl); } if (res.ok) return new Uint8Array(await res.arrayBuffer()); } catch { /* ignore — cover embedding is best-effort */ } return null; } /** * Inject a JPEG image as a METADATA_BLOCK_PICTURE into a FLAC file. * Appends the PICTURE block (type 6) as the new last metadata block, * replacing the "last" flag that was on the previous final block. * * @param {Uint8Array} flacData Raw FLAC file bytes * @param {Uint8Array} jpegBytes JPEG image bytes * @returns {Uint8Array} New FLAC bytes with cover embedded (or original on error) */ function injectFlacCover(flacData, jpegBytes) { // Verify "fLaC" signature if (flacData[0] !== 0x66 || flacData[1] !== 0x4C || flacData[2] !== 0x61 || flacData[3] !== 0x43) { return flacData; } const mimeBytes = new TextEncoder().encode("image/jpeg"); const pictureDataSize = 4 + // picture type 4 + mimeBytes.length + // MIME type length + bytes 4 + // description length (0) 4 + 4 + 4 + 4 + // width, height, depth, color count 4 + jpegBytes.length; // data length + data const pictureData = new Uint8Array(pictureDataSize); const pv = new DataView(pictureData.buffer); let off = 0; pv.setUint32(off, 3, false); off += 4; // type: front cover pv.setUint32(off, mimeBytes.length, false); off += 4; pictureData.set(mimeBytes, off); off += mimeBytes.length; pv.setUint32(off, 0, false); off += 4; // description length = 0 pv.setUint32(off, 0, false); off += 4; // width (unknown) pv.setUint32(off, 0, false); off += 4; // height (unknown) pv.setUint32(off, 24, false); off += 4; // color depth pv.setUint32(off, 0, false); off += 4; // indexed colors pv.setUint32(off, jpegBytes.length, false); off += 4; // data length pictureData.set(jpegBytes, off); // Walk the metadata-block chain to locate any existing PICTURE block and // the overall last metadata block. let pos = 4; let lastBlockStart = 4; let pictureStart = -1; let pictureBlockTotal = 0; while (pos + 4 <= flacData.length) { lastBlockStart = pos; const header = flacData[pos]; const blockType = header & 0x7F; const blockLen = (flacData[pos + 1] << 16) | (flacData[pos + 2] << 8) | flacData[pos + 3]; if (blockType === 6 && pictureStart < 0) { // type 6 = PICTURE pictureStart = pos; pictureBlockTotal = 4 + blockLen; } pos += 4 + blockLen; if (header & 0x80) break; // isLast } const metadataEnd = pos; // start of audio data if (pictureStart >= 0) { // Replace existing PICTURE block in-place (size may change) const originalIsLast = (flacData[pictureStart] & 0x80) !== 0; const newTotal = 4 + pictureDataSize; const delta = newTotal - pictureBlockTotal; const result = new Uint8Array(flacData.length + delta); result.set(flacData.slice(0, pictureStart)); result[pictureStart] = (originalIsLast ? 0x80 : 0) | 6; result[pictureStart + 1] = (pictureDataSize >> 16) & 0xFF; result[pictureStart + 2] = (pictureDataSize >> 8) & 0xFF; result[pictureStart + 3] = pictureDataSize & 0xFF; result.set(pictureData, pictureStart + 4); result.set(flacData.slice(pictureStart + pictureBlockTotal), pictureStart + newTotal); return result; } // No existing PICTURE — append as the new last block before audio data const result = new Uint8Array(flacData.length + 4 + pictureDataSize); result.set(flacData.slice(0, metadataEnd)); result[lastBlockStart] = flacData[lastBlockStart] & 0x7F; // clear "last" flag result[metadataEnd] = 0x80 | 6; // last=1, type=PICTURE result[metadataEnd + 1] = (pictureDataSize >> 16) & 0xFF; result[metadataEnd + 2] = (pictureDataSize >> 8) & 0xFF; result[metadataEnd + 3] = pictureDataSize & 0xFF; result.set(pictureData, metadataEnd + 4); result.set(flacData.slice(metadataEnd), metadataEnd + 4 + pictureDataSize); return result; } // ── M4A / MPEG-4 helpers ──────────────────────────────────────────────────── function _readU32BE(data, off) { return (data[off] * 0x1000000) + ((data[off + 1] << 16) | (data[off + 2] << 8) | data[off + 3]); } function _writeU32BE(data, off, v) { data[off] = (v >>> 24) & 0xFF; data[off + 1] = (v >>> 16) & 0xFF; data[off + 2] = (v >>> 8) & 0xFF; data[off + 3] = v & 0xFF; } /** * Find the first MP4 box of the given 4-char type within [start, end). * Returns { start, size } or null. */ function _findBox(data, type, start, end) { let pos = start; while (pos + 8 <= end) { const size = _readU32BE(data, pos); if (size < 8 || pos + size > end) break; const t = String.fromCharCode(data[pos+4], data[pos+5], data[pos+6], data[pos+7]); if (t === type) return { start: pos, size }; pos += size; } return null; } /** * Walk the MP4 box region data[start..end), recursing into known container * boxes, and add `delta` to every stco/co64 entry whose value is strictly * greater than `threshold`. * * This must be called after inserting bytes inside moov so that the absolute * chunk-offset tables (stco/co64) remain valid when mdat lies after the edit * point. When mdat precedes moov the entries will all be below the threshold * and are left untouched. */ function _shiftChunkOffsets(data, start, end, threshold, delta) { if (delta === 0) return; let pos = start; while (pos + 8 <= end) { const size = _readU32BE(data, pos); if (size < 8 || pos + size > end) break; const t = String.fromCharCode(data[pos+4], data[pos+5], data[pos+6], data[pos+7]); if (t === "stco") { // FullBox: 8-byte header + 4-byte version/flags + 4-byte entry_count + N×4-byte offsets const count = _readU32BE(data, pos + 12); for (let i = 0; i < count; i++) { const o = pos + 16 + i * 4; const v = _readU32BE(data, o); if (v > threshold) _writeU32BE(data, o, v + delta); } } else if (t === "co64") { // FullBox: 8-byte header + 4-byte version/flags + 4-byte entry_count + N×8-byte offsets const count = _readU32BE(data, pos + 12); for (let i = 0; i < count; i++) { const o = pos + 16 + i * 8; const hi = _readU32BE(data, o); const lo = _readU32BE(data, o + 4); if (hi > 0 || (hi === 0 && lo > threshold)) { const newLo = (lo + delta) >>> 0; const carry = (lo + delta) > 0xFFFFFFFF ? 1 : 0; _writeU32BE(data, o, hi + carry); _writeU32BE(data, o + 4, newLo); } } } else if (t === "trak" || t === "mdia" || t === "minf" || t === "stbl") { _shiftChunkOffsets(data, pos + 8, pos + size, threshold, delta); } pos += size; } } /** * Inject a JPEG image as a 'covr' atom into an M4A (MPEG-4) file. * * Searches for the ilst container via multiple common paths: * moov > udta > meta > ilst (iTunes/QuickTime — standard BTS files) * moov > meta > ilst (no udta wrapper) * moov > ilst (bare ilst directly under moov) * * If no ilst is found (e.g. fMP4 / DASH streams that lack iTunes metadata), * a full udta > meta > hdlr > ilst structure is created from scratch. * * After inserting or replacing the covr atom all ancestor box sizes are * updated, and the stco/co64 chunk-offset tables are adjusted so that audio * data is still found at the correct position in the file. * * @param {Uint8Array} m4aData Raw M4A file bytes * @param {Uint8Array} jpegBytes JPEG image bytes * @returns {Uint8Array} New M4A bytes with cover embedded (or original on error) */ function injectM4aCover(m4aData, jpegBytes) { // Build the covr atom: covr [ data [ type=JPEG + locale=0 + jpegBytes ] ] const dataAtomSize = 8 + 4 + 4 + jpegBytes.length; // hdr + type + locale + data const covrAtomSize = 8 + dataAtomSize; const covrAtom = new Uint8Array(covrAtomSize); const cv = new DataView(covrAtom.buffer); let off = 0; cv.setUint32(off, covrAtomSize, false); off += 4; covrAtom.set([0x63, 0x6F, 0x76, 0x72], off); off += 4; // "covr" cv.setUint32(off, dataAtomSize, false); off += 4; covrAtom.set([0x64, 0x61, 0x74, 0x61], off); off += 4; // "data" cv.setUint32(off, 0x0000000D, false); off += 4; // type indicator: JPEG cv.setUint32(off, 0, false); off += 4; // locale covrAtom.set(jpegBytes, off); const moov = _findBox(m4aData, "moov", 0, m4aData.length); if (!moov) return m4aData; // Try multiple possible ilst locations (highest-precedence first): // Path 1: moov > udta > meta > ilst (iTunes standard, BTS manifests) // Path 2: moov > meta > ilst (no udta wrapper) // Path 3: moov > ilst (bare ilst under moov) // ancestors = boxes whose sizes must grow by delta, listed inner→outer (moov last) let ilst = null; let ancestors = []; const udta = _findBox(m4aData, "udta", moov.start + 8, moov.start + moov.size); if (udta) { // 'meta' is a FullBox — children start 4 bytes after the regular 8-byte header const metaU = _findBox(m4aData, "meta", udta.start + 8, udta.start + udta.size); if (metaU) { const il = _findBox(m4aData, "ilst", metaU.start + 12, metaU.start + metaU.size); if (il) { ilst = il; ancestors = [metaU, udta, moov]; } } } if (!ilst) { const metaM = _findBox(m4aData, "meta", moov.start + 8, moov.start + moov.size); if (metaM) { const il = _findBox(m4aData, "ilst", metaM.start + 12, metaM.start + metaM.size); if (il) { ilst = il; ancestors = [metaM, moov]; } } } if (!ilst) { const il = _findBox(m4aData, "ilst", moov.start + 8, moov.start + moov.size); if (il) { ilst = il; ancestors = [moov]; } } if (ilst) { // Find any existing 'covr' to replace, or append a new one at the end of ilst const existingCovr = _findBox(m4aData, "covr", ilst.start + 8, ilst.start + ilst.size); let result, delta; if (existingCovr) { delta = covrAtom.length - existingCovr.size; result = new Uint8Array(m4aData.length + delta); result.set(m4aData.slice(0, existingCovr.start)); result.set(covrAtom, existingCovr.start); result.set(m4aData.slice(existingCovr.start + existingCovr.size), existingCovr.start + covrAtom.length); } else { delta = covrAtom.length; const insertPos = ilst.start + ilst.size; result = new Uint8Array(m4aData.length + delta); result.set(m4aData.slice(0, insertPos)); result.set(covrAtom, insertPos); result.set(m4aData.slice(insertPos), insertPos + covrAtom.length); } // Update ilst size and all ancestor box sizes _writeU32BE(result, ilst.start, _readU32BE(result, ilst.start) + delta); for (const anc of ancestors) { _writeU32BE(result, anc.start, _readU32BE(result, anc.start) + delta); } // Fix absolute chunk offsets: inserting bytes inside moov shifts any mdat // that sits after the edit point. Adjust every stco/co64 entry that // pointed beyond the first shifted byte. if (delta !== 0) { const threshold = existingCovr ? existingCovr.start + existingCovr.size // first byte shifted (replacement) : ilst.start + ilst.size; // first byte shifted (insertion) _shiftChunkOffsets(result, moov.start + 8, moov.start + moov.size + delta, threshold, delta); } return result; } // ── No ilst found ──────────────────────────────────────────────────────── // Build udta > meta (FullBox) > hdlr > ilst > covr from scratch and append // it at the end of moov. This handles fMP4 / DASH streams that carry no // iTunes metadata atoms. const ilstSize = 8 + covrAtomSize; const ilstAtom = new Uint8Array(ilstSize); new DataView(ilstAtom.buffer).setUint32(0, ilstSize, false); ilstAtom.set([0x69, 0x6C, 0x73, 0x74], 4); // "ilst" ilstAtom.set(covrAtom, 8); // hdlr FullBox: size + "hdlr" + version/flags(0) + pre_defined(0) + // handler_type("mdir") + reserved[12] + name null-terminator const hdlrSize = 4 + 4 + 4 + 4 + 4 + 12 + 1; // 33 bytes const hdlrAtom = new Uint8Array(hdlrSize); new DataView(hdlrAtom.buffer).setUint32(0, hdlrSize, false); hdlrAtom.set([0x68, 0x64, 0x6C, 0x72], 4); // "hdlr" hdlrAtom.set([0x6D, 0x64, 0x69, 0x72], 16); // handler_type = "mdir" // meta FullBox: size + "meta" + version/flags(0) + hdlr + ilst const metaSize = 12 + hdlrSize + ilstSize; const metaAtom = new Uint8Array(metaSize); new DataView(metaAtom.buffer).setUint32(0, metaSize, false); metaAtom.set([0x6D, 0x65, 0x74, 0x61], 4); // "meta" metaAtom.set(hdlrAtom, 12); metaAtom.set(ilstAtom, 12 + hdlrSize); // udta: size + "udta" + meta const udtaSize = 8 + metaSize; const udtaAtom = new Uint8Array(udtaSize); new DataView(udtaAtom.buffer).setUint32(0, udtaSize, false); udtaAtom.set([0x75, 0x64, 0x74, 0x61], 4); // "udta" udtaAtom.set(metaAtom, 8); // Append udta right after the current end of moov const insertPos = moov.start + moov.size; const result = new Uint8Array(m4aData.length + udtaSize); result.set(m4aData.slice(0, insertPos)); result.set(udtaAtom, insertPos); result.set(m4aData.slice(insertPos), insertPos + udtaSize); // Grow moov to encompass the new udta _writeU32BE(result, moov.start, moov.size + udtaSize); // Fix chunk offsets for any mdat that follows moov _shiftChunkOffsets(result, moov.start + 8, moov.start + moov.size + udtaSize, insertPos, udtaSize); return result; } /** * Embed cover art into audio data. * @param {Uint8Array} data Raw audio bytes * @param {string} extension ".flac" or ".m4a" * @param {Uint8Array} cover JPEG cover bytes * @returns {Uint8Array} */ function embedCoverArt(data, extension, cover) { if (extension === ".flac") return injectFlacCover(data, cover); if (extension === ".m4a") return injectM4aCover(data, cover); return data; } // ─── Metadata tag injection ──────────────────────────────────────────────── /** * Inject a VORBIS_COMMENT block into a FLAC file, replacing any existing one. * Appends as the new last metadata block when no VORBIS_COMMENT is present. * * @param {Uint8Array} flacData Raw FLAC bytes * @param {Object} tags Plain-object of tag key→value strings * @returns {Uint8Array} New FLAC bytes with tags embedded (or original on error) */ function injectFlacTags(flacData, tags) { if (flacData[0] !== 0x66 || flacData[1] !== 0x4C || flacData[2] !== 0x61 || flacData[3] !== 0x43) { return flacData; } const enc = new TextEncoder(); const vendorBytes = enc.encode("tiddl-web"); const comments = []; if (tags.title) comments.push(`TITLE=${tags.title}`); if (tags.artist) comments.push(`ARTIST=${tags.artist}`); if (tags.albumTitle) comments.push(`ALBUM=${tags.albumTitle}`); if (tags.albumArtist) comments.push(`ALBUMARTIST=${tags.albumArtist}`); if (tags.trackNumber) comments.push(`TRACKNUMBER=${tags.trackNumber}`); if (tags.discNumber) comments.push(`DISCNUMBER=${tags.discNumber}`); if (tags.year) comments.push(`DATE=${tags.year}`); if (tags.isrc) comments.push(`ISRC=${tags.isrc}`); if (tags.copyright) comments.push(`COPYRIGHT=${tags.copyright}`); if (!comments.length) return flacData; const commentBytes = comments.map(c => enc.encode(c)); let blockDataSize = 4 + vendorBytes.length + 4; for (const cb of commentBytes) blockDataSize += 4 + cb.length; const blockData = new Uint8Array(blockDataSize); const dv = new DataView(blockData.buffer); let off = 0; dv.setUint32(off, vendorBytes.length, true); off += 4; blockData.set(vendorBytes, off); off += vendorBytes.length; dv.setUint32(off, commentBytes.length, true); off += 4; for (const cb of commentBytes) { dv.setUint32(off, cb.length, true); off += 4; blockData.set(cb, off); off += cb.length; } // Walk existing metadata blocks looking for an existing VORBIS_COMMENT (type 4) let pos = 4; let vorbisStart = -1, vorbisBlockTotal = 0; let lastBlockStart = 4; while (pos + 4 <= flacData.length) { const header = flacData[pos]; const blockType = header & 0x7F; const blockLen = (flacData[pos + 1] << 16) | (flacData[pos + 2] << 8) | flacData[pos + 3]; if (blockType === 4) { vorbisStart = pos; vorbisBlockTotal = 4 + blockLen; } lastBlockStart = pos; pos += 4 + blockLen; if (header & 0x80) break; } const metadataEnd = pos; if (vorbisStart >= 0) { // Replace the existing VORBIS_COMMENT block const originalIsLast = (flacData[vorbisStart] & 0x80) !== 0; const newTotal = 4 + blockDataSize; const delta = newTotal - vorbisBlockTotal; const result = new Uint8Array(flacData.length + delta); result.set(flacData.slice(0, vorbisStart)); result[vorbisStart] = (originalIsLast ? 0x80 : 0) | 4; result[vorbisStart + 1] = (blockDataSize >> 16) & 0xFF; result[vorbisStart + 2] = (blockDataSize >> 8) & 0xFF; result[vorbisStart + 3] = blockDataSize & 0xFF; result.set(blockData, vorbisStart + 4); result.set(flacData.slice(vorbisStart + vorbisBlockTotal), vorbisStart + newTotal); return result; } // Append a new VORBIS_COMMENT as the last metadata block, before audio data const newBlock = new Uint8Array(4 + blockDataSize); newBlock[0] = 0x80 | 4; // isLast=1, type=VORBIS_COMMENT newBlock[1] = (blockDataSize >> 16) & 0xFF; newBlock[2] = (blockDataSize >> 8) & 0xFF; newBlock[3] = blockDataSize & 0xFF; newBlock.set(blockData, 4); const result = new Uint8Array(flacData.length + newBlock.length); result.set(flacData.slice(0, metadataEnd)); result[lastBlockStart] = flacData[lastBlockStart] & 0x7F; // clear isLast on previous last block result.set(newBlock, metadataEnd); result.set(flacData.slice(metadataEnd), metadataEnd + newBlock.length); return result; } /** * Inject iTunes text atoms into the ilst container of an M4A file. * Creates the full udta > meta > hdlr > ilst hierarchy when none is present, * mirroring the approach used by injectM4aCover for new files. * * @param {Uint8Array} m4aData Raw M4A bytes * @param {Object} tags Plain-object of tag key→value strings * @returns {Uint8Array} New M4A bytes with tags embedded (or original on error) */ function injectM4aTags(m4aData, tags) { const enc = new TextEncoder(); // Build an iTunes text atom: fourCC [ data [ type=1 + locale=0 + UTF-8 text ] ] function buildTextAtom(fourCC, text) { if (!text) return null; const textBytes = enc.encode(text); const dataSize = 8 + 4 + 4 + textBytes.length; const totalSize = 8 + dataSize; const atom = new Uint8Array(totalSize); const dv2 = new DataView(atom.buffer); let o = 0; dv2.setUint32(o, totalSize, false); o += 4; atom.set(fourCC, o); o += 4; dv2.setUint32(o, dataSize, false); o += 4; atom.set([0x64, 0x61, 0x74, 0x61], o); o += 4; // "data" dv2.setUint32(o, 0x00000001, false); o += 4; // type = UTF-8 dv2.setUint32(o, 0, false); o += 4; // locale = 0 atom.set(textBytes, o); return atom; } // Build an iTunes integer atom for track/disc number: // fourCC [ data [ type=0 (implicit) + locale=0 + 8 bytes: // 0x0000 | num (uint16 BE) | total (uint16 BE) | 0x0000 ] ] function buildIntAtom(fourCC, num, total = 0) { const dataSize = 8 + 4 + 4 + 8; // 24 bytes const totalSize = 8 + dataSize; // 32 bytes const atom = new Uint8Array(totalSize); const dv2 = new DataView(atom.buffer); let o = 0; dv2.setUint32(o, totalSize, false); o += 4; atom.set(fourCC, o); o += 4; dv2.setUint32(o, dataSize, false); o += 4; atom.set([0x64, 0x61, 0x74, 0x61], o); o += 4; // "data" dv2.setUint32(o, 0x00000000, false); o += 4; // type = implicit integer dv2.setUint32(o, 0, false); o += 4; // locale = 0 dv2.setUint16(o, 0, false); o += 2; // padding dv2.setUint16(o, num, false); o += 2; // track/disc number dv2.setUint16(o, total, false); o += 2; // total (0 = unknown) dv2.setUint16(o, 0, false); // padding return atom; } const builtAtoms = [ buildTextAtom([0xA9, 0x6E, 0x61, 0x6D], tags.title), // ©nam buildTextAtom([0xA9, 0x41, 0x52, 0x54], tags.artist), // ©ART buildTextAtom([0xA9, 0x61, 0x6C, 0x62], tags.albumTitle), // ©alb buildTextAtom([0x61, 0x41, 0x52, 0x54], tags.albumArtist), // aART buildTextAtom([0xA9, 0x64, 0x61, 0x79], tags.year), // ©day buildTextAtom([0x63, 0x70, 0x72, 0x74], tags.copyright), // cprt tags.trackNumber ? buildIntAtom([0x74, 0x72, 0x6B, 0x6E], tags.trackNumber) : null, // trkn tags.discNumber ? buildIntAtom([0x64, 0x69, 0x73, 0x6B], tags.discNumber) : null, // disk ].filter(Boolean); if (!builtAtoms.length) return m4aData; const totalAtomsSize = builtAtoms.reduce((s, a) => s + a.length, 0); const combinedAtoms = new Uint8Array(totalAtomsSize); let wPos = 0; for (const a of builtAtoms) { combinedAtoms.set(a, wPos); wPos += a.length; } const moov = _findBox(m4aData, "moov", 0, m4aData.length); if (!moov) return m4aData; // Locate ilst via the same three paths used by injectM4aCover let ilst = null; let ancestors = []; const udta = _findBox(m4aData, "udta", moov.start + 8, moov.start + moov.size); if (udta) { const metaU = _findBox(m4aData, "meta", udta.start + 8, udta.start + udta.size); if (metaU) { const il = _findBox(m4aData, "ilst", metaU.start + 12, metaU.start + metaU.size); if (il) { ilst = il; ancestors = [metaU, udta, moov]; } } } if (!ilst) { const metaM = _findBox(m4aData, "meta", moov.start + 8, moov.start + moov.size); if (metaM) { const il = _findBox(m4aData, "ilst", metaM.start + 12, metaM.start + metaM.size); if (il) { ilst = il; ancestors = [metaM, moov]; } } } if (!ilst) { const il = _findBox(m4aData, "ilst", moov.start + 8, moov.start + moov.size); if (il) { ilst = il; ancestors = [moov]; } } if (ilst) { // Append new tag atoms at the end of the existing ilst const insertPos = ilst.start + ilst.size; const delta = totalAtomsSize; const result = new Uint8Array(m4aData.length + delta); result.set(m4aData.slice(0, insertPos)); result.set(combinedAtoms, insertPos); result.set(m4aData.slice(insertPos), insertPos + delta); _writeU32BE(result, ilst.start, _readU32BE(result, ilst.start) + delta); for (const anc of ancestors) { _writeU32BE(result, anc.start, _readU32BE(result, anc.start) + delta); } _shiftChunkOffsets(result, moov.start + 8, moov.start + moov.size + delta, insertPos, delta); return result; } // No ilst found — build udta > meta (FullBox) > hdlr > ilst from scratch const ilstSize = 8 + totalAtomsSize; const ilstAtom = new Uint8Array(ilstSize); new DataView(ilstAtom.buffer).setUint32(0, ilstSize, false); ilstAtom.set([0x69, 0x6C, 0x73, 0x74], 4); // "ilst" ilstAtom.set(combinedAtoms, 8); const hdlrSize = 4 + 4 + 4 + 4 + 4 + 12 + 1; const hdlrAtom = new Uint8Array(hdlrSize); new DataView(hdlrAtom.buffer).setUint32(0, hdlrSize, false); hdlrAtom.set([0x68, 0x64, 0x6C, 0x72], 4); // "hdlr" hdlrAtom.set([0x6D, 0x64, 0x69, 0x72], 16); // handler_type = "mdir" const metaSize = 12 + hdlrSize + ilstSize; const metaAtom = new Uint8Array(metaSize); new DataView(metaAtom.buffer).setUint32(0, metaSize, false); metaAtom.set([0x6D, 0x65, 0x74, 0x61], 4); // "meta" metaAtom.set(hdlrAtom, 12); metaAtom.set(ilstAtom, 12 + hdlrSize); const udtaSize = 8 + metaSize; const udtaAtom = new Uint8Array(udtaSize); new DataView(udtaAtom.buffer).setUint32(0, udtaSize, false); udtaAtom.set([0x75, 0x64, 0x74, 0x61], 4); // "udta" udtaAtom.set(metaAtom, 8); const insertPos = moov.start + moov.size; const result = new Uint8Array(m4aData.length + udtaSize); result.set(m4aData.slice(0, insertPos)); result.set(udtaAtom, insertPos); result.set(m4aData.slice(insertPos), insertPos + udtaSize); _writeU32BE(result, moov.start, moov.size + udtaSize); _shiftChunkOffsets(result, moov.start + 8, moov.start + moov.size + udtaSize, insertPos, udtaSize); return result; } /** * Embed track metadata tags into audio data. * @param {Uint8Array} data Raw audio bytes * @param {string} extension ".flac" or ".m4a" * @param {Object} tags Plain-object of tag key→value * @returns {Uint8Array} */ function embedMetadata(data, extension, tags) { if (extension === ".flac") return injectFlacTags(data, tags); if (extension === ".m4a") return injectM4aTags(data, tags); return data; } // ─── ZIP timestamp helpers ───────────────────────────────────────────────── /** * Encode the current local date/time as MS-DOS date and time words. * ZIP local/central-directory headers use this format. * Date word: bits 15-9 = year-1980, bits 8-5 = month (1-12), bits 4-0 = day * Time word: bits 15-11 = hours, bits 10-5 = minutes, bits 4-0 = secs/2 * Zeroing these fields produces "December 31 1979" on macOS — avoid that. */ function dosNow() { const d = new Date(); const date = (((d.getFullYear() - 1980) & 0x7F) << 9) | (((d.getMonth() + 1) & 0x0F) << 5) | ( d.getDate() & 0x1F); const time = ((d.getHours() & 0x1F) << 11) | ((d.getMinutes() & 0x3F) << 5) | ((Math.floor(d.getSeconds() / 2) & 0x1F)); return { date, time }; } /** * Compute CRC-32 of a Uint8Array (ISO 3309 polynomial). * Used by buildZip for the ZIP local/central-directory headers. */ function crc32(buf) { if (!crc32._t) { const t = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[i] = c; } crc32._t = t; } const t = crc32._t; let c = 0xFFFFFFFF; for (let i = 0; i < buf.length; i++) c = t[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; } /** * Build a ZIP file (STORE method — no compression) from an array of * { name: string, data: Uint8Array } entries. * Suitable for packaging already-compressed audio/video files. * Returns a Uint8Array of the full ZIP binary. */ function buildZip(files) { const enc = new TextEncoder(); const localParts = []; // Uint8Arrays making up the local file headers + data const centralEntries = []; // Uint8Arrays for the central directory headers let localOffset = 0; for (const { name, data } of files) { const nameBytes = enc.encode(name); const checksum = crc32(data); const size = data.byteLength; const { date: dosDate, time: dosTime } = dosNow(); // ── Local file header (30 bytes + file name) ── const lhBuf = new ArrayBuffer(30 + nameBytes.length); const lh = new DataView(lhBuf); lh.setUint32( 0, 0x04034b50, true); // LFH signature lh.setUint16( 4, 20, true); // version needed (2.0) lh.setUint16( 6, 0x0800, true); // general-purpose flags: UTF-8 name lh.setUint16( 8, 0, true); // compression method: STORE lh.setUint16(10, dosTime, true); // last mod time lh.setUint16(12, dosDate, true); // last mod date lh.setUint32(14, checksum, true); // CRC-32 lh.setUint32(18, size, true); // compressed size lh.setUint32(22, size, true); // uncompressed size lh.setUint16(26, nameBytes.length, true); // file name length lh.setUint16(28, 0, true); // extra field length new Uint8Array(lhBuf).set(nameBytes, 30); localParts.push(new Uint8Array(lhBuf), data); // ── Central directory header (46 bytes + file name) ── const cdhBuf = new ArrayBuffer(46 + nameBytes.length); const cdh = new DataView(cdhBuf); cdh.setUint32( 0, 0x02014b50, true); // CDH signature cdh.setUint16( 4, 20, true); // version made by cdh.setUint16( 6, 20, true); // version needed cdh.setUint16( 8, 0x0800, true); // general-purpose flags: UTF-8 cdh.setUint16(10, 0, true); // compression: STORE cdh.setUint16(12, dosTime, true); // last mod time cdh.setUint16(14, dosDate, true); // last mod date cdh.setUint32(16, checksum, true); // CRC-32 cdh.setUint32(20, size, true); // compressed size cdh.setUint32(24, size, true); // uncompressed size cdh.setUint16(28, nameBytes.length, true); // file name length cdh.setUint16(30, 0, true); // extra field length cdh.setUint16(32, 0, true); // file comment length cdh.setUint16(34, 0, true); // disk number start cdh.setUint16(36, 0, true); // internal attributes cdh.setUint32(38, 0, true); // external attributes cdh.setUint32(42, localOffset, true); // offset of local header new Uint8Array(cdhBuf).set(nameBytes, 46); centralEntries.push(new Uint8Array(cdhBuf)); localOffset += 30 + nameBytes.length + size; } // ── End of central directory record (22 bytes) ── const cdSize = centralEntries.reduce((a, b) => a + b.byteLength, 0); const eocdBuf = new ArrayBuffer(22); const eocd = new DataView(eocdBuf); eocd.setUint32( 0, 0x06054b50, true); // EOCD signature eocd.setUint16( 4, 0, true); // disk number eocd.setUint16( 6, 0, true); // central dir start disk eocd.setUint16( 8, files.length, true); // entries on this disk eocd.setUint16(10, files.length, true); // total entries eocd.setUint32(12, cdSize, true); // size of central dir eocd.setUint32(16, localOffset, true); // offset of central dir eocd.setUint16(20, 0, true); // comment length // Assemble the ZIP as a Blob from its constituent parts. // Using Blob avoids allocating a single contiguous buffer equal to the total // ZIP size, which can fail with "Array buffer allocation failed" for large // collections (playlists, albums) where all track data would need to be // copied into one block. return new Blob([...localParts, ...centralEntries, new Uint8Array(eocdBuf)], { type: "application/zip" }); } // ─── Internal track data fetcher ───────────────────────────────────────── /** * Fetch and decode a single track's audio data without triggering a browser * save. Used internally by downloadTrack and the multi-track ZIP bundlers. * * @returns {{ data: Uint8Array, extension: string, title: string, * artist: string, trackNumber: number, discNumber: number, * albumTitle: string, albumArtist: string, year: string, * isrc: string, copyright: string, coverHash: string|null }} */ async function fetchTrackData(trackId, quality, onProgress) { onProgress?.(0, 1, `Fetching track info for #${trackId}…`); const track = await getTrack(trackId); const rawTitle = (track.version ? `${track.title} (${track.version})` : track.title) || String(trackId); const title = sanitize(rawTitle); const rawArtist = track.artist?.name || "Unknown Artist"; const artist = sanitize(rawArtist); const trackNumber = track.trackNumber || 0; const discNumber = track.volumeNumber || 1; const albumTitle = track.album?.title || ""; const albumArtist = track.album?.artist?.name || rawArtist; const year = (track.streamStartDate || track.album?.releaseDate || "").slice(0, 4); const isrc = track.isrc || ""; const copyright = track.copyright || ""; const coverHash = track.album?.cover || null; onProgress?.(0, 1, `Getting stream for "${title}"…`); const streamInfo = await getTrackStream(trackId, quality); const { urls, extension, encryptionType } = parseStreamManifest(streamInfo); if (encryptionType && encryptionType !== "NONE") { throw new Error( `Stream is encrypted (${encryptionType}). Encrypted streams cannot be downloaded in the browser.` ); } onProgress?.(0, urls.length, `Downloading ${urls.length} segment(s)…`); const data = await fetchSegments(urls, (done, total) => onProgress?.(done, total, `Downloading segment ${done}/${total}…`) ); return { data, extension, title, artist, rawTitle, rawArtist, trackNumber, discNumber, albumTitle, albumArtist, year, isrc, copyright, coverHash }; } // ─── Public API ──────────────────────────────────────────────────────────── /** * Download a single track. * @param {string|number} trackId * @param {string} quality LOW | HIGH | LOSSLESS | HI_RES_LOSSLESS * @param {Function} onProgress (downloaded, total, message) => void * @returns {Promise<{filename: string, success: boolean, error?: string}>} */ export async function downloadTrack(trackId, quality = "HIGH", onProgress, nameSuffix = "") { try { let { data, extension, title, artist, rawTitle, rawArtist, trackNumber, discNumber, albumTitle, albumArtist, year, isrc, copyright, coverHash } = await fetchTrackData(trackId, quality, onProgress); if (getMetadataEnable()) { data = embedMetadata(data, extension, { title: rawTitle, artist: rawArtist, albumTitle, albumArtist, trackNumber, discNumber, year, isrc, copyright }); } const needCover = (getMetadataCover() || (getCoverSave() && getCoverAllowed().includes("track"))) && coverHash; if (needCover) { onProgress?.(0, 1, "Fetching cover art…"); const cover = await fetchCoverArt(coverHash, getCoverSize()); if (cover) { if (getMetadataCover()) data = embedCoverArt(data, extension, cover); if (getCoverSave() && getCoverAllowed().includes("track")) { triggerDownload(cover, "cover.jpg", "image/jpeg"); } } } const filename = `${artist} - ${title}${nameSuffix}${extension}`; triggerDownload(data, filename, extToMime(extension)); return { filename, success: true }; } catch (err) { return { filename: String(trackId), success: false, error: err.message }; } } /** * Download all tracks in an album as a single ZIP file. * ZIP folder structure: `{albumArtist} - {albumTitle}/{track#}. {artist} - {title}.ext` */ export async function downloadAlbum(albumId, quality = "HIGH", onProgress, onSubItemProgress, nameSuffix = "") { try { onProgress?.(0, 1, `Fetching album info…`); const [albumMeta, itemsData] = await Promise.all([ getAlbum(albumId), getAlbumItems(albumId, 100, 0), ]); const rawAlbumTitle = albumMeta.title || "Unknown Album"; const rawAlbumArtist = albumMeta.artist?.name || "Unknown Artist"; const albumTitle = sanitize(rawAlbumTitle); const albumArtist = sanitize(rawAlbumArtist); const albumYear = (albumMeta.releaseDate || "").slice(0, 4); const folder = `${albumArtist} - ${albumTitle}`; // Fetch album cover once for all tracks (best-effort) const needCover = (getMetadataCover() || (getCoverSave() && getCoverAllowed().includes("album"))) && albumMeta.cover; let albumCover = null; if (needCover) { onProgress?.(0, 1, "Fetching cover art…"); albumCover = await fetchCoverArt(albumMeta.cover, getCoverSize()); } const items = (itemsData.items || []).filter((i) => i.type === "track"); const zipFiles = []; const results = []; for (let i = 0; i < items.length; i++) { const track = items[i].item; onProgress?.(i, items.length, `Track ${i + 1}/${items.length}: ${track.title}`); onSubItemProgress?.(i, items.length, track.title, 0, 1, "active"); try { const td = await fetchTrackData(track.id, quality, (d, t, msg) => { onProgress?.(i, items.length, msg); onSubItemProgress?.(i, items.length, track.title, d, t, "active"); }); let trackData = td.data; if (getMetadataEnable()) { trackData = embedMetadata(trackData, td.extension, { title: td.rawTitle, artist: td.rawArtist, albumTitle: td.albumTitle || rawAlbumTitle, albumArtist: td.albumArtist || rawAlbumArtist, trackNumber: td.trackNumber, discNumber: td.discNumber, year: td.year || albumYear, isrc: td.isrc, copyright: td.copyright, }); } if (albumCover && getMetadataCover()) trackData = embedCoverArt(trackData, td.extension, albumCover); const num = String(td.trackNumber || (i + 1)).padStart(2, "0"); const fname = `${folder}/${num}. ${td.artist} - ${td.title}${td.extension}`; zipFiles.push({ name: fname, data: trackData }); results.push({ filename: fname, success: true }); onSubItemProgress?.(i, items.length, td.title || track.title, 1, 1, "done"); } catch (err) { results.push({ filename: track.title || String(track.id), success: false, error: err.message }); onSubItemProgress?.(i, items.length, track.title, 0, 1, "failed"); } } if (albumCover && getCoverSave() && getCoverAllowed().includes("album")) { zipFiles.push({ name: `${folder}/cover.jpg`, data: albumCover }); } if (zipFiles.length > 0) { onProgress?.(items.length, items.length, `Packaging ${zipFiles.length} track(s) as ZIP…`); const zip = buildZip(zipFiles); triggerDownload(zip, `${folder}${nameSuffix}.zip`, "application/zip"); } return results; } catch (err) { return [{ filename: String(albumId), success: false, error: err.message }]; } } /** * Download all tracks in a playlist as a single ZIP file. * ZIP folder structure: `{playlistTitle}/{track#}. {artist} - {title}.ext` */ export async function downloadPlaylist(playlistId, quality = "HIGH", onProgress, onSubItemProgress, nameSuffix = "") { try { onProgress?.(0, 1, `Fetching playlist info…`); const [playlistMeta, itemsData] = await Promise.all([ getPlaylist(playlistId), getPlaylistItems(playlistId, 100, 0), ]); const playlistTitle = sanitize(playlistMeta.title || "Playlist"); const folder = playlistTitle; // Fetch playlist cover once (best-effort) for cover-save feature. // Playlist cover images on Tidal max out at 1080px (per the settings UI label). let playlistCover = null; if (getCoverSave() && getCoverAllowed().includes("playlist") && playlistMeta.squareImage) { onProgress?.(0, 1, "Fetching playlist cover art…"); playlistCover = await fetchCoverArt(playlistMeta.squareImage, Math.min(getCoverSize(), 1080)); } const items = (itemsData.items || []).filter((i) => i.type === "track"); const zipFiles = []; const results = []; // Cache cover art per hash to avoid redundant fetches across tracks const coverCache = new Map(); for (let i = 0; i < items.length; i++) { const track = items[i].item; onProgress?.(i, items.length, `Track ${i + 1}/${items.length}: ${track.title}`); onSubItemProgress?.(i, items.length, track.title, 0, 1, "active"); try { const td = await fetchTrackData(track.id, quality, (d, t, msg) => { onProgress?.(i, items.length, msg); onSubItemProgress?.(i, items.length, track.title, d, t, "active"); }); let trackData = td.data; if (getMetadataEnable()) { trackData = embedMetadata(trackData, td.extension, { title: td.rawTitle, artist: td.rawArtist, albumTitle: td.albumTitle, albumArtist: td.albumArtist, trackNumber: td.trackNumber, discNumber: td.discNumber, year: td.year, isrc: td.isrc, copyright: td.copyright, }); } if (getMetadataCover() && td.coverHash) { if (!coverCache.has(td.coverHash)) { const fetched = await fetchCoverArt(td.coverHash, getCoverSize()); if (fetched) coverCache.set(td.coverHash, fetched); } const cover = coverCache.get(td.coverHash); if (cover) trackData = embedCoverArt(trackData, td.extension, cover); } const num = String(i + 1).padStart(2, "0"); const fname = `${folder}/${num}. ${td.artist} - ${td.title}${td.extension}`; zipFiles.push({ name: fname, data: trackData }); results.push({ filename: fname, success: true }); onSubItemProgress?.(i, items.length, td.title || track.title, 1, 1, "done"); } catch (err) { results.push({ filename: track.title || String(track.id), success: false, error: err.message }); onSubItemProgress?.(i, items.length, track.title, 0, 1, "failed"); } } if (playlistCover && getCoverSave() && getCoverAllowed().includes("playlist")) { zipFiles.push({ name: `${folder}/cover.jpg`, data: playlistCover }); } if (zipFiles.length > 0) { onProgress?.(items.length, items.length, `Packaging ${zipFiles.length} track(s) as ZIP…`); const zip = buildZip(zipFiles); triggerDownload(zip, `${folder}${nameSuffix}.zip`, "application/zip"); } return results; } catch (err) { return [{ filename: String(playlistId), success: false, error: err.message }]; } } /** * Download all tracks in a mix as a single ZIP file. * ZIP folder structure: `Mix/{track#}. {artist} - {title}.ext` */ export async function downloadMix(mixId, quality = "HIGH", onProgress, onSubItemProgress, nameSuffix = "") { try { onProgress?.(0, 1, `Fetching mix items…`); const itemsData = await getMixItems(mixId, 100, 0); const folder = sanitize(`Mix-${mixId}`); const items = (itemsData.items || []).filter((i) => i.type === "track"); const zipFiles = []; const results = []; const coverCache = new Map(); for (let i = 0; i < items.length; i++) { const track = items[i].item; onProgress?.(i, items.length, `Track ${i + 1}/${items.length}: ${track.title}`); onSubItemProgress?.(i, items.length, track.title, 0, 1, "active"); try { const td = await fetchTrackData(track.id, quality, (d, t, msg) => { onProgress?.(i, items.length, msg); onSubItemProgress?.(i, items.length, track.title, d, t, "active"); }); let trackData = td.data; if (getMetadataEnable()) { trackData = embedMetadata(trackData, td.extension, { title: td.rawTitle, artist: td.rawArtist, albumTitle: td.albumTitle, albumArtist: td.albumArtist, trackNumber: td.trackNumber, discNumber: td.discNumber, year: td.year, isrc: td.isrc, copyright: td.copyright, }); } if (getMetadataCover() && td.coverHash) { if (!coverCache.has(td.coverHash)) { const fetched = await fetchCoverArt(td.coverHash, getCoverSize()); if (fetched) coverCache.set(td.coverHash, fetched); } const cover = coverCache.get(td.coverHash); if (cover) trackData = embedCoverArt(trackData, td.extension, cover); } const num = String(i + 1).padStart(2, "0"); const fname = `${folder}/${num}. ${td.artist} - ${td.title}${td.extension}`; zipFiles.push({ name: fname, data: trackData }); results.push({ filename: fname, success: true }); onSubItemProgress?.(i, items.length, td.title || track.title, 1, 1, "done"); } catch (err) { results.push({ filename: track.title || String(track.id), success: false, error: err.message }); onSubItemProgress?.(i, items.length, track.title, 0, 1, "failed"); } } if (zipFiles.length > 0) { onProgress?.(items.length, items.length, `Packaging ${zipFiles.length} track(s) as ZIP…`); const zip = buildZip(zipFiles); triggerDownload(zip, `${folder}${nameSuffix}.zip`, "application/zip"); } return results; } catch (err) { return [{ filename: String(mixId), success: false, error: err.message }]; } } // ─── URL / ID parser (mirrors tiddl/cli/utils/resource.py) ──────────────── const RESOURCE_TYPES = ["track", "video", "album", "artist", "playlist", "mix"]; export function parseTidalInput(input) { input = input.trim(); // Full URL: https://tidal.com/browse/track/12345 or https://listen.tidal.com/album/123/track/456 try { const u = new URL(input); const parts = u.pathname.replace(/^\/browse\//, "/").split("/").filter(Boolean); // Look for a known resource type in the path segments for (let i = 0; i < parts.length - 1; i++) { const type = parts[i].toLowerCase(); if (RESOURCE_TYPES.includes(type)) { return { type, id: parts[i + 1] }; } } } catch { /* not a URL */ } // Short form: track/12345 or album/abc-uuid const slashMatch = input.match(/^(track|video|album|artist|playlist|mix)\/([^\s/]+)$/i); if (slashMatch) { return { type: slashMatch[1].toLowerCase(), id: slashMatch[2] }; } // Bare numeric ID — assume track if (/^\d+$/.test(input)) { return { type: "track", id: input }; } return null; } /** * Download every album (plus singles/EPs) by an artist. * Each album is packaged as its own ZIP file, downloaded in sequence. * Returns an array of per-album result arrays. */ export async function downloadArtistAlbums(artistId, quality = "HIGH", onProgress, onSubItemProgress, nameSuffix = "") { try { onProgress?.(0, 1, "Fetching artist discography…"); const [albumsData, singlesData] = await Promise.all([ getArtistAlbums(artistId, 50, 0).catch(() => ({ items: [] })), getArtistSingles(artistId, 50, 0).catch(() => ({ items: [] })), ]); const all = [ ...(albumsData.items || []), ...(singlesData.items || []), ]; if (all.length === 0) { return [{ filename: `artist-${artistId}`, success: false, error: "No albums found." }]; } const allResults = []; for (let i = 0; i < all.length; i++) { const album = all[i]; onProgress?.(i, all.length, `Album ${i + 1}/${all.length}: ${album.title}`); onSubItemProgress?.(i, all.length, album.title, 0, 1, "active"); const res = await downloadAlbum(album.id, quality, (d, t, msg) => onProgress?.(i, all.length, `Album ${i + 1}/${all.length}: ${msg || ""}`), undefined, nameSuffix, ); const allOk = Array.isArray(res) ? res.every((r) => r.success) : res?.success; onSubItemProgress?.(i, all.length, album.title, 1, 1, allOk ? "done" : "failed"); allResults.push(...(Array.isArray(res) ? res : [res])); } return allResults; } catch (err) { return [{ filename: `artist-${artistId}`, success: false, error: `Failed to download artist ${artistId}: ${err.message}` }]; } }