From 3804e62eea1e80fce41b5d7a8cdf2e8ef77eae4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 18:22:48 +0100 Subject: [PATCH] chore: add issue seeding script --- scripts/seed-issues.py | 962 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 962 insertions(+) create mode 100644 scripts/seed-issues.py diff --git a/scripts/seed-issues.py b/scripts/seed-issues.py new file mode 100644 index 0000000..904184e --- /dev/null +++ b/scripts/seed-issues.py @@ -0,0 +1,962 @@ +#!/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. + +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( + "uisearch", "Unified search box", + "phase/5-ui", "area/web", "difficulty/moderate", + deps=["web", "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. + +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", "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. +""", + ), + issue( + "uilibrary", "Library views with derived status", + "phase/5-ui", "area/web", "difficulty/moderate", + deps=["web", "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. +""", + ), + issue( + "uiqueues", "Attention queues: no-PT-source and needs-decision", + "phase/5-ui", "area/web", "difficulty/easy", + deps=["uilibrary", "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. +""", + ), + + # ---------------------------------------------------------------- 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 = {} + + for spec in ISSUES: + title = spec["title"] + if title in by_title: + numbers[spec["key"]] = by_title[title] + print(f"issue = #{by_title[title]} {title}") + continue + + body = spec["body"] + if spec["deps"]: + refs = [] + for d in spec["deps"]: + refs.append(f"#{numbers[d]}" if d in numbers else f"`{d}` (not yet created)") + body += "\n\n---\n\n**Depends on:** " + ", ".join(refs) + body += f"\n\nLabels: `{'`, `'.join(spec['labels'])}`" + + print(f"issue + {title}") + if DRY: + continue + made = req("POST", f"/repos/{OWNER}/{REPO}/issues", { + "title": title, + "body": body, + "labels": [label_ids[n] for n in spec["labels"] if n in label_ids], + }) + if made: + numbers[spec["key"]] = made["number"] + + print(f"\n{len(numbers)}/{len(ISSUES)} issues present") + + +if __name__ == "__main__": + main()