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:
141
server/db.js
Normal file
141
server/db.js
Normal 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
15
server/package.json
Normal 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
352
server/server.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user