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:
102
server/db.js
102
server/db.js
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user