Channels load for every channel type; parallel saves setting (default 4)

Channels: one loader tries, in order, the Videos/Live/Shorts tabs (normal
channels), the uploads playlist (Topic channels), the Releases/Playlists tabs
expanded into their tracks (artist/music channels whose only content is albums,
e.g. Cathedral of Praise Worship), then the home page; bare names are resolved
to an id first. Nothing public → ok with a message, never a raw yt-dlp error.

Saves: SaveSlots caps concurrent offline saves (Settings → Offline cache →
Videos saved at once, 1–8, default 4); queued saves show "Waiting for a free
slot", and resumed saves now run up to the limit instead of one at a time.
This commit is contained in:
Jonathan Sykes
2026-10-03 02:04:27 +08:00
parent 71f1913d0b
commit 9ce9a96f38
2 changed files with 101 additions and 36 deletions

View File

@@ -270,6 +270,21 @@ const SearchLibrary = (() => {
// OPFS bridge wrappers — return the same shape as the Tauri cache_* commands // OPFS bridge wrappers — return the same shape as the Tauri cache_* commands
// so the rest of app.js works without changes. // so the rest of app.js works without changes.
// ---------- Save slots: how many videos save at once (Settings, default 4) ----------
const SaveSlots = (() => {
let active = 0;
const waiters = [];
const limit = () => Math.max(1, Math.min(8, Number(data.settings.parallelSaves) || 4));
function pump() {
while (waiters.length && active < limit()) { active++; waiters.shift()(); }
}
return {
acquire() { return new Promise((r) => { waiters.push(r); pump(); }); },
release() { active = Math.max(0, active - 1); pump(); },
pump,
};
})();
// ---------- Save queue: resumable saves survive reloads and dropped links ---------- // ---------- Save queue: resumable saves survive reloads and dropped links ----------
// Every save is recorded here when it starts and removed when it finishes or // Every save is recorded here when it starts and removed when it finishes or
// fails for good. A save that only paused (partial kept on the device) stays // fails for good. A save that only paused (partial kept on the device) stays
@@ -298,6 +313,7 @@ const SaveQueue = (() => {
function label(p) { function label(p) {
if (!p) return '⏳ Saving for offline…'; if (!p) return '⏳ Saving for offline…';
if (p.phase === 'waiting') return '⏳ Waiting for a free slot…';
const mb = (n) => (n / 1048576).toFixed(n >= 1048576 * 100 ? 0 : 1); const mb = (n) => (n / 1048576).toFixed(n >= 1048576 * 100 ? 0 : 1);
if (p.phase === 'preparing') return `⏳ Preparing on server… ${Math.round((p.elapsed || 0) / 1000)} s`; if (p.phase === 'preparing') return `⏳ Preparing on server… ${Math.round((p.elapsed || 0) / 1000)} s`;
if (p.phase === 'verifying') return '✓ Verifying…'; if (p.phase === 'verifying') return '✓ Verifying…';
@@ -325,13 +341,14 @@ const SaveQueue = (() => {
if (running || !navigator.onLine) return; if (running || !navigator.onLine) return;
running = true; running = true;
try { try {
const jobs = [];
for (const v of pending()) { for (const v of pending()) {
if (cachedIds.has(v.id)) { remove(v.id); continue; } if (cachedIds.has(v.id)) { remove(v.id); continue; }
if (downloading.has(v.id)) continue; if (downloading.has(v.id)) continue;
if (Date.now() - v.at > MAX_AGE) { cancel(v.id); continue; } if (Date.now() - v.at > MAX_AGE) { cancel(v.id); continue; }
await preload(v, { quiet: true }); // one at a time: the homelab link is the bottleneck jobs.push(preload(v, { quiet: true })); // SaveSlots caps how many run at once
if (!navigator.onLine) break;
} }
await Promise.all(jobs);
} finally { running = false; } } finally { running = false; }
if (view.type === 'downloads') renderList(); if (view.type === 'downloads') renderList();
} }
@@ -594,6 +611,7 @@ const DEFAULT_SETTINGS = {
shuffle: false, // shuffle upcoming tracks when playing a list shuffle: false, // shuffle upcoming tracks when playing a list
theme: 'dark', // 'dark' | 'light' | 'contrast' theme: 'dark', // 'dark' | 'light' | 'contrast'
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl' fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
density: 'comfortable', // 'comfortable' | 'compact' density: 'comfortable', // 'comfortable' | 'compact'
perfMode: false, // disable heavy visual effects for speed perfMode: false, // disable heavy visual effects for speed
reduceMotion: false, // disable animations reduceMotion: false, // disable animations
@@ -1751,7 +1769,12 @@ async function preload(video, { quiet = false, mux = false } = {}) {
if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
if (!quiet) toast(`Saving “${video.title}” for offline…`); if (!quiet) toast(`Saving “${video.title}” for offline…`);
SaveQueue.add(video); SaveQueue.add(video);
SaveQueue.progress(id, { phase: 'waiting' });
let slot = false;
try { try {
await SaveSlots.acquire();
slot = true;
SaveQueue.progress(id, null);
const res = await API.cacheDownload(id, { mux, onProgress: (p) => SaveQueue.progress(id, p) }); const res = await API.cacheDownload(id, { mux, onProgress: (p) => SaveQueue.progress(id, p) });
if (res && res.paused) { if (res && res.paused) {
// The partial stays on the device; SaveQueue picks it up again. // The partial stays on the device; SaveQueue picks it up again.
@@ -1772,6 +1795,7 @@ async function preload(video, { quiet = false, mux = false } = {}) {
SaveQueue.remove(id); SaveQueue.remove(id);
if (!quiet) toast('⚠ Saving not supported in this build.'); if (!quiet) toast('⚠ Saving not supported in this build.');
} finally { } finally {
if (slot) SaveSlots.release();
SaveQueue.progress(id, null); SaveQueue.progress(id, null);
downloading.delete(id); downloading.delete(id);
downloadMeta.delete(id); downloadMeta.delete(id);
@@ -8937,6 +8961,13 @@ async function renderSettings() {
</span> </span>
<input id="setAutoPreload" type="checkbox" ${data.settings.autoPreload ? 'checked' : ''} /> <input id="setAutoPreload" type="checkbox" ${data.settings.autoPreload ? 'checked' : ''} />
</label> </label>
<label class="set-row">
<span>
Videos saved at once
<small>How many offline saves run in parallel; the rest wait their turn.</small>
</span>
${sel('setParallelSaves', String(Math.max(1, Math.min(8, Number(data.settings.parallelSaves) || 4))), [['1', '1'], ['2', '2'], ['3', '3'], ['4', '4 (default)'], ['6', '6'], ['8', '8']])}
</label>
<div class="set-row"> <div class="set-row">
<span>Storage used</span> <span>Storage used</span>
<span id="cacheTotal" class="set-stat">…</span> <span id="cacheTotal" class="set-stat">…</span>
@@ -9084,6 +9115,7 @@ async function renderSettings() {
$('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); }); $('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); });
$('setLayout').addEventListener('change', (e) => { data.settings.layout = e.target.value; applyAppearance(); persist(); }); $('setLayout').addEventListener('change', (e) => { data.settings.layout = e.target.value; applyAppearance(); persist(); });
$('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); }); $('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); });
$('setParallelSaves').addEventListener('change', (e) => { data.settings.parallelSaves = Number(e.target.value) || 4; persist(); SaveSlots.pump(); });
$('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); }); $('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); });
$('setPerf').addEventListener('change', (e) => { data.settings.perfMode = e.target.checked; applyAppearance(); persist(); }); $('setPerf').addEventListener('change', (e) => { data.settings.perfMode = e.target.checked; applyAppearance(); persist(); });
$('setMotion').addEventListener('change', (e) => { data.settings.reduceMotion = e.target.checked; applyAppearance(); persist(); }); $('setMotion').addEventListener('change', (e) => { data.settings.reduceMotion = e.target.checked; applyAppearance(); persist(); });

View File

@@ -515,42 +515,74 @@ app.get('/api/search', async (c) => {
}); });
// GET /api/channel?c=<channel> // GET /api/channel?c=<channel>
// Channel listing that works for every kind of channel, not one-off cases:
// 1. /videos, /streams, /shorts tabs — normal channels
// 2. uploads playlist (UC… → UU…) — auto-generated "- Topic" channels
// 3. /releases, /playlists → their tracks — artist/music channels whose only
// content is albums or playlists (no Videos tab, no uploads playlist)
// 4. channel home page
// A bare channel name is first resolved to its id with a search. A channel with
// nothing public answers ok with no results (never a raw yt-dlp error).
const ytdlpFlat = (url, end) => runYtdlpResilient([url, '--dump-json', '--flat-playlist', '--no-warnings', '--ignore-errors',
'--playlist-end', String(end)], { pooled: true });
// Playlist links (albums, playlists) in a flat tab listing.
function playlistUrlsIn(out) {
const urls = [];
for (const line of out.split('\n')) {
try {
const j = JSON.parse(line);
const u = String(j.url || j.webpage_url || '');
if (/[?&]list=/.test(u)) urls.push(u);
} catch { /* not JSON */ }
}
return urls;
}
async function loadChannel(chan) {
let candidates = channelUrlCandidates(chan);
if (!/^(https?:|@|UC[\w-]{22}$)/.test(chan)) {
const id = await channelIdForName(chan).catch(() => null);
if (id) candidates = channelUrlCandidates(id);
}
const root = candidates[candidates.length - 1];
// 1, 2, 4 — tabs, uploads playlist, home (home is last in the candidate list)
const direct = [candidates[0], `${root}/streams`, `${root}/shorts`, ...candidates.slice(1)];
for (const url of direct) {
try {
const out = await ytdlpFlat(url, CHANNEL_LIMIT);
const results = parseCards(out);
if (results.length) return { out, results };
} catch { /* next source */ }
}
// 3 — albums / playlists, expanded into tracks (a few at a time).
for (const tab of ['releases', 'playlists']) {
let lists = [];
try { lists = playlistUrlsIn(await ytdlpFlat(`${root}/${tab}`, 15)); } catch { continue; }
if (!lists.length) continue;
const results = [], seen = new Set();
let out = '';
for (let i = 0; i < lists.length && results.length < CHANNEL_LIMIT; i += 3) {
const batch = await Promise.all(lists.slice(i, i + 3).map((u) => ytdlpFlat(u, 40).catch(() => '')));
for (const o of batch) {
out += o + '\n';
for (const card of parseCards(o)) {
if (seen.has(card.id)) continue;
seen.add(card.id);
results.push(card);
}
}
}
if (results.length) return { out, results: results.slice(0, CHANNEL_LIMIT) };
}
return { out: '', results: [] };
}
app.get('/api/channel', async (c) => { app.get('/api/channel', async (c) => {
const chan = (c.req.query('c') || '').trim(); const chan = (c.req.query('c') || '').trim();
if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400); if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400);
try { try {
let out = '', results = [], lastErr = null; const { out, results } = await loadChannel(chan);
for (const url of channelUrlCandidates(chan)) {
try {
out = await runYtdlpResilient([
url,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
'--playlist-end', String(CHANNEL_LIMIT),
], { pooled: true });
results = parseCards(out);
if (results.length) break;
} catch (err) { lastErr = err; }
}
// A bare name ("Maverick City Music - Topic") is not a handle: find the
// channel id through a search, then try its uploads/home again.
if (!results.length && !/^(https?:|@|UC[\w-]{22}$)/.test(chan)) {
const id = await channelIdForName(chan).catch(() => null);
if (id) {
for (const url of channelUrlCandidates(id)) {
try {
out = await runYtdlpResilient([url, '--dump-json', '--flat-playlist', '--no-warnings', '--ignore-errors',
'--playlist-end', String(CHANNEL_LIMIT)], { pooled: true });
results = parseCards(out);
if (results.length) { lastErr = null; break; }
} catch (err) { lastErr = err; }
}
}
}
if (!results.length && lastErr) throw lastErr;
// Extract channel name + URL from the first record
const first = results[0];
let channelName = '', channelUrl = ''; let channelName = '', channelUrl = '';
for (const line of out.split('\n')) { for (const line of out.split('\n')) {
const t = line.trim(); const t = line.trim();
@@ -562,9 +594,10 @@ app.get('/api/channel', async (c) => {
if (channelName && channelUrl) break; if (channelName && channelUrl) break;
} catch { /* skip */ } } catch { /* skip */ }
} }
return c.json({ ok: true, channel: channelName || (first?.channel || ''), channelUrl, results }); return c.json({ ok: true, channel: channelName || (results[0]?.channel || ''), channelUrl, results,
...(results.length ? {} : { message: 'This channel has no public videos to show.' }) });
} catch (err) { } catch (err) {
return c.json({ ok: false, error: err.message }, 500); return c.json({ ok: false, error: 'Could not load this channel right now — try again.' }, 500);
} }
}); });