diff --git a/.claude/skills/ersatztv/SKILL.md b/.claude/skills/ersatztv/SKILL.md index 14b47eb1c..218e98b58 100644 --- a/.claude/skills/ersatztv/SKILL.md +++ b/.claude/skills/ersatztv/SKILL.md @@ -5,19 +5,55 @@ description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Je # ErsatzTV Channel Management -Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart) -Web UI: internal only (`http://localhost:8409` via SSH) -SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`) -Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026) +Host: **jazz (192.168.1.29)**. Prod container `ersatztv` port **8409**; test `ersatztv-test` port +**8410** (tracks `:latest` via Komodo auto-update, daily 03:00 — a same-day validation needs the +manual pull below). +SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` (owned by root — use `sudo sqlite3`) +Image: **our fork**, `192.168.1.95:3000/timothy/ersatztv` (`:prod` / `:latest`). Upstream +`ghcr.io/ersatztv/ersatztv` was archived at v26.3.0 and is NOT what runs here. ## Architecture -ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited: -- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks -- **POST endpoints**: library scan, playout reset, show scan -- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly +**This section described upstream v26.3.0 and was wrong for the fork — corrected 2026-07-21.** -## REST API +- The **Blazor UI is gone** (#91 phase b). The only UI is the ChicoryTV React SPA at `/app`; legacy + routes 302 there. +- There **is** a full versioned REST API under **`/api/v1`**, write paths included — channels, + collections, schedules, playouts and media sources have CRUD. **Do not hand-edit SQLite for + something the API can do.** The DB-scripting recipes below survive only for gaps with no endpoint. +- Controllers stay thin and delegate to MediatR handlers; the SPA talks to `/api/v1` only. +- Authoritative endpoint list: `docs/endpoint-index.md` (generated) + `docs/api-conventions.md`. + Prefer those over any list in this file — a hand-maintained copy drifts. + +## REST API access (auth-gated — read before curling) + +Calls need **`X-Api-Key`** (machine clients) or a browser session. An unauthenticated call returns a +401 JSON body that is easy to mistake for real data — see the silent-401 trap in Gotchas. + +The key file is **root-owned `0600`**, so `cat` as `timothy` fails *silently* and yields an empty +header. Read it with `sudo`, inline, so the value is never printed: + +```bash +# prod (8409); test is identical with .../ersatztv-test/api.key and port 8410 +ssh timothy@192.168.1.29 'K=$(sudo -n cat /home/timothy/downloadswarm/ersatztv/api.key); \ + curl -s -H "X-Api-Key: $K" http://localhost:8409/api/v1/channels' +``` + +Settings live under `/api/v1/settings/*` — `settings/ffmpeg` (`workAheadSegmenterLimit`, +`qsvExtraHardwareFrames`) and `settings/logging` (`streamingMinimumLogLevel`). Note the order: it is +`settings/ffmpeg`, **not** `ffmpeg/settings`. + +Refresh test to the newest `:latest` without waiting for 03:00 — scope it to the service, since a +bare `up -d` would recreate everything else in the compose project: + +```bash +D=/etc/komodo/stacks/ersatztv/docker/jazz/stacks/ersatztv +docker compose -f $D/compose.yaml pull ersatztv-test +docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test +``` + +The unversioned `/api/*` endpoints below predate the `/api/v1` surface — verify one against +`docs/endpoint-index.md` before relying on it. ```bash # Via docker exec @@ -148,13 +184,25 @@ After creating: `POST /api/channels/{number}/playout/reset` ## Gotchas - DB owned by root — always use `sudo sqlite3` +- **The api.key file is root-owned too, and an unsudo'd read fails SILENTLY.** `cat` returns nothing, + the header goes out empty, and the 401 body parses as a dict — so a naive script reports "0 + channels" rather than an auth error. If a query returns a suspiciously empty result, check auth + before believing it. (Cost a wrong reading on 2026-07-21.) - WAL mode: reads OK while running, stop container for writes -- No REST API for channel/collection/schedule CRUD — DB scripting only +- ~~No REST API for channel/collection/schedule CRUD~~ — **false since the fork's `/api/v1`**; use the + API, not DB scripting, wherever an endpoint exists +- **A container's OCI labels lie about what is running** — they are inherited from the linuxserver + base image (they claimed `2026-06-27` on an image built minutes earlier). To prove which build is + live, compare `docker inspect --format '{{.Image}}'` to the registry's `Docker-Content-Digest` + for that tag +- **Container log lines carry a LOCAL-time bracket (`[18:48:13 DBG]`) while `docker logs -t` emits + UTC**, so `--since` windows silently mis-slice. For before/after measurements capture by line + offset instead (`wc -l` before, `tail -n +N` after) - Secrets file uses PascalCase JSON (`Address`, `ApiKey`) - Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner` - EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table - External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`) -- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API +- `/api/health` predates the Blazor removal; verify the API with an authenticated `/api/v1/channels` instead - PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels) - CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId) - SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels