lidarr: placed dirs, the operator's custom scripts carried, the image ace runs #164

Closed
mesh-admin wants to merge 7 commits from feat/lidarr-for-ace into main
Contributor

Prepares lidarr for ace. Conversion only — nothing assigned, deployed or moved.

Depends on #156 (feat/servarr-api-provision): this branch contains it (lidarr provides lidarr-api there). Merge #156 first; this then shows only its own commit. Does not need controller #149 (lidarr needs no knowledge of its own URL).

What changed (modules/lidarr/module.json)

  • config is a pathless ${dir:config} (0700, 1000:1000) instead of /services/lidarr/config; route binding moves to a placed state dir (${dir:state}/route.json); /var/lib/mesh/lidarr keeps only the broker secret.
  • New: custom-cont-init and custom-services, pathless root-owned 0755 dirs mounted at /custom-cont-init.d and /custom-services.d — the linuxserver image's documented hook dirs. ace runs the operator's arr-scripts from them (an init script installing beets/deemix/SMA on each start; eight services incl. Audio, AutoConfig, QueueCleaner, ARLChecker), all live today. The catalogue mounted neither, so a take would have silently stopped them. Empty on a new machine = skipped by the image.
  • Image pinned to what ace runs: 3.1.0.4875-ls41 sha256:8ab0fd37…. The old pin 6b38dd33… is ls40 — older than ace's data.

Verified

  • MESH_CATALOGUE=<this> go test ./internal/catalogue/ — parse + mounts pass (not skipped).
  • Scratch resolve of route-adapter+lidarr on a fake ace: every ${dir:}/${port:} filled.
  • Pinned image in a throwaway container (fresh 0700 1000:1000 config, root 0755 hook dirs): ran a custom-cont-init script as root, started a custom-services service, /ping + UI 200, /api/v1/system/status 200 with its key / 401 without; re-owned a 1001:2000 file in /config to PUID (the image runs lsiown -R abc:abc /config).
  • With PUID 1000 the image cannot write a 1001:2000 0775 library — see gaps.

Gaps (not solvable in the manifest today)

  • hq 153: ace's paths are /storage/media/music and /storage/downloads (manifest keeps generic /services/media/*), and lidarr must run as the data's owner 1001:2000 (${access:<id>:uid|gid} proposed in 153). Until 153 lands lidarr cannot be taken on ace.
  • Download clients: lidarr's NZBGet (nzbget:6789) and qBittorrent (qbittorrent:8112) are HAL container names. There is no nzbget-api/qbittorrent-api provision yet (those modules are outside this PR); a consumer step like ombi's/bazarr's belongs here once they exist. Jackett (indexers.zurag.be) and Plex (plex.zurag.be:443) are public names and keep working.
  • TZ stays Etc/UTC (ace: Europe/Brussels) — no per-node container env.
Prepares lidarr for ace. **Conversion only — nothing assigned, deployed or moved.** **Depends on #156** (feat/servarr-api-provision): this branch contains it (lidarr provides `lidarr-api` there). Merge #156 first; this then shows only its own commit. Does not need controller #149 (lidarr needs no knowledge of its own URL). ## What changed (`modules/lidarr/module.json`) - `config` is a pathless `${dir:config}` (0700, 1000:1000) instead of `/services/lidarr/config`; route binding moves to a placed `state` dir (`${dir:state}/route.json`); `/var/lib/mesh/lidarr` keeps only the broker secret. - **New:** `custom-cont-init` and `custom-services`, pathless root-owned 0755 dirs mounted at `/custom-cont-init.d` and `/custom-services.d` — the linuxserver image's documented hook dirs. ace runs the operator's arr-scripts from them (an init script installing beets/deemix/SMA on each start; eight services incl. Audio, AutoConfig, QueueCleaner, ARLChecker), all live today. The catalogue mounted neither, so a take would have silently stopped them. Empty on a new machine = skipped by the image. - Image pinned to what ace runs: `3.1.0.4875-ls41` `sha256:8ab0fd37…`. The old pin `6b38dd33…` is `ls40` — older than ace's data. ## Verified - `MESH_CATALOGUE=<this> go test ./internal/catalogue/` — parse + mounts pass (not skipped). - Scratch resolve of route-adapter+lidarr on a fake ace: every `${dir:}`/`${port:}` filled. - Pinned image in a throwaway container (fresh 0700 1000:1000 config, root 0755 hook dirs): ran a custom-cont-init script as root, started a custom-services service, `/ping` + UI 200, `/api/v1/system/status` 200 with its key / 401 without; re-owned a 1001:2000 file in /config to PUID (the image runs `lsiown -R abc:abc /config`). - With PUID 1000 the image **cannot write a 1001:2000 0775 library** — see gaps. ## Gaps (not solvable in the manifest today) - **hq 153**: ace's paths are `/storage/media/music` and `/storage/downloads` (manifest keeps generic `/services/media/*`), and lidarr must run as the data's owner `1001:2000` (`${access:<id>:uid|gid}` proposed in 153). Until 153 lands lidarr cannot be taken on ace. - **Download clients**: lidarr's NZBGet (`nzbget:6789`) and qBittorrent (`qbittorrent:8112`) are HAL container names. There is no `nzbget-api`/`qbittorrent-api` provision yet (those modules are outside this PR); a consumer step like ombi's/bazarr's belongs here once they exist. Jackett (`indexers.zurag.be`) and Plex (`plex.zurag.be:443`) are public names and keep working. - TZ stays `Etc/UTC` (ace: Europe/Brussels) — no per-node container env.
mesh-admin added 6 commits 2026-09-30 09:59:31 +00:00
ombi's definition named /services/ombi/config (a HAL machine path) in three
places and pinned an image older than the one ace runs. Ombi migrates its
own SQLite schema, so a take onto the older pin (v4.53.10-ls267) would start
it on a database the newer build (ls269) already touched.

- config is a pathless placed directory, mounted as ${dir:config}
- a state directory placed at the assignment root carries route.json
- image pinned to the digest ace runs today (v4.53.10-ls269)
- the sidecar reaches ombi on the machine port the mesh assigns
  (${port:3579}) rather than assuming 3579 is free
- the sidecar no longer mounts ombi's data directory: MESH_OMBI_CONFIG_DIR
  is read by no code, and the mount exposed the databases for nothing

Verified: catalogue tests (MESH_CATALOGUE set, 6 pass, none skipped); the
pinned image starts as PUID 1000 in a 0700 dir and answers /api/v1/Status
200; data owned 1001:2000 (ace's media ids) under a 1000:1000 dir is
re-owned by the image's init and serves 200; a minted ApiKey is refused
(401) - the api-key secret must be accepted from ombi's own settings.
A host-network sidecar reaches its service over the machine's loopback, and
the mesh publishes that service on a machine port it assigns (ADR 0038) —
so dialling the software's port reaches whatever else holds it. On ace,
searxng's sidecar dialled 127.0.0.1:8080 and got unifi's inform port. The
same shape in bazarr, bookshelf, lidarr, nzbget, qbittorrent, radarr and
sonarr; each now asks with ${port:N} (hq 088). Found in review of ace's
module preparation.
A consumer on another machine (ombi first; jackett, bazarr and home-assistant
later) reached these by container name on HAL's shared network, which the mesh
does not have. Each app now provides <app>-api at mesh scope and serves the
software port, so the mesh tells a consumer where it is and redirects the
port to where the machine published it.

Named per app, not one servarr-api: a requirement is matched by name and
answered by exactly one provider per node, so a consumer cannot require one
name from three providers - and ombi's code is written against each app's
own API version (ADR 0027).

No grants and no provisioner: a Servarr instance has one API key, which the
mesh cannot mint. The operator accepts it as the pair credential for each
consumer (ADR 0092).
ombi keeps its Servarr connections in its own database, so the mesh has no
file to write them into. A run-once step reads the three bindings and pair
credentials and writes host, port, TLS, base path and key into ombi through
ombi's own API - only when they differ, and nothing else ombi keeps.

Until the operator accepts an app's key for this pair the mesh delivers a
value it minted, which no Servarr app accepts. The step tries the key against
the app first and, refused, writes nothing and fails naming the secret accept
that fixes it, so the old working key in ombi is never replaced by a dead one.

Declared last so its failing gates nothing else of ombi (ADR 0136), and
restart-on its six inputs so it runs again when a provider moves (ADR 0099).
ace's lidarr is not a plain lidarr. HAL mounts custom-cont-init.d and
custom-services.d, and the operator's arr-scripts run from them: an init
script installs beets/deemix/SMA on every start, and eight services
(Audio, AutoConfig, QueueCleaner, ARLChecker, ...) run beside lidarr and
are working today. The catalogue mounted neither, so a take would have
silently stopped all of it. Both are now pathless directories mounted at
the linuxserver image's own paths, root-owned 0755 as the image expects;
empty on a new machine, which the image skips.

The config dir is a pathless ${dir:config} instead of /services/lidarr/config,
the route binding lives in a placed state dir, and /var/lib/mesh/lidarr
keeps only the broker secret.

The image is pinned to the digest ace runs (3.1.0.4875-ls41, sha256:8ab0fd...).
The previous pin was ls40 - older than the data it would open.

Based on feat/servarr-api-provision (#156), which makes lidarr provide
lidarr-api; this branch contains it.

Verified: mesh-controller catalogue tests with MESH_CATALOGUE pointed here
pass (parse + mounts, not skipped); a resolve of route-adapter+lidarr on a
fake ace renders every ${dir:} and ${port:}; the pinned image in a
throwaway container ran a root-owned custom-cont-init.d script and started
a custom-services.d service, answered /ping, the UI and /api/v1/system/status
(200 with its key, 401 without), and re-owned a 1001:2000 file in /config to
PUID. With PUID 1000 it cannot write a 1001:2000 0775 library - the reason
ace needs hq 153 before a take.
jschoubben added 1 commit 2026-09-30 11:08:08 +00:00
lidarr reached its download clients as nzbget:6789 and qbittorrent:8112 and its indexers
through jackett:9117 or indexers.zurag.be - HAL container names and a public route,
typed into its database by hand. Nothing on the mesh answers those names.

It now requires nzbget-api, qbittorrent-api and jackett-api. A run-once step
(downloads/, declared last, restart-on its bindings, credentials and settings) writes
host, port, TLS, base path, user and credential into lidarr through lidarr's own API:

- A download client is the mesh's when its name is downloads.<provision>.name and its
  kind the provider's; it is registered when missing. A jackett feed is the mesh's when
  its host is the bound one, one the step bound before, or one in
  downloads.jackett-api.adopt-hosts; the jackett indexers in
  downloads.jackett-api.indexers are registered when missing. Every other entry is left
  alone, and nothing is ever deleted.
- Only connection fields, only when they differ. The stored password is masked, so the
  app tests the entry with the credential it holds; only if that fails is the delivered
  one written. Categories, priorities and "enabled" are never touched.
- Each credential is tried against its provider first. A minted value (nothing accepted
  yet) is never written; the step exits 1 naming the exact secret accept.

The step is byte-identical in sonarr, radarr, lidarr and bookshelf (the same Servarr
API, v3 or v1): each module builds from its own directory, so each carries a copy, and
test/downloads.test.ts fails if a sibling's copy differs.

Verified: strict typecheck, the Dockerfile build, 14 unit tests; and the compiled step
against fresh pinned sonarr/radarr/lidarr/bookshelf with throwaway nzbget, qBittorrent
and jackett - minted credentials refused with nothing written, accepted ones registered
and tested by the app, a migration-shaped radarr repointed, reruns unchanged.
Author
Contributor

New commit e1febc0: lidarr reaches nzbget, qbittorrent and jackett through the mesh.

Manifest

  • Now requires nzbget-api (#167), qbittorrent-api (#168) and jackett-api (#150).
  • The bindings and pair credentials land in ${dir:state}.
  • New resources:
    • downloads-config: ${dir:state}/downloads.json, merge json, carrying node: ${machine:name} and the downloads.* settings.
    • downloads-memory: a directory.
    • downloads: a run-once step on the host network, declared last. It restarts when the settings, any of the three bindings or any of the three secrets change.

What the step does, through lidarr's own API:

  • It writes the connection fields only (host, port, TLS, base path, user, credential) into the entries the mesh manages:
    • Download clients: the entry whose name is downloads.<provision>.name and whose kind matches that provider. If there is none, the step registers one.
    • jackett feeds: a Torznab feed of jackett's shape whose host is one of: the bound at, a host the step pointed feeds at before (kept in downloads-memory), or a host listed in downloads.jackett-api.adopt-hosts. A jackett indexer listed in downloads.jackett-api.indexers that the app lacks is registered.
  • Everything else is left alone: other entries, categories, priorities and "enabled". Nothing is ever deleted.
  • The stored password is masked, so the step asks lidarr to test the entry with the password it already holds. The delivered credential is written only if that test fails.
  • Each credential is tried against its provider first. A minted value is never written: the step exits 1 with secret accept <node> lidarr <provision> --provider <node> --from <file>.
  • Shared code: the step is byte-identical in sonarr, radarr, lidarr and bookshelf, which use the same Servarr API (v3 or v1). Each module builds from its own directory, so each carries a copy, and test/downloads.test.ts fails if a sibling's copy differs.

On ace (draft ace-assignments/lidarr.json): names NZBGet and qBitTorrent, with adopt-hosts: ["indexers.zurag.be", "jackett"]. That adopts the three disabled Torznab feeds (MixtapeTorrent, RuTracker, Torrent9), which today read jackett through its public route. Their scheme, host and port become the mesh's. The Arr-Extended blackhole client is another kind of client and is not touched.

Verified

  • Strict typecheck and the Dockerfile build pass, and 14 unit tests pass.
  • Catalogue tests pass with MESH_CATALOGUE set to all seven downloads branches merged. A scratch resolve on a fake ace filled every ${bound}, ${port}, ${machine}, ${dir} and each consumer's own sealed credential, and the step is the module's last container.
  • End-to-end run of the compiled step against the fresh pinned app, with throwaway nzbget, qBittorrent and jackett:
    • Minted credentials were refused, with the exact accept command, and nothing was written.
    • Accepted credentials registered the clients and a feed, and the app's own test passed.
    • A radarr shaped like ace's (NZBGet at nzbget:6789, qBitTorrent with an old password, disabled feeds at jackett:9117 including one dead one) was repointed.
    • A person's seedbox feed was left untouched, and reruns reported "already as the mesh says".
**New commit e1febc0: lidarr reaches nzbget, qbittorrent and jackett through the mesh.** **Manifest** - Now requires `nzbget-api` (#167), `qbittorrent-api` (#168) and `jackett-api` (#150). - The bindings and pair credentials land in `${dir:state}`. - New resources: - `downloads-config`: `${dir:state}/downloads.json`, merge json, carrying `node: ${machine:name}` and the `downloads.*` settings. - `downloads-memory`: a directory. - `downloads`: a run-once step on the host network, declared last. It restarts when the settings, any of the three bindings or any of the three secrets change. **What the step does**, through lidarr's own API: - It writes the connection fields only (host, port, TLS, base path, user, credential) into the entries the mesh manages: - **Download clients:** the entry whose name is `downloads.<provision>.name` and whose kind matches that provider. If there is none, the step registers one. - **jackett feeds:** a Torznab feed of jackett's shape whose host is one of: the bound `at`, a host the step pointed feeds at before (kept in `downloads-memory`), or a host listed in `downloads.jackett-api.adopt-hosts`. A jackett indexer listed in `downloads.jackett-api.indexers` that the app lacks is registered. - **Everything else is left alone:** other entries, categories, priorities and "enabled". **Nothing is ever deleted.** - **The stored password is masked,** so the step asks lidarr to test the entry with the password it already holds. The delivered credential is written only if that test fails. - **Each credential is tried against its provider first.** A minted value is never written: the step exits 1 with `secret accept <node> lidarr <provision> --provider <node> --from <file>`. - **Shared code:** the step is byte-identical in sonarr, radarr, lidarr and bookshelf, which use the same Servarr API (v3 or v1). Each module builds from its own directory, so each carries a copy, and `test/downloads.test.ts` fails if a sibling's copy differs. **On ace** (draft `ace-assignments/lidarr.json`): names `NZBGet` and `qBitTorrent`, with `adopt-hosts: ["indexers.zurag.be", "jackett"]`. That adopts the three disabled Torznab feeds (MixtapeTorrent, RuTracker, Torrent9), which today read jackett through its public route. Their scheme, host and port become the mesh's. The `Arr-Extended` blackhole client is another kind of client and is not touched. **Verified** - Strict typecheck and the Dockerfile build pass, and 14 unit tests pass. - Catalogue tests pass with `MESH_CATALOGUE` set to all seven downloads branches merged. A scratch resolve on a fake ace filled every `${bound}`, `${port}`, `${machine}`, `${dir}` and each consumer's own sealed credential, and the step is the module's last container. - End-to-end run of the compiled step against the fresh pinned app, with throwaway nzbget, qBittorrent and jackett: - Minted credentials were refused, with the exact accept command, and nothing was written. - Accepted credentials registered the clients and a feed, and the app's own test passed. - A radarr shaped like ace's (`NZBGet` at `nzbget:6789`, `qBitTorrent` with an old password, disabled feeds at `jackett:9117` including one dead one) was repointed. - A person's seedbox feed was left untouched, and reruns reported "already as the mesh says".
Author
Contributor

Superseded: this module now lives in novox/mesh-media-catalog (the media chain, consolidated from #145–#168 in stack order; its non-media parts merged via #195). Closing.

Superseded: this module now lives in novox/mesh-media-catalog (the media chain, consolidated from #145–#168 in stack order; its non-media parts merged via #195). Closing.
mesh-admin closed this pull request 2026-09-30 19:45:25 +00:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: novox/mesh-catalog#164