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