# 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`…) | | `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: `GET /api/search|channel|streams|download/:id|version|user/data|profile/load`, `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. ## Local dev ```bash 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`** (project `ytplayer`, env `production`). - **Pushing to git does NOT deploy.** Trigger explicitly (build ≈ 5–6 min): ```bash 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` — the `buildTag` (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 with `docker exec bun -e '...' `using `@libsql/client`). ## Update-flow architecture (fragile — read before touching) - `GET /sw.js` is served by the server with the real `BUILD_TAG` **injected by regex** over the fallback expression in `frontend/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/version` kept announcing one — the "Update available keeps showing" bug. - `BUILD_TAG` hashes **every** file under `./public` recursively. Don't reduce it to a file subset; a change to an unlisted shell file would stop busting caches. - The update banner only shows when a waiting SW exists **and the page already has a controller** — a first install (fresh visit, or after Settings → Force refresh unregisters) passes through `waiting` transiently and must not banner. - "Refresh UI" (`frontend/sw-update.js`): if no worker is waiting yet (banner came from the `/api/version` poll), it calls `reg.update()`, waits for `installed`, posts SKIP_WAITING, waits for `controllerchange`, then reloads once. ## Data model quirks - Client state persists in localStorage key **`_ytpdata`** and syncs (debounced 400 ms) to `POST /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** (`profiles` table, `/api/profile/*`): named cross-device sync where the lowercase profile NAME is the only credential (passkey-style, by design). Client stores `data.profile = {name, syncedAt}`; sync is last-write-wins — push debounced on every persist(), pull on app launch when the server's `updated_at` is newer than the local `syncedAt`. ## Testing - Unit: `node --test frontend/` (sw, sw-update, async-guard). - E2E: `npx playwright test` — WebKit iPhone-12 profile against a static serve of `frontend/` (needs `npx 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. - 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.