feat: convert to PWA — OPFS storage, service worker, Bun/Hono server

- 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
This commit is contained in:
Jonathan Sykes
2026-06-30 06:43:23 +08:00
parent 8a30fcfc4f
commit 4efa1d1182
26 changed files with 1149 additions and 0 deletions

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# ============================================================================
# YT Player PWA — Docker image
#
# Base: oven/bun:1-debian (Bun runtime on Debian slim)
# yt-dlp: downloaded from GitHub releases at build time (stays current)
# libsql: embedded via @libsql/client (no separate DB container needed)
#
# Build: docker compose build
# Run: docker compose up
# ============================================================================
FROM oven/bun:1-debian
# ---- System dependencies ----
# python3 is required by yt-dlp for some extraction paths
# ca-certificates for HTTPS fetches from yt-dlp
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends \
curl \
python3 \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
# ---- Install yt-dlp ----
RUN curl -fsSL \
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \
-o /usr/local/bin/yt-dlp && \
chmod +x /usr/local/bin/yt-dlp
WORKDIR /app
# ---- Install Node/Bun deps ----
COPY server/package.json ./
RUN bun install --production
# ---- Copy server source ----
COPY server/ ./
# ---- Copy built frontend (served as static files from ./public) ----
COPY frontend/ ./public/
# ---- Persistent data directory (volume-mounted) ----
RUN mkdir -p /app/data
EXPOSE 3000
# Healthcheck — ping the version endpoint
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -sf http://localhost:3000/api/version || exit 1
CMD ["bun", "server.js"]

25
docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
ytplayer:
build: .
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
volumes:
# libsql DB file persists across container rebuilds
- ytplayer-data:/app/data
environment:
PORT: "3000"
DB_PATH: "/app/data/ytplayer.db"
APP_VERSION: "1.0.0"
# Optional: override yt-dlp binary path if you mount a custom one
# YTDLP_PATH: "/usr/local/bin/yt-dlp"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
volumes:
ytplayer-data:
driver: local

81
frontend/fingerprint.js Normal file
View File

@@ -0,0 +1,81 @@
/* ============================================================================
* fingerprint.js — stable browser identifier for server-side playlist sync
*
* Combines canvas rendering, UA, screen geometry, and hardware hints into a
* short hex string. The result is stored in localStorage so the same ID
* survives page reloads, and only regenerated when localStorage is cleared.
*
* Not for tracking — used exclusively so the server can associate playlist
* and history rows with this browser without requiring a login.
* ========================================================================== */
(function () {
'use strict';
// djb2 hash over a string → 32-bit unsigned int
function djb2(str) {
let h = 5381;
for (let i = 0; i < str.length; i++) {
h = (((h << 5) + h) + str.charCodeAt(i)) >>> 0;
}
return h;
}
// Render a small canvas to capture GPU/font rasterisation differences,
// then hash the pixel data. Falls back to empty string if canvas is blocked.
function canvasHash() {
try {
const c = document.createElement('canvas');
c.width = 200; c.height = 40;
const ctx = c.getContext('2d');
if (!ctx) return '';
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.fillText('YTPlayer🎵', 2, 15);
ctx.fillStyle = 'rgba(102,204,0,0.7)';
ctx.fillText('YTPlayer🎵', 4, 17);
return djb2(c.toDataURL()).toString(16);
} catch {
return '';
}
}
function generateFingerprint() {
const parts = [
navigator.userAgent || '',
String(screen.width) + 'x' + String(screen.height),
String(screen.colorDepth),
Intl.DateTimeFormat().resolvedOptions().timeZone || '',
navigator.language || '',
String(navigator.hardwareConcurrency || 0),
String(navigator.deviceMemory || 0),
canvasHash(),
];
// Combine all component hashes into one 16-char hex fingerprint
const combined = parts.reduce((acc, p) => acc + '|' + p, '');
const h1 = djb2(combined);
const h2 = djb2(combined.split('').reverse().join(''));
return h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0');
}
window.getFingerprint = function getFingerprint() {
try {
let fp = localStorage.getItem('_ytpfp');
if (!fp || fp.length < 8) {
fp = generateFingerprint();
localStorage.setItem('_ytpfp', fp);
}
return fp;
} catch {
// localStorage blocked (e.g. private mode on some browsers) — generate
// ephemeral fingerprint that survives the page session via a closure.
if (!window._ytpfpEphemeral) {
window._ytpfpEphemeral = generateFingerprint();
}
return window._ytpfpEphemeral;
}
};
}());

BIN
frontend/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
frontend/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,41 @@
{
"name": "YT Player",
"short_name": "YTPlayer",
"description": "Ad-free YouTube player — search, play, and keep playlists offline. No login, no tracking.",
"display": "standalone",
"orientation": "any",
"start_url": "/",
"scope": "/",
"theme_color": "#1a1311",
"background_color": "#1a1311",
"categories": ["music", "entertainment"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [],
"shortcuts": [
{
"name": "Search",
"short_name": "Search",
"url": "/?view=search",
"description": "Search YouTube"
},
{
"name": "History",
"short_name": "History",
"url": "/?view=history",
"description": "Recently watched videos"
}
]
}

194
frontend/opfs.js Normal file
View File

@@ -0,0 +1,194 @@
/* ============================================================================
* opfs.js — Origin Private File System video storage for PWA mode
*
* Exposes window.OPFS with methods that mirror the Tauri cache_* commands
* so app.js can call them transparently in WEB mode.
*
* Videos are stored under the OPFS root at:
* videos/<videoId>.<ext>
*
* Object URLs created by getFileUrl() are tracked so they can be revoked
* when no longer needed — call OPFS.revokeUrl(url) after the <video> unloads.
*
* OPFS is available in all modern browsers (Chrome 86+, Firefox 111+,
* Safari 15.2+). Calls degrade gracefully if the API is absent.
* ========================================================================== */
(function () {
'use strict';
// Root directory handle, lazily initialised
let _rootPromise = null;
async function getRoot() {
if (!_rootPromise) {
_rootPromise = (async () => {
const root = await navigator.storage.getDirectory();
return root.getDirectoryHandle('videos', { create: true });
})();
}
return _rootPromise;
}
// Iterate the videos/ directory and find a file whose stem matches videoId
// (ignoring the extension). Returns [FileSystemFileHandle, filename] or null.
async function findHandle(videoId) {
const dir = await getRoot();
for await (const [name, handle] of dir.entries()) {
if (handle.kind !== 'file') continue;
const dot = name.lastIndexOf('.');
const stem = dot > -1 ? name.slice(0, dot) : name;
if (stem === videoId) return [handle, name];
}
return null;
}
// Extension → MIME type for Content-Type headers on playback
function extToMime(ext) {
const map = { mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
m4a: 'audio/mp4', ogg: 'audio/ogg', opus: 'audio/ogg' };
return map[ext] || 'application/octet-stream';
}
const _blobUrls = new Set();
window.OPFS = {
// Does a file exist for this videoId?
async hasVideo(videoId) {
try {
return (await findHandle(videoId)) !== null;
} catch {
return false;
}
},
// Return a blob: URL usable as <video src>. Returns null if not cached.
// The caller is responsible for calling OPFS.revokeUrl(url) when done.
async getFileUrl(videoId) {
try {
const found = await findHandle(videoId);
if (!found) return null;
const [handle, name] = found;
const file = await handle.getFile();
const ext = name.slice(name.lastIndexOf('.') + 1);
const blob = new Blob([await file.arrayBuffer()], { type: extToMime(ext) });
const url = URL.createObjectURL(blob);
_blobUrls.add(url);
return url;
} catch {
return null;
}
},
// Revoke an object URL previously returned by getFileUrl().
revokeUrl(url) {
if (url && _blobUrls.has(url)) {
URL.revokeObjectURL(url);
_blobUrls.delete(url);
}
},
// Stream a fetch Response body into OPFS. Uses a writable stream so only
// a small chunk lives in memory at a time (no full-file buffering).
// Falls back to ArrayBuffer if WritableStream is unavailable.
async writeFromResponse(videoId, ext, response) {
const dir = await getRoot();
const filename = videoId + '.' + (ext || 'mp4');
// Write to a temporary file first so a partial download doesn't leave
// a corrupt permanent entry.
const tmpName = filename + '.part';
const tmpHandle = await dir.getFileHandle(tmpName, { create: true });
try {
if ('createWritable' in tmpHandle) {
const writable = await tmpHandle.createWritable();
try {
if (response.body && typeof response.body.pipeTo === 'function') {
await response.body.pipeTo(writable);
} else {
// Safari < 16.4 doesn't support pipeTo — buffer the whole response
const buf = await response.arrayBuffer();
await writable.write(buf);
await writable.close();
}
} catch (err) {
await writable.abort();
throw err;
}
} else {
// Fallback: buffer entirely (older browsers)
const buf = await response.arrayBuffer();
const writable = await tmpHandle.createWritable();
await writable.write(buf);
await writable.close();
}
// Rename tmp → final. OPFS doesn't have rename, so: read + write + delete.
const finalHandle = await dir.getFileHandle(filename, { create: true });
const finalWritable = await finalHandle.createWritable();
const tmpFile = await tmpHandle.getFile();
await finalWritable.write(await tmpFile.arrayBuffer());
await finalWritable.close();
} finally {
// Remove .part file regardless
try { await dir.removeEntry(tmpName); } catch { /* already gone */ }
}
},
// List all cached videos — returns [{ id, size, name }]
async listVideos() {
const dir = await getRoot();
const items = [];
try {
for await (const [name, handle] of dir.entries()) {
if (handle.kind !== 'file') continue;
// Skip .part temporary files
if (name.endsWith('.part')) continue;
const dot = name.lastIndexOf('.');
const id = dot > -1 ? name.slice(0, dot) : name;
const file = await handle.getFile();
items.push({ id, name, size: file.size });
}
} catch { /* OPFS not available */ }
return items;
},
// Total bytes stored
async totalSize() {
const items = await this.listVideos();
return items.reduce((s, i) => s + i.size, 0);
},
// Delete one video
async deleteVideo(videoId) {
try {
const found = await findHandle(videoId);
if (!found) return;
const [, name] = found;
const dir = await getRoot();
await dir.removeEntry(name);
} catch { /* already gone */ }
},
// Delete all cached videos
async clearAll() {
try {
const dir = await getRoot();
const names = [];
for await (const [name] of dir.entries()) names.push(name);
await Promise.all(names.map((n) => dir.removeEntry(n).catch(() => {})));
// Revoke any outstanding blob URLs
for (const url of _blobUrls) URL.revokeObjectURL(url);
_blobUrls.clear();
} catch { /* OPFS not available */ }
},
// Is the OPFS API supported in this browser?
isSupported() {
return typeof navigator !== 'undefined' &&
typeof navigator.storage !== 'undefined' &&
typeof navigator.storage.getDirectory === 'function';
},
};
}());

136
frontend/sw.js Normal file
View File

@@ -0,0 +1,136 @@
/* ============================================================================
* sw.js — YT Player Service Worker
*
* Strategy:
* App shell (HTML/CSS/JS) → cache-first, versioned cache
* /api/* requests → network-only (never cache yt-dlp results)
* /api/download/* → network-only (streamed binary, never cache)
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
* Everything else → network, fallback to cache
*
* Auto-update flow:
* 1. New SW installs alongside the old one.
* 2. activate: broadcast SW_UPDATE_AVAILABLE to all clients.
* 3. Client shows "Update ready" banner.
* 4. User clicks → client sends { type: 'SKIP_WAITING' }.
* 5. SW calls skipWaiting() → takes over → client reloads.
* ========================================================================== */
const VERSION = 'v1.0.0'; // ← bump this on every deploy to bust the cache
const CACHE = 'ytplayer-' + VERSION;
// Files that form the installable app shell.
const SHELL = [
'/',
'/index.html',
'/styles.css',
'/async-guard.js',
'/fingerprint.js',
'/opfs.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',
'/icons/icon-512.png',
];
// ---- Install: pre-cache the app shell ----
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(SHELL))
// skipWaiting() is NOT called here — we wait for the client to confirm
// before activating, so the update banner can appear first.
);
});
// ---- Activate: evict old caches, claim clients, notify about update ----
self.addEventListener('activate', (e) => {
e.waitUntil((async () => {
// Delete every cache that isn't the current version
const keys = await caches.keys();
await Promise.all(
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
);
// Claim all open clients immediately (new installs)
await self.clients.claim();
// Broadcast to every open window so the app can show an update banner
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
all.forEach((c) => c.postMessage({ type: 'SW_UPDATE_AVAILABLE', version: VERSION }));
})());
});
// ---- Fetch: routing logic ----
self.addEventListener('fetch', (e) => {
const { request } = e;
const url = new URL(request.url);
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
if (request.method !== 'GET' && request.method !== 'HEAD') return;
// API calls and binary downloads → network only, no caching
if (url.pathname.startsWith('/api/')) {
e.respondWith(fetch(request));
return;
}
// YouTube thumbnails → stale-while-revalidate (fast load, fresh in background)
if (url.hostname === 'i.ytimg.com') {
e.respondWith(staleWhileRevalidate(request, 'ytplayer-thumbs'));
return;
}
// Google Fonts CSS — stale-while-revalidate so offline doesn't break type
if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com') {
e.respondWith(staleWhileRevalidate(request, 'ytplayer-fonts'));
return;
}
// App shell → cache-first, then network, then generic offline fallback
e.respondWith(cacheFirst(request));
});
// ---- Message: handle SKIP_WAITING from the client ----
self.addEventListener('message', (e) => {
if (e.data && e.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// ============================================================================
// Fetch helpers
// ============================================================================
// Cache-first: serve from cache; if missing, fetch, cache, return.
async function cacheFirst(request) {
const cache = await caches.open(CACHE);
const cached = await cache.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
// Only cache successful, non-opaque responses
if (response && response.status === 200 && response.type !== 'opaque') {
cache.put(request, response.clone());
}
return response;
} catch {
// Network failed and nothing in cache — return a minimal offline page
// for navigation requests; let sub-resources fail naturally.
if (request.mode === 'navigate') {
const nav = await cache.match('/index.html');
if (nav) return nav;
}
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
// Stale-while-revalidate: return cached immediately, update in background.
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Start a background revalidation — don't await it before responding
const networkFetch = fetch(request).then((r) => {
if (r && r.status === 200) cache.put(request, r.clone());
return r;
}).catch(() => null);
return cached || networkFetch;
}

113
scripts/make-pwa-icons.js Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env node
/**
* Generates frontend/icons/icon-192.png and icon-512.png for the PWA manifest.
* Same vermilion play-button design as appicon.png — no image libraries needed.
*
* Usage: node scripts/make-pwa-icons.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
// ---- PNG encoder (same approach as make-icon.js) ----
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
const tb = Buffer.from(type, 'ascii');
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([tb, data])), 0);
return Buffer.concat([len, tb, data, crc]);
}
function makePng(SIZE) {
const px = Buffer.alloc(SIZE * SIZE * 4);
function set(x, y, r, g, b, a = 255) {
const i = (y * SIZE + x) * 4;
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a;
}
// Rounded-rectangle background (vermilion gradient)
const radius = Math.round(SIZE * 0.22);
for (let y = 0; y < SIZE; y++) {
const t = y / SIZE;
const r = Math.round(0xff + (0xd4 - 0xff) * t);
const g = Math.round(0x6a + (0x32 - 0x6a) * t);
const b = Math.round(0x52 + (0x1d - 0x52) * t);
for (let x = 0; x < SIZE; x++) {
// Simple corner rounding via distance check
const dx = Math.max(0, Math.abs(x - SIZE / 2) - (SIZE / 2 - radius));
const dy = Math.max(0, Math.abs(y - SIZE / 2) - (SIZE / 2 - radius));
if (dx * dx + dy * dy > radius * radius) {
set(x, y, 0, 0, 0, 0); // transparent outside rounded rect
} else {
set(x, y, r, g, b);
}
}
}
// White play triangle, centered
const scale = SIZE / 512;
const ax = Math.round(196 * scale), ay = Math.round(150 * scale);
const bx = Math.round(196 * scale), by = Math.round(362 * scale);
const cx = Math.round(384 * scale), cy = Math.round(256 * scale);
function edge(px1, py1, px2, py2, x, y) {
return (x - px1) * (py2 - py1) - (y - py1) * (px2 - px1);
}
const yMin = Math.round(120 * scale), yMax = Math.round(392 * scale);
const xMin = Math.round(170 * scale), xMax = Math.round(400 * scale);
for (let y = yMin; y < yMax; y++) {
for (let x = xMin; x < xMax; x++) {
const w0 = edge(bx, by, cx, cy, x, y);
const w1 = edge(cx, cy, ax, ay, x, y);
const w2 = edge(ax, ay, bx, by, x, y);
if ((w0 <= 0 && w1 <= 0 && w2 <= 0) || (w0 >= 0 && w1 >= 0 && w2 >= 0)) {
// Only paint if inside the rounded-rect background
const cur = (y * SIZE + x) * 4;
if (px[cur + 3] > 0) {
px[cur] = 255; px[cur + 1] = 255; px[cur + 2] = 255; px[cur + 3] = 255;
}
}
}
}
// Encode to PNG
const raw = Buffer.alloc((SIZE * 4 + 1) * SIZE);
for (let y = 0; y < SIZE; y++) {
raw[y * (SIZE * 4 + 1)] = 0;
px.copy(raw, y * (SIZE * 4 + 1) + 1, y * SIZE * 4, (y + 1) * SIZE * 4);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(SIZE, 0);
ihdr.writeUInt32BE(SIZE, 4);
ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
const outDir = path.join(__dirname, '..', 'frontend', 'icons');
fs.mkdirSync(outDir, { recursive: true });
for (const size of [192, 512]) {
const out = path.join(outDir, `icon-${size}.png`);
const data = makePng(size);
fs.writeFileSync(out, data);
console.log(`Wrote ${out} (${size}×${size}, ${data.length} bytes)`);
}
console.log('Done — PWA icons ready in frontend/icons/');

141
server/db.js Normal file
View File

@@ -0,0 +1,141 @@
/* ============================================================================
* 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 };
}

15
server/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "ytplayer-server",
"version": "1.0.0",
"description": "YT Player PWA server — Bun + Hono + libsql",
"type": "module",
"scripts": {
"start": "bun server.js",
"dev": "bun --hot server.js"
},
"dependencies": {
"hono": "^4.7.10",
"@hono/node-server": "^1.14.0",
"@libsql/client": "^0.14.0"
}
}

352
server/server.js Normal file
View File

@@ -0,0 +1,352 @@
/* ============================================================================
* server.js — YT Player PWA backend
*
* Runtime: Bun (https://bun.sh)
* Framework: Hono v4
* DB: libsql (concurrent SQLite fork, embedded file mode)
*
* Endpoints:
* GET /api/search?q=<query> yt-dlp search → slim card array
* GET /api/channel?c=<channel> yt-dlp channel uploads → slim card array
* GET /api/streams?v=<videoId> yt-dlp stream info → {meta, audioUrl, qualities}
* GET /api/download/:videoId proxy best progressive stream → binary
* GET /api/version { version }
* POST /api/user/sync upsert user playlists + last-seen version
* GET /api/user/data?fp=<fp> retrieve stored playlists + history
* GET /* serve frontend/public static files
*
* JSON shapes mirror the Tauri (Rust) bridge exactly so the existing app.js
* UI code works without modification in WEB mode.
* ========================================================================== */
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { logger } from 'hono/logger';
import { spawnSync } from 'node:child_process';
import { createServer } from 'node:http';
import { initDb, upsertUser, recordVideoAccess, getUserData } from './db.js';
const PORT = parseInt(process.env.PORT || '3000', 10);
const APP_VERSION = process.env.APP_VERSION || '1.0.0';
const YTDLP = process.env.YTDLP_PATH || 'yt-dlp';
const SEARCH_LIMIT = 25;
const CHANNEL_LIMIT = 60;
// ============================================================================
// yt-dlp helpers
// ============================================================================
// Run yt-dlp synchronously and return stdout as a string.
// Throws on non-zero exit.
function runYtdlp(args) {
const result = spawnSync(YTDLP, args, {
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024, // 32 MB — large channel dumps can be big
});
if (result.error) throw new Error('yt-dlp not found: ' + result.error.message);
if (result.status !== 0) {
const err = (result.stderr || '').trim();
throw new Error(err || 'yt-dlp exited with code ' + result.status);
}
return result.stdout || '';
}
// Helpers to pick the right field from a yt-dlp JSON record
function pick(obj, ...keys) {
for (const k of keys) {
const v = obj[k];
if (v && typeof v === 'string' && v.trim()) return v.trim();
}
return '';
}
function pickChannel(obj) { return pick(obj, 'channel', 'uploader'); }
function pickChannelUrl(obj) { return pick(obj, 'channel_url', 'uploader_url'); }
function pickChannelId(obj) { return pick(obj, 'channel_id', 'uploader_id'); }
// Normalise a flat-playlist yt-dlp record into the slim UI card shape
function slimEntry(j) {
const id = pick(j, 'id');
if (!id) return null;
return {
id,
title: pick(j, 'title') || '(untitled)',
channel: pickChannel(j),
channelId: pickChannelId(j),
channelUrl: pickChannelUrl(j),
duration: typeof j.duration === 'number' ? j.duration : 0,
thumbnail: `https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
};
}
// Parse multi-line JSON output from yt-dlp --dump-json --flat-playlist
function parseCards(output) {
const results = [];
for (const line of output.split('\n')) {
const t = line.trim();
if (!t) continue;
try {
const j = JSON.parse(t);
const card = slimEntry(j);
if (card) results.push(card);
} catch { /* skip malformed lines */ }
}
return results;
}
// Resolve a channel identifier to a /videos URL yt-dlp can fetch
function channelToUrl(c) {
let base = c.trim();
if (!base.startsWith('http')) {
base = base.startsWith('@') ? `https://www.youtube.com/${base}`
: base.startsWith('UC') ? `https://www.youtube.com/channel/${base}`
: `https://www.youtube.com/@${base}`;
}
base = base.replace(/\/$/, '');
return base.endsWith('/videos') ? base : base + '/videos';
}
// ============================================================================
// App setup
// ============================================================================
const app = new Hono();
app.use('*', logger());
// ============================================================================
// API routes
// ============================================================================
// GET /api/version
app.get('/api/version', (c) => c.json({ version: APP_VERSION }));
// GET /api/search?q=<query>
app.get('/api/search', async (c) => {
const q = (c.req.query('q') || '').trim();
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
try {
const out = runYtdlp([
`ytsearch${SEARCH_LIMIT}:${q}`,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
]);
return c.json({ ok: true, results: parseCards(out) });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// GET /api/channel?c=<channel>
app.get('/api/channel', async (c) => {
const chan = (c.req.query('c') || '').trim();
if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400);
try {
const url = channelToUrl(chan);
const out = runYtdlp([
url,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
'--playlist-end', String(CHANNEL_LIMIT),
]);
const results = parseCards(out);
// Extract channel name + URL from the first record
const first = results[0];
let channelName = '', channelUrl = '';
for (const line of out.split('\n')) {
const t = line.trim();
if (!t) continue;
try {
const j = JSON.parse(t);
channelName = channelName || pickChannel(j);
channelUrl = channelUrl || pickChannelUrl(j);
if (channelName && channelUrl) break;
} catch { /* skip */ }
}
return c.json({ ok: true, channel: channelName || (first?.channel || ''), channelUrl, results });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// GET /api/streams?v=<videoId>
app.get('/api/streams', async (c) => {
const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
try {
const url = `https://www.youtube.com/watch?v=${videoId}`;
const out = runYtdlp(['-J', '--no-warnings', url]);
const info = JSON.parse(out);
const formats = Array.isArray(info.formats) ? info.formats : [];
// Best audio-only stream (prefer mp4a/m4a by bitrate)
let bestAudioUrl = null;
let bestAudioScore = -1;
for (const f of formats) {
if (!f.url) continue;
const hasVideo = f.vcodec && f.vcodec !== 'none';
const hasAudio = f.acodec && f.acodec !== 'none';
if (hasVideo || !hasAudio) continue;
let score = f.abr || 0;
if (f.acodec && f.acodec.includes('mp4a')) score += 1000;
if (score > bestAudioScore) { bestAudioScore = score; bestAudioUrl = f.url; }
}
// Quality list — progressive (single-file, hasAudio) first, then adaptive
const qualities = [];
const seen = new Set();
for (const wantProg of [true, false]) {
for (const f of formats) {
if (!f.url) continue;
const hasVideo = f.vcodec && f.vcodec !== 'none';
const hasAudio = f.acodec && f.acodec !== 'none';
if (!hasVideo) continue;
if ((hasAudio) !== wantProg) continue;
const h = f.height || 0;
if (h <= 0 || seen.has(h)) continue;
seen.add(h);
qualities.push({ label: h + 'p', height: h, hasAudio: !!hasAudio, url: f.url, ext: f.ext || '' });
}
}
qualities.sort((a, b) => b.height - a.height);
return c.json({
ok: true,
data: {
meta: {
id: videoId,
title: pick(info, 'title') || '(untitled)',
channel: pickChannel(info),
channelId: pickChannelId(info),
channelUrl: pickChannelUrl(info),
duration: typeof info.duration === 'number' ? info.duration : 0,
thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
},
audioUrl: bestAudioUrl,
qualities,
},
});
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// GET /api/download/:videoId
// Resolves the best progressive (audio+video single-file) stream URL via
// yt-dlp and proxies the binary to the browser so OPFS can store it.
// The browser never contacts YouTube CDN directly (CORS would block it).
app.get('/api/download/:videoId', async (c) => {
const videoId = (c.req.param('videoId') || '').replace(/[/\\:?<>|*"]/g, '').trim();
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
try {
const url = `https://www.youtube.com/watch?v=${videoId}`;
// --get-url with bestvideo+bestaudio/best format is not what we want
// here — we need a SINGLE FILE so no ffmpeg muxing is required in the
// browser. Use -f "bestvideo[ext=mp4][acodec!=none]/best[ext=mp4]/best"
const out = runYtdlp([
url,
'--no-warnings',
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best',
'--get-url',
]);
const streamUrl = out.trim();
if (!streamUrl) throw new Error('No stream URL returned');
// Fetch from YouTube and pipe to the client
const upstream = await fetch(streamUrl, {
headers: {
// Mimic a browser to avoid 403s from YouTube CDN
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Referer': 'https://www.youtube.com/',
},
});
if (!upstream.ok) throw new Error(`Upstream error ${upstream.status}`);
const contentType = upstream.headers.get('content-type') || 'video/mp4';
const contentLength = upstream.headers.get('content-length');
const headers = new Headers({
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${videoId}.mp4"`,
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
});
if (contentLength) headers.set('Content-Length', contentLength);
// Log the access if a fingerprint was supplied (fire-and-forget)
const fp = c.req.query('fp');
if (fp) {
recordVideoAccess(fp, { id: videoId }).catch(() => {});
}
return new Response(upstream.body, { status: 200, headers });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// POST /api/user/sync
// Body: { fingerprint, playlists?, recentVideo?, appVersion? }
app.post('/api/user/sync', async (c) => {
let body;
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
const fp = (body.fingerprint || '').trim();
if (!fp) return c.json({ ok: false, error: 'missing fingerprint' }, 400);
try {
await upsertUser({ fingerprint: fp, appVersion: body.appVersion, playlists: body.playlists });
if (body.recentVideo && body.recentVideo.id) {
await recordVideoAccess(fp, body.recentVideo);
}
return c.json({ ok: true });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// GET /api/user/data?fp=<fingerprint>
app.get('/api/user/data', async (c) => {
const fp = (c.req.query('fp') || '').trim();
if (!fp) return c.json({ ok: false, error: 'missing fingerprint' }, 400);
try {
const userData = await getUserData(fp);
return c.json({ ok: true, ...userData, version: APP_VERSION });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// ============================================================================
// Static files — serve the frontend/public directory
// Must come AFTER all /api routes so API takes priority
// ============================================================================
app.use('/*', serveStatic({ root: './public' }));
// SPA fallback — return index.html for any unmatched path
app.get('/*', serveStatic({ path: './public/index.html' }));
// ============================================================================
// Boot
// ============================================================================
async function main() {
await initDb();
console.log(`[ytplayer] DB ready`);
console.log(`[ytplayer] Starting on port ${PORT}`);
// Bun.serve is the native Bun HTTP server
Bun.serve({
port: PORT,
fetch: app.fetch,
});
console.log(`[ytplayer] Listening → http://localhost:${PORT}`);
}
main().catch((err) => {
console.error('[ytplayer] fatal:', err);
process.exit(1);
});