// ==UserScript==
// @name ICEportal-dl Media Downloader
// @name:de ICEportal-dl Media Downloader
// @namespace https://github.com/Jo11n/iceportal-dl
// @version 0.4.0
// @description Download helper for the ICEportal of DB (audiobooks, podcasts, and magazines)
// @description:de Download-Helfer für das ICEportal der DB (Audiobücher, Podcasts und Zeitschriften)
// @author Jo11n
// @match https://iceportal.de/*
// @grant GM_download
// @connect iceportal.de
// @license MIT
// @homepageURL https://github.com/Jo11n/iceportal-dl
// @supportURL https://github.com/Jo11n/iceportal-dl/issues
// @downloadURL https://raw.githubusercontent.com/Jo11n/iceportal-dl/main/iceportal-dl.user.js
// @updateURL https://raw.githubusercontent.com/Jo11n/iceportal-dl/main/iceportal-dl.user.js
// ==/UserScript==
(function () {
'use strict';
const BASE = 'https://iceportal.de';
const RETRY_COUNT = 2;
const RETRY_DELAY_MS = 600;
// Resolving chapter URLs (and background author enrichment) tolerates parallelism;
// only the auto-download triggers are throttled, since browsers block bursts.
const RESOLVE_CONCURRENCY = 4;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function sanitize(str) {
return (str || '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim();
}
// The portal escapes punctuation in some chapter titles ("Book\, Kapitel 1");
// strip the stray backslash and normalize whitespace.
function cleanText(str) {
return (str || '')
.replace(/\\(?=[,.;:!?'"()\[\]])/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function pad(n, total) {
return String(n).padStart(String(total).length, '0');
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Runs fn over items with at most `limit` in flight at once, preserving order in the result.
async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let index = 0;
async function worker() {
while (index < items.length) {
const current = index++;
results[current] = await fn(items[current], current);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
// ---------------------------------------------------------------------------
// API
// ---------------------------------------------------------------------------
// Train wifi drops packets mid-tunnel; retry a couple of times.
async function fetchWithRetry(url) {
let lastErr;
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
try {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
return resp;
} catch (e) {
lastErr = e;
if (attempt < RETRY_COUNT) await delay(RETRY_DELAY_MS * (attempt + 1));
}
}
throw lastErr;
}
// Outside the train (or with a VPN routing around the wifi captive portal), iceportal.de
// still responds with 200 OK but serves an HTML placeholder instead of the train's JSON API.
const fetchJSON = async path => {
const resp = await fetchWithRetry(BASE + path);
const text = await resp.text();
try {
return JSON.parse(text);
} catch (e) {
throw new Error('Keine Zugdaten verfügbar – bist du mit dem Zug-WLAN verbunden? (Ggf. VPN deaktivieren.)');
}
};
// The portal ships its own filter definitions (content type + duration
// buckets) with the list; reused instead of inventing thresholds.
let audiobookFilterDefs = [];
// Genre is the free-text "subtitle" (bracketed tag), not part of the filter
// definition; collected so the genre dropdown offers only values in use.
let audiobookGenres = [];
let podcastFilterDefs = [];
// Display labels for duration buckets — shared by both audiobook and podcast
// filter bars so all four buckets format consistently.
const DURATION_LABELS = {
zeit_unter30minuten: '< 30 Min.',
zeit_unter1stunde: '< 1h',
zeit_unter2stunden: '< 2h',
zeit_laengerals2stunden: '> 2h',
};
async function listBooks() {
const data = await fetchJSON('/api1/rs/page/hoerbuecher');
const group = data.teaserGroups[0];
audiobookFilterDefs = (group.filter && group.filter.items || []).filter(f => f.id !== 'alle');
const books = group.items
// skip promo/ad tiles (type "adCard") whose href points off-site.
.filter(item => item.type === 'audio')
.sort((a, b) => a.title.localeCompare(b.title))
.map(item => ({
title: item.subtitle ? `${item.title} [${item.subtitle}]` : item.title,
slug: item.navigation.href.split('/').pop(),
genre: item.subtitle || '',
filterIds: item.filterIds || [],
}));
audiobookGenres = [...new Set(books.map(b => b.genre).filter(Boolean))].sort();
return books;
}
// Memoized: the list view fetches details for every book just to show the
// author, so caching means expanding a book afterwards is free.
const bookDetailsCache = new Map();
async function getBookDetails(slug) {
if (bookDetailsCache.has(slug)) return bookDetailsCache.get(slug);
const data = await fetchJSON(`/api1/rs/page/hoerbuecher/${slug}`);
bookDetailsCache.set(slug, data);
return data;
}
const podcastDetailsCache = new Map();
async function listPodcasts() {
const data = await fetchJSON('/api1/rs/page/podcasts');
const group = data.teaserGroups[0];
podcastFilterDefs = (group.filter && group.filter.items || []).filter(f => f.id !== 'alle');
const podcasts = group.items
.filter(item => item.type === 'audio')
.sort((a, b) => a.title.localeCompare(b.title))
.map(item => ({
title: item.title,
slug: item.navigation.href.split('/').pop(),
filterIds: item.filterIds || [],
}));
return podcasts;
}
async function getPodcastDetails(slug) {
if (podcastDetailsCache.has(slug)) return podcastDetailsCache.get(slug);
const data = await fetchJSON(`/api1/rs/page/podcasts/${slug}`);
podcastDetailsCache.set(slug, data);
return data;
}
// chapter.path from the book details API is e.g. "audiobook/path/{slug}/{n}",
// which maps directly to /api1/rs/audiobook/path/{slug}/{n}.
async function resolveChapterURL(chapterPath) {
const clean = String(chapterPath || '').replace(/^\//, '');
const data = await fetchJSON(`/api1/rs/${clean}`);
const resolved = data.path;
return resolved.startsWith('http') ? resolved : BASE + resolved;
}
// "marker" lives on the item itself (not nested under "picture").
async function listMagazines() {
const data = await fetchJSON('/api1/rs/page/zeitungskiosk');
return data.teaserGroups[0].items
.filter(item => item.marker && item.marker.text === 'Freies Exemplar')
.sort((a, b) => a.title.localeCompare(b.title))
.map(item => ({ title: item.title, href: item.navigation.href }));
}
async function getMagazineDetails(href) {
return fetchJSON(`/api1/rs/page${href}`);
}
// details.navigation.href is the direct PDF download path, e.g.
// "api1/rs/magazines/file/freeCopy/{slug}/{hash}" (no leading slash).
function resolveMagazineURL(details) {
const href = details.navigation.href;
return href.startsWith('/') ? BASE + href : `${BASE}/${href}`;
}
// ---------------------------------------------------------------------------
// Download
// ---------------------------------------------------------------------------
function clickDownloadLink(href, filename) {
const a = document.createElement('a');
a.href = href;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
function triggerDownload(url, filename) {
if (typeof GM_download !== 'undefined') GM_download({ url, name: filename });
else clickDownloadLink(url, filename);
}
function buildFilename(author, bookTitle, serialNumber, chapterTitle, totalChapters, ext = 'mp3') {
const track = pad(serialNumber, totalChapters);
const parts = [author, bookTitle, track, sanitize(cleanText(chapterTitle) || `Kapitel ${serialNumber}`)];
return parts.join(' - ') + '.' + ext;
}
function buildMagazineFilename(title, date, ext) {
const parts = [sanitize(title), date].filter(Boolean);
return parts.join(' - ') + '.' + ext;
}
function buildPodcastFilename(podcastTitle, serialNumber, episodeTitle, ext) {
const padded = pad(serialNumber, 9999);
return `${sanitize(podcastTitle)} - ${padded} - ${sanitize(cleanText(episodeTitle))}.${ext}`;
}
// Extension from the path, falling back only if the URL somehow has none.
// The fallback is caller-supplied so the guess matches the content type.
function extFromUrl(url, fallback) {
return url.split('?')[0].split('.').pop() || fallback;
}
function formatDuration(totalSeconds) {
const s = Math.round(totalSeconds || 0);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const mm = h > 0 ? String(m).padStart(2, '0') : String(m);
const ss = String(sec).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
function parseISODuration(iso) {
const m = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/.exec(iso || '');
if (!m) return 0;
return (parseInt(m[1] || '0', 10) * 3600) + (parseInt(m[2] || '0', 10) * 60) + parseInt(m[3] || '0', 10);
}
// ---------------------------------------------------------------------------
// MP4 metadata tagging
// ---------------------------------------------------------------------------
//
// Portal .m4a files already carry a moov → udta → meta → ilst chain with `moov`
// last and `ilst` at its tail. Tags are added by appending atoms inside `ilst`
// and bumping the four ancestor size fields; `mdat` never moves, so sample
// offsets stay valid and the worst case is "tags don't show", never corruption.
const COPYRIGHT = String.fromCharCode(0xA9); // the "©" byte that prefixes ©nam/©ART/…
// A box is [uint32 size][4-char type][payload].
function mp4Box(type, payload) {
const box = new Uint8Array(8 + payload.length);
new DataView(box.buffer).setUint32(0, box.length);
for (let i = 0; i < 4; i++) box[4 + i] = type.charCodeAt(i) & 0xFF;
box.set(payload, 8);
return box;
}
// An iTunes metadata value lives in a "data" atom: [typeIndicator][locale=0][payload].
// typeIndicator: 1 = UTF-8 text, 0 = binary (trkn), 13 = JPEG, 14 = PNG.
function mp4DataAtom(typeIndicator, payload) {
const inner = new Uint8Array(8 + payload.length);
new DataView(inner.buffer).setUint32(0, typeIndicator); // bytes 4..8 (locale) stay 0
inner.set(payload, 8);
return mp4Box('data', inner);
}
function textAtom(type, text) {
return mp4Box(type, mp4DataAtom(1, new TextEncoder().encode(text)));
}
function concatBytes(chunks) {
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) { out.set(c, offset); offset += c.length; }
return out;
}
function buildIlstAtoms(tags) {
const chunks = [];
if (tags.title) chunks.push(textAtom(COPYRIGHT + 'nam', tags.title));
if (tags.artist) chunks.push(textAtom(COPYRIGHT + 'ART', tags.artist));
if (tags.album) chunks.push(textAtom(COPYRIGHT + 'alb', tags.album));
if (tags.track && tags.track.no) {
// trkn payload: 2 reserved, uint16 track, uint16 total, 2 reserved.
const p = new Uint8Array(8);
const d = new DataView(p.buffer);
d.setUint16(2, tags.track.no);
d.setUint16(4, tags.track.total || 0);
chunks.push(mp4Box('trkn', mp4DataAtom(0, p)));
}
if (tags.cover && tags.cover.bytes) {
chunks.push(mp4Box('covr', mp4DataAtom(tags.cover.mime === 'image/png' ? 14 : 13, tags.cover.bytes)));
}
return concatBytes(chunks);
}
// Direct child boxes within [start, end). Intentionally 32-bit only — a
// 64-bit box (size == 1) is treated as malformed and bails the splice,
// downloading untagged. Safe since the portal's tag boxes are always small.
function mp4Children(dv, start, end) {
const out = [];
let offset = start;
while (offset + 8 <= end) {
const size = dv.getUint32(offset);
if (size < 8 || offset + size > end) break;
const type = String.fromCharCode(dv.getUint8(offset + 4), dv.getUint8(offset + 5), dv.getUint8(offset + 6), dv.getUint8(offset + 7));
out.push({ type, start: offset, size, end: offset + size });
offset += size;
}
return out;
}
// Returns a new buffer with tags spliced in, or the original untouched if the
// expected box layout isn't present.
function tagMp4(arrayBuffer, tags) {
const newAtoms = buildIlstAtoms(tags);
if (newAtoms.length === 0) return arrayBuffer;
const bytes = new Uint8Array(arrayBuffer);
const dv = new DataView(arrayBuffer);
// Descend moov → udta → meta → ilst, requiring each to be the last box in
// its parent (parent.end === child.end) so a plain append can't shift media.
const moov = mp4Children(dv, 0, bytes.length).find(b => b.type === 'moov');
if (!moov || moov.end !== bytes.length) return arrayBuffer;
const udta = mp4Children(dv, moov.start + 8, moov.end).find(b => b.type === 'udta');
if (!udta || udta.end !== moov.end) return arrayBuffer;
// `meta` is a FullBox: 4 bytes of version/flags precede its children.
const meta = mp4Children(dv, udta.start + 8, udta.end).find(b => b.type === 'meta');
if (!meta || meta.end !== udta.end) return arrayBuffer;
const ilst = mp4Children(dv, meta.start + 12, meta.end).find(b => b.type === 'ilst');
if (!ilst || ilst.end !== meta.end) return arrayBuffer;
const insertAt = ilst.end;
const out = new Uint8Array(bytes.length + newAtoms.length);
out.set(bytes.subarray(0, insertAt), 0);
out.set(newAtoms, insertAt);
out.set(bytes.subarray(insertAt), insertAt + newAtoms.length);
// Grow the four ancestor size fields. Their headers all sit before insertAt,
// so their positions are unchanged in `out`.
const odv = new DataView(out.buffer);
for (const box of [ilst, meta, udta, moov]) {
odv.setUint32(box.start, dv.getUint32(box.start) + newAtoms.length);
}
return out.buffer;
}
// Best-effort; returns null on failure/unsupported format so tagging proceeds
// without art rather than failing the download.
async function fetchCover(src) {
if (!src) return null;
try {
// cover src is base-relative with no leading slash; new URL() joins it.
const url = new URL(src, BASE + '/').href;
const resp = await fetch(url);
if (!resp.ok) return null;
const bytes = new Uint8Array(await resp.arrayBuffer());
if (bytes[0] === 0x89 && bytes[1] === 0x50) return { bytes, mime: 'image/png' };
if (bytes[0] === 0xFF && bytes[1] === 0xD8) return { bytes, mime: 'image/jpeg' };
return null; // e.g. webp — no ilst image type for it, so skip the cover
} catch (e) {
return null;
}
}
function buildTags(author, bookTitle, chapter, total, cover) {
return {
title: cleanText(chapter.title) || `Kapitel ${chapter.serialNumber}`,
artist: cleanText(author),
album: cleanText(bookTitle),
track: { no: chapter.serialNumber, total },
cover,
};
}
const fetchAudioBuffer = async url => (await fetchWithRetry(url)).arrayBuffer();
function triggerBlobDownload(blob, filename) {
const url = URL.createObjectURL(blob);
clickDownloadLink(url, filename);
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
// Buffer, tag, and download. Any failure degrades to a plain streamed download.
async function downloadTaggedChapter(url, filename, tags) {
if (!/\.(m4a|m4b|mp4)(\?|$)/i.test(url)) {
triggerDownload(url, filename); // not an MP4 container — nothing to tag
return;
}
let buffer;
try {
buffer = await fetchAudioBuffer(url);
} catch (e) {
console.error('[ICE DL] buffering failed, streaming untagged', e);
triggerDownload(url, filename);
return;
}
let tagged;
try {
tagged = tagMp4(buffer, tags);
} catch (e) {
console.error('[ICE DL] tagging failed, downloading untagged', e);
tagged = buffer;
}
triggerBlobDownload(new Blob([tagged], { type: 'audio/mp4' }), filename);
}
// ---------------------------------------------------------------------------
// Audiobook merge: combine chapters into one progressive .m4b
// ---------------------------------------------------------------------------
//
// All chapters of a book share one AAC config (checked at merge time), so their
// samples concatenate without re-encoding. Builds one progressive file
// (ftyp/mdat/moov) with an audio track plus a QuickTime text chapter track
// (audio track → tref 'chap' → text track), the de-facto .m4b chapter format.
const TE = new TextEncoder();
function _u32(n) { const a = new Uint8Array(4); new DataView(a.buffer).setUint32(0, n >>> 0); return a; }
function _u16(n) { const a = new Uint8Array(2); new DataView(a.buffer).setUint16(0, n & 0xFFFF); return a; }
function _str4(s) { return new Uint8Array([s.charCodeAt(0), s.charCodeAt(1), s.charCodeAt(2), s.charCodeAt(3)]); }
function _box(type, ...parts) { const p = concatBytes(parts); return concatBytes([_u32(8 + p.length), _str4(type), p]); }
function _fullbox(type, version, flags, ...parts) { return _box(type, new Uint8Array([version, (flags >> 16) & 255, (flags >> 8) & 255, flags & 255]), ...parts); }
const _MATRIX = concatBytes([_u32(0x00010000), _u32(0), _u32(0), _u32(0), _u32(0x00010000), _u32(0), _u32(0), _u32(0), _u32(0x40000000)]);
// Pack ints into big-endian uint32 bytes directly. Never build a large table
// via `_box(type, ...arr.map(_u32))` — spreading ~700k samples (a full book)
// overflows the call stack.
function _u32Array(nums) {
const out = new Uint8Array(nums.length * 4);
const dv = new DataView(out.buffer);
for (let i = 0; i < nums.length; i++) dv.setUint32(i * 4, nums[i] >>> 0);
return out;
}
// Shared sub-boxes (identical across the audio and chapter tracks).
const _dinf = () => _box('dinf', _fullbox('dref', 0, 0, _u32(1), _fullbox('url ', 0, 1)));
const _mdhd = (timescale, ticks) => _fullbox('mdhd', 0, 0, _u32(0), _u32(0), _u32(timescale), _u32(ticks), _u16(0x55C4), _u16(0));
const _hdlr = (type, name) => _fullbox('hdlr', 0, 0, _u32(0), _str4(type), _u32(0), _u32(0), _u32(0), concatBytes([TE.encode(name), new Uint8Array([0])]));
const _tkhd = (trackId, flags, volume, ticks) => _fullbox('tkhd', 0, flags, _u32(0), _u32(0), _u32(trackId), _u32(0), _u32(ticks), _u32(0), _u32(0), _u16(0), _u16(0), _u16(volume), _u16(0), _MATRIX, _u32(0), _u32(0));
// Identifies whether two chapters' AAC streams are actually compatible for
// raw concatenation: same channel layout/sample size/rate (fixed mp4a
// header) and same DecoderSpecificInfo (AudioSpecificConfig). Deliberately
// excludes bufferSizeDB/maxBitrate/avgBitrate — those are per-encode VBR
// statistics that legitimately differ between chapters of the same book.
function mp4aCodecFingerprint(mp4aBox) {
const dv = new DataView(mp4aBox.buffer, mp4aBox.byteOffset, mp4aBox.byteLength);
const channelCount = dv.getUint16(24);
const sampleSize = dv.getUint16(26);
const sampleRate = dv.getUint32(32);
const esds = _find(_boxList(dv, 36, mp4aBox.byteLength), 'esds');
if (!esds) return null;
let p = esds.dataStart + 4; // skip esds FullBox version/flags
const readByte = () => mp4aBox[p++];
const readDescriptorLen = () => { let len = 0, byte; do { byte = readByte(); len = (len << 7) | (byte & 0x7f); } while (byte & 0x80); return len; };
readByte(); readDescriptorLen(); // ES_Descriptor tag(03) + length
p += 3; // ES_ID(2) + flags(1)
readByte(); readDescriptorLen(); // DecoderConfigDescriptor tag(04) + length
const objectType = readByte();
const streamType = readByte();
p += 3 + 4 + 4; // bufferSizeDB + maxBitrate + avgBitrate — skipped on purpose
readByte(); // DecoderSpecificInfo tag(05)
const ascLen = readDescriptorLen();
const asc = mp4aBox.slice(p, p + ascLen);
return [channelCount, sampleSize, sampleRate, objectType, streamType, Array.from(asc).join(',')].join('|');
}
function _boxList(dv, start, end) {
const list = [];
let o = start;
while (o + 8 <= end) {
let size = dv.getUint32(o), hdr = 8;
const type = String.fromCharCode(dv.getUint8(o + 4), dv.getUint8(o + 5), dv.getUint8(o + 6), dv.getUint8(o + 7));
if (size === 1) { size = dv.getUint32(o + 8) * 2 ** 32 + dv.getUint32(o + 12); hdr = 16; }
if (size < 8 || o + size > end) break;
list.push({ type, start: o, size, dataStart: o + hdr, end: o + size });
o += size;
}
return list;
}
const _find = (l, t) => l.find(x => x.type === t);
function muxParseChapter(arrayBuffer) {
const dv = new DataView(arrayBuffer);
const b = new Uint8Array(arrayBuffer);
const top = _boxList(dv, 0, b.length);
const ftyp = _find(top, 'ftyp'), moov = _find(top, 'moov');
const trak = _find(_boxList(dv, moov.dataStart, moov.end), 'trak');
const mdia = _find(_boxList(dv, trak.dataStart, trak.end), 'mdia');
const mdiaKids = _boxList(dv, mdia.dataStart, mdia.end);
const mdhd = _find(mdiaKids, 'mdhd');
const timescale = b[mdhd.dataStart] === 1 ? dv.getUint32(mdhd.dataStart + 20) : dv.getUint32(mdhd.dataStart + 12);
const minf = _find(mdiaKids, 'minf');
const stbl = _find(_boxList(dv, minf.dataStart, minf.end), 'stbl');
const sk = _boxList(dv, stbl.dataStart, stbl.end);
const stsd = _find(sk, 'stsd');
const mp4a = _boxList(dv, stsd.dataStart + 8, stsd.end)[0];
const mp4aBytes = b.slice(mp4a.start, mp4a.end);
const codecFingerprint = mp4aCodecFingerprint(mp4aBytes);
let p = _find(sk, 'stsz').dataStart + 4;
const uniform = dv.getUint32(p); p += 4;
const count = dv.getUint32(p); p += 4;
const sizes = new Array(count);
if (uniform) sizes.fill(uniform); else for (let i = 0; i < count; i++) { sizes[i] = dv.getUint32(p); p += 4; }
p = _find(sk, 'stts').dataStart + 4;
const sttsN = dv.getUint32(p); p += 4;
const deltas = new Array(count); let si = 0;
for (let e = 0; e < sttsN; e++) { const c = dv.getUint32(p); p += 4; const d = dv.getUint32(p); p += 4; for (let k = 0; k < c; k++) deltas[si++] = d; }
p = _find(sk, 'stsc').dataStart + 4;
const stscN = dv.getUint32(p); p += 4;
const stscE = [];
for (let e = 0; e < stscN; e++) { const fc = dv.getUint32(p); const spc = dv.getUint32(p + 4); const sdi = dv.getUint32(p + 8); p += 12; stscE.push({ fc, spc, sdi }); }
const stco = _find(sk, 'stco'), co64 = _find(sk, 'co64');
const chunkOffsets = [];
if (stco) { p = stco.dataStart + 4; const n = dv.getUint32(p); p += 4; for (let i = 0; i < n; i++) { chunkOffsets.push(dv.getUint32(p)); p += 4; } }
else { p = co64.dataStart + 4; const n = dv.getUint32(p); p += 4; for (let i = 0; i < n; i++) { chunkOffsets.push(dv.getUint32(p) * 2 ** 32 + dv.getUint32(p + 4)); p += 8; } }
const offsets = new Array(count); let sIdx = 0;
for (let c = 0; c < chunkOffsets.length; c++) {
let spc = stscE[0].spc;
for (let e = 0; e < stscE.length; e++) { if (stscE[e].fc <= c + 1) spc = stscE[e].spc; else break; }
let off = chunkOffsets[c];
for (let k = 0; k < spc && sIdx < count; k++) { offsets[sIdx] = off; off += sizes[sIdx]; sIdx++; }
}
const totalBytes = sizes.reduce((n, s) => n + s, 0);
const data = new Uint8Array(totalBytes);
let d = 0;
for (let i = 0; i < count; i++) { data.set(b.subarray(offsets[i], offsets[i] + sizes[i]), d); d += sizes[i]; }
return { timescale, mp4aBytes, codecFingerprint, sizes, deltas, data, count, ftypBytes: b.slice(ftyp.start, ftyp.end) };
}
function _rleStts(deltas) {
const runs = [];
for (const d of deltas) { const last = runs[runs.length - 1]; if (last && last.d === d) last.c++; else runs.push({ c: 1, d }); }
const body = new Uint8Array(runs.length * 8);
const dv = new DataView(body.buffer);
for (let i = 0; i < runs.length; i++) { dv.setUint32(i * 8, runs[i].c >>> 0); dv.setUint32(i * 8 + 4, runs[i].d >>> 0); }
return _fullbox('stts', 0, 0, _u32(runs.length), body);
}
function _textSampleEntry() {
const payload = concatBytes([
_u32(0), _u32(1), // displayFlags, textJustification
_u16(0), _u16(0), _u16(0), // background color
_u16(0), _u16(0), _u16(0), _u16(0), // default text box
_u32(0), _u32(0), // reserved
_u16(0), _u16(0), // fontNumber, fontFace
new Uint8Array([0]), _u16(0), // reserved
_u16(0xFFFF), _u16(0xFFFF), _u16(0xFFFF), // foreground color
new Uint8Array([0]), // empty font name (pascal)
]);
return _box('text', new Uint8Array(6), _u16(1), payload);
}
// Wrap tag atoms in a fresh udta → meta → ilst chain (merged file has none).
function _buildTagUdta(tags) {
const atoms = buildIlstAtoms(tags);
if (!atoms.length) return null;
const hdlr = _fullbox('hdlr', 0, 0, _u32(0), _str4('mdir'), _str4('appl'), _u32(0), _u32(0), new Uint8Array([0]));
return _box('udta', _fullbox('meta', 0, 0, hdlr, _box('ilst', atoms)));
}
// chapters: array from muxParseChapter (all same timescale + mp4aBytes).
function buildMergedM4b(chapters, titles, tags) {
const timescale = chapters[0].timescale;
const sizes = chapters.flatMap(c => Array.from(c.sizes));
const deltas = chapters.flatMap(c => Array.from(c.deltas));
const audioData = concatBytes(chapters.map(c => c.data));
const totalTicks = deltas.reduce((n, d) => n + d, 0);
const chapterTicks = chapters.map(c => c.deltas.reduce((n, d) => n + d, 0));
const ftyp = chapters[0].ftypBytes;
const chapSamples = titles.map(t => { const txt = TE.encode(t); return concatBytes([_u16(txt.length), txt]); });
const chapData = concatBytes(chapSamples);
const mdat = concatBytes([_u32(8 + audioData.length + chapData.length), _str4('mdat'), audioData, chapData]);
const audioStart = ftyp.length + 8;
const chapStart = audioStart + audioData.length;
const aStbl = _box('stbl',
_fullbox('stsd', 0, 0, _u32(1), chapters[0].mp4aBytes),
_rleStts(deltas),
_fullbox('stsc', 0, 0, _u32(1), _u32(1), _u32(sizes.length), _u32(1)),
_fullbox('stsz', 0, 0, _u32(0), _u32(sizes.length), _u32Array(sizes)),
_fullbox('stco', 0, 0, _u32(1), _u32(audioStart)));
const aMinf = _box('minf', _fullbox('smhd', 0, 0, _u16(0), _u16(0)), _dinf(), aStbl);
const aMdia = _box('mdia', _mdhd(timescale, totalTicks), _hdlr('soun', 'SoundHandler'), aMinf);
const aTrak = _box('trak', _tkhd(1, 0x000007, 0x0100, totalTicks), _box('tref', _box('chap', _u32(2))), aMdia);
const cStbl = _box('stbl',
_fullbox('stsd', 0, 0, _u32(1), _textSampleEntry()),
_fullbox('stts', 0, 0, _u32(chapterTicks.length), _u32Array(chapterTicks.flatMap(t => [1, t]))),
_fullbox('stsc', 0, 0, _u32(1), _u32(1), _u32(chapSamples.length), _u32(1)),
_fullbox('stsz', 0, 0, _u32(0), _u32(chapSamples.length), _u32Array(chapSamples.map(s => s.length))),
_fullbox('stco', 0, 0, _u32(1), _u32(chapStart)));
const cMinf = _box('minf',
_box('gmhd', _fullbox('gmin', 0, 0, _u16(0), _u16(0), _u16(0), _u16(0), _u16(0), _u16(0))),
_dinf(), cStbl);
const cMdia = _box('mdia', _mdhd(timescale, totalTicks), _hdlr('text', 'ChapterHandler'), cMinf);
const cTrak = _box('trak', _tkhd(2, 0x000001, 0, totalTicks), cMdia);
const mvhd = _fullbox('mvhd', 0, 0, _u32(0), _u32(0), _u32(timescale), _u32(totalTicks), _u32(0x00010000), _u16(0x0100), _u16(0), _u32(0), _u32(0), _MATRIX, _u32(0), _u32(0), _u32(0), _u32(0), _u32(0), _u32(0), _u32(3));
const udta = _buildTagUdta(tags);
const moov = udta ? _box('moov', mvhd, aTrak, cTrak, udta) : _box('moov', mvhd, aTrak, cTrak);
return concatBytes([ftyp, mdat, moov]);
}
// Resolve every chapter's media URL (capped concurrency); null url = failed.
async function resolveAll(sorted) {
return mapWithConcurrency(sorted, RESOLVE_CONCURRENCY, async chapter => {
try { return { chapter, url: await resolveChapterURL(chapter.path) }; }
catch (e) { console.error('[ICE DL] resolve failed', chapter.title, e); return { chapter, url: null }; }
});
}
// Buffers the whole book in memory before muxing.
async function mergeBookDownload(sorted, tagCtx, btn) {
const { rawAuthor, rawBookTitle, total, coverPromise } = tagCtx;
btn.disabled = true;
try {
btn.textContent = `[...] Löse ${total} Kapitel auf…`;
const resolved = await resolveAll(sorted);
if (resolved.some(r => !r.url)) { btn.textContent = '[FEHLER] Konnte nicht alle Kapitel auflösen'; return; }
const chapters = [], titles = [];
for (let i = 0; i < resolved.length; i++) {
btn.textContent = `[...] Lade ${i + 1}/${total}…`;
const buf = await fetchAudioBuffer(resolved[i].url);
chapters.push(muxParseChapter(buf));
titles.push(cleanText(resolved[i].chapter.title) || `Kapitel ${resolved[i].chapter.serialNumber}`);
}
// Concatenating without re-encoding is only valid if every chapter shares
// the exact AAC config; bail if they diverge. Per-chapter VBR bitrate
// stats are excluded from the fingerprint since they always differ.
const ref = chapters[0];
if (!chapters.every(c => c.timescale === ref.timescale && c.codecFingerprint === ref.codecFingerprint)) {
btn.textContent = '[FEHLER] Kapitel haben unterschiedliche Audioformate'; return;
}
btn.textContent = '[...] Erstelle Datei…';
const cover = await coverPromise;
const tags = { title: cleanText(rawBookTitle), artist: cleanText(rawAuthor), album: cleanText(rawBookTitle), cover: cover && cover.bytes ? cover : null };
const file = buildMergedM4b(chapters, titles, tags);
const filename = `${sanitize(rawAuthor)} - ${sanitize(rawBookTitle)}.m4b`;
triggerBlobDownload(new Blob([file], { type: 'audio/mp4' }), filename);
btn.textContent = `[OK] ${total} Kapitel zusammengeführt`;
} catch (e) {
console.error('[ICE DL] merge failed', e);
btn.textContent = `[FEHLER] ${e.message}`;
} finally {
btn.disabled = false;
}
}
// ---------------------------------------------------------------------------
// UI helpers
// ---------------------------------------------------------------------------
const COLORS_YELLOW = {
panelBg: '#000000',
headerBg: '#000000',
detailBg: '#000000',
accent: '#ffb000',
accentHover: '#ffd23a',
accentText: '#000000', // text sitting on an amber block
btnBg: '#000000',
btnActiveBg: '#ffb000',
muted: '#8a6200', // dim amber — secondary text / separators
textLight: '#ffb000', // amber — primary text
};
const COLORS_TURQUOISE = {
panelBg: '#000000',
headerBg: '#000000',
detailBg: '#000000',
accent: '#43B0B6', // primary turquoise (balanced, readable)
accentHover: '#6FE3DA', // brighter cyan glow (manually corrected toward green)
accentText: '#000000',
btnBg: '#000000',
btnActiveBg: '#43B0B6',
muted: '#186A70', // dim teal (from dark cluster)
textLight: '#43B0B6',
};
const COLORS_RED = {
panelBg: '#000000',
headerBg: '#000000',
detailBg: '#000000',
accent: '#D01E3A',
accentHover: '#FF4A6A',
accentText: '#000000',
btnBg: '#000000',
btnActiveBg: '#D01E3A',
muted: '#7A1A2A',
textLight: '#D01E3A',
};
const PALETTES = [COLORS_YELLOW, COLORS_TURQUOISE, COLORS_RED];
const COLORS = PALETTES[Math.floor(Math.random() * PALETTES.length)];
// Convert a hex color to [r, g, b] for use in rgba() strings.
function hexRgb(hex) {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 176, 0];
}
// Tight bright core + wider soft halo, mirroring the boxShadow "LED glow" used elsewhere.
function textGlow(hex) {
const [r, g, b] = hexRgb(hex);
return `0 0 2px rgba(${r},${g},${b},0.9), 0 0 6px rgba(${r},${g},${b},0.6), 0 0 14px rgba(${r},${g},${b},0.35)`;
}
// Doto (SIL OFL 1.1, https://fonts.google.com/specimen/Doto), wght=900/ROND=100,
// full basic-Latin + Latin-Extended charsets (covers German umlauts/ß in author
// and title text). Self-hosted so the LED-style UI renders without a network
// fetch or hitting the page's CSP.
const DOT_MATRIX_FONT_LATIN = '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';
const DOT_MATRIX_FONT_LATIN_EXT = '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';
function injectDotMatrixFont() {
if (document.getElementById('ice-dl-font')) return;
const style = document.createElement('style');
style.id = 'ice-dl-font';
style.textContent = `
@font-face {
font-family: 'IceDotMatrix';
src: url(data:font/woff2;base64,${DOT_MATRIX_FONT_LATIN}) format('woff2');
font-weight: 900;
font-display: block;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'IceDotMatrix';
src: url(data:font/woff2;base64,${DOT_MATRIX_FONT_LATIN_EXT}) format('woff2');
font-weight: 900;
font-display: block;
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
`;
document.head.appendChild(style);
}
// Inline