chore: add issue seeding script
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user