Ctrl K
ComunidadGuías…

DEVELOPMENT.md

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

Archivo original ↗Preguntar a KiBot
# PosterView — developer guide

Self-hosted artwork manager for Plex/Jellyfin/Emby. Rust/Axum + SQLite backend,
React/Vite/TS/Tailwind v4 frontend, shipped as one Docker image. See README.md
for the user-facing overview; this file is for working on the code.

## Commands

```bash
# backend (from repo root)
cargo run --package posterview-server # dev server on :7979
cargo test --workspace --locked       # contract/integration tests
cargo clippy --workspace --all-targets --locked -- -D warnings

# frontend (from frontend/)
npm run dev                            # Vite on :5173, proxies /api -> :7979
npm test                               # Vitest component and API-client tests
npm run build                          # tsc typecheck + production bundle -> dist/

# deploy the running container (rebuilds frontend + backend into the image)
docker compose up -d --build           # serves SPA + API on :7979
```

Automated fixtures cover contracts, CRUD, encryption, normalization, uploads, and history.
Live provider/media-server verification remains opt-in because it needs user credentials.

## Deployment security

By default, PosterView requires an administrator session for every API route except health and sign-in/out.
Set `POSTERVIEW_PASSWORD`, or retrieve the generated first-run password from
`data/admin-password.txt`. Set `POSTERVIEW_SECURE_COOKIES=true` behind HTTPS. Keep the service on a
trusted network or behind a reverse proxy because application login does not replace TLS and
network access controls.

The administrator username is configured by `POSTERVIEW_USERNAME` (default `admin`). Login
requires both `username` and `password`; failed credentials return the same generic error.
The optional remember-username preference is per browser and stores only the username, never
the password. It is independent of the shared security settings described below.

`POSTERVIEW_AUTH_ENABLED` accepts `true` or `false`. When unset it defaults to `true`;
the Unraid template explicitly defaults it to `false`. False bypasses authentication for all
connections, not only LAN peers, and overrides inactivity and local-bypass settings. A startup
warning is logged when disabled. Credentials are preserved so enabling login restores them.

`GET/PUT /api/security/settings` manages `/config/security-settings.json` (or the configured data
directory). `idle_timeout_minutes` is null for disabled, otherwise an integer from 1 to 1440.
Sessions track monotonic last-activity time; only authenticated `POST /api/auth/activity` extends
it. Normal API calls, status polling, and image loads never extend the session. The frontend sends
activity updates for user input and shares activity across same-origin tabs.

`local_network_bypass` defaults to false. When enabled, middleware and auth status use Axum's
TCP `ConnectInfo<SocketAddr>` to allow private IPv4, loopback, link-local, and IPv6 unique-local
peers (including IPv4-mapped addresses). Missing peer info fails closed. Forwarded headers are
not trusted. A private reverse proxy can therefore bypass authentication for remote visitors;
the UI explicitly warns about this. Tailscale's IPv4 100.64.0.0/10 range is not treated as private.
Password-free access has no session to expire; turning bypass off restores password enforcement.

The public `GET /api/login-backdrop` manifest and `/api/login-backdrop/{opaque-name}` images serve
only the sanitized login cache under the data directory. Refreshing uses the default/first server,
randomizes libraries/items, downloads at most five rows × fourteen posters, decodes them, and
re-encodes 280×420 maximum JPEG thumbnails without names, IDs, upstream URLs, or tokens. Image
requests are restricted to names present in the current manifest. Refresh work runs asynchronously
at startup, login, server creation, and backdrop enablement; concurrent refreshes collapse into one.
`login_backdrop_enabled` is backward-compatible and defaults to true in security settings.

Server URLs intentionally support LAN IPs, direct Tailscale IPs, resolvable MagicDNS names, and
complete domain URLs because PosterView must reach remote media servers. URL validation therefore
allows private destinations only for configured media-server bases. Artwork downloads and their
redirects are HTTPS-only and restricted to the selected provider's domain.

## Architecture

- `crates/posterview-infra-media-servers/` — Plex, Jellyfin, and Emby adapters.
  Everything is normalized to the `Normalized*` / `ItemDetail` schemas so
  routers and the UI never branch on server type.
- `crates/posterview-infra-artwork/` — ThePosterDB session/scraping plus
  Fanart.tv, TheTVDB, AniList, and MediUX providers.
- `crates/posterview-infra-sqlite/` — compatible SQLite schema and Fernet secrets.
- `crates/posterview-url-security/` — shared media-server and provider URL trust rules.
- `crates/posterview-runtime/` — host-independent orchestration, apply, history, and revert.
- `apps/server/` — Axum routes, authentication, configuration, errors, and compiled-SPA serving.
- **Title search for Fanart.tv/TheTVDB/MediUX** (`GET /api/artwork/search`,
  `ArtworkBrowser.tsx`'s id/search box): typing a non-numeric value shows a
  picker of candidates instead of an id lookup. None of the three has its
  own title-search API — Fanart.tv has none at all, MediUX's is invite-only
  beta, and Fanart/MediUX both need a TMDB (or, for Fanart movies, IMDb) id
  rather than a TVDB one — so all three are backed by `TVDBProvider.search()`
  (TheTVDB's own `/search` endpoint), which conveniently returns each
  candidate's `remote_ids` (TMDB/IMDb correlations) alongside its own tvdb
  id. `remote_id()` in `tvdb.py` pulls the right one out per provider: tvdb
  id for TVDB (and Fanart shows), TMDB for MediUX, TMDB/IMDb for Fanart
  movies. This means title search on **any of the three tabs requires a
  TheTVDB API key**, even when searching from the Fanart.tv or MediUX tab —
  surfaced as a friendly message, not an error, when the key's missing.
- `frontend/src/components/ArtworkPanel.tsx` — provider selector wrapping
  `PosterDBBody` (ThePosterDB drill-down), `ArtworkBrowser` (API providers),
  and `ManualUpload`. Apply targets are built once in `lib/targets.ts`.
- `crates/posterview-runtime/` owns apply history (revert-to-previous-image). Every
  successful `set_image` call, from any of the two apply endpoints
  (`/posterdb/apply`, `/artwork/upload`), also calls `history.record(...)`,
  which writes the bytes to `data/history/` and indexes them in the
  `apply_history` SQLite table. Two independent, **user-configurable** caps:
  a global entry-count ceiling (`history_max_entries` setting, default 50,
  always enforced on insert — oldest rows + files pruned first, not
  per-item, a single shared budget; lowering it in Settings also prunes
  immediately via `history.enforce_max_entries()`, not just on the next
  apply) and an optional max age in days (`history_purge_days` setting, 0 =
  disabled — only entries *older than* the threshold are ever touched,
  swept opportunistically on every `record()` call, no scheduler needed).
  Revert re-applies those same bytes and records that as a new entry —
  reverting never deletes the entry it reverted to, so history stays a true
  timeline, not a stack. The UI is a single **global** feed
  (`frontend/src/pages/HistoryPage.tsx`, linked from the sidebar) rather
  than a per-item tab, grouped client-side into one tile per (item, target)
  — the newest entry in each group is the tile's image; clicking it opens a
  modal listing every version in that group with Revert — `GET /api/history`
  returns that global feed when
  called without `item_id`; `POST /api/history/purge` triggers an immediate
  manual purge. `item_title` is denormalized onto each row (sent by the
  frontend at apply time, since it's already in hand there) specifically so
  the global feed doesn't need an extra media-server round trip per row
  just to render.
- Secrets (server tokens, TPDb password, API keys) remain Fernet-encrypted in
  SQLite; the key lives in `data/secret.key`. Neither
  is ever sent to the browser.
- All images the browser shows are proxied through the backend
  (`/api/servers/{id}/image`, `/api/posterdb/image`) so credentials/sessions
  stay server-side and CORS never applies.

## Gotchas (learned the hard way)

- **Jellyfin/Emby image upload** wants the request body **base64-encoded**
  with `Content-Type` set to the real image mime. Raw bytes fail.
- **Emby backdrops are a list** — `POST /Items/{id}/Images/Backdrop` appends;
  it does not replace the displayed image. `set_image` deletes existing
  backdrops first so the new one becomes index 0.
- **Emby item ids change on library rescan.** Never cache them across
  sessions; always re-list.
- **Plex logo upload** uses `POST /library/metadata/{id}/clearLogos` (same
  raw-bytes pattern as `posters`/`arts`).
- **ThePosterDB is behind Cloudflare** but a logged-in httpx session with
  browser-like headers gets through. A 429 is rate-limiting, NOT a dead
  session — do not re-login on it (re-login used to tear down the shared
  client and kill every in-flight request). Grid thumbnails must use the
  small optimized webp from each card's `<picture><source srcset>` (~70 KB),
  never the full-res `/api/assets/{id}` (2–5 MB), or big sets rate-limit.
- **ThePosterDB search results come from TMDB**, so most matched titles have
  zero uploaded posters. `/api/posterdb/verify` counts posters per title and
  the UI hides the empty ones.
- **The user's Emby goes offline intermittently.** If "nothing loads", hit
  `/api/servers/{id}/test` before suspecting PosterView; restarting the
  container does not fix an unreachable media server.
- **Git Bash on Windows mangles container paths** (`/tmp/...` becomes
  `C:/...`) in `docker exec`/`docker cp` args — wrap the remote command in
  `sh -c '...'` or set `MSYS_NO_PATHCONV=1`.
- **Emby's `/Items?Ids=` lookup silently returns nothing for a BoxSet**
  unless `IncludeItemTypes` explicitly allow-lists it alongside the regular
  types (`Movie,Series,BoxSet`) — no error, just an empty result, which reads
  as "item not found."
- **Emby's `CollapseBoxSetItems` query param doesn't reliably group a
  library's movies into their collections** — tested live: it silently drops
  the extra member items instead of replacing them with a collection tile.
  Grouping is done by hand instead (`JellyfinClient._collapse_boxsets`):
  list every BoxSet, fetch each one's children via `ParentId` in parallel,
  then substitute.
- **MediUX needs no login**, unlike ThePosterDB — a plain browser-header GET
  reaches `mediux.pro/{movies,shows,collections}/{tmdbId}` fine. But its
  Next.js image proxy (used for thumbnails) 403s without a same-origin
  `Referer` header and 400s on a non-whitelisted `w=` value (only specific
  sizes like 256 are allowed) — see `crates/posterview-infra-artwork/src/lib.rs`.
- **SQLite's `datetime('now')`** (used for `apply_history.applied_at`) is
  `"YYYY-MM-DD HH:MM:SS"` UTC with **no timezone marker and a space, not a
  `T`** — `Date.parse()` on the frontend needs
  `str.replace(" ", "T") + "Z"` first or it's ambiguous/wrong depending on
  the browser. Jellyfin/Emby's `DateCreated` and Plex's normalized
  `added_at` are proper ISO 8601 and don't need this.
- **Plex's collection-grouping (`_collapse_collections`) and the rest of the
  Plex collections code are unverified** — there's no Plex server in this
  dev environment. It mirrors Jellyfin's `_collapse_boxsets` pattern closely
  (fetch the section's own collections, fetch each one's children via
  `/library/collections/{id}/children` in parallel, substitute), but the
  first real Plex test is the thing most likely to need a tweak.
- **`db.SCHEMA`'s `CREATE TABLE IF NOT EXISTS` doesn't retroactively add new
  columns** to a table that already exists on disk (e.g. adding `item_title`
  to `apply_history` after the table was already created) — needs an
  explicit `ALTER TABLE ... ADD COLUMN` in `db._migrate()`, gated on a
  `PRAGMA table_info` check, run from `init_db()` on every startup.
- **The container's non-root user doesn't survive a bind mount.** The Dockerfile
  bakes in `chown -R posterview:posterview /config` at build time, which is enough for a
  Docker-managed named volume (docker-compose's `posterview-data`) since Docker
  copies that ownership over on first creation — but a bind mount to a host path
  (e.g. Unraid's `Type="Path"` config, which Unraid creates as root) always
  reflects the host directory's actual ownership and ignores the image, so
  `secret.key`/`posterview.db` writes previously failed with a permission error.
  Fixed by starting the container as root and using `docker-entrypoint.sh` to
  repair ownership of the selected state directory at *runtime*, excluding media
  roots (works for both bind mounts and named volumes), before dropping to uid 10001 via `setpriv --reuid=10001
  --regid=10001 --init-groups`. Verified live: a fresh root-owned bind-mounted
  directory now starts cleanly and `docker top` confirms `posterview-server` runs
  as uid 10001, not root.
- **`apply_history` prunes to the last 50 rows *globally*** (not per item)
  — deletes both the DB row and the file on disk, on every `record()` call.
  Originally per-(server,item,target) capped at 5; changed to one shared
  global budget per user request. If a specific history entry you were
  expecting seems to have vanished mid-testing, check whether repeated
  applies (to *any* title) pushed it out of that window before assuming
  something's broken (bit us once while testing revert).
- **A manually-placed Unraid template XML must go in `templates-user/`, not
  `templates/`.** Confirmed live: the user first dropped the downloaded XML
  into `/boot/config/plugins/dockerMan/templates/` — selecting PosterView from
  the Add Container → Template dropdown still produced a blank/default form
  (no WebUI Port/Data rows, Network Type: None) even though the XML itself
  was valid and complete. Moving the exact same file to
  `/boot/config/plugins/dockerMan/templates-user/` fixed it immediately —
  full Overview/Network/WebUI Port/Data rows populated correctly.
  `templates/` is treated as Community-Applications/system-managed;
  `templates-user/` is the directory Unraid actually scans for manually
  supplied templates. README's Unraid instructions lead with this
  local-file method (curl straight into `templates-user/`) rather than
  Unraid's in-UI "Template repositories" paste-a-URL flow, which was never
  confirmed working in this troubleshooting session.

## Conventions

- Applying artwork always flows through `POST /api/posterdb/apply`
  (`provider` field decides how the bytes are fetched) or
  `POST /api/artwork/upload` for user files — then `MediaClient.set_image`,
  then `history.record(...)`. Any new apply path must call all three, in
  that order — history is written from the bytes that were actually
  uploaded, not re-fetched from the source afterward.
- Banners are display-only everywhere: no supported media server has a
  banner-upload endpoint.
- Keep commits focused and verify both the Rust workspace and frontend build before pushing.