#!/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()