Skip to content
Closed
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 maps. File commit strips `envUsed` fields and drops env-only slugs. Live overlay (`ParseENV` + `keepPutInstanceFields`) still creates missing env slugs. |

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 clone the body, **strip `envUsed` fields** (and drop slugs that then have no URL/path/command) onto the file snapshot, then overlay live: snapshot the unstripped PUT, `ParseENV` (which replaces map entries), then `keepPutInstanceFields` so PUT-only fields such as `name` survive. Env-only slugs keep polling. They still never land in `fileConfig` or the TOML. 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.

`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 env 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 is **400** (startup *skips* bad apps; PUT does not). File commit strips env-owned fields and drops slugs that exist only because of env (no name-only stubs). Live map is the unstripped PUT 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 (`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 env-strip / 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.

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.20260913174435-df3cb2f81623
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: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ 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.20260913173639-9eefb17a1ace h1:bHsft2ZQf+CQVl87qNlasNkQeL1+9HkC9bfDeeWDggU=
golift.io/cnfg v0.4.1-0.20260913173639-9eefb17a1ace/go.mod h1:+u238cgJJf1shXJmzMV4I7+F3EACg3NfKSe/g9yRKMY=
golift.io/cnfg v0.4.1-0.20260913174435-df3cb2f81623 h1:f2CFfSOdwtX42GX6Z9Yo2kTOGvUBi0nyqiCYjNtnppc=
golift.io/cnfg v0.4.1-0.20260913174435-df3cb2f81623/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