Uploads library on the USB drive with a nightly stable-disk copy; Topic channels resolve by name

- UPLOAD_DIR moves to the USB drive; reads fall back to UPLOAD_BACKUP_DIR on the
  ytplayer-data volume, and while the drive is offline new uploads land there
- scripts/ops/uploads-backup.sh (homelab cron, 03:30) copies both ways, never
  deletes; the server removes a deleted upload from both places
- /api/channel resolves a bare channel name ('Artist - Topic') to its id via a
  search, and the client falls back to the channel name for old saved entries
This commit is contained in:
Jonathan Sykes
2026-10-02 22:38:05 +08:00
parent 1f9a97a6ae
commit 11ddb8a970
6 changed files with 134 additions and 19 deletions

View File

@@ -371,6 +371,22 @@ function channelUrlCandidates(c) {
return out;
}
// Name → channel id: the first search hit published by a channel with exactly
// that name (case-insensitive). "<Artist> - Topic" names come from YouTube Music.
async function channelIdForName(name) {
const want = name.trim().toLowerCase();
const out = await runYtdlpResilient([`ytsearch10:${name}`, '--dump-json', '--flat-playlist', '--no-warnings', '--ignore-errors'], { pooled: true });
for (const line of out.split('\n')) {
try {
const j = JSON.parse(line);
const ch = String(j.channel || j.uploader || '').trim().toLowerCase();
const id = j.channel_id || j.uploader_id;
if (ch === want && /^UC[\w-]{22}$/.test(id || '')) return id;
} catch { /* not JSON */ }
}
return null;
}
// Resolve a channel identifier to a /videos URL yt-dlp can fetch
function channelToUrl(c) {
let base = c.trim();
@@ -517,6 +533,21 @@ app.get('/api/channel', async (c) => {
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];
@@ -2050,6 +2081,8 @@ const uploads = registerUploadRoutes(app, {
ffmpeg: FFMPEG,
ffprobe: FFPROBE,
uploadDir: UPLOAD_DIR,
backupDir: process.env.UPLOAD_BACKUP_DIR || null,
volumeMarker: process.env.UPLOAD_VOLUME_MARKER || null,
rangeFileResponse,
requireAdminOrToken: notes.requireAdminOrToken,
parseLrc,