# ytplayer — project facts for agents ## What this is Ad-free YouTube player. Three shells share `frontend/`: **web PWA** (Bun + Hono in `server/`, the production deployment), Tauri (Windows), zero-native (Linux/macOS). The PWA is what runs in production; `legacy/` holds the old native-only docs. ## Repo map | Path | What lives there | |------|------------------| | `frontend/app.js` | The entire UI (~3.6k lines, no framework): player, playlists, search, settings, sync | | `frontend/sw.js` + `frontend/sw-update.js` | Service worker + update flow (FRAGILE — see below) | | `frontend/opfs.js` / `opfs-worker.js` | OPFS offline audio cache | | `frontend/fingerprint.js` | Browser fingerprint used as the sync key | | `frontend/async-guard.js` | Stale-async-response guard (unit-tested) | | `server/server.js` | Bun + Hono backend — endpoint list is in its header comment | | `server/db.js` | libsql schema/queries (`users`, `profiles`, `video_history`, `media_cache`…) | | `server/media-cache.js` | Server-side video cache: background fetch jobs, validation gate, x264 lane, LRU | | `bin/yt-dlp` | Downloaded by `npm run setup`, gitignored | | `scripts/` | icon generation, yt-dlp setup, push helper | | `tests/` | Playwright e2e specs · unit tests live next to sources in `frontend/*.test.js` | | `legacy/` | Old native-shell docs — do not treat as current | API endpoints (notes/admin routes are listed in the header of `server/notes.js`): `GET /api/search|channel|streams|download/:id|version|user/data|profile/load`, `GET /api/media/:id|media/:id/status|media/stats`, `POST /api/media/:id/redownload`, `POST /api/user/sync|profile/create|profile/save`, `GET /sw.js` (BUILD_TAG-injected), `GET /*` static. JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone (`/api/streams`' `data.serverCached` is additive and web-only). ## Long videos (1 h+) - Bun's default request-body cap is 128 MB — `Bun.serve` sets `maxRequestBodySize` to 5 GiB; routes enforce their own limits (uploads 4 GB, intake `P2P_INTAKE_MAX_BYTES` 3 GiB). Without it every large upload/intake died with 413. - Admin uploads use `PUT /api/admin/uploads/stream?name=…` (raw body → disk in chunks, browser sends the `File` itself); cover images go to `PUT /api/admin/uploads/:id/art`. The multipart `POST /api/admin/uploads` parses the whole body in memory — scripts with small files only. - Videos over `transcode.maxSeconds` are never re-encoded (lane would be tied up for hours). - Deferred ideas (Cloudflare Worker fetch, Android device download, ffmpeg.wasm client editing): `docs/deferred-ideas.md`. ## Save to device + "On other devices" - `exportToDevice(video)` (app.js, helpers in `frontend/export.js`): puts the file in Photos / Files / Downloads. Source order: copy saved in OPFS → server copy (`GET /api/export/:id?name=`, attachment + Range; direct download on desktop, via OPFS + share sheet on phones) → online P2P holder → normal save via server. Buttons appear only once the file is ready: `navigator.share` needs a fresh tap. - `GET /api/p2p/available` + view `{type:'p2p'}` = the "📡 On other devices" list (sidebar, web only): verified files with ≥1 sharing holder, online first, stale flagged. Rows play, export, or go to a playlist. ## Layout themes + Glass Stage - Settings → Layout theme sets `html[data-layout]`; rules live in `styles.css` (older ones) and `frontend/theme-glass.css` (**glass-stage**: frosted cards, coral glow). Glass Stage also changes layout: overlay on the video (quality chip, Video / Audio-only, −10 / play / +10, shown when paused or after a tap), deck row prev · −10 · play · +10 · next, volume pod + labelled mode pill, quick-setting chips (`#deckChips`, refreshed every 1.5 s by the `glassStage` IIFE), four stacked icon tiles and secondary actions as a pill row, floating mini player. Elements that only this theme shows (`.stg-overlay`, `.skip10`, `.deck-chips`, `.vol-pod` extras, `.mode-name`) are in the shared markup and hidden by default. - Now-playing tiles are `[svg.ti][span.tl]`; change state text with `setTile(btn, label)`, never `textContent`. ## Saved searches + caching notes - `SearchLibrary` (app.js) keeps every search: metadata index in localStorage `ytpSearchIndex` (sync, ~250 KB), result lists in IndexedDB `ytpSearchLibrary` (≤1000 queries × 100 results incl. thumbnail URLs; `get()` is async). A saved copy is trusted 15 days (`FRESH_MS`): a fresh one is shown with NO network call, an older one is shown then refreshed. The "Update search" button (`#searchMeta`) calls `API.search(q,{refresh:true})` → `/api/search?refresh=1` which skips every server cache. The search box's recent list and its "Clear this list" only touch `ytpRecentSearches`; results are deleted ONLY in Settings → Saved searches. Thumbnails are warmed into the SW `ytplayer-thumbs` cache (`THUMB_CACHE_MAX` 25000, trimmed every 100th put — `cache.keys()` is costly at that size). Browse view: `{type:'savedSearches'}`. Web Workers were NOT added: IndexedDB/fetch are already async off-thread I/O and postMessage would clone the same data; DOM rendering can't move to a worker. - Server `/api/search` (`server/search-cache.js`, tables `search_cache` + `video_meta`): memory (200, 10 min fresh) → libsql (up to 200 YouTube cards per query via `innertube.searchDeep` continuation paging; ≤250,000 queries AND `SEARCH_CACHE_MAX_BYTES` 2 GiB, LRU) → YouTube. Stale copies are served for 15 days while refreshing in the background. Every card seen is also stored in `video_meta` (≤500k); `GET /api/search/local?q=` matches known videos by title/channel (all words) + uploads in ms, and the app paints them while the real search loads. yt-dlp fallback still returns 25. - Static files: index.html is stamped with `?v=` on local css/js (`indexHtml()`); a request whose `v` equals the running build is `immutable, max-age=1y`, anything else `no-cache`. The SW `cacheFirst` matches `?v=` URLs with `ignoreSearch` against the plain-URL precache. A new build = new URLs, so old ones simply get evicted. `/fonts` and `/icons` are cached 30 days. ## SHA-256 in WebAssembly (`frontend/sha256.js`) - `Sha256.create()` runs its block function as WebAssembly (`frontend/wasm/sha256.c` → `scripts/build-sha256-wasm.sh` → generated `frontend/sha256-wasm.js`, base64, ~2 KB) and falls back to pure JS if WebAssembly is missing, the CSP refuses it, or the load-time self-test ("abc") fails. ~150 MB/s vs ~65 MB/s in JS (Node/Chromium; phones are slower but the ratio holds). It is synchronous on purpose (callers aren't async) — fine because the module is under 4 KB. - Needs `'wasm-unsafe-eval'` in index.html's CSP `script-src`. Workers pull the blob in with `importScripts('/sha256-wasm.js')` from inside sha256.js; the file is in the SW `SHELL` list. After editing the C file, re-run the build script and commit both. `Sha256.engine()` says which one is active; `create({js:true})` forces JS (tests). ## Server media cache (`server/media-cache.js`) - Every played (`/api/streams`, LOW priority, ≤ `MEDIA_AUTO_MAX_SECONDS`, default 3 h) or saved (`/api/download`, HIGH, ≤ 3 h) video gets ONE copy: `$MEDIA_DIR/..mp4` (≤720p H.264 8-bit + AAC, faststart) + `..m4a` audio sidecar for audio-only mode. `MEDIA_DIR` defaults to `./data/media` (the `ytplayer-data` volume in prod). - Jobs are server-owned — **never pass a request AbortSignal into them**; a client closing its tab must not kill a fetch. Downloads still run inside `withSaveSlot`. - **Validation gate** (`validateMedia`): h264 yuv420p + AAC, container/stream durations cover the source, a full `-c copy` demux pass with zero stderr, head + tail decode. The demux pass is what catches a faststart file whose index is intact but whose data is cut. Nothing reaches the cache dir without passing it; failures back off (15 min · 2^n, max 24 h) and saves fall back to the legacy per-request path. - `gen` is in the filename and URL (`/api/media/?g=`): a URL never serves different bytes. A replaced gen is kept 30 min so in-flight Range playback survives. - **Compression lane** (rev `OPT_REV` = 2): niced x265 8-bit `-preset medium -crf 28`, `hvc1` tag, anime-style tuning (`X265_TUNING`), `-fpsmax 30`, audio copied — kept only if ≥15% smaller, and the copy's `vcodec` becomes `hevc`. **Sample-first**: videos ≥60 s get two 10-s windows encoded first and compared with the source's own packet bytes over the same timestamps; the full encode only runs if the prediction clears the 15% bar (a skip costs ~20 s of video; a full encode ~2× realtime on the homelab). **Why:** re-encoding YouTube's already-compressed H.264 usually needs MORE bits for LOWER quality (generation loss) — on prod 0 of 5 videos shrank at CRF 28, one grew 25% (VMAF 93.6 at 126% of source; capping to 60–70% drops VMAF to ~84–87). Only videos where YouTube over-spent bits win (a live worship video: −34%). AnimeOut-style sizes come from pristine Blu-ray sources, which YouTube never gives us. Bumping `OPT_REV` re-queues every cached copy once at boot. - **HEVC gating**: an HEVC copy is served only to clients that send `?hevc=1` on `/api/streams` and `/api/download` (app.js `hevcCapable()` = canPlayType hvc1, minus a per-device `ytpNoHevc` flag set when an HEVC copy fails to play). Everyone else gets the pre-cache behaviour (YouTube proxy / legacy H.264 save). Verified: iPhone XR, Galaxy S10, Windows Chrome play HEVC; AV1 fails on the XR (no decoder), so it isn't used. The validation gate requires `hvc1` (Safari refuses `hev1`) and 8-bit 4:2:0. - **Budget**: `MEDIA_CACHE_MAX_BYTES` (10 GiB) LRU by `last_access` (anything played in the last 10 min is protected) + `MEDIA_MIN_FREE_BYTES` (5 GiB) disk guard → skip, stream. - **Broken**: `POST /api/media/:id/redownload` (now-playing ⚠ Broken button). When a client falls back from a cached copy (`/api/streams?nocache=1`) the server also re-validates its copy and refetches it if it fails — never trusts one device's error. - Ops: `curl https://worship.hesed.sbs/api/media/stats`; files under `/app/data/media` in the container. ## Lyrics · chapters · bookmarks (`server/notes.js`, `frontend/lyrics-core.js`) - **Shared per video** (every user sees the same copy): `video_notes` holds the live lyrics / chapters doc per `(video_id, kind)`; **every save** also lands in `video_note_revs` as a full snapshot (history + undo). A daily JSON dump of all live notes goes to `/backups/notes-YYYY-MM-DD.json` (30 kept). - **Who writes**: a user linked to an online profile (body `profile` must exist — same trust level as the profile API), a script with an API token (`Authorization: Bearer ytp_…`, only a SHA-256 is stored), or the admin (signed httpOnly cookie). Saves are optimistic: client sends `baseRev`, gets 409 + `current` if someone saved in between (UI asks "Keep mine / Load theirs"). - **Personal, not shared**: bookmarks (`data.bookmarks[videoId]`) and the per-song sync offset (`data.lyricOffsets[videoId]`) — both ride the profile sync. Line shows at `t + doc.offset + personal offset` (positive = later). - Editor text format = LRC superset: `[1:23.45] line`, `# Section`, `! band cue`, `@ Key G, 70 BPM` tags, multi-stamp LRC lines and `[offset:±ms]` accepted. - Captions: `GET /api/notes/:id/captions` (preview) and token/admin-only `POST /api/notes/:id/lyrics/auto` pick human subs first, else the auto track in the spoken language (`*-orig`), never a machine translation. Auto captions of **music** are mostly junk ("oh", "n to") — expect to hand-fix or paste lyrics. - **Admin**: `/admin` (frontend/admin.html, never cached by the SW) — tokens, edit feed with view/restore, caption injection, JSON export, API examples. Disabled unless `ADMIN_PASSWORD` is set (Dokploy Environment tab; compose passes `${ADMIN_PASSWORD:-}`). Login is rate-limited per IP. - **Service mode → 🎵 Lyrics** (`data.settings.serviceLyrics`, `serviceLyricsAutoscroll`): lyrics-only view; the sung line is centred, highlighted and sized to the largest font that fits ONE row. Sizing is measured with a hidden real `.sl-line` in TWO passes — Bricolage Grotesque has an optical-size axis, so glyphs get relatively wider at small sizes and a single 100px measurement over-fits on phones. Re-fit (in place, never rebuilding rows — that would move the scroll position) on resize, `document.fonts` load, and +900 ms. Lyrics-only and the 📺 video panel are mutually exclusive. - **Free batch transcription**: `scripts/lyrics/auto_lyrics.py` (faster-whisper `large-v3-turbo` int8 on CPU, no API key/credits; venv at `~/.local/share/lyrics-asr/.venv`). `--missing` uses `GET /api/admin/media` (admin or token). Measured on the 16-core laptop: ~0.3–0.65× real time; openai-whisper medium was ~2.5×. **`vad_filter` must stay False** — VAD calls sung music non-speech and returns an empty transcript. Karaoke/minus-one tracks have no vocals (reported "instrumental?"); their lyrics were read from the on-screen text (frame sampling + OCR), not audio. ElevenLabs Scribe also works but spends the TTS credit balance (free tier = 10k/month). - Offline: the last-seen notes per video are cached in localStorage (`ytpNotesCache`, 200 videos), so saved songs keep their lyrics offline. - **Toast cap bug (fixed with this feature):** `toast()` used `while (children.length > 3)` with a deferred remove — the 4th toast spun forever and Chrome killed the tab ("Target crashed" in Playwright, no JS error). Never cap with a loop whose exit depends on a deferred removal. ## Playlist transitions + waveform (`Transition` / `Wave` in app.js) - `data.settings.transition` = `off` | `gapless` | `crossfade` (`crossfadeSec`, default 6); the ↦/⇥/⤨ button cycles it with a 6-s toast. Applies only to playlist/queue playback with a next song, not with loop-one or an A-B loop. - A hidden **bridge `