159 lines
9.8 KiB
Markdown
159 lines
9.8 KiB
Markdown
# 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 <c> 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.
|
||
|
||
## 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
|
||
caches** `ytplayer-thumbs` and `ytplayer-fonts` are listed in `UTILITY_CACHES`
|
||
and 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, so `fetch(request)` in the
|
||
SW resolves to an **opaque** response with `status === 0` — not 200. The old
|
||
guard was `if (r.status === 200) cache.put(...)`, which rejected every single
|
||
thumbnail, so `ytplayer-thumbs` was permanently empty and offline showed broken
|
||
images (measured on prod: 0 entries after browsing pages full of visible thumbs).
|
||
Fix in `thumbnail()`: re-issue the request in `cors` mode — ytimg/ggpht all send
|
||
`Access-Control-Allow-Origin: *` — and cache that readable response; an opaque
|
||
one is accepted only as a last resort. **Never reintroduce a bare `status === 200`
|
||
check 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-ordered `cache.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, and `warmOfflineThumbs()` 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 not `return cached || networkFetch` bare:
|
||
the fetch resolves to `null` offline and `respondWith(null)` throws. It ends
|
||
with `|| Response.error()`.
|
||
|
||
## 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`.
|
||
- **Profile share links**: `?profile=<name>` is consumed by `adoptProfileFromUrl()`
|
||
at boot, before any rendering. It strips the param via `replaceState` (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, hence `escapeHtml()` on it.
|
||
- **Empty-home playlist grid**: `renderHomePlaylists()` swaps the branding hero in
|
||
`#playerPlaceholder` for the user's playlists (hero is the no-playlists
|
||
fallback). It is driven from the tail of `renderSidebar()` — not from
|
||
`render()` — so every playlist mutation refreshes both in one place.
|
||
|
||
## Testing
|
||
- Unit: **`node --test frontend/*.test.js`** (sw, sw-update, async-guard, video-edit).
|
||
The directory form `node --test frontend/` fails on Node 22 with
|
||
`Cannot 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 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. On the WSL laptop the
|
||
WebKit install fails host validation (missing `libgtk-4`, `libgstreamer*`, …,
|
||
needs `sudo 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), then `context.setOffline(true)` and assert `img.naturalWidth > 0` plus
|
||
the `ytplayer-thumbs` entry 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.
|