diff --git a/exports/whale/digests/seadexarr.txt b/exports/whale/digests/seadexarr.txt new file mode 100644 index 0000000..f575217 --- /dev/null +++ b/exports/whale/digests/seadexarr.txt @@ -0,0 +1 @@ +sha256:a1d4f7babbf6670635fce10d37cb0329f9b0bc75e7a615b104802b3647ffe4e8 \ No newline at end of file diff --git a/milky-way/environments/stage00/orion-system/main.jsonnet b/milky-way/environments/stage00/orion-system/main.jsonnet index b68a0c4..5556c01 100644 --- a/milky-way/environments/stage00/orion-system/main.jsonnet +++ b/milky-way/environments/stage00/orion-system/main.jsonnet @@ -455,6 +455,10 @@ local pubkeys = import 'magic/common/public_keys.json'; torrent_tags: 'from-seadexarr', // qBittorrent tag on grabs, so SeaDexArr-added torrents are identifiable discord_url: secrets.seadexarr.discordUrl, }, + // Poll every 10 minutes. SCHEDULE_TIME is float HOURS (app does time.sleep(SCHEDULE_TIME*3600)), + // so 10/60. The sleep runs AFTER each full pass, so passes never overlap regardless of interval. + // Kept at 10m (not 1m) to avoid hammering the SeaDex/AniList APIs, which change on the order of days. + scheduleHours = 10.0 / 60, ), // Public-key-only SFTP front door onto the shared mdata volume (read-write), reached over the diff --git a/milky-way/lib/images.libsonnet b/milky-way/lib/images.libsonnet index 77f2100..e93370a 100644 --- a/milky-way/lib/images.libsonnet +++ b/milky-way/lib/images.libsonnet @@ -101,20 +101,25 @@ local images = { 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`. + // qBittorrent. WHALE-BUILT FORK of the pinned upstream `:main` image + a one-line patch to + // add_torrent_to_qbit (see whale/patches/seadexarr-qbit5-add-response.patch; built in + // whale/outputs.nix by pulling the pinned digest and overlaying the patched seadex_arr.py). Why the + // fork: SeaDexArr checks `result != "Ok."` after qBittorrent's torrents/add, but qBittorrent 5.1+ + // returns a TorrentsAddedMetadata JSON dict (never "Ok."), so every successful grab false-raises + // "Failed to add torrent"; the patch inspects failure_count instead. Unfixed upstream (even on main), + // so no bump helps. // - // 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. + // The base is the upstream `:main` build (single-arch amd64, matches methanol's x86_64), 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 CRASHES against our qBittorrent (its AuthSubnetWhitelist bypasses login and answers + // /api/v2/auth/login with `204 No Content`). `:main` has qbittorrent-api==2025.11.1, which counts an + // empty body as success. To bump the base, update seadexarrUpstreamImage.imageDigest in outputs.nix + // (+ re-hash). Digest from exports/whale/digests/seadexarr.txt (written by + // `nix run ./flake-profiles/whale#seadexarr-push`). DROP THIS FORK and return to + // ghcr.io/bbtufty/seadexarr once upstream handles qBittorrent 5.x's torrents/add response. seadexarr: { - fullyQualifiedRepository: "ghcr.io/bbtufty/seadexarr", - defaultDigest: { hash: "sha256:92d539222696bd312c372ee8c6915141025ea10c1daa1a5ebded2966236fdebf", tagHint: "main" }, + fullyQualifiedRepository: "docker.io/yuto7/seadexarr", + defaultDigest: { hash: std.trim(importstr "exports/whale/digests/seadexarr.txt") }, }, // autobrr: download-automation tool (monitors IRC announce / RSS, matches releases against // filters, forwards each to a download client -- here qBittorrent / Sonarr under a per-filter diff --git a/whale/outputs.nix b/whale/outputs.nix index bf6d090..0557b70 100644 --- a/whale/outputs.nix +++ b/whale/outputs.nix @@ -24,14 +24,73 @@ # needs nothing but the binary + its runtime closure + TLS roots + an init. andrefIpfsDepotBin = inputs.andref-ipfs-depot.packages.x86_64-linux.default; + # SeaDexArr (bbtufty) -- whale-built FORK of the pinned upstream `:main` image. NOT built from source + # (it's a niche Python app not in nixpkgs): we pull the exact pinned digest -- which already carries the + # qbittorrent-api 2025.11.1 login fix (see milky-way/lib/images.libsonnet) -- and patch ONE file in + # place. Fix: add_torrent_to_qbit checks `result != "Ok."`, but qBittorrent 5.1+ returns a + # TorrentsAddedMetadata dict from torrents/add (never "Ok."), so it false-raises "Failed to add torrent" + # on every successful grab; the patch inspects failure_count instead. See + # whale/patches/seadexarr-qbit5-add-response.patch and milky-way/lib/seadexarr.libsonnet. DROP THIS FORK + # once upstream handles qBittorrent 5.x's add response. To bump the base, update imageDigest (+ re-hash). + seadexarrUpstreamImage = imagePkgs.dockerTools.pullImage { + imageName = "ghcr.io/bbtufty/seadexarr"; + imageDigest = "sha256:92d539222696bd312c372ee8c6915141025ea10c1daa1a5ebded2966236fdebf"; + finalImageName = "ghcr.io/bbtufty/seadexarr"; + finalImageTag = "main"; + os = "linux"; + arch = "amd64"; + # FOD hash of the pulled amd64 image tarball (from the first build's hash mismatch). + sha256 = "sha256-YLR3i6JRqkesqc+9pieeN/d/M7suU3dKf3IOwYf7WKE="; + }; + # Deterministic, VM-free patch (runAsRoot's runInLinuxVM and enableFakechroot's proot both proved + # flaky on this builder): extract seadex_arr.py from the pulled image's layers with plain tar, apply + # the patch, then layer just that one file over fromImage. `patch -p1` from the extracted rootfs + # matches the patch's `a/app/...` paths. + seadexarrPatchedModule = imagePkgs.runCommand "seadexarr-seadex_arr-patched.py" { + nativeBuildInputs = [ imagePkgs.gnutar imagePkgs.jq imagePkgs.patch ]; + } '' + mkdir extract && tar -xf ${seadexarrUpstreamImage} -C extract + mkdir root + for layer in $(jq -r '.[0].Layers[]' extract/manifest.json); do + tar -xf "extract/$layer" -C root + done + ( cd root && patch -p1 < ${./patches/seadexarr-qbit5-add-response.patch} ) + cp root/app/seadexarr/modules/seadex_arr.py "$out" + ''; + seadexarrPatchLayer = imagePkgs.runCommand "seadexarr-patch-layer" { } '' + install -Dm644 ${seadexarrPatchedModule} "$out/app/seadexarr/modules/seadex_arr.py" + ''; + # fromImage = the pinned upstream image; the overlay layer shadows just seadex_arr.py. buildLayeredImage + # doesn't inherit fromImage's config, so restate the upstream runtime contract (Entrypoint/Env/WorkingDir, + # read from the pulled image's config json) that lib/seadexarr.libsonnet depends on. + seadexarrPatchedImage = imagePkgs.dockerTools.buildLayeredImage { + name = "seadexarr"; + tag = "latest"; + fromImage = seadexarrUpstreamImage; + contents = [ seadexarrPatchLayer ]; + config = { + Entrypoint = [ "seadexarr" ]; + Cmd = [ "python3" ]; + Env = [ + "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + "CONFIG_DIR=/config" + ]; + WorkingDir = "/app"; + }; + }; + # Creates an attrset with two system-keyed targets: the x86_64-linux image and a # per-host script to push it to the docker registry. # @param name: The name of the docker repository for the image. # @param buildLayeredImageArg: The arguments to pass to `dockerTools.buildLayeredImage`. The `name` property is optional, but can be specified here too. - image-nix-artifacts = { name, buildLayeredImageArg }: let - image = imagePkgs.dockerTools.buildLayeredImage ({ - inherit name; - } // buildLayeredImageArg); + image-nix-artifacts = { name, buildLayeredImageArg ? null, image ? null }: let + # Build a layered image from `buildLayeredImageArg`, OR accept a prebuilt `image` (e.g. a + # `buildImage`/`fromImage` fork that patches an upstream image in place). The push-script, + # digest-file, and system-keyed outputs are identical either way. + builtImage = if image != null then image + else imagePkgs.dockerTools.buildLayeredImage ({ + inherit name; + } // buildLayeredImageArg); # The push-script is built from the host's pkgs so it runs natively (native # skopeo, native ~/.config/containers/auth.json), while still pushing the # x86_64-linux image (the docker-archive tarball is arch-agnostic). @@ -62,7 +121,7 @@ # docker.io lists; do NOT use `inspect --raw | .config.digest` (that is the # config-blob digest, which is not a pullable manifest reference). digestfile="$HOME/${digests-directory-home-relative-pathstr}/${name}.txt" - skopeo --insecure-policy copy --digestfile "$digestfile" "docker-archive:${image}" "$dest" + skopeo --insecure-policy copy --digestfile "$digestfile" "docker-archive:${builtImage}" "$dest" echo "Done!" digest=$(cat "$digestfile") @@ -72,7 +131,7 @@ }; in { image = { - x86_64-linux = image; + x86_64-linux = builtImage; }; push-script = { x86_64-linux = mkPushScript (pkgsFor "x86_64-linux"); @@ -196,6 +255,13 @@ }; }; + # SeaDexArr fork -- see the seadexarrPatchedImage derivation comment above. Uses the prebuilt-`image` + # path of image-nix-artifacts (buildImage/fromImage), not buildLayeredImageArg. + seadexarr = image-nix-artifacts { + name = "seadexarr"; + image = seadexarrPatchedImage; + }; + # andref-ipfs-depot (Discord-gated IPFS uploader). Wraps the crane-built Rust binary above in a # minimal layered image: dumb-init is PID 1 so k8s SIGTERM stops the pod promptly; cacert + # SSL_CERT_FILE give the serenity bot's HTTPS calls to Discord a CA bundle. Listens on :8080 @@ -240,6 +306,8 @@ in { grand-central-push = grand-central.push-script.x86_64-linux; autobrr-image = autobrr.image.x86_64-linux; autobrr-push = autobrr.push-script.x86_64-linux; + seadexarr-image = seadexarr.image.x86_64-linux; + seadexarr-push = seadexarr.push-script.x86_64-linux; andref-ipfs-depot-image = andref-ipfs-depot.image.x86_64-linux; andref-ipfs-depot-push = andref-ipfs-depot.push-script.x86_64-linux; }; @@ -248,6 +316,7 @@ in { mopidy-push = mopidy.push-script.aarch64-darwin; grand-central-push = grand-central.push-script.aarch64-darwin; autobrr-push = autobrr.push-script.aarch64-darwin; + seadexarr-push = seadexarr.push-script.aarch64-darwin; andref-ipfs-depot-push = andref-ipfs-depot.push-script.aarch64-darwin; }; }; diff --git a/whale/patches/seadexarr-qbit5-add-response.patch b/whale/patches/seadexarr-qbit5-add-response.patch new file mode 100644 index 0000000..bd572e7 --- /dev/null +++ b/whale/patches/seadexarr-qbit5-add-response.patch @@ -0,0 +1,14 @@ +--- a/app/seadexarr/modules/seadex_arr.py ++++ b/app/seadexarr/modules/seadex_arr.py +@@ -1452,7 +1452,10 @@ + category=self.torrent_category, + tags=self.torrent_tags, + ) +- if result != "Ok.": ++ # qbittorrent-api returns "Ok."/"Fails." (qB <=5.0) or a TorrentsAddedMetadata dict (qB 5.1+, ++ # our build). Reject only an explicit failure: the legacy "Fails." string, or a nonzero ++ # failure_count. (The library already raises on 415/not-found/permission/409-already-added.) ++ if result == "Fails." or (not isinstance(result, str) and result.get("failure_count", 0) > 0): + raise Exception("Failed to add torrent") + + return "torrent_added"