DEVELOPMENT.md
Rama main · réplica de lectura del repositorio original.
# 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.