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

@@ -515,42 +515,74 @@ app.get('/api/search', async (c) => {
});
// GET /api/channel?c=<channel>
app.get('/api/channel', async (c) => {
const chan = (c.req.query('c') || '').trim();
if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400);
// 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 });
try {
let out = '', results = [], lastErr = null;
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; }
// 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 && lastErr) throw lastErr;
// Extract channel name + URL from the first record
const first = results[0];
if (results.length) return { out, results: results.slice(0, CHANNEL_LIMIT) };
}
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 = '';
for (const line of out.split('\n')) {
const t = line.trim();
@@ -562,9 +594,10 @@ app.get('/api/channel', async (c) => {
if (channelName && channelUrl) break;
} 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) {
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);
}
});