Files
Miguel Palhas ab4f95b166
ci / web (push) Successful in 32s
ci / rust (push) Successful in 3m26s
e2e / e2e (push) Failing after 5m2s
ci / image (push) Successful in 4m1s
feat(dl): replace Transmission with qBittorrent
qBittorrent's add call answers `Ok.` and nothing else — no hash, no name,
no duplicate signal — so arr derives the v1 infohash from the magnet or the
`.torrent` bytes before the call and looks the torrent up by it. That also
drops Transmission's numeric torrent id: the hash is the only identity now.

The reaper needs "stopped because a share limit was reached", and
qBittorrent's state field cannot tell that apart from a hand-paused torrent.
So the ratio and idle counters are checked against the limits arr set, and a
torrent stopped by a global limit reads as still seeding rather than being
deleted.

The WebUI needs a login, so `ARR_QBITTORRENT_USERNAME` and
`ARR_QBITTORRENT_PASSWORD` join the env-only secrets; leaving both unset is
valid for an instance that whitelists arr's subnet.

Verified against qBittorrent 5 (WebAPI 2.15.1) in a container: it rejects
`setShareLimits` without `shareLimitAction`, which 4.x ignores, so it is
always sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 18:13:50 +01:00

11 KiB

arr

Single-service replacement for Radarr and Sonarr, later Bazarr. Rust workspace, API-first, SQLite, no authentication layer.

Read DESIGN.md before touching anything. It is the contract. Issues reference its sections rather than restating them. If an issue and the design document disagree, the design document wins and the issue is wrong — say so rather than implementing the discrepancy.

Repo layout

crates/
├── arr-core/      domain types, policy engine, scoring   (no IO, no heavy deps)
├── arr-parse/     release name parsing                   (no IO)
├── arr-meta/      TMDB client
├── arr-indexer/   Torznab via Prowlarr
├── arr-dl/        qBittorrent WebUI API
├── arr-probe/     ffprobe wrapper
├── arr-subs/      subtitle providers, translation, sync
├── arr-db/        sqlx + migrations
├── arr-api/       axum + OpenAPI
├── arr-compat/    Radarr/Sonarr v3 shim for Jellyseerr
├── arr-daemon/    reconcile loop, wires everything
└── arr-e2e/       cross-process integration tests
web/               Vite + TypeScript SPA, embedded via include_dir

The invariant that matters: arr-core and arr-parse must never depend on axum, sqlx or reqwest. They hold the logic that gets tested constantly and they must stay fast to compile. Everything expensive is downstream of them. A pull request that adds a heavy dependency to either is wrong.

Conventions

  • Dependency versions are pinned once in the root [workspace.dependencies]. Member crates reference them by name, never by version.
  • Lints are declared once in the root [workspace.lints]. Member crates carry only [lints] workspace = true.
  • Commits follow Conventional Commits, subject ≤ 50 characters, body only when the "why" is not obvious from the diff.
  • No unwrap() in non-test code. The lint is on.
  • SQL goes through sqlx compile-time-checked queries.

Design and frontend work

Any issue labelled area/web, and any work that decides how something looks or behaves on screen, goes through the impeccable skill. That includes the design system itself, component work, layout, visual hierarchy, empty and error states, and any change to the UI described in DESIGN.md §9.2 and §9.3. Invoke it before writing markup, not as a review pass afterwards.

.impeccable/design.json is the token system — colours, typography, spacing. It is authored once by that skill and then treated as the source of truth; components consume tokens rather than literal values. .impeccable/live/config.json points the skill's live browser iteration at web/index.html.

Drive the browser with the agent-browser CLI, never google-chrome --headless directly. Raw headless Chrome times out on every http://127.0.0.1 URL in this VM, which reads as "the machine cannot reach localhost" and tempts a session into skipping the visual pass. It is the tool, not the machine: agent-browser open and agent-browser snapshot against the same URL work.

~/tea/arcada has the same setup and is worth reading for the shape of a finished design.json.

This matters more here than in a typical CRUD app because the manual-search view (§9.3) is the whole reason for the project's UI existing — Radarr's is unusable specifically because of a layout decision. Getting it right is a design problem, not a markup problem.

Working an issue

  1. Read DESIGN.md, then the issue, then the sections the issue cites.
  2. Branch from main. One issue per branch.
  3. Implement the issue as scoped. Do not widen it. If you find adjacent work, open a new issue and keep going.
  4. just ci must pass locally before pushing. That runs the same gate as CI.
  5. Open a pull request referencing the issue number.

If an issue turns out to be blocked by something not yet built, say so on the issue and stop. Do not implement the dependency inline — it has its own issue, or it needs one.

Label taxonomy

Every issue carries exactly one phase/, one area/, one difficulty/, and one type/. Dependencies are expressed in the issue body as Depends on: #N.

phase/ — build order, from DESIGN.md §13

Label Meaning
phase/1-skeleton workspace, CI, config, database, empty API and SPA
phase/2-logic parsing and the policy engine, pure, no network
phase/3-sourcing TMDB and Prowlarr, read-only, grabs nothing
phase/4-movies movies end to end, first real cutover test
phase/5-ui search, buckets, library views, queues
phase/6-tv seasons, episodes, tracking, derived status
phase/7-people owner tags and notifications
phase/8-compat Jellyseerr shim

Phases are ordered but not strictly serial — issues within a phase often parallelise, and some later-phase issues unblock early. Depends on: is authoritative, the phase label is a hint.

area/ — which crate

area/core, area/parse, area/meta, area/indexer, area/dl, area/probe, area/db, area/api, area/compat, area/daemon, area/web, area/ci, area/infra

difficulty/ — drives model selection

Label Shape of the work
difficulty/trivial one file, mechanical, no design decisions
difficulty/easy bounded, obvious approach, few files
difficulty/moderate multiple files, some judgement, a real interface to design
difficulty/hard subtle correctness, cross-cutting, or the design document itself is thin here

type/

type/feature, type/chore, type/test, type/bug

Driving this autonomously

A driver session should be able to work from DESIGN.md plus the issue list alone. The loop:

  1. List open issues. Drop any whose Depends on: references an open issue.
  2. Of what remains, take the lowest phase number first, then whatever parallelises within it.
  3. Spawn one session per issue, selecting the model from difficulty/.
  4. Merge on green and reviewed, then recompute the ready set.

What happens after a pull request opens

The PR daemon (pr-daemon.service, org-wide over yolo/*) picks up every pull request automatically. It spawns two sessions per PR:

  • <n>-<slug> running the land skill — the author side, fixing CI failures and addressing review comments.
  • rev-<n>-<slug> running review-pr — the reviewer side, posting findings to Gitea.

The driver does not babysit either of them, and does not re-implement what they do. It waits.

Neither of them merges. That is the driver's job, and the operator does not review personally.

Merge when CI is green and the review session has finished. If the review session never spawned, or is stuck — no progress for ~20 minutes, or the session is gone — skip waiting and merge on CI green alone. A stalled reviewer must not block the queue.

Squash merge, delete the branch, close the issue.

Model selection

Sessions are spawned with aoe. Each difficulty tier has two models, and issues at that tier are distributed between them — not run twice. Splitting across two providers keeps a single provider's rate limits or an outage from stalling the whole queue, and keeps one model's blind spots from shaping the entire codebase.

Difficulty Models --tool --extra-args
difficulty/hard Fable 5 claude --model claude-fable-5
Sol codex -m gpt-5.6-sol
difficulty/moderate Opus 5 claude --model claude-opus-5
Sol, low effort codex -m gpt-5.6-sol -c model_reasoning_effort="low"
difficulty/easy Sonnet 5 claude --model claude-sonnet-5
Terra codex -m gpt-5.6-terra
difficulty/trivial Terra codex -m gpt-5.6-terra

Alternate within a tier rather than draining one model first, so a bad run is visible early instead of after ten issues.

Launching a session

Verified working against this repo:

aoe add /home/naps62/tea/arr \
    --title "arr-42-torznab" \
    --worktree issue/42-torznab --new-branch \
    --tool codex --extra-args "-m gpt-5.6-sol" \
    --launch

Things that are easy to get wrong:

  • The worktree path comes from --title, not from the branch. That command creates /home/naps62/tea/arr/worktrees/arr-42-torznab on branch issue/42-torznab. Give every session a distinct title or the second one collides.
  • --new-branch is required alongside --worktree for a branch that does not exist yet. Without it, aoe expects the branch to be there already.
  • The base branch defaults to the repo default (main). Pass --base-branch only when stacking on an in-flight branch.
  • YOLO mode came out enabled without passing -y, from profile config. Check that is what you want before an unattended run.
  • aoe remove <title> keeps the worktree. Use --delete-worktree, or clean up with git worktree remove --force — and note aoe leaves the worktree locked, so git worktree unlock comes first.

Model IDs, verified

All five were checked against the live CLIs on 2026-08-22, with a deliberately bogus ID as a control to prove the check discriminates:

ID Tool Result
claude-fable-5 claude accepted
claude-opus-5 claude accepted
claude-sonnet-5 claude accepted
gpt-5.6-sol codex accepted
gpt-5.6-terra codex accepted, and the default in ~/.codex/config.toml

-c model_reasoning_effort="low" was exercised on a real codex exec run and accepted. The bogus control (gpt-5.6-nonesuch) failed with HTTP 400, and claude-bogus-9 was rejected by the CLI.

Escalate one tier if a session fails CI twice on the same issue — and escalate to the other model at that tier first, before going up. Never de-escalate mid-issue.

What a driver must not do

  • Do not run two sessions editing the same crate at the same time. Each session gets its own worktree, so the risk is merge conflicts rather than build contention. In practice this means most of phase/2-logic is a chain, since policy, lang, hdr and score all live in arr-core. Issues in different crates parallelise freely.
  • Do not start phase/4-movies work until phase/2-logic is complete. The policy engine is the thing everything else calls.
  • Do not merge a pull request that skips tests to get CI green. Failing tests are the signal the design document is wrong somewhere; surface it.

Environment

  • Prowlarrprowlarr container on the Dokploy host, port 9696. Owns tracker auth, FlareSolverr and the Cardigann definitions. Not replaced.
  • qBittorrentqbittorrent.n62.casa, WebUI API v2, login required (ARR_QBITTORRENT_USERNAME / ARR_QBITTORRENT_PASSWORD). Download dir /mnt/media/qbittorrent/complete.
  • Jellyfin — native in LXC at 10.6.10.18:8096. Library roots under /mnt/media-v2.
  • Media — ZFS, single dataset, bind-mounted as /mnt/media. Downloads and library share it, so hardlinks work.
  • ntfy — running on the Dokploy host, one topic per person.

Development happens on the same machine that runs the stack, so all of the above are directly reachable. Never point tests at a live tracker.