715 lines
28 KiB
JavaScript
715 lines
28 KiB
JavaScript
/* ============================================================================
|
|
* 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 { spawn } from 'node:child_process';
|
|
import { createServer } from 'node:http';
|
|
import { readFileSync, readdirSync, statSync, openSync, unlinkSync, createReadStream } from 'node:fs';
|
|
import { Readable } from 'node:stream';
|
|
import { tmpdir } from 'node:os';
|
|
import { createHash } from 'node:crypto';
|
|
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile } 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 FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// BUILD_TAG — must be DETERMINISTIC across restarts of identical code.
|
|
//
|
|
// Previously this was `Date.now().toString(36)`, which changes every time the
|
|
// process starts even if nothing was deployed (crash-loop, healthcheck
|
|
// restart, container reschedule). The frontend's checkBuildTag() polls
|
|
// /api/version and re-shows the "Update available" modal the instant the tag
|
|
// drifts — so a restarting-but-unchanged server kept re-announcing an update
|
|
// that never actually happened, and clicking "Refresh UI" (which itself
|
|
// reloads the page and re-polls) never made the prompt go away for good.
|
|
//
|
|
// Fix: hash the actual served frontend files. Identical code → identical
|
|
// hash → identical tag, no matter how many times the process restarts. A
|
|
// real deploy (changed files) still produces a new tag as intended.
|
|
// process.env.BUILD_TAG still wins if a CI pipeline already injects a git
|
|
// SHA — that's an even better source of truth than a content hash.
|
|
// ----------------------------------------------------------------------------
|
|
function computeBuildTag() {
|
|
try {
|
|
// Hash EVERY served frontend file (recursively, in sorted order), not a
|
|
// hand-picked subset — a change to any shell file (e.g. sw-update.js or
|
|
// opfs.js) must produce a new tag, or clients keep their old SW cache
|
|
// and never receive the change.
|
|
const hash = createHash('sha256');
|
|
const walk = (dir) => {
|
|
for (const name of readdirSync(dir).sort()) {
|
|
const path = `${dir}/${name}`;
|
|
if (statSync(path).isDirectory()) walk(path);
|
|
else { hash.update(path); hash.update(readFileSync(path)); }
|
|
}
|
|
};
|
|
walk('./public');
|
|
return hash.digest('hex').slice(0, 12);
|
|
} catch {
|
|
// Frontend files not readable (e.g. unit tests run outside ./public) —
|
|
// fall back to a fixed tag rather than Date.now(), so it still never
|
|
// drifts spuriously between restarts.
|
|
return 'dev-build';
|
|
}
|
|
}
|
|
|
|
const BUILD_TAG = process.env.BUILD_TAG || computeBuildTag();
|
|
|
|
// BUILD_TIME — human-readable "when was this image built". Written by the
|
|
// Dockerfile at image build time (never at container start, so restarts
|
|
// don't drift it). Kept OUTSIDE ./public so it can't perturb BUILD_TAG.
|
|
const BUILD_TIME = process.env.BUILD_TIME || (() => {
|
|
try { return readFileSync('./build-time.txt', 'utf8').trim(); }
|
|
catch { return null; }
|
|
})();
|
|
|
|
const SEARCH_LIMIT = 25;
|
|
const CHANNEL_LIMIT = 60;
|
|
|
|
// ============================================================================
|
|
// yt-dlp helpers
|
|
// ============================================================================
|
|
|
|
// Run yt-dlp asynchronously and resolve stdout as a string.
|
|
// MUST stay async (spawn, not spawnSync): a sync child process blocks Bun's
|
|
// event loop for the full yt-dlp runtime (~2-3s per call), which stalls every
|
|
// concurrent request — including in-flight /api/download proxy streams, which
|
|
// Bun then kills at its idle timeout ("fetch failed" mid-download on clients).
|
|
// Rejects on non-zero exit.
|
|
function runYtdlp(args) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let out = '';
|
|
let err = '';
|
|
child.stdout.setEncoding('utf8');
|
|
child.stderr.setEncoding('utf8');
|
|
child.stdout.on('data', (d) => { out += d; });
|
|
child.stderr.on('data', (d) => { err += d; });
|
|
child.on('error', (e) => reject(new Error('yt-dlp not found: ' + e.message)));
|
|
child.on('close', (code) => {
|
|
if (code !== 0) reject(new Error(err.trim() || 'yt-dlp exited with code ' + code));
|
|
else resolve(out);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Run ffmpeg the same way — async spawn so a multi-minute trim/concat never
|
|
// blocks Bun's event loop. Rejects on non-zero exit with ffmpeg's stderr tail.
|
|
function runFfmpeg(args) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(FFMPEG, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
let err = '';
|
|
child.stderr.setEncoding('utf8');
|
|
// ffmpeg is extremely chatty on stderr; keep only the tail so an error
|
|
// message stays useful without buffering the whole progress log.
|
|
child.stderr.on('data', (d) => { err = (err + d).slice(-4000); });
|
|
child.on('error', (e) => reject(new Error('ffmpeg not found: ' + e.message)));
|
|
child.on('close', (code) => {
|
|
if (code !== 0) reject(new Error(err.trim() || 'ffmpeg exited with code ' + code));
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
// Parse the compact "s-e,s-e" keep-segment string (see frontend/video-edit.js)
|
|
// into an array of {start,end} second ranges. Skips malformed / non-increasing
|
|
// tokens; returns [] on empty or all-garbage input. Kept in lockstep with the
|
|
// frontend parseKeepParam so both ends agree on the wire format.
|
|
function parseKeepParam(str) {
|
|
if (typeof str !== 'string') return [];
|
|
const out = [];
|
|
for (const tok of str.split(',')) {
|
|
const t = tok.trim();
|
|
if (!t) continue;
|
|
const m = t.match(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/);
|
|
if (!m) continue;
|
|
const a = parseFloat(m[1]);
|
|
const b = parseFloat(m[2]);
|
|
if (!isFinite(a) || !isFinite(b) || b <= a) continue;
|
|
out.push({ start: a, end: b });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Build an ffmpeg filter_complex that trims `src` to the keep segments and
|
|
// concatenates them back into a single continuous stream. Re-encodes (the cut
|
|
// points rarely fall on keyframes, so stream-copy would glitch), producing one
|
|
// clean mp4. Returns the ffmpeg argv (input already appended by the caller).
|
|
function buildTrimArgs(keep) {
|
|
const parts = [];
|
|
keep.forEach((k, i) => {
|
|
parts.push(
|
|
`[0:v]trim=start=${k.start}:end=${k.end},setpts=PTS-STARTPTS[v${i}]`,
|
|
`[0:a]atrim=start=${k.start}:end=${k.end},asetpts=PTS-STARTPTS[a${i}]`,
|
|
);
|
|
});
|
|
const concatInputs = keep.map((_, i) => `[v${i}][a${i}]`).join('');
|
|
const filter = parts.join(';') + ';' +
|
|
`${concatInputs}concat=n=${keep.length}:v=1:a=1[outv][outa]`;
|
|
return [
|
|
'-filter_complex', filter,
|
|
'-map', '[outv]', '-map', '[outa]',
|
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
|
|
'-c:a', 'aac', '-b:a', '160k',
|
|
'-movflags', '+faststart',
|
|
];
|
|
}
|
|
|
|
// 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
|
|
// Returns version string + a build tag that changes on every server restart/deploy.
|
|
// Clients poll this to detect when a new build is live and prompt a reload.
|
|
app.get('/api/version', (c) =>
|
|
c.json(
|
|
{ version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME },
|
|
200,
|
|
{ 'Cache-Control': 'no-store, no-cache, must-revalidate' }
|
|
)
|
|
);
|
|
|
|
// 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 = await 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 = await 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 = await 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);
|
|
}
|
|
});
|
|
|
|
// Download via yt-dlp into a self-cleaning temp file, then stream it.
|
|
// yt-dlp MUST perform the HTTP fetch itself: googlevideo stream URLs are
|
|
// bound to the innertube client that extracted them, so resolving the URL
|
|
// with --get-url and re-fetching it server-side with hand-rolled browser
|
|
// headers intermittently got 403s from the YouTube CDN when the User-Agent
|
|
// didn't match the extraction client.
|
|
async function ytdlpDownloadResponse(videoId, fp, formatArgs) {
|
|
const tmpBase = `ytp-dl-${videoId}-${Date.now()}`;
|
|
const tmp = `${tmpdir()}/${tmpBase}.mp4`;
|
|
let size, fd;
|
|
try {
|
|
await runYtdlp([
|
|
`https://www.youtube.com/watch?v=${videoId}`,
|
|
'--no-warnings', '--no-playlist',
|
|
...formatArgs,
|
|
'-N', '4',
|
|
'-o', tmp,
|
|
]);
|
|
size = statSync(tmp).size;
|
|
// Open the fd BEFORE the finally unlinks: on Linux the data stays
|
|
// readable until the fd closes, so the temp file cleans itself up even
|
|
// if the client disconnects mid-transfer.
|
|
fd = openSync(tmp, 'r');
|
|
} finally {
|
|
// Sweep everything yt-dlp may have left under this request's unique
|
|
// prefix: the output itself, .part partials, and .fNNN single-format
|
|
// intermediates (left when ffmpeg is missing — yt-dlp then downloads
|
|
// the streams separately, exits 0 without merging, and statSync above
|
|
// throws on the absent merged file).
|
|
for (const name of readdirSync(tmpdir())) {
|
|
if (name.startsWith(tmpBase)) {
|
|
try { unlinkSync(`${tmpdir()}/${name}`); } catch { /* already gone */ }
|
|
}
|
|
}
|
|
}
|
|
const stream = createReadStream('', { fd });
|
|
|
|
if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
|
|
|
return new Response(Readable.toWeb(stream), {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'video/mp4',
|
|
'Content-Length': String(size),
|
|
'Content-Disposition': `attachment; filename="${videoId}.mp4"`,
|
|
'Cache-Control': 'no-store',
|
|
'Access-Control-Allow-Origin': '*',
|
|
},
|
|
});
|
|
}
|
|
|
|
// "Edit & download": fetch the source with yt-dlp (muxed up to 720p, same as
|
|
// the mux path), then run ffmpeg to KEEP only the requested segments and
|
|
// concatenate them into one continuous mp4 — the user's custom cut. The result
|
|
// is streamed to the browser exactly like a normal save, so OPFS stores it
|
|
// under the caller-chosen custom id. Every temp file is swept afterwards.
|
|
async function ytdlpEditedDownloadResponse(videoId, fp, keep) {
|
|
const tmpBase = `ytp-edit-${videoId}-${Date.now()}`;
|
|
const srcTmp = `${tmpdir()}/${tmpBase}.src.mp4`;
|
|
const outTmp = `${tmpdir()}/${tmpBase}.out.mp4`;
|
|
let size, fd;
|
|
try {
|
|
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams.
|
|
await runYtdlp([
|
|
`https://www.youtube.com/watch?v=${videoId}`,
|
|
'--no-warnings', '--no-playlist',
|
|
'-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b',
|
|
'--merge-output-format', 'mp4',
|
|
'-N', '4',
|
|
'-o', srcTmp,
|
|
]);
|
|
// 2) Trim + concat the keep segments into the final custom video.
|
|
await runFfmpeg([
|
|
'-y', '-hide_banner', '-loglevel', 'error',
|
|
'-i', srcTmp,
|
|
...buildTrimArgs(keep),
|
|
outTmp,
|
|
]);
|
|
size = statSync(outTmp).size;
|
|
fd = openSync(outTmp, 'r');
|
|
} finally {
|
|
for (const name of readdirSync(tmpdir())) {
|
|
if (name.startsWith(tmpBase)) {
|
|
try { unlinkSync(`${tmpdir()}/${name}`); } catch { /* already gone */ }
|
|
}
|
|
}
|
|
}
|
|
const stream = createReadStream('', { fd });
|
|
|
|
if (fp) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
|
|
|
return new Response(Readable.toWeb(stream), {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'video/mp4',
|
|
'Content-Length': String(size),
|
|
'Content-Disposition': `attachment; filename="${videoId}-edited.mp4"`,
|
|
'Cache-Control': 'no-store',
|
|
'Access-Control-Allow-Origin': '*',
|
|
},
|
|
});
|
|
}
|
|
|
|
// GET /api/download/:videoId
|
|
// Downloads the video server-side via yt-dlp and streams the finished file
|
|
// 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);
|
|
const fp = c.req.query('fp');
|
|
|
|
// ?edit=1&keep=s-e,s-e — "Edit & download" path: download the source, then
|
|
// ffmpeg-trim it to the requested keep segments and stream the custom cut.
|
|
// Requires ffmpeg; there is no progressive fallback because the whole point
|
|
// is the server-side edit. Invalid/empty keep params are rejected up front.
|
|
if (c.req.query('edit') === '1') {
|
|
const keep = parseKeepParam(c.req.query('keep') || '');
|
|
if (!keep.length) return c.json({ ok: false, error: 'missing or invalid keep segments' }, 400);
|
|
try {
|
|
return await ytdlpEditedDownloadResponse(videoId, fp, keep);
|
|
} catch (err) {
|
|
return c.json({ ok: false, error: err.message }, 500);
|
|
}
|
|
}
|
|
|
|
// ?mux=1 — "Save before playing" path: bestvideo up to 720p PLUS bestaudio
|
|
// compiled into one mp4 with ffmpeg on the server. Falls back to the
|
|
// progressive single-file save below when ffmpeg is missing or the merge
|
|
// fails.
|
|
if (c.req.query('mux') === '1') {
|
|
try {
|
|
return await ytdlpDownloadResponse(videoId, fp, [
|
|
'-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b',
|
|
'--merge-output-format', 'mp4',
|
|
]);
|
|
} catch (err) {
|
|
console.warn(`[ytplayer] mux download failed for ${videoId}, falling back to progressive:`, err.message);
|
|
}
|
|
}
|
|
|
|
// Default save — best progressive (audio+video single-file) format, so no
|
|
// ffmpeg is required anywhere in the chain.
|
|
try {
|
|
return await ytdlpDownloadResponse(videoId, fp, [
|
|
'-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best',
|
|
]);
|
|
} catch (err) {
|
|
return c.json({ ok: false, error: err.message }, 500);
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// Online profiles — named cross-device sync. The NAME IS THE PASSKEY:
|
|
// anyone who knows it can load and overwrite that profile, so clients are
|
|
// encouraged to use the random generator. No other auth by design.
|
|
// ============================================================================
|
|
const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/;
|
|
const PROFILE_MAX_BYTES = 2_000_000; // full data blob; typical payloads are ~KBs
|
|
|
|
const RAND_ADJ = ['amber', 'brave', 'calm', 'coral', 'crimson', 'dusty', 'gentle', 'golden',
|
|
'hidden', 'ivory', 'jade', 'lunar', 'mellow', 'misty', 'noble', 'quiet',
|
|
'rapid', 'silver', 'solar', 'stormy', 'swift', 'velvet', 'wild', 'zesty'];
|
|
const RAND_NOUN = ['falcon', 'harbor', 'willow', 'ember', 'canyon', 'meadow', 'otter', 'pine',
|
|
'raven', 'reef', 'sparrow', 'summit', 'thicket', 'tundra', 'brook', 'cedar',
|
|
'dune', 'fjord', 'glade', 'heron', 'lagoon', 'maple', 'prairie', 'wren'];
|
|
function randomProfileName() {
|
|
const a = RAND_ADJ[Math.floor(Math.random() * RAND_ADJ.length)];
|
|
const n = RAND_NOUN[Math.floor(Math.random() * RAND_NOUN.length)];
|
|
return `${a}-${n}-${1000 + Math.floor(Math.random() * 9000)}`;
|
|
}
|
|
|
|
// POST /api/profile/create
|
|
// Body: { name?, data? } — empty/absent name asks the server to generate a
|
|
// unique random one. Fails with 409 when the requested name is taken.
|
|
app.post('/api/profile/create', async (c) => {
|
|
let body;
|
|
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
|
|
|
|
let name = (body.name || '').trim().toLowerCase();
|
|
const dataJson = JSON.stringify(body.data && typeof body.data === 'object' ? body.data : {});
|
|
if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413);
|
|
|
|
try {
|
|
if (name) {
|
|
if (!PROFILE_NAME_RE.test(name)) {
|
|
return c.json({ ok: false, error: 'invalid name — 3-40 characters: letters, digits, - or _' }, 400);
|
|
}
|
|
if (!(await createProfile(name, dataJson))) {
|
|
return c.json({ ok: false, error: `“${name}” is already taken — pick another name` }, 409);
|
|
}
|
|
} else {
|
|
let created = false;
|
|
for (let tries = 0; tries < 20 && !created; tries++) {
|
|
name = randomProfileName();
|
|
created = await createProfile(name, dataJson);
|
|
}
|
|
if (!created) return c.json({ ok: false, error: 'could not generate a unique name — try again' }, 500);
|
|
}
|
|
const row = await getProfile(name);
|
|
return c.json({ ok: true, name, updatedAt: row ? row.updatedAt : 0 });
|
|
} catch (err) {
|
|
return c.json({ ok: false, error: err.message }, 500);
|
|
}
|
|
});
|
|
|
|
// GET /api/profile/load?name=<name>
|
|
app.get('/api/profile/load', async (c) => {
|
|
const name = (c.req.query('name') || '').trim().toLowerCase();
|
|
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
|
|
try {
|
|
const row = await getProfile(name);
|
|
if (!row) return c.json({ ok: false, error: 'profile not found' }, 404);
|
|
let data = {};
|
|
try { data = JSON.parse(row.data || '{}'); } catch { /* corrupt blob — hand back empty */ }
|
|
return c.json({ ok: true, name, data, updatedAt: row.updatedAt });
|
|
} catch (err) {
|
|
return c.json({ ok: false, error: err.message }, 500);
|
|
}
|
|
});
|
|
|
|
// POST /api/profile/save
|
|
// Body: { name, data } — updates an EXISTING profile only (404 otherwise).
|
|
app.post('/api/profile/save', async (c) => {
|
|
let body;
|
|
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
|
|
|
|
const name = (body.name || '').trim().toLowerCase();
|
|
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
|
|
const dataJson = JSON.stringify(body.data && typeof body.data === 'object' ? body.data : {});
|
|
if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413);
|
|
|
|
try {
|
|
if (!(await saveProfile(name, dataJson))) {
|
|
return c.json({ ok: false, error: 'profile not found' }, 404);
|
|
}
|
|
const row = await getProfile(name);
|
|
return c.json({ ok: true, updatedAt: row ? row.updatedAt : 0 });
|
|
} 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);
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// GET /sw.js — serve the service worker with BUILD_TAG injected
|
|
//
|
|
// The raw sw.js file contains the placeholder `__BUILD_TAG__` which is
|
|
// replaced here with the actual BUILD_TAG string so the SW's cache name
|
|
// tracks the deployment automatically — no manual version bump needed.
|
|
// Served with no-store cache headers so browsers always re-fetch it and
|
|
// pick up the substituted value rather than a browser-cached stale copy.
|
|
// ============================================================================
|
|
let _swSource = null;
|
|
app.get('/sw.js', (c) => {
|
|
if (!_swSource) {
|
|
try {
|
|
_swSource = readFileSync('./public/sw.js', 'utf8');
|
|
} catch {
|
|
return c.text('Service worker not found', 404);
|
|
}
|
|
}
|
|
// Inject the build tag: replace the whole fallback expression with the
|
|
// real value. Matched by REGEX, not an exact string — an exact match broke
|
|
// the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'),
|
|
// after which the replacement silently did nothing, the SW version froze,
|
|
// and clients never saw another update no matter how many times we deployed.
|
|
const src = _swSource.replace(
|
|
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/,
|
|
JSON.stringify(BUILD_TAG)
|
|
);
|
|
if (src === _swSource) {
|
|
console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
|
}
|
|
return c.text(src, 200, {
|
|
'Content-Type': 'application/javascript; charset=utf-8',
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// 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,
|
|
// Default is 10s, which killed /api/download proxy streams whenever the
|
|
// connection went idle mid-transfer. 240s covers slow saves; Bun caps
|
|
// this field at 255.
|
|
idleTimeout: 240,
|
|
});
|
|
|
|
console.log(`[ytplayer] Listening → http://localhost:${PORT}`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('[ytplayer] fatal:', err);
|
|
process.exit(1);
|
|
});
|