Ctrl K
ComunidadGuías…

CLAUDE.md

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

Archivo original ↗Preguntar a KiBot
# CLAUDE.md — myNextScreen (Multi-Tenant Digital Signage Platform)

> **myNextScreen** — Multi-Tenant Digital-Signage-Plattform für Konzert-Venues.
> Quelle der Wahrheit für Produkt/Features ist `VISION.md`, für die technische
> Architektur `ARCHITECTURE.md`. Bei Widersprüchen gewinnt der **tatsächliche Code**
> (siehe Hinweise unten). Diese Datei fasst zusammen, was beim Arbeiten am Repo
> wichtig ist.

## Project

Organisationen verwalten **Screens (TVs)** über ein Venue verteilt, laden Bilder/
Videos in eine geteilte **Content-Library** (mit Transcoding), bauen **Playlists**,
planen sie kalenderbasiert über Screens hinweg (**Schedules**) und streamen **Live-Video**
— alles über ein Angular-Admin-Dashboard. Screens ziehen ihren State per HTTP und
bekommen Updates per **SSE** gepusht. Ein separater **Player** (Angular) läuft auf
den Screens.

Kernideen:

- **Multi-Tenancy:** Jede **Organisation** ist voll isoliert (eigene Screens,
  Content, Playlists, Schedules, User). Provisioniert nur durch **Super-Admin**,
  keine Self-Registration. Jede Query ist serverseitig per `organisationId` gescoped.
- **Rollen pro Organisation:** Org Admin / Editor / Viewer. Ein User kann mehreren
  Orgs angehören (separate Rolle je Org). Super-Admin ist system-level.
- **Screens** registrieren sich per **API-Key** (einmalig angezeigt, regenerierbar),
  senden **Heartbeats** (online/offline) und cachen Content lokal (Playback läuft
  weiter, wenn die Verbindung abbricht).
- **Screen-Groups:** Mirror-Mode (identischer, frame-synchroner Inhalt) oder
  Split-Mode (Video-Wall-Grid; Server sliced Content pro Screen-Viewport).
- **Content:** Upload → Original gespeichert → BullMQ-Job transcodiert (Video →
  H.264 MP4, Bild → WebP/JPEG-Fallback). Original **und** Transcoded werden
  behalten. Storage-Limits pro Org (separat für Original/Transcoded) hart enforced.
- **Schedules:** Kalender (Tag/Woche/Monat), drag&drop Playlist-Blöcke, **RRULE**
  für Recurrence, keine Overlaps, Lücken → **Fallback-Playlist** der Org.
- **Live-Streams:** Eigener Modus (nicht Teil einer Playlist). FFmpeg ingestet die
  Quelle, transcodiert nach **HLS**, überschreibt die Schedule; bei Stream-Stop
  automatischer Fallback auf die geplante Playlist.
- **Audit-Log** für alle signifikanten Aktionen; **Notifications** über In-App / E-Mail
  (SMTP) / **ntfy**, pro User togglebar.

> **Hinweise zur Doku-Aktualität (Code gewinnt):**
>
> - **Echtzeit = SSE**, nicht WebSocket/Socket.IO (README-Tabelle ist veraltet).
>   Siehe `backend/src/dashboard/dashboard-sse.service.ts` + `@Sse`-Endpoints.
> - **Screen-Protokoll = JSON-Adapter** (`screen-protocol/json-protocol-adapter.ts`).
>   SMIL ist nur als Abstraktion vorgesehen (`ScreenProtocolAdapter`-Interface),
>   noch **nicht** implementiert — VISION/README nennen SMIL als ersten Wert,
>   real ist JSON über HTTP + SSE.

## Domain Model (Backend-Module = Domains)

```
Organisation         (Tenant; storage-limits, default/fallback playlist, time zone)
  ├── User-Membership { user, role(OrgAdmin|Editor|Viewer), notificationPrefs }
  ├── Screen          { name, resolution/aspect, location, apiKey, heartbeat, status }
  │     └── gehört zu max. 1 ScreenGroup { mode(mirror|split), gridLayout }
  ├── Content         { title, desc, tags, originalPath, transcodedPath, sizes, status }
  ├── Playlist        { kind(static|dynamic), rule(jsonb), nextResolveAt } └── PlaylistItem { contentId, durationMs (für Bilder) }
  ├── ContentSource   { adapterType, params, formats, style, syncInterval, lastSync* }
  │     └── SourceEvent { externalId, eventDate, venue, genres, status, goneAt } └── SourceEventSlide { format, contentId(managed), renderHash }
  ├── Schedule        { screen|group, playlist, start/end, RRULE(recurring), color, managedByRuleId? }
  ├── EventScheduleRule { source, venues(canonical), playlist, windowStart/End(HH:MM lokal), excludeCancelled, enabled,
  │     genreVariants[] (Genre-Gruppen → versteckte Varianten-Playlists, playlists.managedByRuleId) }
  │     ├── Target { screen|group } └── Exclusion { sourceEvent }
  ├── LiveStream      { sourceUrl, target screen(s)/group, ffmpeg lifecycle → HLS }
  ├── SiteAgent       { name, location, version, network(kind/ssid/ip), heartbeat; Enrolment + rotierende Sessions }
  │     └── ScreenRemoteControl { screenId 1:1, agentId, localIp, mac, devmodePassphrase(enc), Toggles, Telemetrie }
  ├── AuditEntry      { timestamp, userId, organisationId, action, resourceType/Id, details }
  └── Notification    { channel(inapp|email|ntfy), userPrefs }
```

> Schema-Hinweise:
>
> - **Entity-IDs sind UUIDs.** Multi-Tenancy serverseitig per Guard/Query-Scope
>   durchsetzen (Viewer darf nichts anlegen) — nie nur im Frontend ausblenden.
> - **Transcoded Files liegen im Filesystem, nicht in der DB.** Pfadschema:
>   `{MEDIA_BASE_PATH}/{organisationId}/originals|transcoded/{contentId}.{ext}`.
> - **Geld/Storage als Integer-Bytes**, Limits separat für Original/Transcoded.
> - **Split-/Slicing-Logik** liegt zentral (`screen/screen-state.service`,
>   `slice-content/`) — nicht pro Screen/Adapter duplizieren.

## Stack

- **Frontend (Admin):** Angular ~22.2 (Standalone, Signals, **zoneless-orientiert**),
  Tailwind CSS v4 (`@tailwindcss/postcss`), eigene Auth-UI (email+password). Tests: **Vitest**.
- **Player:** eigene Angular-22-App (`player/`), **hls.js** für Live-Streams. Tests: **Vitest**.
- **Backend:** NestJS 12 (modular, ein Modul pro Domain), **Drizzle ORM** + **PostgreSQL**
  (`pg`), **BullMQ + Redis** (Transcoding-Jobs), `@nestjs/schedule`, `@nestjs/jwt` + `bcrypt`
  (interne Auth), `rrule`, `nodemailer`, `class-validator`/`class-transformer`. Tests: **Jest**.
- **Auth:** **Interne Auth** (email+password, JWT-Access-Cookie + Redis-Refresh-Tokens,
  bcrypt-Hashing), **API-Keys** (Screens). Erster Super-Admin wird **nicht** per Env
  geseedet, sondern über ein **UI-First-Run-Setup** angelegt (`GET /api/auth/setup-status`,
  `POST /api/auth/setup`; nur möglich, solange kein User existiert — atomar). Der
  First-Run-Super-Admin **bleibt** (sieht alle Orgs, hebt Storage-Limits an).
- **Self-Signup:** `POST /api/auth/register` legt **atomar** User + eigene Org +
  OrgAdmin-Membership an (`emailVerified=false`), mailt einen Verify-Link; **Login bleibt
  blockiert (403 „Email not verified") bis verifiziert** (`POST /api/auth/verify-email`
  → Auto-Login). Plus `resend-verification`, `change-email`/`confirm-email-change`,
  Passwort-Geändert-Notiz. Feature-Flag `SIGNUP_ENABLED` (in `setup-status` gespiegelt).
  Ein stündlicher Cron löscht nie-verifizierte Signups + deren Orphan-Org.
- **Mailer-Trennung:** **Account/System-Mails** (verify, invite, reset, email-change,
  password-changed) laufen über den **ENV-getriebenen `PlatformMailerService`**
  (`notification/channels/`, SMTP via `SMTP_*`, event-getrieben, best-effort). **Per-Org-SMTP**
  bleibt nur für **Org-Notifications** (`EmailNotificationChannel`). Dev: **Mailpit** (`:8025`).
- **Site Agent:** eigene NestJS-App (`apps/site-agent/`), laeuft im Venue-Netz. `ws` (SSAP),
  `ssh2` (Devmode-Verlaengerung ueber Luna), `dgram` (Wake-on-LAN). Eigene JWT-Audience
  `mynextscreen-agent`. Setup-UI auf Port 8787: Onboarding per **Dashboard-Setup-Code**
  (kein PIN mehr); `MNS_SERVER_URL` ist **Pflicht** und muss `https://` sein (Boot-Fehler sonst;
  `MNS_ALLOW_INSECURE_SERVER_URL=true` nur fuer lokale Entwicklung). Agent-Reset verlangt
  einen frischen Setup-Code (serverseitig validiert). Unbeaufsichtigt per
  `MNS_ENROLMENT_TOKEN` (nur solange keine Session gespeichert ist) plus eigenem
  Ansible-Playbook `ansible/site-agent.yml`. Tests: **Jest** (Gate 85/75/83/85).
- **Media:** **FFmpeg** als Child-Process (Transcoding + HLS-Live).
- **Echtzeit:** **SSE** (Dashboard-Updates + Screen-Pushes).
- **Runtime:** Node.js 26 (LTS). **Package-Manager: npm** (npm@12.2.0, kein pnpm/yarn).
- **Deployment:** Docker Compose (dev) · Ansible + rootless Podman Quadlets + Caddy
  (prod, `ansible/`). Repo-Remote: GitHub (`github.com/Flo-Schilli/mynextscreen`).

## Project Structure

> **Nx integrated monorepo.** EIN Root-`package.json` + EIN `package-lock.json`
> (Single Source of Truth, Version + alle Deps), `nx.json` (Targets/Caching),
> `tsconfig.base.json` (Path-Mappings, u. a. `@mynextscreen/shared-types`). Apps haben
> **kein** eigenes `package.json` — nur ein `project.json` mit Nx-Targets
> (`nx:run-commands`). Gemischte Test-Runner: **Jest** (backend, Coverage-Gate),
> **Vitest** (frontend via `ng test`, player via `vitest run`).

```
apps/
  backend/                # NestJS API (REST + SSE + BullMQ-Worker)
    project.json          # Nx-Targets: build/serve/test/typecheck/lint/format + db-* (drizzle-kit)
    jest.config.ts        # Coverage-Gate 92/83/84/92; coverage → apps/backend/coverage
    drizzle.config.ts     # Drizzle-Kit Config (generate/migrate/push/studio)
    src/
      auth/               # Auth (Cookie email+password, API-Key-Auth, Role-Guards)
      organisation/       # Tenant-Mgmt, Storage-Limits, Fallback-Playlist, Time Zone
      user/               # Org-Membership, Rollen, Notification-Prefs
      screen/             # Registrierung, API-Key, Heartbeat, State-Service
      screen-group/       # Mirror/Split-Modi, Grid-Layout
      content/            # Upload, Metadata, triggert Transcoding-Jobs (dto/)
      slice-content/      # Video-Wall-Slicing pro Screen-Viewport
      playlist/           # Playlist-CRUD, geordnete Items mit Dauer; dynamische Playlists (Regel, Resolver, Resolve-Queue)
      content-source/     # Website-Adapter, Sync-Queue (+ täglicher Cleanup), Slide-Renderer (sharp), Event-Pool
      schedule/           # Kalender-CRUD, RRULE-Recurrence, Overlap/Fallback
      event-schedule/     # Event-Schedule-Regeln: Planer (pure), Reconcile (Advisory-Lock je Org), Listener + Stunden-Tick
      live-stream/        # Stream-URL, FFmpeg-Lifecycle, Override/Fallback (HLS)
      screen-protocol/    # Adapter-Abstraktion; JsonProtocolAdapter (erster Impl)
      notification/       # Hub + channels/ (in-app, email, ntfy)
      audit-log/          # Audit-Trail
      dashboard/          # SSE-Service + Controller für Echtzeit-Updates
      media/              # Filesystem-Media-Handling
      search/             # Globale Suche (screens/content/playlists, org-scoped)
      db/                 # Drizzle-Schema + migrations/ (generierte SQL)
      health.controller.ts # Healthcheck
    Dockerfile.prod       # baut aus Repo-Root-Context (COPY package*.json …)
  frontend/               # Angular 22 Admin-SPA (src/app/<domain>, shell/, shared/)
    project.json          # build (@angular/build), serve, test (ng test → Vitest), …
    angular.json          # minimal — hält Vitest-Root am App-Dir (TestBed-Isolation)
  player/                 # Angular 22 Player-App (connection/, playback/, player/)
    project.json          # build (@angular/build), serve, test (vitest run), …
  site-agent/             # NestJS Site-Agent — laeuft IM Venue, steuert die LG-TVs
    project.json          # build/serve/test/typecheck/lint/format
    jest.config.ts        # Coverage-Gate 85/75/83/85
    Dockerfile.prod       # baut aus Repo-Root-Context
    src/{setup,connection,config,tv,probe,supervisor,protocol}/
libs/
  shared-types/           # geteilte TS-Typen (Lib; nur lint/typecheck-Targets)
nx.json                   # Nx-Targets, namedInputs (production), Caching
tsconfig.base.json        # Path-Mappings (@mynextscreen/shared-types) für alle Projekte
package.json              # Single Source: Version + alle Deps (Root)
package-lock.json         # einziges Lockfile
player-applications/      # Native/Plattform-Player
  lg-tvos/                # LG webOS App (app.js, appinfo.json)
ansible/                  # Prod-Deployment: quadlets/, templates/ (Caddy, fail2ban), *.yml
docker-compose.yml        # Dev: backend(3000) · frontend(4200) · player(4300) · redis · postgres(5432)
ARCHITECTURE.md           # Technische Architektur (kanonisch)
VISION.md                 # Produkt-Vision & Feature-Details (kanonisch)
```

> Build-Outputs: backend → `dist/apps/backend/main.js`; frontend/player →
> `dist/apps/{frontend,player}/browser`.

## Active ECC Rules

- common
- typescript
- angular

## Default Skills to load

- nestjs-patterns # Backend-Arbeit in backend/
- angular-developer # Frontend-/Player-Arbeit in frontend/ bzw. player/

## Agents

- @typescript-reviewer # vor jedem PR
- @build-error-resolver # bei Build-/Type-Fehlern
- @tdd-guide # beim Schreiben von Tests

## Conventions

- **Strict TypeScript, kein `any`.**
- **Angular: Standalone Components, Signals, keine NgModules.** Business-Logik
  in Services, nicht in Components.
- **NestJS: ein Modul pro Domain.** DTOs immer mit `class-validator` validieren
  (`dto/`-Ordner pro Modul). Multi-Tenancy in Guards/Services durchsetzen.
- **Drizzle-Schema (`src/db/`) ist die Schema-Quelle.** Schema-Änderungen immer per
  Migration (`nx run backend:db-generate` → `db-migrate`), nie Auto-Sync/`db-push` in Prod.
- **TailwindCSS only** (v4), möglichst kein Custom-CSS.
- **Prettier** für Formatierung (alle drei Apps haben `.prettierrc` + `format`/`format:check`), **ESLint** ohne Formatierungsregeln.
- **npm** als Package-Manager — nie pnpm/yarn.
- **Versions-Single-source-of-truth = Root-`package.json`.** Release via
  `npm run version:patch && git push --follow-tags`; alles andere (Image-Tags,
  OCI-Labels, `/api/version`, `version.json`) leitet sich daraus ab — nie manuell.
  Die Sub-`package.json`/`package-lock.json`-Versionen (backend/frontend/player)
  pflegt der `version`-npm-Hook via `scripts/sync-versions.mjs` aus der Root-Version
  — nie manuell editieren; CI (`sync-versions.mjs --check`) erzwingt das.
- Externe Calls (ntfy, SMTP) hinter Interface/Modul, mockbar.
- Geheimnisse/API-Keys nie plaintext loggen; Config über ENV (`@nestjs/config`).
- **Private SSH-Keys der TVs gehoeren nie auf den Server** — nur die Devmode-Passphrase,
  verschluesselt. Der Agent zieht den Key vom Key-Server des TVs. Die Passphrase ist das
  einzige Feld, das einem Aufrufer im Klartext und einem anderen maskiert geliefert wird;
  deshalb sind Agent- und Dashboard-Payload getrennte Typen, nicht einer mit Flag.

## Do NOT

- Keine NgModules — immer Standalone Components.
- Keine Business-Logik in Angular-Components — immer Services.
- Multi-Tenancy nicht nur im Frontend „verstecken" — serverseitig per Scope/Guard.
- Transcoded/Original-Files nicht in die DB schreiben — Filesystem unter `MEDIA_BASE_PATH`.
- Schema nicht manuell in der Postgres-DB ändern — immer Migration.
- Nicht manuell formatieren — Prettier macht das.
- Kein pnpm/yarn. Keine `.env`-Dateien committen.
- README-Stack-Tabelle nicht blind übernehmen (Echtzeit = **SSE**, Protokoll = **JSON**).

## Commands

```bash
# Alles per Docker (dev): backend:3000 · frontend:4200 · player:4300 · redis:6379
npm run dev                       # docker compose up --build

# Root-Scripts fächern per `nx run-many` über ALLE Projekte (backend/frontend/player/site-agent/shared-types):
npm run lint                      # nx run-many -t lint
npm run test                      # nx run-many -t test  (backend + site-agent Jest · frontend/player Vitest)
npm run typecheck                 # nx run-many -t typecheck
npm run format:check              # nx run-many -t format:check
# Release + lokale Prod-Images (Version aus Root-package.json):
npm run version:patch             # bump + commit + tag (push: git push --follow-tags)
npm run images:build              # baut alle 4 prod-Images aus Repo-Root-Context

# Nx direkt (vom Repo-Root):
npx nx affected -t lint typecheck test build   # nur vom Diff betroffene Projekte (so läuft CI auf PRs)
npx nx run-many -t lint typecheck test build   # alle Projekte (so läuft CI auf main)
npx nx build  <app>               # backend/site-agent → dist/apps/<app> · frontend/player → dist/apps/<app>/browser
npx nx serve  <app>               # backend: nest start --watch · frontend/player: ng dev-server (4200/4300)
npx nx test   <app>               # backend: jest --coverage (Gate) · frontend: ng test · player: vitest run
npx nx typecheck <app> / lint <app> / format:check <app>

# Backend-DB (Drizzle, via Nx run-commands → drizzle-kit, cwd apps/backend):
npx nx run backend:db-generate    # drizzle-kit generate (Migration aus Schema-Diff)
npx nx run backend:db-migrate     # drizzle-kit migrate
npx nx run backend:db-push        # drizzle-kit push
npx nx run backend:db-studio      # drizzle-kit studio
```

> **Lokal ohne Docker:** PostgreSQL (16+), Redis (7+) und FFmpeg müssen erreichbar/auf
> dem `PATH` sein; `.env.example` → `.env` kopieren und `JWT_ACCESS_SECRET` setzen
> (`openssl rand -base64 48`). Schema via `nx run backend:db-migrate` anlegen.

## Tests & Quality Gates

- **CI:** `.github/workflows/ci.yml` — Trigger push `main`, Tags `v*.*.*`, PRs.
  Job 1 `lint-test-build` (EIN Job, kein Matrix mehr): `npm ci` am Root,
  `sync-versions.mjs --check`, dann `nrwl/nx-set-shas` + `nx affected -t
format:check / lint typecheck build / test` auf **PRs** bzw. `nx run-many`
  auf **main** (nichts wird übersprungen). Backend-Coverage-Gate läuft im
  `test`-Target (Jest); zusätzlich `ArtiomTr/jest-coverage-report-action`
  (`working-directory: apps/backend`) für PR-Kommentar + Artifact aus
  `apps/backend/coverage`. Job 2 `docker` (Matrix backend/frontend/player):
  baut alle drei prod-Images aus Repo-Root-Context (`file: apps/<app>/Dockerfile.prod`),
  **published nach GHCR nur auf main/Tags** (nicht bei PRs).
- **Coverage-Gate nur Backend** (`backend/jest.config.ts`, ~80% — gemessen ~85%).
  Frontend/Player ohne Schwelle (Test-Abdeckung dort dünn: ~8 bzw. ~3 Specs;
  Backend ~83 Suites / 900+ Tests). Keine künstlichen Tests nur fürs Gate.
- **Frontend-Tests sind in CI ein harter Gate** (kein `continue-on-error` mehr).
  Der frühere `TestBed`-State-Leak über Spec-Dateien (Vitest +
  `@analogjs/vite-plugin-angular`) lag am Plugin-Default-Pool `vmThreads`, der
  `@angular/core/testing` externalisiert und das TestBed-Singleton über
  VM-Contexts auf demselben Thread teilt — auf Low-Core-Runnern, wo Specs sich
  einen Worker teilen, leakt das. Fix: `pool: 'forks'` in `frontend/vitest.config.ts`
  (frische, prozess-isolierte Modul-Registry je Spec-Datei). Grün auch bei
  `singleFork`/`--no-file-parallelism`.
- Kein e2e-Setup; eine Backend-Integration-Spec (`playlist-transition.integration.spec.ts`).

## Environment (wichtigste Variablen)

| Variable                                                            | Zweck                                                                                                         |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL`                                                      | PostgreSQL-DSN (z. B. `postgres://signage:signage@localhost:5432/signage`)                                    |
| `REDIS_URL`                                                         | BullMQ-Queue + Refresh-Tokens (`redis://localhost:6379`)                                                      |
| `MEDIA_BASE_PATH`                                                   | Root für Original/Transcoded Media                                                                            |
| `JWT_ACCESS_SECRET`                                                 | **Pflicht** — Secret für JWT-Access-Token (`openssl rand -base64 48`)                                         |
| `SECRETS_ENCRYPTION_KEY`                                            | **Pflicht** — AES-256-GCM-Key (32 Byte, base64) für alle rückholbaren Secrets; DB lehnt Klartext per CHECK ab |
| `JWT_ACCESS_TTL` / `JWT_REFRESH_TTL`                                | Token-Lebensdauer (z. B. `15m` / `30d`)                                                                       |
| `COOKIE_SECURE` / `COOKIE_SAMESITE`                                 | Cookie-Härtung (Secure default nur bei `NODE_ENV=production`)                                                 |
| `PUBLIC_BASE_URL`                                                   | Basis-URL der Admin-SPA (Verify/Set-Password/Reset/Confirm-Email-Links + CORS)                                |
| `SMTP_HOST` / `_PORT` / `_USER` / `_PASSWORD` / `_FROM` / `_SECURE` | Platform-Mailer (Account-Mails); Dev → Mailpit (`localhost:1025`)                                             |
| `SIGNUP_ENABLED`                                                    | Feature-Flag Self-Signup (default `true`)                                                                     |
| `SIGNUP_DEFAULT_STORAGE_ORIGINAL_BYTES` / `_TRANSCODED_BYTES`       | Default-Storage-Limits einer self-created Org (default 5 GiB)                                                 |
| `SIGNUP_UNVERIFIED_TTL_HOURS`                                       | Frist bis Cleanup-Cron nie-verifizierte Signups löscht (default 24)                                           |
| `SITE_AGENT_ENROLMENT_TTL_MS`                                       | TTL des Site-Agent-Setup-Codes (default 900000 = 15 min; < 60 s wird ignoriert)                               |
| `MAX_FILE_SIZE_BYTES`                                               | Upload-Limit                                                                                                  |
| `FFMPEG_PATH`                                                       | FFmpeg-Binary (default: System-PATH)                                                                          |
| `FFMPEG_VIDEO_CRF` / `_PRESET` / `_MAXRATE` / `_BUFSIZE`            | Transcoding-Qualität                                                                                          |
| `METRICS_SCRAPE_TOKEN`                                              | Bearer-Token für `GET /api/metrics` (Prometheus); unset = Endpoint 404-disabled, nie öffentlich routen        |

## Deployment (Prod) — GHCR-Pull-Modell

- **CI baut + published** die Images nach **GHCR** (`ghcr.io/flo-schilli/mynextscreen/{backend,frontend,player}`).
- **Ansible** (`ansible/deploy.yml`) baut nichts lokal mehr — es `podman login`t
  (falls `ghcr_token` gesetzt) und **zieht** die Images. App-Quadlets sind Templates
  (`ansible/templates/signage-*.container.j2`) mit `AutoUpdate=registry` + `Pull=newer`;
  der Tag ist über `signage_image_tag` (default `latest`, pinbar auf `1.2.3`) steuerbar.
  Redis kommt direkt von `docker.io/library/redis:7-alpine`.
- **Versions-Surfaces:** Build-args `APP_VERSION/GIT_COMMIT/BUILD_DATE` → OCI-Labels
  in allen Images; Backend `GET /api/version`; Frontend & Player `GET /version.json`.
- **Caddy** als einziger öffentlicher Reverse Proxy (`templates/Caddyfile.j2`),
  inkl. SSE-Pass-Through (X-Real-IP) und **fail2ban**-Jail.
- Container-Härtung: `tini` als PID 1 (backend), `HEALTHCHECK` in allen Images
  (backend → `/api/health`, nginx → `/`). Backend bleibt rootless-gemapptes root
  wegen Host-Bind-Mounts (`%h/app/{data,media}`); erzwungenes `USER 1001` würde
  Schreibrechte auf die Volumes brechen.
- DB-Backup/Restore über `ansible/download_db.yml` / `upload_db.yml`.
- Auth-Flow: Frontend ↔ Backend (email+password) → JWT-Access-Cookie + Redis-
  Refresh-Token; Backend signiert/validiert mit `JWT_ACCESS_SECRET`. Screens nutzen API-Keys.