af33b38c1b
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>
1018 lines
40 KiB
Python
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()
|