# 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). ## Server media cache (`server/media-cache.js`) - Every played (`/api/streams`, LOW priority, ≤ `MEDIA_AUTO_MAX_SECONDS`) 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 `