Files
arr/scripts/seed-issues.py
Claude af33b38c1b docs: require impeccable skill for design and frontend
Adds the design-system issue, scaffolds .impeccable/live/config.json against
web/index.html, and records the aoe spawn commands for the codex GPT-5.6
models in the driver table.

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

1018 lines
40 KiB
Python

#!/usr/bin/env python3
"""Seed Gitea labels and issues for this repo.
Idempotent: labels and issues are matched by name/title and skipped if they
already exist. Dependencies are declared by key and rewritten to real issue
numbers once the referenced issue has been created.
GITEA_TOKEN=... python3 scripts/seed-issues.py [--dry-run]
"""
import json
import os
import sys
import urllib.error
import urllib.request
API = "https://git.naps.pt/api/v1"
OWNER = "yolo"
REPO = "arr"
TOKEN = os.environ.get("GITEA_TOKEN")
DRY = "--dry-run" in sys.argv
LABELS = [
("phase/1-skeleton", "1d76db", "workspace, CI, config, database, empty API and SPA"),
("phase/2-logic", "1d76db", "parsing and policy engine, pure, no network"),
("phase/3-sourcing", "1d76db", "TMDB and Prowlarr, read-only"),
("phase/4-movies", "1d76db", "movies end to end"),
("phase/5-ui", "1d76db", "search, buckets, library views, queues"),
("phase/6-tv", "1d76db", "seasons, episodes, tracking, derived status"),
("phase/7-people", "1d76db", "owner tags and notifications"),
("phase/8-compat", "1d76db", "Jellyseerr shim"),
("area/core", "0e8a16", "arr-core"),
("area/parse", "0e8a16", "arr-parse"),
("area/meta", "0e8a16", "arr-meta"),
("area/indexer", "0e8a16", "arr-indexer"),
("area/dl", "0e8a16", "arr-dl"),
("area/probe", "0e8a16", "arr-probe"),
("area/db", "0e8a16", "arr-db"),
("area/api", "0e8a16", "arr-api"),
("area/compat", "0e8a16", "arr-compat"),
("area/daemon", "0e8a16", "arr-daemon"),
("area/web", "0e8a16", "web/"),
("area/ci", "0e8a16", "CI and test harness"),
("area/infra", "0e8a16", "workspace and tooling"),
("difficulty/trivial", "c2e0c6", "one file, mechanical, no design decisions"),
("difficulty/easy", "fef2c0", "bounded, obvious approach, few files"),
("difficulty/moderate", "f9a825", "multiple files, judgement, an interface to design"),
("difficulty/hard", "d93f0b", "subtle correctness or cross-cutting"),
("type/feature", "5319e7", ""),
("type/chore", "5319e7", ""),
("type/test", "5319e7", ""),
("type/bug", "b60205", ""),
]
def issue(key, title, phase, area, difficulty, body, deps=(), type_="feature"):
return dict(key=key, title=title, deps=list(deps), body=body.strip(),
labels=[phase, area, difficulty, f"type/{type_}"])
ISSUES = [
# ---------------------------------------------------------------- phase 1
issue(
"workspace", "Cargo workspace skeleton",
"phase/1-skeleton", "area/infra", "difficulty/easy", type_="chore",
body="""
Set up the workspace described in `DESIGN.md` §11. Empty crates are fine — this
issue is the shape, not the contents.
- Root `Cargo.toml` with `members = ["crates/*"]`, `[workspace.package]`,
`[workspace.dependencies]` and `[workspace.lints]` exactly as in §12.
- All ten crates created, each with `[lints] workspace = true` and nothing else.
- `rust-toolchain.toml`, `rustfmt.toml`, `Justfile` with a `ci` recipe that runs
the full §12 gate locally.
- Release profile: `lto = "thin"`, `codegen-units = 1`.
- `flake.nix` following `~/tea/maestro`.
Acceptance: `just ci` passes on an empty workspace.
""",
),
issue(
"ci", "CI gate on Gitea Actions",
"phase/1-skeleton", "area/ci", "difficulty/moderate", type_="chore",
deps=["workspace"],
body="""
Implement the per-push gate from `DESIGN.md` §12.
Steps: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`,
`cargo machete`, `cargo nextest run`, and for the frontend `biome ci web/` plus
`tsc -b --noEmit`.
Total wall-clock is an explicit constraint, so:
- Cache `~/.cargo/registry`, `~/.cargo/git` and `target/`, keyed on
`Cargo.lock` plus `rust-toolchain.toml`.
- Never build `--release` in the gate.
- `cargo deny` goes on a schedule, not on push.
- E2E is a separate job, `main` and PRs touching those crates only.
Acceptance: gate is green and under five minutes warm. Record the cold and warm
times on the issue — later issues will be judged against them.
""",
),
issue(
"config", "Bootstrap configuration loading",
"phase/1-skeleton", "area/daemon", "difficulty/easy",
deps=["workspace"],
body="""
Only bootstrap settings come from config/env — everything tunable lives in the
database (`DESIGN.md` §10).
Load, with defaults where sensible: bind address, media root, Prowlarr base URL
and API key, Transmission RPC URL, TMDB API key, Jellyfin base URL and key,
ntfy base URL, database path.
Secrets come from the environment, never from the checked-in file. Every field
that can have a default must have one, so the config file stays optional.
Acceptance: the daemon starts with only environment variables set.
""",
),
issue(
"db", "SQLite setup and initial migrations",
"phase/1-skeleton", "area/db", "difficulty/moderate",
deps=["workspace"],
body="""
`sqlx` with SQLite and compile-time-checked queries (`DESIGN.md` §10).
Migrations for the movie side of the model in §4: `Root`, `Policy`, `Movie`,
`MediaFile`, `Release`, `Grab`, `Blacklist`, `Owner`, `TitleOwner`. Series,
seasons and episodes are a later issue — do not create them speculatively.
- WAL mode, foreign keys on, sensible indices on the columns the reconcile loop
scans (`wanted`, `last_searched_at`, `state`).
- Seed the two movie roots and their policies from §5.1.
- A `just db-reset` recipe.
Acceptance: migrations apply from empty, and `cargo sqlx prepare` output is
committed so CI does not need a live database.
""",
),
issue(
"api", "axum skeleton with health and OpenAPI",
"phase/1-skeleton", "area/api", "difficulty/moderate",
deps=["workspace", "config"],
body="""
`DESIGN.md` §9.1 — the HTTP API is the product surface and the web UI is just a
client of it.
- axum server bound per config, no authentication (§2).
- `GET /api/health` reporting reachability of Prowlarr, Transmission and TMDB.
- OpenAPI generated from handler annotations and served, plus a browsable UI.
- A `just gen-client` recipe that generates the TypeScript client from the
spec into `web/src/api/`.
Acceptance: the spec is generated rather than hand-written, and adding a
handler without annotating it fails CI.
""",
),
issue(
"web", "Vite + TypeScript SPA skeleton, embedded in the binary",
"phase/1-skeleton", "area/web", "difficulty/moderate",
deps=["workspace", "api"],
body="""
Following `~/tea/maestro`: a standalone `web/` built with Vite and pnpm, then
embedded into the binary with `include_dir` and served with correct MIME types.
- Biome configured for lint and format, wired into `just ci`.
- `tsc -b --noEmit` in the gate.
- Dev mode proxies `/api/*` to the running daemon so the SPA has hot reload.
- One page that calls `GET /api/health` and renders it, to prove the whole path.
Use the `impeccable` skill for this — see the design section of `CLAUDE.md`.
Acceptance: `cargo run` serves the built SPA from a single binary with no
external files.
""",
),
# ---------------------------------------------------------------- phase 2
issue(
"coretypes", "arr-core domain types",
"phase/2-logic", "area/core", "difficulty/moderate",
deps=["workspace"],
body="""
The types in `DESIGN.md` §4, movies only. No IO, no sqlx, no axum — this crate
must stay dependency-light (`CLAUDE.md`, the invariant).
Model the things that are easy to get subtly wrong:
- `Language` distinguishing `pt-PT`, `pt-BR` and `por-unverified` (§5.2).
- `HdrFormat` with the DV profile as data, not a boolean (§5.3).
- `Verdict` as `Eligible | Waived(rule) | Rejected(rule)`, so a rejection always
carries which rule killed it (§9.3).
- Intent as `wanted` on the leaf, never a `monitored` flag (§4.1).
Acceptance: the crate compiles with no `reqwest`, `sqlx` or `axum` in its
dependency tree, and `cargo tree` proves it.
""",
),
issue(
"parse", "Release name parser",
"phase/2-logic", "area/parse", "difficulty/hard",
deps=["workspace"],
body="""
Parse `Movie.Title.2024.2160p.UHD.BluRay.DV.HDR10.x265.TrueHD-GROUP` into
structured fields: title, year, resolution, source, codec, HDR markers,
language markers, edition, group.
This is the hardest pure-logic piece in the project and the reason `arr-parse`
is its own crate. Prior art worth reading before writing anything:
`torrent-name-parser`, `parsett`, `guessit`.
Two requirements that are easy to miss:
- Language markers matter as much as quality markers here, because `PT-BR`,
`Dublado`, `Nacional` and `Dual Áudio` are the first of three pt-BR detection
signals (`DESIGN.md` §5.2).
- Everything parsed is a *claim*, not a fact (§5.6). The output type should make
that obvious at the call site — these fields are never mixed with `ffprobe`
results in the same struct.
Acceptance: parses the fixture corpus from the companion issue with no panics
and no `unwrap`.
""",
),
issue(
"parsefix", "Release name fixture corpus",
"phase/2-logic", "area/parse", "difficulty/easy", type_="test",
deps=["parse"],
body="""
A committed corpus of real release names with expected parses, as the regression
suite for `arr-parse`.
Pull real names from Prowlarr rather than inventing them — a manual search
against a few indexers gives hundreds in one call. Cover deliberately:
- Every resolution and source tier in `DESIGN.md` §5.5.
- DV-tagged and DV-untagged 4K releases.
- Multi-audio releases, and the pt-BR markers from §5.2.
- Names that are simply malformed, which must parse partially rather than fail.
Acceptance: table-driven test, one row per name, failures print the diff.
""",
),
issue(
"policy", "Policy engine: filter and verdict pipeline",
"phase/2-logic", "area/core", "difficulty/hard",
deps=["coretypes", "parse"],
body="""
The core of the app (`DESIGN.md` §5). Given a policy and a candidate — either a
parsed release name pre-grab, or a probed file post-download — produce a
`Verdict` that always names the deciding rule.
- The same engine runs in both phases (§5.6) but sees different evidence. A rule
that cannot be evaluated pre-grab must return "unknown", never "pass".
- Post-download, a violation splits into hard fail and soft fail (§5.7). That
split is engine output, not caller policy.
- Per-title `overrides` relax or tighten the root policy, in both directions
(§5.1).
Language, HDR and scoring rules are separate issues that plug into this. Build
the pipeline and the trivial rules (resolution, source, telesync) here.
Acceptance: exhaustive unit tests. This crate is where correctness is bought.
""",
),
issue(
"lang", "Language rule: original language, dubs and pt-BR detection",
"phase/2-logic", "area/core", "difficulty/hard",
deps=["policy"],
body="""
`DESIGN.md` §5.2. The rule is one expression:
reject audio track T if T.lang ∈ dub_blacklist and T.lang ≠ title.original_language
which requires the title's original language from TMDB as a distinct input from
any track's language. Verify both roots behave as §5.2 describes, including a
Brazilian film's own soundtrack passing in `main`.
The hard part is pt-PT versus pt-BR: ISO-639-2 has one code for both. Implement
the three signals in order — release-name markers, `ffprobe` stream title and
`handler_name` strings, container BCP-47 tags — and when none resolve it, emit
`por-unverified` and surface it. Never guess.
Acceptance: a test per row of the truth table in §5.2, plus an explicit test
that an unresolvable Portuguese track produces `por-unverified` and not a
guess in either direction.
""",
),
issue(
"hdr", "HDR and Dolby Vision profile rule",
"phase/2-logic", "area/core", "difficulty/moderate",
deps=["policy"],
body="""
`DESIGN.md` §5.3. Reject DV Profile 5 and Profile 7, accept Profile 8.1, which
carries an HDR10 base layer and plays correctly on this setup.
Two things the naive version gets wrong:
- "Blacklist Dolby Vision" would discard most good 4K releases. The existing
library is almost entirely Profile 8.1.
- Profile is knowable only from `ffprobe`. Pre-grab, a `DV` marker in a name
means "claims DV" and its absence means nothing — so pre-grab this rule
returns unknown, never pass.
Acceptance: unit tests per profile, and an integration test against the real
DV Profile 5 clip once the probe fixtures exist.
""",
),
issue(
"score", "Size-band scoring",
"phase/2-logic", "area/core", "difficulty/moderate",
deps=["policy"],
body="""
`DESIGN.md` §5.5. Score by distance from a target size, not by source tier — a
remux must win only when nothing smaller exists.
- Per resolution: floor, target, growing penalty above target. Below the floor
is a hard filter, not a low score.
- Source tier survives as a small tiebreaker.
- Seeders log-scaled and small.
- Telesync, CAM and screener are hard filters.
The numbers in §5.5 are placeholders pending the remux streaming test in §14, so
they live in the `Policy` row and are trivially changed. Do not hard-code them.
Acceptance: given a realistic candidate set, a 20 GB WEB-DL beats a 60 GB remux,
and the remux still wins when it is the only candidate.
""",
),
# ---------------------------------------------------------------- phase 3
issue(
"tmdb", "TMDB client",
"phase/3-sourcing", "area/meta", "difficulty/moderate",
deps=["workspace"],
body="""
Search, detail lookup and — critically — the two fields the policy engine
depends on that are easy to treat as decoration:
- `original_language`, which the entire dub rule is built on (`DESIGN.md` §5.2).
- Digital release dates, which gate targeted search (§6.2). A movie with no
release date must get zero searches.
Cache responses locally; metadata refresh is daily, not per request.
Acceptance: `wiremock`-backed tests, no live calls in CI.
""",
),
issue(
"prowlarrenum", "Prowlarr indexer enumeration and capabilities",
"phase/3-sourcing", "area/indexer", "difficulty/easy",
deps=["workspace"],
body="""
`GET /api/v1/indexer` to enumerate, then `t=caps` per indexer to record which
search modes each tracker supports (`DESIGN.md` §6.1). Not all support ID-based
search; some are text-only, and the search layer must adapt rather than fail.
Store indexer identity — the per-tracker seeding rules in §7.3 depend on it.
Acceptance: enumerating the live Prowlarr yields indexers with capabilities, and
the parsing is tested against recorded fixtures.
""",
),
issue(
"torznab", "Torznab search and RSS client",
"phase/3-sourcing", "area/indexer", "difficulty/hard",
deps=["prowlarrenum"],
body="""
`DESIGN.md` §6.1. Per-indexer Torznab, not Prowlarr's aggregate search API —
the reasoning is in that section and should not be revisited without changing
the design document.
Four call shapes: `t=caps`, `t=search`, `t=movie` with `imdbid`, `t=tvsearch`
with `tvdbid`/`season`/`ep`. An empty-query search is the RSS feed; it is the
same endpoint, and that equivalence is what makes §6.2's cost model work.
Torznab XML is Newznab-derived and inconsistent between trackers: optional
attributes, missing sizes, varying seeder fields, occasional invalid XML.
Malformed responses from one indexer must not fail the whole search.
Acceptance: `wiremock` fixtures recorded from at least three real indexers,
including one with a deliberately broken response.
""",
),
issue(
"searchapi", "Search API returning classified releases",
"phase/3-sourcing", "area/api", "difficulty/moderate",
deps=["torznab", "policy", "api", "tmdb"],
body="""
Wire sourcing to the policy engine and expose the result. This is the first
issue where the app does something visibly useful, and it still grabs nothing.
- `GET /api/search?q=` — unified search per `DESIGN.md` §9.2: library results
and TMDB results, grouped, plus raw TMDB/IMDb ID and magnet handling.
- `GET /api/releases?movie_id=` — manual search, returning every candidate with
its `Verdict` and score already computed (§9.3), so the client never
re-implements policy.
Acceptance: every returned release carries a verdict, and every rejection names
its rule.
""",
),
# ---------------------------------------------------------------- phase 4
issue(
"transmission", "Transmission RPC client",
"phase/4-movies", "area/dl", "difficulty/moderate",
deps=["workspace"],
body="""
`DESIGN.md` §7.1. RPC at the configured host, no credentials.
Needed: add torrent (with label, download dir, `seedRatioLimit` and
`seedIdleLimit` set at add time per §7.3), list torrents with state and
progress, remove torrent with and without data.
Two things that bite: the `X-Transmission-Session-Id` 409 handshake must be
handled transparently on every call, and torrent state must be treated as
authoritative — the app rebuilds its transient view from Transmission on
startup (§8), never the other way round.
Acceptance: integration test against a real Transmission container.
""",
),
issue(
"probe", "ffprobe wrapper",
"phase/4-movies", "area/probe", "difficulty/moderate",
deps=["workspace"],
body="""
`DESIGN.md` §5.6 — the source of truth about a file.
Extract: resolution, video codec, HDR format including **DV profile as a
number**, per-track audio language plus track title and `handler_name` strings
(both needed for §5.2's second pt-BR signal), subtitle tracks, duration, size.
Also decide which file in a multi-file torrent is the feature: largest video
file, with a duration sanity check against TMDB runtime to avoid picking a
90-minute extras reel.
Acceptance: parses real output from the fixture clips, and a file `ffprobe`
cannot read produces an error rather than a default-valued struct.
""",
),
issue(
"probefix", "ffprobe fixture clips, including DV Profile 5",
"phase/4-movies", "area/probe", "difficulty/easy", type_="test",
deps=["probe"],
body="""
Tiny committed clips, a few KB each, covering every branch of the HDR rule.
The Jellyfin LXC already has exactly the right fixtures at `/srv/jellyfin-test`:
synthetic HDR10, HLG, SDR 10-bit HEVC and H264 SDR clips generated with
`jellyfin-ffmpeg`, plus a **real DV Profile 5 clip** pulled from archive.org.
Reuse them rather than regenerating.
Also needed: a multi-audio clip with a Portuguese track whose stream title says
"Portuguese (Brazil)", which is the only way to test §5.2's second signal.
Acceptance: the DV rule test from the `hdr` issue passes against the real
Profile 5 clip, not a mock.
""",
),
issue(
"reconcile", "Reconcile loop skeleton",
"phase/4-movies", "area/daemon", "difficulty/hard",
deps=["db", "coretypes", "config"],
body="""
`DESIGN.md` §8. There is no job queue — the database rows are the work list, and
every tick compares desired state to actual state and acts on the gap.
- Idempotent by construction. Killing the process mid-operation and restarting
must converge, and there should be a test that does exactly that.
- Staggered ticks: reconcile 30 s, RSS 10 min, metadata daily, reaper 5 min.
- Transient state in memory, rebuilt from Transmission on startup.
- Structured logging per tick: what gap was found, what action was taken. This
is the only debugging surface when something silently does not download.
This issue is the loop and its scheduling. The individual actions are separate
issues that register into it.
Acceptance: a test that runs several ticks against a seeded database with a
faked Transmission and asserts convergence and idempotence.
""",
),
issue(
"grab", "Grab pipeline: select, send to Transmission, track",
"phase/4-movies", "area/daemon", "difficulty/moderate",
deps=["reconcile", "transmission", "score", "searchapi"],
body="""
Close the gap "wanted movie, no file" — search, score, pick the winner, send to
Transmission, record a `Grab`.
- No grab delay. Deliberately rejected in `DESIGN.md` §5.4 — do not reintroduce
one.
- Set the label and both seeding limits at add time (§7.1, §7.3).
- A title already having an in-flight grab is not a gap. Double-grabbing on a
restart is the obvious failure mode here.
Acceptance: a seeded wanted movie ends with one torrent in Transmission and one
`Grab` row, and a restart mid-flight does not produce a second.
""",
),
issue(
"import", "Import pipeline: probe, hardlink, rename, layout",
"phase/4-movies", "area/daemon", "difficulty/hard",
deps=["grab", "probe", "lang", "hdr"],
body="""
`DESIGN.md` §7.2 and §7.4. A completed torrent is probed, judged against the
policy a second time with real evidence, then linked into the library.
- Hardlink, falling back to copy on `EXDEV` only. No configuration flag.
- Layout and naming exactly as §7.4, including the `[tmdbid-N]` folder marker
and attribute tags taken **from `ffprobe`, not from the release name**.
- One folder per title even for a single file.
- Never move or delete the torrent's own files. The two lifecycles are separate
(§7.3).
Acceptance: an end-to-end test producing the exact paths in §7.4, and an
assertion that the source file still exists with a link count of two.
""",
),
issue(
"failure", "Hard/soft fail handling and release blacklist",
"phase/4-movies", "area/daemon", "difficulty/moderate",
deps=["import"],
body="""
`DESIGN.md` §5.7 and §6.3.
- Hard fail: do not import, blacklist by infohash and normalised name, grab the
next candidate. The torrent keeps seeding regardless (§7.3).
- Soft fail: import, record a `waiver` naming the relaxed rule, surface it. The
file must not look like a clean match in the UI.
- Blacklisted releases are excluded from RSS matching too, not just from
targeted search.
Acceptance: a hard-failing release is never re-grabbed across restarts, and a
soft-failed import carries a waiver that reaches the API.
""",
),
issue(
"reaper", "Per-tracker seeding rules and torrent reaper",
"phase/4-movies", "area/daemon", "difficulty/moderate",
deps=["grab"],
body="""
`DESIGN.md` §7.3. Seeding obligations are per tracker and configured locally,
because Prowlarr does not expose tracker rules.
Transmission enforces them — set `seedRatioLimit` and `seedIdleLimit` at add
time. The reaper only removes torrents Transmission already reports as done
seeding.
The invariant worth a test: a hard-failed, blacklisted, never-imported release
still seeds to completion before removal. Nothing is deleted early to satisfy
the library.
Acceptance: integration test against a real Transmission with a short ratio
limit, asserting the reaper waits.
""",
),
issue(
"backoff", "Targeted-search backoff and release-date gating",
"phase/4-movies", "area/daemon", "difficulty/easy",
deps=["reconcile", "tmdb"],
body="""
`DESIGN.md` §6.2. Targeted search costs one query per item per attempt, so it
backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when metadata changes.
Also gate on release date: a movie with no digital release date on TMDB gets
zero targeted searches. This is the single largest source of wasted queries in
Radarr and it is free to avoid.
RSS is unaffected — its cost does not scale with the wanted list, so it never
backs off.
Acceptance: a wanted unreleased movie produces no indexer calls across many
ticks, and an unsatisfiable released one produces a decaying number.
""",
),
issue(
"rss", "RSS sync worker",
"phase/4-movies", "area/daemon", "difficulty/moderate",
deps=["reconcile", "torznab"],
body="""
`DESIGN.md` §6.2. One empty-query Torznab call per indexer every ten minutes,
matched against the whole wanted list locally.
The property that makes this worth having: cost is independent of library size,
so every wanted item is matched against every result forever, with no backoff.
Matching parsed release names to wanted titles is the substance here — it must
be conservative, since a false positive grabs the wrong film. Prefer ID matches
where the indexer supplies them.
Acceptance: given a recorded RSS response and a seeded wanted list, exactly the
intended titles match, with a test for a near-miss that must not match.
""",
),
issue(
"jellyfin", "Jellyfin library refresh on import",
"phase/4-movies", "area/daemon", "difficulty/trivial",
deps=["import"],
body="""
`DESIGN.md` §7.5. One HTTP call to Jellyfin's refresh endpoint for the affected
library at the end of a successful import, because its filesystem watcher misses
things.
Failure to reach Jellyfin must not fail the import — log it and continue.
""",
),
issue(
"moviesapi", "Movie CRUD API",
"phase/4-movies", "area/api", "difficulty/moderate",
deps=["api", "db", "coretypes"],
body="""
Add, list, get, update and delete movies; set `wanted`, `blocked` and per-title
`overrides` (`DESIGN.md` §5.1); trigger a manual search; grab a specific
release.
Two endpoints exist specifically to serve §9.3's UI without duplicating policy
in the client: a movie's releases with verdicts already attached, and the
attention queues (no-PT-source, needs-decision).
Acceptance: OpenAPI spec covers every endpoint, and the generated TypeScript
client compiles.
""",
),
# ---------------------------------------------------------------- phase 5
issue(
"design", "Design system tokens",
"phase/5-ui", "area/web", "difficulty/moderate",
deps=["web"],
body="""
Author `.impeccable/design.json` — colours, typography, spacing, the token set
every component consumes. **Use the `impeccable` skill for this**; it is what
the file's schema belongs to (`CLAUDE.md`, design section).
`~/tea/arcada` has a finished `design.json` at `schemaVersion: 2` worth reading
first for the shape.
Constraints specific to this app:
- The manual-search view (`DESIGN.md` §9.3) is dense, tabular and chip-heavy.
The palette has to carry three verdict states — eligible, waived, rejected —
legibly at small sizes and against each other, without relying on colour
alone.
- Library status (§4.2) is five states, and they are informational rather than
severity-ranked. Do not borrow an error/warning/success ramp for them.
- It runs on a NAS dashboard, often on a phone, often in a dark room. Dark mode
is not an afterthought.
`.impeccable/live/config.json` is already scaffolded and points at
`web/index.html`.
Acceptance: components consume tokens, never literal colour or spacing values,
and CI can assert that.
""",
),
issue(
"uisearch", "Unified search box",
"phase/5-ui", "area/web", "difficulty/moderate",
deps=["web", "design", "searchapi", "moviesapi"],
body="""
`DESIGN.md` §9.2. One box, results grouped: in-library first, TMDB below. Enter
on a TMDB result opens the add flow with root and policy pre-filled.
Also accepts a raw TMDB or IMDb ID and a pasted magnet or `.torrent`.
The requirement, stated as a test: there is never a moment where the user has to
know whether they are searching or adding. If the UI has an "Add Movie" page
separate from search, this issue is not done.
Use the `impeccable` skill for this — see the design section of `CLAUDE.md`.
Debounce, and cancel in-flight requests on keystroke.
""",
),
issue(
"uimanual", "Manual search buckets and attribute chips",
"phase/5-ui", "area/web", "difficulty/moderate",
deps=["web", "design", "searchapi"],
body="""
`DESIGN.md` §9.3. The fix for Radarr's unusable manual search.
- Three buckets: `eligible` shown by default and sorted by score; `waived` and
`rejected` collapsed to a count, expandable.
- Columns are parsed attributes as fixed-width chips — score, resolution,
source, HDR, audio languages, size, seeders. **No horizontal scroll at any
viewport.** That is the acceptance criterion, not a preference.
- Release name truncated and secondary, full string on expand.
- Every rejected row names the rule that killed it.
- One click on a `waived` row grabs it and writes the override.
The client must not re-implement any policy. Verdicts arrive from the API.
Use the `impeccable` skill for this — see the design section of `CLAUDE.md`.
""",
),
issue(
"uilibrary", "Library views with derived status",
"phase/5-ui", "area/web", "difficulty/moderate",
deps=["web", "design", "moviesapi"],
body="""
`DESIGN.md` §4.2. The default view shows `airing` and `incomplete` only;
everything else collapses behind one toggle.
Status is derived and displayed, never an editable flag — the only user-set
field is intent (§4.1). A UI that lets you toggle "monitored" has reintroduced
the exact problem this design removed.
Show waivers honestly: a file imported under `allow_english_audio` reads as
"English, no dub", not as a clean match.
Use the `impeccable` skill for this — see the design section of `CLAUDE.md`.
""",
),
issue(
"uiqueues", "Attention queues: no-PT-source and needs-decision",
"phase/5-ui", "area/web", "difficulty/easy",
deps=["uilibrary", "design", "failure"],
body="""
`DESIGN.md` §5.2 and §5.7. Two lists of things a human has to look at.
- **No PT source** — kids' titles with no qualifying Portuguese release. Items
sit here for months; that is expected, not a bug. One click applies
`allow_english_audio` and lets the normal search proceed.
- **Needs decision** — hard-failed twice on different releases.
These are the only places the app asks for attention, and they are the same
events that notify (§9.5). Keep them consistent.
Use the `impeccable` skill for this — see the design section of `CLAUDE.md`.
""",
),
# ---------------------------------------------------------------- phase 6
issue(
"tvmodel", "Series, season and episode model",
"phase/6-tv", "area/db", "difficulty/moderate",
deps=["db", "coretypes"],
body="""
`DESIGN.md` §4. Series, Season and Episode tables and types, plus the TV roots
and their policies.
Movies and series are separate aggregates that share machinery, not a table
(§4, §11). Do not generalise the movie code to accommodate this — build it
concretely and extract only what is obviously identical.
Intent is `Episode.wanted`. `Series.auto_track` is a rule, not intent (§4.1).
Acceptance: migrations apply on top of the existing movie schema without
touching it.
""",
),
issue(
"tvtrack", "auto_track and wanted propagation",
"phase/6-tv", "area/core", "difficulty/moderate",
deps=["tvmodel"],
body="""
`DESIGN.md` §4.1. When metadata reveals a new season on a series with
`auto_track`, mark its episodes wanted. That is all tracking means.
The property that makes this design work: an untracked series where the user
manually marked S02 needs no special case — three wanted episodes, nothing
else. There should be a test asserting exactly that, with `auto_track` false.
Acceptance: a metadata refresh adding a season propagates correctly, and does
nothing for an untracked series.
""",
),
issue(
"tvstatus", "Derived series status",
"phase/6-tv", "area/core", "difficulty/moderate",
deps=["tvmodel"],
body="""
`DESIGN.md` §4.2. Compute `airing`, `incomplete`, `waiting`, `complete`,
`ended` from air dates, the wanted set and files on disk. Never stored as
intent; recomputed on metadata refresh and file change.
The two behaviours worth testing directly, because they are the whole point:
- A one-off season grab disappears from the default view by itself once
satisfied.
- A tracked show reappears by itself when a new season is announced.
Acceptance: a test per status, plus those two transitions.
""",
),
issue(
"tvsearch", "TV search: tvsearch parameters, seasons and episodes",
"phase/6-tv", "area/indexer", "difficulty/moderate",
deps=["torznab", "tvmodel"],
body="""
`t=tvsearch` with `tvdbid`, `season` and `ep`, falling back to text search on
indexers whose `t=caps` says they cannot do ID lookups (`DESIGN.md` §6.1).
Also: recognising season packs in results, which the grab-selection issue
depends on. A pack and a single episode are different things with the same
title.
Acceptance: fixtures covering single episodes, season packs, multi-episode
files and daily-dated shows.
""",
),
issue(
"tvgrab", "Season-pack versus per-episode grab selection",
"phase/6-tv", "area/daemon", "difficulty/hard",
deps=["tvsearch", "grab", "tvtrack"],
body="""
The operator's rule: **season packs only when the season is fully released**;
while a season is airing, grab per episode.
- A completed season with no episodes on disk prefers the pack — one torrent,
better seeded, consistent encode.
- An airing season grabs episodes individually as they appear.
- A pack that hard-fails costs the whole season, so failure handling must fall
back to per-episode rather than blacklisting the season outright.
- Partial overlap — a pack containing episodes already on disk — must not
re-import what exists.
`DESIGN.md` §14 notes that re-grabbing a pack once an airing season completes is
deliberately not done. Do not add it.
Acceptance: tests for each of the four cases above.
""",
),
issue(
"tvapi", "Series API and UI",
"phase/6-tv", "area/api", "difficulty/moderate",
deps=["tvmodel", "moviesapi", "tvstatus"],
body="""
Series equivalents of the movie endpoints, plus season and episode granularity:
set `auto_track`, mark individual seasons or episodes wanted, per-episode manual
search.
UI reuses the components from phase 5 — buckets, chips, derived status. If they
need forking rather than reusing, say so on the issue; that is a signal the
phase 5 components were built too narrowly.
""",
),
# ---------------------------------------------------------------- phase 7
issue(
"owners", "Owner tags and filtered views",
"phase/7-people", "area/core", "difficulty/easy",
deps=["coretypes", "moviesapi"],
body="""
`DESIGN.md` §4.3. `Owner` is a many-to-many tag on titles. It drives UI
filtering, the default list and notification routing.
It never appears in a path. A file lives in one place; audiences overlap. A
pull request that adds an owner directory to the layout is wrong.
Acceptance: a title with two owners appears in both filtered views and exists
once on disk.
""",
),
issue(
"ntfy", "ntfy notifications for the three events",
"phase/7-people", "area/daemon", "difficulty/easy",
deps=["owners", "import", "failure"],
body="""
`DESIGN.md` §9.5. One topic per `Owner`. Exactly three events:
- **Imported** → to the title's owners.
- **Needs a decision** → to the operator alone.
- **Broken** → to the operator alone.
Explicitly not notified: grabs, searches, downloads starting or finishing, soft
fails. Radarr's failure mode is notifying on everything and being muted within a
week; adding a fourth event type needs a design document change, not an issue.
Acceptance: an import notifies only that title's owners.
""",
),
# ---------------------------------------------------------------- phase 8
issue(
"compatmovie", "Radarr v3 shim for Jellyseerr",
"phase/8-compat", "area/compat", "difficulty/moderate",
deps=["moviesapi"],
body="""
`DESIGN.md` §9.4. The slice Jellyseerr actually calls: `system/status`,
`rootfolder`, `qualityprofile`, `tag`, `movie` GET and POST, `movie/lookup`.
- One fake quality profile per root, since policy attaches to the root here
(§5.1). Whatever profile Jellyseerr sends is ignored.
- Thin mappings only. This crate must never leak into `arr-core`, and
`arr-core` must never learn what a "quality profile" is.
Acceptance: the live Jellyseerr instance connects, lists root folders, and a
request from it creates a wanted movie in the right root.
""",
),
issue(
"compattv", "Sonarr v3 shim for Jellyseerr",
"phase/8-compat", "area/compat", "difficulty/moderate",
deps=["tvapi", "compatmovie"],
body="""
The `series` equivalents of the Radarr shim, including `languageprofile` if the
installed Jellyseerr still asks for it.
Season-level requests from Jellyseerr map to marking that season's episodes
wanted (`DESIGN.md` §4.1), not to a `monitored` flag.
Acceptance: a season request from the live Jellyseerr produces the right wanted
set.
""",
),
# ------------------------------------------------------------------- test
issue(
"e2e", "E2E harness: wiremock and a Transmission service container",
"phase/4-movies", "area/ci", "difficulty/hard", type_="test",
deps=["transmission", "torznab", "ci"],
body="""
`DESIGN.md` §12. End-to-end tests only at the seams that actually break.
- Prowlarr and TMDB — `wiremock` with recorded real responses. **Never live**:
trackers rate-limit and it would leak credentials into CI.
- Transmission — a real container as a CI service. RPC semantics are the most
likely source of surprise and it starts in a second.
- `ffprobe` — the committed fixture clips.
Runs on `main` and on pull requests touching those crates, not every push, so
the per-push gate stays under five minutes.
Acceptance: a full add-to-imported run against faked indexers and a real
Transmission, green in CI, under two minutes.
""",
),
]
def req(method, path, payload=None):
url = f"{API}{path}"
data = json.dumps(payload).encode() if payload is not None else None
r = urllib.request.Request(url, data=data, method=method)
r.add_header("Authorization", f"token {TOKEN}")
r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
body = resp.read().decode()
return json.loads(body) if body else None
except urllib.error.HTTPError as e:
print(f" ! {method} {path} -> {e.code} {e.read().decode()[:300]}")
return None
def main():
if not TOKEN:
sys.exit("GITEA_TOKEN not set")
existing = {l["name"]: l["id"] for l in (req("GET", f"/repos/{OWNER}/{REPO}/labels?limit=200") or [])}
label_ids = dict(existing)
for name, color, desc in LABELS:
if name in label_ids:
continue
print(f"label + {name}")
if DRY:
continue
made = req("POST", f"/repos/{OWNER}/{REPO}/labels",
{"name": name, "color": color, "description": desc})
if made:
label_ids[name] = made["id"]
open_issues = req("GET", f"/repos/{OWNER}/{REPO}/issues?state=all&limit=200") or []
by_title = {i["title"]: i["number"] for i in open_issues}
numbers = {}
# Pass 1 — ensure every issue exists, so dependency numbers are all known
# before any body is rendered.
for spec in ISSUES:
title = spec["title"]
if title in by_title:
numbers[spec["key"]] = by_title[title]
continue
print(f"issue + {title}")
if DRY:
continue
made = req("POST", f"/repos/{OWNER}/{REPO}/issues", {
"title": title,
"body": spec["body"],
"labels": [label_ids[n] for n in spec["labels"] if n in label_ids],
})
if made:
numbers[spec["key"]] = made["number"]
# Pass 2 — render bodies with real dependency numbers and push them.
# Declarative: this overwrites hand-edits to issue bodies. Edit the spec
# here, not the issue on Gitea.
for spec in ISSUES:
if spec["key"] not in numbers:
continue
body = spec["body"]
if spec["deps"]:
refs = ", ".join(f"#{numbers[d]}" for d in spec["deps"] if d in numbers)
body += f"\n\n---\n\n**Depends on:** {refs}"
body += f"\n\nLabels: `{'`, `'.join(spec['labels'])}`"
if DRY:
continue
req("PATCH", f"/repos/{OWNER}/{REPO}/issues/{numbers[spec['key']]}", {
"body": body,
"labels": [label_ids[n] for n in spec["labels"] if n in label_ids],
})
print(f"\n{len(numbers)}/{len(ISSUES)} issues present")
if __name__ == "__main__":
main()