Media cache on the homelab USB drive with an offline guard; Topic channels list their tracks

- MEDIA_DIR moves to /mnt/data/ytplayer (bind-mounted at /app/bulk); DB, lyrics,
  notes and uploads stay on the ytplayer-data volume
- MEDIA_VOLUME_MARKER: while the marker on the drive is missing the cache writes,
  serves and deletes nothing, so playback streams and an unmounted drive never
  fills the disk under its mountpoint or loses its index on a reboot
- Budget 250 GiB, keep 20 GiB free on the drive
- /api/channel falls back to the uploads playlist and the channel home page when
  a channel has no Videos tab (auto-generated '- Topic' music channels)
This commit is contained in:
Jonathan Sykes
2026-10-02 21:21:23 +08:00
parent 53da71ceb9
commit 1f9a97a6ae
4 changed files with 106 additions and 15 deletions

View File

@@ -357,6 +357,20 @@ function parseCards(output) {
return results;
}
// Auto-generated "<Artist> - Topic" channels (YouTube Music) have no Videos
// tab, so /videos fails with "This channel does not have a videos tab". Their
// tracks are still in the channel's uploads playlist (UC… → UU…) and on the
// channel home page, so those are tried next.
function channelUrlCandidates(c) {
const videos = channelToUrl(c);
const root = videos.replace(/\/videos$/, '');
const m = /(?:^|\/channel\/)(UC[\w-]{22})(?:[/?#]|$)/.exec(c.trim()) || /\/channel\/(UC[\w-]{22})/.exec(root);
const out = [videos];
if (m) out.push(`https://www.youtube.com/playlist?list=UU${m[1].slice(2)}`);
out.push(root);
return out;
}
// Resolve a channel identifier to a /videos URL yt-dlp can fetch
function channelToUrl(c) {
let base = c.trim();
@@ -490,14 +504,20 @@ app.get('/api/channel', async (c) => {
if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400);
try {
const url = channelToUrl(chan);
const out = await runYtdlpResilient([
url,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
'--playlist-end', String(CHANNEL_LIMIT),
], { pooled: true });
const results = parseCards(out);
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; }
}
if (!results.length && lastErr) throw lastErr;
// Extract channel name + URL from the first record
const first = results[0];
let channelName = '', channelUrl = '';
@@ -1122,10 +1142,14 @@ async function ytdlpEditedDownloadResponse(videoId, fp, keep, signal, cachedSrc
// ============================================================================
const envNum = (k, d) => (process.env[k] !== undefined && process.env[k] !== '' ? Number(process.env[k]) : d);
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
mkdirSync(MEDIA_DIR, { recursive: true });
// Set when MEDIA_DIR sits on a removable drive: the cache only uses the
// directory while this marker (created once on the drive itself) exists.
const MEDIA_VOLUME_MARKER = process.env.MEDIA_VOLUME_MARKER || null;
if (!MEDIA_VOLUME_MARKER || existsSync(MEDIA_VOLUME_MARKER)) mkdirSync(MEDIA_DIR, { recursive: true });
const media = createMediaCache({
dir: MEDIA_DIR,
volumeMarker: MEDIA_VOLUME_MARKER,
db: {
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats,
// Retention (docs/p2p-architecture.md flow 9): cold copies go before popular ones.