- Move native shells (Zig/src, Tauri/src-tauri, app.zon, releases) to legacy/
- Add Bun + Hono server with yt-dlp proxy endpoints (search, channel, streams,
download), libsql (concurrent SQLite fork) for fingerprint-keyed playlist/
history sync, and static file serving for the frontend
- Add Dockerfile + docker-compose.yml (single container, volume-mounted DB)
- Add frontend/sw.js: app-shell cache-first, /api/* network-only,
thumbnails stale-while-revalidate, SW_UPDATE_AVAILABLE broadcast,
SKIP_WAITING message handler for seamless auto-update
- Add frontend/manifest.webmanifest: standalone PWA, vermilion theme,
search/history shortcuts
- Add frontend/icons/icon-{192,512}.png: generated PWA icons
- Add frontend/fingerprint.js: canvas+UA djb2 fingerprint, localStorage-cached,
exposes window.getFingerprint() for server-side playlist keying
- Add frontend/opfs.js: full OPFS video store (writeFromResponse streams
directly without full-file buffering), exposes window.OPFS
- Add scripts/make-pwa-icons.js: regenerate icons without external deps
- Patch frontend/app.js: WEB mode detection, webFetch + opfs* bridge wrappers,
API object routes to WEB helpers when no native bridge present,
Player.loadVideo handles OPFS blob URLs + revokes them on next load,
SW registration + update banner in boot()
- Patch frontend/index.html: manifest link, theme-color, Apple PWA meta,
CSP blob:/worker-src, fingerprint.js + opfs.js script tags
- Patch frontend/styles.css: .toast-update + .toast-reload-btn for update banner
- Native Tauri/Zig builds unchanged — all new code is additive via WEB flag
142 lines
4.9 KiB
JavaScript
142 lines
4.9 KiB
JavaScript
/* ============================================================================
|
|
* db.js — libsql (embedded SQLite) database setup and query helpers
|
|
*
|
|
* The DB file lives at $DB_PATH (mounted volume in Docker so data persists
|
|
* across container rebuilds). libsql is an open-source SQLite fork by Turso
|
|
* with WAL-by-default for concurrent reads.
|
|
*
|
|
* Schema (three small tables):
|
|
* 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
|
|
* ========================================================================== */
|
|
|
|
import { createClient } from '@libsql/client';
|
|
import { join } from 'node:path';
|
|
|
|
const DB_PATH = process.env.DB_PATH || join(process.cwd(), 'data', 'ytplayer.db');
|
|
|
|
export const db = createClient({
|
|
url: 'file:' + DB_PATH,
|
|
});
|
|
|
|
// ---- Schema ----------------------------------------------------------------
|
|
|
|
export async function initDb() {
|
|
await db.executeMultiple(`
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA synchronous = NORMAL;
|
|
PRAGMA foreign_keys = ON;
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
fingerprint TEXT PRIMARY KEY,
|
|
last_version TEXT,
|
|
last_seen INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS playlists (
|
|
fingerprint TEXT PRIMARY KEY,
|
|
data TEXT NOT NULL DEFAULT '[]',
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS video_history (
|
|
fingerprint TEXT NOT NULL,
|
|
video_id TEXT NOT NULL,
|
|
title TEXT,
|
|
channel TEXT,
|
|
thumbnail TEXT,
|
|
duration REAL DEFAULT 0,
|
|
accessed_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
PRIMARY KEY (fingerprint, video_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_vh_fp_time
|
|
ON video_history (fingerprint, accessed_at DESC);
|
|
`);
|
|
}
|
|
|
|
// ---- Helpers ---------------------------------------------------------------
|
|
|
|
// Upsert the users row and optionally update playlists.
|
|
export async function upsertUser({ fingerprint, appVersion, playlists }) {
|
|
await db.execute({
|
|
sql: `INSERT INTO users (fingerprint, last_version, last_seen)
|
|
VALUES (?, ?, unixepoch())
|
|
ON CONFLICT(fingerprint) DO UPDATE SET
|
|
last_version = excluded.last_version,
|
|
last_seen = excluded.last_seen`,
|
|
args: [fingerprint, appVersion || null],
|
|
});
|
|
|
|
if (Array.isArray(playlists)) {
|
|
await db.execute({
|
|
sql: `INSERT INTO playlists (fingerprint, data, updated_at)
|
|
VALUES (?, ?, unixepoch())
|
|
ON CONFLICT(fingerprint) DO UPDATE SET
|
|
data = excluded.data,
|
|
updated_at = excluded.updated_at`,
|
|
args: [fingerprint, JSON.stringify(playlists)],
|
|
});
|
|
}
|
|
}
|
|
|
|
// Record a video access (insert or bump accessed_at).
|
|
export async function recordVideoAccess(fingerprint, video) {
|
|
await db.execute({
|
|
sql: `INSERT INTO video_history
|
|
(fingerprint, video_id, title, channel, thumbnail, duration, accessed_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, unixepoch())
|
|
ON CONFLICT(fingerprint, video_id) DO UPDATE SET
|
|
title = excluded.title,
|
|
channel = excluded.channel,
|
|
thumbnail = excluded.thumbnail,
|
|
duration = excluded.duration,
|
|
accessed_at = excluded.accessed_at`,
|
|
args: [
|
|
fingerprint,
|
|
video.id,
|
|
video.title || null,
|
|
video.channel || null,
|
|
video.thumbnail || null,
|
|
video.duration || 0,
|
|
],
|
|
});
|
|
|
|
// Keep only the 200 most recent entries per user to avoid unbounded growth
|
|
await db.execute({
|
|
sql: `DELETE FROM video_history
|
|
WHERE fingerprint = ?
|
|
AND video_id NOT IN (
|
|
SELECT video_id FROM video_history
|
|
WHERE fingerprint = ?
|
|
ORDER BY accessed_at DESC
|
|
LIMIT 200
|
|
)`,
|
|
args: [fingerprint, fingerprint],
|
|
});
|
|
}
|
|
|
|
// Fetch stored data for a fingerprint.
|
|
export async function getUserData(fingerprint) {
|
|
const [userRow, plRow, histRows] = await Promise.all([
|
|
db.execute({ sql: 'SELECT last_version FROM users WHERE fingerprint = ?', args: [fingerprint] }),
|
|
db.execute({ sql: 'SELECT data FROM playlists WHERE fingerprint = ?', args: [fingerprint] }),
|
|
db.execute({
|
|
sql: `SELECT video_id AS id, title, channel, thumbnail, duration, accessed_at
|
|
FROM video_history WHERE fingerprint = ?
|
|
ORDER BY accessed_at DESC LIMIT 50`,
|
|
args: [fingerprint],
|
|
}),
|
|
]);
|
|
|
|
const lastVersion = userRow.rows[0]?.last_version || null;
|
|
const playlists = plRow.rows[0]?.data ? JSON.parse(plRow.rows[0].data) : [];
|
|
const history = histRows.rows.map((r) => ({
|
|
id: r.id, title: r.title, channel: r.channel,
|
|
thumbnail: r.thumbnail, duration: r.duration,
|
|
}));
|
|
|
|
return { lastVersion, playlists, history };
|
|
}
|