19 KiB
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/<id>.<gen>.mp4(≤720p H.264 8-bit + AAC, faststart) +<id>.<gen>.m4aaudio sidecar for audio-only mode.MEDIA_DIRdefaults to./data/media(theytplayer-datavolume 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 copydemux 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. genis in the filename and URL (/api/media/<id>?g=<gen>): 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,hvc1tag, anime-style tuning (X265_TUNING),-fpsmax 30, audio copied — kept only if ≥15% smaller, and the copy'svcodecbecomeshevc. 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. BumpingOPT_REVre-queues every cached copy once at boot. - HEVC gating: an HEVC copy is served only to clients that send
?hevc=1on/api/streamsand/api/download(app.jshevcCapable()= canPlayType hvc1, minus a per-deviceytpNoHevcflag 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 requireshvc1(Safari refuseshev1) and 8-bit 4:2:0. - Budget:
MEDIA_CACHE_MAX_BYTES(10 GiB) LRU bylast_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/mediain the container.
Lyrics · chapters · bookmarks (server/notes.js, frontend/lyrics-core.js)
- Shared per video (every user sees the same copy):
video_notesholds the live lyrics / chapters doc per(video_id, kind); every save also lands invideo_note_revsas a full snapshot (history + undo). A daily JSON dump of all live notes goes to<DB dir>/backups/notes-YYYY-MM-DD.json(30 kept). - Who writes: a user linked to an online profile (body
profilemust 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 sendsbaseRev, gets 409 +currentif 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 att + doc.offset + personal offset(positive = later). - Editor text format = LRC superset:
[1:23.45] line,# Section,! band cue,@ Key G, 70 BPMtags, multi-stamp LRC lines and[offset:±ms]accepted. - Captions:
GET /api/notes/:id/captions(preview) and token/admin-onlyPOST /api/notes/:id/lyrics/autopick 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 unlessADMIN_PASSWORDis set (Dokploy Environment tab; compose passes${ADMIN_PASSWORD:-}). Login is rate-limited per IP. - 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()usedwhile (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.
Phone remote (server/remote.js, Remote in app.js)
- Desktop tab playing to a TV = host; phone = remote. Both are browser
tabs of this app, so the server relays over
/ws/remote(Bunserver.upgradein theBun.servefetch wrapper, before Hono). Mockup:docs/mockups/mock-06-remote-control.html(it assumed a LAN host + mDNS; the web app can't do that, so it's a server relay + a 6-digit code instead). - Host secret lives in the desktop's localStorage (
ytpRemoteHost); room id = hash(secret). Pairing (POST /api/remote/pair) is a one-time 6-digit code (10 min) → phone getstoken = HMAC(secret, remoteId)(ytpRemotePair). Nothing about phones is stored server-side: tokens are re-checked against the connected host's secret, so server restarts / desktop reloads keep phones paired, and "Unpair all" (new secret) revokes every phone at once. - The host pushes
state(≤1/s, deduped) andqueue({items, idx}of the livequeue/queueIndex); commands are whitelisted server-side (REMOTE_COMMANDS) and run byrunCommand()on the host with the same functions the UI uses. REMOTE_SAME_NETWORK=1= pairing requires the phone and desktop to share a public IP (firstX-Forwarded-Forhop). Off by default — verify that the VPS→homelab Traefik chain forwards the real client IP before turning it on.- QR:
GET /api/remote/qr/:code(server-side SVG viaqrcode) encodes<origin>/?pair=<code>; the app consumes and strips?pair=at boot.
Local dev
npm run setup # download bin/yt-dlp (once)
cd server && bun install
ln -s ../frontend public # once — the server serves ONLY ./public (Docker copies frontend/ there)
bun --hot server.js # http://localhost:3000
node --test frontend/ # unit tests (run from repo root)
npx playwright test # e2e (see Testing below)
Local DB file: server/data/ytplayer.db (gitignored). BUILD_TAG is computed from ./public contents.
Production deployment (web PWA)
- URL: https://worship.hesed.sbs (Traefik label in
docker-compose.yml) - Runs on the homelab Dokploy remote node; control plane is Dokploy on the VPS
(
193.160.119.172, API key in~/development/.secrets/dokploy-api.env). - Compose ID:
wprYCM8T51f7JtSHb983p(projectytplayer, envproduction). - Pushing to git does NOT deploy. Trigger explicitly (build ≈ 5–6 min):
ssh -i ~/.ssh/tmp_vps/dokploy_session root@193.160.119.172 \ "curl -s -X POST -H 'x-api-key: $KEY' -H 'Content-Type: application/json' \ -d '{\"composeId\":\"wprYCM8T51f7JtSHb983p\"}' http://localhost:3000/api/compose.deploy" # poll composeStatus via /api/compose.one?composeId=... until done|error - Confirm the deploy landed:
curl https://worship.hesed.sbs/api/version— thebuildTag(content hash of every file under./public) must change. - Homelab node is NOT always reachable on LAN; SSH via the VPS hop:
ssh root@193.160.119.172→ssh root@10.8.0.2(WireGuard). Container name:ytplayer-main-1dihzn-ytplayer-1. DB: libsql file/app/data/ytplayer.db(query withdocker exec <c> bun -e '...'using@libsql/client).
Update-flow architecture (fragile — read before touching)
GET /sw.jsis served by the server with the realBUILD_TAGinjected by regex over the fallback expression infrontend/sw.js. Never switch back to an exact-string replace: when the fallback literal was bumped (v1.0.3→v1.0.4) the exact match silently failed, the SW version froze, and no client ever received another update while/api/versionkept announcing one — the "Update available keeps showing" bug.BUILD_TAGhashes every file under./publicrecursively. Don't reduce it to a file subset; a change to an unlisted shell file would stop busting caches.- The banner has ONE rule (
maybeShowUpdateBannerin app.js): show it only when the build this page runs differs from/api/version. The server stamps the running build into index.html (<meta name="ytp-build">, at request time like sw.js's BUILD_TAG); the SW caches that index.html with the rest of the shell, so the meta always describes the code in the tab. The poll, a waiting worker and the SW_UPDATE_AVAILABLE broadcast are only prompts to re-check. This loop was "fixed" three times by chasing individual triggers — don't add a trigger that callsshowUpdateBanner()directly. - Refresh UI (
frontend/sw-update.js):refreshShellInPlace()re-downloads every file the shell caches hold — cache-busted (?__ytpfresh=) so even an OLD worker's cache-first handler can't answer from its cache, 3 tries per file, all-or-nothing — and writes them into every versioned shell cache; then it activates a waiting worker (SKIP_WAITING) if any and reloads once. A failed download shows an error toast and re-offers later; it never reloads into the old shell.checkUpdateOutcome()verifies after the reload (sessionStorage, max 3 attempts) instead of looping. - Why it kept looping on prod: the VPS→homelab link is slow and drops
requests. The SW install (
cache.addAll, all-or-nothing) failed, Refresh UI reloaded into the old cached shell, and the partialytplayer-<tag>cache left by the failed install later made activate broadcast "update available" to pages that were already current. sw.js now precaches withcache: 'reload', retries each file, and deletes its partial cache on failure. - Reproduce with the throttling/stalling proxy approach: WebKit (Playwright on the Windows side) or Chrome over CDP against a scratch copy of the server, stalling every Nth shell request after "deploying" v2. A fast local link never shows the bug.
Cache layout & offline thumbnails
- Three cache families, and the split matters on activate: the versioned shell
cache
ytplayer-<BUILD_TAG>is evicted on every deploy, while the utility cachesytplayer-thumbsandytplayer-fontsare listed inUTILITY_CACHESand deliberately survive it. Adding a new utility cache means adding it there too, or it gets wiped on the next deploy. - Thumbnails are cache-first, not stale-while-revalidate — a given thumbnail URL is immutable, so revalidating just burns a round trip per image per launch.
- The opaque-response trap (this silently emptied the thumb cache for months).
An
<img>to another origin is a no-cors request, sofetch(request)in the SW resolves to an opaque response withstatus === 0— not 200. The old guard wasif (r.status === 200) cache.put(...), which rejected every single thumbnail, soytplayer-thumbswas permanently empty and offline showed broken images (measured on prod: 0 entries after browsing pages full of visible thumbs). Fix inthumbnail(): re-issue the request incorsmode — ytimg/ggpht all sendAccess-Control-Allow-Origin: *— and cache that readable response; an opaque one is accepted only as a last resort. Never reintroduce a barestatus === 200check on a cross-origin subresource. - Thumbnail hosts live in
THUMB_HOSTS. An unlisted host doesn't error — it just bypasses the cache and breaks offline, so add mirrors/avatar hosts there. - The thumb cache is capped at
THUMB_CACHE_MAX(800, oldest-first via the insertion-orderedcache.keys()). Keep a cap: CacheStorage and the OPFS offline audio share one origin quota, and opaque entries are padded to ~7 MB each for quota accounting, so an unbounded thumb cache can evict saved audio. - App side (
app.js):warmThumb()pulls artwork through the SW when a video is saved offline, andwarmOfflineThumbs()runs a bounded launch backfill (THUMB_WARM_MAX= 400, 4 at a time) over cached ids + playlist videos so libraries saved before this fix repair themselves. Thumbnails only cache when something requests them — a device needs one online launch to get offline art. staleWhileRevalidate()(fonts) must notreturn cached || networkFetchbare: the fetch resolves tonulloffline andrespondWith(null)throws. It ends with|| Response.error().
Data model quirks
- Client state persists in localStorage key
_ytpdataand syncs (debounced 400 ms) toPOST /api/user/sync, keyed by a browser fingerprint. - A-B loop markers are per-song-per-playlist: stored on the playlist's own
copy of the video (
entry.ab = {a, b}) when playback source is that playlist;data.abMarkers[videoId]is only the fallback for non-playlist playback. - Online profiles (
profilestable,/api/profile/*): named cross-device sync where the lowercase profile NAME is the only credential (passkey-style, by design). Client storesdata.profile = {name, syncedAt}; sync is last-write-wins — push debounced on every persist(), pull on app launch when the server'supdated_atis newer than the localsyncedAt. - Profile share links:
?profile=<name>is consumed byadoptProfileFromUrl()at boot, before any rendering. It strips the param viareplaceState(so a reload can't re-fire it) and confirms first when the device already has playlists/history or another profile — adopting replaces the synced slice. The name comes off the URL untrusted, henceescapeHtml()on it. - Empty-home playlist grid:
renderHomePlaylists()swaps the branding hero in#playerPlaceholderfor the user's playlists (hero is the no-playlists fallback). It is driven from the tail ofrenderSidebar()— not fromrender()— so every playlist mutation refreshes both in one place.
Testing
- Unit:
node --test frontend/*.test.js(sw, sw-update, async-guard, video-edit). Server:cd server && bun run test— runs each file in its own process (db.js is a singleton, so two test files in onebun testrun share one temp DB; barebun testalso drags in the frontend node tests via thepublicsymlink). media-cache needs a 60 s hook timeout for its ffmpeg fixtures. Local e2e needs a CURRENT yt-dlp — a 2-month-old one 403s on every download. The directory formnode --test frontend/fails on Node 22 withCannot find module .../frontend— it resolves the dir as a module, not a test glob. That's the harness, not the tests. - E2E:
npx playwright test— WebKit iPhone-12 profile against a static serve offrontend/(needsnpx playwright install webkit). A spurious update banner will make the settings-panel specs fail with#modal intercepts pointer events— that failure mode is a real app bug, not test flake. On the WSL laptop the WebKit install fails host validation (missinglibgtk-4,libgstreamer*, …, needssudo npx playwright install-deps); chromium is already downloaded, so ad-hoc rendering/offline checks can drive it directly instead. - Service-worker behaviour (thumb caching, offline) is only provable in a real
browser: serve
frontend/statically, let the SW take control (needs a second reload), thencontext.setOffline(true)and assertimg.naturalWidth > 0plus theytplayer-thumbsentry count. Asserting against the browser's own HTTP cache proves nothing — check the Cache API entry count. - Test records on prod use
Probe */Recon *names; clean via the container DB, children (video_history) first.
Harness
Skills live in .agents/skills/ (symlinked into .claude/skills/):
- deploy-prod — the Dokploy deploy + buildTag verification flow (manual-only; use for any "deploy"/"release" request).
- mobile-app-ui-design — UI/UX design work on the PWA screens.
Commit rules
One changeset = one commit, single-line imperative message, no AI attribution
of any kind (global rule). git push origin main pushes to both remotes.