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:
@@ -5,8 +5,17 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# libsql DB file persists across container rebuilds
|
# libsql DB file persists across container rebuilds
|
||||||
- ytplayer-data:/app/data
|
- ytplayer-data:/app/data
|
||||||
|
# Bulk, re-fetchable media on the homelab's USB drive (/mnt/data, mounted
|
||||||
|
# nofail). Only the media cache lives here — the DB, lyrics, notes and
|
||||||
|
# uploads stay on ytplayer-data. If the drive is missing, the marker file
|
||||||
|
# below is missing too and the cache pauses: playback streams instead.
|
||||||
|
- /mnt/data/ytplayer:/app/bulk
|
||||||
environment:
|
environment:
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
|
MEDIA_DIR: "/app/bulk/media"
|
||||||
|
MEDIA_VOLUME_MARKER: "/app/bulk/.hl-data"
|
||||||
|
MEDIA_CACHE_MAX_BYTES: "268435456000" # 250 GiB of the 458 GiB drive
|
||||||
|
MEDIA_MIN_FREE_BYTES: "21474836480" # keep 20 GiB free on the drive
|
||||||
DB_PATH: "/app/data/ytplayer.db"
|
DB_PATH: "/app/data/ytplayer.db"
|
||||||
APP_VERSION: "1.0.0"
|
APP_VERSION: "1.0.0"
|
||||||
# Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in
|
# Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in
|
||||||
@@ -36,9 +45,8 @@ services:
|
|||||||
# YTDLP_WORKER: "0" # disable the long-lived yt-dlp worker pool
|
# YTDLP_WORKER: "0" # disable the long-lived yt-dlp worker pool
|
||||||
# YTDLP_WORKERS: "2" # pool size
|
# YTDLP_WORKERS: "2" # pool size
|
||||||
# Server media cache (server/media-cache.js) — defaults shown.
|
# Server media cache (server/media-cache.js) — defaults shown.
|
||||||
# MEDIA_DIR: "/app/data/media" # on the ytplayer-data volume
|
# (MEDIA_DIR / MEDIA_CACHE_MAX_BYTES / MEDIA_MIN_FREE_BYTES set above;
|
||||||
# MEDIA_CACHE_MAX_BYTES: "10737418240" # 10 GiB, LRU eviction past this
|
# defaults without them: /app/data/media, 10 GiB, 5 GiB)
|
||||||
# MEDIA_MIN_FREE_BYTES: "5368709120" # skip caching below 5 GiB free disk
|
|
||||||
# MEDIA_AUTO_MAX_SECONDS: "3600" # longest video auto-cached on play
|
# MEDIA_AUTO_MAX_SECONDS: "3600" # longest video auto-cached on play
|
||||||
# MEDIA_TRANSCODE: "1" # 0 disables the compression lane
|
# MEDIA_TRANSCODE: "1" # 0 disables the compression lane
|
||||||
# MEDIA_CODEC: "hevc" # hevc | h264
|
# MEDIA_CODEC: "hevc" # hevc | h264
|
||||||
|
|||||||
@@ -171,7 +171,17 @@ export function createMediaCache({
|
|||||||
hashFile = sha256File, // server-computed SHA-256 = the file's P2P content id
|
hashFile = sha256File, // server-computed SHA-256 = the file's P2P content id
|
||||||
onReady = null, // ({ id, gen, path, sha256, size, … }) after a validated copy lands
|
onReady = null, // ({ id, gen, path, sha256, size, … }) after a validated copy lands
|
||||||
backfillDelayMs = 30_000, // hash pre-existing copies this long after init()
|
backfillDelayMs = 30_000, // hash pre-existing copies this long after init()
|
||||||
|
// Optional marker file that only exists on the real cache volume (e.g. a USB
|
||||||
|
// drive bind-mounted into the container). While it is missing the volume is
|
||||||
|
// treated as OFFLINE: nothing is written (so an unmounted drive never fills
|
||||||
|
// the disk underneath its mountpoint), nothing is served (playback streams
|
||||||
|
// instead), and nothing is deleted (a drive that comes back keeps its copies).
|
||||||
|
volumeMarker = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
const available = () => {
|
||||||
|
if (!volumeMarker) return true;
|
||||||
|
try { return existsSync(volumeMarker); } catch { return false; }
|
||||||
|
};
|
||||||
const tmpDir = join(dir, '.tmp');
|
const tmpDir = join(dir, '.tmp');
|
||||||
const fileFor = (id, gen, kind = 'mp4') => join(dir, `${id}.${gen}.${kind}`);
|
const fileFor = (id, gen, kind = 'mp4') => join(dir, `${id}.${gen}.${kind}`);
|
||||||
|
|
||||||
@@ -208,7 +218,7 @@ export function createMediaCache({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readyFilesExist(row) {
|
function readyFilesExist(row) {
|
||||||
return existsSync(fileFor(row.video_id, row.gen)) && existsSync(fileFor(row.video_id, row.gen, 'm4a'));
|
return available() && existsSync(fileFor(row.video_id, row.gen)) && existsSync(fileFor(row.video_id, row.gen, 'm4a'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeFree() {
|
function safeFree() {
|
||||||
@@ -223,6 +233,7 @@ export function createMediaCache({
|
|||||||
|
|
||||||
// Make room for ~est bytes under the budget (LRU), then check the disk guard.
|
// Make room for ~est bytes under the budget (LRU), then check the disk guard.
|
||||||
async function makeRoom(id, est) {
|
async function makeRoom(id, est) {
|
||||||
|
if (!available()) throw new MediaSkip('cache volume offline — streaming instead');
|
||||||
let { bytes } = await db.mediaStats();
|
let { bytes } = await db.mediaStats();
|
||||||
if (bytes + est > maxBytes) {
|
if (bytes + est > maxBytes) {
|
||||||
const cutoff = now() - EVICT_PROTECT_MS;
|
const cutoff = now() - EVICT_PROTECT_MS;
|
||||||
@@ -512,6 +523,7 @@ export function createMediaCache({
|
|||||||
if (again) { bump(again, priority, auto); return again.promise; }
|
if (again) { bump(again, priority, auto); return again.promise; }
|
||||||
if (row && row.status === 'ready') {
|
if (row && row.status === 'ready') {
|
||||||
if (readyFilesExist(row)) return row;
|
if (readyFilesExist(row)) return row;
|
||||||
|
if (!available()) throw new MediaSkip('cache volume offline — streaming instead');
|
||||||
await db.deleteMedia(id);
|
await db.deleteMedia(id);
|
||||||
removeFiles(id);
|
removeFiles(id);
|
||||||
} else if (row && row.status === 'failed' && !force && now() < row.retry_at) {
|
} else if (row && row.status === 'failed' && !force && now() < row.retry_at) {
|
||||||
@@ -560,6 +572,7 @@ export function createMediaCache({
|
|||||||
|
|
||||||
async function redownload(id) {
|
async function redownload(id) {
|
||||||
if (!isMediaId(id)) throw new MediaSkip('invalid video id');
|
if (!isMediaId(id)) throw new MediaSkip('invalid video id');
|
||||||
|
if (!available()) throw new MediaSkip('cache volume offline — streaming instead');
|
||||||
const existing = jobs.get(id);
|
const existing = jobs.get(id);
|
||||||
if (existing) { bump(existing, HIGH, false); return { status: existing.status }; }
|
if (existing) { bump(existing, HIGH, false); return { status: existing.status }; }
|
||||||
const row = await db.getMedia(id);
|
const row = await db.getMedia(id);
|
||||||
@@ -585,7 +598,7 @@ export function createMediaCache({
|
|||||||
// Path for /api/media. With a gen, ONLY that generation is served (never
|
// Path for /api/media. With a gen, ONLY that generation is served (never
|
||||||
// different bytes under the same URL); without one, the current copy.
|
// different bytes under the same URL); without one, the current copy.
|
||||||
async function filePath(id, gen, kind = 'mp4') {
|
async function filePath(id, gen, kind = 'mp4') {
|
||||||
if (!isMediaId(id)) return null;
|
if (!isMediaId(id) || !available()) return null;
|
||||||
let g = gen !== undefined && gen !== null && gen !== '' ? Number(gen) : null;
|
let g = gen !== undefined && gen !== null && gen !== '' ? Number(gen) : null;
|
||||||
if (g !== null && !Number.isInteger(g)) return null;
|
if (g !== null && !Number.isInteger(g)) return null;
|
||||||
if (g === null) {
|
if (g === null) {
|
||||||
@@ -659,6 +672,12 @@ export function createMediaCache({
|
|||||||
|
|
||||||
// Boot: sweep partial work, drop files/rows that disagree, resume jobs.
|
// Boot: sweep partial work, drop files/rows that disagree, resume jobs.
|
||||||
async function init() {
|
async function init() {
|
||||||
|
if (!available()) {
|
||||||
|
// Reconciling now would drop every row (files "missing") and then, once
|
||||||
|
// the drive is back, delete every file as an orphan. Leave it all alone.
|
||||||
|
log.warn?.(`[media] cache volume offline (no ${volumeMarker}) — caching paused, playback streams`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
mkdirSync(tmpDir, { recursive: true });
|
mkdirSync(tmpDir, { recursive: true });
|
||||||
sweepTmp();
|
sweepTmp();
|
||||||
const rows = await db.listMedia();
|
const rows = await db.listMedia();
|
||||||
@@ -712,5 +731,5 @@ export function createMediaCache({
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile };
|
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile, available };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -389,3 +389,43 @@ describe('media cache jobs', () => {
|
|||||||
expect(await cache.filePath('..', 1)).toBeNull();
|
expect(await cache.filePath('..', 1)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('removable cache volume (volumeMarker)', () => {
|
||||||
|
test('offline volume: nothing is written, nothing served, rows survive a reboot', async () => {
|
||||||
|
await clearDb();
|
||||||
|
const markerDir = mkdtempSync(join(tmpdir(), 'ytp-vol-'));
|
||||||
|
const marker = join(markerDir, '.hl-data');
|
||||||
|
writeFileSync(marker, '');
|
||||||
|
const seed = makeCache({ volumeMarker: marker });
|
||||||
|
await seed.cache.init();
|
||||||
|
const row = await seed.cache.ensureCached('volAAAAAAA1', { priority: HIGH });
|
||||||
|
expect(row.status).toBe('ready');
|
||||||
|
expect(await seed.cache.filePath('volAAAAAAA1')).not.toBeNull();
|
||||||
|
|
||||||
|
// Drive unplugged: the marker vanishes.
|
||||||
|
rmSync(marker);
|
||||||
|
expect(seed.cache.available()).toBe(false);
|
||||||
|
expect(await seed.cache.filePath('volAAAAAAA1')).toBeNull();
|
||||||
|
expect(await seed.cache.getReady('volAAAAAAA1')).toBeNull();
|
||||||
|
await expect(seed.cache.ensureCached('volAAAAAAA2', { priority: HIGH })).rejects.toBeInstanceOf(MediaSkip);
|
||||||
|
await expect(seed.cache.ensureCached('volAAAAAAA1', { priority: HIGH })).rejects.toBeInstanceOf(MediaSkip);
|
||||||
|
expect(seed.calls).toEqual(['volAAAAAAA1']);
|
||||||
|
|
||||||
|
// Reboot while offline must not reconcile (it would drop every row).
|
||||||
|
const again = makeCache({ volumeMarker: marker, dir: seed.dir });
|
||||||
|
await again.cache.init();
|
||||||
|
expect((await dbmod.getMedia('volAAAAAAA1')).status).toBe('ready');
|
||||||
|
expect(existsSync(join(seed.dir, `volAAAAAAA1.${row.gen}.mp4`))).toBe(true);
|
||||||
|
|
||||||
|
// Drive back: the same copy is served again without a refetch.
|
||||||
|
writeFileSync(marker, '');
|
||||||
|
expect(await again.cache.filePath('volAAAAAAA1')).not.toBeNull();
|
||||||
|
rmSync(markerDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no marker configured behaves exactly as before', async () => {
|
||||||
|
await clearDb();
|
||||||
|
const { cache } = makeCache();
|
||||||
|
expect(cache.available()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -357,6 +357,20 @@ function parseCards(output) {
|
|||||||
return results;
|
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
|
// Resolve a channel identifier to a /videos URL yt-dlp can fetch
|
||||||
function channelToUrl(c) {
|
function channelToUrl(c) {
|
||||||
let base = c.trim();
|
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);
|
if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = channelToUrl(chan);
|
let out = '', results = [], lastErr = null;
|
||||||
const out = await runYtdlpResilient([
|
for (const url of channelUrlCandidates(chan)) {
|
||||||
url,
|
try {
|
||||||
'--dump-json', '--flat-playlist',
|
out = await runYtdlpResilient([
|
||||||
'--no-warnings', '--ignore-errors',
|
url,
|
||||||
'--playlist-end', String(CHANNEL_LIMIT),
|
'--dump-json', '--flat-playlist',
|
||||||
], { pooled: true });
|
'--no-warnings', '--ignore-errors',
|
||||||
const results = parseCards(out);
|
'--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
|
// Extract channel name + URL from the first record
|
||||||
const first = results[0];
|
const first = results[0];
|
||||||
let channelName = '', channelUrl = '';
|
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 envNum = (k, d) => (process.env[k] !== undefined && process.env[k] !== '' ? Number(process.env[k]) : d);
|
||||||
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
|
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({
|
const media = createMediaCache({
|
||||||
dir: MEDIA_DIR,
|
dir: MEDIA_DIR,
|
||||||
|
volumeMarker: MEDIA_VOLUME_MARKER,
|
||||||
db: {
|
db: {
|
||||||
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats,
|
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats,
|
||||||
// Retention (docs/p2p-architecture.md flow 9): cold copies go before popular ones.
|
// Retention (docs/p2p-architecture.md flow 9): cold copies go before popular ones.
|
||||||
|
|||||||
Reference in New Issue
Block a user