From 49f66ca9bb23610004feef1cf3d589b609af4b8a Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Sun, 21 Jun 2026 02:26:39 -0700 Subject: [PATCH] [milky-way][orion-system] Add sdx --- .../stage00/orion-system/main.jsonnet | 30 ++++ milky-way/lib/images.libsonnet | 16 ++ milky-way/lib/seadexarr.libsonnet | 162 ++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 milky-way/lib/seadexarr.libsonnet diff --git a/milky-way/environments/stage00/orion-system/main.jsonnet b/milky-way/environments/stage00/orion-system/main.jsonnet index e50e592..67f5d66 100644 --- a/milky-way/environments/stage00/orion-system/main.jsonnet +++ b/milky-way/environments/stage00/orion-system/main.jsonnet @@ -17,6 +17,7 @@ local sonarr = import 'milky-way/lib/sonarr.libsonnet'; local prowlarr = import 'milky-way/lib/prowlarr.libsonnet'; local jellyfin = import 'milky-way/lib/jellyfin.libsonnet'; local buildarr = import 'milky-way/lib/buildarr.libsonnet'; +local seadexarr = import 'milky-way/lib/seadexarr.libsonnet'; local utils = import 'milky-way/lib/utils.libsonnet'; local wgConf = import 'milky-way/lib/wireguard-conf.libsonnet'; local sftp = import 'milky-way/lib/sftp.libsonnet'; @@ -322,6 +323,35 @@ local secrets = import 'milky-way/secrets/k8s-secret-values.jsonnet'; }, buildarrConnect: buildarr.new(config = buildarrConfig), + // SeaDexArr: scheduled daemon (no web UI -> no Service/Ingress) that reads the Sonarr library, picks + // SeaDex's "best" release per anime, and adds its torrent straight into qBittorrent under the + // tv-sonarr category (so Sonarr imports it) tagged `from-seadexarr`. qBittorrent creds are omitted: + // its AuthSubnetWhitelist bypasses auth for in-cluster callers (same as buildarr/Sonarr). Radarr + // isn't deployed, so only Sonarr + qBittorrent are wired; the scheduled run tolerates the absent + // Radarr per-module. Host/port for each app come from its Service (the source of truth) the same way + // buildarrConfig does (utils.domainOfService + the webui port looked up by name); API key + Discord + // webhook come from sops. config.yml is authoritative -- the app reads it read-only and never rewrites it. + seadexarr: seadexarr.new( + config = { + sonarr_url: 'http://%s:%d' % [ + utils.domainOfService(this.sonarr.service), + utils.associateObjectsByKey(this.sonarr.service.spec.ports, 'name')['webui'].port, + ], + sonarr_api_key: secrets.sonarr.apiKey, + qbit_info: { + host: 'http://%s:%d' % [ + utils.domainOfService(this.qbittorrent.service), + utils.associateObjectsByKey(this.qbittorrent.service.spec.ports, 'name')['webui'].port, + ], + username: '', + password: '', + }, + sonarr_torrent_category: 'tv-sonarr', // matches Sonarr's qBittorrent download-client category (buildarr) + torrent_tags: 'from-seadexarr', // qBittorrent tag on grabs, so SeaDexArr-added torrents are identifiable + discord_url: secrets.seadexarr.discordUrl, + }, + ), + // Public-key-only SFTP front door onto the shared mdata volume (read-write), reached over the // tailnet (mdata-sftp.tail4c9a.ts.net:22) and over the LAN via methanol's mDNS alias // (mdata-methanol.local:30022 -- alias + firewall port live in venus methanol.nix). Authorized diff --git a/milky-way/lib/images.libsonnet b/milky-way/lib/images.libsonnet index 9015c10..905ef30 100644 --- a/milky-way/lib/images.libsonnet +++ b/milky-way/lib/images.libsonnet @@ -81,6 +81,22 @@ local images = { fullyQualifiedRepository: "callum027/buildarr", defaultDigest: { hash: "sha256:57e2343fefe5d5701364b5e93b4985dbf08310d7b152f70556bdaba7e9475447", tagHint: "0.7.8" }, }, + // SeaDexArr (bbtufty): scheduled daemon syncing Sonarr/Radarr anime picks from SeaDex into + // qBittorrent. SINGLE-ARCH amd64 manifest (matches methanol's x86_64) -- the digest is that one + // manifest, not a multi-arch index. Re-resolve with + // `docker buildx imagetools inspect ghcr.io/bbtufty/seadexarr:main`. + // + // Pinned to the `:main` build (2026-01-12), NOT the v0.9.0 release: v0.9.0 ships + // qbittorrent-api==2025.7.0, whose auth_log_in() requires the login body to be "Ok." and so + // CRASHES against our qBittorrent, which uses an AuthSubnetWhitelist that bypasses login for + // in-cluster callers and answers /api/v2/auth/login with `204 No Content` (empty body) instead. + // `:main` bumps to qbittorrent-api==2025.11.1, which counts an empty body as success -- so the + // whitelist bypass works and qBittorrent needs no password. Move to the next tagged release + // (>v0.9.0) once one ships with that bump. + seadexarr: { + fullyQualifiedRepository: "ghcr.io/bbtufty/seadexarr", + defaultDigest: { hash: "sha256:92d539222696bd312c372ee8c6915141025ea10c1daa1a5ebded2966236fdebf", tagHint: "main" }, + }, // Minimal OpenSSH SFTP-only server. The :alpine tag is a single-arch (linux/amd64) manifest -- // matches methanol -- so the digest below is that manifest, not a multi-arch index. "atmoz-sftp": { diff --git a/milky-way/lib/seadexarr.libsonnet b/milky-way/lib/seadexarr.libsonnet new file mode 100644 index 0000000..2d1f67f --- /dev/null +++ b/milky-way/lib/seadexarr.libsonnet @@ -0,0 +1,162 @@ +local images = import 'milky-way/lib/images.libsonnet'; + +// SeaDexArr (bbtufty/seadexarr): a scheduled BACKGROUND daemon -- it reads your Sonarr (and, when +// present, Radarr) library, picks the SeaDex-curated "best" release per entry, and adds that torrent +// straight into qBittorrent under a category Sonarr watches, so Sonarr imports it. It runs a loop +// (`run scheduled`, sleeping SCHEDULE_TIME hours between passes), not a web server, so this lib emits +// NO Service and NO Ingress -- there is nothing to expose. +// +// CONFIG_DIR (=/config, baked into the image) must be WRITABLE: on boot the app's verify_config() +// opens /config/config.yml with "w+" (it re-validates/normalizes the file against its bundled +// template), and it also writes a regenerable cache.json there. A read-only config mount makes both +// the Sonarr and Radarr modules crash with "Read-only file system: '/config/config.yml'". +// +// So /config is a writable emptyDir, and the rendered config is SEEDED into it from a read-only +// Secret by an init container that copies /seed/config.yml -> /config/config.yml on every start. +// Because the emptyDir starts empty on each (re)start, the seed is re-applied every boot, so the +// Jsonnet-rendered config still wins each time (the same "our declaration re-applied every boot" +// contract as the servarr env overrides) -- the app's in-place rewrites to its working copy are +// transient and discarded on the next roll. A config change rolls the pod via the checksum/config +// annotation below, which re-seeds the new content. The Secret holds the API keys + Discord webhook, +// so it's a Secret, not a ConfigMap. Losing the emptyDir on restart just re-warms cache.json from +// SeaDex/AniList -- not state worth persisting. +// +// The image is plain python (no s6/LinuxServer layer): it runs as ROOT (no PUID/PGID), and its +// ENTRYPOINT is `seadexarr` with NO default CMD -- so we must pass `run scheduled` ourselves or it +// just prints help and crashloops. +{ + // Mirror of seadexarr/modules/config_sample.yml defaults. The caller passes only the meaningful + // keys via `config`; merging over this set means every key the app reads is still emitted (so a + // loader that indexes a key directly can't KeyError on an advanced one the caller left unset). + local defaultConfig = { + // Sonarr + sonarr_url: null, + sonarr_api_key: null, + sonarr_ignore_unmonitored: false, + ignore_movies_in_radarr: false, + // Radarr + radarr_url: null, + radarr_api_key: null, + radarr_ignore_unmonitored: false, + // qBittorrent + qbit_info: { host: null, username: null, password: null }, + // Categories / tags for added torrents + sonarr_torrent_category: null, + radarr_torrent_category: null, + torrent_tags: null, + // Behaviour + ignore_seadex_update_times: false, + use_torrent_hash_to_filter: false, + max_torrents_to_add: null, + discord_url: null, + // SeaDex filters + public_only: true, + prefer_dual_audio: true, + want_best: true, + ignore_tags: null, + trackers: null, + // Advanced + sleep_time: 2, + cache_time: 1, + interactive: false, + anime_mappings: null, + anidb_mappings: null, + anibridge_mappings: null, + log_level: 'INFO', + }, + + new( + config, // overrides shallow-merged over defaultConfig; rendered to a read-only Secret + name='seadexarr', + namespace='default', + image=images.seadexarr.fullyQualifiedImageReferencePinned, + scheduleHours=6, // SCHEDULE_TIME: hours the scheduled loop sleeps between passes + timezone='America/Los_Angeles', + ):: { + local this = self, + // One source of truth for the rendered config.yml: feeds both the Secret and the pod-template + // checksum below. + local mergedConfigYaml = std.manifestYamlDoc(defaultConfig + config), + + // config.yml carries the Sonarr/qBittorrent API keys and the Discord webhook -> Secret, not + // ConfigMap. YAML is the format the app's loader (PyYAML) expects. + secret: { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: name + '-config', namespace: namespace }, + type: 'Opaque', + stringData: { 'config.yml': mergedConfigYaml }, + }, + + deployment: { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: name, namespace: namespace }, + spec: { + replicas: 1, + // It mutates EXTERNAL state (adds torrents to qBittorrent); two instances overlapping during + // a rolling update could double-add. Recreate keeps at most one alive. + strategy: { type: 'Recreate' }, + selector: { matchLabels: { app: name } }, + template: { + metadata: { + labels: {} + this.deployment.spec.selector.matchLabels, + // A subPath Secret mount does NOT live-update, and editing a Secret doesn't roll a + // Deployment on its own. Hashing the rendered config into the pod template makes a config + // change roll the pod so the new config.yml actually takes effect. + annotations: { 'checksum/config': std.md5(mergedConfigYaml) }, + }, + spec: { + tolerations: [ + { key: 'ephemeral', operator: 'Exists', effect: 'NoSchedule' }, + ], + // Seed the rendered config.yml from the read-only Secret into the writable /config emptyDir + // before the app starts (the app then reads+rewrites it in place). `command` overrides the + // image's `seadexarr` entrypoint; `cp` exists in the python base image, so no extra image. + initContainers: [ + { + name: 'seed-config', + image: image, + command: ['cp', '/seed/config.yml', '/config/config.yml'], + volumeMounts: [ + { name: 'config', mountPath: '/config' }, + { name: 'config-seed', mountPath: '/seed', readOnly: true }, + ], + }, + ], + containers: [ + { + name: name, + image: image, + args: ['run', 'scheduled'], // ENTRYPOINT `seadexarr` has no default CMD + env: [ + { name: 'SCHEDULE_TIME', value: std.toString(scheduleHours) }, + { name: 'TZ', value: timezone }, + // CONFIG_DIR=/config is already baked into the image; config.yml + cache.json live there. + ], + volumeMounts: [ + // Writable /config: holds the seeded config.yml (app rewrites it) and cache.json. + { name: 'config', mountPath: '/config' }, + ], + resources: { + requests: { memory: '128Mi', cpu: '50m' }, + limits: { memory: '512Mi', cpu: '1' }, + }, + }, + ], + volumes: [ + { name: 'config', emptyDir: {} }, + { + name: 'config-seed', + secret: { + secretName: this.secret.metadata.name, + items: [{ key: 'config.yml', path: 'config.yml' }], + }, + }, + ], + }, + }, + }, + }, + }, +} -- 2.51.2