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:
51
Dockerfile
Normal file
51
Dockerfile
Normal 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
25
docker-compose.yml
Normal 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
81
frontend/fingerprint.js
Normal 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
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
BIN
frontend/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
41
frontend/manifest.webmanifest
Normal file
41
frontend/manifest.webmanifest
Normal 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
194
frontend/opfs.js
Normal 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
136
frontend/sw.js
Normal 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
113
scripts/make-pwa-icons.js
Normal 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
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