Cache every played or saved video on the server via background jobs with a validation gate and a Broken re-download button

This commit is contained in:
Jonathan Sykes
2026-09-13 09:45:01 +08:00
parent 292a0c7e29
commit 5fb863ba25
8 changed files with 1240 additions and 20 deletions

View File

@@ -9,6 +9,7 @@
* users — one row per fingerprint; tracks last app version seen
* playlists — one row per fingerprint; full playlist JSON blob
* video_history — one row per (fingerprint, video_id); recent 200 entries
* media_cache — one row per server-cached video file (see media-cache.js)
* ========================================================================== */
import { createClient } from '@libsql/client';
@@ -81,9 +82,110 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS idx_inbox_to
ON playlist_inbox (to_name, created_at DESC);
-- Server-side media cache: one validated mp4 per video under MEDIA_DIR.
-- The file on disk is <video_id>.<gen>.mp4 (+ .m4a audio sidecar); gen
-- bumps on every replacement so a URL carrying ?g= always maps to the
-- same bytes. Timestamps here are ms epochs (backoff math needs ms).
CREATE TABLE IF NOT EXISTS media_cache (
video_id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued', -- queued|downloading|validating|ready|failed
gen INTEGER NOT NULL DEFAULT 0,
size INTEGER NOT NULL DEFAULT 0, -- mp4 + m4a bytes
height INTEGER NOT NULL DEFAULT 0,
vcodec TEXT,
acodec TEXT,
duration REAL NOT NULL DEFAULT 0,
optimized INTEGER NOT NULL DEFAULT 0,
meta TEXT NOT NULL DEFAULT '{}',
priority INTEGER NOT NULL DEFAULT 1,
auto INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
error TEXT,
retry_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0,
last_access INTEGER NOT NULL DEFAULT 0,
hits INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_media_lru
ON media_cache (status, last_access);
`);
}
// ---- Media cache -------------------------------------------------------------
const MEDIA_COLS = new Set([
'status', 'gen', 'size', 'height', 'vcodec', 'acodec', 'duration', 'optimized',
'meta', 'priority', 'auto', 'attempts', 'error', 'retry_at', 'created_at',
'updated_at', 'last_access', 'hits',
]);
function rowToObj(res, row) {
const o = {};
res.columns.forEach((c, i) => { o[c] = row[i]; });
return o;
}
export async function getMedia(videoId) {
const r = await db.execute({ sql: 'SELECT * FROM media_cache WHERE video_id = ?', args: [videoId] });
return r.rows[0] ? rowToObj(r, r.rows[0]) : null;
}
// Insert or partially update a media row. Unknown keys are ignored.
export async function upsertMedia(videoId, fields) {
const cols = Object.keys(fields).filter((k) => MEDIA_COLS.has(k));
const all = ['video_id', ...cols];
const set = cols.length ? 'DO UPDATE SET ' + cols.map((c) => `${c} = excluded.${c}`).join(', ') : 'DO NOTHING';
await db.execute({
sql: `INSERT INTO media_cache (${all.join(', ')}) VALUES (${all.map(() => '?').join(', ')})
ON CONFLICT(video_id) ${set}`,
args: [videoId, ...cols.map((c) => fields[c] ?? null)],
});
}
export async function deleteMedia(videoId) {
await db.execute({ sql: 'DELETE FROM media_cache WHERE video_id = ?', args: [videoId] });
}
export async function listMedia() {
const r = await db.execute('SELECT * FROM media_cache');
return r.rows.map((row) => rowToObj(r, row));
}
// Ready rows, least-recently-played first — the eviction order.
export async function listMediaLru() {
const r = await db.execute(
"SELECT video_id, size, last_access FROM media_cache WHERE status = 'ready' ORDER BY last_access ASC",
);
return r.rows.map((row) => rowToObj(r, row));
}
export async function touchMedia(videoId, now) {
await db.execute({
sql: 'UPDATE media_cache SET last_access = ?, hits = hits + 1 WHERE video_id = ?',
args: [now, videoId],
});
}
export async function mediaStats() {
const r = await db.execute(
`SELECT
SUM(CASE WHEN status = 'ready' THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN status = 'ready' THEN size ELSE 0 END) AS bytes,
SUM(CASE WHEN status IN ('queued','downloading','validating') THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN status = 'ready' AND optimized = 1 THEN 1 ELSE 0 END) AS optimized
FROM media_cache`,
);
const o = r.rows[0] ? rowToObj(r, r.rows[0]) : {};
return {
count: o.count || 0, bytes: o.bytes || 0, pending: o.pending || 0,
failed: o.failed || 0, optimized: o.optimized || 0,
};
}
// ---- Profiles (named cross-device sync; the name acts as the passkey) ------
// Insert a new profile. Returns false when the name is already taken.