# Streamline Unified media management platform replacing the *arr stack (Radarr, Sonarr, Lidarr, Readarr) and Seerr. Single self-hosted binary with a slick web UI, REST API for mobile developers, multi-user support with SSO, built-in request system, and automatic media organization. Supports Torznab and Prowlarr indexers, torrent download clients (qBittorrent, Transmission, Deluge, or the built-in engine), and media server notifications (Plex, Jellyfin, Emby). Movies, TV, music and books are all shipped; Usenet and a player are planned (`docs/wiki/Roadmap.md`). ## Stack - Go monolith: chi + oapi-codegen (OpenAPI → server) + ent ORM + modernc.org/sqlite (CGO-free) - Config: koanf (file + env + flags, STREAMLINE_ prefix) - Logging/Observability: slog + OpenTelemetry (traces, metrics, logs via OTel bridge) - Frontend: Svelte 5 SPA (TypeScript) — Routify v3 file-routing, TailwindCSS v4, TanStack Query + TanStack Form, valibot schemas; bundled by esbuild, embedded via go:embed ## Frontend Svelte 5 SPA in `web/app/` (TypeScript everywhere), Routify v3 file-routing over `web/app/routes/`, TanStack Query + Form, valibot, TailwindCSS v4, bundled by esbuild and `//go:embed`-ed. A section's wrapping layout MUST be `_module.svelte` — `_layout.svelte` renders as a sibling route, not a wrapper. Both library lists paginate server-side, and their toolbars read **faceted** counts — each facet counted against the *other* filters, each with its own `*_total` "all" row. Detail-page tabs swipe on touch screens below `lg` (`lib/swipe-tabs.ts`). On a touch screen and below `md` search is a page (`/search`), not an overlay, so Back closes it; from `md` a mouse's search bar (CSS `pointer-fine`, never a JS check) and Ctrl/⌘+K on any pointer open the command palette; and Back/Forward restore `#main`'s scroll (`lib/scroll-restore.ts`). A page's filters, search, sort and tab live in its query string through one helper, `syncRouteQuery` (`lib/url-state.svelte.ts`), with one parameter vocabulary across pages and old names still read. Every bottom sheet and full-screen layer calls `closeOnBack` (`lib/close-on-back.svelte.ts`): Android's Back closes the topmost one through `CloseWatcher`, and any open layer closes when the route's path changes. Settings pages pick save-per-control **or** one TanStack form, never both on a page, and either way the page must call `markConfigForm()` or nothing on it locks on a read-only instance. **Conventions, the dropped facets, search folding, the PWA surface and the bundling pipeline: [`docs/agents/frontend.md`](docs/agents/frontend.md) — read it before touching `web/app/**` or `web/embed.go`.** ## Logging - No `*slog.Logger` plumbing. `cmd/main.go` calls `observability.Setup` then `slog.SetDefault`; every package logs via `slog.XContext(ctx, ...)` (top-level) — never hold a logger field. - `observability.Setup` returns one `slog.Handler` = `contextEnrichingHandler(multiHandler{stderr, otelslog.Handler})`. stderr is pretty text/json from `log.app.{level,format}`; otelslog bridges to the OTLP logs pipeline (traces/metrics/logs all batch-exported to `otel.endpoint`). - **`log.app.enabled` gates the stderr sink only**; the OTel pipeline is gated on `otel.endpoint` alone. It used to return early with a `DiscardHandler`, so quieting local logs silently stopped traces and metrics as well — an instance exporting nothing, for a reason nowhere near the OTel config. - `contextEnrichingHandler` auto-attaches `request_id` (chi), `user.id`/`user.email`/`user.roles` (auth claims, OTel semconv v1.40.0), `http.route` (chi route pattern). Trace/span IDs come from otelslog. Use `slog.XContext` so ctx flows through. - On an authenticated request `user.*` is therefore the **caller**. A log line about another account (admin CRUD, unlock, session revoke) names it `target.user.*` — writing `user.id` there emits the key twice, target and actor, and a filter on `user.id` matches both people. - `observability.LevelCritical` (= `slog.LevelError + 4`, rendered `CRITICAL`) for panics, invariant violations, unrecoverable conditions. Call via `slog.LogAttrs(ctx, observability.LevelCritical, ...)`. - OTel semconv pinned at v1.40.0 — use `semconv.Key` constants (e.g. `semconv.HTTPRouteKey`) over string literals; keep versions aligned across files. ## Observability - `internal/otelx` is the leaf OTel helper package. Holds `HTTPClient` (otelhttp-wrapped — use for every outbound HTTP call; never `http.DefaultClient`; every response body is capped at `MaxResponseBody` inflated bytes, a backstop against a hostile remote's gzip bomb, so a caller streaming something larger needs its own client; a call that knows its answer is small decodes through `otelx.DecodeJSON(body, max, out)` with a per-package ceiling) and `RecordSpanError(span, err) error` (inline: `return ..., otelx.RecordSpanError(span, err)` — no named returns). Must stay dependency-free; `internal/observability` imports `internal/auth`, so anything auth transitively uses must live in `otelx` not `observability`. - Per-package tracer: `var tracer = otel.Tracer("github.com/datahearth/streamline/internal/")`. Span names `.` (e.g. `download.grab`, `rss.process_movie`, `indexer.query`). - DB auto-instrumented via `otelsql.Open` + `RegisterDBStatsMetrics` in `internal/db/client.go`. Add business-logic spans in service methods + `span.SetAttributes` for domain context. - Prefer semconv helper funcs (`semconv.UserEmail`, `semconv.UserRoles`, `semconv.UserID`, `semconv.DBSystemNameSQLite`) over raw `attribute.String("user.email", …)`. - **Never put a row id in a metric attribute** — `movie.id`, `episode.id`, a request path with an id in it. That is one time series per row in the library. Ids belong in the log line and on the span; a metric attribute is a low-cardinality dimension (`outcome`, `kind`, `status`, an indexer or client *name*). - **A span that can fail must record the failure.** Every terminal path funnels its error through `otelx.RecordSpanError` or, where a helper owns the exits, through one choke point (`transcoding.record`, `scheduler.executeJob`). A span left untouched by its own failure paths reports OK, and filtering a backend for error spans then finds nothing wrong. - **Every goroutine started with `go` defers `observability.RecoverPanic(ctx, what, onPanic)`.** `middleware.Recoverer` covers the request path and nothing else; an unrecovered panic in a background worker takes the whole process down. `onPanic` is the caller's policy — a scheduled job records `errJobPanicked` and retries next tick, a transcode fails terminally, a boot goroutine calls `stop()` for a clean shutdown. - Sampling is `otel.sample_ratio` (default `0.05`), overridden by `OTEL_TRACES_SAMPLER` — `Setup` skips its own `WithSampler` when that variable is set, because passing the option at all makes the SDK ignore the env. `otel.insecure` is needed for an `http://` collector; `otel.environment` fills `deployment.environment`. - `otel.SetErrorHandler` routes SDK export failures into the stderr handler **only** — never the full handler, or a failing log exporter is fed its own error records. ## HTTP Routes - `/health` — pre-auth bare JSON endpoint (NOT in OpenAPI), for k8s probes + load balancers. Registered in `internal/server/server.go`. - `/api/docs` — Scalar UI shell (`web.Handler.APIDocs`). `/api/v1/openapi.yaml` — embedded spec. REST API mounted via `restapi.Mount`. - SPA fallback: `s.router.NotFound` → `web.Handler.SPAShell`, which writes the embedded `web/app/index.html` for every non-API, non-static path; Routify owns client-side routing incl. its own 404. Static assets served at `/static/*` from `fs.Sub(web.Assets, "static")` (wired in `web.Mount`). - `/rest` (Subsonic, `subsonic.Handler.Routes`; its public share links at `/share/{token}`, `subsonic.Handler.SharePage`) and `/opds` (`opds.Handler.Router`) are mounted in `server.New` beside `/posters`, NOT in OpenAPI, and authenticate themselves (Subsonic from the query, OPDS by Basic with the per-user `OpdsToken`, stored as a SHA-256 hash and compared in constant time). A successful call of either stamps `last_used_at`/`last_client` through `internal/appaccess` (debounced to one off-request write per user and kind per 5 minutes, guarded on the credential's `*_created_at`). Logging and the body cap still apply. OPDS cover links point at `/opds/cover/{bookID}`, an OPDS route behind the same Basic auth that serves the `books` poster, so Basic-only readers get thumbnails. - `/posters/{kind}/{id}/poster.jpg` — poster proxy via `s.posters.Serve`; `kind` is `movies`, `tvshows`, `artists`, `albums`, `books` or `authors`. An artist with no photo is served the cover of its newest released album that has one (`artistPosterSource`, `db.ArtistCoverAlbums`), and a 404 only when none does, which is the SPA's cue for the monogram. - `/posters/lookup/{kind}/{key}/poster.jpg` — the **only** proxy for art of a title that is not in the library (`internal/artwork`, behind the session middleware, not in OpenAPI): `kind` is `artists` or `albums` (an MBID, the latter a release-group) or `books` (a Hardcover book id), 404 means no art and the SPA draws its placeholder. The client never supplies a URL or a name: the lookup services remember the provider's own reference as they serve a payload (`artwork.Remember(kind, key, artwork.Source{URL | DeezerID | Name})`, a bounded in-memory map, 2000 entries, 24 h, lost on restart) and the proxy resolves from that — an artist by a Deezer name search accepted only with **exactly one** hit that folds equal (no MusicBrainz request), else through what MusicBrainz links for **that** artist (`ArtistLinks`: one `url-rels` call, since a search hit never carries them, at `metadata.Prefetch` priority): its Deezer page unless another MusicBrainz artist links the same page (`DeezerArtistShared`, one `/url` call memoised 24 h per page — Deezer files namesakes under one page, and MusicBrainz links the grunge Nirvana and the 60s UK band to the same 415), else its own Commons `image` rel; a namesake gets no face rather than a wrong one. An album resolves through the Cover Art Archive front, a book through the Hardcover image URL the payload already carried. It **never spends a Hardcover request**. Every fetched URL and every redirect hop must be https on `dzcdn.net`, `coverartarchive.org`, `archive.org`, `assets.hardcover.app`, `commons.wikimedia.org` or `upload.wikimedia.org` (Wikimedia refuses Go's default User-Agent, so the fetch sends streamline's); a key that is already a library row serves that row's file instead; a copy older than 24 h is refetched (and served stale if the refetch fails); the on-disk cache under `posters/lookup/` drops its 200 oldest entries past 2000; a failed resolve is remembered for an hour in memory, keyed on the key **and the source it was tried with** (a MusicBrainz `rate_limited` miss is not remembered at all), so a Deezer link a detail lookup remembers later is tried at once (and `Remember` keeps a live entry's fields a new source leaves empty, so a search listing the artist again does not forget it); concurrent requests for one key share one fetch. The metric label is `lookup-artists|lookup-albums|lookup-books` on the existing `streamline.posters.*` counters. - `POST /api/v1/schedules/{name}/run` marks its run with `scheduler.Manual(ctx)`, and the throttled jobs waive their own windows on it — the missing searches take `unthrottledWindow()` (no cooldown, no failure cap), the metadata refreshes take a cutoff of now. Without it a manual run walked the list, touched nothing, and read as a trigger that did not fire. - `GET /api/v1/schedules/events` is the one streaming endpoint: `text/event-stream`, a full `ScheduleList` frame on connect and on every `scheduler.Subscribe` nudge, coalesced to 250ms, keepalive comment every 15s. It is a strict-handler response type (`scheduleStream`) that writes the stream from its own `Visit` method, and it clears the listener's 2min `WriteTimeout` on its connection via `http.ResponseController`. The only other code that moves that deadline is the Subsonic stream, download and share-stream path (`idleWriter` in `internal/server/subsonic/media.go`), which pushes it 2min ahead of every write, so a client still reading is never cut and a stalled one still frees its transcode slot. A job reports progress with `scheduler.Progress(ctx, done, total)` on the ctx the scheduler handed it (a no-op on any other ctx); `total` 0 means "counting without a known size" and the SPA shows a bare count with no bar. - `DELETE /api/v1/torrents/{hash}` also calls `downloads.PurgeRecordForHash`, dropping the live download record behind that torrent and reverting its media. The monitor's orphan sweep would eventually do it, but only after `monitorOrphanGrace` (2 min) — a window that exists to tolerate a client that hasn't caught up with its own listing, which a removal issued through streamline is not. The Torrents page invalidates `["activity","queue"]` on success for the same reason. `DELETE /api/v1/activity/queue/{id}` (`CancelQueueItem`) runs the same `ReconcileStrandedMedia` pass after deleting the record, so a cancelled episode, album or book is back to `wanted` in the same request, like a movie. - The auth middleware's exclude list is the package-level `authExcludePaths` in `internal/server/wire.go`, handed to `middleware.NewAuth` (`/health`, `/api/docs`, `/api/v1/openapi.yaml`, `/static/`, `/login`, `/register`, `/auth/login`, `/auth/register`, `/auth/config`, `/auth/invite/`, `/auth/oidc/`, `/rest/`, `/share/`, `/opds`, `/opds/`); matcher in `internal/server/middleware/auth.go`, paths ending `/` match as prefix and everything else exactly, which is why OPDS needs both forms. ## Stats `GET /stats?range=7d|30d|90d` (admin, `db.GetStats`) is computed on demand from existing tables, with no table of its own, and the SPA polls it every 5 minutes. `library` is always now; the range bounds the pipeline and the requests, and `previous` is the same totals over the stretch before. Two figures are read rather than recorded: **`failures` classifies the free-text `failure_reason`** (`failureCode`: `stalled`, `verification`, `no_match`, `tracker`, `no_space`, `unpack`, else `other`), and **the two request medians read `update_time`**, so an approved request that later went available loses its decision time. `hdr` is the probe's `MediaFile.dynamic_range`, never the path: renaming drops the release's HDR tag, and `unknown` is a file the backfill has not reached. Each of the largest files shows the resolution the probe measured, else what the import recorded. The pipeline leaves out an `imported` event whose source is an adoption (`wizard`, `orphan`, `bulk_import`), which has no grab behind it. Days are UTC. User docs: "Stats" in `docs/wiki/Activity-and-Calendar.md`. ## Auth, sessions & OIDC The middleware splits transport by path prefix: `/api/v1/*` takes Bearer, `X-API-Key`, or a same-origin `streamline_session` cookie; everything else is cookie-only and 302s to `/login`. API keys are read-only on the identity band, and each carries a `scope` (`read` keys send GET/HEAD only, refused in the middleware before RBAC, so no role widens them) and an optional `expires_at` (an expired key gets the unknown-key 401; the `purge-api-keys` system job drops it a week later). OIDC is multi-provider, and provider trust is **two independent axes** — `email_linking` (which accounts it may adopt) and `allow_admin` (whether it may grant admin). `role.Federated` in `internal/role/` is the single admin ceiling, enforced by a package boundary that makes skipping it a compile error, plus type-resolved specs over package `auth`. **Never merge the two trust keys, never export the role field, never move the type back into `auth`** — four rounds of weaker guards were each bypassed. **Linking policy, the API-key denial list, seed admin, registration modes and what the guards deliberately do not cover: [`docs/agents/auth-and-oidc.md`](docs/agents/auth-and-oidc.md) — read it before touching `internal/auth/`, `internal/role/`, or the auth middleware.** ## Config surface Seven sections are readable and patchable at runtime (`/config/{auth,library,ffmpeg,download,metadata,system,transcoding}`, admin-only, partial-patch); everything else is file-only and needs a restart. The trust boundary and every secret are deliberately file-only — a key the API can reach is a key the API can be made to grant. Media servers, download clients, indexers and quality profiles live in the YAML config, name-keyed and hot-editable, not in ent/SQLite. A blank secret on their update keeps the stored one **unless the update moves the entry to another scheme, host or port**: then the secret must be sent again (422 `secret_required`; a `*_file` secret refuses the move, 403 `secret_file_managed`). A missing `--config` file is written at boot from `config init`'s defaults (`config.Seed`), with only `data_dir` and the library paths taken from `STREAMLINE_*` — never a secret. **Adding or removing a config field means `defaults()` AND `api/config.schema.json`.** Bounded ints go through `narrowUint8` and durations through `checkDuration` — both must reject *before* conversion, since nothing validates a request body against the spec. **The full section-by-section rules and the resource CRUD contract: [`docs/agents/config-surface.md`](docs/agents/config-surface.md) — read it before touching `internal/config/` or the `/config/*` handlers.** ## Database connections `db.Open` returns one ent client over **two pools** (`internal/db/router.go`): a single connection for every write and every transaction, and four `query_only` connections for autocommit statements starting with `SELECT` (`isRead`), all kept idle-open (`database/sql` closes all but two by default, and a reopened reader starts with an empty page cache and re-reads the schema). The split is by statement, not by method, because ent sends `INSERT … RETURNING` through `Query`; a write that reaches the read pool fails on `query_only`. A read never queues behind a write: on the homelab's Ceph volume a write waits on three replicas 25–30 ms apart and some take seconds, which with one shared connection made every page wait behind them. WAL keeps the readers consistent (each sees what was committed before it started), and nothing reads outside its own transaction while it is open — on one connection that deadlocked, which is why the `withTx` rule exists. Both pools are named on their spans and connection metrics (`db.client.connection.pool.name` `write|read`). Each connection has its own page cache, so `readConns` multiplies `cache_size`. Before `db.Open`, boot reads the database file, its WAL and the builtin engine's bolt piece completion (`bittorrent.PieceCompletionPath`) into the OS page cache (`prewarm` in `internal/server`, eight ranges at once, at most 2 minutes in all): boot queries read rows spread across the database, and re-adding torrents reads the bolt file for every piece. On network storage each cold page read is a round trip (about 26 ms on a Ceph RBD volume reached over a WAN, up to 200 ms on CephFS, where every random read pulls a readahead window), minutes in all, while large reads pay one round trip per window. ## Database backups `internal/dbbackup` owns every copy of the database: `VACUUM INTO` snapshots in `/backups` (dir `0700`, files `0600` — they hold password, API-key and session hashes), named `streamline---.db` so a listing and the retention pass read nothing but the directory. It never imports `config` or `db` (the migration runner calls it from inside `db.Open`). Four kinds, retention per kind (`backup.retention.{scheduled,pre_migration,manual}`, 0 keeps all; `pre-restore` is never pruned). **`runMigrations` snapshots before applying anything** — only when a migration is pending on a versioned, clean database — and a failed snapshot fails the boot with nothing migrated (`backup.pre_migration: false` opts out); with automated image updates nobody else is there to take the copy. A snapshot is written under a hidden `.partial` name and renamed once synced. The `database-backup` job (`24h`) snapshots through its **own connection** (`SnapshotFile`), never the server's own pools, and an automatic run skips when a scheduled backup from within 90% of the job's *current* interval exists, or every restart would rotate the week out. `streamline backup` never goes through `db.Open` (which would migrate); `streamline restore` validates first (opens, `quick_check`, clean `schema_migrations`, version ≤ `db.LatestSchemaVersion()`), detects a running server by switching the live database out of WAL (SQLite refuses while another connection is open — `ErrInUse`), keeps the replaced file as `pre-restore`, and needs `--force` where no database exists. User docs: "Backups" in `docs/wiki/GitOps-and-Kubernetes.md`. ## Downloads, imports & adoption The builtin engine owns its own peer sockets, caps its peer pool with constants (not config keys), and reads pieces itself past the page cache (`uncachedPiece`), since anacrolix's mmap reads let seeding evict the database. Its limit enforcer only stops uploading; removing a seed-complete torrent **with its files** is the download manager's call on the monitor tick, and only for a builtin torrent whose record the importer already completed. The importer verifies a probed source against the release claim before any transfer and parks a failing record `held` rather than counting an attempt. Rejecting a held release (resolve `regrab` or `delete`) marks its record `rejected`, and **every automatic pick — missing searches and feeds, all four verticals — drops what that blocklist matches before choosing** (`db.RejectedReleases`); manual search and grab never consult it. Untracked torrents the clients report are matched against the library each monitor tick and either auto-imported or filed as a `pending` proposal. Every successful import, bulk-import commit, transcode swap, rename and file delete dispatches a media-server refresh (movies, series, music and books alike) — the last two through `mediaserver.RefreshInBackground`, off the request path. A refresh names the paths it changed and the dispatcher rescans only their folders, mapped through the server entry's `path_mappings` (`from` Streamline's prefix, `to` the server's), falling back to the whole library when a folder lies outside everything the server reports; refreshes coalesce for 30 s into one request per server, and shutdown flushes the queue. A record is filed under one `anchor_episode` and links every episode its download is for through the `episodes` many-to-many (anchor included, upgrade targets too) — **every status write over that set filters on the status it moves from**, and every grab path links every episode it marks `downloading`, since the orphan sweep spares exactly what an in-flight record links. **A record's `save_path` is resolved on disk, never sent.** Streamline never sends a save path on add — qBittorrent gets `category: "streamline"` and nothing else — so the location contract is `/` and lives in the client's category configuration, which qBittorrent applies only under Automatic Torrent Management (Manual, its default, ignores it). Two torrents named alike share that folder, so **an import keeps only the files the record's own torrent lists** (`Worker.sourceFilter`; unfiltered only when the client cannot list it). **Both a completed grab and an adoption stat the payload before the import starts** (`payloadPaths` + `locate` in `internal/download/payload.go`): the convention path first, then the client's own `Torrent.SavePath` through `download.path_mappings`. At neither, adoption files a `pending` proposal and the completion sweep holds the record (`downloading → held`, one `location` reason naming both paths, no attempt counted), which every monitor tick re-checks and releases to `importing` from the path it finds (`ReleaseLocatedRecord`); `resolve import` answers 409 on it. An unverified path reaches the importer as a *retryable* ENOENT, burns all `import_max_attempts`, and lands `failed`. `POST /activity/history/{id}/retry` is the only exit from `failed` — `resolve` takes `held` only and the importer's scan takes `importing` only. **Every grab checks free space first** (`download.space_check`, default on, `space_reserve_gb` 10): the release plus what the client's torrents still have to write plus the reserve must fit in the free space the **client** reports, else `ErrNoSpace` — `grab_rejected` by hand, no `grab_failures` strike automatically. A check that cannot be made lets the grab through. **Uploads** (`POST …/upload` on an album, track, artist or book, previewed by `…/upload/preview`) are client-less `uploaded` download records imported by `copy` from `/.uploads`, whatever `library.import_mode` says; a target with a live record is refused `409 upload_conflict`. **Engine internals, port rebinding, hold/resolve semantics, the free-space check, uploaded records and their staging sweep, the adoption proposal lifecycle, the retry contract and Plex section discovery: [`docs/agents/downloads-and-imports.md`](docs/agents/downloads-and-imports.md) — read it before touching `internal/bittorrent/`, `internal/importer/`, `adopt.go`, or `internal/mediaserver/`.** ## Selective file download `download.selective_files` (default **false**, runtime-toggleable) gates grabbing only the files a release is wanted *for*; off is bit-for-bit the whole-torrent grab. Two flows — a `.torrent` decodes the metainfo before add, a magnet parks the record `selection_state: pending` until `RunSelectionPass` resolves it. `replace_mode` decides the wanted set; `selection_mode` on `TorrentSession` is what survives a restart. **Flow rules, per-client quirks and the widen semantics: [`docs/agents/selective-file-download.md`](docs/agents/selective-file-download.md) — read it before touching `internal/download/manager.go`, `selection_pass.go`, or any client's `SetWantedFiles`.** ## Quality scoring `internal/quality` is the pure scoring engine (no config/db imports); context builders live in `internal/quality/qualityctx`. A release is gated by resolution **band** first (`preferred_resolution` is a hard ceiling), then by summed custom-format score against `min_score`. `quality.ReplacesFile` is the single predicate deciding whether one release replaces one file. Builtins describe a release and never judge one — opinionated rules belong in `custom_formats`. Every `indexer.Manager` search takes **two name sets**: `titles` is queried (one request per title per indexer) and `aliases` is only matched against. `Movie.aliases`/`TVShow.aliases` hold the providers' translated and alternative titles, which is what stops a library holding a work under its localized title from discarding every release named in another language. **Never pass an alias list as `titles`** — that multiplies every search by its length against rate-limited trackers. **Condition semantics, the release-vs-file evidence asymmetry, indexer result filtering, profile assembly and the upgrade paths: [`docs/agents/quality-scoring.md`](docs/agents/quality-scoring.md) — read it before touching `internal/quality/**`, the RSS scanners, or the profile/custom-format handlers.** ## Native indexers (Cardigann definitions) `internal/cardigann` reads the Cardigann YAML definitions Prowlarr and Jackett describe trackers in, so a stack can drop both. **Today it is the converter, a catalog read from upstream at run time, and the engine** (`internal/cardigann/engine`) — nothing of upstream ships in the binary or the repository: a `protocol: cardigann` indexer — added from the catalog (`GET /cardigann/catalog[/{file}]`, Settings → Indexers → Add built-in, a form generated from the definition's settings) or the YAML; **secret settings never leave the API** (`secrets_set` names them; an edit sending one empty keeps it, unless the definition changes), and any setting may instead be read from `_file` (`CardigannIndexer.Resolved()`, the only form the engine, the check and `config_hash` read), which is **file-only** — the API refuses a `_file` key, a value for a file-held setting and a `site`/`proxy` move while a file holds a secret; on a read-only instance *Add built-in* still opens the form and shows the entry as YAML to paste — **is pinned to the upstream commit of the definition it runs** (`cardigann.commit`, written by the API from the catalog on create or definition change, never taken from a request; the converted JSON is kept per commit under `/cardigann/v/`, read by `definitions.Pinned`, which never reaches the network and is what `config` checks against, and `definitions.Resolve`, which fetches that commit's tarball when it is not kept — every caller that pins or edits an entry keeps or resolves first), **logging in when it says to** — the engine runs one login (`post`/`form`/`get`/`oneurl`/`cookie`), `internal/indexer` decides when: before the first request without a live `IndexerSession` row, again once on `ErrLoginRequired`, never during a failure's backoff (a wrong password retried per RSS tick bans the account), and a captcha only through a person (`POST /indexers/{name}/captcha` → `/login`). A browser challenge (Prowlarr's Cloudflare/DDoS-Guard detection, in `Engine.do`, so every request) goes to the FlareSolverr-compatible `cardigann.solver_url` once, its cookies and user agent kept in the session; `cardigann.proxy` routes a tracker (and the solver's browser) through one egress. The `cardigann-update` job (boot, then 24h) reads Prowlarr/Indexers' newest commit (`snapshot.Latest`/`Fetch`, a GitHub tarball) into the **in-memory** catalog (`definitions.Install`; `update.Ensure` fetches on demand when nothing was read yet, `catalog_unavailable` when GitHub cannot be reached) and moves each entry to the catalog's version of its definition: a change that alters the hosts it names or its login is **held** (in memory, forgotten on restart and found again by the next run) until `POST /cardigann/updates/{file}/approve`, anything else is kept and pinned (`config.PinCardigann`), a version equal to the catalog's stays on its commit, and the kept commits no entry names are pruned; on a **read-only config every change is held** and the SPA offers the new `commit` to copy into the file. A definition upstream drops or the converter refuses leaves entries on the version they are pinned to. There is no format version on a kept definition: it sits under `convert.Version`, so a converter change re-converts each pinned commit on first use. Its ids follow the definition's search mode and an id search leaves the term out, its links resolve through the download block at grab time and are **fetched by the engine itself** (`indexer.ResolveDownload`, owner found by host first — the indexer's proxy, solver, pacing and session, never the shared client), and `checkReleaseSource` admits its site's host. **A definition the entry can no longer run is a warning at `Load` and a failed search, never a failed boot**: `CheckCardigann` (decode, `convert.Check`, site, settings) runs on API writes and pins only. An edit moving an entry's `site` or `proxy` asks for its secret settings again (`secret_required`), and the trust hold reads scheme-relative `//host` paths as hosts too. A session row carries `config_hash` (definition, site, proxy, settings) and one earned under other settings is read as none, so no edit `Forget`s it; a parallel login adopts the session the first stored; `keep` is best effort. A fetch reads `definitions/v12` with `v11` as the per-file fallback (Prowlarr's own, the newest copy of a file winning) and rewrites .NET spellings through `internal/cardigann/convert` — keyed by upstream **file name**, not id; `convert.Version` moves with any change to what the converter emits. Tests use three unmodified upstream fixtures (`internal/testutil/cardiganntest`); the whole upstream catalog goes through the engine only in the weekly `cardigann-upstream` workflow (`STREAMLINE_CARDIGANN_UPSTREAM=1`), so `task test` never needs the network. **Compiling on RE2 is not the bar — meaning the same is**: `\w \d \s` are spelled as the Unicode sets .NET means, and what RE2 cannot express with .NET's meaning (lookarounds, `\b`, `$`, …) is tagged `engine: regexp2` at conversion, never chosen by trial at runtime. Templates are re-quoted and validated by Go's parser, so the engine must pass `.Config`/`.Result` as maps; dates are parsed with `cardigann.ParseDate`, never bare `time.Parse` (a layout without a year takes the current one, as in .NET). Prowlarr's load-time defaults are baked in by `convert.clean`; what cannot be (null renders as `""`, Newtonsoft's JSON stringification, URL-encoding only substituted values) is the file's **engine contract**. Each converted definition keeps its `source` block (upstream path, sha256, modification notice). **Fetching and updates, the regex/date/template rewrites, secret flagging and the licensing basis: [`docs/agents/cardigann.md`](docs/agents/cardigann.md) — read it before touching `internal/cardigann/**`.** ## Media lifecycle & library queries Twelve optional probe columns hang off `MediaFile`; `probed_at` stamped means all twelve were recorded, so **a migration adding a probe column nulls `probed_at` in the same file**. `MediaEvent` has seven optional owner edges (movie, episode, tv_show, artist, album, book, book_series) and exactly one is set; an album import is one event on the album, never one per track, and every detail page's History tab reads its owner's slice of `GET /activity`. `episode.status` is `wanted | downloading | importing | paused | available | skipped`, and every path that grabs must mark its episodes — **adoption included, which lands at `importing` and needs its own record-scoped mover** (`MarkWantedRecordEpisodesImporting`), since the completion sweep's one only moves `downloading`/`paused`. The series list filters, sorts and pages in SQL with a per-show counts rollup — never the episode tree — and derives a show's downloading/importing state from `episode.status`, so an unmarked episode is a show missing from those filters entirely. An episode with no air date is **unaired, never missing**, and five places split aired from unaired — they must agree or a show matches a filter with nothing missing on its page. Specials (season 0) are out of every show-level number — seasons, episodes, the missing filter, the episode sort — and must be out of all of them at once; the specials season still renders with its own counts. Cast is written inside the title's transaction; the **person biography fetch is not** — it runs after the commit, from the service layer that holds the providers, and `internal/db` stays free of any provider dependency. A person is enriched **once, ever**, keyed on `Person.details_fetched_at`, and no failure there may fail the import. **A library root that looks gone costs nothing** (`internal/library/roothealth`): it is offline when it has tracked files and none of a sample of at most 20 can be stat'ed. `drift-check`, the orphan scans, tag sync, transcoding, imports and automatic grabs leave it alone, by-hand changes to its files answer `409 code: library_offline`, and `GET /system/info` carries `library_offline` for the pill and the Settings → Library banner. The ways out are path migration (music and book roots re-point only) and `POST /library/roots/{root}/accept-missing`. **A file gone from disk keeps its row** (`MediaFile.missing_confirmed_at`): past the grace window the title stays `available` with a "missing from disk" mark until `library.missing_file_redownload_after` (default `168h`, `0` at once, `off` never) and a file that returns heals. While `missing_since` is set nothing touches the file on its own schedule, every question of "does this title hold a file" answers through `db.PresentFiles` (imports, adoption, uploads, RSS upgrades), a confirmed row whose owner holds another file goes on the next tick whatever the wait says, and `POST /library/missing-files/forget` removes the row and stops monitoring the title. One word everywhere: missing. **Probe semantics, the event hooks, cast ingest and person enrichment, re-identify ordering, the in-flight marking paths and the list push-down: [`docs/agents/media-lifecycle.md`](docs/agents/media-lifecycle.md) — read it before touching status transitions, `internal/events/`, or `internal/db` list queries.** ## Music Third vertical, mirroring the TV tree: `Artist → Album → Track`, with `MediaFile.track` as the file edge. Metadata is MusicBrainz (`metadata.NewMusicBrainz`, no key, a 1 req/s limiter and a mandatory User-Agent; `metadata.musicbrainz_url` points it at a self-hosted mirror — the server root, `/ws/2` is appended — and `metadata.musicbrainz_interval` paces it; both on Settings → Metadata, read at construction, restart required) and an album is a MB **release-group**; an album cover is resolved by `music.Service.resolveCover` (`covers.go`) from the first source that yields an image: a picture embedded in one of the album's files (`audiotags.Picture`), a `cover|folder|front.jpg|jpeg|png` beside the first media file (neither ever the tag writer's own copy, told apart by its recorded hash), Deezer by the canonical release's barcode (`Album.barcode`, kept from MusicBrainz at add time; `metadata.NewDeezer`, keyless, 10 req/s), Deezer search (only when the first hit's artist matches, via `library.TitleMatches`), then the Cover Art Archive release-group front. Local sources store through `posters.Manager.Put` and run even when a cover is cached (the album's own art outranks a downloaded one); the remote ones only when none is, and a failing source falls through to the next. It runs in the background (`ResolveCoversInBackground`, behind the `music.CoverResolver` interface that bulkimport and the importer call after adoption/import) and never fails the add; `RefreshOne` re-resolves only albums with no cached cover. Span `music.resolve_cover` carries `cover.source`, counter `streamline.music.cover_resolutions{source}`. Library covers go through the posters proxy under the `albums` kind and artist photos under `artists` (a monogram when there is none). `Add` is **two-phase** (below): the request fetches only the artist and its release-group list and returns album stubs; a background worker fills the tracks. `Track.duration` is **seconds** (MusicBrainz reports milliseconds; the client divides) and the spec, SPA and ent field all agree on that. Music profiles are their own config family, `music_quality_profiles[]` + `music_quality_default_profile`, edited through `/music/quality-profiles` (never `/config/*`; the first profile created becomes the default, `POST …/{name}/default` moves it). A profile ticks **tiers** (`hires > lossless > high > standard > low`, stored best first) and names the `preferred` one, the upgrade ceiling; there is no `other` tier. `internal/quality/audio.go` is the one classifier (`AudioTierOf`, `AudioFine`, `MusicProfile.Accepts/Score/Replaces`), and the SPA's `releaseTier()` is written to the same rule. `library.ParseMusicRelease` reads codec, bit depth, sample rate, rate and VBR preset into `Facts` plus the display label `Source` (`FLAC 24/96`, `MP3 V0`); `JudgeMusicRelease`/`ScoreMusicRelease` take a `MusicScope` (a discography is rejected for an album search and kept for an artist-level one) and a release whose name states no quality stays in the list, rejected, instead of landing in a catch-all tier. `MediaFile.quality` is the tier (`hires|lossless|high|standard|low`, empty when it could not be established), `format` the extension. Album art for a title **not in the library** (the add flow, a request panel) comes from the one lookup proxy, `GET /posters/lookup/{artists|albums|books}/{key}/poster.jpg` (`internal/artwork`, see HTTP Routes); artist photos in the library are the `artists` poster kind. `POST /music/albums/{id}/search` and `/grab` are the manual search and grab (member-only; the grab body is a search item's `release` handle intact, answered with an empty 202). `POST /music/albums/{id}/release-files` takes the same body and lists the release's files against the album's tracks before any grab (`download.ReleaseFiles`: the grab's own `checkReleaseSource` + `resolveTorrentSource` + `decodeTorrentFiles`, nothing added to a client; `library.MatchTrackByName` reads disc folder/prefix, number, then a loose title off each name, so it is a preview — the import matches by tags). One indexer request per call, so only ever on a person's tap; a magnet is `422 files_unavailable`, an indexer that does not hand a readable `.torrent` over `422 torrent_unreadable`. A grab without `replace_existing` already fills only the tracks without a file (`planAlbumImport`), which is the SPA's *Download missing tracks only*. A discography has it too: `POST /music/artists/{id}/release-files` (`music.Service.ArtistPackFiles`) groups the audio by `library.PackAlbumDir` and matches each folder with `library.MatchPackDir` against `ListPackAlbums` — the pack importer's own two functions, moved out of `importer/pack.go` so the preview cannot drift from the import — then each file to a track by name; a folder naming any other album of the artist is reported skipped. TV has the same pair for packs: `POST /series/{id}/release-files` and `/series/{id}/seasons/{number}/release-files` (`tvshow.Service.PackReleaseFiles`) match each file with `library.MatchEpisodeInSeason` over video files ≥ `library.MinEpisodeSize` — the selective keep-set's own candidate rule and the importer's matcher, so the preview is what a grab keeps — and mark an episode `wanted` by `wantedAiredEpisodes`, what a grab without replace fetches; *Download missing episodes only* is that grab, and `takesPackFile` leaves every episode holding a file in place. `GET /music/artists` is filtered (status, monitoring, `genre`, query), sorted, paged and faceted in SQL (`/music/artists/counts`, the faceted-counts convention of `/series/counts`, plus `genres`, and `letters`: the artists per letter under every filter, from which the SPA works out the page a letter opens on) and its items carry their albums as tiles with no tracks. The name sort is by the displayed name, never `sort_name`, through the `sort_letter` SQL function first, so `#` (anything not starting A–Z) comes before A and each letter is one run; `sortLetter` in `web/app/lib/music-books.ts` must file names the same way; the detail, patch and refresh responses carry the tree, each track with its `featuring` only — `writers` and `artists` come from `GET /music/artists/{id}/track-credits`, which the Discography tab fetches behind the page (under its own cache key outside the `["music"]` prefix, refetched only when hydration ends, so a monitor toggle does not reload megabytes of credits), and `GET /music/albums/{id}`, which fills the open release until that answers, since every track's credits are hundreds of uncached page reads on an artist's first load on the homelab's Ceph volume (about 30 ms per read), and a kind filter over the track index saves nothing because the kinds share pages; the `(kind, track)` index is what makes `featuring` alone cheap. **Adoption** is `ImportScan.kind=music` through bulkimport: every folder directly holding audio files is one candidate (its `CD1`/`Disc 2`/`Disk1` subfolders fold into it, `library.DiscFolderNumber`, unless the parent is the scan root), tags are read with `internal/library/audiotags` (TagLib; the scan never writes, and a commit writes tags only when it asks, `write_tags`, default off), the majority `(albumartist|artist, album)` pair is matched through `MusicProvider.SearchReleaseGroups`, and commit adopts in place (`source=orphan`) after seeding the artist through `music.Service.Add` with `Monitor: "none"`; `mode=rename` is refused for music **and book** scans (`ErrRenameUnsupported`, 422, checked before the Hardcover key), each review row carries `tagged_year`/`format`/`size` and a live `artist_id`, an `existing` row that joins the library's album ends `attached` not `created`, a release group missing from the artist's discography fails that album with a message, commit adopts the parent's own files and every disc subfolder's, a file with no disc tag taking its folder's disc number, and `AdoptAlbumFiles` never moves an album out of `downloading`. Music **does** refresh media servers (`mediaserver.KindMusic`): album and pack imports, music bulk-import commits, renames, track-file deletes and artist deletes-with-files all go through `Refresher`/`RefreshInBackground`. Plex rescans only `artist`-type sections (path match first, then every `artist` section; none at all is an error, never the movie section), Jellyfin/Emby refresh everything. **Subsonic** is `internal/server/subsonic` at `/rest` (API 1.16.1 plus OpenSubsonic: every response carries `openSubsonic/type/serverVersion`, `getOpenSubsonicExtensions` answers **before** authentication as the spec requires; opaque ids `ar-/al-/tr-/pl-/sh-/rs-`, cover art straight from the posters service), authenticated against `User.subsonic_password` — generated by `/account/subsonic-password`, stored recoverable because the token scheme is `md5(password+salt)`, never valid for web or REST auth; non-API errors are logged before the generic error-0 envelope. Everything but podcasts, chat, jukebox and video is served. **Accounts are managed in the web UI only**: `createUser`/`updateUser`/`deleteUser` answer error 50 (a Subsonic credential is stored recoverable and travels in every request, so it never mutates identity, like an API key), `getUser` reads one's own account (any for an admin), `getUsers` is admin-only, and `changePassword` sets the **caller's own** Subsonic password only (`auth.SetSubsonicPassword` through `subsonic.Accounts`, login policy applied; nobody else's, admins included). Per-account Subsonic permissions are `User.subsonic_can_{stream,download,manage_playlists,share,rate,change_password}` (default true) and `subsonic_max_bit_rate` (forces a transcode even over `format=raw`, best effort without ffmpeg), set by an admin through `PATCH /users/{uid}` `subsonic` (`auth.UserPatch.Subsonic` → `db.SubsonicAccessParams`; the SPA's `UserSubsonicCard`); they gate Subsonic calls only, never web/REST. Per-user state lives in ent: `MusicAnnotation` (star, rating, play count, exactly one of artist/album/track set, unique per user and target; a scrobble bumps the track, its album **and** its artist, so `frequent`/`recent` page album rows rather than aggregating), `Playlist` and `PlayQueue` (ordered `track_ids` JSON, duplicates allowed, dead ids skipped on read; `songIndexToRemove` and the queue's `current` index count the **playable** list, so the stored list is reduced first), `Bookmark`, `Share`, `RadioStation` (admin-only writes; never fetched server-side). Albums and artists are served only with a playable track (`listed()`/`listedArtist()` in every list, search, index, starred and genre query; `songCount`/`albumCount`/duration count those): a followed artist's whole discography is album rows, and clients showed every unheld one as an empty album; `newest` orders by an album's newest live file (`byLatestFile`), since the rows are created with the discography. Every handler that returns songs/albums/artists answers through `h.respond`, which decorates them with the caller's marks in one read per kind. Writes and transactions share one connection: inside `withTx` use the tx client only. Now playing is in memory (`scrobble submission=false`). `stream` sends the original unless `streamPlan` asks for MP3/Opus/AAC (a known other format, or `maxBitRate` under the file's), through `ffmpeg.CLI.TranscodeAudio` (≤ 8 concurrent, then the original; `timeOffset` seeks a transcode; a failure before the first byte still answers an envelope). Lyrics come from a sidecar `.lrc` then the embedded tag (`audiotags.Lyrics`). `startScan` runs `drift-check` (admin). **Shares** are public at `/share/{token}` (`SharePage`, in `authExcludePaths`; 128-bit token, re-read and expiry-checked per request, a visitor reaches only tracks the share expands to; a link carries its owner's **current** rights — it 404s once the owner loses `can_share`, its audio once they lose `can_stream`, and `createShare` needs both; the page has no script and shows the owner's display name, never the email). User docs: `docs/wiki/Subsonic.md`. **Acquisition**: `library.ParseMusicRelease`/`ScoreMusicRelease` (tiers, discographies rejected for an album scope), `indexer.Manager.SearchAlbum` (cat 3000), `music.Service.SearchAlbumReleases`/`GrabAlbumRelease` over `download.GrabAlbum`, which links the record's `album` edge; `Store.SetAlbumStatus` and `RecordAlbumImportSuccess` filter on the status they move from. **A terminal import failure parks an incomplete album at `failed`** (one holding every track goes back to `available`; the orphan sweep splits on the same `completeAlbum` predicate) with `Album.failure_reason` (`RecordImportFailure`, a pack's linked albums too, and a held record rejected without requeue), mirroring movies: the missing searches, RSS and `search-now` read `wanted` only, so a failed album is never re-grabbed automatically — it used to go back to `wanted` and the next pass grabbed the same release again. A manual grab or pack (`SetAlbumStatus` from `wanted|paused|failed`, which clears the reason) and `POST /activity/history/{id}/retry` (back to `downloading`) take it out; the artist rollup and its SQL twin count `failed` as `wanted`. The album importer refuses a **CUE disc image** (`library.CueImage`: the folders holding a `.cue` hold at most half as many audio files — `.ape`, `.wv` and `.wav` included — as the album has tracks, so one image per disc and one per vinyl side are both caught; a one-file-per-folder rule let `Side A…D.flac` 2LP rips through to fail as unmatched) as `ErrCueImage`, terminal, *before* matching — the image's tags can name track 1, and the whole disc would be imported as that track. **One image is never downloaded to be refused**: a name stating it (`image+.cue`, `img+cue`; never `tracks+.cue`) is rejected by `JudgeMusicRelease` at either scope; an album grab of a `.torrent` reads the file list it already decoded and refuses with `download.ErrCueImage` (`grab_rejected`) before the client is contacted, which `searchAlbum` answers by trying the next accepted release in the same pass, no strike; a magnet is checked by the monitor's `RejectCueImages`, run before `CheckStatus` each tick, once its client lists the files (each record listed once, remembered in memory) — the torrent and its data are removed, the record is failed **rejected** (the blocklist) and the album requeued `wanted`, no strike. The album importer (`importer/album.go`) matches files to tracks through `library.TrackMatcher`: by tagged `(disc, track)` then title, unless more files name *another* track's title at their number than name the one there — an edition with interludes between the songs — when every file matches by title alone (a title naming no track of the album is no evidence either way, so a translated release keeps its numbers; the importer's `track` hold check catches every title that differs and every file it could not place on a single album, on a pack only a minority when at least half agree, and the Resolve dialog then lets a person pick each file's track, `GET /downloads/{id}/track-plan` and `track_assignments` on the resolve); bulk-import adoption and both release-files previews build the same matcher over the whole release. An empty plan whose files all hit filled tracks is `ErrTracksHeld` (an `ErrDestExists` to `classify` and the pack importer, with a message that says what happened), renames per `library.music_naming` (`{Artist}` `{Album}` `{Disc}` `{Track:02}` `{Title}` `{Year}`) and queues each file for its tags, which the engine writes before the media servers are told. The tags themselves are the **music tag engine** (stub below). **Automation**: `rss/music_feed.go` gates on 3000–3999 *after* 3030 (audiobooks) is carved out, `SearchMissing` and `RefreshStale` (10 artists per run) are the `music-missing-search` and `music-metadata-refresh` jobs (books have the `book-*` pair; RSS for both still rides `movie-rss-sync`). **Requests**: `media_type` artist and album carry `media_mbid` with `media_id=0` (artist MBID, release-group MBID; there are no author requests); the active-uniqueness index is partial per key (`media_id <> 0` / `media_mbid <> ''`); approving an artist adds it monitored `all` under the reviewer's profile, and the request flips to `available` on the artist's first imported album (`MarkRequestsAvailableByMBID`), not when the discography is complete. **An album request's `artist_mbid`/`artist_name` are the requester's hints and are never trusted**: approval and `GET /requests/{id}/metadata` resolve the artist from `GetReleaseGroup(media_mbid).ArtistMBID`, approval overwrites the stored pair with the verified one (`ApproveAlbumRequest`, one statement) and keeps the typed wording in `requested_as`; a missing artist is added with `Monitor: "manual"` and only that album monitored, an artist already held keeps its own profile, and a release group not on the artist (after one `RefreshOne`) answers `422 code: album_not_found` with the request left pending. `GET /music/search/releases` (`MusicProvider.SearchReleaseGroupsFreeText`, memoised 10 minutes) is the release-group picker for the import review and the request flow. **The music design backend** (contract `docs/superpowers/specs/mb-design/contract-music.md`). **`Add` is two-phase:** it costs `1 + ceil(n/100)` MusicBrainz calls (a 10-minute, singleflight, 32-entry `ArtistDetails` cache shared with `GET /music/search/{mbid}`, so a lookup followed by an add costs one set), creates the artist, its `ArtistMember` rows and one stub `Album` per release group in one transaction, and returns with `hydrating: true`. `Remix`, `DJ-mix`, `Mixtape/Street` and `Demo` release groups are type `other`. The discography browse sends `release-group-status=website-default`, so a release group with no official release — a fan remix compilation, a bootleg live recording — is never an album; `RefreshArtist` prunes the stored albums a refresh no longer lists, deleting one nothing was done with and unmonitoring one holding files, history, events or a co-artist, unless pinned (an empty listing prunes nothing). `music/hydrate.go` then fills each album — tracks, label, country, media, bonus/featuring flags, and, when the heavy release call (`inc=…artist-rels+recording-level-rels+work-rels+work-level-rels+place-rels`, response cap `maxMusicReleaseResponse` 8 MiB) succeeds, credits, performers, writers (each with its part: composer, lyricist, writer, arranger) and studio; the album's credit and performer rows fold every recording's in for display, and each track also keeps its own recording's credit and performer rows, which is what the tag writer reads. A heavy success stamps `Album.credits_version` (`library.MusicCreditsVersion`); an album holding files with an older one is fetched again in the background like a NULL `genres`. The release-group call carries `inc=…+genres` and stores the top five as `Album.genres`; hydration also keeps `Album.original_date` (the group's `first-release-date`) and `Album.edition_date` (the canonical release's `date`), both as MusicBrainz writes them (`YYYY`, `YYYY-MM` or `YYYY-MM-DD`; `release_date` stays the parsed one that sorting and upcoming detection read). A person can override the pick per album (`Album.edition_mbid`, `GET` and `PUT /music/albums/{id}/editions`, re-hydrating at once; refreshes keep it, and an edition MusicBrainz stops listing falls back to the pick). **A hydration that leaves every track holding a file moves a `wanted` or `failed` album to `available`** and clears `failure_reason` (`settleCompletedAlbum`, in `SetAlbumHydration`'s transaction; a `downloading`/`paused` album is its record's): re-picking the edition the files are of used to leave a failed album failed, with an `ErrTracksHeld` reason about tracks it no longer lacked, and nothing searches a failed album. It never demotes. A release-group lookup embeds at most 25 releases and does not say when there are more, so one that returns 25 is read again from the paged `/release` browse (`mbLookupReleaseCap`), for the canonical pick and the editions list alike. The canonical release is the earliest **official** one — among releases out the same day the fewest audio tracks, then the fewest audio discs, then the first listed (the standard edition, not a deluxe or a CD + DVD), and `fillRelease` skips video discs (`isVideoMedium`: DVD, Blu-ray, VHS…) unless the release has nothing else, since Feathers & Flesh's live DVD became eight tracks no audio release carries — a partial date ranking after every fuller date inside its period (`releaseSortDate`: compared raw, `2022` sorts before `2022-10-07`, and Omens took a year-only Russian pressing with three bonus tracks over the standard edition, so every 10-track release looked 3 files short), and a re-hydration (`matchStoredTracks`) keeps each recording's row wherever the edition places it, moving a held file's row to its new position and folding the empty twin an earlier re-pick left, never hands a held row to another song at its position (a same-title recording merge excepted), and deletes every stored track the release no longer lists unless it holds a file. **`genres` NULL marks an album hydrated before genres were fetched** — hydration always writes a list, `[]` when there are none: `HydrateAlbum` fetches such an album best effort (a failure is logged and returns nil, since the caller has its tracks; the importer's `hydratedAlbum` calls it for that case too), the sweep's `details` leg and the worker's idle refill backfill the ones holding files (`ListAlbumsAwaitingDetails`), and a release group MusicBrainz no longer knows keeps its stored facts and gets `[]` (`storedDetails`) rather than being asked for forever. A failed heavy call retries once with the light `recordings+artist-credits+labels` inc and stamps `metadata_fetched_at` but not `credits_fetched_at`; the credits sweep (`RefreshStale`, 20 a tick, releases above `db.MaxCreditsTracks` skipped) retries it later. **`metadata_fetched_at` NULL is the one "tracks not here yet" marker**: `tracks_pending` on a tile, `HydrateAlbum` (the synchronous, idempotent entry the album importer and bulk-import adoption call before matching files to tracks, also removing the album from the queue), the sweep, and the eligibility predicates all key on it — `ListEligibleAlbumsForSync` and `ListWantedAlbums` skip an unhydrated or **unreleased** album (the music twin of "unaired is never missing"; an undated album is released, and `upcoming` is derived from a wanted album dated after today, never stored). The worker is one process-wide goroutine behind `defer RecoverPanic` (its `onPanic` resets the queue, or the dead worker's albums would stay marked queued forever), serves one album per artist in turn, monitored and newest first, backs off for a `Retry-After` and refills from the database when idle (restart recovery, plus `ResumeHydration` at boot). **Three MusicBrainz classes share the one limiter** (`metadata/mblimiter.go`, one call per `metadata.musicbrainz_interval`, default `1s`, `0s` for a mirror with no limit): an interactive caller takes the next token; a context marked `metadata.Prefetch(ctx)` (the lookup art proxy settling a search tile) takes one only while no interactive caller waits, with no gap; a context marked `metadata.Background(ctx)` (the worker, the credits sweep, the scheduled refresh) takes one only while neither waits and not within `backgroundGapFactor` (2) intervals of its last, so bulk hydration is about 4 s per album at the default and a lookup never queues behind either. A 503 from MusicBrainz is `*metadata.RateLimitedError` (`Retry-After`, a minute when absent or zero), which the handlers answer as `429 rate_limited`; it also **holds the prefetch and background classes** for that wait (they fail with the remaining wait, unsent), since calls sent into a refusal are refused too and a page of search tiles kept a person's own lookup landing on 503s — interactive calls are never held, and the SPA never retries a 429 (`lib/query.ts`). The warning names MusicBrainz's `X-RateLimit-Zone`: `global` is MusicBrainz shedding load for everyone. A login lockout answers `too_many_attempts`, never `rate_limited`, which the SPA renders as a provider limit. `hydrating` stays true only while an album has no tracks, or has tracks whose credits are missing and landed under an hour ago and fit the sweep's size cap, so the SPA does not poll for a day over a release whose heavy call keeps failing. **A release group is one album however many artists it credits.** `albums.mbid` stays globally unique and `Album.artist` is the **owner** (folder, naming, `ALBUMARTIST`, hydration rotation, renames, monitor policy); `Artist.co_albums ↔ Album.co_artists` lists it under every other library artist whose discography carries it — `CreateArtist`/`RefreshArtist` link through `linkSharedAlbums` instead of inserting (a collab used to fail the whole add on the unique index). Everything that *lists* an artist's albums reads owned ∪ co (`db.AlbumOfArtist`, `db.ArtistAlbums`, `artistHasAlbum` for the status twin and the title filter; detail, tiles, packs, search-now, album-request `findAlbum`, an import flipping co-artists' artist requests, Subsonic); policy re-applies and renames stay owner-only. `db.DeleteArtist` hands every shared album the artist owns to its lowest-id co-artist in the delete's own transaction, files in place; `Service.Delete` leaves those albums' files and covers alone and re-queues their tags (the album artist changed). Each track's own credited artists (before any `feat.`) are `MusicCredit` kind `artist`, written with `featuring` from either release call; they lead the `ARTIST` tag unless they are the owner alone, and the wire carries them as `MusicTrack.artists` (the album's linked artists as `artists`, only for a shared album). **Monitoring is a policy** (`Artist.monitor` all/future/manual/none, no `monitored` flag): `PATCH` re-applies it to the existing albums in one transaction and never touches `status`; new release groups on add and refresh follow it (`future` = undated or dated after today); **`all` and `future` only ever monitor the release types in `library.music_monitored_types`** (runtime-editable, default `[album, ep, single]`, Settings → Music), the others land unmonitored — `monitoredFor` at seed time and `db.applyMonitorPolicy` on a re-apply share the rule, and the setting reaches existing artists only through a re-apply (`PATCH monitor`, or `POST /music/monitored-types/apply`, admin, which re-applies every `all`/`future` artist in one transaction but **skips every album whose flag was set by hand** — `PATCH /music/albums/{id}` and album-request approval set `Album.monitor_pinned`, and only a `PATCH monitor` on the artist clears the pins, since choosing a policy is starting over); `Album.monitored` stays the only flag searches, RSS and imports read. The artist's own `status` field is a rollup (`wanted|downloading|available`, over monitored, non-upcoming albums, `music.artistStatusAt`). The list's status filter and its facet counts ask a different question, what an artist **has** (`db.artistHas`): `wanted` is any monitored, released album wanted or failed, `downloading` any downloading or paused, `available` neither, so an artist can be under both of the first two and the counts overlap. **The overview is Wikipedia in both SPA locales**, fetched once (`details_fetched_at`; a refresh re-fetches only an empty English one) from the Wikidata item MusicBrainz links, stored as `overview`/`overview_source` (en) and `overview_fr`/`overview_source_fr`, served for `?lang=` with English as the fallback and the source following the text actually served. The **artist photo** is Deezer by MusicBrainz's own link unless another MusicBrainz artist links the same page (`metadata.ArtistLinkProvider.DeezerArtistShared`: Deezer merges namesakes, and the 60s UK Nirvana got the grunge band's face), else by name only when exactly one hit folds equal to it (never "first hit wins"), and what that leaves unsettled takes MusicBrainz's own Commons `image` rel (`ArtistArt`) or no photo — the same rule as the lookup proxy, its MusicBrainz calls at `metadata.Prefetch` priority off the add request; stored under the `artists` poster kind (`posters.Fetch` sends streamline's User-Agent, which Wikimedia requires); lookup art for hits not yet added is `internal/artwork`'s, fed by `artwork.Remember` from `SearchArtists` and `LookupArtist`. `GET /music/tracks` is the search surfaces' track list, the `/episodes` twin. **Searches and packs.** `POST /music/albums/{id}/search` keeps every release the profile rejects, flagged with a reason ("tier not in the profile", "quality is not stated in the release name", and "names a different album or artist" when `library.ReleaseNamesOtherWork` reads another album or artist off the name (a name it cannot read as `creator - title` counts as another work only when it neither opens on the artist nor holds the album title anywhere: `Williams.Joshua.Rammstein.Relaxing.Acoustic` is a covers album the indexer matched on the artist's name) — read with and without the bracket groups a tracker opens a name on (RuTracker's `(Groove Metal) [CD] Artist - Album - 2026, FLAC`, whose first ` - ` otherwise made the whole prefix the artist), `&` and `and` equal; `ReleaseNamesWork` is its positive twin and the automatic `namingAlbum` filter, so a scene name with no ` - ` is grabbable too, and indexer titles are HTML-unescaped at both adapters (`Feathers & Flesh`) — an indexer answers loosely, and a lossless "Artist - Other Album" otherwise scored like the album) and drops a discography (rejected by scope, not listed); `/music/artists/{id}/browse` is the inverse — only discographies whose artist part (`library.SplitCreatorTitle`) folds equal to the artist. `search-now` on an album/artist/track is a 202 that runs `searchAlbum` in a goroutine (waiving the cooldown and failure cap by not consulting them; an available, upcoming or in-flight album is a no-op). **A discography pack is a `DownloadRecord` with an `artist` edge and the albums it expects on the `albums` many-to-many** (`download.GrabArtistPack`: it links monitored, released, hydrated, wanted-or-paused albums, refuses with `ErrNoWantedFiles` before touching the client when none qualify, and marks them `downloading`); `importer/pack.go` groups the audio files by folder (a `CD1`/`Disc 2` subfolder belongs to its parent), matches each folder to a **linked** album by folded title and, when it names one, year — no provider call — and runs the ordinary album import for each with `KeepRecord`, then `CompletePackRecord` completes the record and returns the linked albums no folder named to `wanted` with no strike. Rename (`music.Renamer`, `library.Renamer` keyed by artist) renders `library.music_naming` with the importer's own helper from database metadata only; `DELETE /music/tracks/{id}/file` checks every path against `library.music_path` first (`409` if any lies outside, nothing deleted) and moves a `available` album to `wanted`. ## Music tags Streamline writes what the library knows into every music file through TagLib (`go.senan.xyz/taglib`, WebAssembly on wazero, CGO-free; only `internal/library/audiotags` imports it). `audiotags.Write` **never modifies a file in place** — it writes a sibling copy, reads it back, and renames it over the original — and gives the copy the original's mode, extended attributes and owner (`ErrOwnership` when it cannot, the original untouched); **every change to an album's track files — a tag write, the importer's transfers, a rename, a delete — holds `library.LockAlbumFiles(albumID)`**, and the writer re-reads its row under it, or a copy taken before an upgrade or delete is renamed back over the path. It replaces only the keys it is given (empty deletes), deletes `library.music_tags.strip`, and never touches `preserve`, which wins over everything; the key set is `library.AlbumTrackTags`, pure, every owned key present. **The `music-tag-sync` job is the only writer after an import**; the importer records files `pending` and tags them through the same engine (`TagAlbum`) before it refreshes the media servers. `MediaFile.tags_state`/`tags_hash` carry the state; an equal hash settles a file without opening it, and an empty one forces the write. **A file a listed torrent serves is never written**, and under `seeding: defer` neither is a hardlink whose torrent is still listed — every client's listing is the only seeding signal, since only the builtin engine reports a stopped torrent. `mode` is `off`, `import` (once) or `managed` (re-queued on hydration, refresh and settings changes); only albums with `Album.tags_managed` are ever re-queued, which a music import review sets per commit (`write_tags`) and which adopted-only albums start without. **Artwork rides the same write**: `audiotags.WriteWithArt` embeds the cached cover (scaled to `art_max_px`) as the front picture and the pass leaves `cover.jpg` beside the album and the artist photo as `folder.jpg` in a folder named after the artist; **ownership is a SHA-256 on the row** (`MediaFile.tags_art_hash`, `Album`/`Artist.art_sidecar_hash`), never a picture description (MP4 keeps none), so art Streamline did not write is never replaced and its own copies are never read back as the album's art. The album's **tag panel** (`GET /music/albums/{id}/tags`) compares each file with the write through `audiotags.Compare`, which shares the writer's own `plan`; `Album`/`Track.tag_overrides` layer hand-set tags over the library's (`desiredTags`), and under `managed` the job's **outside-edit check** stats written files in rotation and queues one whose owned keys were changed elsewhere. **The write contract, the key mapping and where each value comes from, the tag state, the job and its triggers, the seeding guard, `albumImportMode`, the artwork rules, the panel, overrides and the edit check: [`docs/agents/music-tags.md`](docs/agents/music-tags.md) — read it before touching `internal/library/audiotags/`, `internal/media/music/tags.go`, the album importer's tagging, or `library.music_tags`.** ## Books Fourth vertical, **book-first**: `Book` is the root entity and a shelf item on its own, or a volume of a `BookSeries` (`ShelfItem = Book | BookSeries`; a volume is never a shelf item). People are slim `Author` rows (name, sort name, photo source) joined to a book *or* a series through `BookContribution` (`role` author/writer/artist/colorist/cover/translator/narrator, `language` on a series translator; exactly one owner is set, enforced by the writer like `Credit`). **There is no author-follow**: nothing is monitored per author, there is no `/books/authors*` route and no `author` request type, and an `Author` with no contribution left is deleted in the same transaction as the book or series that orphaned it. `Book.author_name` is the display string (authors, else writers, else artists, joined with ` & `, three then ` et al.` — `pick.DisplayAuthor`) and what the naming `{Author}`, OPDS filenames and the indexer query read. A book carries two independent **slots**, ebook and audiobook, each with its own `*_monitored`/`*_status`/`*_grab_failures`/`*_last_search_at`, a `*_edition` pointer and a `*_replacing_language`; `MediaFile.book_kind` says which slot a file fills. Slot state on the wire is derived (`book.SlotState`, mirrored by the shelf SQL — change one and the other): unmonitored iff not monitored and neither available nor downloading, a paused record is `downloading`, a monitored `wanted`/`skipped` slot is `wanted`. A book's status ignores unmonitored slots and reports `available` until its release date (an unreleased book is never a wanted slot — `ListWantedBooks`, `SearchMissing`, `search-now` and RSS all require `last_refreshed_at IS NOT NULL` and `release_date IS NULL OR <= now`); a series' status looks at its hydrated, released volumes' **ebook** slot only. **Editions**: `BookEdition` keeps the best Hardcover edition per `(language, publisher, format)` (≤ 40 per book, the winner of every `(language, format)` pair always kept; `metadata.SelectEditions`, pure — a paperback counts as an ebook only for a language with no digital ebook, which is how a comic gets one). A slot points at one; a row a slot points at is **never pruned**, so `edition_id` is stable across refreshes. `internal/media/book/pick` is the shared pure rule set for the database layer and the service: slot pick (preferred language → original → most popular), title derivation (best edition in `preferred_language`, ebook before audiobook, else the original's, else Hardcover's) and the series monitor policy. `Book.title` is never tied to a slot's edition. A **refresh fills a slot that has no edition but never re-points one that has** (a hand-picked edition survives it); `PATCH preferred_language` re-picks every slot without a file; `PATCH format+edition_id` on a slot *with* a file stores `*_replacing_language`, moves the slot to `wanted` (filtered on `available`), and the next grab — `GrabBookRelease` reads the flag — sets the record's `replace_mode = all`, so the importer removes the old file only after the new one is placed and clears the flag in `RecordBookImportSuccess`. A corrected `kind` (a heuristic, `metadata.ClassifyBookKind`) survives a refresh: it is written only when a stub is hydrated. **Hardcover is called in batches of twenty.** Depth limit 3, 60 req/min through the 1 req/s limiter, 5,000 req/day (`hardcoverDailyBudget`, a UTC-day counter of requests actually sent) with `hardcoverScanReserve` (500) left to adds, refreshes and approvals: the bulk-import scanner and every background pass (`budgetLow`) stop at `Remaining() <= ScanReserve()`. The batch query (`GetBooks`, `hardcover_books.go`) reads up to 20 books with five aliased edition lists in one request — the most popular digital and paper editions, the same two restricted to `library.book_language` (a widely translated book's top editions can all be foreign: *1984*'s French audiobook and paperbacks were in neither), and the earliest; `cached_contributors` carries the makers and a book whose blob names none is read through the `contributions` join (one more request, only for those). A batch Hardcover cuts off is halved and retried (each retry spends budget). Costs: lookup 3 requests (1 with `type=book`; `type=series` is still 3: book search, series search, the batch below), memoised 5 min per normalised query, with the batch memoised 5 min on the sorted series-id set (`Hardcover.seriesLookups`) and `Service.Lookup` itself uncached so `markAdded` reads the library on every call; lookup detail 1 (book) or 2 (series: skeleton + one batch of the first six volumes), memoised 10 min; add book 1; **add series `1 + ceil(N/20)`, of which two are on the HTTP request**; refresh book 1; refresh series `1 + ceil(N/20)`; scheduled refresh `ceil(B/20)` per ≤ 100 stale books plus a series pass for ≤ 5 stale series. A `429` or an exhausted day is `*metadata.RateLimitedError` wrapping `metadata.ErrRateLimited` and never sets `hardcover_auth_warn`; add, refresh, lookup and approve answer `429` `code: rate_limited` with `Retry-After`, no key answers `503`, and `RefreshStale` stops its tick at the first one and returns nil. `streamline.metadata.hardcover.requests{outcome}` counts it. **Series lookup finds a series through its books.** Hardcover's series search matches only the series name, so a series searched by a translated title was invisible. The book hits whose title or any alternative title folds equal to the query (`library.TitleMatchesStrict`) or contains every query token contribute their `series_ids`; those ids are unioned with the series-search hits and read in **one batched request** (`GetSeriesLookup`: name, slug, `is_completed`, counts, author and up to 50 `book_series` rows ordered by position then readers, so each position's winner survives the limit, with `book{id users_count compilation cached_image}`, not `image`, which is a relation past the depth limit). A series with `primary_books_count = 0` is dropped, and so is a book hit with neither a credited maker nor a cover (a stray row nobody can tell apart, which the importer would hold for want of an author anyway). The cap (20) never drops the top group: name-matched series plus series reached through a book whose title **equals** the query. A series reached only through a book holding the query's words goes after the books, because a broad query ("star wars") reaches dozens and would otherwise push out every book. Each hit carries `cover_book_ids` (the winners at the first three positions by the add path's rule, `winnersByPosition`, **that have a cover** — a series that numbers none, or none with a cover, fans its most read unnumbered books instead, read in the same request as `loose` — registered with `artwork.Remember(KindBooks, …)`), `matched_title` (set when the matching book title differs from the series name, shown as `via « … »`) and `slug`; the lookup detail picks its volume preview with the same winner rule from the skeleton. `BookSeries.hardcover_slug` is stored on add and refresh (an empty slug clears it); the series page and the lookup panel build `hardcover.app/series/{slug}` from it (a book links `hardcover.app/book/{id}`, since `/books/{id}` is a 404). A library series' fan of covers is `cover_ids` (`db.SeriesCoverIDs`: the first three positioned, **hydrated** volumes in position order — add, hydrate and refresh keep only each position's winner, so these are the lookup fan's books once the series is hydrated; the shelf reads them in one extra windowed query per page), drawn by the SPA's `SeriesCover`. **Series add is two-phase.** Phase one (synchronous, the 2 requests): the skeleton (`series` + numbered `book_series`, one request) and the first 20 volumes; one transaction writes the series, those volumes fully hydrated and a **stub** `Book` for every other position (`last_refreshed_at NULL`, placeholder title `"{Series} #{n}"`, no editions, slot flags from the series policy). The series answers `hydrating: true`. Phase two: one process-wide worker (`HydrateSeriesInBackground`, `defer RecoverPanic`, span `book.hydrate_series`) drains stubs in batches of 20, each volume in its own settle step (series policy against the series' creation time, the series' chosen `(language, publisher)` edition); a stub Hardcover no longer knows, or that is a compilation, is deleted when it holds no file. **A stub is never searched, grabbed or imported** (the importer holds the record; `GrabBookRelease` answers `ErrNotHydrated`), and restart recovery is free: `RefreshStale` hydrates stubs *first*. A volume already in the library as a standalone book is **adopted** into the series (its data kept); one in another series is skipped and logged. Series `monitor` (`all`/`future`/`none`, stored) applies to each volume's **ebook** slot only; `future` is measured against the moment of the patch (or `BookSeries.create_time` for volumes a refresh finds). `BookSeries.quality_profile` is written through to every volume, and resolution for a book is `Book.quality_profile` → its series' → **the default for the book's kind** (`book_quality_default_profiles: {novel, bd, comic, manga}`, `config.ResolveBookQualityProfile(name, kind)`, `book.ProfileFor`). `PATCH series edition` takes a label from `BookSeries.editions` (`"{language in its own name} · {publisher}"`, ordered by volumes covered) and re-picks only the volumes **without** an ebook file; volumes with one keep file and edition. Metadata is Hardcover (GraphQL, `metadata.hardcover_api_key` or `_file`): **the key is optional** — without it `wire.go` leaves the provider nil, the service answers `ErrNotConfigured`, the handlers answer `503` and the add page says Hardcover is not configured. Every Hardcover-dependent handler (search, lookup, add, refresh, request approve, import start and commit) builds that 503 through `errHardcoverUnavailable` (`restapi/hardcover_errors.go`): `code: hardcover_not_configured` for a missing key, `code: hardcover_key_rejected` when Hardcover answered 401 (the import operations can only report the first; a key rejected mid-scan surfaces after the fact). The key is settable through `PATCH /config/metadata` like TMDB/TVDB, restart required. `library.book_language` (ISO 639-1, runtime-editable on Settings → Library, default `en`) seeds `Book.preferred_language`; `library.book_series_naming` (file-only; **not** `library.series_naming`, which is the TV template) names an ebook volume of a series (`{Author}/{Series}/{Series} - Vol. {Volume:02}`), `ebook_naming`/`audiobook_naming` gain `{Language}`, `{Series}`, `{Volume}`, and `{Title}` is the slot edition's title. Covers and creator photos come through the posters proxy under the `books` and `authors` kinds, fetched in the background from the CDN address the Hardcover payload already carried (never a Hardcover request); lookup art is `artwork.Remember(KindBooks, id, Source{URL})` as lookup and detail responses are served. **The shelf** (`GET /books`, `internal/db/book_shelf.go`) is one raw-SQL `UNION ALL` of standalone books and series — ent cannot union, and merging two paged ent queries gives wrong deep pages — with the filters applied inside both arms and the derived status filtered, ordered and paged outside; counts are faceted (each facet counted with the others applied and its own left out, each with an `*_total` row, authors keyed by folded creator name so homonyms merge). Live download progress comes from `download.Queue` matched to the book's active records (`ListActiveBookRecords`); the download manager is untouched. **Acquisition**: `ParseBookRelease` (bare `MP3`, `M4A` and `FLAC` without audiobook context are music, never a book; `CBR` is a comic container only when the name carries no audio token and no `CBR 64`-style rate; a stated audiobook rate lands in `BitrateKbps`; `Language` from the design's word list, `Volume`/`VolumePrefix` from `tome|volume|vol|t|v|#` + digits, fractional allowed; ranges and `intégrale` are collections) with `ScoreEbookRelease`/`ScoreAudiobookRelease` (and `Judge*` for the reject reason), `indexer.Manager.SearchBook` (7000+7020 ebook, 3030 audiobook; the query is `author + slot edition title (+ year)`, never an alias list). `POST /books/{id}/search` returns **flat** `SearchResult` items (`slot`, upper-case container in `source`, `bitrate_kbps`), both slots merged without `kind`; the grab reads the slot from the release's parsed container and refuses a body whose `slot` disagrees (`grab_rejected`), `kind` and the body slot disagreeing is a `400`. **Automatic paths only** skip a release tagged with a language other than the slot edition's (`book.WrongLanguage`); interactive search never filters on language. `SearchMissing` walks standalone slots one by one and groups series volumes: `searchSeriesVolumes` runs **one** query per series, takes each volume's best accepted match by parsed `Volume`, and makes at most 3 per-volume fallback queries; `search-now` for a book or series runs the same pass in the background and answers `202 {queued}`. Both the music and book services export `ErrNoQualityProfile` (`422`, `code: no_quality_profile`). **Import**: the book importer places one best-format file for an ebook and the whole folder, original filenames kept, for an audiobook (`library.BookDestination`), and a failed audiobook import removes what it placed. A record is **held** (`Check: metadata`) when the book is an unhydrated stub or has no `author_name` — never a wrong path. A volume import flips both the `book` and the `book_series` request. **Rename** (`POST /books/{id}/rename`, `/books/series/{id}/rename`) moves an ebook to its template path and an audiobook folder whole, never onto an existing file. **Adoption** is `ImportScan.kind=book`: ebooks group by `(dir, stem)`, every folder directly holding audio is one audiobook candidate, identification runs Calibre `metadata.opf` → embedded epub OPF → filename, then `BookByISBN` short-circuits `SearchBooks`; the scanner spends 1 search (+1 ISBN lookup) per candidate and **no** book fetch. The committer prefetches its reviewed books in batches of 20 (`ceil(C/20)` requests) and adds an unknown book **unmonitored** through `AddBook`; nothing is ever written into a book file. Book imports, adoption commits, renames and deletes-with-files refresh media servers (`mediaserver.KindBook`): Jellyfin and Emby rescan, **Plex is skipped** since it has no book library type. Both `StartScan` and `Commit` refuse `kind=book` without a Hardcover key (`book.ErrNotConfigured`, 503) and refuse `mode=rename` first (`ErrRenameUnsupported`, 422): books adopt in place only. A Hardcover 401 mid-scan is scan-fatal rather than a per-row miss: `markScanFailedWithCode` sets `ImportScan.failure_code` (`hardcover_key_rejected`, with a fixed message; `hardcover_not_configured` if the key vanished before the run) so the SPA can link to Settings; every other failure leaves the code empty. Each review row carries `parsed_year` and `size` (stored) and `format`, `file_count`, `source_path` (derived on read from `file_paths`: best ebook format by `quality.EbookLadder`, or the audiobook's dominant extension and common folder). **OPDS** is `internal/server/opds` at `/opds` (OPDS 1.2 Atom: root, `/authors` — authors and writers of a book holding an ebook —, `/authors/{id}` with volumes included, `/recent`, `/search.xml` + `/search` over title or any contributing author, `/download/{bookID}/{format}`), HTTP Basic against `User.opds_token`, **ebooks only**, `` is every maker, one acquisition link per book at the best profile format. Cover links point at `/opds/cover/{bookID}` (Basic-authenticated, backed by the posters service). **Automation**: `rss/book_feed.go` gates 7000–7999 to the ebook matcher and 3030 to the audiobook matcher; a release matches a standalone book by `Author - Title` against any of its title forms (display, original, every edition title) and a series volume by the series name plus the parsed volume number, the author optional. **Requests**: `media_type` `book` and `book_series` carry a Hardcover id (two id spaces, hence `media_type` in the dedup key); approving a book adds it `both`, a series `all`, under the reviewer's profile (empty means the family default, a name outside the family answers `422` and the request stays pending). **Send to device**: `POST /books/{id}/send` mails one ebook to the caller's `device_email` and `POST /books/series/{id}/send` packs a series' hydrated volumes into emails by first-fit under `smtp.max_message_size_mb` (raw bytes before base64, default 25) and 25 attachments each. `book.Sendable` is the one rule for both and for `Book.ebook_send` on the detail: the ebook-slot file whose `quality` is `EPUB`, else `PDF` (EPUB wins), that exists on disk and fits the limit; CBZ, AZW3 and MOBI are not sendable and the reason points at OPDS. The `smtp` section is file-only (restart required, never under `/config/*`), an empty `host` turns the feature off, `password`/`password_file` pair with `excluded_with`, and `tls: none` loads only for a loopback host; `starttls` against a server that does not advertise it fails before AUTH, never falling back to plain. A series send answers `202` with the plan and sends in a background goroutine, so a failing email is **logged (WARN) only**, never surfaced to the API; a single send answers `502 send_failed` with the SMTP reply. Codes: `not_sendable`, `no_device_email`, `smtp_not_configured`, `send_failed`. User docs: `docs/wiki/Send-to-Kindle.md`. ## Transcoding `transcoding: {enabled, max_concurrent, max_failures, hw_accel, hw_device, defer_seeding, verify}` is the global surface, all runtime-editable with no restart; the **policy** lives on the quality profile (`quality_profiles[].transcode`), not globally — edited in the profile form, though only a profile that has none can be switched off there, since the API cannot remove one. A job is created in the same call as the `MediaFile` row. Encode → verify → atomic swap beside the original; a verified-worse output is `rejected` (terminal, retryable by hand), a failure retries to `max_failures`. **Job lifecycle, verification rules, boot recovery, VAAPI/hardware notes and the deliberate non-features: [`docs/agents/transcoding.md`](docs/agents/transcoding.md) — read it before touching `internal/transcoding/`, `internal/ffmpeg/`, or the `/transcoding/*` handlers.** ## Release-name parsing & bulk import matching `library.Parse` and friends turn a release name into facts; `internal/library/bulkimport/` classifies scanned folders against TMDB/TVDB and commits them. Nearly every rule in this area exists because a specific real-world release broke the obvious implementation — do not simplify one without reading why it is shaped that way. **Parser rules, title normalization, classifier ranking, commit-time re-resolution and the naming-template/path-sanitizing contract: [`docs/agents/bulk-import-matching.md`](docs/agents/bulk-import-matching.md) — read it before touching `internal/library/` parsing, the rename services, or the metadata classifiers.** ## API conventions - **A change to the files of an offline library root answers `409` with `code: library_offline`**, built in `responseError` from `*roothealth.OfflineError`, so a handler only returns the error. The message names the root and never its path, which is admin-only. Book and series operations go by the roots they actually hold (`db.BookFileRoots`). The SPA renders it verbatim. - **The upload endpoints carry three codes the SPA renders verbatim**: `422 upload_rejected` (a bad path, wrong-kind media, no media, a stub book, nothing in a discography matched), `409 upload_conflict` (the target already has a live download record) and `422 no_space` (the download volume cannot hold it). `no_quality_profile` applies as on a grab. Like `grab_rejected`, they need a code or the SPA falls through to the generic 422 line. - Every list endpoint bounds `limit` to `[1, 100]` (200 for activity) and **400s** a value outside it (`pagination.go` `limitOr` — there is no request-validation middleware, so the spec's bounds are enforced there). The SPA's `apiAllPages` (`web/app/lib/api.ts`) walks pages at `PAGE_LIMIT = 100` against `total`; the handlers used to clamp silently, and asking for `limit=500` — which nine call sites did — showed 100 of 621 movies with no indication anything was missing. - `otelx.RecordSpanError(span, nil)` returns nil and leaves the span alone. It used to deref the error for `SetStatus`, so a missing `if err != nil` was a panic the handler reported as a 500 nowhere near the mistake — which is exactly how `BulkDecide` shipped and how the dev server caught it. - Connection-test 422s carry `code: connection_failed` and the SPA renders their message verbatim — it is composed by our own client code and is the only place the upstream status appears. A plain 422 reads "some of those values weren't accepted", which blames credentials that are fine. - **A search result's `download_url` is a sealed handle, not the indexer's link** (`restapi/release_handle.go`): AES-GCM under a key derived from the session secret, prefixed `slr1.`, opened by `toIndexerResult` on the way back in. The link carries the indexer's API key and search is a member endpoint. A plain link in a grab body still passes, held to the indexer allowlist by `checkReleaseSource`; `info_url` goes out through `otelx.RedactURL`. Rotating the JWT secret retires every outstanding handle; a grab carrying one answers `grab_rejected` saying to search again, not the missing-field 422. - **A refused grab answers `422` with `code: grab_rejected`**, and the SPA renders that message verbatim (`errorText`, same arm as `connection_failed`). All six grab handlers share it — `GrabMovieRelease`, `GrabEpisodeRelease`, `GrabSeasonRelease`, `GrabSeriesRelease`, `GrabMusicAlbumRelease`, `GrabBookRelease` — over `download.ErrUntrustedSource`, `ErrNoWantedFiles` (video only; album and book grabs take the whole torrent), `ErrClientFull` (the builtin engine at its `maxTorrents` constant), `ErrNoSpace` (the free-space check, `download.space_check`), `ErrUnsafeTorrentName` (a `.torrent` whose name the engine's path guard refuses) and `ErrTorrentAlreadyExists` (the client already holds the torrent, or a live record tracks its hash; `errAlreadyInClient` names the release — it fell through to a bare 500 "internal error" until a re-grab of a season the queue showed downloading surfaced it). Without a code the SPA falls through `BY_CODE` to `byStatus(422)` and shows its generic "some of those values weren't accepted", which blames the request body: on the homelab, two anime episodes refused for `ErrNoWantedFiles` (the `SxxE` keep-set gap) were indistinguishable from a malformed release payload, and the message naming the actual reason — the sentinel plus the caller's own release title — was discarded before it reached the screen. `errUnprocessable` still has no code by design; a 422 whose message is a *diagnostic* rather than field validation needs one, or the message never renders. - **`replace_existing: true` on a music or book grab sets the record's `replace_mode = all`** (the field the importer already honours; the RSS upgrade arm sets `upgrades`). The old files go only **after** the new ones are placed and verified: per matched track for an album, per linked album that already had files for an artist pack, every file of the grabbed slot for a book (a replaced audiobook folder is removed whole, the other slot is never touched). A held or failed import leaves the originals. Accepted and a no-op on a title with no files. - **A list response must be an inline `type: array` schema, not a `$ref`'d `components/responses` array.** oapi-codegen turns the `$ref` form into a struct embedding the slice, which encodes as `{"TypeName": [...]}` rather than a bare array — `/music/quality-profiles` and `/books/quality-profiles` shipped answering an object until the spec was inlined. - **`GET /requests/{id}/metadata` for an artist, book or series calls the same handler method as its lookup detail** (`GetMusicArtistLookup`, `GetBookLookup`), so the request panel and the add flow share one cache and one provider budget; 429 and 503 pass through and the SPA falls back to the title without retrying. - **A release search no enabled indexer answered answers `502` (`504` when every one timed out) with `code: indexers_unavailable`**, never an empty `200`: the search handlers hand `indexer.Unavailable` errors to `responseError`, which builds the response, and the SPA renders the message (indexer names and what went wrong) verbatim. An indexer failing while another answers costs only its own results. The automatic missing searches stop their pass at the first such search and stamp no cooldown. A caller that hung up (the SPA aborts a release search nothing is watching) is logged as `499` at debug, not as a 500. Rules in `docs/agents/quality-scoring.md`. - **A 422 for a title with no usable quality profile carries `code: no_quality_profile`** (`errNoQualityProfile`), for the same reason: the album and book-slot search/grab endpoints would otherwise fall to the SPA's generic 422 line. - **A Hardcover `503` carries `code: hardcover_not_configured` or `code: hardcover_key_rejected`**, distinct from `rate_limited` (429). One helper builds it (`errHardcoverUnavailable`); a handler that returns the book provider's 503 with `errServiceUnavailable` leaves the SPA unable to tell a missing key from a refused one from an outage. Approving a book or series request answers the same codes (and `429`) and the request stays pending. ## Testing - Framework: Ginkgo (Describe/Context/It/By) + Gomega assertions - Mocks: Mockery (`go tool mockery`) — config in `.mockery.yaml`, generated to `internal//mocks/` - Run tests: `task test:unit` / `task test:integration` / `task test:e2e` / `task test` / `task test:coverage` — all `go tool ginkgo run -r --keep-going`; the first three and `test:coverage` (`!e2e`) carry a label filter, `task test` (and `test:e2e`) build the frontend first (`build:js` + `build:css`, skipped when unchanged), then `task test` runs every spec exactly once in two passes over disjoint package sets with no label filter (`E2E_PACKAGES` first and alone, then `REST_PACKAGES`, every other package holding a spec; one JUnit file each under `JUNIT_DIR`), so its reports list as skipped only specs that skip themselves — a label-filtered pass would list every spec it filtered out as skipped, and the two-filter split reported 362 phantom skips; forward extra args via `CLI_ARGS`, narrow with `LABEL_FILTER=` (not on `task test`) - `task test:e2e` is capped at `--timeout=1m15s` (`E2E_TIMEOUT` overrides it; CI's frontend-only path sets `4m` for a cold build cache) and points ginkgo only at the packages holding a spec whose `Label(` line names `"e2e"` (`E2E_PACKAGES`, derived by grep, so keep that label on the `Label(` line). A build counts against the timeout, and building every suite stalled the API suite's SQLite fsyncs until it failed in `BeforeSuite`. `CLI_ARGS` replaces the package list. `apptest.Start` also sets `TMPDIR` to `/dev/shm` where it exists, so the app's database and config are in memory: on disk, building the whole tree stalled a request past the e2e client's 10 s timeout. A browser click under the same load timed out too, which is why `task test` runs e2e apart from those builds. - `task test:e2e:containers` — container-backed e2e (Docker + `STREAMLINE_E2E_CONTAINERS=1`, 5m timeout); hermetic `test:e2e` excludes `containers`-labeled specs. `task test` selects them and they skip themselves unless `STREAMLINE_E2E_CONTAINERS` is set and Docker answers; CI's `test` job sets it and installs ffmpeg (the `ffmpeg`/`transcoding` e2e specs skip without it on PATH), so on CI the only skips left are the upstream Cardigann specs (`STREAMLINE_CARDIGANN_UPSTREAM`, the weekly workflow) and the ones that need root. - Run single suite: `task test:unit -- ./internal/metadata/...` - Each Ginkgo suite has a dedicated `_suite_test.go` (the three `internal/utils/*` packages inline it in their single test file) with `TestX` + `RunSpecs` + `BeforeSuite(func() { DeferCleanup(testutil.InstallSlog()) })` — `testutil.InstallSlog()` routes `slog.Default` to GinkgoWriter for the suite's lifetime. - Mocks emit to `internal//mocks/mock_.go` — type `Mock`, constructor `NewMock(GinkgoT())` - Regenerate mocks after interface changes: `go tool mockery` - Span-instrumented funcs wrap ctx via `tracer.Start(...)`, breaking exact-ctx mock matchers. Use `mock.Anything` for the ctx param in `.EXPECT().Fn(mock.Anything, ...)` calls. ## Multi-agent fix workflows Convention for landing a batch of independent fixes (audit findings, review comments, a bug list). Iterate on this as we learn. - **Group findings by file ownership first.** Two agents editing one file is the failure mode; disjoint file domains are what make the rest safe. One group per domain (e.g. `restapi/`, `download+importer`, `middleware+web`, `auth+db+ent`). - **One workflow per group, one agent per finding.** Each fix agent runs with `isolation: 'worktree'` so it can never see or clobber another's tree. Agents fix *only* their assigned finding — no drive-by refactors, no fixing things they notice. - **Seed every worktree before the agents build.** A fresh worktree cannot compile *anything* importing `web`: all four `go:embed` inputs are gitignored — `web/static/css/style.css`, `web/static/css/docs.min.css`, `web/static/js/docs.min.js`, `web/static/dist/`. The failure reads `pattern static/css/docs.min.css: no matching files found`, which looks like broken code and is not. Copy them in from the main checkout right after launch (they stay gitignored, so they never pollute a patch); re-run the copy as assemble-phase worktrees appear. Building them per-worktree instead costs a full pnpm+esbuild+tailwind cycle per agent. - **Agents commit; they do not emit patches.** Every fix agent ends by committing its work on its worktree's own branch, one commit per finding, with a real Conventional Commits subject. Integration is then a merge/cherry-pick against a shared ancestor — a proper 3-way merge that understands moved and adjacent hunks. Patch files were the earlier convention and they mangle exactly this: a flat diff has no base, so two agents editing neighbouring lines of one file produce hunks that apply cleanly in isolation and silently mis-stack when combined. Commits carry the base; diffs don't. - Inside a harness worktree the agent uses `git commit` (the worktree is a git worktree, not a jj workspace — this is the "raw git plumbing" exception, not a licence to use git elsewhere). The integrating session brings the work across with jj, which sees those commits after its automatic import. - If a patch file is ever unavoidable, generate it with `git diff --no-ext-diff`: this machine sets `diff.external = difft`, so a bare `git diff > x.patch` writes difftastic's *rendered* output — diff-shaped, no `---`/`+++`/`@@`, and `git apply` rejects it with "No valid patches in input". Applying is unaffected; only generation. - **Agents never write to the main working copy.** A per-workflow assemble agent merges the group's commits in its own worktree, resolves intra-group overlap, verifies `task build:go`, and leaves a single reviewable branch. - **The main session integrates.** Workflows finish in any order; the session reviews each group's commits, brings them into the working copy, and verifies — serialized, so nothing races on the tree. Never let a workflow write to main itself. - **Codegen runs once, at the end, in the main tree.** `task generate` / `task migrate:diff` rewrite `ent/*` and `internal/*/mocks/*`; per-workflow codegen produces conflicting generated files against a partial tree. Agents may run it inside their worktree to compile-check, but must keep generated output out of the commits they hand over. Migrations from `task migrate:diff` *are* handed over, unedited. - **Verification is the session's, after integration** — `task build:go`, `task lint:go`, `task test:unit` over the merged result. A group that passed alone can still break the merge. - **Clean up after integration.** Remove each group's worktrees and their `worktree-*` branches once its work is in and verified. They live under `.claude/worktrees/` *inside* the repo, so until they go the editor/LSP indexes every half-finished agent tree and reports its errors as if they were yours. - Agents get no history rewriting and no publishing: no `jj`, no rebase/amend of anything they did not create, no push, no `task dev`, no `task release:*`. ## Nix - `nix/` holds the whole surface: `package.nix` (buildGoModule over a `frontend` derivation that runs the same pnpm/esbuild/tailwind pipeline as `build:js`+`build:css`), `go.nix` + `node.nix` + `pnpm.nix` (the toolchain pins, with `toolchains.json` holding their override hashes), `options.nix` + `common.nix` (shared by both modules), `module.nix` (NixOS), `hm-module.nix` (home-manager), `test.nix` (two-node VM test). Flake outputs: `packages.streamline`, `overlays.default`, `nixosModules.streamline`, `homeModules.streamline`, `checks.{build,nixos,home-manager,lint}`. - **Two hashes, a version and two base-image digests are derived, not written.** `pnpmDeps.hash` moves with `pnpm-lock.yaml`, `vendorHash` with `go.sum`, `version ? "X.Y.Z"` is rewritten by `release:app:changelog` (`app:tag` refuses a mismatch), and `deploy/Dockerfile`'s `GO_DIGEST` and node digest follow tags nothing else rewrites, and `nix/toolchains.json` follows the declared toolchain versions. `.github/workflows/renovate-pins.yaml` derives all but the version, on renovate's own branches. **Both package FODs are named after their inputs** (`inputsId` in `nix/package.nix`: `pnpmDeps` after `pnpm-lock.yaml`, `pnpm-workspace.yaml` and the pnpm version, `goModules` after `go.mod`, `go.sum` and the Go version) — a fixed-output path is only its name and declared hash, so under a fixed name a stale hash still resolves to the old output wherever that path is cached, and the homelab builder passed exactly such a `pnpmDeps.hash` after the pnpm 12.11.2 bump. The lint check shares the package's `goModules` path rather than fetching it twice. `goModules` takes `preBuild = ""`: buildGoModule hands it the package's `preBuild`, whose `cp ${frontend}/…` made vendoring wait on the frontend build, and so on a source-built Node whenever `engines.node` is ahead of nixpkgs. CI's `nix` job filters on exactly those inputs, so a lock change is what triggers the expensive check. - **Upstream decides each toolchain's patch; `flake.lock`'s nixpkgs decides which lines exist.** The declarations are the source: go.mod's `go` directive, `package.json`'s `engines.node` (a `>=` floor, stripped) and `packageManager`. `nix/{go,node,pnpm}.nix` take the line from it — `go_1_NN`, `nodejs_NN`, `pnpm_NN` — and **throw when the locked nixpkgs has no such line**, so a new Go minor or Node/pnpm major waits for nixpkgs. Within the line, nixpkgs' package is used as-is when it is already on the declared version and **overridden onto it otherwise, in either direction**: Go and Node are rebuilt from upstream's source tarball; **pnpm is not** — it installs pnpm's own `@pnpm/exe` binary (what `pnpm/action-setup` and corepack download anyway), patched with `autoPatchelfHook`, because pnpm 12's Rust build recipe moves between patches (12.10 began generating `cas-loader.mjs.inc` with esbuild before `cargo build`, a step nixpkgs' 12.9.0 recipe lacks) and a version override broke on exactly that. All hashes sit in `nix/toolchains.json` keyed by version — Go and Node one source hash each, pnpm npm's `integrity` per platform. The devshell, `nix build`, CI and the container therefore run one exact version, without a security patch waiting on a nixpkgs channel — Go 1.27.2 sat in `nixos-unstable-small` for days while `vulncheck` stayed red and the old equality throw kept go.mod from moving. An overridden Go or Node is compiled from source and is not on cache.nixos.org: the homelab builder keeps it for CI, and an overlay user whose nixpkgs differs builds it once. `nodejs_NN` is a symlinkJoin, so `node.nix` overrides `nodejs-slim_NN` and rebuilds the wrapper, and the override drops `--shared-brotli` so Node builds the brotli it bundles — a patch release can carry a newer one than nixpkgs ships, and 24.22.1's `node_zlib.cc` names a decoder error code brotli 1.2.0 leaves reserved; `package.nix` passes its own pnpm to `fetchPnpmDeps` (whose store layout follows the pnpm that runs it) and exposes all three as `.#streamline.{go,nodejs,pnpm}`. - **Renovate bumps the declarations from upstream; `renovate-pins.yaml` derives the rest.** Two regex managers (`customManagers` in `renovate.json`) move Go (`golang-version`: go.mod and the Dockerfile's `GO_VERSION`) and Node (`node-version`: `engines.node`, the Dockerfile's node tag, every workflow `node-version`) as one PR each; pnpm's `packageManager` rides the existing npm/`pnpm/action-setup` group. pnpm 12 records its own version in `pnpm-lock.yaml` (`packageManagerDependencies`), so `packageManager` never moves without the lockfile — renovate's npm manager regenerates both, and a hand bump needs `pnpm install --lockfile-only` or every `--frozen-lockfile` install (CI, the Dockerfile) fails with `ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE`. The built-in gomod/npm/dockerfile/github-actions managers stay `enabled: false` for go/node/golang, or each would bump one copy and leave the rest behind. A Go minor and a pnpm or Node major wait on the dependency dashboard — each is a new nixpkgs line. Renovate writes the workflow copies itself because a push made with the Actions token may not touch `.github/workflows/`. On renovate's branch `renovate-pins.yaml` then checks that the locked nixpkgs has each line and that every copy agrees with its source, re-pins both image digests, rebuilds `toolchains.json` from scratch (an entry only for a tool nixpkgs has on a different version, so a lock that catches up drops it; Go and Node by `nix store prefetch-file`, pnpm by reading npm's `integrity` off the registry — no build), and derives `pnpmDeps.hash` and `vendorHash` — in that order, since both build through the pinned pnpm. **Both bot workflows push with a GitHub App token when `vars.PINS_APP_CLIENT_ID` + `secrets.PINS_APP_PRIVATE_KEY` are set** (Contents: write only, setup in `renovate-pins.yaml`'s header): a push made with the Actions token creates CI runs that sit at "approval required", so every derived commit left its PR without checks until the owner approved them; without the App they fall back to that token. `renovate.json`'s `gitIgnoredAuthors` names the bot's commit email, or renovate would treat each derived commit as a human edit and stop rebasing the branch; it recreates the branch over them and the workflows derive again. `engines.node` stays a **`>=` floor**, for anyone building without the devshell; pnpm enforces a project's own `engines` unconditionally, so an exact value there breaks `pnpm install` for everyone not on that patch. - **`GOTOOLCHAIN = "local"` is set twice, deliberately.** `buildGoModule` sets it for `nix build` (`pkgs/build-support/go/module.nix`), and `flake.nix`'s devshell env sets it again because nothing else does for `nix develop` — the default, `auto`, silently downloads whatever toolchain go.mod asks for, which would paper over exactly the mismatch `nix/go.nix` exists to refuse, in the one shell meant to be the reproduction. - The package `src` is a fileset that **excludes** every frontend build output (`web/static/dist`, `style.css`, `docs.min.*`, `.routify`, `lib/paraglide`) — they are gitignored, so leaving them in would make the source hash depend on whether someone ran `task build:js` locally. `.golangci.yml` rides along for the `lint` check only; `e2e/` is deliberately out, since its rod deps are not in the vendored module set. - **The config file is runtime-writable, so the module picks a side.** `mutableSettings = true` (default) seeds `settings` into `${stateDir}/config.yaml` on first start and never touches it again — the UI works, and a rebuild does *not* re-sync. `false` serves the store file and forces `read_only`, which is the only way a store path is safe: without it `config.Update` fails on a read-only filesystem instead of returning `ErrReadOnly`. - `ffmpeg.path` is a **directory** (`resolveBinary(dir, "ffprobe")`), not the executable. `hardwareAcceleration.enable` only opens `/dev/dri` — `ffmpeg-headless` still cannot use it, and `ffmpeg-full` is the build carrying VAAPI *and* libvmaf (without which `transcoding.verify.min_vmaf` parses and silently never runs). - `ProtectSystem = "strict"` means `ReadWritePaths` is load-bearing: it is the union of `stateDir`, the absolute `data_dir`/`movie_path`/`series_path`/`download_path` derived from `settings`, and `extraReadWritePaths` — which **adds** rather than replaces, because a computed `default` on a plain list option would silently drop the derived paths the moment a user set it. A library root added only through the UI is never granted. A path that does not exist yet fails the unit with a mount-namespace error, not a streamline error. - The `lint` check is `packages.streamline.overrideAttrs` running `go tool golangci-lint`, not a standalone `runCommand` — golangci-lint type-checks, so it needs the module cache and the `//go:embed` assets buildGoModule already arranges. The old standalone check had neither and only ever reported "could not import entgo.io/ent" from a network-less sandbox. - User-facing docs: `docs/wiki/NixOS.md` (linked from `Installation.md` and `_Sidebar.md`). Format with `task fmt` (nixfmt over `flake.nix nix/*.nix`); `nix fmt` also works via `formatter`. ## Docs & Spec Upkeep - Any change to `/api/v1` behavior — routes, auth/authz, status codes, security schemes, request/response shapes — updates `api/openapi.yaml` in the same change, then `task generate` to regenerate `gen.go` (the spec itself is served from `api/embed.go`'s `//go:embed openapi.yaml`). A handler and its spec entry must never disagree. - When a feature or behavior changes, update the affected CLAUDE.md section — **or the `docs/agents/` file its stub points at, when the change lands in one of those subsystems** — **and the wiki** (`docs/wiki/` — user-facing pages like `REST-API.md`, `Authentication-and-SSO.md`) in the same change — stale docs describing the old behavior are worse than none. - `docs/agents/` holds the subsystem detail — ten files, one per area, each behind a `##` stub above. `docs/agents/README.md` is the index and states the upkeep rule. A stub and its file must never disagree: the stub carries the shape and the load-bearing invariants, the file carries the rules and the reasons. - **A code comment describes the code as it is, never as a diff against what it replaced.** "No longer", "used to", "instead of X", "not `total`" only make sense to someone who saw the earlier version, and the next reader did not: the comment says what the code does and why it has to, and how it got there belongs in the commit message and the PR. - `docs/wiki/` is the source of truth for the GitHub wiki; `.github/workflows/wiki.yaml` mirrors it to `DataHearth/streamline.wiki.git` on every push to `main` touching it. Filename = page title (dashes become spaces), `_Sidebar.md` is the nav. Never edit through the wiki UI — the next sync overwrites it. ## Code Generation - API: `go tool oapi-codegen --config api/oapi-codegen.yaml api/openapi.yaml` → `internal/server/restapi/gen.go` (package `restapi`) — regenerate after spec changes - ORM: `go generate ./ent` — regenerate after schema changes - All codegen: `task generate` (runs `go tool oapi-codegen`, `go generate ./ent`, `go tool mockery`). Versioned migrations: `task migrate:diff -- NAME` diffs the ent schema into a new migration. - **Never hand-write or hand-edit a migration file, the down step included.** `ent/migrate/main.go` runs every new down file on the replayed history and checks it restores the previous schema. Atlas's own reverse of a SQLite table rebuild (any change `ALTER TABLE` cannot express — an added column that carries an index included) is just `DROP TABLE new_`, a table that no longer exists on the way down; when the check fails the generator replaces the down with Atlas's plan from the new schema back to the old one, checks that too, and says so in its output. `task migrate:check` runs the same check over the whole history (it regenerated ten downs that could never run, the init one included) and keeps every down that passes — which is why `replace_mode`'s hand-written down stays: it folds `replace_mode = 'all'` back into `replace_existing`, data a schema-only plan cannot carry. The `down migrations` spec in `internal/db/migrations_test.go` rolls a populated database all the way back through the real runner, so a down that cannot run fails CI. - Prefer the narrowest integer type on ent schema fields (`field.Uint8` for bounded counters like `grab_failures`, `field.Uint16` for small ranges like `number`/`year`) — sqlite storage is identical but Go structs stay memory-efficient. - After ent regen the LSP may report "undefined" for new fields/methods briefly; `task build:go` is the source of truth. ## Build - Task runner: [Taskfile.yaml](Taskfile.yaml) — sole build orchestrator (no npm scripts). - **Always** invoke operations via `task `. Raw `go build`/`go test`/`ginkgo`/`golangci-lint`/`pnpm exec` bypass the Taskfile and drift from CI. - Go tooling runs via the Go `tool` directive: `go tool {ginkgo,golangci-lint,oapi-codegen,mockery,air,govulncheck}` (`task vulncheck` runs the last, as CI does) — not system-installed; the flake devshell only ships `go`, `pnpm`, `go-task`, `biome`, etc. - **A tool-directive module needs its full `h1:` in go.sum, and only the tool jobs notice when it doesn't.** `build`/`test`/`lint-go` never build those packages, so a `go.sum` carrying just the `/go.mod` hash resolves fine for them and fails in `vulncheck` (`go tool govulncheck`) and in `nix`, where the `streamline-lint` goModules derivation runs `go mod download` over the whole set — as a builder error inside a fixed-output derivation, which reads like broken code. `renovate.json` sets `postUpdateOptions: ["gomodTidy"]` so renovate can't open a PR in that state; a hand-edited go.mod needs `go mod tidy`, not `go get` alone. - Full build: `task` (= `build:app` → `build:go`, which depends on `build:js` + `build:css` because assets are `//go:embed`-ed) → `go build -o streamline ./cmd`. - Frontend: `task build:js` (Scalar docs bundle + Paraglide message compile + `routify build` + esbuild SPA) and `task build:css` (TailwindCSS v4). - Dev server: `task dev` — live reload via `go tool air` (`.air.toml`; rebuilds with `task build:app`, runs `streamline --config ./tmp/config.yaml`). - Lint: `task lint` (`lint:go` golangci-lint + `lint:frontend` biome over the SPA and the static JS/CSS + `lint:ts` svelte-check). Format: `task fmt` (`golangci-lint fmt` + `biome format --write` over the static JS/CSS only — Biome's formatter changes what Svelte markup renders) — Biome, not prettier. - Clean: `task clean`. - README screenshots: `task docs:screenshots` (`e2e/demoshots`) boots a throwaway instance, seeds a demo library through a fake Torznab indexer and a fake qBittorrent with ffmpeg-made files, so imports and the held import are real, and shoots the twenty pages into `docs/assets/`. Only the calendar's release dates and the browser's view of `/system/info` (disk, public URL) are staged. Needs ffmpeg, a Chromium and the TMDB/TVDB/Hardcover keys; `CONTRIBUTING.md` has the usage. - **CI runs a section only when its inputs change.** The `changes` job at the top of `.github/workflows/ci.yaml` holds one path filter per section (`go`, `frontend`, `biome`, `helm`, `chart`, `nix`), and every other job is skipped at the job level unless its filter matched. A frontend-only change runs the SPA jobs, `build`, the web e2e specs (`./e2e/web`) and the unit specs over the embedded assets (`./internal/server/web`), not the whole Go tree; `vulncheck` also runs on every push to main, since advisories arrive without a change. A new top-level file a section reads must be added to its filter, or a change to it runs nothing. **`ci-ok` is the one check branch protection requires**: a job skipped by its `if` reads as passing, and so does every job when `changes` itself fails, so it fails unless `changes` succeeded and every other job passed or was skipped — add a new job to its `needs`. CI and Helm E2E have **no concurrency group**: each CI run tests only its own diff, so a run cancelled or replaced by a newer one would leave its commit untested for good. The frontend-only path runs `task test:e2e E2E_TIMEOUT=4m`, since the timeout counts the build: on a 4-vCPU runner the web suite takes about 28s from a warm Go build cache and about 2m from a cold one, past the 1m15s default. `e2e/web` skips its whole suite when `CHROME_PATH` is unset; the `test` job points it at the runner's Chrome and fails when there is none. Biome runs at the version `biome.json`'s `$schema` names. ## Issue triage - `.github/labels.yml` is every label the repo carries, grouped `type | status | area | maintainer | renovate`; `labels.yaml` syncs it on push to main (create, update, rename an `aliases` entry in place — never delete). **Add or rename a label in that file, never through the UI**: the triage allowlist is derived from it, and a description doubles as Claude's guidance for when the label fits (100 characters max). - `issue-triage.yaml` runs `anthropics/claude-code-action` (Haiku, `CLAUDE_CODE_OAUTH_TOKEN`) on `issues: opened`, and on `workflow_dispatch` as a backfill (given numbers, or every issue). Anyone who can open an issue starts it with the Claude token, so the job holds an issues-only `GITHUB_TOKEN`, a `.github/`-only sparse checkout, and **no tool but three wrappers** in `.github/scripts/triage/`: `gh.sh` (read-only), `edit-issue.sh` (labels of the permitted groups only, at most two `area/*`, `needs-info` never on a maintainer's issue) and `comment-issue.sh` (one per run, new issue from an outside author only, no @-mention). Every limit comes from the event payload or `labels.yml`, never from an argument. A backfill touches `type` and `area` only and never comments. - `needs-triage` is the workflow's own marker: set at the start, cleared by `finish.sh` after a successful run — an issue still carrying it is one the run failed on. **Native Issue Types are not set**: they are an organization feature and this repository belongs to a personal account — the `type` update answers 200 and keeps nothing for the Actions token (with or without `contents: write`) and for the owner's token alike. The type labels carry it. Should the repo move to an organization, setting a type takes push access: give it its own job, **never** repository write in the job holding the Claude token. ## Release - App and chart version independently, but they release the same way, so both live in one `release` namespace (`.taskfiles/release.yaml`). App: `task release:app VERSION=X.Y.Z`, which is `release:app:changelog` (regenerates the CHANGELOG **and rewrites `version ? "X.Y.Z"` in `nix/package.nix`** — `nix build` has no tag to read, so a nix install would otherwise report the previous release forever; `app:tag` refuses to tag until both are on main — commits them on their own revision and advances `main`) then `release:app:tag` (tags `main`, push fans out to `.github/workflows/{release,image}.yaml`). Chart: `task release:chart VERSION=X.Y.Z`, which is `release:chart:bump` (rewrites `version` in `deploy/helm/streamline/Chart.yaml`, regenerates `deploy/helm/streamline/CHANGELOG.md`, commits both, advances `main`) then `release:chart:tag` (tags `chart-vX.Y.Z`, triggers `.github/workflows/chart.yaml`); run the two apart to review the bump commit before the tag goes out. Bump the chart against the diff, not against the version already there — a renovate deps PR gets a patch bump pushed onto its branch by `.github/workflows/renovate-chart.yaml` (lint-helm refuses an unbumped chart change, and renovate's own `bumpVersion` only reaches the subchart's Chart.yaml), so a feature landing after it inherits a patch number that claims nothing shipped. Both take `VERSION` as bare semver; a leading `v` is accepted and stripped, so the same string works for either. The tag prefix is the task's business — `v` for the app, `chart-v` for the chart. The shared halves are `release:_changelog` and `release:_tag`, internal and the only place the jj-vs-git commit dance and the tag push are written. **`_changelog` fetches and cuts its revision from `main@origin`, never from `@`** — `jj new` on `@` sealed the empty working-copy commit into released history — and **`_tag` pushes `main` with the tag** (`jj git push --remote origin -b main --tag`): pushing the tag alone left `main` local, renovate moved origin's underneath it, and the next fetch made `main` conflicted. `_tag` is all-jj since jj 0.45 — the tags it writes are lightweight, not annotated, which nothing here reads. - Artifacts come from goreleaser (`.goreleaser.yaml`): CGO-free binaries for linux/darwin/windows × amd64/arm64, archives carry `config.example.yaml`, `checksums.txt` is cosign-signed (bundle format), one SPDX SBOM per archive. Images are cosign-signed keyless + SBOM-attested + grype-scanned in `image.yaml`. - **PR images live in their own package, `ghcr.io/datahearth/streamline-pr`**, so nothing that deletes them can reach a release tag. `pr-image.yaml` builds each same-repo, non-Renovate PR's **head** commit (not the merge commit) whenever the PR touches an image input. It builds `linux/amd64` only, with `provenance: false` and `sbom: false` so each push is one package version. It pushes `pr-` and `pr--`, each with a `-vaapi` twin. It reads main's build cache and never writes one, and grype only reports. `pr-image-cleanup.yaml` runs on `closed` and in a daily sweep. It uses `.github/scripts/pr-image/prune.sh` from the default branch: a version goes only when **every** tag on it is `pr-` or starts with `pr--`, which is why each push also carries a commit tag (the next push moves `pr-` off the old version). The sweep also deletes untagged versions older than a day. The script never deletes the package's last tagged version, which GHCR refuses. The two workflows share the concurrency group `pr-image-`, so closing a PR cancels its running build. Fork PRs are deliberately unsupported: their `pull_request` token is read-only, and building under `pull_request_target` is ruled out. A new GHCR package is private, so `streamline-pr` has to be made public by hand once, after its first publish. - Both tags publish a GitHub release, and both bodies come from `.github/actions/release-notes` (composite): it extracts the version's CHANGELOG section, pipes it to the Claude CLI to rewrite as operator-facing notes (installed from `claude.ai/install.sh`, run from `RUNNER_TEMP` with the section on stdin and no tool granted — no file writes, no repo access), then appends the caller's install/verify footer. **Both workflows run it in a separate `notes` job** (`contents: read`, `persist-credentials: false`, workflow-level `permissions: {}`) and hand the file to the publishing job as an artifact: the installer is an unpinned `curl | bash`, and in the publishing job it ran beside the checkout's persisted token, cosign's OIDC token and the tree GoReleaser signs. `anthropics/claude-code-action` is deliberately *not* used: it rejects `push` outright (`Unsupported event type: push`) and both release lines are tag-push triggered, so the step never once ran under it. **The model is never load-bearing**: the step is `continue-on-error` and skipped entirely without `CLAUDE_CODE_OAUTH_TOKEN`, and empty output falls back to the raw changelog section — which is exactly what shipped before. A *missing* changelog section still fails the release, since that is our mistake and not the model's. The chart release is created with `--latest=false` so it does not displace the app release on the releases page. - `release:app` / `release:app:tag` / `release:chart` / `release:chart:tag` / `release:image` / `release:helm` publish for real and have no dry-run. Both end-to-end targets commit *before* they reach the half that pushes, so a dry check must be `task --dry`, never a real run with a deliberately bad VERSION. Never run one to "test" it — only exercise a precondition's failure path. ## Version Control - Repository uses jj (Jujutsu) with a git backend. Commit a task with `jj new -m "msg"` (creates empty child, auto-snapshots subsequent work). Seal a task by starting the next with `jj new -m "..."`. - Don't re-run `jj describe` on a commit you're already working in — message is set once per task. - If working copy holds unrelated leftover edits (e.g. settings.json), describe them into their own commit *before* `jj new` for feature work. - Abandon empty working-copy commits after no-artifact steps (smoke tests, manual verification): `jj abandon @`. - Commit messages: Conventional Commits `type(scope): msg` — add a `(scope)` where it clarifies, omit it when it doesn't. Bundle related changes; avoid single-file revisions. ## Project Structure - `api/openapi.yaml` — OpenAPI spec (source of truth for REST API) - `internal/` — all application code - `ent/schema/` — ent ORM schemas - `web/app/` — Svelte 5 SPA (`routes/`, `components/`, `lib/`); `web/static/` — CSS/JS/fonts/images; `web/embed.go` `//go:embed`s the built assets + SPA shell - `docs/superpowers/{specs,plans}/` — design docs and plans (gitignored, local only; `docs/plans/` is the legacy location) - `docs/wiki/Roadmap.md` — public, user-facing feature status (Shipped / In progress / Planned). Not an internal phase list; update it when a feature ships or starts. - `CHANGELOG.md` — generated from conventional commits by git-cliff (`task release:app:changelog VERSION=X.Y.Z`); don't hand-write entries. Commits touching `deploy/helm/**` are excluded — those belong to `deploy/helm/streamline/CHANGELOG.md`, generated the same way but off `chart-v*` tags by `release:chart:bump` and packaged into the chart tarball. Two configs: `cliff.toml` (app) and `cliff.chart.toml` (chart). They exist separately only because `commit_parsers` differs and git-cliff exposes that neither as a flag nor as an env override — the chart promotes `chore(helm)` and `chore(deps)` into Changed, since a chart release is often nothing but those and cliff.toml's blanket chore skip produced a heading with nothing under it. The `chore(helm): chart vX.Y.Z` bump commit is skipped there, or it lands as an entry inside its own section. The templates are duplicated between the two files — keep them in sync. Path scope stays at each call site (`--exclude-path` for the app, `--include-path` for the chart), so a commit touching app *and* chart appears in both. - `deploy/` — Dockerfile + Helm charts + `compose.yaml` (local test stack: gluetun VPN + qBittorrent + Prowlarr + Plex, builds from source — *not* a user deployment template) - `deploy/unraid/streamline.xml` — Unraid Community Applications template (`--user 99:100`, library paths as `STREAMLINE_LIBRARY__*_PATH`); `deploy/lxc/` — Proxmox LXC install/update script (`streamline.sh`) plus the same install in the Proxmox VE Helper-Scripts layout (`community-scripts/`), kept in step; each directory's README says why it is shaped that way. - `deploy/helm/streamline/` — streamline chart (installs to `streamline` ns). Optional subchart `charts/observability/` installs upstream alloy/VM/VL/VT/grafana into `observability` ns via `namespaceOverride`. - `deploy/helm/streamline/kubeconfig.yaml` — kind cluster kubeconfig (auto-exported by `task helm:kind:up`; flake devshell sets `KUBECONFIG` to this path). - `nix/` — package, NixOS + home-manager modules, VM test (see **Nix** above) ## Helm Gotchas - VM/VL/VT charts: at v0.35/0.12/0.0.7 the selector used `app: server` but the template labels dropped it, so `server.podLabels.app: server` is set in each subchart's values. The charts pinned in `charts/observability/Chart.yaml` derive pod labels from the selector labels, so the workaround is likely removable — verify against a rendered template before dropping it. - Cross-namespace k8s DNS requires FQDN: `..svc.cluster.local`. Streamline→alloy uses `alloy.observability.svc.cluster.local:4318`. - OTel SDK defaults to HTTPS. The chart renders `otel.insecure: true` into the config for the subchart's Alloy (plain HTTP), and for any `config.otel.endpoint` written as `http://…` — it strips the scheme, since the exporters take `host:port` via `WithEndpoint`. - The Grafana dashboard is rendered by the **parent** chart (`templates/grafana-dashboard.yaml`) for both the subchart's Grafana and `grafanaDashboard.enabled` (an existing Grafana's sidecar) — a subchart can't read its parent's files. `task helm:prep` copies `deploy/grafana/dashboards/streamline.json` into `deploy/helm/streamline/dashboards/` (gitignored). Panels use `$datasource`/`$logs` variables, never a datasource uid; the logs panel's plugin type and query are picked at render time (`grafanaDashboard.logs.type`), since a datasource variable is bound to one plugin type.