: je tam bug, anebo má někdo jenom averzi k MyHeritage? (bez premium účtu si nemám palec dolů jak vysvětlit)
// ==UserScript==
// @name mujRozhlas Chapter Downloader
// @namespace https://mujrozhlas.cz/
// @version 1.7
// @description Adds a panel to mujrozhlas.cz pages that downloads every chapter/episode of the current audiobook, serial or show. MP3 is used when Český rozhlas offers it; otherwise the HLS stream (AAC) is stitched in the browser into one .aac file without transcoding. Saves into a per-book folder (a folder you pick once, desktop Chromium) or straight into Downloads.
// @match https://www.mujrozhlas.cz/*
// @match https://mujrozhlas.cz/*
// @grant GM_xmlhttpRequest
// @grant GM_download
// @grant GM_info
// @connect api.mujrozhlas.cz
// @connect croaod.cz
// @connect rozhlas.cz
// @connect portal.rozhlas.cz
// @connect mujrozhlas.cz
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ---------------------- Configuration ----------------------
const API = 'https://api.mujrozhlas.cz';
const SEGMENT_PARALLELISM = 4; // HLS segments fetched at once per episode
const PAGE_LIMIT = 100; // API page size when listing a show's episodes
const MAX_EPISODES = 1000; // safety cap for huge shows
const BASE_FOLDER = 'MujRozhlas'; // books land in <picked folder>\MujRozhlas\<book>\ (unless the picked folder is MujRozhlas itself)
const PREFIX_PART_NUMBER = true; // "03 - Title.aac" instead of "Title.aac"
const RETAIN_MB = 400; // keep finished files in memory (for "save again") up to this total
const URL_POLL_MS = 500; // the site is a single-page app; poll for URL changes
const ID = 'mrdl'; // DOM id prefix
const LS_METHOD = 'mrdl_method'; // auto | folder | plain – how files are saved (see "Saving strategies")
const LS_ASCII_NAMES = 'mrdl_ascii_names'; // "1" – apply the folder-name rule to file names too
// ---------------------- Runtime state ----------------------
const state = {
context: null, // {bundle, id, label}
episodes: [], // see normalizeEpisode()
running: false,
abort: null, // AbortController while running
loaded: false,
gen: 0, // bumped on every page change; stale async loads check it and bail out
loadAbort: null, // AbortController of the running list load
stale: false, // URL changed while a download was running
saver: null, // {kind: 'folder'|'gm'|'anchor', ...} chosen for the current run
plainForced: false, // user chose "save without a folder" after a folder method failed
folderStored: false,// a picked base folder is remembered in IndexedDB
retained: [], // [{ep, blob}] built files kept for "save again" / instant retry
};
// ---------------------- Utilities --------------------------
const $ = (sel, root) => (root || document).querySelector(sel);
let panelEl = null; // the script's own panel (all UI lookups are scoped to it)
const ui = (sel) => (panelEl ? panelEl.querySelector(sel) : null);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function sanitizeFilename(s) {
return (s || '')
.replace(/[\\/:*?"<>|\x00-\x1f]/g, '-')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/, '')
.slice(0, 150) || 'audio';
}
// Folder names: ASCII only, per the user's transliteration table.
const TRANSLIT = {
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'é': 'e', 'è': 'e', 'ê': 'e', 'ě': 'e', 'à': 'a',
'Ä': 'AE', 'Ö': 'OE', 'Ü': 'UE', 'É': 'E', 'È': 'E', 'Ê': 'E', 'Ě': 'E', 'À': 'A',
'á': 'a', 'č': 'c', 'ď': 'd', 'í': 'i', 'ň': 'n', 'ó': 'o', 'ř': 'r', 'š': 's', 'ť': 't', 'ů': 'u', 'ú': 'u', 'ý': 'y', 'ž': 'z', 'ł': 'l', 'đ': 'd',
'Á': 'A', 'Č': 'C', 'Ď': 'D', 'Í': 'I', 'Ň': 'N', 'Ó': 'O', 'Ř': 'R', 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z', 'Ł': 'L', 'Đ': 'D',
'ß': 'ss',
};
function folderName(s) {
let out = '';
for (const ch of (s || '')) {
if (TRANSLIT[ch] !== undefined) out += TRANSLIT[ch];
else if (/[,. !§'´ˇ;()\-]/.test(ch)) out += '_';
else if (/[A-Za-z0-9_]/.test(ch)) out += ch;
else {
// anything else non-ASCII: strip combining marks, keep ASCII letters, otherwise "_"
const plain = ch.normalize('NFD').replace(/[̀-ͯ]/g, '');
out += /^[A-Za-z0-9]$/.test(plain) ? plain : '_';
}
}
return out.replace(/_+/g, '_').replace(/^_|_$/g, '').slice(0, 100) || 'mujRozhlas';
}
function fmtDuration(sec) {
if (!sec && sec !== 0) return '';
sec = Math.round(sec);
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(s).padStart(2, '0');
}
function fmtBytes(n) {
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' kB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
function log(msg) {
console.log('[mujRozhlas DL] ' + msg);
}
function esc(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
}
// ---------------------- HTTP layer -------------------------
// Plain fetch first (api.mujrozhlas.cz, croaod.cz and portal.rozhlas.cz all send CORS headers),
// GM_xmlhttpRequest as a fallback should CORS ever be withdrawn.
async function httpGet(url, { type = 'text', signal, onProgress } = {}) {
try {
const r = await fetch(url, { signal, headers: type === 'json' ? { Accept: 'application/json' } : {} });
if (!r.ok) throw new Error('HTTP ' + r.status);
if (type === 'json') return await r.json();
if (type === 'text') return await r.text();
// arraybuffer with progress
if (onProgress && r.body && typeof r.body.getReader === 'function') {
const total = Number(r.headers.get('content-length')) || 0;
const reader = r.body.getReader();
const chunks = [];
let got = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
got += value.length;
onProgress(got, total);
}
const out = new Uint8Array(got);
let off = 0;
for (const c of chunks) { out.set(c, off); off += c.length; }
return out.buffer;
}
return await r.arrayBuffer();
} catch (e) {
if (signal && signal.aborted) throw e;
if (typeof GM_xmlhttpRequest !== 'function') throw e;
log('fetch failed for ' + url + ' (' + e.message + '), retrying via GM_xmlhttpRequest');
return gmGet(url, { type, signal, onProgress });
}
}
function gmGet(url, { type, signal, onProgress }) {
return new Promise((resolve, reject) => {
const req = GM_xmlhttpRequest({
method: 'GET',
url,
responseType: type === 'json' ? 'json' : (type === 'text' ? 'text' : 'arraybuffer'),
headers: type === 'json' ? { Accept: 'application/json' } : {},
onprogress: (p) => { if (onProgress && p.lengthComputable) onProgress(p.loaded, p.total); },
onload: (r) => {
if (r.status < 200 || r.status >= 300) return reject(new Error('HTTP ' + r.status));
if (type === 'json' && typeof r.response === 'string') {
try { return resolve(JSON.parse(r.response)); } catch (e) { return reject(e); }
}
resolve(type === 'text' ? r.responseText : r.response);
},
onerror: () => reject(new Error('network error')),
ontimeout: () => reject(new Error('timeout')),
});
if (signal) signal.addEventListener('abort', () => { try { req.abort(); } catch (_) { /* ignore */ } reject(new DOMException('Aborted', 'AbortError')); });
});
}
// ---------------------- Page context -----------------------
// mujrozhlas.cz pushes a dataLayer object ("var dl = {...}") into an inline script:
// siteEntityBundle: serial | serialPart | episode | show | custom_hp | ...
// contentId: UUID of the serial / episode (absent on show pages)
// contentShow: "<show UUID>: <show title>"
// siteDocumentPath: path the object describes
// The site is a single-page app: after in-page navigation that inline script (and parts of the
// DOM such as the title) stay stale, so when it does not describe the current path the page
// HTML is fetched again and parsed instead.
function parseContext(html, fallbackLabel) {
const m = (re) => (html.match(re) || [])[1];
const raw = m(/"siteEntityBundle":"([^"]*)"/) || '';
const contentId = m(/"contentId":"([0-9a-f-]{36})"/);
const showId = m(/"contentShow":"([0-9a-f-]{36})/);
const docPath = (m(/"siteDocumentPath":"((?:[^"\\]|\\.)*)"/) || '').replace(/\\\//g, '/');
let label = fallbackLabel || '';
const rawLabel = m(/"siteEntityLabel":"((?:[^"\\]|\\.)*)"/);
if (rawLabel) { try { label = JSON.parse('"' + rawLabel + '"'); } catch (_) { label = rawLabel; } }
let bundle = 'page', id = null;
if (raw === 'serial' && contentId) { bundle = 'serial'; id = contentId; }
else if (raw === 'show' && showId) { bundle = 'show'; id = showId; }
else if ((raw === 'serialPart' || raw === 'episode') && contentId) { bundle = 'episode'; id = contentId; }
return { bundle, id, label, docPath, raw };
}
async function detectContext(signal) {
const path = location.pathname;
const titleLabel = document.title.replace(/\s*•.*$/, '').trim();
for (const s of document.scripts) {
const t = s.textContent;
if (!t || t.indexOf('siteEntityBundle') === -1) continue;
const ctx = parseContext(t, titleLabel);
if (ctx.docPath === path) return ctx;
}
// Stale or missing: fetch the server-rendered HTML of the current URL.
try {
const html = await httpGet(location.href, { type: 'text', signal });
const lbl = ((html.match(/<title>([^<]*)<\/title>/) || [])[1] || titleLabel).replace(/\s*•.*$/, '').trim();
const ctx = parseContext(html, lbl);
if (ctx.raw) return ctx;
} catch (e) { log('page fetch failed: ' + e.message); }
return { bundle: 'page', id: null, label: titleLabel, docPath: path, raw: '' };
}
function dataEntriesOnPage() {
const out = [];
for (const el of document.querySelectorAll('[data-entry]')) {
try {
const j = JSON.parse(el.getAttribute('data-entry'));
if (j && j.uuid && j.type) out.push(j);
} catch (_) { /* ignore */ }
}
return out;
}
// ---------------------- API ------------------------------
async function apiList(url, signal, cap) {
const items = [];
let next = url;
while (next && items.length < cap) {
const j = await httpGet(next, { type: 'json', signal });
items.push(...(j.data || []));
next = j.links && j.links.next;
if (next) setStatus(`Loading… ${items.length} episodes so far`);
}
return items;
}
function normalizeEpisode(res) {
const a = res.attributes || {};
const links = Array.isArray(a.audioLinks) ? a.audioLinks : [];
const byVariant = {};
for (const l of links) if (l && l.url && l.variant && !byVariant[l.variant]) byVariant[l.variant] = l;
let kind = null, link = null;
if (byVariant.mp3) { kind = 'mp3'; link = byVariant.mp3; }
else if (byVariant.hls) { kind = 'hls'; link = byVariant.hls; }
else if (byVariant.dash && /manifest\.mpd$/.test(byVariant.dash.url)) {
// same stream directory also serves the HLS playlist
kind = 'hls';
link = Object.assign({}, byVariant.dash, { url: byVariant.dash.url.replace(/manifest\.mpd$/, 'playlist.m3u8') });
}
const now = Date.now();
let status = 'ready';
if (!link) status = (a.since && Date.parse(a.since) > now) ? 'not yet published' : 'no audio';
else if (link.playableTill && Date.parse(link.playableTill) < now) status = 'expired';
const rel = res.relationships || {};
return {
uuid: res.id,
title: a.title || a.shortTitle || res.id,
part: a.part || null,
since: a.since || '',
duration: link ? link.duration : null,
bitrate: link ? link.bitrate : null,
kind,
url: link ? link.url : null,
status,
serialUuid: rel.serial && rel.serial.data && rel.serial.data.id || null,
selected: status === 'ready',
progress: '',
};
}
async function loadEpisodes(signal) {
const ctx = state.context;
let raw = [];
if (ctx.bundle === 'serial' && ctx.id) {
raw = await apiList(`${API}/serials/${ctx.id}/episodes?page[limit]=500`, signal, MAX_EPISODES);
raw.sort((x, y) => (x.attributes.part || 0) - (y.attributes.part || 0));
} else if (ctx.bundle === 'show' && ctx.id) {
raw = await apiList(`${API}/shows/${ctx.id}/episodes?page[limit]=${PAGE_LIMIT}`, signal, MAX_EPISODES);
} else if (ctx.bundle === 'episode' && ctx.id) {
const j = await httpGet(`${API}/episodes/${ctx.id}`, { type: 'json', signal });
raw = j.data ? [j.data] : [];
// A chapter of a serial: list the whole serial so every chapter can be taken at once.
const serialUuid = normalizeEpisode(raw[0] || {}).serialUuid;
if (serialUuid) {
state.context = Object.assign({}, ctx, { bundle: 'serial', id: serialUuid });
return loadEpisodes(signal);
}
} else {
// any other page: take whatever episodes are listed on it
const seen = new Set();
const uuids = dataEntriesOnPage().filter((e) => e.type === 'episode' && !seen.has(e.uuid) && seen.add(e.uuid)).map((e) => e.uuid);
for (const u of uuids.slice(0, MAX_EPISODES)) {
try {
const j = await httpGet(`${API}/episodes/${u}`, { type: 'json', signal });
if (j.data) raw.push(j.data);
} catch (e) { log('episode ' + u + ' failed: ' + e.message); }
}
}
state.episodes = raw.map(normalizeEpisode);
state.loaded = true;
}
// ---------------------- Stream assembly --------------------
// Strip the ID3v2 timestamp tag HLS packs in front of every AAC segment so the
// concatenation is a clean ADTS stream.
function stripId3(u8) {
let off = 0;
while (u8.length - off >= 10 && u8[off] === 0x49 && u8[off + 1] === 0x44 && u8[off + 2] === 0x33) {
const flags = u8[off + 5];
const size = ((u8[off + 6] & 0x7f) << 21) | ((u8[off + 7] & 0x7f) << 14) | ((u8[off + 8] & 0x7f) << 7) | (u8[off + 9] & 0x7f);
off += 10 + size + ((flags & 0x10) ? 10 : 0);
}
return off ? u8.subarray(off) : u8;
}
async function resolveMediaPlaylist(url, signal) {
let text = await httpGet(url, { type: 'text', signal });
let base = url;
for (let hop = 0; hop < 3 && /#EXT-X-STREAM-INF/.test(text); hop++) {
const lines = text.split(/\r?\n/);
let next = null;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('#EXT-X-STREAM-INF')) {
for (let k = i + 1; k < lines.length; k++) if (lines[k] && !lines[k].startsWith('#')) { next = lines[k].trim(); break; }
break;
}
}
if (!next) break;
base = new URL(next, base).href;
text = await httpGet(base, { type: 'text', signal });
}
return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#')).map((l) => new URL(l, base).href);
}
async function pool(items, n, fn) {
const results = new Array(items.length);
let idx = 0;
async function worker() {
for (;;) {
const i = idx++;
if (i >= items.length) return;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
return results;
}
async function fetchWithRetry(url, opts, tries = 3) {
let err;
for (let t = 0; t < tries; t++) {
try { return await httpGet(url, opts); } catch (e) {
err = e;
if (opts.signal && opts.signal.aborted) throw e;
await sleep(500 * (t + 1));
}
}
throw err;
}
async function buildHls(ep, signal, setProgress) {
const segs = await resolveMediaPlaylist(ep.url, signal);
if (!segs.length) throw new Error('empty playlist');
let done = 0, bytes = 0;
const parts = await pool(segs, SEGMENT_PARALLELISM, async (u) => {
const buf = await fetchWithRetry(u, { type: 'arraybuffer', signal });
const u8 = stripId3(new Uint8Array(buf));
done++; bytes += u8.length;
setProgress(`${done}/${segs.length} segments, ${fmtBytes(bytes)}`, done / segs.length);
return u8;
});
return new Blob(parts, { type: 'audio/aac' });
}
async function buildMp3(ep, signal, setProgress) {
const buf = await fetchWithRetry(ep.url, {
type: 'arraybuffer', signal,
onProgress: (got, total) => setProgress(fmtBytes(got) + (total ? ' / ' + fmtBytes(total) : ''), total ? got / total : 0),
});
return new Blob([buf], { type: 'audio/mpeg' });
}
const asciiNamesWanted = () => localStorage.getItem(LS_ASCII_NAMES) === '1';
function fileNameFor(ep) {
const ext = ep.kind === 'mp3' ? 'mp3' : 'aac';
let base = ep.title;
if (PREFIX_PART_NUMBER && ep.part) base = String(ep.part).padStart(2, '0') + ' - ' + base;
return (asciiNamesWanted() ? folderName(base) : sanitizeFilename(base)) + '.' + ext;
}
function currentFolderName() {
return folderName(state.context && state.context.label);
}
// ---------------------- Saving strategies ------------------
// 1. "folder": File System Access API (desktop Chromium). The user picks a base folder once
// (remembered in IndexedDB); the script creates BASE_FOLDER\<book folder> inside it and writes
// files directly - no download prompts at all, already-present files are skipped. Chromium
// refuses Downloads / Desktop / Documents / home *themselves* ("contains system files"), so
// the user must pick or create a sub-folder, e.g. Downloads\MujRozhlas.
// 2. "gm": Tampermonkey GM_download with a Blob, plain file name. Used for "Downloads directly"
// when available because extension-started downloads do not trigger Chromium's
// "allow multiple downloads" prompt. (Sub-folders in the name are NOT used: Chromium forks
// silently drop them, so that route was removed.)
// 3. "anchor": plain <a download> click. Works everywhere (incl. Firefox/Kiwi on Android) but
// Chromium asks once per site to allow multiple downloads.
const hasFolderApi = typeof window.showDirectoryPicker === 'function';
const savedMethod = () => { const m = localStorage.getItem(LS_METHOD); return m === 'folder' || m === 'plain' ? m : 'auto'; }; // older stored values (e.g. 'gm') fall back to auto
// -- IndexedDB persistence of the picked directory handle --
function idb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('mrdl', 1);
req.onupgradeneeded = () => req.result.createObjectStore('kv');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function idbGet(key) {
try {
const db = await idb();
return await new Promise((resolve, reject) => {
const r = db.transaction('kv').objectStore('kv').get(key);
r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error);
});
} catch (_) { return undefined; }
}
async function idbSet(key, val) {
try {
const db = await idb();
await new Promise((resolve, reject) => {
const tx = db.transaction('kv', 'readwrite');
tx.objectStore('kv').put(val, key);
tx.oncomplete = resolve; tx.onerror = () => reject(tx.error);
});
} catch (_) { /* ignore */ }
}
// Must be called from a user gesture (the Download button click).
async function pickBaseFolder(forcePick) {
let handle = forcePick ? null : await idbGet('baseDir');
if (handle) {
try {
let p = await handle.queryPermission({ mode: 'readwrite' });
if (p !== 'granted') p = await handle.requestPermission({ mode: 'readwrite' });
if (p !== 'granted') handle = null;
} catch (_) { handle = null; }
}
if (!handle) {
handle = await window.showDirectoryPicker({ id: 'mrdl-base', mode: 'readwrite', startIn: 'downloads' });
await idbSet('baseDir', handle);
state.folderStored = true;
}
return handle;
}
async function makeFolderSaver(forcePick) {
const picked = await pickBaseFolder(forcePick);
const base = picked.name.toLowerCase() === BASE_FOLDER.toLowerCase() ? picked : await picked.getDirectoryHandle(BASE_FOLDER, { create: true });
const sub = await base.getDirectoryHandle(currentFolderName(), { create: true });
return {
kind: 'folder',
label: `folder "${picked.name}${base === picked ? '' : '/' + BASE_FOLDER}/${sub.name}"`,
async exists(name) {
try { const f = await (await sub.getFileHandle(name)).getFile(); return f.size > 0; } catch (_) { return false; }
},
async save(blob, name) {
const fh = await sub.getFileHandle(name, { create: true });
const w = await fh.createWritable();
await w.write(blob);
await w.close();
},
};
}
function gmDownloadMode() {
try { return (typeof GM_info !== 'undefined' && GM_info && GM_info.downloadMode) || ''; } catch (_) { return ''; }
}
const gmSaver = {
kind: 'gm',
label: 'Tampermonkey download into Downloads (no folder)',
exists: async () => false,
save(blob, name) {
return new Promise((resolve, reject) => {
try {
GM_download({
url: blob,
name,
conflictAction: 'uniquify',
onload: () => resolve(),
onerror: (e) => reject(new Error('Tampermonkey download failed: ' + ((e && e.error) || 'error'))),
ontimeout: () => reject(new Error('Tampermonkey download timed out')),
});
} catch (e) { reject(e); }
});
},
};
const anchorSaver = {
kind: 'anchor',
label: 'browser download (no folder)',
exists: async () => false,
async save(blob, name) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 60000);
},
};
const ANCHOR_WARNING = 'Chromium browsers (Chrome, Edge, Vivaldi, Brave…) show an "allow multiple downloads" prompt near the address bar when the 2nd file is saved. ' +
'Click Allow, otherwise the 2nd and later files are silently dropped. The choice is remembered per site. ' +
'Dropped files can be saved again from the list afterwards.';
const gmAvailable = () => typeof GM_download === 'function' && gmDownloadMode() !== 'disabled';
const plainSaver = () => (gmAvailable() ? gmSaver : anchorSaver);
// Which strategy to use. "auto": a folder picked earlier wins, otherwise plain downloads.
async function chooseSaver(forcePick) {
if (state.plainForced && !forcePick) return plainSaver();
let method = forcePick ? 'folder' : savedMethod();
if (method === 'auto') method = (hasFolderApi && await idbGet('baseDir')) ? 'folder' : 'plain';
if (method === 'folder' && hasFolderApi) {
try { return await makeFolderSaver(forcePick); }
catch (e) {
if (e && e.name === 'AbortError') throw new Error(`Folder selection cancelled. Browsers refuse the Downloads folder itself - pick or create a sub-folder such as Downloads\\${BASE_FOLDER}.`);
throw new Error('Cannot use the picked folder: ' + e.message);
}
}
return plainSaver();
}
// ---------------------- Download run -----------------------
function retain(ep, blob) {
state.retained = state.retained.filter((r) => r.ep.uuid !== ep.uuid);
state.retained.push({ ep, blob });
let total = state.retained.reduce((n, r) => n + r.blob.size, 0);
while (state.retained.length > 1 && total > RETAIN_MB * 1024 * 1024) {
const dropped = state.retained.shift();
total -= dropped.blob.size;
}
}
// Thrown when a folder-capable method (Tampermonkey / picked folder) cannot save: the run is
// stopped and the user told what to fix, instead of silently dropping files into Downloads.
class SaveSetupError extends Error {}
async function saveOrExplain(blob, name) {
try {
await state.saver.save(blob, name);
} catch (e) {
if (state.saver.kind === 'folder') throw new SaveSetupError(e.message);
if (state.saver.kind === 'gm') {
log('Tampermonkey download failed (' + e.message + '), using a plain browser download');
state.saver = anchorSaver;
showWarning(ANCHOR_WARNING);
setMethod();
await anchorSaver.save(blob, name);
return;
}
throw e;
}
}
async function downloadEpisode(ep, signal) {
const setProgress = (txt, frac) => { ep.progress = txt; renderRow(ep, frac); };
const name = fileNameFor(ep);
if (await state.saver.exists(name)) {
ep.status = 'done';
setProgress('already in folder, skipped', 1);
return;
}
let blob = (state.retained.find((r) => r.ep.uuid === ep.uuid) || {}).blob; // built earlier, save failed
if (!blob) {
setProgress('starting…', 0);
blob = ep.kind === 'mp3' ? await buildMp3(ep, signal, setProgress) : await buildHls(ep, signal, setProgress);
retain(ep, blob);
}
setProgress('saving…', 1);
await saveOrExplain(blob, name);
ep.status = 'done';
setProgress('saved ' + fmtBytes(blob.size), 1);
}
async function downloadSelected(forcePick) {
if (state.running) return;
const todo = state.episodes.filter((e) => e.selected && e.status === 'ready');
if (!todo.length) { setStatus('Nothing selected.'); return; }
try {
state.saver = await chooseSaver(forcePick); // inside the click's user activation
} catch (e) {
setStatus(e.message);
return;
}
setMethod();
if (state.saver.kind === 'anchor') showWarning(ANCHOR_WARNING); else showWarning('');
state.running = true;
state.abort = new AbortController();
renderButtons();
let ok = 0, fail = 0, setupProblem = null;
for (let i = 0; i < todo.length; i++) {
const ep = todo[i];
if (state.abort.signal.aborted) break;
setStatus(`Downloading ${i + 1} / ${todo.length}: ${ep.title}`);
try {
await downloadEpisode(ep, state.abort.signal);
ok++;
// give the browser a moment to start the save before the next file
await sleep(400);
} catch (e) {
if (state.abort.signal.aborted) { ep.progress = 'cancelled'; renderRow(ep, 0); break; }
if (e instanceof SaveSetupError) {
ep.progress = 'built, waiting to be saved';
renderRow(ep, 1);
setupProblem = e.message;
break;
}
fail++;
ep.progress = 'failed: ' + e.message;
renderRow(ep, 0);
log('failed ' + ep.title + ': ' + e.message);
}
}
const cancelled = state.abort.signal.aborted;
state.running = false;
state.abort = null;
renderButtons();
if (setupProblem) {
state.saver = null;
setMethod();
showWarning(setupProblem, true);
setStatus(`Stopped after ${ok} file(s) - see the note above.`);
return;
}
let msg = cancelled ? `Cancelled after ${ok} file(s).` : `Finished: ${ok} saved${fail ? ', ' + fail + ' failed' : ''}.`;
if (!cancelled && state.saver.kind === 'anchor' && ok > 1) msg += ' If the browser blocked some downloads, use "save again" on the affected rows.';
setStatus(msg);
if (state.stale) resetForNewPage();
}
async function saveAgain(uuid) {
const r = state.retained.find((x) => x.ep.uuid === uuid);
if (!r) { setStatus('That file is no longer held in memory – download it again.'); return; }
try {
if (!state.saver) state.saver = await chooseSaver(false);
await saveOrExplain(r.blob, fileNameFor(r.ep));
setStatus('Saved again: ' + fileNameFor(r.ep));
} catch (e) { setStatus('Save failed: ' + e.message); }
}
function cancel() {
if (state.abort) state.abort.abort();
}
// ---------------------- SPA navigation ---------------------
function resetForNewPage() {
state.gen++;
if (state.loadAbort) { try { state.loadAbort.abort(); } catch (_) { /* ignore */ } state.loadAbort = null; }
state.stale = false;
state.context = null;
state.episodes = [];
state.loaded = false;
state.retained = [];
state.saver = null;
const list = ui('#' + ID + '-list');
if (list) list.innerHTML = '';
showWarning('');
setMethod();
if (panelEl && panelEl.classList.contains('open')) openPanel(); // reload for the new page
}
let lastHref = location.href;
setInterval(() => {
if (location.href === lastHref) return;
lastHref = location.href;
if (state.running) { state.stale = true; setStatus('Page changed – the list will refresh when this download finishes.'); }
else resetForNewPage();
}, URL_POLL_MS);
// ---------------------- UI ------------------------------
const CSS = `
#${ID}-fab{position:fixed;right:16px;bottom:16px;z-index:2147483000;background:#e2001a;color:#fff;border:0;border-radius:28px;
padding:12px 18px;font:600 15px/1 system-ui,Segoe UI,Roboto,sans-serif;box-shadow:0 4px 14px rgba(0,0,0,.35);cursor:pointer}
#${ID}-fab:hover{background:#b80015}
#${ID}-panel{position:fixed;right:12px;bottom:12px;z-index:2147483001;width:min(600px,calc(100vw - 24px));max-height:min(85vh,calc(100vh - 24px));
display:none;flex-direction:column;background:#fff;color:#111;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);
font:14px/1.4 system-ui,Segoe UI,Roboto,sans-serif;overflow:hidden}
#${ID}-panel.open{display:flex}
#${ID}-head{display:flex;align-items:center;gap:8px;padding:10px 12px;background:#e2001a;color:#fff;font-weight:600}
#${ID}-head .t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#${ID}-head button{background:transparent;border:0;color:#fff;font-size:20px;cursor:pointer;padding:0 4px;line-height:1}
#${ID}-tools{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;border-bottom:1px solid #ddd;align-items:center}
#${ID}-tools button{border:1px solid #bbb;background:#f6f6f6;color:#111;border-radius:6px;padding:7px 11px;font-size:13px;cursor:pointer}
#${ID}-tools button.primary{background:#e2001a;border-color:#e2001a;color:#fff;font-weight:600}
#${ID}-tools button:disabled{opacity:.5;cursor:default}
#${ID}-opts{display:flex;flex-wrap:wrap;gap:4px 14px;padding:6px 12px;border-bottom:1px solid #ddd;font-size:12px;color:#333;align-items:center}
#${ID}-opts label{display:flex;align-items:center;gap:5px;cursor:pointer}
#${ID}-opts .m{color:#666}
#${ID}-opts a{color:#0b57d0;cursor:pointer}
#${ID}-warn{display:none;padding:8px 12px;background:#fff4d6;color:#5a3d00;border-bottom:1px solid #f0d58c;font-size:12px;line-height:1.35}
#${ID}-warn.show{display:block}
#${ID}-warn button{margin-top:6px;border:1px solid #c9a74a;background:#fff;color:#5a3d00;border-radius:6px;padding:5px 9px;font-size:12px;cursor:pointer}
#${ID}-list{overflow:auto;flex:1;padding:4px 0}
.${ID}-row{display:grid;grid-template-columns:auto 1fr auto;gap:4px 10px;align-items:center;padding:6px 12px;border-bottom:1px solid #eee}
.${ID}-row .ttl{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.${ID}-row .meta{grid-column:2/4;color:#555;font-size:12px}
.${ID}-row .meta a{color:#0b57d0;cursor:pointer;margin-left:6px}
.${ID}-row .badge{font-size:11px;padding:2px 6px;border-radius:4px;background:#eee;color:#333;white-space:nowrap}
.${ID}-row .badge.mp3{background:#d9f2d9;color:#155715}
.${ID}-row .badge.hls{background:#dbe8ff;color:#113a7a}
.${ID}-row .badge.off{background:#f6d9d9;color:#7a1111}
.${ID}-row .bar{grid-column:2/4;height:4px;background:#eee;border-radius:2px;overflow:hidden}
.${ID}-row .bar i{display:block;height:100%;width:0;background:#e2001a;transition:width .2s}
.${ID}-row.done .ttl{color:#155715}
#${ID}-status{padding:8px 12px;border-top:1px solid #ddd;font-size:12px;color:#333;background:#fafafa}
`;
function buildUI() {
if (document.getElementById(ID + '-fab')) return; // already built (script loaded twice)
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
const fab = document.createElement('button');
fab.id = ID + '-fab';
fab.textContent = '⬇ Download chapters';
fab.addEventListener('click', openPanel);
document.body.appendChild(fab);
const panel = document.createElement('div');
panel.id = ID + '-panel';
panelEl = panel;
panel.innerHTML = `
<div id="${ID}-head"><span class="t"></span><button type="button" title="Close">×</button></div>
<div id="${ID}-tools">
<button type="button" data-act="all">Select all</button>
<button type="button" data-act="none">Select none</button>
<span style="flex:1"></span>
<button type="button" data-act="cancel" style="display:none">Cancel</button>
<button type="button" data-act="dl" class="primary">Download selected</button>
</div>
<div id="${ID}-opts">
<label><input type="checkbox" data-opt="ascii" ${asciiNamesWanted() ? 'checked' : ''}> ASCII file names (same rule as the folder)</label>
<label>save to <select data-opt="method">
<option value="auto">folder picked earlier, otherwise Downloads</option>
${hasFolderApi ? `<option value="folder">folder I pick once → ${BASE_FOLDER}\\<book>\\ (works everywhere)</option>` : ''}
<option value="plain">Downloads directly, no sub-folder</option>
</select></label>${hasFolderApi ? `<a data-act="pick">choose folder…</a>` : ''}
<span class="m" data-role="method"></span>
</div>
<div id="${ID}-warn"></div>
<div id="${ID}-list"></div>
<div id="${ID}-status">Loading…</div>`;
document.body.appendChild(panel);
$('#' + ID + '-head button', panel).addEventListener('click', () => panel.classList.remove('open'));
$('#' + ID + '-tools', panel).addEventListener('click', onToolClick);
$('#' + ID + '-warn', panel).addEventListener('click', (ev) => {
if (ev.target.closest('button[data-act="plain"]')) { state.plainForced = true; showWarning(''); downloadSelected(false); }
});
$('#' + ID + '-opts', panel).addEventListener('click', (ev) => {
const a = ev.target.closest('a[data-act="pick"]');
if (a) {
ev.preventDefault();
pickBaseFolder(true).then((h) => {
localStorage.setItem(LS_METHOD, 'folder');
const sel = ui('select[data-opt="method"]'); if (sel) sel.value = 'folder';
state.plainForced = false;
setMethod();
setStatus(`Folder chosen: ${h.name}. Files will go to ${h.name}${h.name.toLowerCase() === BASE_FOLDER.toLowerCase() ? '' : '/' + BASE_FOLDER}/<book>/.`);
}).catch((e) => setStatus(e && e.name === 'AbortError' ? 'Folder selection cancelled.' : 'Folder selection failed: ' + e.message));
}
});
$('#' + ID + '-opts', panel).addEventListener('change', (ev) => {
if (ev.target.dataset.opt === 'method') { localStorage.setItem(LS_METHOD, ev.target.value); state.plainForced = false; setMethod(); }
if (ev.target.dataset.opt === 'ascii') { localStorage.setItem(LS_ASCII_NAMES, ev.target.checked ? '1' : '0'); renderList(); }
});
$('#' + ID + '-list', panel).addEventListener('change', (ev) => {
const cb = ev.target;
if (cb.type !== 'checkbox') return;
const ep = state.episodes.find((e) => e.uuid === cb.dataset.uuid);
if (ep) ep.selected = cb.checked;
});
$('#' + ID + '-list', panel).addEventListener('click', (ev) => {
const a = ev.target.closest('a[data-again]');
if (a) { ev.preventDefault(); saveAgain(a.dataset.again); }
});
const sel = ui('select[data-opt="method"]');
if (sel) sel.value = [...sel.options].some((o) => o.value === savedMethod()) ? savedMethod() : 'auto';
setMethod();
}
function setStatus(s) {
const el = ui('#' + ID + '-status');
if (el) el.textContent = s;
}
function showWarning(s, offerPlain) {
const el = ui('#' + ID + '-warn');
if (!el) return;
el.textContent = s;
if (s && offerPlain) {
const b = document.createElement('button');
b.type = 'button';
b.dataset.act = 'plain';
b.textContent = 'Save straight to Downloads instead (no folder)';
el.appendChild(document.createElement('br'));
el.appendChild(b);
}
el.classList.toggle('show', !!s);
}
// Describe which saving method the next run will use.
function setMethod() {
const el = ui(`#${ID}-opts [data-role="method"]`);
if (!el) return;
const folder = state.context ? currentFolderName() : '…';
const m = savedMethod();
let txt;
if (state.saver) txt = 'Saving via ' + state.saver.label + '.';
else if (state.plainForced || m === 'plain') txt = 'Files go straight to the default download folder (no sub-folder).';
else if (m === 'folder' || (m === 'auto' && hasFolderApi && state.folderStored)) txt = `Files go to <picked folder>\\${BASE_FOLDER}\\${folder}\\ - pick Downloads\\${BASE_FOLDER} once (the browser refuses Downloads itself) and choose "Allow on every visit" when asked.`;
else if (hasFolderApi) txt = 'No folder picked yet, so files go straight to the default download folder. Use "choose folder…" once to get a book folder.';
else txt = 'Files go straight to the default download folder (no sub-folder possible in this browser).';
el.textContent = txt;
}
async function openPanel() {
if (!panelEl) return;
panelEl.classList.add('open');
if (state.loaded) return;
state.loaded = true; // guard against double load while fetching
const gen = state.gen;
const ctl = new AbortController();
state.loadAbort = ctl;
setStatus('Loading…');
ui('#' + ID + '-head .t').textContent = 'mujRozhlas';
if (hasFolderApi) idbGet('baseDir').then((h) => { state.folderStored = !!h; setMethod(); });
try {
const ctx = await detectContext(ctl.signal);
if (gen !== state.gen) return; // page changed meanwhile – a newer load owns the panel
state.context = ctx;
ui('#' + ID + '-head .t').textContent = state.context.label || 'mujRozhlas';
await loadEpisodes(ctl.signal);
if (gen !== state.gen) return;
renderList();
setMethod();
const ready = state.episodes.filter((e) => e.status === 'ready').length;
setStatus(`${state.episodes.length} episode(s) found, ${ready} downloadable.`);
} catch (e) {
if (gen !== state.gen) return;
state.loaded = false;
setStatus('Failed to load episode list: ' + e.message);
log(e.stack || e);
} finally {
if (state.loadAbort === ctl) state.loadAbort = null;
}
}
function onToolClick(ev) {
const btn = ev.target.closest('button[data-act]');
if (!btn) return;
const act = btn.dataset.act;
if (act === 'all' || act === 'none') {
for (const e of state.episodes) if (e.status === 'ready') e.selected = act === 'all';
renderList();
} else if (act === 'dl') {
downloadSelected(false);
} else if (act === 'cancel') {
cancel();
}
}
function renderButtons() {
const dl = ui(`#${ID}-tools [data-act="dl"]`), cn = ui(`#${ID}-tools [data-act="cancel"]`);
if (dl) dl.disabled = state.running;
if (cn) cn.style.display = state.running ? '' : 'none';
}
function renderList() {
const list = ui('#' + ID + '-list');
list.innerHTML = '';
if (!state.episodes.length) {
list.innerHTML = '<div style="padding:16px;color:#666">No episodes found on this page.</div>';
return;
}
for (const ep of state.episodes) {
const row = document.createElement('div');
row.className = ID + '-row';
row.dataset.row = ep.uuid;
list.appendChild(row);
renderRow(ep, 0);
}
}
function renderRow(ep, frac) {
const row = ui(`.${ID}-row[data-row="${ep.uuid}"]`);
if (!row) return;
const ready = ep.status === 'ready' || ep.status === 'done';
const badge = ep.status === 'done' ? 'done' : (ready ? (ep.kind === 'mp3' ? 'MP3' : 'AAC (HLS)') : ep.status);
const badgeCls = ep.status === 'done' ? 'mp3' : (ready ? ep.kind : 'off');
const again = ep.status === 'done' && state.retained.some((r) => r.ep.uuid === ep.uuid) ? `<a data-again="${ep.uuid}">save again</a>` : '';
row.classList.toggle('done', ep.status === 'done');
row.innerHTML = `
<input type="checkbox" data-uuid="${ep.uuid}" ${ep.selected && ep.status === 'ready' ? 'checked' : ''} ${ep.status === 'ready' ? '' : 'disabled'}>
<div class="ttl" title="${esc(ep.title)}">${ep.part ? ep.part + '. ' : ''}${esc(ep.title)}</div>
<span class="badge ${badgeCls}">${badge}</span>
<div class="meta" title="${esc(fileNameFor(ep))}">${ep.duration ? fmtDuration(ep.duration) : ''}${ep.bitrate ? ' · ' + ep.bitrate + ' kbps' : ''}${ep.since ? ' · ' + ep.since.slice(0, 10) : ''}${ep.progress ? ' · ' + esc(ep.progress) : ''}${again}</div>
<div class="bar"><i style="width:${Math.round((frac || 0) * 100)}%"></i></div>`;
}
// ---------------------- Boot -------------------------------
if (document.body) buildUI();
else document.addEventListener('DOMContentLoaded', buildUI);
})();