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:
@@ -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(); });
|
||||||
|
|||||||
101
server/server.js
101
server/server.js
@@ -515,42 +515,74 @@ app.get('/api/search', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/channel?c=<channel>
|
// GET /api/channel?c=<channel>
|
||||||
app.get('/api/channel', async (c) => {
|
// Channel listing that works for every kind of channel, not one-off cases:
|
||||||
const chan = (c.req.query('c') || '').trim();
|
// 1. /videos, /streams, /shorts tabs — normal channels
|
||||||
if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400);
|
// 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 });
|
||||||
|
|
||||||
try {
|
// Playlist links (albums, playlists) in a flat tab listing.
|
||||||
let out = '', results = [], lastErr = null;
|
function playlistUrlsIn(out) {
|
||||||
for (const url of channelUrlCandidates(chan)) {
|
const urls = [];
|
||||||
try {
|
for (const line of out.split('\n')) {
|
||||||
out = await runYtdlpResilient([
|
try {
|
||||||
url,
|
const j = JSON.parse(line);
|
||||||
'--dump-json', '--flat-playlist',
|
const u = String(j.url || j.webpage_url || '');
|
||||||
'--no-warnings', '--ignore-errors',
|
if (/[?&]list=/.test(u)) urls.push(u);
|
||||||
'--playlist-end', String(CHANNEL_LIMIT),
|
} catch { /* not JSON */ }
|
||||||
], { pooled: true });
|
}
|
||||||
results = parseCards(out);
|
return urls;
|
||||||
if (results.length) break;
|
}
|
||||||
} catch (err) { lastErr = err; }
|
|
||||||
}
|
async function loadChannel(chan) {
|
||||||
// A bare name ("Maverick City Music - Topic") is not a handle: find the
|
let candidates = channelUrlCandidates(chan);
|
||||||
// channel id through a search, then try its uploads/home again.
|
if (!/^(https?:|@|UC[\w-]{22}$)/.test(chan)) {
|
||||||
if (!results.length && !/^(https?:|@|UC[\w-]{22}$)/.test(chan)) {
|
const id = await channelIdForName(chan).catch(() => null);
|
||||||
const id = await channelIdForName(chan).catch(() => null);
|
if (id) candidates = channelUrlCandidates(id);
|
||||||
if (id) {
|
}
|
||||||
for (const url of channelUrlCandidates(id)) {
|
const root = candidates[candidates.length - 1];
|
||||||
try {
|
// 1, 2, 4 — tabs, uploads playlist, home (home is last in the candidate list)
|
||||||
out = await runYtdlpResilient([url, '--dump-json', '--flat-playlist', '--no-warnings', '--ignore-errors',
|
const direct = [candidates[0], `${root}/streams`, `${root}/shorts`, ...candidates.slice(1)];
|
||||||
'--playlist-end', String(CHANNEL_LIMIT)], { pooled: true });
|
for (const url of direct) {
|
||||||
results = parseCards(out);
|
try {
|
||||||
if (results.length) { lastErr = null; break; }
|
const out = await ytdlpFlat(url, CHANNEL_LIMIT);
|
||||||
} catch (err) { lastErr = err; }
|
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 && lastErr) throw lastErr;
|
if (results.length) return { out, results: results.slice(0, CHANNEL_LIMIT) };
|
||||||
// Extract channel name + URL from the first record
|
}
|
||||||
const first = results[0];
|
return { out: '', results: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/channel', async (c) => {
|
||||||
|
const chan = (c.req.query('c') || '').trim();
|
||||||
|
if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400);
|
||||||
|
try {
|
||||||
|
const { out, results } = await loadChannel(chan);
|
||||||
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user