Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 15 additions & 14 deletions INTERNALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`.

---

Expand Down Expand Up @@ -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`.

Expand All @@ -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).
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One consequence worth a sentence here: renaming a TOML key that env references (e.g. [sonarr.1] → [sonarr.uhd]) does not move the env identity — after the PUT, live holds both "uhd" (file values, file key) and "1" (env values, env key), so the same server polls twice until the env var is renamed or cleared with UN_SONARR_1=. I verified this by experiment; fine if it's intended, but a client that renames keys in the UI without touching env will hit it.


`UN_WEBSERVER_UI_PASSWORD`:

Expand Down Expand Up @@ -193,22 +194,22 @@ 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}`

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.

Expand All @@ -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 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:** 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. 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.

Expand Down Expand Up @@ -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`) |
Expand Down Expand Up @@ -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 |

---

Expand Down
2 changes: 1 addition & 1 deletion examples/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 18 additions & 15 deletions examples/unpackerr.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -166,20 +166,23 @@ 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 ##
## REMOVE THE HASH # FROM CONFIG LINES YOU WANT TO CHANGE ##
###############################################################################
###############################################################################

## 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".
Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion pkg/configdef/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading