From 6587edaae6c9030c3c9cb88bc2c697bdb1729348 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Sun, 13 Sep 2026 11:49:04 -0700 Subject: [PATCH 1/3] Identify Starr, folder, and hook instances by map key so ParseENV can overlay the same slug after a PUT. PUT writes the request body as the file document. Live is clone(fileConfig)+ParseENV; there is no peel of env-owned fields. Co-authored-by: Cursor --- .github/copilot-instructions.md | 2 +- INTERNALS.md | 29 +-- examples/docker-compose.yml | 2 +- examples/unpackerr.conf.example | 33 +-- go.mod | 2 +- go.sum | 4 +- pkg/configdef/compose.go | 2 +- pkg/configdef/config.go | 54 ++-- pkg/configdef/definitions.yml | 26 +- pkg/configdef/docusaurus.go | 9 +- pkg/configdef/help.go | 2 +- pkg/configdef/live.go | 62 +++-- pkg/configdef/live_test.go | 47 ++-- pkg/configdef/start.go | 3 +- pkg/configdef/validate.go | 6 + pkg/unpackerr/api_test.go | 8 +- pkg/unpackerr/apps.go | 102 ++++---- pkg/unpackerr/cnfgfile.go | 3 +- pkg/unpackerr/cnfgfile_test.go | 129 +++++++--- pkg/unpackerr/configapi.go | 60 ++++- pkg/unpackerr/configapi_test.go | 8 +- pkg/unpackerr/configclone.go | 75 ++++-- pkg/unpackerr/configdump.go | 49 ++-- pkg/unpackerr/configdump_test.go | 32 +-- pkg/unpackerr/configput.go | 97 +++++--- pkg/unpackerr/configput_test.go | 360 ++++++++++++++++++++++++--- pkg/unpackerr/folder.go | 12 +- pkg/unpackerr/historyrestore_test.go | 10 +- pkg/unpackerr/instancemap.go | 195 +++++++++++++++ pkg/unpackerr/instancemap_test.go | 90 +++++++ pkg/unpackerr/openapi.json | 122 ++++++++- pkg/unpackerr/queue_actions_test.go | 18 +- pkg/unpackerr/starrpoll.go | 93 ++++--- pkg/unpackerr/starrpoll_test.go | 80 +++--- pkg/unpackerr/start_test.go | 25 +- pkg/unpackerr/webhook.go | 26 +- 36 files changed, 1422 insertions(+), 455 deletions(-) create mode 100644 pkg/unpackerr/instancemap.go create mode 100644 pkg/unpackerr/instancemap_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3f9c30b1..1a01527d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -12,7 +12,7 @@ in mind; the items below have been raised and rejected before. - Live `Config` fields are read and written only on the main loop. Do not ask for a mutex around `u.StartDelay`, `u.Passwords`, `u.Sonarr`, and similar. If a new reader runs on another goroutine, route it through `onMainLoop` instead. - Exception: `GET /api/stats` / Prometheus `Collect` read Starr/folder slice headers + Exception: `GET /api/stats` / Prometheus `Collect` read Starr/folder map headers under `configMu` and the last poll snapshot under `History.mu`. Poll workers publish `Queue` and `last*` after `GetQueue` returns. Do not hop that path onto `onMainLoop`. - `retrieveAppQueues` does not need to snapshot the app lists. A config PUT applies on diff --git a/INTERNALS.md b/INTERNALS.md index 53d20715..53cae127 100644 --- a/INTERNALS.md +++ b/INTERNALS.md @@ -24,7 +24,8 @@ Two stacked feature series plus a follow-up mux swap. Closed duplicates (`#688`, | [#692](https://github.com/Unpackerr/unpackerr/pull/692) | Config PUT | Per-section PUT, `onMainLoop`, idle restart. Replaced the overbuilt `#688`. | | [#693](https://github.com/Unpackerr/unpackerr/pull/693) | OpenAPI | Embedded `pkg/unpackerr/openapi.json`. | | [#697](https://github.com/Unpackerr/unpackerr/pull/697) | Stdlib mux | Dropped `julienschmidt/httprouter`. Go `http.ServeMux` with `{section}` and `GET …/{$}` for the index. | -| [#722](https://github.com/Unpackerr/unpackerr/pull/722) | PUT env overlay | Starr / folder / hook PUTs re-apply `UN_*` onto live so `[]` cannot wipe env-only rows. File snapshot stays file-shaped. | +| [#722](https://github.com/Unpackerr/unpackerr/pull/722) | PUT env overlay | Starr / folder / hook PUTs re-apply `UN_*` onto live so omitting an env-only slug cannot wipe it. File snapshot stays file-shaped. | +| v1.0.0 (September 2026) | Named instance maps | Sonarr, Radarr, Lidarr, Readarr, folders, webhooks, and cmdhooks are `map[string]*Config` keyed by a slug. Dual-read old `[[section]]` arrays as `"0"`, `"1"`, …. PUT writes the request body as the file document. Live is `clone(fileConfig)` + `ParseENV` (cnfg overlays existing map entries). Env-only slugs still appear on live after `{}`. A client that PUTs live overlay values persists them. | The mux PR is routing only. Behavior below is from the API stacks unless noted. @@ -42,7 +43,7 @@ Unpackerr is **one process**. One goroutine — `(*Unpackerr).Run()` in `pkg/unp HTTP handlers **must not** mutate those on the HTTP goroutine. They validate the body, then call `onMainLoop`. Queue retry/forget use the same handoff. Config GET of the **file** snapshot does **not** need the main loop (it is under `configMu`). Config GET of **live** general/starr/folders **does**, because live `Config` is main-loop memory. -`GET /api/stats` (and Prometheus `Collect`) is the exception that reads live Starr/folder slice headers off-loop. That path takes `configMu` for the slice headers (same as hook counts) and `History.mu` for `Queue` / `lastQueued` / `lastRetrieved` / `lastPollErr`. Poll workers publish those fields under the history write lock **after** `GetQueue` returns. Do not hop stats onto `onMainLoop`; that would stall scrapes behind Starr HTTP. Other new readers of live `u.Sonarr` / `u.Passwords` / `u.StartDelay` still go through `onMainLoop`. +`GET /api/stats` (and Prometheus `Collect`) is the exception that reads live Starr/folder map headers off-loop. That path takes `configMu` for the instance headers (same as hook counts) and `History.mu` for `Queue` / `lastQueued` / `lastRetrieved` / `lastPollErr`. Poll workers publish those fields under the history write lock **after** `GetQueue` returns. Do not hop stats onto `onMainLoop`; that would stall scrapes behind Starr HTTP. Other new readers of live `u.Sonarr` / `u.Passwords` / `u.StartDelay` still go through `onMainLoop`. --- @@ -117,7 +118,7 @@ Clone of config **after TOML load, before `UN_*` overlay**. Keeps: - `filepath:/path` as the string `filepath:/path` (not file contents) - on-disk `ui_password` (`!!cryptd!!…`, `filepath:…`, or leftover plaintext) - API keys as stored in the file (not env-only keys) -- Starr lists / folders / hooks as written +- Starr / folder / hook maps as written (`[sonarr.uhd]`, not env-only slugs) **Owner:** `configMu`. Also written by the **tray** (Change Password, generated admin key). HTTP GET of the file snapshot clones under that lock. PUT stages a clone, atomically writes TOML, then swaps `fileConfig`. @@ -136,7 +137,7 @@ Archive passwords **after env overlay, before `filepath:` expansion**. `GET /api 1. Find or create a TOML file (`configdef` example on first run). 2. `cnfgfile.Unmarshal` into `u.Config`. 3. **`snapshotFileConfig()`** — this is the file-shaped copy. **Must happen before env.** -4. `cnfg.UnmarshalENV(u.Config, u.EnvPrefix)` — default prefix `UN`. `UN_SONARR_0_API_KEY`, `UN_WEBSERVER_UI_PASSWORD`, `UN_WEBSERVER_ROLES_stats_PERMISSIONS_0`, etc. +4. `cnfg.UnmarshalENV(u.Config, u.EnvPrefix)` — default prefix `UN`. `UN_SONARR_uhd_URL` (slug case matches the TOML key), `UN_SONARR_0_API_KEY` (array rows migrate to key `"0"`), `UN_WEBSERVER_UI_PASSWORD`, `UN_WEBSERVER_ROLES_stats_PERMISSIONS_0`, etc. Do not set a bare `UN_SONARR` / `UN_FOLDER` / `UN_WEBHOOK` / `UN_CMDHOOK`. 5. `snapshotLivePasswords()` — copy `Passwords` (post-env, still `filepath:`). 6. Password / UI password / API key setup (hash, generate, `--reset`). 7. `validateAuth`, normalize + **validate URLBase** (`{` / `}` forbidden; ServeMux wildcards). @@ -164,7 +165,7 @@ Empty / missing secret file is an error on PUT (400) when the `filepath:` was al ### Env (`UN_*`) -Env overlays **live only**. They are not merged into `fileConfig`. Starr / folder / hook PUTs re-apply the overlay onto the live copy after the file-shaped body is written, so env-only list rows survive a save that omitted them; they still never land in `fileConfig` or the TOML. Env-only extra keys/roles exist at runtime until restart unless you add them in the PUT body. General scalars (`UN_INTERVAL`, `UN_PASSWORDS`, …) still overlay only at startup. +Env overlays **live only**. They are not merged into `fileConfig`. Starr / folder / hook PUTs write the request body as the file snapshot, then overlay live with `ParseENV` (cnfg now overlays existing map entries instead of replacing them). Env-only slugs keep polling. They land in `fileConfig` only if the PUT body included them. PUT `{}` (or a legacy `[]`) clears file instances; live still has `UN_SONARR_uhd_*` / `UN_READARR_0_*`. Env-only extra keys/roles exist at runtime until restart unless you add them in the PUT body. General scalars (`UN_INTERVAL`, `UN_PASSWORDS`, …) still overlay only at startup. The official UI must GET the file section (not `/live`) so it does not persist overlay values. `UN_WEBSERVER_UI_PASSWORD`: @@ -193,7 +194,7 @@ Same read perm. Running shape: - UI password hashed / webauth as used for login. - Webserver key redaction same as file GET (`*` to see secrets). -Live GET of general/starr/folders/hooks runs `onMainLoop` so it does not race `Run()`. +Live GET of Starr / folders / hooks includes env-created slugs and **redacts instance secrets** (`apiKey`, HTTP/native passwords, webhook token) even for `*`. File GET of those sections still shows file-stored Starr API keys. Live GET of general/starr/folders/hooks runs `onMainLoop` so it does not race `Run()`. ### PUT `/api/config/{section}` @@ -201,14 +202,14 @@ Permission: `config:{section}:write`. Body **replaces** the section (not patch). Workflow the UI is built for: GET file → edit → PUT. -Handler on HTTP goroutine: read ≤1 MiB, reject trailing JSON, reject `null` / empty object `{}` for object sections, reject `[null]` in lists. `DisallowUnknownFields`. Webserver PUT that is only `uiCurrentKdf` is the same empty-section 400 (`uiCurrentKdf` is a sidecar, not a config field). +Handler on HTTP goroutine: read ≤1 MiB, reject trailing JSON, reject `null`. Empty object `{}` is 400 for general/webserver/folders wrappers; Starr / webhook / cmdhook maps accept `{}` (clear file instances). Legacy arrays still load as keys `"0"`, `"1"`, …. `DisallowUnknownFields`. Webserver PUT that is only `uiCurrentKdf` is the same empty-section 400 (`uiCurrentKdf` is a sidecar, not a config field). Then `onMainLoop` → `replaceConfigSection` → `commitConfig`: 1. Clone `fileConfig`, mutate the **unexpanded** section onto the clone. 2. Atomic write TOML (`configdef.AtomicWrite`). Failure → **500**, live unchanged (`errPersistConfig`). 3. Swap `fileConfig` to the clone. -4. `applyLive()`: expand, validate, swap live lists / general fields / webserver auth. +4. `applyLive()`: expand, validate, swap live maps / general fields / webserver auth. Env-only (no config path): skip write, still apply live. @@ -218,11 +219,11 @@ Env-only (no config path): skip write, still apply live. **New `ui_password` on PUT:** `!!cryptd!!…`, `webauth`, `noauth`, or `user:<64-char hex>` where the hex is the same PBKDF2 digest as login (`CryptPass.Set`, then bcrypt; mixed-case hex is stored lowercase). Plaintext `user:pass` is **400**. While live auth is local password, changing the hash or switching to header/noauth requires `uiCurrentKdf` (login `Valid()` on the current username). Header/noauth live mode does not. `uiCurrentKdf` is a PUT-only JSON field and is never written to TOML. A body that contains only `uiCurrentKdf` is **400** (empty section), so it cannot wipe `listen_addr` / keys / roles. Omitting `uiPassword` or sending the on-disk value unchanged keeps the live overlay, so `UN_WEBSERVER_UI_PASSWORD` is not replaced by the file hash. -**Starr PUT:** invalid URL/key is **400** (startup *skips* bad apps; PUT does not). Live list is the file-shaped body plus the env overlay, so an env-only extra instance survives `[]`. `path` merges into `paths` without dupes. Last poll `Queue` carries over when `url` + expanded `apiKey` match. Work thread pool **grows** to `starrAppCount`. +**Starr PUT:** JSON object keyed by slug (letters, digits, `_`, `-`; same charset as roles). `name` is display only. Invalid URL/key is **400** (startup *skips* bad apps; PUT does not). File commit is the PUT body. Live map is that body plus `ParseENV`, so an env-only extra instance survives `{}`. `path` merges into `paths` without dupes. Last poll `Queue` carries over when `url` + expanded `apiKey` match (`starrIdentity`). Work thread pool **grows** to `starrAppCount`. Changed in v1.0.0 (September 2026). -**Folders PUT:** always `restartRequired: true`. Watcher is built once; rebuilding in-process was rejected (leak / dual poller). +**Folders PUT:** wrapper `{ interval, buffer, folder }`; inner `folder` is a slug map. Always `restartRequired: true`. Watcher is built once; rebuilding in-process was rejected (leak / dual poller). -**Webhooks / cmdhooks PUT:** validate (including HTTP client) then publish. First-ever hook starts the hook worker. +**Webhooks / cmdhooks PUT:** slug maps, same file-body / live overlay as Starr. Validate (including HTTP client) then publish. First-ever hook starts the hook worker. **General PUT:** applies interval / delays / remnant action / keep_history / passwords in place and **`resetTickers()`**. Interval is **not** `restartRequired`. Logger construction, `parallel` (xtractr), `file_mode` / `dir_mode`, `timeout` / `delete_delay` (copied into apps at validate time) **are** restart. @@ -339,7 +340,7 @@ This file is **ours**. Do not add line-length caps, atomic rename, or `.bak` har | Lock | Guards | | --- | --- | | (none — main loop) | live `Config` minus webserver auth, `Map`, folders, tickers, `pendingRestart` | -| `configMu` | `fileConfig` + hook/Starr/folder slices `/api/stats` counts | +| `configMu` | `fileConfig` + hook/Starr/folder maps `/api/stats` counts | | `uiPassMu` | live webserver auth fields HTTP reads | | `histMu` | history records + JSONL | | `History.mu` | extract map; Starr poll snapshot (`Queue`, `lastQueued`, `lastRetrieved`, `lastPollErr`) | @@ -379,8 +380,8 @@ Two admins saving at once is not a design target. Do not add snapshot-merge. | general | Yes; `resetTickers`; expand passwords | Logger / parallel / file+dir mode / timeout / delete_delay | | webserver | Auth fields in place | listen, urlbase, TLS, metrics, pprof, HTTP log | | sonarr…readarr | Rebuild clients, carry queues, grow workers | No | -| folders | Live slices updated | **Always** (watcher) | -| webhooks / cmdhooks | Replace lists, ensure worker | No | +| folders | Live map updated | **Always** (watcher) | +| webhooks / cmdhooks | Replace maps, ensure worker | No | --- diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml index 6acc8c84..193e0cab 100644 --- a/examples/docker-compose.yml +++ b/examples/docker-compose.yml @@ -167,4 +167,4 @@ services: - UN_CMDHOOK_0_EXCLUDE_1=lidarr - UN_CMDHOOK_0_TIMEOUT=10s -## => Content Auto Generated, 13 SEP 2026 07:39 UTC +## => Content Auto Generated, 13 SEP 2026 07:48 UTC diff --git a/examples/unpackerr.conf.example b/examples/unpackerr.conf.example index 2a835299..974c9bcb 100644 --- a/examples/unpackerr.conf.example +++ b/examples/unpackerr.conf.example @@ -166,10 +166,13 @@ passwords = [] ############################################################################### ## The following sections can be repeated if you have more than one Sonarr, ## ## Radarr, Lidarr, Readarr, Folder, Webhook, and/or Command Hook. ## -## You MUST uncomment the [[header]], url and api_key at for any Starr app. ## -## The [[sonarr]] and [[radarr]] headers come uncommented. Uncomment the url ## +## Identify each instance with a short key: [sonarr.uhd], [folder.tv]. ## +## Changed in v1.0.0 (September 2026): map keys, not [[array]] list rows. ## +## You MUST uncomment the [header.0], url and api_key for any Starr app. ## +## The [sonarr.0] and [radarr.0] headers come uncommented. Uncomment the url ## ## and api_key if they are in use. Comment them with a hash if they are not. ## -## Uncomment the [[lidarr]] and/or [[readarr]] headers and values if in use. ## +## Uncomment the [lidarr.0] and/or [readarr.0] headers and values if in use. ## +## Do not set a bare UN_SONARR / UN_FOLDER / UN_WEBHOOK / UN_CMDHOOK. ## ############################################################################### ############################################################################### ## ALL LINES BEGINNING WITH A HASH # ARE IGNORED COMMENTS ## @@ -177,9 +180,9 @@ passwords = [] ############################################################################### ############################################################################### -## Leaving the [[sonarr]] header uncommented (no leading hash #) without also +## Leaving the [sonarr.0] header uncommented (no leading hash #) without also ## uncommenting the api_key (remove the hash #) will produce a startup warning. -[[sonarr]] +[sonarr.0] ## Empty uses the app name (Sonarr, Radarr, and so on). Set this for a ## Sonarr-compatible app such as Sportarr so logs and Discord are not ## tagged Sonarr. Add Whisparr as a Radarr instance with name = "Whisparr". @@ -224,9 +227,9 @@ passwords = [] ## `0` or `0B` disables the cap for this instance. # max_bytes = "" -## Leaving the [[radarr]] header uncommented (no leading hash #) without also +## Leaving the [radarr.0] header uncommented (no leading hash #) without also ## uncommenting the api_key (remove the hash #) will produce a startup warning. -[[radarr]] +[radarr.0] ## Empty uses the app name (Sonarr, Radarr, and so on). Set this for a ## Sonarr-compatible app such as Sportarr so logs and Discord are not ## tagged Sonarr. Add Whisparr as a Radarr instance with name = "Whisparr". @@ -271,7 +274,7 @@ passwords = [] ## `0` or `0B` disables the cap for this instance. # max_bytes = "" -#[[lidarr]] +#[lidarr.0] ## Empty uses the app name (Sonarr, Radarr, and so on). Set this for a ## Sonarr-compatible app such as Sportarr so logs and Discord are not ## tagged Sonarr. Add Whisparr as a Radarr instance with name = "Whisparr". @@ -319,7 +322,7 @@ passwords = [] ## individual track files. # split_flac = false -#[[readarr]] +#[readarr.0] ## Empty uses the app name (Sonarr, Radarr, and so on). Set this for a ## Sonarr-compatible app such as Sportarr so logs and Discord are not ## tagged Sonarr. Add Whisparr as a Radarr instance with name = "Whisparr". @@ -375,7 +378,7 @@ passwords = [] ## subfolder into a watched folder (defined below) any extractable items in the ## ## folder will be decompressed. This has nothing to do with Starr applications. ## ################################################################################## -#[[folder]] +#[folder.0] # path = '/downloads/auto_extract' ## Paths to ignore while watching this folder. Excluded paths and their ## children are not tracked or extracted. @@ -420,8 +423,8 @@ passwords = [] # Created to integrate with notifiarr.com. # Also works natively with Discord.com, Telegram.org, and Slack.com webhooks. # Can possibly be used with other services by providing a custom template_path. -###### Don't forget to uncomment [[webhook]] and url at a minimum !!!! -#[[webhook]] +###### Don't forget to uncomment [webhook.0] and url at a minimum !!!! +#[webhook.0] # url = "https://notifiarr.com/api/v1/notification/unpackerr/api_key_from_notifiarr_com" ## Provide an optional name to hide the URL in logs. ## If a name is not provided then the URL is used. @@ -456,8 +459,8 @@ passwords = [] ##################### # Executes a script or command when an extraction queues, starts, finishes, and/or is deleted. # All data is passed in as environment variables. Try /usr/bin/env to see what variables are available. -###### Don't forget to uncomment [[cmdhook]] at a minimum !!!! -#[[cmdhook]] +###### Don't forget to uncomment [cmdhook.0] at a minimum !!!! +#[cmdhook.0] # command = '/downloads/scripts/command.sh' ## Provide an optional name to hide the URL in logs. ## If a name is not provided the first word in the command is used. @@ -475,4 +478,4 @@ passwords = [] ## You can adjust how long to wait for the command to run. # timeout = "10s" -## => Content Auto Generated, 13 SEP 2026 07:39 UTC +## => Content Auto Generated, 13 SEP 2026 08:49 UTC diff --git a/go.mod b/go.mod index 9de23269..d31f6c42 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( golang.org/x/crypto v0.56.0 golang.org/x/mod v0.41.0 golang.org/x/sys v0.48.0 - golift.io/cnfg v0.4.0 + golift.io/cnfg v0.4.1-0.20260913183411-6fc2ae31e285 golift.io/cnfgfile v0.0.0-20240713024420-a5436d84eb48 golift.io/rotatorr v0.0.0-20260901062538-fc9f05905af3 golift.io/starr v1.3.1 diff --git a/go.sum b/go.sum index 18f79ff9..4dfaf53c 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= -golift.io/cnfg v0.4.0 h1:HRxcfI8xhRt4Kw9TpP1ZFiMgBDLzXv2sT/goI1/ZRn4= -golift.io/cnfg v0.4.0/go.mod h1:+u238cgJJf1shXJmzMV4I7+F3EACg3NfKSe/g9yRKMY= +golift.io/cnfg v0.4.1-0.20260913183411-6fc2ae31e285 h1:G9YosSJhsk6ehmYBTqKu0ZoxUxhuFiFKNW38kl3jdsQ= +golift.io/cnfg v0.4.1-0.20260913183411-6fc2ae31e285/go.mod h1:+u238cgJJf1shXJmzMV4I7+F3EACg3NfKSe/g9yRKMY= golift.io/cnfgfile v0.0.0-20240713024420-a5436d84eb48 h1:c7cJWRr0cUnFHKtq072esKzhQHKlFA5YRY/hPzQrdko= golift.io/cnfgfile v0.0.0-20240713024420-a5436d84eb48/go.mod h1:zHm9o8SkZ6Mm5DfGahsrEJPsogyR0qItP59s5lJ98/I= golift.io/rotatorr v0.0.0-20260901062538-fc9f05905af3 h1:Hx5CAKKNwjQ6w6syMCPyQWh3kHlQ4OdVqNv4kvq3RM0= diff --git a/pkg/configdef/compose.go b/pkg/configdef/compose.go index e5ec524a..65ac7e7c 100644 --- a/pkg/configdef/compose.go +++ b/pkg/configdef/compose.go @@ -90,7 +90,7 @@ func (h *Header) makeCompose(prefix string, bare bool) string { continue } - if h.Kind == list { + if h.repeatable() { buf.WriteString(param.Compose(pfx + prefix + h.Prefix + "0_")) } else { buf.WriteString(param.Compose(pfx + prefix + h.Prefix)) diff --git a/pkg/configdef/config.go b/pkg/configdef/config.go index 23b0ac9f..2b07e18a 100644 --- a/pkg/configdef/config.go +++ b/pkg/configdef/config.go @@ -74,25 +74,18 @@ func (h *Header) makeSection(name section, showHeader, showValue bool) string { buf.WriteString(h.Text) } - space, comment := "", "#" - if showHeader { - // this only happens when a defined section has a comment override on the repeating headers. - comment = "" - } + space := "" - if !h.NoHeader { // Print the [section] or [[section]] header. + if !h.NoHeader { // Print the [section], [section.0], or [[section]] header. space = " " - - if h.Kind == list { // list sections are commented by default. - buf.WriteString(comment) - buf.WriteString("[[") - buf.WriteString(string(name)) - buf.WriteString("]]\n") // list sections use double-brackets. - } else { - buf.WriteByte('[') - buf.WriteString(string(name)) - buf.WriteString("]\n") // non-list sections use single brackets. + comment := "" + // Repeatable templates start commented. Singleton tables ([webserver], + // [folders]) stay live so their keys do not fall into the root table. + if h.repeatable() && !showHeader { + comment = "#" } + + h.writeTOMLHeader(&buf, name, "0", comment) } for _, param := range h.Params { @@ -125,8 +118,8 @@ func (h *Header) makeSection(name section, showHeader, showValue bool) string { case param.Example != nil: // If example is not empty, use that commented out, otherwise use the default. fallthrough - case h.Kind == list: - // If the 'kind' is a 'list', we comment all the parameters. + case h.repeatable(): + // Repeatable sections comment every parameter in the example template. fmt.Fprintf(&buf, "#%s%s = %s\n", space, param.Name, param.Value()) } } @@ -149,6 +142,31 @@ func (p *Param) isNested() bool { return p != nil && (p.Kind == "map" || p.Kind == tables) } +func (h *Header) repeatable() bool { + return h != nil && (h.Kind == list || h.Kind == named) +} + +func (h *Header) writeTOMLHeader(buf *bytes.Buffer, name section, key, comment string) { + left, inner, right := "[", string(name), "]" + + switch h.Kind { + case list: + left, right = "[[", "]]" + case named: + if key == "" { + key = "0" + } + + inner = string(name) + "." + key + } + + buf.WriteString(comment) + buf.WriteString(left) + buf.WriteString(inner) + buf.WriteString(right) + buf.WriteByte('\n') +} + // makeDefinedSection duplicates sections from overrides, and prints it once for each override. func (h *Header) makeDefinedSection(defs Defs, order []section, showValue bool) string { var buf bytes.Buffer diff --git a/pkg/configdef/definitions.yml b/pkg/configdef/definitions.yml index 88f865de..33107bdf 100644 --- a/pkg/configdef/definitions.yml +++ b/pkg/configdef/definitions.yml @@ -149,7 +149,7 @@ defs: title: Sonarr Settings prefix: SONARR_ text: | - ## Leaving the [[sonarr]] header uncommented (no leading hash #) without also + ## Leaving the [sonarr.0] header uncommented (no leading hash #) without also ## uncommenting the api_key (remove the hash #) will produce a startup warning. docker_example: url: http://sonarr:8989 @@ -159,7 +159,7 @@ defs: title: Radarr Settings prefix: RADARR_ text: | - ## Leaving the [[radarr]] header uncommented (no leading hash #) without also + ## Leaving the [radarr.0] header uncommented (no leading hash #) without also ## uncommenting the api_key (remove the hash #) will produce a startup warning. docker_example: url: http://radarr:7878 @@ -559,10 +559,13 @@ sections: ############################################################################### ## The following sections can be repeated if you have more than one Sonarr, ## ## Radarr, Lidarr, Readarr, Folder, Webhook, and/or Command Hook. ## - ## You MUST uncomment the [[header]], url and api_key at for any Starr app. ## - ## The [[sonarr]] and [[radarr]] headers come uncommented. Uncomment the url ## + ## Identify each instance with a short key: [sonarr.uhd], [folder.tv]. ## + ## Changed in v1.0.0 (September 2026): map keys, not [[array]] list rows. ## + ## You MUST uncomment the [header.0], url and api_key for any Starr app. ## + ## The [sonarr.0] and [radarr.0] headers come uncommented. Uncomment the url ## ## and api_key if they are in use. Comment them with a hash if they are not. ## - ## Uncomment the [[lidarr]] and/or [[readarr]] headers and values if in use. ## + ## Uncomment the [lidarr.0] and/or [readarr.0] headers and values if in use. ## + ## Do not set a bare UN_SONARR / UN_FOLDER / UN_WEBHOOK / UN_CMDHOOK. ## ############################################################################### ############################################################################### ## ALL LINES BEGINNING WITH A HASH # ARE IGNORED COMMENTS ## @@ -571,7 +574,7 @@ sections: ############################################################################### starr: - kind: list + kind: named params: - name: name envvar: NAME @@ -756,8 +759,9 @@ sections: docs: | Folders are a way to watch a folder for things to extract. You can use this to monitor your download client's "move to" path if you're not using it with a Starr app. + Use `[folder.software]` (env `UN_FOLDER_software_PATH`). Do not set a bare `UN_FOLDER`. envvar_prefix: FOLDER_ - kind: list + kind: named params: - name: path envvar: PATH @@ -918,7 +922,7 @@ sections: # Created to integrate with notifiarr.com. # Also works natively with Discord.com, Telegram.org, and Slack.com webhooks. # Can possibly be used with other services by providing a custom template_path. - ###### Don't forget to uncomment [[webhook]] and url at a minimum !!!! + ###### Don't forget to uncomment [webhook.0] and url at a minimum !!!! docs: | This application can send a `POST` webhook to a URL when an extraction begins, and again when it finishes. Configure 1 or more webhook URLs with the parameters below. @@ -931,7 +935,7 @@ sections: - _`Name` is only used in logs, but it's also available as a template value as `{{name}}`._ - Built-In Templates: `pushover`, `telegram`, `discord`, `notifiarr`, `slack`, `gotify`. envvar_prefix: WEBHOOK_ - kind: list + kind: named params: - name: url envvar: URL @@ -1083,9 +1087,9 @@ sections: ##################### # Executes a script or command when an extraction queues, starts, finishes, and/or is deleted. # All data is passed in as environment variables. Try /usr/bin/env to see what variables are available. - ###### Don't forget to uncomment [[cmdhook]] at a minimum !!!! + ###### Don't forget to uncomment [cmdhook.0] at a minimum !!!! envvar_prefix: CMDHOOK_ - kind: list + kind: named params: - name: command envvar: COMMAND diff --git a/pkg/configdef/docusaurus.go b/pkg/configdef/docusaurus.go index 0a276dd6..ff841e30 100644 --- a/pkg/configdef/docusaurus.go +++ b/pkg/configdef/docusaurus.go @@ -97,14 +97,19 @@ func (h *Header) makeDocs(prefix string, section section) string { if !h.NoHeader { brace1, brace2 := "[", "]" - if h.Kind == list { + inner := string(section) + + switch h.Kind { + case list: brace1, brace2 = "[[", "]]" + case named: + inner = string(section) + ".0" } buf.WriteString(h.Prefix) buf.WriteString(", Header: ") buf.WriteString(brace1) - buf.WriteString(string(section)) + buf.WriteString(inner) buf.WriteString(brace2) } diff --git a/pkg/configdef/help.go b/pkg/configdef/help.go index 5a0b9557..646386a8 100644 --- a/pkg/configdef/help.go +++ b/pkg/configdef/help.go @@ -124,7 +124,7 @@ func (h *Header) exampleEnv(prefix string, param *Param) string { } hSuffix := "" - if h.Kind == list { + if h.repeatable() { hSuffix = "0_" } diff --git a/pkg/configdef/live.go b/pkg/configdef/live.go index 8dcc3f15..1e94a062 100644 --- a/pkg/configdef/live.go +++ b/pkg/configdef/live.go @@ -89,12 +89,12 @@ func (c *Config) renderLive(live any, persist persistSet) string { sectionVal, _ = fieldByTOML(root, string(name)) } - if header.Kind == list { + if header.Kind == list || header.Kind == named { buf.WriteString(header.renderListLive(name, sectionVal, persist)) continue } - buf.WriteString(header.makeSectionLive(name, false, sectionVal, persist)) + buf.WriteString(header.makeSectionLive(name, "", false, sectionVal, persist)) } return buf.String() @@ -119,6 +119,10 @@ func (c *Config) renderDefinedLive(name section, root reflect.Value, persist per func (h *Header) renderListLive(name section, live reflect.Value, persist persistSet) string { live = derefValue(live) + if h.Kind == named { + return h.renderNamedLive(name, live, persist) + } + if !live.IsValid() || live.Kind() != reflect.Slice || live.Len() == 0 { return h.makeSection(name, false, false) } @@ -126,13 +130,48 @@ func (h *Header) renderListLive(name section, live reflect.Value, persist persis var buf bytes.Buffer for idx := range live.Len() { - buf.WriteString(h.makeSectionLive(name, true, derefValue(live.Index(idx)), persist)) + buf.WriteString(h.makeSectionLive(name, "", true, derefValue(live.Index(idx)), persist)) } return buf.String() } -func (h *Header) makeSectionLive(name section, showHeader bool, live reflect.Value, persist persistSet) string { +func (h *Header) renderNamedLive(name section, live reflect.Value, persist persistSet) string { + if !live.IsValid() || live.Kind() != reflect.Map || live.Len() == 0 { + return h.makeSection(name, false, false) + } + + keys := make([]string, 0, live.Len()) + + for _, keyVal := range live.MapKeys() { + if keyVal.Kind() == reflect.String { + keys = append(keys, keyVal.String()) + } + } + + slices.Sort(keys) + + var buf bytes.Buffer + + for _, key := range keys { + item := derefValue(live.MapIndex(reflect.ValueOf(key))) + if !item.IsValid() { + continue + } + + buf.WriteString(h.makeSectionLive(name, key, true, item, persist)) + } + + if buf.Len() == 0 { + return h.makeSection(name, false, false) + } + + return buf.String() +} + +func (h *Header) makeSectionLive( + name section, key string, showHeader bool, live reflect.Value, persist persistSet, +) string { var buf bytes.Buffer if h.Text != "" { @@ -143,22 +182,13 @@ func (h *Header) makeSectionLive(name section, showHeader bool, live reflect.Val if !h.NoHeader { space = " " - left, right := "[", "]" - - if h.Kind == list { - left, right = "[[", "]]" - } - comment := "" - if h.Kind == list && !showHeader { + + if h.repeatable() && !showHeader { comment = "#" } - buf.WriteString(comment) - buf.WriteString(left) - buf.WriteString(string(name)) - buf.WriteString(right) - buf.WriteByte('\n') + h.writeTOMLHeader(&buf, name, key, comment) } live = derefValue(live) diff --git a/pkg/configdef/live_test.go b/pkg/configdef/live_test.go index e83be012..af5183d6 100644 --- a/pkg/configdef/live_test.go +++ b/pkg/configdef/live_test.go @@ -12,12 +12,12 @@ import ( ) type liveRoot struct { - Debug bool `toml:"debug"` - Interval liveDuration `toml:"interval"` - Webserver *liveWeb `toml:"webserver"` - Sonarr []liveStarr `toml:"sonarr"` - Folder []liveFolder `toml:"folder"` - Webhook []liveHook `toml:"webhook"` + Debug bool `toml:"debug"` + Interval liveDuration `toml:"interval"` + Webserver *liveWeb `toml:"webserver"` + Sonarr map[string]liveStarr `toml:"sonarr"` + Folder map[string]liveFolder `toml:"folder"` + Webhook map[string]liveHook `toml:"webhook"` } type liveWeb struct { @@ -69,11 +69,22 @@ func TestExampleTOMLContainsWebserver(t *testing.T) { t.Parallel() body := MustLoad(t).ExampleTOML() - for _, want := range []string{"[webserver]", "listen_addr", "metrics"} { + for _, want := range []string{"[webserver]", "[folders]", "listen_addr", "metrics"} { if !strings.Contains(body, want) { t.Fatalf("example TOML missing %q", want) } } + + for _, bad := range []string{"#[webserver]", "#[folders]"} { + if strings.Contains(body, bad) { + t.Fatalf("singleton header must stay live, found %q", bad) + } + } + + var dest map[string]any + if _, err := toml.Decode(body, &dest); err != nil { + t.Fatalf("example TOML must parse: %v", err) + } } func TestRenderLiveCommentsDefaults(t *testing.T) { @@ -99,8 +110,8 @@ func TestRenderLiveCommentsDefaults(t *testing.T) { t.Fatalf("default listen_addr should stay commented, got:\n%s", snippet(body, "listen_addr")) } - if !strings.Contains(body, "#[[sonarr]]") && !strings.Contains(body, "# [[sonarr]]") { - t.Fatalf("empty sonarr list should keep the commented template, got:\n%s", snippet(body, "sonarr")) + if !strings.Contains(body, "#[sonarr.0]") && !strings.Contains(body, "# [sonarr.0]") { + t.Fatalf("empty sonarr map should keep the commented template, got:\n%s", snippet(body, "sonarr")) } } @@ -109,26 +120,26 @@ func TestRenderLiveWritesNonDefaultList(t *testing.T) { schema := MustLoad(t) live := &liveRoot{ - Sonarr: []liveStarr{{URL: "http://sonarr:8989", APIKey: "0123456789abcdef0123456789abcdef"}}, - Folder: []liveFolder{{Path: "/downloads/watch"}}, + Sonarr: map[string]liveStarr{"0": {URL: "http://sonarr:8989", APIKey: "0123456789abcdef0123456789abcdef"}}, + Folder: map[string]liveFolder{"watch": {Path: "/downloads/watch"}}, } body := schema.RenderTOML(live, RenderOpts{Mode: RenderLive}) - if strings.Contains(body, "#[[sonarr]]") { + if strings.Contains(body, "#[sonarr.0]") { t.Fatal("configured sonarr instance should not use the commented template header") } - if !strings.Contains(body, "[[sonarr]]") { - t.Fatalf("missing live [[sonarr]]:\n%s", snippet(body, "sonarr")) + if !strings.Contains(body, "[sonarr.0]") { + t.Fatalf("missing live [sonarr.0]:\n%s", snippet(body, "sonarr")) } if !strings.Contains(body, `url = "http://sonarr:8989"`) { t.Fatalf("missing live sonarr url:\n%s", snippet(body, "url")) } - if !strings.Contains(body, "[[folder]]") { - t.Fatalf("missing live [[folder]]:\n%s", snippet(body, "folder")) + if !strings.Contains(body, "[folder.watch]") { + t.Fatalf("missing live [folder.watch]:\n%s", snippet(body, "folder")) } } @@ -136,7 +147,7 @@ func TestRenderLiveNilDurationStaysCommented(t *testing.T) { t.Parallel() body := MustLoad(t).RenderTOML(&liveRoot{ - Folder: []liveFolder{{Path: "/downloads/watch"}}, + Folder: map[string]liveFolder{"0": {Path: "/downloads/watch"}}, }, RenderOpts{Mode: RenderLive}) if strings.Contains(body, "delete_after = ''") { @@ -152,7 +163,7 @@ func TestRenderLiveEventsStayNumeric(t *testing.T) { t.Parallel() body := MustLoad(t).RenderTOML(&liveRoot{ - Webhook: []liveHook{{ + Webhook: map[string]liveHook{"discord": { URL: "https://example.invalid/hook", Token: "tok", Events: []liveStatus{1, 4}, diff --git a/pkg/configdef/start.go b/pkg/configdef/start.go index 23fbfce5..96f7c705 100644 --- a/pkg/configdef/start.go +++ b/pkg/configdef/start.go @@ -19,6 +19,7 @@ import ( const ( list = "list" + named = "named" tables = "tables" dirMode = 0o755 fileMode = 0o644 @@ -54,7 +55,7 @@ type Header struct { Notes string `yaml:"notes"` Prefix string `yaml:"envvar_prefix"` Params []*Param `yaml:"params"` - Kind string `yaml:"kind"` // "", list + Kind string `yaml:"kind"` // "", list, named NoHeader bool `yaml:"no_header"` // Do not print [section] header. } diff --git a/pkg/configdef/validate.go b/pkg/configdef/validate.go index 7c799783..5ea2abb6 100644 --- a/pkg/configdef/validate.go +++ b/pkg/configdef/validate.go @@ -213,6 +213,12 @@ func (h *Header) validate(name section) []string { errs = append(errs, mdxProblems(h.Tail, string(name)+" tail")...) errs = append(errs, mdxProblems(h.Prefix, string(name)+" envvar_prefix")...) + switch h.Kind { + case "", list, named: + default: + errs = append(errs, string(name)+": unknown kind "+h.Kind) + } + for _, param := range h.Params { if param == nil { errs = append(errs, string(name)+": param is empty (null)") diff --git a/pkg/unpackerr/api_test.go b/pkg/unpackerr/api_test.go index 5f5907a9..ccdb2490 100644 --- a/pkg/unpackerr/api_test.go +++ b/pkg/unpackerr/api_test.go @@ -265,13 +265,13 @@ func TestLiveExportOmitsWithoutConfigRead(t *testing.T) { t.Parallel() unpack := testAuthUnpackerr(t) - unpack.Webhook = []*WebhookConfig{{ + unpack.Webhook = instanceMap([]*WebhookConfig{{ Name: "https://example.com/hook?token=hook-secret", - }} - unpack.Cmdhook = []*WebhookConfig{{ + }}) + unpack.Cmdhook = instanceMap([]*WebhookConfig{{ Name: "cmd", Command: "/usr/bin/env token=cmd-secret", - }} + }}) infoKey := strings.Repeat("I", apiKeyMinLen) unpack.Webserver.Roles = map[string]Role{ diff --git a/pkg/unpackerr/apps.go b/pkg/unpackerr/apps.go index c49b7e42..d5467bd0 100644 --- a/pkg/unpackerr/apps.go +++ b/pkg/unpackerr/apps.go @@ -64,37 +64,37 @@ func skipInvalidApp(err error) bool { // //nolint:lll type Config struct { - Debug bool `json:"debug" toml:"debug" xml:"debug" yaml:"debug"` - Quiet bool `json:"quiet" toml:"quiet" xml:"quiet" yaml:"quiet"` - Activity bool `json:"activity" toml:"activity" xml:"activity" yaml:"activity"` - Parallel uint `json:"parallel" toml:"parallel" xml:"parallel" yaml:"parallel"` - ErrorStdErr bool `json:"errorStderr" toml:"error_stderr" xml:"error_stderr" yaml:"errorStderr"` - LogFile string `json:"logFile" toml:"log_file" xml:"log_file" yaml:"logFile"` - LogFiles int `json:"logFiles" toml:"log_files" xml:"log_files" yaml:"logFiles"` - LogFileMb int `json:"logFileMb" toml:"log_file_mb" xml:"log_file_mb" yaml:"logFileMb"` - LogFileMode string `json:"logFileMode" toml:"log_file_mode" xml:"log_file_mode" yaml:"logFileMode"` - MaxRetries uint `json:"maxRetries" toml:"max_retries" xml:"max_retries" yaml:"maxRetries"` - RemnantAction string `json:"remnantAction" toml:"remnant_action" xml:"remnant_action" yaml:"remnantAction"` - FileMode string `json:"fileMode" toml:"file_mode" xml:"file_mode" yaml:"fileMode"` - DirMode string `json:"dirMode" toml:"dir_mode" xml:"dir_mode" yaml:"dirMode"` - LogQueues cnfg.Duration `json:"logQueues" toml:"log_queues" xml:"log_queues" yaml:"logQueues"` - Interval cnfg.Duration `json:"interval" toml:"interval" xml:"interval" yaml:"interval"` - Timeout cnfg.Duration `json:"timeout" toml:"timeout" xml:"timeout" yaml:"timeout"` - DeleteDelay cnfg.Duration `json:"deleteDelay" toml:"delete_delay" xml:"delete_delay" yaml:"deleteDelay"` - StartDelay cnfg.Duration `json:"startDelay" toml:"start_delay" xml:"start_delay" yaml:"startDelay"` - RetryDelay cnfg.Duration `json:"retryDelay" toml:"retry_delay" xml:"retry_delay" yaml:"retryDelay"` - Progress cnfg.Duration `json:"progress" toml:"progress" xml:"progress" yaml:"progress"` - KeepHistory uint `json:"keepHistory" toml:"keep_history" xml:"keep_history" yaml:"keepHistory"` - Passwords StringSlice `json:"passwords" toml:"passwords" xml:"password" yaml:"passwords"` - Webserver *WebServer `json:"webserver" toml:"webserver" xml:"webserver" yaml:"webserver"` - Lidarr []*LidarrConfig `json:"lidarr,omitempty" toml:"lidarr" xml:"lidarr" yaml:"lidarr,omitempty"` - Radarr []*RadarrConfig `json:"radarr,omitempty" toml:"radarr" xml:"radarr" yaml:"radarr,omitempty"` - Readarr []*ReadarrConfig `json:"readarr,omitempty" toml:"readarr" xml:"readarr" yaml:"readarr,omitempty"` - Sonarr []*SonarrConfig `json:"sonarr,omitempty" toml:"sonarr" xml:"sonarr" yaml:"sonarr,omitempty"` - Folders []*FolderConfig `json:"folder,omitempty" toml:"folder" xml:"folder" yaml:"folder,omitempty"` - Webhook []*WebhookConfig `json:"webhook,omitempty" toml:"webhook" xml:"webhook" yaml:"webhook,omitempty"` - Cmdhook []*WebhookConfig `json:"cmdhook,omitempty" toml:"cmdhook" xml:"cmdhook" yaml:"cmdhook,omitempty"` - Folder FoldersConfig `json:"folders" toml:"folders" xml:"folders" yaml:"folders"` // undocumented. + Debug bool `json:"debug" toml:"debug" xml:"debug" yaml:"debug"` + Quiet bool `json:"quiet" toml:"quiet" xml:"quiet" yaml:"quiet"` + Activity bool `json:"activity" toml:"activity" xml:"activity" yaml:"activity"` + Parallel uint `json:"parallel" toml:"parallel" xml:"parallel" yaml:"parallel"` + ErrorStdErr bool `json:"errorStderr" toml:"error_stderr" xml:"error_stderr" yaml:"errorStderr"` + LogFile string `json:"logFile" toml:"log_file" xml:"log_file" yaml:"logFile"` + LogFiles int `json:"logFiles" toml:"log_files" xml:"log_files" yaml:"logFiles"` + LogFileMb int `json:"logFileMb" toml:"log_file_mb" xml:"log_file_mb" yaml:"logFileMb"` + LogFileMode string `json:"logFileMode" toml:"log_file_mode" xml:"log_file_mode" yaml:"logFileMode"` + MaxRetries uint `json:"maxRetries" toml:"max_retries" xml:"max_retries" yaml:"maxRetries"` + RemnantAction string `json:"remnantAction" toml:"remnant_action" xml:"remnant_action" yaml:"remnantAction"` + FileMode string `json:"fileMode" toml:"file_mode" xml:"file_mode" yaml:"fileMode"` + DirMode string `json:"dirMode" toml:"dir_mode" xml:"dir_mode" yaml:"dirMode"` + LogQueues cnfg.Duration `json:"logQueues" toml:"log_queues" xml:"log_queues" yaml:"logQueues"` + Interval cnfg.Duration `json:"interval" toml:"interval" xml:"interval" yaml:"interval"` + Timeout cnfg.Duration `json:"timeout" toml:"timeout" xml:"timeout" yaml:"timeout"` + DeleteDelay cnfg.Duration `json:"deleteDelay" toml:"delete_delay" xml:"delete_delay" yaml:"deleteDelay"` + StartDelay cnfg.Duration `json:"startDelay" toml:"start_delay" xml:"start_delay" yaml:"startDelay"` + RetryDelay cnfg.Duration `json:"retryDelay" toml:"retry_delay" xml:"retry_delay" yaml:"retryDelay"` + Progress cnfg.Duration `json:"progress" toml:"progress" xml:"progress" yaml:"progress"` + KeepHistory uint `json:"keepHistory" toml:"keep_history" xml:"keep_history" yaml:"keepHistory"` + Passwords StringSlice `json:"passwords" toml:"passwords" xml:"password" yaml:"passwords"` + Webserver *WebServer `json:"webserver" toml:"webserver" xml:"webserver" yaml:"webserver"` + Lidarr InstanceMap[LidarrConfig] `json:"lidarr,omitempty" toml:"lidarr" xml:"lidarr" yaml:"lidarr,omitempty"` + Radarr InstanceMap[RadarrConfig] `json:"radarr,omitempty" toml:"radarr" xml:"radarr" yaml:"radarr,omitempty"` + Readarr InstanceMap[ReadarrConfig] `json:"readarr,omitempty" toml:"readarr" xml:"readarr" yaml:"readarr,omitempty"` + Sonarr InstanceMap[SonarrConfig] `json:"sonarr,omitempty" toml:"sonarr" xml:"sonarr" yaml:"sonarr,omitempty"` + Folders InstanceMap[FolderConfig] `json:"folder,omitempty" toml:"folder" xml:"folder" yaml:"folder,omitempty"` + Webhook InstanceMap[WebhookConfig] `json:"webhook,omitempty" toml:"webhook" xml:"webhook" yaml:"webhook,omitempty"` + Cmdhook InstanceMap[WebhookConfig] `json:"cmdhook,omitempty" toml:"cmdhook" xml:"cmdhook" yaml:"cmdhook,omitempty"` + Folder FoldersConfig `json:"folders" toml:"folders" xml:"folders" yaml:"folders"` // undocumented. } func (u *Unpackerr) watchWorkThread() { @@ -132,17 +132,17 @@ func (u *Unpackerr) ensureWorkThreads(count int) { func (u *Unpackerr) retrieveAppQueues(now time.Time) { wait := sync.WaitGroup{} wait.Add(u.starrAppCount()) - enqueueStarrPoll(u, u.Lidarr, starr.Lidarr, now, &wait) - enqueueStarrPoll(u, u.Radarr, starr.Radarr, now, &wait) - enqueueStarrPoll(u, u.Readarr, starr.Readarr, now, &wait) - enqueueStarrPoll(u, u.Sonarr, starr.Sonarr, now, &wait) + enqueueStarrPoll[LidarrConfig, *LidarrConfig](u, u.Lidarr, starr.Lidarr, now, &wait) + enqueueStarrPoll[RadarrConfig, *RadarrConfig](u, u.Radarr, starr.Radarr, now, &wait) + enqueueStarrPoll[ReadarrConfig, *ReadarrConfig](u, u.Readarr, starr.Readarr, now, &wait) + enqueueStarrPoll[SonarrConfig, *SonarrConfig](u, u.Sonarr, starr.Sonarr, now, &wait) wait.Wait() // These are not thread safe because they call saveCompletedDownload. - checkStarrQueue(u, u.Lidarr, starr.Lidarr, now) - checkStarrQueue(u, u.Radarr, starr.Radarr, now) - checkStarrQueue(u, u.Readarr, starr.Readarr, now) - checkStarrQueue(u, u.Sonarr, starr.Sonarr, now) + checkStarrQueue[LidarrConfig, *LidarrConfig](u, u.Lidarr, starr.Lidarr, now) + checkStarrQueue[RadarrConfig, *RadarrConfig](u, u.Radarr, starr.Radarr, now) + checkStarrQueue[ReadarrConfig, *ReadarrConfig](u, u.Readarr, starr.Readarr, now) + checkStarrQueue[SonarrConfig, *SonarrConfig](u, u.Sonarr, starr.Sonarr, now) u.sweepForgotten() } @@ -150,10 +150,10 @@ func (u *Unpackerr) retrieveAppQueues(now time.Time) { func (u *Unpackerr) validateApps() error { for _, validate := range []func() error{ u.validateRemnantAction, - func() error { return validateStarrList(u, &u.Lidarr, starr.Lidarr) }, - func() error { return validateStarrList(u, &u.Radarr, starr.Radarr) }, - func() error { return validateStarrList(u, &u.Readarr, starr.Readarr) }, - func() error { return validateStarrList(u, &u.Sonarr, starr.Sonarr) }, + func() error { return validateStarrList[LidarrConfig, *LidarrConfig](u, u.Lidarr, starr.Lidarr) }, + func() error { return validateStarrList[RadarrConfig, *RadarrConfig](u, u.Radarr, starr.Radarr) }, + func() error { return validateStarrList[ReadarrConfig, *ReadarrConfig](u, u.Readarr, starr.Readarr) }, + func() error { return validateStarrList[SonarrConfig, *SonarrConfig](u, u.Sonarr, starr.Sonarr) }, u.validateFolders, } { if err := validate(); err != nil { @@ -162,10 +162,10 @@ func (u *Unpackerr) validateApps() error { } seen := make(map[string]string) - warnDuplicateStarrNames(u, seen, starr.Lidarr, u.Lidarr) - warnDuplicateStarrNames(u, seen, starr.Radarr, u.Radarr) - warnDuplicateStarrNames(u, seen, starr.Readarr, u.Readarr) - warnDuplicateStarrNames(u, seen, starr.Sonarr, u.Sonarr) + warnDuplicateStarrNames[LidarrConfig, *LidarrConfig](u, seen, starr.Lidarr, u.Lidarr) + warnDuplicateStarrNames[RadarrConfig, *RadarrConfig](u, seen, starr.Radarr, u.Radarr) + warnDuplicateStarrNames[ReadarrConfig, *ReadarrConfig](u, seen, starr.Readarr, u.Readarr) + warnDuplicateStarrNames[SonarrConfig, *SonarrConfig](u, seen, starr.Sonarr, u.Sonarr) for _, validate := range []func() error{ u.validateCmdhook, @@ -182,13 +182,13 @@ func (u *Unpackerr) validateApps() error { func (u *Unpackerr) haveQitem(name string, app starr.App) bool { switch app { case starr.Lidarr: - return haveStarrQitem(u.Lidarr, name) + return haveStarrQitem[LidarrConfig, *LidarrConfig](u.Lidarr, name) case starr.Radarr: - return haveStarrQitem(u.Radarr, name) + return haveStarrQitem[RadarrConfig, *RadarrConfig](u.Radarr, name) case starr.Readarr: - return haveStarrQitem(u.Readarr, name) + return haveStarrQitem[ReadarrConfig, *ReadarrConfig](u.Readarr, name) case starr.Sonarr: - return haveStarrQitem(u.Sonarr, name) + return haveStarrQitem[SonarrConfig, *SonarrConfig](u.Sonarr, name) default: return false } diff --git a/pkg/unpackerr/cnfgfile.go b/pkg/unpackerr/cnfgfile.go index a390d97d..3f3c805a 100644 --- a/pkg/unpackerr/cnfgfile.go +++ b/pkg/unpackerr/cnfgfile.go @@ -66,7 +66,8 @@ func (u *Unpackerr) unmarshalConfig() (uint64, uint64, string, error) { msg = msgConfigCreate + u.ConfigFileWithAge() } - // File snapshot first so UN_* overlays stay on the live Config and never get written back. + // File snapshot first so ParseENV overlays onto live only; PUT writes the + // request body, not this overlay. u.snapshotFileConfig() res, err := cnfg.ParseENV(u.Config, u.EnvPrefix) diff --git a/pkg/unpackerr/cnfgfile_test.go b/pkg/unpackerr/cnfgfile_test.go index 964d69a6..c304e247 100644 --- a/pkg/unpackerr/cnfgfile_test.go +++ b/pkg/unpackerr/cnfgfile_test.go @@ -161,10 +161,10 @@ func TestWriteConfigFileKeepsFilepathAfterParse(t *testing.T) { unpack := New() unpack.ConfigFile = filepath.Join(dir, "unpackerr.conf") - unpack.Sonarr = []*SonarrConfig{{ + unpack.Sonarr = instanceMap([]*SonarrConfig{{ URL: "http://127.0.0.1:8989", APIKey: filePrefix + keyFile, - }} + }}) unpack.snapshotFileConfig() @@ -172,8 +172,8 @@ func TestWriteConfigFileKeepsFilepathAfterParse(t *testing.T) { t.Fatal(err) } - if unpack.Sonarr[0].APIKey != secret { - t.Fatalf("Parse should expand api_key, got %q", unpack.Sonarr[0].APIKey) + if unpack.Sonarr["0"].APIKey != secret { + t.Fatalf("Parse should expand api_key, got %q", unpack.Sonarr["0"].APIKey) } if err := unpack.writeConfigFile(); err != nil { @@ -195,8 +195,9 @@ func TestWriteConfigFileKeepsFilepathAfterParse(t *testing.T) { t.Fatalf("decode: %v\n%s", err, text) } - if len(loaded.Sonarr) != 1 || loaded.Sonarr[0].APIKey != filePrefix+keyFile { - t.Fatalf("api_key %q", loaded.Sonarr[0].APIKey) + got := loaded.Sonarr["0"] + if len(loaded.Sonarr) != 1 || got == nil || got.APIKey != filePrefix+keyFile { + t.Fatalf("api_key %+v\n%s", loaded.Sonarr, text) } } @@ -226,7 +227,7 @@ func TestUnmarshalConfigDoesNotPersistEnvSecrets(t *testing.T) { t.Fatal("live config should take UN_DEBUG") } - if len(unpack.Sonarr) != 1 || unpack.Sonarr[0].APIKey != secret { + if len(unpack.Sonarr) != 1 || unpack.Sonarr["0"].APIKey != secret { t.Fatalf("live sonarr %+v", unpack.Sonarr) } @@ -257,6 +258,56 @@ func TestUnmarshalConfigDoesNotPersistEnvSecrets(t *testing.T) { } } +func TestUnmarshalConfigKeepsFileInstanceFieldsAcrossEnvURL(t *testing.T) { + dir := t.TempDir() + conf := filepath.Join(dir, "unpackerr.conf") + secret := strings.Repeat("F", 32) + body := "[webserver]\nlisten_addr = \"127.0.0.1:0\"\nui_password = \"\"\n" + + "[sonarr.0]\nurl = \"http://file.invalid:8989\"\napi_key = \"" + secret + "\"\n" + + "name = \"uhd\"\npaths = [\"/downloads/tv\"]\n" + + if err := os.WriteFile(conf, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + t.Setenv("UN_SONARR_0_URL", "http://127.0.0.1:8989") + + unpack := New() + unpack.ConfigFile = conf + + if _, _, _, err := unpack.unmarshalConfig(); err != nil { + t.Fatal(err) + } + + got := unpack.Sonarr["0"] + if got == nil { + t.Fatal("live sonarr.0 missing") + } + + if got.URL != "http://127.0.0.1:8989" { + t.Fatalf("live url %q", got.URL) + } + + if got.APIKey != secret || got.Name != "uhd" || len(got.Paths) != 1 || got.Paths[0] != "/downloads/tv" { + t.Fatalf("ParseENV replaced file instance fields: %+v", got) + } + + file := unpack.fileConfig.Sonarr["0"] + if file == nil || file.URL != "http://file.invalid:8989" || file.APIKey != secret { + t.Fatalf("file snapshot took the env URL: %+v", unpack.fileConfig.Sonarr) + } + + written, err := os.ReadFile(conf) + if err != nil { + t.Fatal(err) + } + + text := string(written) + if strings.Contains(text, "http://127.0.0.1:8989") { + t.Fatalf("env URL leaked into the config file:\n%s", text) + } +} + func TestUnmarshalConfigEnvUIPasswordStaysOutOfFile(t *testing.T) { dir := t.TempDir() conf := filepath.Join(dir, "unpackerr.conf") @@ -377,13 +428,13 @@ func liveWriteUnpackerr(dir, passFile string) *Unpackerr { unpack.Config.Debug = true unpack.Passwords = StringSlice{"filepath:" + passFile} unpack.Webserver.Pprof = true - unpack.Folders = []*FolderConfig{{Path: "/downloads/watch"}} - unpack.Webhook = []*WebhookConfig{{ + unpack.Folders = instanceMap([]*FolderConfig{{Path: "/downloads/watch"}}) + unpack.Webhook = instanceMap([]*WebhookConfig{{ URL: "https://example.invalid/hook", Token: "tok", Events: ExtractStatuses{QUEUED, EXTRACTED}, - }} - unpack.Sonarr = []*SonarrConfig{{ + }}) + unpack.Sonarr = instanceMap([]*SonarrConfig{{ URL: "http://127.0.0.1:8989", APIKey: strings.Repeat("a", 32), HTTPUser: "basicuser", @@ -392,7 +443,7 @@ func liveWriteUnpackerr(dir, passFile string) *Unpackerr { Password: "nativepass", ValidSSL: true, Paths: StringSlice{"/custom"}, - }} + }}) return unpack } @@ -432,20 +483,20 @@ func assertLiveWriteLoaded(t *testing.T, loaded *Unpackerr, passFile string) { t.Fatal("pprof") case len(loaded.Passwords) != 1 || loaded.Passwords[0] != "filepath:"+passFile: t.Fatalf("passwords %q", loaded.Passwords) - case len(loaded.Folders) != 1 || loaded.Folders[0].Path != "/downloads/watch": + case len(loaded.Folders) != 1 || loaded.Folders["0"].Path != "/downloads/watch": t.Fatal("folder path") - case loaded.Folders[0].DeleteAfter != nil: + case loaded.Folders["0"].DeleteAfter != nil: t.Fatal("nil delete_after should stay unset") - case len(loaded.Webhook) != 1 || loaded.Webhook[0].Token != "tok": + case len(loaded.Webhook) != 1 || loaded.Webhook["0"].Token != "tok": t.Fatal("webhook token") - case len(loaded.Webhook[0].Events) != 2 || - loaded.Webhook[0].Events[0] != QUEUED || loaded.Webhook[0].Events[1] != EXTRACTED: - t.Fatalf("webhook events %v", loaded.Webhook[0].Events) - case len(loaded.Sonarr) != 1 || !loaded.Sonarr[0].ValidSSL: + case len(loaded.Webhook["0"].Events) != 2 || + loaded.Webhook["0"].Events[0] != QUEUED || loaded.Webhook["0"].Events[1] != EXTRACTED: + t.Fatalf("webhook events %v", loaded.Webhook["0"].Events) + case len(loaded.Sonarr) != 1 || !loaded.Sonarr["0"].ValidSSL: t.Fatal("valid_ssl") - case loaded.Sonarr[0].HTTPUser != "basicuser" || loaded.Sonarr[0].HTTPPass != "basicpass": + case loaded.Sonarr["0"].HTTPUser != "basicuser" || loaded.Sonarr["0"].HTTPPass != "basicpass": t.Fatal("http basic auth") - case loaded.Sonarr[0].Username != "nativeuser" || loaded.Sonarr[0].Password != "nativepass": + case loaded.Sonarr["0"].Username != "nativeuser" || loaded.Sonarr["0"].Password != "nativepass": t.Fatal("native auth") } } @@ -552,31 +603,31 @@ func TestWriteConfigFileFullRoundTrip(t *testing.T) { //nolint:funlen // one fie } } - unpack.Sonarr = []*SonarrConfig{{StarrConfig: starrConf("http://sonarr:8989")}} - unpack.Radarr = []*RadarrConfig{ + unpack.Sonarr = instanceMap([]*SonarrConfig{{StarrConfig: starrConf("http://sonarr:8989")}}) + unpack.Radarr = instanceMap([]*RadarrConfig{ {StarrConfig: starrConf("http://radarr:7878")}, {StarrConfig: starrConf("http://whisparr:6969")}, - } - unpack.Lidarr = []*LidarrConfig{{StarrConfig: starrConf("http://lidarr:8686"), SplitFlac: true}} - unpack.Readarr = []*ReadarrConfig{{StarrConfig: starrConf("http://readarr:8787")}} - unpack.Readarr[0].APIKey = starrKey + }) + unpack.Lidarr = instanceMap([]*LidarrConfig{{StarrConfig: starrConf("http://lidarr:8686"), SplitFlac: true}}) + unpack.Readarr = instanceMap([]*ReadarrConfig{{StarrConfig: starrConf("http://readarr:8787")}}) + unpack.Readarr["0"].APIKey = starrKey unpack.Folder.Interval = cnfg.Duration{Duration: 4 * time.Second} unpack.Folder.Buffer = 5000 - unpack.Folders = []*FolderConfig{{ + unpack.Folders = instanceMap([]*FolderConfig{{ Path: "/watch", ExtractPath: "/extracted", DeleteOrig: true, MoveBack: true, ExtractISOs: true, DeleteAfter: &cnfg.Duration{Duration: 11 * time.Minute}, MaxNested: 2, MaxFiles: 99, MaxRatio: 3.5, ExcludePaths: []string{"/watch/skip"}, - }} - unpack.Webhook = []*WebhookConfig{{ //nolint:gosec // filepath: reference, not a credential. + }}) + unpack.Webhook = instanceMap([]*WebhookConfig{{ //nolint:gosec // filepath: reference, not a credential. Name: "discord", URL: "https://discord.example/hook", CType: "text/plain", Timeout: cnfg.Duration{Duration: 9 * time.Second}, IgnoreSSL: true, Silent: true, Events: ExtractStatuses{EXTRACTED, EXTRACTFAILED}, Exclude: hooks.StringSlice{"lidarr"}, Nickname: "Bot", Token: "filepath:/run/secrets/hook", Channel: "general", - }} - unpack.Cmdhook = []*WebhookConfig{{ + }}) + unpack.Cmdhook = instanceMap([]*WebhookConfig{{ Name: "notify", Command: "/usr/local/bin/notify.sh --flag", Shell: true, Timeout: cnfg.Duration{Duration: 5 * time.Second}, Events: ExtractStatuses{IMPORTED}, - }} + }}) unpack.snapshotFileConfig() if err := unpack.writeConfigFile(); err != nil { @@ -666,12 +717,12 @@ func TestValidateSonarrSkipsShortAPIKey(t *testing.T) { t.Parallel() unpack := New() - unpack.Sonarr = []*SonarrConfig{{ + unpack.Sonarr = instanceMap([]*SonarrConfig{{ URL: "http://127.0.0.1:8989", APIKey: "short", - }} + }}) - if err := validateStarrList(unpack, &unpack.Sonarr, starr.Sonarr); err != nil { + if err := validateStarrList[SonarrConfig, *SonarrConfig](unpack, unpack.Sonarr, starr.Sonarr); err != nil { t.Fatal(err) } @@ -742,13 +793,13 @@ func TestValidateStarrListRejectsBadName(t *testing.T) { t.Parallel() unpack := New() - unpack.Sonarr = []*SonarrConfig{{ + unpack.Sonarr = instanceMap([]*SonarrConfig{{ Name: `Sport"arr`, URL: "http://127.0.0.1:8989", APIKey: strings.Repeat("a", apiKeyMinLength), - }} + }}) - err := validateStarrList(unpack, &unpack.Sonarr, starr.Sonarr) + err := validateStarrList[SonarrConfig, *SonarrConfig](unpack, unpack.Sonarr, starr.Sonarr) if !errors.Is(err, ErrInvalidName) { t.Fatalf("got %v want ErrInvalidName", err) } diff --git a/pkg/unpackerr/configapi.go b/pkg/unpackerr/configapi.go index 82c4ea51..a994e1bd 100644 --- a/pkg/unpackerr/configapi.go +++ b/pkg/unpackerr/configapi.go @@ -34,9 +34,9 @@ type generalConfig struct { // foldersConfigAPI is global folder poller settings plus the watch list. type foldersConfigAPI struct { - Interval cnfg.Duration `json:"interval"` - Buffer uint `json:"buffer"` - Folder []*FolderConfig `json:"folder"` + Interval cnfg.Duration `json:"interval"` + Buffer uint `json:"buffer"` + Folder InstanceMap[FolderConfig] `json:"folder"` } func (u *Unpackerr) requireConfigPerm(write bool, next http.HandlerFunc) http.HandlerFunc { @@ -96,7 +96,9 @@ func (u *Unpackerr) configGetLiveHandler(response http.ResponseWriter, request * return nil } - payload = configSectionFrom(cloneConfig(u.Config), section) + cfg := cloneConfig(u.Config) + redactLiveSecrets(cfg) + payload = configSectionFrom(cfg, section) return nil }) @@ -120,19 +122,19 @@ func configSectionFrom(cfg *Config, section ConfigSection) any { return cfg.Webserver case SectionSonarr: - return emptyIfNil(cfg.Sonarr) + return emptyIfNilMap(cfg.Sonarr) case SectionRadarr: - return emptyIfNil(cfg.Radarr) + return emptyIfNilMap(cfg.Radarr) case SectionLidarr: - return emptyIfNil(cfg.Lidarr) + return emptyIfNilMap(cfg.Lidarr) case SectionReadarr: - return emptyIfNil(cfg.Readarr) + return emptyIfNilMap(cfg.Readarr) case SectionFolders: return foldersConfigFrom(cfg) case SectionWebhooks: - return emptyIfNil(cfg.Webhook) + return emptyIfNilMap(cfg.Webhook) case SectionCmdhooks: - return emptyIfNil(cfg.Cmdhook) + return emptyIfNilMap(cfg.Cmdhook) default: return nil } @@ -203,7 +205,7 @@ func foldersConfigFrom(cfg *Config) foldersConfigAPI { return foldersConfigAPI{ Interval: cfg.Folder.Interval, Buffer: cfg.Folder.Buffer, - Folder: emptyIfNil(cfg.Folders), + Folder: emptyIfNilMap(cfg.Folders), } } @@ -214,3 +216,39 @@ func emptyIfNil[T any](list []T) []T { return list } + +// redactLiveSecrets blanks instance secrets on a cloned live Config. File GET +// still shows file-owned keys; live GET does not leak env (or file) secrets. +func redactLiveSecrets(cfg *Config) { + if cfg == nil { + return + } + + redactLiveStarr[SonarrConfig, *SonarrConfig](cfg.Sonarr) + redactLiveStarr[RadarrConfig, *RadarrConfig](cfg.Radarr) + redactLiveStarr[LidarrConfig, *LidarrConfig](cfg.Lidarr) + redactLiveStarr[ReadarrConfig, *ReadarrConfig](cfg.Readarr) + redactHookSecrets(cfg.Webhook) + redactHookSecrets(cfg.Cmdhook) +} + +func redactLiveStarr[T any, P starrApp[T]](items InstanceMap[T]) { + for _, item := range items { + if item == nil { + continue + } + + conf := asStarr[T, P](item).conf() + conf.APIKey = "" + conf.Password = "" + conf.HTTPPass = "" + } +} + +func redactHookSecrets(items InstanceMap[WebhookConfig]) { + for _, hook := range items { + if hook != nil { + hook.Token = "" + } + } +} diff --git a/pkg/unpackerr/configapi_test.go b/pkg/unpackerr/configapi_test.go index 0eeda6ad..4da75e28 100644 --- a/pkg/unpackerr/configapi_test.go +++ b/pkg/unpackerr/configapi_test.go @@ -14,10 +14,10 @@ func TestConfigGetSection(t *testing.T) { unpack := testAuthUnpackerr(t) unpack.Config.Debug = true unpack.KeepHistory = 200 - unpack.Sonarr = []*SonarrConfig{{}} - unpack.Sonarr[0].Path = "/downloads" - unpack.Sonarr[0].URL = "http://127.0.0.1:8989" - unpack.Sonarr[0].APIKey = strings.Repeat("k", apiKeyMinLength) + unpack.Sonarr = instanceMap([]*SonarrConfig{{}}) + unpack.Sonarr["0"].Path = "/downloads" + unpack.Sonarr["0"].URL = "http://127.0.0.1:8989" + unpack.Sonarr["0"].APIKey = strings.Repeat("k", apiKeyMinLength) unpack.snapshotFileConfig() withKey := func(req *http.Request) { diff --git a/pkg/unpackerr/configclone.go b/pkg/unpackerr/configclone.go index 058b7aa1..beb53676 100644 --- a/pkg/unpackerr/configclone.go +++ b/pkg/unpackerr/configclone.go @@ -62,13 +62,13 @@ func cloneConfig(src *Config) *Config { dst := *src dst.Passwords = append(StringSlice(nil), src.Passwords...) dst.Webserver = cloneWebserver(src.Webserver) - dst.Lidarr = cloneStarrList(src.Lidarr) - dst.Radarr = cloneStarrList(src.Radarr) - dst.Readarr = cloneStarrList(src.Readarr) - dst.Sonarr = cloneStarrList(src.Sonarr) - dst.Folders = cloneFolderList(src.Folders) - dst.Webhook = hooks.CloneList(src.Webhook) - dst.Cmdhook = hooks.CloneList(src.Cmdhook) + dst.Lidarr = cloneStarrMap[LidarrConfig, *LidarrConfig](src.Lidarr) + dst.Radarr = cloneStarrMap[RadarrConfig, *RadarrConfig](src.Radarr) + dst.Readarr = cloneStarrMap[ReadarrConfig, *ReadarrConfig](src.Readarr) + dst.Sonarr = cloneStarrMap[SonarrConfig, *SonarrConfig](src.Sonarr) + dst.Folders = cloneFolderMap(src.Folders) + dst.Webhook = cloneHookMap(src.Webhook) + dst.Cmdhook = cloneHookMap(src.Cmdhook) return &dst } @@ -120,31 +120,68 @@ func cloneRoles(src map[string]Role) map[string]Role { return out } -// cloneStarrList copies a Starr list into its file shape: config only, +func asStarr[T any, P starrApp[T]](item *T) P { //nolint:ireturn // P is *T with the starr methods. + return any(item).(P) //nolint:forcetypeassert // callers only pass *T that implements starrApp. +} + +// cloneStarrMap copies a Starr map into its file shape: config only, // no queue, no client. Nil in, nil out so the TOML writer omits the table. -func cloneStarrList[T any, P starrApp[T]](src []P) []P { +func cloneStarrMap[T any, P starrApp[T]](src InstanceMap[T]) InstanceMap[T] { if src == nil { return nil } - out := make([]P, len(src)) + out := make(InstanceMap[T], len(src)) + + for key, app := range src { + if app == nil { + continue + } - for idx, app := range src { cloned := *app - out[idx] = &cloned + item := asStarr[T, P](&cloned) + item.conf().Paths = append(StringSlice(nil), asStarr[T, P](app).conf().Paths...) + item.stripRuntime() - out[idx].conf().Paths = append(StringSlice(nil), app.conf().Paths...) - out[idx].stripRuntime() + out[key] = &cloned } return out } -func cloneFolderList(src []*FolderConfig) []*FolderConfig { - return folders.CloneList(src) +func cloneFolderMap(src InstanceMap[FolderConfig]) InstanceMap[FolderConfig] { + if src == nil { + return nil + } + + out := make(InstanceMap[FolderConfig], len(src)) + for key, folder := range src { + if folder == nil { + continue + } + + cloned := folders.CloneList([]*FolderConfig{folder}) + out[key] = cloned[0] + } + + return out } -// cloneHookList copies hooks without the mutex, counters, client, or template. -func cloneHookList(src []*WebhookConfig) []*WebhookConfig { - return hooks.CloneList(src) +// cloneHookMap copies hooks without the mutex, counters, client, or template. +func cloneHookMap(src InstanceMap[WebhookConfig]) InstanceMap[WebhookConfig] { + if src == nil { + return nil + } + + out := make(InstanceMap[WebhookConfig], len(src)) + for key, hook := range src { + if hook == nil { + continue + } + + cloned := hooks.CloneList([]*WebhookConfig{hook}) + out[key] = cloned[0] + } + + return out } diff --git a/pkg/unpackerr/configdump.go b/pkg/unpackerr/configdump.go index 4f6c69c0..14172716 100644 --- a/pkg/unpackerr/configdump.go +++ b/pkg/unpackerr/configdump.go @@ -59,19 +59,19 @@ func (u *Unpackerr) liveConfigText(info authInfo) string { // It does not mutate config; callers that need a normalized URL base do that first. func (u *Unpackerr) writeRunningConfig(printf configLine, auth dumpAuth) { if !auth.omit(printf, SectionSonarr, "Sonarr Config") { - logStarr(printf, starr.Sonarr, u.Sonarr) + logStarr[SonarrConfig, *SonarrConfig](printf, starr.Sonarr, u.Sonarr) } if !auth.omit(printf, SectionRadarr, "Radarr Config") { - logStarr(printf, starr.Radarr, u.Radarr) + logStarr[RadarrConfig, *RadarrConfig](printf, starr.Radarr, u.Radarr) } if !auth.omit(printf, SectionLidarr, "Lidarr Config") { - logStarr(printf, starr.Lidarr, u.Lidarr) + logStarr[LidarrConfig, *LidarrConfig](printf, starr.Lidarr, u.Lidarr) } if !auth.omit(printf, SectionReadarr, "Readarr Config") { - logStarr(printf, starr.Readarr, u.Readarr) + logStarr[ReadarrConfig, *ReadarrConfig](printf, starr.Readarr, u.Readarr) } if !auth.omit(printf, SectionFolders, "Folder Config") { @@ -120,29 +120,33 @@ func (u *Unpackerr) logGeneral(printf configLine) { } } -func logStarr[T any, P starrApp[T]](printf configLine, app starr.App, list []P) { - count := len(list) +func logStarr[T any, P starrApp[T]](printf configLine, app starr.App, list InstanceMap[T]) { + items := instanceValues(list) + + count := len(items) if count == 1 { - item := list[0] - c := item.conf() + item := items[0] + server := asStarr[T, P](item) + c := server.conf() printf(" => %s Config: 1 server: %s"+starrLogLine+"%s", app, starrNamePrefix(c.Name), c.URL, c.APIKey != "", c.Timeout.String(), c.ValidSSL, c.Protocols, c.Syncthing, c.DeleteOrig, c.DeleteDelay.String(), - logMaxBytes(c.MaxBytes, defaultAppMaxBytes(app)), c.Paths, item.logExtra()) + logMaxBytes(c.MaxBytes, defaultAppMaxBytes(app)), c.Paths, server.logExtra()) return } printf(" => %s Config: %d servers", app, count) - for _, item := range list { - c := item.conf() + for _, item := range items { + server := asStarr[T, P](item) + c := server.conf() printf(starrLogPfx+"%s"+starrLogLine+"%s", starrNamePrefix(c.Name), c.URL, c.APIKey != "", c.Timeout.String(), c.ValidSSL, c.Protocols, c.Syncthing, c.DeleteOrig, c.DeleteDelay.String(), - logMaxBytes(c.MaxBytes, defaultAppMaxBytes(app)), c.Paths, item.logExtra()) + logMaxBytes(c.MaxBytes, defaultAppMaxBytes(app)), c.Paths, server.logExtra()) } } @@ -164,8 +168,9 @@ func logMaxBytes(configured, fallback string) string { } func (u *Unpackerr) logFolders(printf configLine) { - if epath, count := "", len(u.Folders); count == 1 { - folder := u.Folders[0] + folders := instanceValues(u.Folders) + if epath, count := "", len(folders); count == 1 { + folder := folders[0] if folder.ExtractPath != "" { epath = ", extract to: " + folder.ExtractPath } @@ -180,7 +185,7 @@ func (u *Unpackerr) logFolders(printf configLine) { } else { printf(" => Folder Config: %d paths, event_buffer:%d ", count, u.Folder.Buffer) - for _, folder := range u.Folders { + for _, folder := range folders { if epath = ""; folder.ExtractPath != "" { epath = " extract to: " + folder.ExtractPath } @@ -198,14 +203,15 @@ func (u *Unpackerr) logFolders(printf configLine) { func (u *Unpackerr) logWebhook(printf configLine) { var vars, prefix string - if len(u.Webhook) == 1 { + list := instanceValues(u.Webhook) + if len(list) == 1 { prefix = " => Webhook Config: 1 URL" } else { - printf(" => Webhook Configs: %d URLs", len(u.Webhook)) + printf(" => Webhook Configs: %d URLs", len(list)) prefix = " => URL" //nolint:wsl_v5 } - for _, hook := range u.Webhook { + for _, hook := range list { if vars = ""; hook.TmplPath != "" { vars = ", template: " + hook.TmplPath + ", content_type: " + hook.CType } @@ -230,14 +236,15 @@ func (u *Unpackerr) logWebhook(printf configLine) { func (u *Unpackerr) logCmdhook(printf configLine) { var prefix string - if len(u.Cmdhook) == 1 { + cmds := instanceValues(u.Cmdhook) + if len(cmds) == 1 { prefix = " => Command Hook Config: 1 cmd" } else { - printf(" => Command Hook Configs: %d commands", len(u.Cmdhook)) + printf(" => Command Hook Configs: %d commands", len(cmds)) prefix = " => Command" //nolint:wsl_v5 } - for _, hook := range u.Cmdhook { + for _, hook := range cmds { printf("%s: %s, timeout: %v, silent: %v, events: %v, shell: %v, cmd: %s", prefix, hook.Name, hook.Timeout, hook.Silent, hooks.LogEvents(hook.Events), hook.Shell, hook.Command) } diff --git a/pkg/unpackerr/configdump_test.go b/pkg/unpackerr/configdump_test.go index 23babba4..cd6b65fe 100644 --- a/pkg/unpackerr/configdump_test.go +++ b/pkg/unpackerr/configdump_test.go @@ -58,25 +58,25 @@ func TestRunningDumpPrintsPerAppMaxBytes(t *testing.T) { t.Parallel() unpack := testAuthUnpackerr(t) - unpack.Sonarr = []*SonarrConfig{{ + unpack.Sonarr = instanceMap([]*SonarrConfig{{ URL: "http://sonarr.test", Name: "Sportarr", - }} - unpack.Radarr = []*RadarrConfig{{ + }}) + unpack.Radarr = instanceMap([]*RadarrConfig{{ URL: "http://radarr.test", MaxBytes: "10GB", - }} - unpack.Lidarr = []*LidarrConfig{{ + }}) + unpack.Lidarr = instanceMap([]*LidarrConfig{{ URL: "http://lidarr.test", - }} - unpack.Readarr = []*ReadarrConfig{{ + }}) + unpack.Readarr = instanceMap([]*ReadarrConfig{{ URL: "http://readarr.test", MaxBytes: "0", - }} - unpack.Folders = []*FolderConfig{ + }}) + unpack.Folders = instanceMap([]*FolderConfig{ {Path: "/watch"}, {Path: "/capped", MaxBytes: "2GB"}, - } + }) got := dumpRunningConfig(unpack, dumpAuth{}) if strings.Contains(got, "Default Extract Limits") { @@ -114,10 +114,10 @@ func TestRunningDumpPrintsSingleFolderMaxBytes(t *testing.T) { t.Parallel() unpack := testAuthUnpackerr(t) - unpack.Folders = []*FolderConfig{{ + unpack.Folders = instanceMap([]*FolderConfig{{ Path: "/watch", MaxBytes: "2GB", - }} + }}) got := dumpRunningConfig(unpack, dumpAuth{}) @@ -149,13 +149,13 @@ func TestLiveConfigOmitsWithoutSectionRead(t *testing.T) { t.Parallel() unpack := testAuthUnpackerr(t) - unpack.Webhook = []*WebhookConfig{{ + unpack.Webhook = instanceMap([]*WebhookConfig{{ Name: "https://example.com/hook?token=hook-secret", - }} - unpack.Cmdhook = []*WebhookConfig{{ + }}) + unpack.Cmdhook = instanceMap([]*WebhookConfig{{ Name: "cmd", Command: "/usr/bin/env token=cmd-secret", - }} + }}) got := dumpRunningConfig(unpack, dumpAuth{ gated: true, diff --git a/pkg/unpackerr/configput.go b/pkg/unpackerr/configput.go index 5978b0a1..65c6fa77 100644 --- a/pkg/unpackerr/configput.go +++ b/pkg/unpackerr/configput.go @@ -84,25 +84,25 @@ func (u *Unpackerr) replaceConfigSection(section ConfigSection, raw json.RawMess case SectionWebserver: return u.putWebserver(raw) case SectionSonarr: - return false, putStarrList(u, raw, starr.Sonarr, SectionSonarr, - func(c *Config) *[]*SonarrConfig { return &c.Sonarr }) + return false, putStarrList[SonarrConfig, *SonarrConfig](u, raw, starr.Sonarr, SectionSonarr, + func(c *Config) *InstanceMap[SonarrConfig] { return &c.Sonarr }) case SectionRadarr: - return false, putStarrList(u, raw, starr.Radarr, SectionRadarr, - func(c *Config) *[]*RadarrConfig { return &c.Radarr }) + return false, putStarrList[RadarrConfig, *RadarrConfig](u, raw, starr.Radarr, SectionRadarr, + func(c *Config) *InstanceMap[RadarrConfig] { return &c.Radarr }) case SectionLidarr: - return false, putStarrList(u, raw, starr.Lidarr, SectionLidarr, - func(c *Config) *[]*LidarrConfig { return &c.Lidarr }) + return false, putStarrList[LidarrConfig, *LidarrConfig](u, raw, starr.Lidarr, SectionLidarr, + func(c *Config) *InstanceMap[LidarrConfig] { return &c.Lidarr }) case SectionReadarr: - return false, putStarrList(u, raw, starr.Readarr, SectionReadarr, - func(c *Config) *[]*ReadarrConfig { return &c.Readarr }) + return false, putStarrList[ReadarrConfig, *ReadarrConfig](u, raw, starr.Readarr, SectionReadarr, + func(c *Config) *InstanceMap[ReadarrConfig] { return &c.Readarr }) case SectionFolders: return u.putFolders(raw) case SectionWebhooks: return false, u.putHooks(raw, u.validateWebhookList, SectionWebhooks, - func(c *Config) *[]*WebhookConfig { return &c.Webhook }) + func(c *Config) *InstanceMap[WebhookConfig] { return &c.Webhook }) case SectionCmdhooks: return false, u.putHooks(raw, u.validateCmdhookList, SectionCmdhooks, - func(c *Config) *[]*WebhookConfig { return &c.Cmdhook }) + func(c *Config) *InstanceMap[WebhookConfig] { return &c.Cmdhook }) default: return false, fmt.Errorf("%w: %s", errUnknownSection, section) } @@ -342,9 +342,10 @@ func (u *Unpackerr) cloneFileConfig() *Config { return cloneConfig(u.fileConfig) } -// overlayEnv applies the UN_* values captured at startup onto cfg. PUT writes -// the file-shaped payload, then live is this overlay so env-only list rows -// (Readarr, folders, …) cannot be wiped by a save that omitted them. +// overlayEnv applies current UN_* values onto cfg. PUT writes the request body +// as the file document; live is clone(file)+ParseENV so env-only slugs still +// appear after a save that omitted them. A client that PUTs live overlay values +// persists them — there is no peel safety net. func (u *Unpackerr) overlayEnv(cfg *Config) error { if cfg == nil || len(u.envUsed) == 0 { return nil @@ -681,14 +682,15 @@ func normalizeStoredPassword(pass *CryptPass, fallback string, allowPlain bool) return pass.Set(user, secret) } -// putStarrList replaces one Starr app list. The file copy keeps filepath: -// values; the live copy is expanded, validated, and given clients. Queues -// carry over by url+apikey so an unchanged app keeps polling state. +// putStarrList replaces one Starr app map. The file copy is the PUT body +// (filepath: kept as written); the live copy is that body plus ParseENV, +// then expanded, validated, and given clients. Queues carry over by +// url+apikey so an unchanged app keeps polling. func putStarrList[T any, P starrApp[T]]( - unpackerr *Unpackerr, raw json.RawMessage, app starr.App, section ConfigSection, field func(*Config) *[]P, + unpackerr *Unpackerr, raw json.RawMessage, app starr.App, section ConfigSection, field func(*Config) *InstanceMap[T], ) error { - var list []P - if err := unmarshalList(raw, &list); err != nil { + var list InstanceMap[T] + if err := unmarshalInstances(raw, &list); err != nil { return err } @@ -696,10 +698,10 @@ func putStarrList[T any, P starrApp[T]]( return err } - fileList := cloneStarrList(list) + fileList := cloneStarrMap[T, P](list) preview, err := unpackerr.applyEnvOverlay(func(cfg *Config) { - *field(cfg) = cloneStarrList(list) + *field(cfg) = cloneStarrMap[T, P](list) }) if err != nil { return err @@ -710,21 +712,26 @@ func putStarrList[T any, P starrApp[T]]( return err } - for _, item := range liveList { + for key, item := range liveList { + if err := validateInstanceSlug(key); err != nil { + return err + } + if item == nil { return errNilConfigEntry } - if err := unpackerr.validateApp(item.conf(), app); err != nil { + server := asStarr[T, P](item) + if err := unpackerr.validateApp(server.conf(), app); err != nil { return err } - item.connect() + server.connect() } return unpackerr.commitConfig(func(cfg *Config) { *field(cfg) = fileList }, func() { live := field(unpackerr.Config) - carryQueues(*live, liveList) + carryQueues[T, P](*live, liveList) *live = liveList unpackerr.ensureWorkThreads(unpackerr.starrAppCount()) @@ -735,15 +742,25 @@ func starrIdentity(conf *StarrConfig) string { return conf.URL + "\x00" + conf.APIKey } -func carryQueues[T any, P starrApp[T]](prev, next []P) { +func carryQueues[T any, P starrApp[T]](prev, next InstanceMap[T]) { seen := make(map[string]P, len(prev)) for _, app := range prev { - seen[starrIdentity(app.conf())] = app + if app == nil { + continue + } + + server := asStarr[T, P](app) + seen[starrIdentity(server.conf())] = server } for _, app := range next { - if old, ok := seen[starrIdentity(app.conf())]; ok { - app.takeQueue(old) + if app == nil { + continue + } + + server := asStarr[T, P](app) + if old, ok := seen[starrIdentity(server.conf())]; ok { + server.takeQueue(old) } } } @@ -754,7 +771,11 @@ func (u *Unpackerr) putFolders(raw json.RawMessage) (bool, error) { return false, err } - for _, folder := range next.Folder { + for key, folder := range next.Folder { + if err := validateInstanceSlug(key); err != nil { + return false, err + } + if folder == nil { return false, errNilConfigEntry } @@ -764,12 +785,12 @@ func (u *Unpackerr) putFolders(raw json.RawMessage) (bool, error) { return false, err } - fileList := cloneFolderList(next.Folder) + fileList := cloneFolderMap(next.Folder) preview, err := u.applyEnvOverlay(func(cfg *Config) { cfg.Folder.Interval = next.Interval cfg.Folder.Buffer = next.Buffer - cfg.Folders = cloneFolderList(next.Folder) + cfg.Folders = cloneFolderMap(next.Folder) }) if err != nil { return false, err @@ -797,12 +818,12 @@ func (u *Unpackerr) putFolders(raw json.RawMessage) (bool, error) { func (u *Unpackerr) putHooks( raw json.RawMessage, - validate func([]*WebhookConfig) error, + validate func(InstanceMap[WebhookConfig]) error, section ConfigSection, - field func(*Config) *[]*WebhookConfig, + field func(*Config) *InstanceMap[WebhookConfig], ) error { - var list []*WebhookConfig - if err := unmarshalList(raw, &list); err != nil { + var list InstanceMap[WebhookConfig] + if err := unmarshalInstances(raw, &list); err != nil { return err } @@ -810,10 +831,10 @@ func (u *Unpackerr) putHooks( return err } - fileList := cloneHookList(list) + fileList := cloneHookMap(list) preview, err := u.applyEnvOverlay(func(cfg *Config) { - *field(cfg) = cloneHookList(list) + *field(cfg) = cloneHookMap(list) }) if err != nil { return err diff --git a/pkg/unpackerr/configput_test.go b/pkg/unpackerr/configput_test.go index a47f948b..7ee94b3c 100644 --- a/pkg/unpackerr/configput_test.go +++ b/pkg/unpackerr/configput_test.go @@ -254,7 +254,7 @@ func TestConfigPutSonarrValidates(t *testing.T) { t.Fatalf("good %d %s", good.Code, good.Body.String()) } - if len(unpack.Sonarr) != 1 || unpack.Sonarr[0].URL != "http://127.0.0.1:8989" { + if len(unpack.Sonarr) != 1 || unpack.Sonarr["0"].URL != "http://127.0.0.1:8989" { t.Fatalf("sonarr %+v", unpack.Sonarr) } @@ -266,6 +266,14 @@ func TestConfigPutSonarrValidates(t *testing.T) { if !strings.Contains(string(written), "http://127.0.0.1:8989") { t.Fatalf("sonarr PUT missed fileConfig:\n%s", written) } + + if !strings.Contains(string(written), "[sonarr.0]") { + t.Fatalf("sonarr PUT should write named tables:\n%s", written) + } + + if strings.Contains(string(written), "[[sonarr]]") { + t.Fatalf("sonarr PUT wrote a 0.x array table:\n%s", written) + } } func TestConfigPutLidarrURLNeedsAPIKey(t *testing.T) { @@ -519,14 +527,14 @@ func TestConfigPutSonarrPreservesQueueAndPath(t *testing.T) { app := &SonarrConfig{Queue: queued} app.URL = "http://127.0.0.1:8989" app.APIKey = strings.Repeat("k", apiKeyMinLength) - unpack.Sonarr = []*SonarrConfig{app} + unpack.Sonarr = instanceMap([]*SonarrConfig{app}) body := `[{"url":"http://127.0.0.1:8989","apiKey":"` + strings.Repeat("k", apiKeyMinLength) + `","path":"/dl"}]` if rec := doAuth(t, unpack, http.MethodPut, "/api/config/sonarr", body, key); rec.Code != http.StatusOK { t.Fatalf("first put %d %s", rec.Code, rec.Body.String()) } - if unpack.Sonarr[0].Queue != queued { + if unpack.Sonarr["0"].Queue != queued { t.Fatal("PUT dropped last-known Starr queue") } @@ -536,14 +544,14 @@ func TestConfigPutSonarrPreservesQueueAndPath(t *testing.T) { var hits int - for _, path := range unpack.Sonarr[0].Paths { + for _, path := range unpack.Sonarr["0"].Paths { if path == "/dl" { hits++ } } if hits != 1 { - t.Fatalf("path merged %d times: %+v", hits, unpack.Sonarr[0].Paths) + t.Fatalf("path merged %d times: %+v", hits, unpack.Sonarr["0"].Paths) } } @@ -1003,7 +1011,7 @@ func TestEnsureWorkThreadsGrowsWithApps(t *testing.T) { t.Fatalf("floor workers %d", unpack.workThreads) } - unpack.Sonarr = []*SonarrConfig{{}, {}, {}} + unpack.Sonarr = instanceMap([]*SonarrConfig{{}, {}, {}}) unpack.ensureWorkThreads(unpack.starrAppCount()) if unpack.workThreads != 3 { @@ -1124,7 +1132,7 @@ func TestConfigPutStarrFilepathKeyExpandsLiveOnly(t *testing.T) { app := &SonarrConfig{} app.URL = "http://127.0.0.1:8989" app.APIKey = filePrefix + keyFile - unpack.fileConfig.Sonarr = []*SonarrConfig{app} + unpack.fileConfig.Sonarr = instanceMap([]*SonarrConfig{app}) body, err := json.Marshal([]map[string]string{{"url": "http://127.0.0.1:8989", "apiKey": filePrefix + keyFile}}) if err != nil { @@ -1139,11 +1147,11 @@ func TestConfigPutStarrFilepathKeyExpandsLiveOnly(t *testing.T) { t.Fatalf("put %d %s", put.Code, put.Body.String()) } - if got := unpack.Sonarr[0].APIKey; got != secret { + if got := unpack.Sonarr["0"].APIKey; got != secret { t.Fatalf("live api key %q, want the file contents", got) } - if got := unpack.fileConfig.Sonarr[0].APIKey; got != "filepath:"+keyFile { + if got := unpack.fileConfig.Sonarr["0"].APIKey; got != "filepath:"+keyFile { t.Fatalf("file api key %q, want filepath: kept", got) } @@ -1305,7 +1313,7 @@ func TestConfigPutReplacesFilepathWithLiteral(t *testing.T) { app := &SonarrConfig{} app.URL = "http://127.0.0.1:8989" app.APIKey = filePrefix + "/run/secrets/sonarr" - unpack.fileConfig.Sonarr = []*SonarrConfig{app} + unpack.fileConfig.Sonarr = instanceMap([]*SonarrConfig{app}) literal := strings.Repeat("k", apiKeyMinLength) body := `[{"url":"http://127.0.0.1:8989","apiKey":"` + literal + `"}]` @@ -1315,11 +1323,11 @@ func TestConfigPutReplacesFilepathWithLiteral(t *testing.T) { t.Fatalf("replace filepath: put %d %s", rec.Code, rec.Body.String()) } - if got := unpack.Sonarr[0].APIKey; got != literal { + if got := unpack.Sonarr["0"].APIKey; got != literal { t.Fatalf("live api key %q", got) } - if got := unpack.fileConfig.Sonarr[0].APIKey; got != literal { + if got := unpack.fileConfig.Sonarr["0"].APIKey; got != literal { t.Fatalf("file api key %q", got) } } @@ -1523,10 +1531,12 @@ func TestConfigPutCanceledRequestIsGatewayTimeout(t *testing.T) { } } -func envReadarrUnpackerr(t *testing.T, url, secret string) *Unpackerr { +const envReadarrURL = "http://readarr:8787/readarr" + +func envReadarrUnpackerr(t *testing.T, secret string) *Unpackerr { t.Helper() - t.Setenv("UN_READARR_0_URL", url) + t.Setenv("UN_READARR_0_URL", envReadarrURL) t.Setenv("UN_READARR_0_API_KEY", secret) unpack := testAuthUnpackerr(t) @@ -1546,10 +1556,9 @@ func envReadarrUnpackerr(t *testing.T, url, secret string) *Unpackerr { // A save of [] must not drop an env-only Starr instance from live. func TestConfigPutReadarrEmptyKeepsEnv(t *testing.T) { //nolint:paralleltest // t.Setenv cannot run with t.Parallel. secret := strings.Repeat("R", apiKeyMinLength) - url := "http://readarr:8787/readarr" - unpack := envReadarrUnpackerr(t, url, secret) + unpack := envReadarrUnpackerr(t, secret) - put := doAuth(t, unpack, http.MethodPut, "/api/config/readarr", `[]`, func(req *http.Request) { + put := doAuth(t, unpack, http.MethodPut, "/api/config/readarr", `{}`, func(req *http.Request) { req.Header.Set(headerAPIKey, unpack.Webserver.adminAPIKey()) }) if put.Code != http.StatusOK { @@ -1560,34 +1569,327 @@ func TestConfigPutReadarrEmptyKeepsEnv(t *testing.T) { //nolint:paralleltest // t.Fatalf("file readarr %+v", unpack.fileConfig.Readarr) } - if len(unpack.Readarr) != 1 || unpack.Readarr[0].URL != url || unpack.Readarr[0].APIKey != secret { + got := unpack.Readarr["0"] + if len(unpack.Readarr) != 1 || got == nil || got.URL != envReadarrURL || got.APIKey != secret { t.Fatalf("live readarr %+v", unpack.Readarr) } } -// Name is not an env field; PUT can write it to the file while env still fills URL/key. +// A name-only stub is written to the file. Env still fills live URL/key. func TestConfigPutReadarrNameKeepsEnv(t *testing.T) { //nolint:paralleltest // t.Setenv cannot run with t.Parallel. secret := strings.Repeat("R", apiKeyMinLength) - url := "http://readarr:8787/readarr" - unpack := envReadarrUnpackerr(t, url, secret) + unpack := envReadarrUnpackerr(t, secret) + + put := doAuth(t, unpack, http.MethodPut, "/api/config/readarr", `{"0":{"name":"books"}}`, func(req *http.Request) { + req.Header.Set(headerAPIKey, unpack.Webserver.adminAPIKey()) + }) + if put.Code != http.StatusOK { + t.Fatalf("put %d %s", put.Code, put.Body.String()) + } + + file := unpack.fileConfig.Readarr["0"] + if file == nil || file.Name != "books" { + t.Fatalf("name-only PUT missing from file: %+v", unpack.fileConfig.Readarr) + } + + if file.URL != "" || file.APIKey != "" { + t.Fatalf("file picked up env identity: %+v", file) + } + + got := unpack.Readarr["0"] + if got == nil || got.Name != "books" || got.URL != envReadarrURL || got.APIKey != secret { + name, gotURL, key := "", "", "" + if got != nil { + name, gotURL, key = got.Name, got.URL, got.APIKey + } + + t.Fatalf("live name=%q url=%q key=%q", name, gotURL, key) + } +} + +func TestConfigPutOtherSlugDoesNotWriteEnv(t *testing.T) { //nolint:paralleltest // t.Setenv cannot run with t.Parallel. + secret := strings.Repeat("R", apiKeyMinLength) + unpack := envReadarrUnpackerr(t, secret) + + otherKey := strings.Repeat("U", apiKeyMinLength) + body := `{"uhd":{"url":"http://readarr-uhd:8787","apiKey":"` + otherKey + `"}}` - put := doAuth(t, unpack, http.MethodPut, "/api/config/readarr", `[{"name":"books"}]`, func(req *http.Request) { + put := doAuth(t, unpack, http.MethodPut, "/api/config/readarr", body, func(req *http.Request) { req.Header.Set(headerAPIKey, unpack.Webserver.adminAPIKey()) }) if put.Code != http.StatusOK { t.Fatalf("put %d %s", put.Code, put.Body.String()) } - if unpack.fileConfig == nil || len(unpack.fileConfig.Readarr) != 1 || unpack.fileConfig.Readarr[0].Name != "books" { - t.Fatalf("file %+v", unpack.fileConfig.Readarr) + written, err := os.ReadFile(unpack.ConfigFile) + if err != nil { + t.Fatal(err) + } + + text := string(written) + if strings.Contains(text, secret) || strings.Contains(text, envReadarrURL) { + t.Fatalf("env leaked into file:\n%s", text) + } + + if !strings.Contains(text, "[readarr.uhd]") || !strings.Contains(text, "http://readarr-uhd:8787") { + t.Fatalf("named slug missing from file:\n%s", text) + } + + if unpack.fileConfig.Readarr["0"] != nil { + t.Fatalf("env slug written to file: %+v", unpack.fileConfig.Readarr) + } + + if unpack.Readarr["0"] == nil || unpack.Readarr["0"].APIKey != secret { + t.Fatalf("live env slug %+v", unpack.Readarr) + } + + if unpack.Readarr["uhd"] == nil || unpack.Readarr["uhd"].APIKey != otherKey { + t.Fatalf("live uhd %+v", unpack.Readarr) + } +} + +func TestConfigGetLiveRedactsEnvAPIKey(t *testing.T) { //nolint:paralleltest // t.Setenv cannot run with t.Parallel. + secret := strings.Repeat("R", apiKeyMinLength) + unpack := envReadarrUnpackerr(t, secret) + + rec := doAuth(t, unpack, http.MethodGet, "/api/config/readarr/live", "", func(req *http.Request) { + req.Header.Set(headerAPIKey, unpack.Webserver.adminAPIKey()) + }) + if rec.Code != http.StatusOK { + t.Fatalf("live get %d %s", rec.Code, rec.Body.String()) + } + + if strings.Contains(rec.Body.String(), secret) { + t.Fatalf("env api key leaked on live GET: %s", rec.Body.String()) + } + + if !strings.Contains(rec.Body.String(), envReadarrURL) { + t.Fatalf("live GET should still show env url: %s", rec.Body.String()) + } +} + +func TestConfigPutRejectsBadInstanceSlug(t *testing.T) { + t.Parallel() + + unpack := testAuthUnpackerr(t) + unpack.ConfigFile = filepath.Join(t.TempDir(), "unpackerr.conf") + unpack.snapshotFileConfig() + + withKey := func(req *http.Request) { + req.Header.Set(headerAPIKey, unpack.Webserver.adminAPIKey()) + } + + rec := doAuth(t, unpack, http.MethodPut, "/api/config/sonarr", + `{"starrs & stripes":{"url":"http://127.0.0.1:8989","apiKey":"`+strings.Repeat("k", apiKeyMinLength)+`"}}`, withKey) + if rec.Code != http.StatusBadRequest { + t.Fatalf("bad slug %d %s", rec.Code, rec.Body.String()) + } +} + +type envFolderWatch struct { + unpack *Unpackerr + watch string + fileExtract string + envExtract string +} + +func envFolderExtractUnpackerr(t *testing.T) envFolderWatch { + t.Helper() + + watch := t.TempDir() + fileExtract := t.TempDir() + envExtract := t.TempDir() + + t.Setenv("UN_FOLDER_watch_EXTRACT_PATH", envExtract) + t.Setenv("UN_FOLDER_watch_DELETE_AFTER", "5m") + + unpack := testAuthUnpackerr(t) + unpack.ConfigFile = filepath.Join(t.TempDir(), "unpackerr.conf") + unpack.Folders = InstanceMap[FolderConfig]{ + "watch": {Path: watch, ExtractPath: fileExtract}, + } + unpack.snapshotFileConfig() + + res, err := cnfg.ParseENV(unpack.Config, unpack.EnvPrefix) + if err != nil { + t.Fatal(err) + } + + unpack.envUsed = envSuffixes(res.Used, unpack.EnvPrefix) + + return envFolderWatch{unpack: unpack, watch: watch, fileExtract: fileExtract, envExtract: envExtract} +} + +// Env extract_path overlays live; the PUT body is what lands in the file. +// +//nolint:paralleltest // t.Setenv cannot run with t.Parallel. +func TestConfigPutFoldersKeepsEnvExtractPath(t *testing.T) { + setup := envFolderExtractUnpackerr(t) + if got := setup.unpack.Folders["watch"]; got == nil || got.ExtractPath != setup.envExtract { + t.Fatalf("startup env extract %+v", got) + } + + body, err := json.Marshal(map[string]any{ + "interval": "2s", + "buffer": 1000, + "folder": map[string]any{ + "watch": map[string]any{ + "path": setup.watch, + "extract_path": setup.fileExtract, + "delete_after": "10m", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + put := doAuth(t, setup.unpack, http.MethodPut, "/api/config/folders", string(body), putKey(setup.unpack)) + if put.Code != http.StatusOK { + t.Fatalf("put %d %s", put.Code, put.Body.String()) + } + + live := setup.unpack.Folders["watch"] + if live == nil || live.ExtractPath != setup.envExtract { + t.Fatalf("live extract_path after PUT %+v", live) + } + + if live.DeleteAfter == nil || live.DeleteAfter.Duration != 5*time.Minute { + t.Fatalf("live delete_after %+v", live.DeleteAfter) + } + + file := setup.unpack.fileConfig.Folders["watch"] + if file == nil || file.ExtractPath != setup.fileExtract || file.Path != setup.watch { + t.Fatalf("file after PUT %+v", file) + } + + if file.DeleteAfter == nil || file.DeleteAfter.Duration != 10*time.Minute { + t.Fatalf("file delete_after %+v", file.DeleteAfter) + } +} + +type envFolderExcludes struct { + unpack *Unpackerr + watch string +} + +func envFolderExcludeUnpackerr(t *testing.T) envFolderExcludes { + t.Helper() + + watch := t.TempDir() + + t.Setenv("UN_FOLDER_watch_EXCLUDE_PATH_0", "/c") + + unpack := testAuthUnpackerr(t) + unpack.ConfigFile = filepath.Join(t.TempDir(), "unpackerr.conf") + unpack.Folders = InstanceMap[FolderConfig]{ + "watch": {Path: watch, ExcludePaths: []string{"/a", "/b"}}, + } + unpack.snapshotFileConfig() + + res, err := cnfg.ParseENV(unpack.Config, unpack.EnvPrefix) + if err != nil { + t.Fatal(err) + } + + unpack.envUsed = envSuffixes(res.Used, unpack.EnvPrefix) + + return envFolderExcludes{unpack: unpack, watch: watch} +} + +// Indexed env must not wipe sibling exclude_paths out of the file commit. +// +//nolint:paralleltest // t.Setenv cannot run with t.Parallel. +func TestConfigPutFoldersKeepsSiblingExcludePaths(t *testing.T) { + setup := envFolderExcludeUnpackerr(t) + + body, err := json.Marshal(map[string]any{ + "interval": "2s", + "buffer": 1000, + "folder": map[string]any{ + "watch": map[string]any{ + "path": setup.watch, + "exclude_paths": []string{"/a", "/b"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + put := doAuth(t, setup.unpack, http.MethodPut, "/api/config/folders", string(body), putKey(setup.unpack)) + if put.Code != http.StatusOK { + t.Fatalf("put %d %s", put.Code, put.Body.String()) + } + + live := setup.unpack.Folders["watch"] + if live == nil || len(live.ExcludePaths) != 2 || live.ExcludePaths[0] != "/c" || live.ExcludePaths[1] != "/b" { + t.Fatalf("live exclude_paths after PUT %+v", live) + } + + file := setup.unpack.fileConfig.Folders["watch"] + if file == nil || len(file.ExcludePaths) != 2 || file.ExcludePaths[0] != "/a" || file.ExcludePaths[1] != "/b" { + t.Fatalf("file after PUT %+v", file) + } +} + +func envSonarrURLUnpackerr(t *testing.T, secret string) *Unpackerr { + t.Helper() + + t.Setenv("UN_SONARR_0_URL", "http://127.0.0.1:8989") + + unpack := testAuthUnpackerr(t) + unpack.ConfigFile = filepath.Join(t.TempDir(), "unpackerr.conf") + + app := &SonarrConfig{} + app.URL = "http://file.invalid:8989" + app.APIKey = secret + app.Name = "uhd" + app.Paths = StringSlice{"/downloads/tv"} + unpack.Sonarr = InstanceMap[SonarrConfig]{"0": app} + unpack.snapshotFileConfig() + + res, err := cnfg.ParseENV(unpack.Config, unpack.EnvPrefix) + if err != nil { + t.Fatal(err) + } + + unpack.envUsed = envSuffixes(res.Used, unpack.EnvPrefix) + + return unpack +} + +// File API key + env URL must survive a config PUT; ParseENV overlays the URL on live. +// +//nolint:paralleltest // t.Setenv cannot run with t.Parallel. +func TestConfigPutSonarrKeepsFileAPIKeyWhenURLIsEnv(t *testing.T) { + secret := strings.Repeat("F", apiKeyMinLength) + unpack := envSonarrURLUnpackerr(t, secret) + + body, err := json.Marshal(map[string]any{ + "0": map[string]any{ + "url": "http://file.invalid:8989", + "apiKey": secret, + "name": "uhd", + "paths": []string{"/downloads/tv"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + put := doAuth(t, unpack, http.MethodPut, "/api/config/sonarr", string(body), putKey(unpack)) + if put.Code != http.StatusOK { + t.Fatalf("put %d %s", put.Code, put.Body.String()) } - if unpack.fileConfig.Readarr[0].URL != "" || unpack.fileConfig.Readarr[0].APIKey != "" { - t.Fatalf("env leaked into file url=%q key=%q", unpack.fileConfig.Readarr[0].URL, unpack.fileConfig.Readarr[0].APIKey) + live := unpack.Sonarr["0"] + if live == nil || live.URL != "http://127.0.0.1:8989" || live.APIKey != secret || live.Name != "uhd" { + t.Fatalf("live after PUT %+v", live) } - if len(unpack.Readarr) != 1 || unpack.Readarr[0].Name != "books" || - unpack.Readarr[0].URL != url || unpack.Readarr[0].APIKey != secret { - t.Fatalf("live %+v", unpack.Readarr) + file := unpack.fileConfig.Sonarr["0"] + if file == nil || file.URL != "http://file.invalid:8989" || file.APIKey != secret || file.Name != "uhd" { + t.Fatalf("file after PUT %+v", file) } } diff --git a/pkg/unpackerr/folder.go b/pkg/unpackerr/folder.go index 828d6a89..1f14105b 100644 --- a/pkg/unpackerr/folder.go +++ b/pkg/unpackerr/folder.go @@ -19,8 +19,14 @@ func (u *Unpackerr) validateFolders() error { return validateFolderList(u.Folders) } -func validateFolderList(list []*FolderConfig) error { - if err := folders.ValidateList(list, parseOptionalMaxBytes); err != nil { +func validateFolderList(list InstanceMap[FolderConfig]) error { + for key := range list { + if err := validateInstanceSlug(key); err != nil { + return err + } + } + + if err := folders.ValidateList(instanceValues(list), parseOptionalMaxBytes); err != nil { return fmt.Errorf("validating folders: %w", err) } @@ -42,7 +48,7 @@ func (u *Unpackerr) PollFolders() { // Abs-expand a clone so GET /api/config/folders/live keeps the configured // path (file vs env), not the runtime filepath.Abs rewrite. - watched, flist := folders.Check(folders.CloneList(u.Folders), u.Logger) + watched, flist := folders.Check(folders.CloneList(instanceValues(u.Folders)), u.Logger) tracker, err := u.Folder.NewWatcher(watched, u.Logger, updateChanBuf, suffix) if err != nil { diff --git a/pkg/unpackerr/historyrestore_test.go b/pkg/unpackerr/historyrestore_test.go index ccb631d7..22c342e0 100644 --- a/pkg/unpackerr/historyrestore_test.go +++ b/pkg/unpackerr/historyrestore_test.go @@ -17,9 +17,9 @@ func restoreTestUnpackerr(t *testing.T) *Unpackerr { unpack.KeepHistory = 20 unpack.histPath = filepath.Join(t.TempDir(), historyFileName) unpack.RetryDelay.Duration = time.Minute - unpack.Sonarr = []*SonarrConfig{{}} - unpack.Sonarr[0].Name = "Sportarr" - unpack.Sonarr[0].URL = "http://sonarr:8989" + unpack.Sonarr = instanceMap([]*SonarrConfig{{}}) + unpack.Sonarr["0"].Name = "Sportarr" + unpack.Sonarr["0"].URL = "http://sonarr:8989" return unpack } @@ -152,8 +152,8 @@ func TestRestoreQueueMatchesURLWhenKindMissing(t *testing.T) { unpack := New() unpack.KeepHistory = 10 unpack.histPath = filepath.Join(t.TempDir(), historyFileName) - unpack.Sonarr = []*SonarrConfig{{}} - unpack.Sonarr[0].URL = "http://sonarr:8989" + unpack.Sonarr = instanceMap([]*SonarrConfig{{}}) + unpack.Sonarr["0"].URL = "http://sonarr:8989" unpack.upsertHistory(HistoryRecord{ ID: "legacy", App: "Sportarr", URL: "http://sonarr:8989", Path: "/dl/legacy", diff --git a/pkg/unpackerr/instancemap.go b/pkg/unpackerr/instancemap.go new file mode 100644 index 00000000..004bffcf --- /dev/null +++ b/pkg/unpackerr/instancemap.go @@ -0,0 +1,195 @@ +package unpackerr + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" + "slices" + "strconv" + + "github.com/BurntSushi/toml" +) + +// InstanceMap is a slug-keyed instance list. JSON and TOML still accept the +// 0.x array form ([[sonarr]] / [{…}]) and convert rows to keys "0", "1", …. +type InstanceMap[T any] map[string]*T + +var ( + errInvalidInstanceJSON = errors.New("instance list must be a JSON object or array") + errInvalidInstanceTOML = errors.New("instance list must be a TOML table or array of tables") + errInvalidInstanceSlug = errors.New("instance key must be letters, digits, underscore, or hyphen") +) + +func (m *InstanceMap[T]) UnmarshalJSON(raw []byte) error { + return unmarshalInstances(raw, m) +} + +func (m *InstanceMap[T]) UnmarshalTOML(data any) error { + if data == nil { + *m = nil + return nil + } + + val := reflect.ValueOf(data) + switch val.Kind() { + case reflect.Slice: + return m.unmarshalTOMLSlice(val) + case reflect.Map: + return m.unmarshalTOMLMap(val) + default: + return fmt.Errorf("%w: %T", errInvalidInstanceTOML, data) + } +} + +func (m *InstanceMap[T]) unmarshalTOMLSlice(val reflect.Value) error { + out := make(InstanceMap[T], val.Len()) + + for idx := range val.Len() { + dest := new(T) + if err := decodeTOMLValue(val.Index(idx).Interface(), dest); err != nil { + return err + } + + out[strconv.Itoa(idx)] = dest + } + + *m = out + + return nil +} + +func (m *InstanceMap[T]) unmarshalTOMLMap(val reflect.Value) error { + if val.Type().Key().Kind() != reflect.String { + return fmt.Errorf("%w: %s", errInvalidInstanceTOML, val.Type()) + } + + out := make(InstanceMap[T], val.Len()) + + for _, keyVal := range val.MapKeys() { + key := keyVal.String() + if err := validateInstanceSlug(key); err != nil { + return err + } + + dest := new(T) + if err := decodeTOMLValue(val.MapIndex(keyVal).Interface(), dest); err != nil { + return err + } + + out[key] = dest + } + + *m = out + + return nil +} + +func unmarshalInstances[T any](raw json.RawMessage, dest *InstanceMap[T]) error { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + *dest = nil + return nil + } + + switch trimmed[0] { + case '[': + var list []*T + if err := unmarshalList(raw, &list); err != nil { + return err + } + + out := make(InstanceMap[T], len(list)) + for idx, item := range list { + out[strconv.Itoa(idx)] = item + } + + *dest = out + + return nil + case '{': + var probe map[string]json.RawMessage + if err := json.Unmarshal(raw, &probe); err != nil { + return wrapJSONErr(err) + } + + out := make(InstanceMap[T], len(probe)) + + for key, val := range probe { + if err := validateInstanceSlug(key); err != nil { + return err + } + + if bytes.Equal(bytes.TrimSpace(val), []byte("null")) { + return errNilConfigEntry + } + + var item T + if err := unmarshalStrict(val, &item); err != nil { + return err + } + + out[key] = &item + } + + *dest = out + + return nil + default: + return fmt.Errorf("%w", errInvalidInstanceJSON) + } +} + +func decodeTOMLValue(src, dest any) error { + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(src); err != nil { + return fmt.Errorf("encoding instance table: %w", err) + } + + if err := toml.Unmarshal(buf.Bytes(), dest); err != nil { + return fmt.Errorf("decoding instance table: %w", err) + } + + return nil +} + +func validateInstanceSlug(name string) error { + if !validRoleName(name) { + return fmt.Errorf("%w: %q", errInvalidInstanceSlug, name) + } + + return nil +} + +func emptyIfNilMap[K comparable, V any](m map[K]V) map[K]V { + if m == nil { + return map[K]V{} + } + + return m +} + +func instanceKeys[T any](m InstanceMap[T]) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + + slices.Sort(keys) + + return keys +} + +func instanceValues[T any](m InstanceMap[T]) []*T { + keys := instanceKeys(m) + out := make([]*T, 0, len(keys)) + + for _, key := range keys { + if item := m[key]; item != nil { + out = append(out, item) + } + } + + return out +} diff --git a/pkg/unpackerr/instancemap_test.go b/pkg/unpackerr/instancemap_test.go new file mode 100644 index 00000000..a46feac2 --- /dev/null +++ b/pkg/unpackerr/instancemap_test.go @@ -0,0 +1,90 @@ +package unpackerr + +import ( + "encoding/json" + "strconv" + "testing" + + "github.com/BurntSushi/toml" +) + +func instanceMap[T any](items []*T) InstanceMap[T] { + out := make(InstanceMap[T], len(items)) + for idx, item := range items { + out[strconv.Itoa(idx)] = item + } + + return out +} + +func TestInstanceMapJSONArrayAndObject(t *testing.T) { + t.Parallel() + + var fromArray InstanceMap[SonarrConfig] + if err := json.Unmarshal([]byte(`[{"url":"http://sonarr:8989","name":"TV"}]`), &fromArray); err != nil { + t.Fatal(err) + } + + if got := fromArray["0"]; got == nil || got.URL != "http://sonarr:8989" || got.Name != "TV" { + t.Fatalf("array row: %+v", fromArray) + } + + var fromObject InstanceMap[SonarrConfig] + if err := json.Unmarshal([]byte( + `{"uhd":{"url":"http://sonarr-4k:8989","name":"Starrs & Stripes"}}`, + ), &fromObject); err != nil { + t.Fatal(err) + } + + if got := fromObject["uhd"]; got == nil || got.Name != "Starrs & Stripes" { + t.Fatalf("named: %+v", fromObject) + } + + if err := json.Unmarshal([]byte(`{"starrs & stripes":{"url":"http://x"}}`), &fromObject); err == nil { + t.Fatal("expected invalid slug") + } +} + +func TestInstanceMapTOMLArrayAndTable(t *testing.T) { + t.Parallel() + + type wrap struct { + Sonarr InstanceMap[SonarrConfig] `toml:"sonarr"` + } + + var array wrap + if _, err := toml.Decode("[[sonarr]]\nurl = \"http://sonarr:8989\"\n", &array); err != nil { + t.Fatal(err) + } + + if got := array.Sonarr["0"]; got == nil || got.URL != "http://sonarr:8989" { + t.Fatalf("array: %+v", array.Sonarr) + } + + var named wrap + if _, err := toml.Decode( + "[sonarr.uhd]\nname = \"Starrs & Stripes\"\nurl = \"http://sonarr-4k:8989\"\n", &named, + ); err != nil { + t.Fatal(err) + } + + if got := named.Sonarr["uhd"]; got == nil || got.Name != "Starrs & Stripes" { + t.Fatalf("named: %+v", named.Sonarr) + } +} + +func TestInstanceMapRejectsBadSlug(t *testing.T) { + t.Parallel() + + for _, slug := range []string{"starrs & stripes", "has space", `quo"te`, "brace{"} { + raw, err := json.Marshal(map[string]map[string]string{slug: {"url": "http://x"}}) + if err != nil { + t.Fatal(err) + } + + var dest InstanceMap[SonarrConfig] + if err := json.Unmarshal(raw, &dest); err == nil { + t.Fatalf("accepted %q", slug) + } + } +} diff --git a/pkg/unpackerr/openapi.json b/pkg/unpackerr/openapi.json index 035f312a..e8c5e97c 100644 --- a/pkg/unpackerr/openapi.json +++ b/pkg/unpackerr/openapi.json @@ -227,6 +227,114 @@ "restartRequired": {"type": "boolean"} } }, + "StarrInstance": { + "type": "object", + "description": "One Sonarr, Radarr, Lidarr, or Readarr instance. The map key is the slug, not this object. name is display only. Changed in v1.0.0 (September 2026).", + "properties": { + "name": {"type": "string"}, + "url": {"type": "string"}, + "apiKey": {"type": "string"}, + "httpUser": {"type": "string"}, + "httpPass": {"type": "string"}, + "username": {"type": "string"}, + "password": {"type": "string"}, + "path": {"type": "string"}, + "paths": {"type": "array", "items": {"type": "string"}}, + "protocols": {"type": "string"}, + "delete_orig": {"type": "boolean"}, + "delete_delay": {"type": "string", "description": "Go duration"}, + "syncthing": {"type": "boolean"}, + "valid_ssl": {"type": "boolean"}, + "timeout": {"type": "string", "description": "Go duration"}, + "maxBytes": {"type": "string"}, + "split_flac": {"type": "boolean", "description": "Lidarr only"} + } + }, + "StarrMap": { + "type": "object", + "description": "Starr instances keyed by slug (letters, digits, _, -). Empty object clears file instances. Legacy arrays still load as keys 0, 1, ….", + "additionalProperties": {"$ref": "#/components/schemas/StarrInstance"} + }, + "FolderInstance": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "extract_path": {"type": "string"}, + "delete_original": {"type": "boolean"}, + "delete_files": {"type": "boolean"}, + "disable_log": {"type": "boolean"}, + "move_back": {"type": "boolean"}, + "delete_after": {"type": "string", "nullable": true, "description": "Go duration; null omits"}, + "extract_isos": {"type": "boolean"}, + "disableRecursion": {"type": "boolean"}, + "maxNested": {"type": "integer"}, + "extrasMaxDepth": {"type": "integer"}, + "allowSymlinks": {"type": "boolean"}, + "maxBytes": {"type": "string"}, + "maxFiles": {"type": "integer"}, + "maxRatio": {"type": "number"}, + "exclude_paths": {"type": "array", "items": {"type": "string"}} + } + }, + "FolderMap": { + "type": "object", + "description": "Watch folders keyed by slug. Empty object clears file folders.", + "additionalProperties": {"$ref": "#/components/schemas/FolderInstance"} + }, + "FoldersSection": { + "type": "object", + "description": "Folders wrapper. Empty {} is 400; send interval, buffer, and folder (possibly {}).", + "properties": { + "interval": {"type": "string", "description": "Go duration"}, + "buffer": {"type": "integer"}, + "folder": {"$ref": "#/components/schemas/FolderMap"} + } + }, + "HookInstance": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "url": {"type": "string"}, + "command": {"type": "string"}, + "contentType": {"type": "string"}, + "templatePath": {"type": "string"}, + "template": {"type": "string"}, + "timeout": {"type": "string", "description": "Go duration"}, + "shell": {"type": "boolean"}, + "ignoreSsl": {"type": "boolean"}, + "silent": {"type": "boolean"}, + "events": {"type": "array", "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}}, + "exclude": {"type": "array", "items": {"type": "string"}}, + "nickname": {"type": "string"}, + "token": {"type": "string"}, + "channel": {"type": "string"} + } + }, + "HookMap": { + "type": "object", + "description": "Webhooks or cmdhooks keyed by slug. Empty object clears file instances. Legacy arrays still load as keys 0, 1, ….", + "additionalProperties": {"$ref": "#/components/schemas/HookInstance"} + }, + "ConfigSection": { + "description": "Shape depends on section. Starr (sonarr/radarr/lidarr/readarr) and hooks are slug maps. Folders is {interval, buffer, folder} with folder a slug map. General and webserver stay objects. Changed in v1.0.0 (September 2026).", + "oneOf": [ + {"$ref": "#/components/schemas/StarrMap"}, + {"$ref": "#/components/schemas/FoldersSection"}, + {"$ref": "#/components/schemas/HookMap"}, + {"type": "object", "description": "general or webserver object"} + ] + }, + "ConfigSectionPut": { + "description": "Same as GET, plus legacy Starr/hook/folder arrays (loaded as keys 0, 1, …). Starr/hooks {} clears file instances. Folders still needs the wrapper; folder may be {}. Empty object is 400 for general/webserver. Unknown fields and [null] are 400. PUT of a map writes named TOML tables.", + "oneOf": [ + {"$ref": "#/components/schemas/StarrMap"}, + {"type": "array", "items": {"$ref": "#/components/schemas/StarrInstance"}, "description": "Legacy Starr list"}, + {"$ref": "#/components/schemas/FoldersSection"}, + {"$ref": "#/components/schemas/HookMap"}, + {"type": "array", "items": {"$ref": "#/components/schemas/HookInstance"}, "description": "Legacy webhook/cmdhook list"}, + {"type": "object", "description": "general or webserver object"} + ] + }, "BrowseDir": { "type": "object", "properties": { @@ -555,7 +663,7 @@ "get": { "tags": ["config"], "summary": "Environment variables that overlaid config at startup", - "description": "Requires authentication. Keys are UN_* suffixes (DEBUG, SONARR_0_URL). Values are the env strings cnfg wrote into fields. Password, API key, webhook token, and webserver api key values are blank unless the caller has *.", + "description": "Requires authentication. Keys are UN_* suffixes with the same case as the TOML map key (DEBUG, SONARR_0_URL, SONARR_uhd_URL, WEBSERVER_ROLES_stats_PERMISSIONS_0). Values are the env strings cnfg wrote into fields. Password, API key, webhook token, and webserver api key values are blank unless the caller has *.", "responses": { "200": { "description": "Map of env suffix to value", @@ -600,11 +708,11 @@ "get": { "tags": ["config"], "summary": "Read the on-disk config section", - "description": "Requires config:{section}:read. Returns file-shaped values (filepath: prefixes kept). Starr API keys are visible as stored. Unpackerr webserver apiKeys[].key is returned only to callers with *; PUT keeps a blank key of the same name. ui_password is !!cryptd!!, webauth, noauth, or filepath:; plaintext user:pass is blanked. PUT a new password as user: (same digest as login), not plaintext. Use GET /api/config/{section}/live for expanded running values. PUT this payload back to save.", + "description": "Requires config:{section}:read. Returns file-shaped values (filepath: prefixes kept). Starr, folders.folder, webhooks, and cmdhooks are objects keyed by slug (v1.0.0, September 2026); env-only slugs are omitted. Starr API keys are visible as stored. Unpackerr webserver apiKeys[].key is returned only to callers with *; PUT keeps a blank key of the same name. ui_password is !!cryptd!!, webauth, noauth, or filepath:; plaintext user:pass is blanked. PUT a new password as user: (same digest as login), not plaintext. Use GET /api/config/{section}/live for expanded running values. PUT this payload back to save.", "responses": { "200": { "description": "On-disk section payload", - "content": {"application/json": {"schema": {"description": "Shape depends on section"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConfigSection"}}} }, "404": {"description": "Unknown section", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, @@ -614,10 +722,10 @@ "put": { "tags": ["config"], "summary": "Replace one config section and rewrite the TOML file", - "description": "Requires config:{section}:write. Body is the file-shaped GET payload (not /live). Unknown JSON fields, extra values, empty object sections, and nil list entries return 400. Arrays may be [] to clear. Validation, the TOML write, and the live apply run on the main loop; a persist failure is 500 and leaves runtime unchanged. An existing filepath: string in the same section is expanded for the running process and kept as filepath: on disk. A new or changed filepath: is 400; the API will not read a file that was not already referenced in that section. Replacing filepath: with a literal value is allowed. Empty apiKeys[].key keeps the existing key of the same name so a redacted GET can round-trip without *. webserver uiPassword on PUT is !!cryptd!!, webauth, noauth, an existing filepath:, or user:<64-char PBKDF2 hex> (the same kdf as login); plaintext user:pass is 400. Changing the live password hash or auth type while local password auth is on requires uiCurrentKdf (KDF of the current username+password). General interval changes reset the running tickers. Folder lists, webserver listen_addr, urlbase, TLS, metrics, pprof, log settings, debug, quiet, parallel, and file modes return restartRequired: true; the daemon then re-execs itself once nothing is queued, extracting, or awaiting delete. In-flight extracts are never cancelled.", + "description": "Requires config:{section}:write. Body is the file-shaped GET payload (not /live). Starr/hooks are slug maps; {} clears file instances (legacy [] still loads as keys 0, 1, …). Folders is {interval, buffer, folder}; empty {} is 400, folder may be {}. PUT writes the request body as the file document; live is clone(file)+ParseENV so env-only slugs still appear after {}. Unknown JSON fields, extra values, empty general/webserver objects, and nil list entries return 400. Validation, the TOML write, and the live apply run on the main loop; a persist failure is 500 and leaves runtime unchanged. An existing filepath: string in the same section is expanded for the running process and kept as filepath: on disk. A new or changed filepath: is 400; the API will not read a file that was not already referenced in that section. Replacing filepath: with a literal value is allowed. Empty apiKeys[].key keeps the existing key of the same name so a redacted GET can round-trip without *. webserver uiPassword on PUT is !!cryptd!!, webauth, noauth, an existing filepath:, or user:<64-char PBKDF2 hex> (the same kdf as login); plaintext user:pass is 400. Changing the live password hash or auth type while local password auth is on requires uiCurrentKdf (KDF of the current username+password). General interval changes reset the running tickers. Folder lists, webserver listen_addr, urlbase, TLS, metrics, pprof, log settings, debug, quiet, parallel, and file modes return restartRequired: true; the daemon then re-execs itself once nothing is queued, extracting, or awaiting delete. In-flight extracts are never cancelled. Changed in v1.0.0 (September 2026).", "requestBody": { "required": true, - "content": {"application/json": {"schema": {"description": "Same shape as GET /api/config/{section}"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConfigSectionPut"}}} }, "responses": { "200": { @@ -648,11 +756,11 @@ "get": { "tags": ["config"], "summary": "Read the expanded running config section", - "description": "Requires config:{section}:read. Most filepath: values are expanded. Archive passwords are the post-env, pre-expansion slice, so filepath: from the file or UN_PASSWORDS is not replaced with secret-file contents. Unpackerr webserver apiKeys[].key is returned only to callers with *. ui_password is the stored hash or auth mode, not plaintext. Do not PUT this payload if you need to keep filepath: lines.", + "description": "Requires config:{section}:read. Most filepath: values are expanded. Archive passwords are the post-env, pre-expansion slice, so filepath: from the file or UN_PASSWORDS is not replaced with secret-file contents. Starr/folders/hooks include env-created slugs; env secrets (apiKey, HTTP/native passwords, webhook token) are redacted even for *. Unpackerr webserver apiKeys[].key is returned only to callers with *. ui_password is the stored hash or auth mode, not plaintext. Do not PUT this payload if you need to keep filepath: lines.", "responses": { "200": { "description": "Live section payload", - "content": {"application/json": {"schema": {"description": "Shape depends on section"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConfigSection"}}} }, "404": {"description": "Unknown section", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "504": {"$ref": "#/components/responses/GatewayTimeout"}, diff --git a/pkg/unpackerr/queue_actions_test.go b/pkg/unpackerr/queue_actions_test.go index 10a72e6a..acfce8c8 100644 --- a/pkg/unpackerr/queue_actions_test.go +++ b/pkg/unpackerr/queue_actions_test.go @@ -223,7 +223,7 @@ func TestForgottenStarrTitle(t *testing.T) { t.Parallel() unpack := testAuthUnpackerr(t) - unpack.Radarr = []*RadarrConfig{{ + unpack.Radarr = instanceMap([]*RadarrConfig{{ Protocols: defaultProtocol, Queue: &radarr.Queue{Records: []*radarr.QueueRecord{{ Title: "Movie", @@ -231,8 +231,8 @@ func TestForgottenStarrTitle(t *testing.T) { Protocol: starr.Protocol("torrent"), OutputPath: "/dl/Movie", }}}, - }} - unpack.Radarr[0].polled = true + }}) + unpack.Radarr["0"].polled = true unpack.Map["Movie"] = &Extract{App: starr.Radarr, Path: "/dl/Movie", Status: EXTRACTFAILED, NoRetry: true} withKey := func(req *http.Request) { @@ -250,10 +250,10 @@ func TestForgottenStarrTitle(t *testing.T) { t.Fatal("forgotten title recreated from Starr queue") } - unpack.Radarr[0].Queue.Records = nil + unpack.Radarr["0"].Queue.Records = nil unpack.sweepForgotten() - unpack.Radarr[0].Queue.Records = []*radarr.QueueRecord{{ + unpack.Radarr["0"].Queue.Records = []*radarr.QueueRecord{{ Title: "Movie", Status: "completed", Protocol: starr.Protocol("torrent"), @@ -270,9 +270,9 @@ func TestSweepForgottenSkipsUnpolledSnapshot(t *testing.T) { t.Parallel() unpack := New() - unpack.Sonarr = []*SonarrConfig{{}} - unpack.Radarr = []*RadarrConfig{{}} - unpack.Radarr[0].polled = true + unpack.Sonarr = instanceMap([]*SonarrConfig{{}}) + unpack.Radarr = instanceMap([]*RadarrConfig{{}}) + unpack.Radarr["0"].polled = true unpack.forgotten["show"] = struct{}{} unpack.sweepForgotten() @@ -281,7 +281,7 @@ func TestSweepForgottenSkipsUnpolledSnapshot(t *testing.T) { t.Fatal("swept tombstone while Sonarr has not polled") } - unpack.Sonarr[0].polled = true + unpack.Sonarr["0"].polled = true unpack.sweepForgotten() if unpack.isForgotten("show") { diff --git a/pkg/unpackerr/starrpoll.go b/pkg/unpackerr/starrpoll.go index 2d51306a..78e648d1 100644 --- a/pkg/unpackerr/starrpoll.go +++ b/pkg/unpackerr/starrpoll.go @@ -19,30 +19,39 @@ type queueView struct { DebugExtra string } -func validateStarrList[T any, P starrApp[T]](unpack *Unpackerr, list *[]P, app starr.App) error { - tmp := (*list)[:0] +func validateStarrList[T any, P starrApp[T]](unpack *Unpackerr, list InstanceMap[T], app starr.App) error { + for key, item := range list { + if err := validateInstanceSlug(key); err != nil { + return err + } - for idx := range *list { - if err := unpack.validateApp((*list)[idx].conf(), app); err != nil { + if item == nil { + delete(list, key) + continue + } + + server := asStarr[T, P](item) + if err := unpack.validateApp(server.conf(), app); err != nil { if skipInvalidApp(err) { - continue // We ignore these errors, just remove the instance from the list. + delete(list, key) + continue } return err } - (*list)[idx].connect() - tmp = append(tmp, (*list)[idx]) + server.connect() } - *list = tmp - return nil } -func warnDuplicateStarrNames[T any, P starrApp[T]](unpack *Unpackerr, seen map[string]string, app starr.App, list []P) { - for _, item := range list { - cfg := item.conf() +func warnDuplicateStarrNames[T any, P starrApp[T]]( + unpack *Unpackerr, seen map[string]string, app starr.App, list InstanceMap[T], +) { + for _, item := range instanceValues(list) { + server := asStarr[T, P](item) + cfg := server.conf() name := strings.TrimSpace(cfg.Name) if name == "" { @@ -64,9 +73,10 @@ func warnDuplicateStarrNames[T any, P starrApp[T]](unpack *Unpackerr, seen map[s } func enqueueStarrPoll[T any, P starrApp[T]]( - unpack *Unpackerr, list []P, app starr.App, start time.Time, wait *sync.WaitGroup, + unpack *Unpackerr, list InstanceMap[T], app starr.App, start time.Time, wait *sync.WaitGroup, ) { - for _, server := range list { + for _, item := range instanceValues(list) { + server := asStarr[T, P](item) unpack.workChan <- []func(){func() { unpack.getStarrQueue(server, app, start) }, wait.Done} } } @@ -117,11 +127,12 @@ func (u *Unpackerr) publishStarrPoll(cfg *StarrConfig, bind func(), total, retri cfg.polled = true } -func checkStarrQueue[T any, P starrApp[T]](unpack *Unpackerr, list []P, app starr.App, now time.Time) { +func checkStarrQueue[T any, P starrApp[T]](unpack *Unpackerr, list InstanceMap[T], app starr.App, now time.Time) { unpack.lockHistory() defer unpack.unlockHistory() - for _, server := range list { + for _, item := range instanceValues(list) { + server := asStarr[T, P](item) cfg := server.conf() for _, rec := range server.queueViews() { @@ -161,8 +172,13 @@ func checkStarrQueue[T any, P starrApp[T]](unpack *Unpackerr, list []P, app star } } -func haveStarrQitem[T any, P starrApp[T]](list []P, name string) bool { - for _, server := range list { +func haveStarrQitem[T any, P starrApp[T]](list InstanceMap[T], name string) bool { + for _, item := range list { + if item == nil { + continue + } + + server := asStarr[T, P](item) if server.hasQueueTitle(name) { return true } @@ -181,22 +197,23 @@ func (u *Unpackerr) queueSnapshotReady(item *Extract) bool { switch item.App { case starr.Lidarr: - return starrSnapshotReady(u.Lidarr, item.URL) + return starrSnapshotReady[LidarrConfig, *LidarrConfig](u.Lidarr, item.URL) case starr.Radarr: - return starrSnapshotReady(u.Radarr, item.URL) + return starrSnapshotReady[RadarrConfig, *RadarrConfig](u.Radarr, item.URL) case starr.Readarr: - return starrSnapshotReady(u.Readarr, item.URL) + return starrSnapshotReady[ReadarrConfig, *ReadarrConfig](u.Readarr, item.URL) case starr.Sonarr: - return starrSnapshotReady(u.Sonarr, item.URL) + return starrSnapshotReady[SonarrConfig, *SonarrConfig](u.Sonarr, item.URL) default: return true } } -func starrSnapshotReady[T any, P starrApp[T]](list []P, url string) bool { +func starrSnapshotReady[T any, P starrApp[T]](list InstanceMap[T], url string) bool { if url != "" { - for _, server := range list { - if server != nil && server.conf().URL == url { + for _, item := range instanceValues(list) { + server := asStarr[T, P](item) + if server.conf().URL == url { return server.conf().hasPolled() } } @@ -204,8 +221,9 @@ func starrSnapshotReady[T any, P starrApp[T]](list []P, url string) bool { return true // instance is gone from config; missing from the queue is real. } - for _, server := range list { - if server != nil && !server.conf().hasPolled() { + for _, item := range instanceValues(list) { + server := asStarr[T, P](item) + if !server.conf().hasPolled() { return false } } @@ -214,10 +232,10 @@ func starrSnapshotReady[T any, P starrApp[T]](list []P, url string) bool { } func (u *Unpackerr) allStarrSnapshotsReady() bool { - return starrSnapshotReady(u.Lidarr, "") && - starrSnapshotReady(u.Radarr, "") && - starrSnapshotReady(u.Readarr, "") && - starrSnapshotReady(u.Sonarr, "") + return starrSnapshotReady[LidarrConfig, *LidarrConfig](u.Lidarr, "") && + starrSnapshotReady[RadarrConfig, *RadarrConfig](u.Radarr, "") && + starrSnapshotReady[ReadarrConfig, *ReadarrConfig](u.Readarr, "") && + starrSnapshotReady[SonarrConfig, *SonarrConfig](u.Sonarr, "") } func (u *Unpackerr) starrQueueStats() []StarrQueueStat { @@ -227,18 +245,19 @@ func (u *Unpackerr) starrQueueStats() []StarrQueueStat { } out := make([]StarrQueueStat, 0, n) - out = append(out, starrQueueRows(u.Lidarr, starr.Lidarr)...) - out = append(out, starrQueueRows(u.Radarr, starr.Radarr)...) - out = append(out, starrQueueRows(u.Readarr, starr.Readarr)...) - out = append(out, starrQueueRows(u.Sonarr, starr.Sonarr)...) + out = append(out, starrQueueRows[LidarrConfig, *LidarrConfig](u.Lidarr, starr.Lidarr)...) + out = append(out, starrQueueRows[RadarrConfig, *RadarrConfig](u.Radarr, starr.Radarr)...) + out = append(out, starrQueueRows[ReadarrConfig, *ReadarrConfig](u.Readarr, starr.Readarr)...) + out = append(out, starrQueueRows[SonarrConfig, *SonarrConfig](u.Sonarr, starr.Sonarr)...) return out } -func starrQueueRows[T any, P starrApp[T]](list []P, app starr.App) []StarrQueueStat { +func starrQueueRows[T any, P starrApp[T]](list InstanceMap[T], app starr.App) []StarrQueueStat { out := make([]StarrQueueStat, 0, len(list)) - for _, server := range list { + for _, item := range instanceValues(list) { + server := asStarr[T, P](item) cfg := server.conf() counts := tallyQueueViews(server.queueViews(), cfg.Protocols) out = append(out, StarrQueueStat{ diff --git a/pkg/unpackerr/starrpoll_test.go b/pkg/unpackerr/starrpoll_test.go index c496181d..82c43be9 100644 --- a/pkg/unpackerr/starrpoll_test.go +++ b/pkg/unpackerr/starrpoll_test.go @@ -46,11 +46,11 @@ func TestQueueViewsIDs(t *testing.T) { t.Fatalf("readarr bookId: got %v", got) } - if haveStarrQitem([]*SonarrConfig{son}, "Show") != true { + if haveStarrQitem[SonarrConfig, *SonarrConfig](instanceMap([]*SonarrConfig{son}), "Show") != true { t.Fatal("expected haveStarrQitem true for Show") } - if haveStarrQitem([]*SonarrConfig{son}, "Nope") { + if haveStarrQitem[SonarrConfig, *SonarrConfig](instanceMap([]*SonarrConfig{son}), "Nope") { t.Fatal("expected haveStarrQitem false for Nope") } } @@ -71,7 +71,7 @@ func TestCheckStarrQueueLidarrTweak(t *testing.T) { } unpack := New() - unpack.Lidarr = []*LidarrConfig{{ + unpack.Lidarr = instanceMap([]*LidarrConfig{{ Protocols: defaultProtocol, Paths: StringSlice{mappedRoot}, SplitFlac: true, @@ -81,7 +81,7 @@ func TestCheckStarrQueueLidarrTweak(t *testing.T) { Protocol: starr.Protocol("torrent"), OutputPath: outputPath, }}}, - }} + }}) checkStarrQueue(unpack, unpack.Lidarr, starr.Lidarr, time.Now()) @@ -120,7 +120,7 @@ func TestCheckStarrQueueSetsName(t *testing.T) { } unpack := New() - unpack.Sonarr = []*SonarrConfig{{ + unpack.Sonarr = instanceMap([]*SonarrConfig{{ Name: "Sportarr", Protocols: defaultProtocol, Paths: StringSlice{mappedRoot}, @@ -129,7 +129,7 @@ func TestCheckStarrQueueSetsName(t *testing.T) { Status: "completed", Protocol: starr.Protocol("torrent"), }}}, - }} + }}) checkStarrQueue(unpack, unpack.Sonarr, starr.Sonarr, time.Now()) @@ -150,7 +150,7 @@ func TestCheckStarrQueueSetsName(t *testing.T) { t.Fatalf("Label: got %q want Sportarr", item.Label()) } - unpack.Sonarr[0].Name = "Fightarr" + unpack.Sonarr["0"].Name = "Fightarr" checkStarrQueue(unpack, unpack.Sonarr, starr.Sonarr, time.Now()) if unpack.Map[title].Name != "Fightarr" { @@ -169,7 +169,7 @@ func TestCheckStarrQueueKeepsForeignName(t *testing.T) { Name: "Movies", URL: "http://radarr:7878", } - unpack.Sonarr = []*SonarrConfig{{ + unpack.Sonarr = instanceMap([]*SonarrConfig{{ Name: "Sportarr", URL: "http://sonarr:8989", Protocols: defaultProtocol, @@ -178,7 +178,7 @@ func TestCheckStarrQueueKeepsForeignName(t *testing.T) { Status: "downloading", Protocol: starr.Protocol("torrent"), }}}, - }} + }}) checkStarrQueue(unpack, unpack.Sonarr, starr.Sonarr, time.Now()) @@ -222,7 +222,7 @@ func TestStarrQueueStatsCountsRecords(t *testing.T) { t.Parallel() unpack := New() - unpack.Sonarr = []*SonarrConfig{{ + unpack.Sonarr = instanceMap([]*SonarrConfig{{ Name: "Sportarr", Protocols: "torrent", lastQueued: 6, lastRetrieved: 4, Queue: &sonarr.Queue{Records: []*sonarr.QueueRecord{ {Status: "completed", Protocol: "torrent"}, @@ -231,7 +231,7 @@ func TestStarrQueueStatsCountsRecords(t *testing.T) { {Status: "downloading"}, {Status: "warning"}, }}, - }} + }}) stats := &Stats{} unpack.fillQueueStats(stats) @@ -247,9 +247,9 @@ func TestFillQueueStatsConfigCounts(t *testing.T) { t.Parallel() unpack := New() - unpack.Sonarr = []*SonarrConfig{{}, {}} - unpack.Radarr = []*RadarrConfig{{}} - unpack.Folders = []*FolderConfig{{Path: "/watch"}, {Path: "/other"}} + unpack.Sonarr = instanceMap([]*SonarrConfig{{}, {}}) + unpack.Radarr = instanceMap([]*RadarrConfig{{}}) + unpack.Folders = instanceMap([]*FolderConfig{{Path: "/watch"}, {Path: "/other"}}) unpack.Finished = 9 unpack.Map["live"] = &Extract{Status: IMPORTED, Updated: time.Now()} unpack.Map["out"] = &Extract{Status: EXTRACTED, Updated: time.Now()} @@ -265,7 +265,7 @@ func TestFillQueueStatsConfigCounts(t *testing.T) { t.Fatalf("folders %d", stats.Folders) } - unpack.Webhook = []*WebhookConfig{{}} + unpack.Webhook = instanceMap([]*WebhookConfig{{}}) stats = &Stats{} unpack.fillQueueStats(stats) @@ -327,12 +327,12 @@ func TestStarrQueueStatsShowsPollCounts(t *testing.T) { t.Parallel() unpack := New() - unpack.Sonarr = []*SonarrConfig{{}} - unpack.Sonarr[0].Name = "Sportarr" - unpack.Sonarr[0].URL = "http://127.0.0.1:8989" - unpack.Sonarr[0].lastQueued = 12 - unpack.Sonarr[0].lastRetrieved = 8 - unpack.Sonarr[0].lastPollErr = "timeout" + unpack.Sonarr = instanceMap([]*SonarrConfig{{}}) + unpack.Sonarr["0"].Name = "Sportarr" + unpack.Sonarr["0"].URL = "http://127.0.0.1:8989" + unpack.Sonarr["0"].lastQueued = 12 + unpack.Sonarr["0"].lastRetrieved = 8 + unpack.Sonarr["0"].lastPollErr = "timeout" stats := &Stats{} unpack.fillQueueStats(stats) @@ -353,8 +353,8 @@ func TestCheckQueueChangesSkipsUnpolledSnapshot(t *testing.T) { const url = "http://sonarr:8989" unpack := New() - unpack.Sonarr = []*SonarrConfig{{}} - unpack.Sonarr[0].URL = url + unpack.Sonarr = instanceMap([]*SonarrConfig{{}}) + unpack.Sonarr["0"].URL = url item := &Extract{ App: starr.Sonarr, URL: url, Path: "/dl/show", @@ -371,8 +371,8 @@ func TestCheckQueueChangesSkipsUnpolledSnapshot(t *testing.T) { t.Fatalf("unpolled import %+v", got) } - unpack.Sonarr[0].Queue = &sonarr.Queue{} - unpack.Sonarr[0].polled = true + unpack.Sonarr["0"].Queue = &sonarr.Queue{} + unpack.Sonarr["0"].polled = true unpack.checkQueueChanges(time.Now()) if got := unpack.Map["show"]; got == nil || got.Status != IMPORTED { @@ -386,9 +386,9 @@ func TestCheckQueueChangesKeepsExtractedWhenStillQueued(t *testing.T) { const url = "http://sonarr:8989" unpack := New() - unpack.Sonarr = []*SonarrConfig{{}} - unpack.Sonarr[0].URL = url - unpack.Sonarr[0].Queue = &sonarr.Queue{Records: []*sonarr.QueueRecord{{Title: "show"}}} + unpack.Sonarr = instanceMap([]*SonarrConfig{{}}) + unpack.Sonarr["0"].URL = url + unpack.Sonarr["0"].Queue = &sonarr.Queue{Records: []*sonarr.QueueRecord{{Title: "show"}}} item := &Extract{ App: starr.Sonarr, URL: url, Path: "/dl/show", @@ -422,10 +422,10 @@ func TestCheckQueueChangesResetsImportedWhenStillQueued(t *testing.T) { const url = "http://sonarr:8989" unpack := New() - unpack.Sonarr = []*SonarrConfig{{}} - unpack.Sonarr[0].URL = url - unpack.Sonarr[0].polled = true - unpack.Sonarr[0].Queue = &sonarr.Queue{Records: []*sonarr.QueueRecord{{Title: "show"}}} + unpack.Sonarr = instanceMap([]*SonarrConfig{{}}) + unpack.Sonarr["0"].URL = url + unpack.Sonarr["0"].polled = true + unpack.Sonarr["0"].Queue = &sonarr.Queue{Records: []*sonarr.QueueRecord{{Title: "show"}}} unpack.Map["show"] = importedTestItem(url) unpack.checkQueueChanges(time.Now()) @@ -441,10 +441,10 @@ func TestCheckExtractDoneSkipsImportedStillQueued(t *testing.T) { const url = "http://sonarr:8989" unpack := New() - unpack.Sonarr = []*SonarrConfig{{}} - unpack.Sonarr[0].URL = url - unpack.Sonarr[0].polled = true - unpack.Sonarr[0].Queue = &sonarr.Queue{Records: []*sonarr.QueueRecord{{Title: "show"}}} + unpack.Sonarr = instanceMap([]*SonarrConfig{{}}) + unpack.Sonarr["0"].URL = url + unpack.Sonarr["0"].polled = true + unpack.Sonarr["0"].Queue = &sonarr.Queue{Records: []*sonarr.QueueRecord{{Title: "show"}}} unpack.Map["show"] = importedTestItem(url) unpack.checkExtractDone(time.Now()) @@ -460,8 +460,8 @@ func TestCheckExtractDoneSkipsImportedUntilPolled(t *testing.T) { const url = "http://sonarr:8989" unpack := New() - unpack.Sonarr = []*SonarrConfig{{}} - unpack.Sonarr[0].URL = url + unpack.Sonarr = instanceMap([]*SonarrConfig{{}}) + unpack.Sonarr["0"].URL = url unpack.Map["show"] = importedTestItem(url) unpack.checkExtractDone(time.Now()) @@ -470,8 +470,8 @@ func TestCheckExtractDoneSkipsImportedUntilPolled(t *testing.T) { t.Fatalf("deleted before poll %+v", got) } - unpack.Sonarr[0].polled = true - unpack.Sonarr[0].Queue = &sonarr.Queue{} + unpack.Sonarr["0"].polled = true + unpack.Sonarr["0"].Queue = &sonarr.Queue{} unpack.checkExtractDone(time.Now()) if got := unpack.Map["show"]; got == nil || got.Status != DELETED { diff --git a/pkg/unpackerr/start_test.go b/pkg/unpackerr/start_test.go index 8f2bc922..c6441ba2 100644 --- a/pkg/unpackerr/start_test.go +++ b/pkg/unpackerr/start_test.go @@ -233,30 +233,31 @@ func TestValidateFoldersExtrasDefaults(t *testing.T) { t.Parallel() unpack := New() - unpack.Folders = []*FolderConfig{ + unpack.Folders = instanceMap([]*FolderConfig{ {Path: "unset"}, {Path: "custom", MaxNested: 32, ExtrasMaxDepth: 6, AllowSymlinks: true}, {Path: "unlimited", MaxNested: -1, ExtrasMaxDepth: -1}, - } + }) if err := unpack.validateFolders(); err != nil { t.Fatalf("validate: %v", err) } - if unpack.Folders[0].MaxNested != 0 || - unpack.Folders[0].ExtrasMaxDepth != 0 || - unpack.Folders[0].MaxFiles != 0 || - unpack.Folders[0].MaxRatio != 0 || - unpack.Folders[0].ResolvedMaxBytes != 0 { - t.Fatalf("unset must stay unlimited: %+v", unpack.Folders[0]) + if unpack.Folders["0"].MaxNested != 0 || + unpack.Folders["0"].ExtrasMaxDepth != 0 || + unpack.Folders["0"].MaxFiles != 0 || + unpack.Folders["0"].MaxRatio != 0 || + unpack.Folders["0"].ResolvedMaxBytes != 0 { + t.Fatalf("unset must stay unlimited: %+v", unpack.Folders["0"]) } - if unpack.Folders[1].MaxNested != 32 || unpack.Folders[1].ExtrasMaxDepth != 6 || !unpack.Folders[1].AllowSymlinks { - t.Fatalf("custom: %+v", unpack.Folders[1]) + folder := unpack.Folders["1"] + if folder.MaxNested != 32 || folder.ExtrasMaxDepth != 6 || !folder.AllowSymlinks { + t.Fatalf("custom: %+v", folder) } - if unpack.Folders[2].MaxNested != -1 || unpack.Folders[2].ExtrasMaxDepth != -1 { - t.Fatalf("unlimited: %+v", unpack.Folders[2]) + if unpack.Folders["2"].MaxNested != -1 || unpack.Folders["2"].ExtrasMaxDepth != -1 { + t.Fatalf("unlimited: %+v", unpack.Folders["2"]) } } diff --git a/pkg/unpackerr/webhook.go b/pkg/unpackerr/webhook.go index 08758d98..670b57a7 100644 --- a/pkg/unpackerr/webhook.go +++ b/pkg/unpackerr/webhook.go @@ -82,22 +82,28 @@ func (u *Unpackerr) hookList() []*hooks.Config { u.configMu.RLock() defer u.configMu.RUnlock() - return u.Webhook + return instanceValues(u.Webhook) } func (u *Unpackerr) cmdhookList() []*hooks.Config { u.configMu.RLock() defer u.configMu.RUnlock() - return u.Cmdhook + return instanceValues(u.Cmdhook) } func (u *Unpackerr) validateWebhook() error { return u.validateWebhookList(u.Webhook) } -func (u *Unpackerr) validateWebhookList(list []*WebhookConfig) error { - if err := hooks.ValidateWebhooks(list, u.Timeout.Duration); err != nil { +func (u *Unpackerr) validateWebhookList(list InstanceMap[WebhookConfig]) error { + for key := range list { + if err := validateInstanceSlug(key); err != nil { + return err + } + } + + if err := hooks.ValidateWebhooks(instanceValues(list), u.Timeout.Duration); err != nil { return fmt.Errorf("validating webhooks: %w", err) } @@ -108,8 +114,14 @@ func (u *Unpackerr) validateCmdhook() error { return u.validateCmdhookList(u.Cmdhook) } -func (u *Unpackerr) validateCmdhookList(list []*WebhookConfig) error { - if err := hooks.ValidateCmdhooks(list, u.Timeout.Duration, expandHomedir); err != nil { +func (u *Unpackerr) validateCmdhookList(list InstanceMap[WebhookConfig]) error { + for key := range list { + if err := validateInstanceSlug(key); err != nil { + return err + } + } + + if err := hooks.ValidateCmdhooks(instanceValues(list), u.Timeout.Duration, expandHomedir); err != nil { return fmt.Errorf("validating cmdhooks: %w", err) } @@ -134,7 +146,7 @@ func (u *Unpackerr) sampleWebhook(e extract.Status) error { return fmt.Errorf("preparing sample webhook: %w", err) } - for _, hook := range u.Webhook { + for _, hook := range instanceValues(u.Webhook) { hooks.SendWithLog(u.Logger, hook, payload) } From 25c3207614787fa16b7f5cadf446848f6c683e73 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Sun, 13 Sep 2026 12:23:02 -0700 Subject: [PATCH 2/3] Treat path-dependent config schemas as anyOf so overlapping Starr and hook objects stay valid. Live GET redaction now covers Starr passwords and hook tokens; a webhook {} PUT keeps env-only rows. Co-authored-by: Cursor --- pkg/unpackerr/configput_test.go | 112 ++++++++++++++++++++++++++++++++ pkg/unpackerr/openapi.json | 8 +-- pkg/unpackerr/openapi_test.go | 23 +++++++ 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/pkg/unpackerr/configput_test.go b/pkg/unpackerr/configput_test.go index 7ee94b3c..1275bdcf 100644 --- a/pkg/unpackerr/configput_test.go +++ b/pkg/unpackerr/configput_test.go @@ -1668,6 +1668,118 @@ func TestConfigGetLiveRedactsEnvAPIKey(t *testing.T) { //nolint:paralleltest // } } +func TestConfigGetLiveRedactsInstanceSecrets(t *testing.T) { + t.Parallel() + + unpack := testAuthUnpackerr(t) + app := &SonarrConfig{} + app.URL = "http://127.0.0.1:8989" + app.APIKey = strings.Repeat("S", apiKeyMinLength) + app.HTTPPass = "basic-secret" + app.Password = "native-secret" + unpack.Sonarr = InstanceMap[SonarrConfig]{"uhd": app} + unpack.Webhook = InstanceMap[WebhookConfig]{ + "discord": {URL: "http://hooks.example/discord", Token: "hook-token", Name: "discord"}, + } + unpack.Cmdhook = InstanceMap[WebhookConfig]{ + "script": {Command: "/bin/true", Token: "cmd-token", Name: "script"}, + } + unpack.snapshotFileConfig() + + key := putKey(unpack) + secrets := []string{app.APIKey, app.HTTPPass, app.Password, "hook-token", "cmd-token"} + + for _, path := range []string{ + "/api/config/sonarr/live", + "/api/config/webhooks/live", + "/api/config/cmdhooks/live", + } { + rec := doAuth(t, unpack, http.MethodGet, path, "", key) + if rec.Code != http.StatusOK { + t.Fatalf("%s %d %s", path, rec.Code, rec.Body.String()) + } + + body := rec.Body.String() + for _, secret := range secrets { + if strings.Contains(body, secret) { + t.Fatalf("%s leaked %q: %s", path, secret, body) + } + } + } + + fileSonarr := doAuth(t, unpack, http.MethodGet, "/api/config/sonarr", "", key) + if fileSonarr.Code != http.StatusOK { + t.Fatalf("file sonarr %d %s", fileSonarr.Code, fileSonarr.Body.String()) + } + + if !strings.Contains(fileSonarr.Body.String(), app.APIKey) || + !strings.Contains(fileSonarr.Body.String(), app.HTTPPass) || + !strings.Contains(fileSonarr.Body.String(), app.Password) { + t.Fatalf("file GET should still show Starr secrets: %s", fileSonarr.Body.String()) + } + + fileHook := doAuth(t, unpack, http.MethodGet, "/api/config/webhooks", "", key) + if fileHook.Code != http.StatusOK || !strings.Contains(fileHook.Body.String(), "hook-token") { + t.Fatalf("file webhooks %d %s", fileHook.Code, fileHook.Body.String()) + } + + fileCmd := doAuth(t, unpack, http.MethodGet, "/api/config/cmdhooks", "", key) + if fileCmd.Code != http.StatusOK || !strings.Contains(fileCmd.Body.String(), "cmd-token") { + t.Fatalf("file cmdhooks %d %s", fileCmd.Code, fileCmd.Body.String()) + } +} + +func envWebhookUnpackerr(t *testing.T, envURL, envSecret string) *Unpackerr { + t.Helper() + + t.Setenv("UN_WEBHOOK_0_URL", envURL) + t.Setenv("UN_WEBHOOK_0_TOKEN", envSecret) + + unpack := testAuthUnpackerr(t) + unpack.ConfigFile = filepath.Join(t.TempDir(), "unpackerr.conf") + unpack.snapshotFileConfig() + + res, err := cnfg.ParseENV(unpack.Config, unpack.EnvPrefix) + if err != nil { + t.Fatal(err) + } + + unpack.envUsed = envSuffixes(res.Used, unpack.EnvPrefix) + + return unpack +} + +// A save of {} must not drop an env-only webhook from live. +func TestConfigPutWebhooksEmptyKeepsEnv(t *testing.T) { //nolint:paralleltest // t.Setenv cannot run with t.Parallel. + const envURL = "http://hooks.example/env" + + unpack := envWebhookUnpackerr(t, envURL, "env-hook-token") + + put := doAuth(t, unpack, http.MethodPut, "/api/config/webhooks", `{}`, putKey(unpack)) + if put.Code != http.StatusOK { + t.Fatalf("put %d %s", put.Code, put.Body.String()) + } + + if unpack.fileConfig != nil && len(unpack.fileConfig.Webhook) != 0 { + t.Fatalf("file webhook %+v", unpack.fileConfig.Webhook) + } + + got := unpack.Webhook["0"] + if len(unpack.Webhook) != 1 || got == nil || got.URL != envURL || got.Token != "env-hook-token" { + t.Fatalf("live webhook %+v", unpack.Webhook) + } + + written, err := os.ReadFile(unpack.ConfigFile) + if err != nil { + t.Fatal(err) + } + + text := string(written) + if strings.Contains(text, "env-hook-token") || strings.Contains(text, envURL) { + t.Fatalf("env leaked into file:\n%s", text) + } +} + func TestConfigPutRejectsBadInstanceSlug(t *testing.T) { t.Parallel() diff --git a/pkg/unpackerr/openapi.json b/pkg/unpackerr/openapi.json index e8c5e97c..44d70b3b 100644 --- a/pkg/unpackerr/openapi.json +++ b/pkg/unpackerr/openapi.json @@ -316,8 +316,8 @@ "additionalProperties": {"$ref": "#/components/schemas/HookInstance"} }, "ConfigSection": { - "description": "Shape depends on section. Starr (sonarr/radarr/lidarr/readarr) and hooks are slug maps. Folders is {interval, buffer, folder} with folder a slug map. General and webserver stay objects. Changed in v1.0.0 (September 2026).", - "oneOf": [ + "description": "Shape depends on section. Starr (sonarr/radarr/lidarr/readarr) and hooks are slug maps. Folders is {interval, buffer, folder} with folder a slug map. General and webserver stay objects. anyOf (not oneOf): the object alternatives overlap (optional properties, catch-all object). Changed in v1.0.0 (September 2026).", + "anyOf": [ {"$ref": "#/components/schemas/StarrMap"}, {"$ref": "#/components/schemas/FoldersSection"}, {"$ref": "#/components/schemas/HookMap"}, @@ -325,8 +325,8 @@ ] }, "ConfigSectionPut": { - "description": "Same as GET, plus legacy Starr/hook/folder arrays (loaded as keys 0, 1, …). Starr/hooks {} clears file instances. Folders still needs the wrapper; folder may be {}. Empty object is 400 for general/webserver. Unknown fields and [null] are 400. PUT of a map writes named TOML tables.", - "oneOf": [ + "description": "Same as GET, plus legacy Starr/hook/folder arrays (loaded as keys 0, 1, …). Starr/hooks {} clears file instances. Folders still needs the wrapper; folder may be {}. Empty object is 400 for general/webserver. Unknown fields and [null] are 400. PUT of a map writes named TOML tables. anyOf (not oneOf): the alternatives overlap.", + "anyOf": [ {"$ref": "#/components/schemas/StarrMap"}, {"type": "array", "items": {"$ref": "#/components/schemas/StarrInstance"}, "description": "Legacy Starr list"}, {"$ref": "#/components/schemas/FoldersSection"}, diff --git a/pkg/unpackerr/openapi_test.go b/pkg/unpackerr/openapi_test.go index 17fd7d84..404ec13f 100644 --- a/pkg/unpackerr/openapi_test.go +++ b/pkg/unpackerr/openapi_test.go @@ -129,6 +129,29 @@ func TestOpenAPILoginRequestOnlyRequiresKDF(t *testing.T) { } } +func TestOpenAPIConfigSectionUsesAnyOf(t *testing.T) { + t.Parallel() + + var doc map[string]any + if err := json.Unmarshal(openapiJSON, &doc); err != nil { + t.Fatal(err) + } + + comps, _ := doc["components"].(map[string]any) + schemas, _ := comps["schemas"].(map[string]any) + + for _, name := range []string{"ConfigSection", "ConfigSectionPut"} { + schema, _ := schemas[name].(map[string]any) + if _, ok := schema["anyOf"]; !ok { + t.Fatalf("%s missing anyOf: %v", name, schema) + } + + if _, ok := schema["oneOf"]; ok { + t.Fatalf("%s still uses oneOf", name) + } + } +} + func decodeOpenAPI(t *testing.T, body []byte) map[string]any { t.Helper() From d2da78d7b5bf8b00d17510c1e05d4a391c7e5017 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Sun, 13 Sep 2026 12:34:50 -0700 Subject: [PATCH 3/3] Do not 400 a config save because an env overlay leftover is missing a URL or API key. Save omits env-owned secrets and may omit a slug whose identifying URL is in env; ParseENV still fills complete rows, and incomplete extras are skipped the same way as startup. Co-authored-by: Cursor --- INTERNALS.md | 4 +- pkg/unpackerr/apps.go | 6 +- pkg/unpackerr/configput.go | 27 +++++- pkg/unpackerr/configput_test.go | 140 ++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 5 deletions(-) diff --git a/INTERNALS.md b/INTERNALS.md index 53cae127..b3656510 100644 --- a/INTERNALS.md +++ b/INTERNALS.md @@ -219,11 +219,11 @@ Env-only (no config path): skip write, still apply live. **New `ui_password` on PUT:** `!!cryptd!!…`, `webauth`, `noauth`, or `user:<64-char hex>` where the hex is the same PBKDF2 digest as login (`CryptPass.Set`, then bcrypt; mixed-case hex is stored lowercase). Plaintext `user:pass` is **400**. While live auth is local password, changing the hash or switching to header/noauth requires `uiCurrentKdf` (login `Valid()` on the current username). Header/noauth live mode does not. `uiCurrentKdf` is a PUT-only JSON field and is never written to TOML. A body that contains only `uiCurrentKdf` is **400** (empty section), so it cannot wipe `listen_addr` / keys / roles. Omitting `uiPassword` or sending the on-disk value unchanged keeps the live overlay, so `UN_WEBSERVER_UI_PASSWORD` is not replaced by the file hash. -**Starr PUT:** JSON object keyed by slug (letters, digits, `_`, `-`; same charset as roles). `name` is display only. Invalid URL/key is **400** (startup *skips* bad apps; PUT does not). File commit is the PUT body. Live map is that body plus `ParseENV`, so an env-only extra instance survives `{}`. `path` merges into `paths` without dupes. Last poll `Queue` carries over when `url` + expanded `apiKey` match (`starrIdentity`). Work thread pool **grows** to `starrAppCount`. Changed in v1.0.0 (September 2026). +**Starr PUT:** JSON object keyed by slug (letters, digits, `_`, `-`; same charset as roles). `name` is display only. Invalid URL/key on a **PUT-body** instance is **400** after `ParseENV` fills env secrets (so a Save that omits `apiKey` because `UN_*_API_KEY` is set still succeeds). Env-only overlay slugs that startup would skip (URL without key, or the reverse) are dropped from live, not 400 — they cannot block saving other instances. File commit is the PUT body. Live map is that body plus `ParseENV`, so a *complete* env-only extra instance survives `{}`. `path` merges into `paths` without dupes. Last poll `Queue` carries over when `url` + expanded `apiKey` match (`starrIdentity`). Work thread pool **grows** to `starrAppCount`. Changed in v1.0.0 (September 2026). **Folders PUT:** wrapper `{ interval, buffer, folder }`; inner `folder` is a slug map. Always `restartRequired: true`. Watcher is built once; rebuilding in-process was rejected (leak / dual poller). -**Webhooks / cmdhooks PUT:** slug maps, same file-body / live overlay as Starr. Validate (including HTTP client) then publish. First-ever hook starts the hook worker. +**Webhooks / cmdhooks PUT:** slug maps, same file-body / live overlay as Starr. Env-only overlay slugs that fail validation are dropped from live, not 400. PUT-body hooks still validate (including HTTP client) then publish. First-ever hook starts the hook worker. **General PUT:** applies interval / delays / remnant action / keep_history / passwords in place and **`resetTickers()`**. Interval is **not** `restartRequired`. Logger construction, `parallel` (xtractr), `file_mode` / `dir_mode`, `timeout` / `delete_delay` (copied into apps at validate time) **are** restart. diff --git a/pkg/unpackerr/apps.go b/pkg/unpackerr/apps.go index d5467bd0..f3294e8d 100644 --- a/pkg/unpackerr/apps.go +++ b/pkg/unpackerr/apps.go @@ -54,8 +54,10 @@ func checkStarrName(name string) error { return nil } -// skipInvalidApp reports whether a Starr instance should be dropped at -// startup (missing/short URL or API key) rather than aborting the process. +// skipInvalidApp reports whether a Starr instance should be dropped +// (missing/short URL or API key) rather than failing the operation. +// Startup uses this for every instance. PUT uses it only for env overlay +// slugs that were not in the request body. func skipInvalidApp(err error) bool { return errors.Is(err, ErrInvalidURL) || errors.Is(err, ErrInvalidKey) } diff --git a/pkg/unpackerr/configput.go b/pkg/unpackerr/configput.go index 65c6fa77..5550285e 100644 --- a/pkg/unpackerr/configput.go +++ b/pkg/unpackerr/configput.go @@ -723,7 +723,16 @@ func putStarrList[T any, P starrApp[T]]( server := asStarr[T, P](item) if err := unpackerr.validateApp(server.conf(), app); err != nil { - return err + // Env overlay may recreate a slug the PUT omitted. Incomplete + // leftovers (URL in env, key in the deleted file row) must not + // 400 the save; startup already skips those. A PUT-body row + // still 400s after overlay fills env secrets. + if _, inPut := list[key]; !inPut && skipInvalidApp(err) { + delete(liveList, key) + continue + } + + return fmt.Errorf("%s instance %q: %w", app, key, err) } server.connect() @@ -845,6 +854,8 @@ func (u *Unpackerr) putHooks( return err } + dropInvalidEnvOverlay(list, liveList, validate) + if err := validate(liveList); err != nil { return err } @@ -856,3 +867,17 @@ func (u *Unpackerr) putHooks( u.ensureHookWorker() }) } + +// dropInvalidEnvOverlay removes live slugs that ParseENV created (not in the +// PUT body) when they fail validation. PUT-body slugs stay and still 400. +func dropInvalidEnvOverlay[T any](put, live InstanceMap[T], validate func(InstanceMap[T]) error) { + for key, item := range live { + if _, inPut := put[key]; inPut { + continue + } + + if err := validate(InstanceMap[T]{key: item}); err != nil { + delete(live, key) + } + } +} diff --git a/pkg/unpackerr/configput_test.go b/pkg/unpackerr/configput_test.go index 1275bdcf..46f3ae8a 100644 --- a/pkg/unpackerr/configput_test.go +++ b/pkg/unpackerr/configput_test.go @@ -297,6 +297,10 @@ func TestConfigPutLidarrURLNeedsAPIKey(t *testing.T) { t.Fatalf("want API key error, got %s", emptyKey.Body.String()) } + if !strings.Contains(emptyKey.Body.String(), `\"0\"`) { + t.Fatalf("want instance key in error, got %s", emptyKey.Body.String()) + } + shortKey := doAuth(t, unpack, http.MethodPut, "/api/config/lidarr", `[{"url":"http://sdfsdf.sdsd.com/lidarr","apiKey":"tooshort"}]`, withKey) if shortKey.Code != http.StatusBadRequest { @@ -1780,6 +1784,41 @@ func TestConfigPutWebhooksEmptyKeepsEnv(t *testing.T) { //nolint:paralleltest // } } +// Env token without a URL must not 400 a save of a different slug. +func TestConfigPutWebhooksOtherSlugWhenEnvHasNoURL(t *testing.T) { + t.Setenv("UN_WEBHOOK_0_TOKEN", "env-only-token") + + unpack := testAuthUnpackerr(t) + unpack.ConfigFile = filepath.Join(t.TempDir(), "unpackerr.conf") + unpack.snapshotFileConfig() + + res, err := cnfg.ParseENV(unpack.Config, unpack.EnvPrefix) + if err != nil { + t.Fatal(err) + } + + unpack.envUsed = envSuffixes(res.Used, unpack.EnvPrefix) + + put := doAuth(t, unpack, http.MethodPut, "/api/config/webhooks", + `{"discord":{"url":"http://hooks.example/file"}}`, putKey(unpack)) + if put.Code != http.StatusOK { + t.Fatalf("put %d %s", put.Code, put.Body.String()) + } + + if unpack.fileConfig.Webhook["0"] != nil { + t.Fatalf("env slug written to file: %+v", unpack.fileConfig.Webhook) + } + + if unpack.Webhook["0"] != nil { + t.Fatalf("incomplete env leftover on live: %+v", unpack.Webhook["0"]) + } + + got := unpack.Webhook["discord"] + if got == nil || got.URL != "http://hooks.example/file" { + t.Fatalf("live discord %+v", got) + } +} + func TestConfigPutRejectsBadInstanceSlug(t *testing.T) { t.Parallel() @@ -2005,3 +2044,104 @@ func TestConfigPutSonarrKeepsFileAPIKeyWhenURLIsEnv(t *testing.T) { t.Fatalf("file after PUT %+v", file) } } + +func envSonarrAPIKeyUnpackerr(t *testing.T, secret string) *Unpackerr { + t.Helper() + + t.Setenv("UN_SONARR_0_API_KEY", secret) + + unpack := testAuthUnpackerr(t) + unpack.ConfigFile = filepath.Join(t.TempDir(), "unpackerr.conf") + + app := &SonarrConfig{} + app.URL = "http://file.invalid:8989" + app.Name = "uhd" + app.Paths = StringSlice{"/downloads/tv"} + unpack.Sonarr = InstanceMap[SonarrConfig]{"0": app} + unpack.snapshotFileConfig() + + res, err := cnfg.ParseENV(unpack.Config, unpack.EnvPrefix) + if err != nil { + t.Fatal(err) + } + + unpack.envUsed = envSuffixes(res.Used, unpack.EnvPrefix) + + return unpack +} + +// Official UI GET file (no apiKey) then Save. Overlay must fill UN_SONARR_0_API_KEY. +// +//nolint:paralleltest // t.Setenv cannot run with t.Parallel. +func TestConfigPutSonarrOmitsEnvAPIKey(t *testing.T) { + secret := strings.Repeat("E", apiKeyMinLength) + unpack := envSonarrAPIKeyUnpackerr(t, secret) + + for _, body := range []string{ + `{"0":{"url":"http://file.invalid:8989","name":"uhd","paths":["/downloads/tv"]}}`, + `{"0":{"url":"http://file.invalid:8989","apiKey":"","name":"uhd","paths":["/downloads/tv"]}}`, + } { + put := doAuth(t, unpack, http.MethodPut, "/api/config/sonarr", body, putKey(unpack)) + if put.Code != http.StatusOK { + t.Fatalf("put %s → %d %s", body, put.Code, put.Body.String()) + } + + live := unpack.Sonarr["0"] + if live == nil || live.URL != "http://file.invalid:8989" || live.APIKey != secret || live.Name != "uhd" { + t.Fatalf("live after PUT %s: %+v", body, live) + } + + file := unpack.fileConfig.Sonarr["0"] + if file == nil || file.URL != "http://file.invalid:8989" || file.APIKey != "" || file.Name != "uhd" { + t.Fatalf("file after PUT %s: %+v", body, file) + } + } +} + +// Env URL without a key must not 400 a save of a different slug. +func TestConfigPutSonarrOtherSlugWhenEnvURLHasNoKey(t *testing.T) { + t.Setenv("UN_SONARR_0_URL", "http://127.0.0.1:8989") + + unpack := testAuthUnpackerr(t) + unpack.ConfigFile = filepath.Join(t.TempDir(), "unpackerr.conf") + + fileKey := strings.Repeat("F", apiKeyMinLength) + otherKey := strings.Repeat("U", apiKeyMinLength) + zero := &SonarrConfig{} + zero.URL = "http://file.invalid:8989" + zero.APIKey = fileKey + zero.Name = "hd" + one := &SonarrConfig{} + one.URL = "http://sonarr-uhd:8989" + one.APIKey = otherKey + one.Name = "uhd" + unpack.Sonarr = InstanceMap[SonarrConfig]{"0": zero, "1": one} + unpack.snapshotFileConfig() + + res, err := cnfg.ParseENV(unpack.Config, unpack.EnvPrefix) + if err != nil { + t.Fatal(err) + } + + unpack.envUsed = envSuffixes(res.Used, unpack.EnvPrefix) + + body := `{"1":{"url":"http://sonarr-uhd:8989","apiKey":"` + otherKey + `","name":"uhd"}}` + + put := doAuth(t, unpack, http.MethodPut, "/api/config/sonarr", body, putKey(unpack)) + if put.Code != http.StatusOK { + t.Fatalf("put %d %s", put.Code, put.Body.String()) + } + + if unpack.fileConfig.Sonarr["0"] != nil { + t.Fatalf("omitted slug written to file: %+v", unpack.fileConfig.Sonarr) + } + + if unpack.Sonarr["0"] != nil { + t.Fatalf("incomplete env leftover on live: %+v", unpack.Sonarr["0"]) + } + + got := unpack.Sonarr["1"] + if got == nil || got.URL != "http://sonarr-uhd:8989" || got.APIKey != otherKey { + t.Fatalf("live slug 1 %+v", got) + } +}