diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f94423a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e00ee2a --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/fingerprint.js b/frontend/fingerprint.js new file mode 100644 index 0000000..5a8ff6c --- /dev/null +++ b/frontend/fingerprint.js @@ -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; + } + }; +}()); diff --git a/frontend/icons/icon-192.png b/frontend/icons/icon-192.png new file mode 100644 index 0000000..87286ae Binary files /dev/null and b/frontend/icons/icon-192.png differ diff --git a/frontend/icons/icon-512.png b/frontend/icons/icon-512.png new file mode 100644 index 0000000..31316a8 Binary files /dev/null and b/frontend/icons/icon-512.png differ diff --git a/frontend/manifest.webmanifest b/frontend/manifest.webmanifest new file mode 100644 index 0000000..fe6f6ae --- /dev/null +++ b/frontend/manifest.webmanifest @@ -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" + } + ] +} diff --git a/frontend/opfs.js b/frontend/opfs.js new file mode 100644 index 0000000..08f6845 --- /dev/null +++ b/frontend/opfs.js @@ -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/. + * + * Object URLs created by getFileUrl() are tracked so they can be revoked + * when no longer needed — call OPFS.revokeUrl(url) after the