# CLAUDE.md Guidance for working in this repo. Keep it current when an invariant changes. ## What this is A **self-hosted** multi-tenant TV-guide app, distributed as one Docker Compose stack (published image `issinoho1969/tvtimes`, mirrored to `ghcr.io/issinoho/tvtimes`). Not a hosted SaaS. Full picture: `README.md`, `docs/homelab.md`, `docs/deploy.md`. ## Layout ``` backend/ FastAPI · SQLAlchemy 2.0 async · Alembic · arq worker frontend/ React 18 · Vite · TypeScript · TanStack Query · react-virtual · PWA connector/ standalone agent for HDHomeRun tuners the server can't reach docker/ entrypoint.sh — bootstraps secrets, runs migrations, dispatches api|worker Dockerfile multi-stage: node builds the SPA -> python image (API + worker + SPA) ``` ## Commands ```sh # backend (run from backend/) uv sync --extra dev uv run ruff check . && uv run ruff format --check . && uv run mypy . && uv run pytest -q uv run alembic revision --autogenerate -m "…" # then hand-check the migration # frontend (run from frontend/) npm ci npm run lint && npm run typecheck && npm test && npm run build npm run gen:api # regenerate src/lib/api/schema.d.ts (needs the API running, or pass a file) # dev stack docker compose -f docker-compose.dev.yml up --build ``` `npm run test:browser` runs the layout suite (`*.browser.test.tsx`) in a real Chromium via Vitest browser mode — overlap, scroll position and contrast, none of which jsdom can see. It needs `npx playwright install chromium` once. CI runs it as its own step; `npm test` stays jsdom-only and needs no browser. CI pins **newer** ruff/mypy than an old local pin may resolve — if `ruff format --check` passes locally but fails CI, run `uvx ruff@ format .` (the version prints in the failed job log). ## Conventions - **Python**: 3.12, `from __future__ import annotations`, full type hints, `uv`. ruff select = `E,F,I,UP,B,C4,SIM,PTH,RUF`; `mypy` strict (see `pyproject.toml` `[tool.mypy]`, `alembic/` excluded). Structured logging via `app.logging` (`get_logger`), never bare `print` except the pre-logging startup check. - **The name is always lowercase `tvtimes`.** - **Config**: `app/config.py`, env prefix `TVTIMES_`. `get_settings()` is `lru_cache`d; tests call `.cache_clear()`. - **Datetimes**: store UTC. Models use `app.db.TZDateTime` (SQLite returns naive — this coerces to aware UTC). Never persist a naive datetime. - **Outbound HTTP for user-supplied URLs goes through `app/ingest/ssrf.py`** (rejects private/loopback/link-local/CGNAT, re-checks redirects, size-caps). The one deliberate exception is `app/ingest/hdhomerun.py`, which *requires* a private LAN address — don't route it through the generic guard. - **Secrets** (`config_encrypted`, TOTP seed, TMDB token, connector token) are Fernet-encrypted via `app/auth/crypto.py`. Errors/logs are redacted through `app/ingest/redact.py`. - **Frontend**: path alias `@/` → `src/`. API client is `openapi-fetch` over the generated `schema.d.ts`; `baseUrl` is the page origin (paths already carry `/api`). Component ``, not the data router. Guide times **must** be formatted in the channel's zone via `features/guide/time.ts` helpers — never bare `toLocaleTimeString([])` (see invariants). ## Non-obvious invariants - **Same-origin.** The API serves the built SPA at `/` (`app.main._mount_spa`, gated on `TVTIMES_STATIC_DIR`, mounted *after* the `/api` router). No CORS in prod; the refresh cookie is first-party. - **Cookies.** Refresh token: HttpOnly, `path=/api/auth`. CSRF token: non-HttpOnly, **`path=/`** — the SPA reads it from any route to do the silent refresh on load. Don't narrow the CSRF path or reloads bounce to login. - **Guide timezone.** The grid ruler is drawn in `channel.timezone` (per-source override → tenant default). Cards, the agenda, the sheet and the day label must render in that same zone. Tenant default is set from the browser's IANA zone at signup (`RegisterIn.timezone`, validated, UTC fallback). - **Channel refresh.** `Programme.channel_id` is `ON DELETE CASCADE`. `services/sources.refresh_source` reconciles channels **in place** by `dedupe_key` (`tvg-id | name | sha1(stream_ref)[:12]`) so unchanged channels keep their id and their programmes. It returns `bool` (channel set changed); the worker passes `reset_cache=changed` to `epg.ensure_epg_source_for` so a changed set forces a full EPG re-parse instead of short-circuiting on a 304. The manual `POST /sources/{id}/refresh` also forces it (`force_epg=True`). - **EPG fan-out.** `epg._channel_index` maps each XMLTV key to a **list** of channel ids; `_resolve_channels` returns all of them and a programme row is written per channel — so an East/West pair sharing a tvg-id both get data. - **Email is fail-open.** `auth/email.send_email` catches provider errors, logs `email.delivery_failed` + `email.undelivered_body` (so the link is recoverable), and returns — a broken mailer must not 500 registration. Those two events (and `email.console`) carry a live `?token=` link, so `app.logging._UNREDACTED_FIELDS` exempts their `body` from the log redactor — masking it leaves a dead link and a console-mode install that can't finish signup (#190). - **Watchlist items are snapshots.** A `watchlist_item` of kind `programme` stores `channel_id` + start + title, deliberately **not** an FK to `programme` (an EPG refresh deletes and recreates those rows). Kind `title` matches every future airing whose normalised title is equal. `watchlist_notification` is a send-once ledger so a title watch never double-emails; the `reminders` arq cron (every 5 min) skips unverified users and goes through the same fail-open mailer, ~15 min before air. - **Export token.** Per-tenant, `Tenant.export_token_hash` is the **sha256** of the token (the raw value is shown once, never stored). It gates the unauthenticated `/api/exports/*` endpoints via `?token=` — a tuner client can't send an `Authorization` header. Rotating just replaces the hash. The M3U/XMLTV writers call `epg.resolve_display_tz` so the export and the grid agree on zone + per-channel clock shift. - **`tmdb.cache_key`** (was `_cache_key`, now public) is the one mapping from a programme to its `tmdb_enrichment` row: `(normalize_name(title)[:300], year or "")`. `services/epg` highlights and `services/tmdb` must both use it or the rating join silently misses. - **`SourceKind` enum** is `native_enum=False` (VARCHAR) — adding a value needs no migration. - **PWA icons are two manifest entries, not one.** `favicon.svg` is `purpose: 'any'`; `maskable-icon.svg` is `purpose: 'maskable'` and full-bleed. Android crops a maskable icon to the central 80% and then applies its own circle/squircle mask, and favicon.svg's TV is drawn with a margin that crop cuts straight through — collapsing them back into one `'any maskable'` entry re-clips the splash screen and the launcher icon (#168). Artwork in the maskable variant must stay inside the safe zone: a circle of radius 25.6 about (32, 32) of the 64-unit viewBox. ## Workflow Feature branch → PR → **squash-merge** (`gh pr merge --squash`; the branch is deleted automatically, and `--auto` lands it as soon as CI passes). CI (`ci.yml`): backend / connector / frontend / image / migrations jobs must pass. `main` does **not** require branches to be up to date, so a PR is not tested against the main it merges into — the post-merge CI run is the real integration check, and it must be green before you tag. Releases are cut by pushing a tag: `v*` (app image) or `connector-v*` (connector image + wheel); needs repo secrets `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN`. `git remote` is SSH. ### Release versioning **From 1.0.0 on, `v*` tags are real semantic versioning** (`MAJOR.MINOR.PATCH`): `PATCH` for a bug fix, `MINOR` for a backward-compatible feature, `MAJOR` for a breaking change — not the `0.1.NN` build counter that ran up to 0.1.69, where every release was the next number regardless of what was in it. Breaking here means something the *operator* has to act on before pulling: a renamed or removed `TVTIMES_` key, a migration that won't roll back, a changed `/api/exports` or connector contract, a raised minimum Compose/Postgres. A change users see but operators don't is a `MINOR`. **The tag is the only place the version lives.** `backend/app/__init__.py` reads `TVTIMES_VERSION`, which `release.yml` passes as a build-arg from the tag (`Dockerfile` ARG → ENV) for `/api/health` and the OpenAPI doc to report. The `version` fields in `backend/pyproject.toml` and `frontend/package.json` are inert placeholders — don't bump them, and don't add a version file or a CHANGELOG.md. **The annotated tag's message is the release note**, so write it for an operator deciding whether to pull: a ` — ` headline, then a short paragraph on why it matters. Lightweight tags carry no message, so always `-a`, and push the tag explicitly (`--follow-tags` won't). ```sh git tag -a v1.1.0 -F - # headline, blank line, paragraph git push origin v1.1.0 ``` `connector-v*` is versioned independently on the same rules — it ships to operators as its own image and wheel, and its number tracks the connector's own compatibility with the server, not the app's release cadence.