Ctrl K
ComunidadGuías…

CLAUDE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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@<ci-version> 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 `<Routes>`, 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 <n> --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 `<version> — <what changed>`
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.