mirror of
https://github.com/19JVJeffery/tiddl-gui.git
synced 2026-10-22 19:02:16 -05:00
Agent-Logs-Url: https://github.com/19JVJeffery/tiddl-gui/sessions/db78f253-cbe0-41d8-af01-761bbd544c54 Co-authored-by: 19JVJeffery <94115228+19JVJeffery@users.noreply.github.com>
1315 lines
54 KiB
JavaScript
1315 lines
54 KiB
JavaScript
/**
|
||
* 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<Uint8Array|null>}
|
||
*/
|
||
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}` }];
|
||
}
|
||
}
|