# Matching accuracy plan Scope: bring AoNarr's release-to-item matching (grab, import, scan, IRC, manual) to Sonarr/Radarr behaviour where it matters most, in shippable rounds. The ordering follows the "highest-impact-minimal" plan (both judges asked for the identity gate to ship first, flag-guarded, with log-only dry run); the clean-title index, match-type gating, typed indexer queries and scene-numbering fixes from the "starr-fidelity" plan are grafted in as later rounds. Line anchors were re-checked against the working tree. ## Why media gets mismatched today Ranked by how many wrong grabs/imports each causes. 1. **Video types have no title gate at all.** `chooseBestResult` (`server/src/services/scheduler.ts:593`) sets `tiered = usesQualityTiers(mediaType)` at `:614`; the `relevant` filter at `:668-691` calls `releaseNameMatch` only inside `if (!tiered)` (`:669-675`). `usesQualityTiers` (`quality.ts:110-115`) is true for movie/series/anime/sports/ppv/video/course/adult, so for them only quality/upgrade/size are checked (`:676-690`). Single-shape callers pass `target: null` (`:1416`, `:1654`, `:2118`). `matchTierFor` (`:326-334`) is documented as a tiebreaker and sorts behind `totalScore` (`:768-771`). `tests/scheduler.test.ts:1896-1902` enshrines "nothing matches identity, still chosen". Example: wanted *Dune (2021)*, query `Dune 2021`, only hit `Dune.Part.Two.2024.1080p.WEB-DL.x265-GRP` → grabbed and imported as Dune (2021). Same for `Dead.Heat.1988` vs *Heat (1995)*. 2. **Episode searches compare numbers only.** `searchEpisode` passes `identity` and `name` as null (`scheduler.ts:1437-1448`); `searchAndGrabTarget` leaves them null for episodes (`:1644-1647`); `retryFailedGrab` likewise (`:2097-2100`). The only gate is `releaseMatchesEpisode`/`releaseMatchesAirDate` (`:648-664`; `releaseParser.ts:460-482`). The manual-search route does the same: `routes/search.ts:190` deliberately sets `identity = null` for episode/season/sub-item searches and `identityExternalIds` is only built for whole-item searches (`:123-149`). Example: *Charmed (1998)* S01E01 grabs `Charmed.2018.S01E01.1080p.AMZN.WEB-DL-NTb` because it has more seeders; *The Daily Show* 2024-08-25 grabs `The.Late.Show.2024.08.25.1080p.WEB-GRP`. The IRC path already does title + ±1 year (`ircAnnounce.ts:76-79`, `:97-99`); search never did. 3. **Identity is only a tiebreaker, never a rejection.** Sort at `scheduler.ts:768-778` is `totalScore → matchTier → …`; `matchTier` can only reorder releases that all survived the (absent) title filter, so a wrong-title release with +50 for x265 is both accepted and preferred. Example: *Up (2009)* chooses `Up.in.the.Air.2009.1080p.WEB.x265-GRP` over `Up.2009.1080p.BluRay.x264-GRP`. The fix is a gate (item 1), not a sort change: once wrong-title releases are rejected, `totalScore` must stay first so custom-format preferences keep deciding among releases that passed identity. 4. **Import selection is a token-overlap heuristic that never re-verifies identity.** `findDownloadedFile` (`importer.ts:477`) widens to all of `config.downloadsDir` when the scoped search finds nothing and there is no episode target (`:498`, `:506`); a reported single file with a non-media extension (`.iso`) falls through `:489-491` straight to that wide search. `scoreByTokenOverlap` (`:422-443`) weights `1080p`/`x264`/group like title words and `numberTokens` (`:257-262`) only sees 1-3 digit numbers, so 2021 vs 2024 never trips `otherNumber`. `pickDownloadedFile` (`:529`) first drops every candidate whose `otherNumber` is set, sorts by score descending then size descending, and keeps those scoring ≥0.4 (`:590-605`) — the winner is the highest token-overlap score, size as tiebreaker, so an unrelated file with enough shared quality tokens wins; the folder-name fallback (`:563-586`) lets an unnumbered extra win. `placeFile` (`:1075`) trusts the queue row; its only parse (`:1137-1166`) adds sibling episodes. `isSharedDownloadFolder` (`:395`) returns "own folder" when `releaseTitleTokens` is empty (`:397-398`), i.e. for titles like *24*, *ER*, *Up*, and `pickDownloadedFile:539-549` only applies the series-title gate when tokens are non-empty. Example: queue row `Dune.Part.Two.2024.1080p.WEB.H264-GRP` with a stale path imports the seeding `Dune.2021.1080p.WEB.H264-GRP.mkv` (5/8 tokens = 0.625, no `otherNumber` because `numberTokens` ignores 4-digit years). 5. **Season packs drop other seasons silently and the data is then deleted.** `planSeasonPackFiles` (`importer.ts:1894`) sets `numbers: []` for another season's file (`:1921`) and does not count it (`:1958`), so `removeQueueItemDownload` runs with `deleteFiles` true (`:2335`). The pack branch of `importQueueItem` (`:2266-2277`) never sets `mayHoldMore`, so the `releaseHoldsMoreMedia` safety check at `:2318` is skipped for packs and only `pack.unmatchedCount/leftInPlace/notUpgradedCount` protect the data. `retryFailedGrab`'s season branch (`scheduler.ts:2110-2116`) pre-filters by season number only (`:2144-2153`) before the shared `chooseBestResult` call (`:2157-2174`). Example: `Show.S01-S02.COMPLETE` with `season_number=1` imports S01 and destroys S02; a failed `ShowX.S02` retry grabs `ShowY.S02.COMPLETE`. 6. **Parser gaps feed 1-5.** `ParsedRelease` (`releaseParser.ts:5-46`) has no title field; the only title isolation is `guessTitleFromText` (`libraryScan.ts:192-223`), which cuts at the FIRST `\b(19|20)\d{2}\b` (so `Blade.Runner.2049.2017` guesses "Blade Runner", `2001.A.Space.Odyssey.1968` and `1917.2019` guess ""), never strips a leading `[Group]`, a trailing ` - 05`, `(1080p)`, `[CRC]` or `2nd Season` (`[SubsPlease] Jujutsu Kaisen - 25 (1080p) [ABCD1234]` guesses `[SubsPlease] Jujutsu Kaisen - 25`). `YEAR` (`:86`, applied at `:400-401`) is the first `\b(19|20)\d{2}\b` anywhere, including an air date's year: `The.Daily.Show.2024.08.25` → year 2024 against an item year of 1996. Season/episode detection (`:340-398`) is an ordered if-chain (`SEASON_EP_RANGE` → fansub `S2 - 05` → `SEASON_EP_X_FORMAT` → `SEASON_ONLY` → `FULL_SEASON_HINT` → batch); it is the attribute extractors (year, airDate, quality, group, languages, edition) that run independently over the whole string, so `French Connection` scores a French language and `Final Destination` an edition. `releaseMatchesEpisode` ORs aired/scene/absolute (`:468-474`) so `Show.S05E01` satisfies both TVDB S05E01 and XEM-mapped S01E05 (the OR itself was a deliberate choice: `releaseParser.ts:454-458`, `scheduler.ts:1428-1431`, CHANGELOG Round 195 at ~5470-5478, because some groups number a XEM-mapped show correctly; the bug is the double match, not the aired fallback). `ABSOLUTE_EPISODE` (`:95`) rejects `- 05v2`; `withoutVersionTag` lives in the importer only (`importer.ts:1062`). `[SubsPlease] Mushoku Tensei 2nd Season - 05` parses as absolute 5 and matches S01E05 because `computeAbsoluteEpisodeNumber` (`importer.ts:49-57`) is a DB row count; the real `episodes.absolute_episode_number` column exists (`db/client.ts:92`, `db/postgresSchema.ts:100`, `db/mappers.ts:83`) but is only written by `routes/metadata.ts:271`. `S01E01.E02`, `S01E01-S01E02`, `1x01-02`, `Season.2.Episode.5`, `MM.DD.YYYY` and seasonless `Complete.Series` are unparsed or mis-typed (`Inception.2010.COMPLETE.BLURAY` becomes a season pack). 7. **Non-tiered name match does not reject a different parent.** `releaseNamesTarget` (`scheduler.ts:446-462`) needs parent tokens only when self-titled; `namesLaterVolume` (`:397`) knows roman numerals only. Example: `ABBA - Greatest Hits (1992)` passes for Queen's *Greatest Hits*; `Super Mario Bros. 3 (USA)` passes for SMB. Note the existing positive cases at `scheduler.test.ts:2008-2172` (`Atomic Habits by James Clear`, `Sapiens - Harari, Yuval Noah [EPUB]`, ROM titles with no parent at all) show that unconditionally requiring the parent would break books/ROMs. 8. **Library scan and bulk add guess.** `pickTitleMatch` (`libraryScan.ts:107-111`) accepts a sole same-title row regardless of year and falls back to `matches[0]`; scan enrichment's `.find` (`:1176-1182`) already requires `sameTitleIgnoringQualifier` AND `fileYear == null || r.year == null || |Δ| ≤ 1`, with `fileYear` passed to `searchMetadata` so same-year hits rank first — its only gap versus `pickVerifiedProviderHit` (`:138-150`, used by `matchAdditionalProviders` at `:2547` and `routes/media.ts:1373`) is the ambiguous case: two or more same-title hits (a remake and the original) with an unknown year on either side, where `.find` takes the first and `pickVerifiedProviderHit` returns undefined; `refreshOneItem` has the same gap when `item.year` is null (`:2064-2085`). `detectSeasonEpisode` (`:306-321`) ignores `parsed.absoluteEpisode` and `parsed.airDate`, so `[SubsPlease] One Piece - 1085` and `The.Daily.Show.2024.05.01` are skipped. `POST /:id/rematch` overwrites `title` (`routes/media.ts:1748-1752`); the scan never revisits tracked files (`knownPaths` at `libraryScan.ts:965`, skipped in `walkForExtensions:392-404`), so existing files stay attached, but a later scan of a NEWLY added episode file whose guessed title is `Greys Anatomy` creates a duplicate row because `titlesMatch("Greys Anatomy", "Grey's Anatomy")` is false under both `normalizeForMatch` and `asciiMatchKey` (`:65-81`). `importLists.ts:470`, `routes/watchlistImport.ts:62` and `routes/discordInteractions.ts:152` take `results[0]` without passing `year` to `searchMetadata`. 9. **No alias storage, canonical-title queries only.** `media_items` has `title`/`sort_title` only (`db/schema.sql:23-48`); `fetchAlternateTitlesFor` (`metadata.ts:3322`) is display-only (`routes/media.ts:1098`). `searchTorznabNewznab` always sends `t=search` (`indexerClient.ts:242-244`), ids only for whole-item video callers (`:246-259`); scene variants run only on zero results (`:552-622`). *Attack on Titan* never matches `Shingeki.no.Kyojin.S04E01`. Movies released under a short title (`Birdman`, `Leon`, `Dead.Reckoning`) never match a metadata title carrying a subtitle. Every grab-side fix in the history has been a ranking change (CHANGELOG.md:906-911 "purely an additional ranking signal, never a hard filter"; CHANGELOG.md:5938-5945 sorted the manual view). ## How the Starr apps do it (what we port) - **Parse → resolve → decide**, as separate pure stages. The parser isolates a title; the resolver maps it to exactly one library item by exact equality on a stored `CleanSeriesTitle`/`CleanMovieTitle` column plus alias tables, with an ordered fallback chain (alias → clean title → title+year → indexer id); two hits is a rejection (`MultipleSeriesFoundException`), never `matches[0]`. Crucially, Sonarr's `ParsingService.GetSeries` checks `searchCriteria.Series` first: when the target is already known (a search for a specific item), the release is compared against that item's titles only, and the library-wide ambiguity rejection applies only to unsolicited mapping (RSS, disk). - **Identity is a hard rejection with a named reason** (`UnknownSeries`, `MatchesAnotherSeries`, `UnknownEpisode`, Radarr year gate), evaluated before quality/format specs. After acceptance, ranking is by quality/custom-format score; neither app ranks by "year present". - **Year parsing (Radarr):** the first year token that is preceded by at least one title token and not immediately followed by another year token (`(?(19|20)\d{2}(?!p|i|(19|20)\d{2}|\]|\W(19|20)\d{2}))`). - **Match type recorded on the grab** (`Title`/`Alias`/`Id`); a release matched by indexer id only, not from interactive search, is blocked from automatic import. - **Scene numbering is converted with precedence**, not OR'd: `GetStandardEpisodes` tries `FindEpisodesBySceneNumbering` first and falls back to `FindEpisode(season, episode)` (aired) only when no episode of the series carries those scene numbers; absolute numbers map through the stored absolute list; numberless specials match an S00 episode title by substring. Hashed-name rejection uses exact-length regexes (`^[0-9a-zA-Z]{32}`, `^[a-z0-9]{24}$`, `^[A-Z]{11}\d{3}$`, `^[a-z]{12}\d{3}$`, `^Backup_\d{5,}S\d{2}-\d{2}$`, …). - **Import ties back by DownloadId**, never a downloads-wide fuzzy search; each file must map to the grabbed item/episodes (`MatchesGrab`, `MatchesFolder`); unmatched files go to manual import and client data is kept. - **Typed queries**: `t=tvsearch` with `tvdbid/season/ep`, `t=movie` with `imdbid/tmdbid`, plus per-alias free-text fallback. - **Users can add an alias from the rejection itself** (Sonarr/Radarr scene-mapping / alternative-title UI), which is the realistic "needs review" recovery. - **Table-driven parser fixtures** (hundreds of real release names). Not ported: the ~110-regex ordered parser table, Lidarr fingerprinting, Radarr's exact-year-or-SecondaryYear rule (see Open questions). ## Target design A new pure module `server/src/services/releaseIdentity.ts` exporting: - `releaseIdentifiesTarget(releaseTitle, parsed, item, opts) → { ok, matchType: 'id' | 'title' | 'alias' | 'title-main' | 'title-noyear' | 'none', reason }`. Per-item form: compares `parsed.title` against THAT item's title (and from Round 3 its `media_item_titles` rows) only, no library-wide lookup — used by grab, retry, manual search/grab, and import, where the target is already known. Round 1 implements the comparison over `sameTitleIgnoringQualifier` + `normalizeForMatch` (exported from `libraryScan.ts`) plus roman↔arabic numeral equivalence; Round 3 swaps it for exact equality on stored `clean_title` values. - `resolveReleaseItem(release) → { item, matchType } | { ambiguous: true } | null` (Round 3): the library-wide `clean_title` index lookup with two-hits-is-ambiguous rejection, used only by IRC announces, unsolicited import and scan — never on a grab for a known item (otherwise `The.Office.S03E01` would be rejected for The Office (US) because the UK row also matches). - `fileContradictsTarget(filePath, folderName, clientTitle, item, target) → reason | null` for the import side. - `cleanTitle(s)` (Round 3): `normalizeForMatch` output → drop non-leading `a|an|the|and|or|of` → remove `[^\p{L}\p{N}]` with the `u` flag (NOT `\W`, which in JavaScript is `[^A-Za-z0-9_]` and would delete every CJK/Cyrillic/Greek character) → lowercase; no additional `\p{M}` strip (`normalizeForMatch` already strips combining marks on Latin/Greek letters only, deliberately, so `がき` ≠ `かき` and Devanagari/Thai survive); digit-only pass-through; `%` → `percent`; roman numerals I–XX ↔ arabic. - `identityModeFor(type, opts)`: reads `strictReleaseIdentity.` (per media type, values `off | log | enforce`; a bare `strictReleaseIdentity` value is the default for types without their own key) via `getSetting` from `services/settingsStore.ts`, overridable through `options.identityMode` so `chooseBestResult` tests can exercise `enforce` vs `log` without seeding settings (`getSetting` is synchronous, `settingsStore.ts:71`, and the tests call `chooseBestResult` directly with no DB settings). Rules: - **Title:** `parsed.title` (Round 1 parser output, see Round 1) must equal the item title (qualifier-tolerant: `(US)`, `(2018)` peeled and checked as qualifiers; roman ↔ arabic numerals equivalent), or an alias (Round 3), or — with lower rank — the item's main title up to the first `:` / ` - ` (`title-main`, as `namesMainTitle` already does for books), so `Birdman`, `Leon.1994` and `Dead.Reckoning.2023` match metadata titles carrying a subtitle before the alias table exists. - **Year:** `parsed.titleYear` is a year token that is NOT inside the `AIR_DATE` match and sits before the episode marker. When `parsed.airDate` is set the year check never runs. For `single`-shape movie/ppv targets, when both `titleYear` and `item.year` are known, |Δ| ≤ 1 (declared deviation from Radarr, see Q1). For series/anime targets the year is a qualifier, not a numeric gate (Sonarr): `Charmed.2018.S01E01` matches an item whose titles include `Charmed (2018)`/`Charmed 2018` (the primary title + year is always an implicit alias); `Charmed.1998.S01E01` matches neither `Charmed` nor `Charmed 2018` for the 2018 item and is rejected; a yearless `Charmed.S01E01` matches the known target on grab. - **Id:** indexer `imdbid`/`tmdbid`/`tvdbid` equal to the item's bypasses both and yields `matchType: 'id'`; item `year == null` accepts a yeared release as `title-noyear`. - **Per-type strictness:** movie/series/anime use the exact/alias gate above. sports/ppv/course/adult/video (also `usesQualityTiers`, but their item titles are folder-derived or rarely verbatim in releases: `UFC.300.Pereira.vs.Hill.PPV` vs `UFC 300`, course titles, site rips) use a `looseTitlesMatch`-style whole-word containment of the item title in `parsed.title`, and stay in `log` until a corpus from real announces exists. Anime stays in `log` (or `enforce` scoped to items that already have alias rows) until Round 3 aliases and Round 4 fansub conventions land, because every fansub-named release depends on title isolation + synonyms. Per surface: - **Grab** (`chooseBestResult` via `searchSingle`, `searchEpisode`, `searchSubItem` video path, `searchAndGrabTarget`, `retryFailedGrab`): identity gate inside the tiered branch before quality filters; episodic targets keep `releaseMatchesEpisode` AND identity; sort unchanged (`totalScore` first, `matchTier` stays the tiebreaker); `ChosenResult` (`scheduler.ts:783` currently `{ result, quality }`) gains `matchType`/`identityReason`, `grabBestRelease`'s retry loop carries it, and `grab()` (`scheduler.ts:852`) records `match_type` and `grab_source` on the queue row. - **Import**: no widening beyond the reported path; `fileContradictsTarget` in `placeFile` and per file in `planSeasonPackFiles`; a mismatch throws `ImportIdentityMismatchError`, a plain `ImportSkippedError` subclass that propagates out of `importQueueItem` and is handled by the generic skipped path in `importCompletedDownload` (`scheduler.ts:2270-2288`: row stays `completed` with `import_skipped_reason`, manual-interaction notification, data kept) and `markQueueImportFailed` (`:2326-2329`); rows with `match_type = 'id'` whose `grab_source` is not `interactive` (NULL counts as manual/legacy and is NOT blocked) are left for manual import. - **Library scan / auto-match**: `pickTitleMatch` rejects a sole match on year contradiction only for `single`-shape files (or files with no season/episode/airDate), using `titleYear`; ambiguous year-less files skip with a review log; enrichment and Refresh use `pickVerifiedProviderHit`; absolute and air-date files importable. - **IRC**: `resolveReleaseItem` (gaining aliases), candidates scoped by the new `irc_feeds.media_types`, loop continues when `tryGrabMatch` declines. - **Manual**: interactive search computes `matchesTarget` from the per-item predicate and returns `identityReason`; `POST /grab` and `/import/manual*` return 409 with the reason unless `force: true`; the rejection UI offers "add this title as an alias and grab". "Needs review" instead of auto: a release whose identity is `none` is rejected and logged, never grabbed; an import whose file contradicts the target stays as a queue row visible in Activity; a scan file with two same-title rows and no year is skipped and logged; bulk adds with ambiguous hits go to `import_review_items`; an anime release with a `Part N`/`Final Season` qualifier and no alias row is rejected, not guessed. ## Rounds ### Round 0 — Regression corpus (S) **Goal:** a table-driven release-name fixture before any behaviour change. **Files:** new `server/tests/fixtures/release-corpus.jsonl`, new `server/tests/fixtures/identity-library.json`, new `server/tests/releaseCorpus.test.ts` (`it.each`; today the only `it.each` is `archiveExtract.test.ts:440`, and `tests/helpers/` holds only `bookFixtures.ts`, `isolateDataDirs.ts`, `testDb.ts`). **Rules (as shipped):** each release row is `{ title, kind: "release" | "file", expect: { … }, todo?: { round: "R1".."R6", expect: { … } }, identity?: [{ item: "", round, indexer?: { imdbId }, expect: { ok, matchType, reason? } }] }`. `expect` holds what `parseReleaseTitle` returns TODAY (shorthand keys: season, episodes, absolute, group, fullSeason; everything else by its `ParsedRelease` name) and is asserted with `it`; `todo.expect` holds the target a later round delivers (including the future fields `title`, `titleYear`, `rejected`, `seasonRange`) and runs merged over `expect` as `it.fails`, so the suite is green while the bug stands and turns red the moment a round fixes it, the cue to fold `todo.expect` into `expect` and drop the marker. `identity[]` checks describe the Round 1 `releaseIdentifiesTarget` verdict and are registered `it.todo` until that module exists. The library fixture is `{ "": { type, title, year, externalIds: { imdb?, tmdb?, tvdb? }, aliases: [{ title, source }] } }` (Dune 2021/1984/Part Two, Charmed 1998/2018, The Office UK/US, Heat 1995, Up 2009, Blade Runner 1982/2049, Rocky II, Star Wars IV, Attack on Titan with `Shingeki no Kyojin` alias, The Daily Show 1996, The Late Show, One Piece, Léon, Extraction/Extraction 2, Alien, Mushoku Tensei, Jujutsu Kaisen, 24, Breaking Bad), so Round 1 flips `todo` rows rather than inventing a second format. **Tests:** seeded from the CHANGELOG failure catalogue and the surface maps: `Charmed.2018.S01E01.1080p.AMZN.WEB-DL-NTb`, `The.Office.US.S03E01.720p`, `Dune.Part.Two.2024.1080p.WEB-DL.x265-GRP`, `Dead.Heat.1988.1080p`, `[SubsPlease] Mushoku Tensei 2nd Season - 05 (1080p) [ABCD1234]`, `[Group] Show - 05v2 [1080p]`, `[SubsPlease] Jujutsu Kaisen - 25 (1080p) [ABCD1234]` (title `Jujutsu Kaisen`), `Show.Name.The.Complete.Series.1080p.WEB-GRP`, `The.Daily.Show.08.25.2024.1080p.WEB-GRP` and `The.Daily.Show.2024.08.25.1080p` (titleYear null), `Show.S01E01.2019.1080p` (titleYear null — year after the episode marker), `Inception.2010.COMPLETE.BLURAY-SharpHD` (releaseType must not be `seasonPack`), `Blade.Runner.2049.2017.1080p.BluRay.x264-GRP` (title `Blade Runner 2049`, year 2017), `2001.A.Space.Odyssey.1968.1080p` (title `2001 A Space Odyssey`, year 1968), `1917.2019.1080p` (title `1917`, year 2019), `1917.1080p` (title `1917`, no year), `Nineteen.Eighty-Four.1984.2024.Remake.1080p` (year 2024), `Rocky.2.1979.1080p` (identity ok for `Rocky II`), `Star.Wars.Episode.4.1977` (`Star Wars: Episode IV`), `French.Connection.1971.1080p` (no French language), `Final.Destination.2000.1080p` (no `final` edition), `Show.Name.x264-2024` (group `2024`, no year), `a1b2c3d4e5f67890abcdef1234567890` (rejected), `Attack.on.Titan.The.Final.Season.Part.2.-.05` (rejected without alias row). **Risk/rollback:** none; additive. ### Round 1 — Parser title + identity gate for tiered types, log-only then enforce (M) **Status: shipped, in `log` mode.** `ParsedRelease.title`/`titleYear` (`releaseParser.ts`), `services/releaseIdentity.ts` (`releaseIdentifiesTarget`, `identityModeFor`), the gate inside `chooseBestResult`'s tiered branch, `name` + `identity` passed by every tiered caller (single, episode, season-pack retry), `queue.match_type`/`queue.grab_source` (both schemas), the manual search's `identityOk`/`identityMatch`/`identityReason` annotations and `grab_source = 'interactive'` on a manual grab, and the per-search indexer-id tally in the log. Title normalization moved to `services/titleMatch.ts` (re-exported from `libraryScan.ts`). Every type runs in `log`: the container log shows `identity gate (log-only) would reject "…": ` for each release `enforce` would drop. **To enforce:** set the `strictReleaseIdentity` setting to `enforce` (all types) or `strictReleaseIdentity.movie` / `.series` per type; `off` disables the gate. The bare setting never takes anime or the containment types (sports/ppv/course/adult/video) past `log`; an explicit per-type `enforce` does. An adversarial review of the round added: a title's own year-shaped number read back when the release has no year (`Blade.Runner.2049.1080p`), bracketed years after such a number (`Blade Runner 2049 (2017)`), a `title-region` match for scene region tags on a bare provider title (`Shameless.US` for "Shameless", never for "Shameless (UK)"), `(US)` kept in the parsed title, AKA dual titles, a dropped leading article (`Flash` for "The Flash"), anime season qualifiers on either side (`Jujutsu Kaisen S2` for "JUJUTSU KAISEN Season 2"), edition phrases before the year, `complete` as a title word, `www.site.tld -` prefixes, `[Batch]` among leading tags, year-last air dates (pulled forward from Round 4), and the UI forwarding indexer ids on a manual grab. Known gap left for Round 3 aliases: a dropped possessive brand (`Agents.of.S.H.I.E.L.D` for "Marvel's Agents of S.H.I.E.L.D."). Not done in this round: `rejected` (hashed names, Round 4), alias rows (Round 3). **Goal:** stop wrong-title video grabs on every indexer-backed path. **Files/functions:** - `releaseParser.ts`: `ParsedRelease` gains `title`, `titleYear`, `rejected`. Title isolation lives here, not in `guessTitleFromText`: strip a leading `LEADING_BRACKET_GROUP` and bracketed/parenthesised tags, then cut at the earliest index of the season/episode/absolute/airDate match already found (`rangeMatch.index`, `dashMatch.index`, the `ABSOLUTE_EPISODE` index, the `AIR_DATE` range), the chosen title-year, or the first quality/source/codec/edition token. `YEAR` selection at `:400-401` adopts the Radarr rule: the first `(19|20)\d{2}` token that has ≥1 preceding title token and is not immediately followed by another year token, excluding tokens inside the `AIR_DATE` match range and the trailing `-GROUP` (so `Blade.Runner.2049.2017` → 2017, `2001.A.Space.Odyssey.1968` → 1968, `Nineteen.Eighty-Four.1984.2024.Remake` → 2024, `1917.2019` → 2019, `1917.1080p` → no year, `x264-2024` → no year). `parsed.year` keeps the old first-token value for one release as `firstYear` for diffing; `titleYear` is the new value. This parser change is not behind the identity flag (it is covered by the corpus diff, not by `strictReleaseIdentity=off`). - new `releaseIdentity.ts` (per-item `releaseIdentifiesTarget`, `identityModeFor`, roman↔arabic, `title-main`). - `scheduler.ts`: `chooseBestResult:668-691` (gate in tiered branch, `[scheduler] rejected "…": ` log in the existing `:621-646` style; `options.identityMode` override); sort `:768-771` unchanged; `ChosenResult:783` gains `matchType`/`identityReason`; `searchSingle:1405-1417`, `searchEpisode:1419-1449`, `searchSubItem:1489-1492`, `searchAndGrabTarget:1644-1657`, `retryFailedGrab:2097-2121` (all shapes pass `name: { title: item.title }` and `identity: { year, externalIds }`); `grabBestRelease` retry loop carries the verdict; `grab():852` writes `match_type` and `grab_source` (`'auto'|'irc'|'retry'` from its caller). - `routes/search.ts:291-307`: the manual `POST /api/search/:mediaItemId/grab` INSERTs the queue row directly, bypassing `grab()`, so it must write `grab_source = 'interactive'` and `match_type` itself (from the `identityReason` the search response carried); the IRC path uses `grab()` and passes `'irc'`. **Schema:** `queue.match_type TEXT` and `queue.grab_source TEXT` via `ensureColumn()` in `db/client.ts` and `ALTER TABLE … ADD COLUMN IF NOT EXISTS` in `db/postgresSchema.ts` (the `queue` table, `schema.sql:166-191`, has no column today that records whether a grab came from interactive search; `import_resume_state = 3` marks a manual IMPORT, not a manual grab). **Rules:** as in Target design, per type: movie/series exact/alias/title-main gate; sports/ppv/course/adult/video containment rule in `log`; anime `log`. In `log` mode the verdict is computed and logged but the old filters stand; ship one release with movie/series in `log` (default), then flip movie/series to `enforce` (anime/sports/… flip in Round 3/4 once their corpus is green). **Tests (`scheduler.test.ts`, new describe "chooseBestResult — identity gate for tiered types", all with `identityMode: 'enforce'` and a `log` sibling):** positive `Dune.2021.1080p.BluRay.x264-GRP` for Dune (2021); `Charmed.2018.S01E01.1080p.WEB-DL-NTb` and yearless `Charmed.S01E01` for Charmed (2018); `The.Office.US.S03E01.720p` for "The Office (US)"; `Blade.Runner.2049.2017.1080p` for Blade Runner 2049 (2017); `2001.A.Space.Odyssey.1968` and `1917.2019` for their items; `Rocky.2.1979` for Rocky II; `Leon.1994` as `title-main` for "Léon: The Professional"; `Heat.1996.1080p` (±1) for Heat (1995); `The.Daily.Show.2024.08.25` for The Daily Show (1996) (air-dated, year check skipped); `Some.Other.Name.2021` with indexer `imdbId` equal to the item's → `matchType: 'id'`. Negative (reason named): `Dune.Part.Two.2024…` for Dune (2021); `Dead.Heat.1988…` for Heat (1995); `Charmed.1998.S01E01` for Charmed (2018); `The.Late.Show.2024.08.25` for The Daily Show; `ShowY.S02.COMPLETE` on season-pack retry for ShowX; `Up.in.the.Air.2009.x265` (+50) rejected with reason `title` for Up (2009) (a gate test, not a sort test). Rewrite `scheduler.test.ts:1896-1902` to assert `null` under `enforce` (and the old result under `log`); rewrite `:1904-1921` with same-titled releases. `releaseParser.test.ts`: the year/title cases from the Round 0 corpus. `searchRoute.test.ts`: manual grab writes `grab_source='interactive'`. **Risk:** under-matching for titles whose metadata differs from scene naming until Round 3 aliases (mitigated by `title-main` and per-type `log`). **Rollback:** `strictReleaseIdentity=off` (gate only; parser `title`/`titleYear` changes stay and are covered by the corpus). ### Round 2 — Import verification, no wide fallback (M) **Status: shipped, following the type's identity mode.** `locateDownloadedFile` (`importer.ts`) returns a reason instead of widening: a reported single file of another type (`.iso`), or a folder holding only a disc image, is `unsupported-extension` and `importQueueItem` leaves the row for a manual import (data kept); an existing reported folder with nothing importable is `nothing-in-reported-folder` and fails the release like any other bad release (blocklist + retry), since the release delivered no usable media. A reported archive file is looked up in its extraction folder. The downloads-wide search survives only for no path or a missing path, or with the `importDownloadsWideSearch=1` setting. `scoreByTokenOverlap` weighs quality words 0.25 and title words/years 1.0, `numberTokens` includes years (and no longer reads `2024.1080p`/`2024.10bit` as decimals), Extras-folder files are passed over, extras-named files only when a plausible candidate remains and the word is not part of the title ("Trailer Park Boys"), and a token-less title ("24") in a shared folder rejects a file plainly named AND numbered for another show (scene `grp-` prefixes stripped). New `fileContradictsTarget` (release-shaped file name, else its release-shaped folder with "Season N" folders looked past, scene group prefix stripped, parsed and put through `releaseIdentifiesTarget`; positive evidence only, and a file that identifies the item is never second-guessed by its collection folder) runs in `placeFile` for automatic imports (`verify`) and over a pack's files before they are mapped to episodes; under `enforce` a mismatch throws `ImportIdentityMismatchError extends ImportSkippedError` (never caught by the `NoPackFileMatchedError` fallback), under `log` it only logs. Multi-season packs count another season's (season > 0) files as unmatched so the download's data is kept; specials and extras still don't count, and the pack branch relies on that count rather than a post-placement walk. `releaseIdentifiesTarget` now reports `id` only when the title does NOT name the item (a title match with a matching id is `title`), so the automatic-grab `match_type = 'id'` hold for manual import while the type enforces (decision 8 pending data; `log` imports it) fires only for id-only grabs. Manual imports and interactively grabbed releases are never refused on identity. **Goal:** a wrong file never lands under a target; packs never destroy other seasons. **Files/functions (`importer.ts`):** - `findDownloadedFile:487-506` — when the client-reported `searchRoot` exists and the walk finds nothing, stop (row left for manual import, reason logged); a reported single file whose extension is not in the type's list (`.iso`/`.img`, `:489-491`) IS the download (a disc image), so nothing is searched and the row is left with reason `unsupported-extension` (or `.iso` is added to movie extensions behind the existing brdisk quality — Q9); the `config.downloadsDir`-wide search survives only for a non-existent mapped path (the docker remote-path case the comment at `:468-470` describes) and is gated by `fileContradictsTarget`. Before shipping, list the in-process downloaders (HTTP/debrid, `:391-394`) whose files sit loose in `config.downloadsDir` with no own folder and confirm their `download_path` is a file path, not the root; otherwise they lose auto-import. - `scoreByTokenOverlap:422-443` — title tokens weight 1.0, others 0.25; `numberTokens:257-262` includes 4-digit years so 2021 vs 2024 sets `otherNumber`. - `isSharedDownloadFolder:397-398` stays UNCHANGED for empty title tokens (flipping it to "shared" would also break `collectSeasonPackFiles:1843-1875`: `isOwnReleaseFolder` would be false for a *24*/*ER*/*Up* pack, `packDir` only set when the folder name itself parses to the season (`:1840/:1849`), and the loose-folder branch requires `fileTitle.size > 0` (`:1873`), so only the anchor would import). Instead `pickDownloadedFile:539-549` adds the guessed-title contradiction check when tokens are empty: the file's own `parsed.title` must not be a different ≥2-token title, so *24* no longer imports `Breaking.Bad.S01E03`. A `24.S01.COMPLETE` pack test guards `collectSeasonPackFiles`. - `pickDownloadedFile:563-586` — exclude `extras|featurettes|bonus|behind the scenes` paths; among unnumbered ties prefer basenames without `making|featurette|trailer|sample|extra`. - New `fileContradictsTarget` called in `placeFile` before the move (`~:1124`) and per file in `planSeasonPackFiles`; mismatch throws `ImportIdentityMismatchError extends ImportSkippedError`. It must NOT be caught by the `NoPackFileMatchedError` fallback at `:2282-2298` (that fallback still imports via `placeFile` when the row has an `episodeId` and the file name identifies the episode); it propagates out of `importQueueItem` to the generic skipped handler in `scheduler.ts:2270-2288` / `:2326-2329`, which keeps the data, leaves the row `completed` with `import_skipped_reason` and notifies. The fallback `placeFile` call at `:2298` runs the same check (thrown before the move). - `planSeasonPackFiles:1921,1958` — other-season files count as unmatched so `leftFilesBehind` is true AND the pack branch of `importQueueItem` (`:2266-2277`) sets `mayHoldMore = true` so the `releaseHoldsMoreMedia` check at `:2318` also runs for packs; `removeQueueItemDownload` keeps data through both routes. - `importQueueItem`: a row with `match_type = 'id'` and `grab_source <> 'interactive'` (NULL treated as manual/legacy, not blocked) is left for manual import with a logged reason (Sonarr/Radarr "matched by ID" block). **Tests (`importer.test.ts`):** Dune Part Two row with only `Dune.2021.mkv` in downloads → null; `.iso` single-file torrent beside an older `.mkv` → null with reason `unsupported-extension`; `Show.S01E05` folder with `x7k2q.mkv` + `Extras/Making.Of.Show.mkv` → the obfuscated file; `Show.S01-S02.COMPLETE` with `season_number 1` → S02 reported left behind, download not deleted via both `unmatchedCount` and `mayHoldMore`; `24.S01.COMPLETE` pack imports every episode; `24` row with `Breaking.Bad.S01E03.mkv` → null; `placeFile` given `Better.Call.Saul.S01E01.mkv` for Breaking Bad S01E01 → throws `ImportIdentityMismatchError`, and `importQueueItem` with an episode row rethrows it (not swallowed by the `NoPackFileMatchedError` fallback); `Heat.1995…Extras.Featurette` not chosen over the feature; `match_type='id', grab_source='auto'` row not auto-imported, `grab_source='interactive'` or NULL imported. **Risk:** more rows waiting for manual import. **Rollback:** wide search behind a setting; `fileContradictsTarget` log-only when the type's identity mode is `log`. ### Round 3 — Alternate titles, clean-title index, IRC and manual surfaces (M/L) **Status: shipped.** New `services/releaseAliases.ts`: `media_item_titles` table (SQLite + Postgres, `UNIQUE(media_item_id, clean_title)`; its `season_number`/`episode_offset` columns exist already and stay unused until Round 4) and `media_items.clean_title`; `cleanTitle` (Sonarr's CleanSeriesTitle: `identityKey` less a/an/the/and/or/of, run together); `storeAliases` (dedupes by clean title, never the item's own), `syncPrimaryTitle` (keys the item; a changed title demotes the old `primary` row to `original` and promotes a matching alias row to `primary`), `backfillCleanTitles` at startup, `identityTargetFor`, and `resolveReleaseItem` (per-item gate over every candidate; two claimants are ambiguous and nothing is grabbed unless the release's year singles one out). `services/aliasBackfill.ts`: `refreshProviderAliases` stores TMDB alternative titles (`tmdb_alt`) or AniList synonyms (`anilist_synonym`) per item, called, fire-and-forget, on add (`POST /media` and `POST /metadata/import`) and on `refreshOneMediaItem`; `backfillProviderAliases` runs as the "Alternate Titles" background job (`aliasBackfill`, 04:20 daily, kicked through the job registry 2 minutes after startup). An item is marked checked only on a definitive answer from the provider; a missing or refused API key, a rate limit and network errors leave it for the next run, a provider that can't be asked is skipped for the rest of the run (TMDB's key problem never stops AniList items), and AniList items are paced at its limit. Surfaces: `chooseBestResult` passes `aliasTitlesFor(mediaItemId)` to the gate; `ircAnnounce.ts` resolves single items and shows library-wide through `resolveReleaseItem` (shape-filtered), logs and skips ambiguous announces, and keeps trying candidates when one declines; `routes/search.ts` annotates `identityMatch`/`parsedTitle`, and `POST /grab` answers 409 with the reason when the type enforces and the release fails the gate unless `force: true`; `routes/media.ts` adds `GET /:id/aliases` (any authenticated user), `POST` (admin, `user` source, audited) and `DELETE` (admin, not audited), and the rematch route drops the old title and the old match's provider titles (a rematch fixes a wrong match, so the old title is usually another work's) unless the body carries `keepOldTitleAsAlias: true`; `MediaDetail.tsx` and `EpisodeDetail.tsx` confirm a 409 and `MediaDetail.tsx` offers "add as alternate title and grab" for title rejections; the MCP `grab_release` tool takes `force`. Non-tiered fixes: a music/audiobook release leading with another artist before ` - ` is rejected; a bare sequel number right after the wanted title is a later volume. Anime now enforces under the bare `strictReleaseIdentity` setting (only the containment types stay capped at `log`). Deferred: `irc_feeds.media_types`, the `/import/manual` 409, the by-name rekey predicate, and `xem_alias` rows (Round 4, with scene numbering). The bare `E1000` absolute form is a Round 4 parser item. **Follow-up (adversarial review of the round):** a manual grab settles the identity verdict (and the 409) before the download client is touched, so a refused grab leaves nothing in the client and the forced retry adds it once; the music lead-artist rule reads underscores and en/em dashes as the separator, reads the lead as the first ` - ` segment with a word in it (not a year or format tag), matches an artist spelled with other punctuation or by initials (`REM`, `P!nk`/`Pink`, `NWA`, `VA`), and lets an audiobook release lead with the main title before its subtitle; the bare-digit later-volume rule applies to ROM/`single`-shape types only and skips discs, bit depths, bitrates and ordinals (`2CD`, `24bit`, `64kbps`, `2nd Edition`); IRC keeps the old ±1 guard on a year written after the quality words and compares a containment type's title by equality (`strictTitle`) while its gate does not enforce, and no longer offers an episode-shaped announce to a movie; `syncPrimaryTitle` takes `keepOldTitle` (rematch and the startup backfill drop the old primary row, which is chunked into transactions); a duplicate merge carries the loser's title and aliases to the keeper; library-wide Refresh keys the alias index when it retitles an item; the alias checked marker is written with a re-read compare-and-swap so it cannot erase a concurrent `extra_metadata` write. **Known gaps left:** the IRC ambiguity check only sees items still wanted (a complete same-title item on disk does not make an announce ambiguous; loading the whole library per announce was judged too costly); the lead-artist rule still accepts a lead that merely contains the artist's words ("Queen Latifah - Greatest Hits" for Queen, since collaborations lead that way) and cannot split an unspaced `ABBA-Greatest Hits`; the `/import/manual` 409 is still deferred; `POST /aliases` answers 201 with the rows whether or not it stored one, so the web checks the returned rows. **Goal:** stop the under-matching Round 1 introduced and unify the remaining paths. **Schema:** new table `media_item_titles(id, media_item_id, title, clean_title, source TEXT CHECK IN ('primary','original','tmdb_alt','anilist_synonym','xem_alias','user'), year, season_number, episode_offset)` (`season_number` + `episode_offset` carry Sonarr/XEM-style `Part N`/`Final Season`/named-cour mappings for Round 4) with `CREATE TABLE IF NOT EXISTS` in both `db/schema.sql` and `db/schema.postgres.sql`; `clean_title` column on `media_items` via `ensureColumn()` + `postgresSchema.ts`; `irc_feeds.media_types TEXT` (same comma-list convention as `indexers.media_types`, default all types) with migration and a UI field — `IrcFeedRow` (`ircAnnounce.ts:15-20`) has only id/name/announce_regex/protocol today, so there is nothing to filter by. **Files/functions:** `metadata.ts` `fetchAlternateTitlesFor:3322` gains a persisting caller in `refreshOneItem` (`libraryScan.ts`) and add-time; `sceneNumbering.ts` stores XEM alias names as `xem_alias` rows; `POST /:id/rematch` (`routes/media.ts:1748-1752`) inserts the old scan title as a `user` row before overwriting; `PUT /api/media/:id/aliases` for user aliases, plus the UI entry point from the rejection: Activity and interactive-search rows with `identityReason` offer "add this title as alias and grab" (alias insert + `force` grab in one action). `releaseIdentity.ts` `cleanTitle` (Target design) and the split predicate: per-item `releaseIdentifiesTarget` over the item's rows; `resolveReleaseItem` with ambiguity rejection for IRC/scan/unsolicited import only. `ircAnnounce.ts:72-116`: use `resolveReleaseItem`, filter candidates by `irc_feeds.media_types` and `getMediaTypeConfig(item.type).shape`, and make `attempted` (`:80-85`, `:111-116`) reflect an actual grab so the loop continues. `routes/search.ts`: build `identity` (year/externalIds/title) for episodic targets too (`:190` currently nulls it; `identityExternalIds` at `:123-149` only for whole items) and pass the ids to `searchAllIndexers` at `:154`; `:193-198` `matchesTarget = identity.ok && episodeOk` (per-item form), add `identityReason`; `POST /grab` (`:247-322`) and `routes/import.ts` `/manual`, `/manual-batch` (`:165-257`) return 409 with reason unless `force: true`; `web/src/pages/MediaDetail.tsx` shows a warning badge. `scheduler.ts:2525-2545` by-name rekey additionally requires the predicate. Non-tiered music fix in `releaseNamesTarget` (`scheduler.ts:446-462`): scoped to `artist`/`audiobook` when `name.parentTitle` exists and the release carries a leading ` - `-separated artist-like segment that is NOT the parent → reject (a release naming a different parent), never require the parent; `namesLaterVolume:397` extended to arabic numerals directly after the wanted tokens for ROM/`single` (`Super Mario Bros 3` when wanting `Super Mario Bros`), with the existing book/ROM positives at `scheduler.test.ts:2008-2172` kept green. Flip anime to `enforce` once its corpus rows are green. **Tests:** `titleIndex`: "The Walking Dead (2010)" → `thewalkingdead2010`; "Marvel's Agents of S.H.I.E.L.D." → `marvelsagentsshield`; "24" → "24"; "3%" → `3percent`; "Amélie" = "Amelie"; "進撃の巨人" and "Москва слезам не верит" survive non-empty; "がき" ≠ "かき"; two Charmed rows + year 2018 → the 2018 row, no year → ambiguous for `resolveReleaseItem` but `releaseIdentifiesTarget(The.Office.S03E01, The Office (US))` ok. `scheduler.test.ts`: `Shingeki.no.Kyojin.S04E01` accepted for Attack on Titan; `Leon.1994` as `alias` for "Léon: The Professional"; `ABBA - Greatest Hits` rejected for Queen's, `Atomic Habits by James Clear` and `Sapiens - Harari, Yuval Noah [EPUB]` still accepted; `Super Mario Bros. 3 (USA)` rejected for SMB. `ircAnnounce.test.ts`: `Dune.1080p.WEB` not grabbed for ROM "Dune" on a video-only feed; second candidate tried after a blocklist decline. `searchRoute.test.ts`: `Aliens.1986` under Alien (1979) → `matchesTarget=false`; `Charmed.1998.S01E01` under Charmed (2018) S01E01 → false (episodic identity now built); manual grab 409 then `force`; alias-and-grab action inserts a `user` row. `mediaRoutesRematch.test.ts`: rematch, then add a fresh `Greys.Anatomy.S02E01.mkv` and rescan → attached to the renamed row, no duplicate. **Risk:** three migrations on SQLite + Postgres. **Rollback:** table unused when the type's identity mode is `off`. ### Round 4 — Parser conventions and episode numbering (M) **Status: shipped, with the alias-driven part deferred.** `releaseParser.ts`: `S01E01.E02`, `S01E01-S01E02` (same season only) and `1x01-02`/`1x01x02` are multi-episode releases (a range of 100 or more is not one); `Season.2.Episode.5` is an episode; the season word tolerates dots and the plural (`Season.3`, `Seasons.1-5`); `S01-S05`, `Seasons 1-5` and `Season 1 - 9` set the new `seasonRange` (recognised only: a pack is still taken for its first season, since grabbing and importing one pack for several seasons needs queue and import bookkeeping that does not exist); a bare `COMPLETE` is a season-pack marker only when the title stands before it and a pack word follows or a year/season precedes it, and `Movie.2010.COMPLETE.BLURAY` is a disc image, so `Complete.Unknown.2016` and `Inception.2010.COMPLETE.BLURAY` are no longer packs; `v2` after an absolute number is read (`[Group] Show - 05v2`, giving it its group and title back); an ordinal season (`2nd Season - 05`, `3rd Season - 01 ~ 12`) is read as `Season N` for the dash-numbering rules, so it is season N rather than an absolute number, while `Part N`/`Final Season` before the dash stay unmapped; Sonarr's hashed-name list sets `rejected`, honoured by `chooseBestResult`, the interactive search annotation (`rejected`/`rejectReason`) and IRC, and by nothing that sees file names; language, edition and quality-modifier tags are read from the leading tags, the tags at the end of the title and everything after the cut, not the title's own words (`French.Connection`, `Final Destination`, `The Final Season Part 2`, `Christmas Special` carry none; `Movie.FRENCH.1080p`, `Alien.Directors.Cut.1979` and `Movie.R5.XviD` still do). Scene numbering (decision Q10): `releaseMatchesEpisode` takes a `SceneIndex` (`sceneIndexFor`, `sceneNumbering.ts`: each scene number differing from the aired one, mapped to the episode carrying it) and matches the episode's scene numbers first, then its aired numbers only while they are no other episode's scene numbers; every caller (auto search, interactive search, IRC, import, `isAlreadyQueued`) passes it; the `sceneNumberingMode=or` setting restores the either-numbering rule. `syncSceneNumbering` clears the show's scene numbers and rewrites them in one transaction when TheXEM answers with a mapping (including an empty one), and leaves them alone on a failure. Corpus: the 14 Round 4 `todo` rows are folded into their `expect` (and `Christmas.Special` no longer reads a "Special" edition); the pending suite registers only while rows remain. **Deferred:** `Part N`/`Final Season`/named cours placed by alias rows carrying `season_number` + `episode_offset`, and the `needs-alias` rejection (the columns exist, but nothing writes such rows until `xem_alias` rows or a UI entry do; today these releases stay unmapped and the identity gate rejects them by title); the season-0 special matched by episode title (`Show.Name.Christmas.Special.2023`); `scene_2` columns (`scene2_*`); moving `withoutVersionTag` out of `importer.ts` (the parser now reads `v2` itself, the importer's strip is harmless). **Follow-up (independent review, regressions confirmed by running old and new parsers):** a bare `complete` after a title stays a pack whatever follows it (only a film's disc image and a `Complete` with no title before it are not), and a non-pack falls through to the batch rules; language/modifier tags are found past their filler words (`German.DL`, `MULTi.AC3`, `R5.LiNE`) and editions named by a phrase (`Special Edition`, `30th Anniversary Edition`, `Extended Version`) are found by peeling tag runs and edition phrases off the title's end; `Season 2 Ep 01-12` is the season's pack; `S01E001-E500` is a range again; a TheXEM sync writes only the rows whose scene numbers differ. **Known gap:** a language written first (`German.Movie.Name.2010`) is read as a title word, since `French.Connection` must be. **Goal:** fewer wrong-episode and wrongly-rejected cases. **Files/functions (`releaseParser.ts`):** `SEASON_EP_RANGE:52` accepts `S01E01.E02`, `S01E01-S01E02`; `SEASON_EP_X_FORMAT:56` gets `1x01-02`/`1x01x02` and must sit after a title token; `Season.2.Episode.5`; `FULL_SEASON_HINT:83` tolerates `Season.2` and no longer fires on `COMPLETE.BLURAY` movie titles; `S01-S05` → `seasonRange`; `AIR_DATE:90` accepts `MM.DD.YYYY` (abort on ambiguous both-≤12); `ABSOLUTE_EPISODE:95` tolerates `v\d` (move `withoutVersionTag` from `importer.ts:1062` here); `Nth Season|Season N` before ` - NN` → season N with absolute null ONLY when no `Part/Cour` qualifier follows; `Part N`/`Final Season`/named cours are NOT mapped to a season (`Attack on Titan The Final Season Part 2 - 05` is TVDB S04E21; `Mushoku Tensei 2nd Season Part 2 - 05` is S02E17) but resolved through `media_item_titles` rows carrying `season_number` AND `episode_offset`, rejected (needs review) when no alias row exists; hashed-name rejection uses Sonarr's exact-length list (`^[0-9a-zA-Z]{32}`, `^[a-z0-9]{24}$`, `^[A-Z]{11}\d{3}$`, `^[a-z]{12}\d{3}$`, `^Backup_\d{5,}S\d{2}-\d{2}$`) → `parsed.rejected`, applied only to release/queue titles and to a file basename whose folder name is also unparseable, never to a basename inside a folder that names the release (`importer.ts:557-561` parses the folder-relative path for exactly that reason); a numberless title containing a season-0 episode title (normalized substring) resolves to that special. `languages`, `edition` and `qualityModifier` extractors run on the remainder after `parsed.title`, not the whole string (`French Connection`, `Final Destination`). `releaseMatchesEpisode:468-471`: scene numbering first, aired fallback only when the release's numbers map to no scene-numbered episode of the series (precedence, not OR) — a release matching episode X by scene numbering never also matches episode Y by aired numbering, while a WEB-DL group using TVDB numbering for a XEM-mapped show still matches; this changes the documented OR at `releaseParser.ts:454-458` / `scheduler.ts:1428-1431` (CHANGELOG Round 195) and is listed as Q10 with a rollback. Absolute mapping uses the existing `episodes.absolute_episode_number` (backfilled in `refreshOneItem` and `routes/metadata.ts:271` stays), with `computeAbsoluteEpisodeNumber` (`importer.ts:49-57`) only when the column is NULL. `sceneNumbering.ts:51-60`: clear stale `scene_*` for the item before writing, store both `scene` and `scene_2` (new `scene2_*` columns via `ensureColumn`). `episodeSearchFor` (`scheduler.ts:343`) reads the stored absolute. **Tests:** every example above as positive/negative pairs in the corpus; `Show.S05E01` matches exactly one of TVDB S05E01 / XEM S01E05; a correctly TVDB-numbered `Show.S04E03` of a scene-mapped show whose scene numbers never reach S04E03 still matches S04E03 (aired fallback); `[SubsPlease] Mushoku Tensei 2nd Season - 05` rejected for S01E05, accepted for S02E05 once the stored absolute is 17; `Mushoku Tensei 2nd Season Part 2 - 05` → S02E17 only with an `episode_offset` alias row, rejected without; `Show.Name.Christmas.Special.2023` matches S00 "Christmas Special"; `French.Connection.1971` carries no language; an obfuscated basename inside `Show.S01E05.1080p-GRP/` imports; `sceneNumbering.test.ts` stale-row clearing. **Risk:** existing `releaseParser.test.ts` cases shift; scene-precedence is a behaviour change. **Rollback:** revert file; corpus diff; `sceneNumberingMode=or` setting restores the OR. ### Round 5 — Query recall (M) **Status: shipped.** `indexerClient.ts`: `getIndexerCaps` probes `t=caps` once per indexer per process (a failed probe is retried after ten minutes; not counted against `query_limit_per_hour`), and `typedSearchFor` turns a torznab/newznab search into `t=movie&imdbid` (then `tmdbid`) for movie/ppv, or `t=tvsearch&tvdbid&season[&ep]` for series/anime that have a tvdb id, only where the indexer lists the params; no `q` is sent. Anime without a tvdb id, daily (air-dated) and whole-series searches, and every other type stay text searches. A typed search that returns nothing, is rejected (201/203, remembered per indexer so later searches skip it) or fails falls back to the text search in the same call; a 429 ends the call. `searchAllIndexers` takes `SearchHints`: `season`/`episode` (the scene numbers the query was built from) and `alternateQueries`. Up to three alternate queries run beside the main one over the indexers that can take another request (a backed-off or out-of-budget indexer is left out silently), and the releases are merged and deduped by indexer + download URL; the scene-name variants still run only when everything came back empty. `releaseAliases.alternateQueriesFor` writes the query under the title less its "(US)" qualifier, the qualifier spelled out, then the first two alias rows. The search cache key now carries the tvdb id and the season/episode, so a typed result is never reused for a text request. Every caller passes ids and hints: auto search (single, episode), the manual target search, the failed-grab retry (season and episode) and the interactive search route; an episode or season search sends the tvdb id alone, so the text fallback is not narrowed by an imdb/tmdb id it never carried. **Follow-up (independent review, reproduced by running it):** the alternate-title queries and scene-name variants are text searches (`skipTyped`), where they had repeated the main query's typed request; every request an indexer is sent counts against its hourly limit (the text fallback after a typed search, the id-param retry), not one per search; alternates run after the main query, together, and only on Torznab/Newznab indexers that are not backed off and have room for them twice over in their hourly limit (an RSS feed is not asked again); "Charmed (2018)" is searched as "Charmed 2018", never as the bare title that is also the other show; an indexer's rejection of a typed search is remembered per search type (a missing tvsearch says nothing about its movie search); a typed search that gets no answer at all (timeouts, network errors) is not followed by a text search. **Not done:** an opt-in per indexer (decision Q4 asked for probing every indexer); the AniList id is not a Torznab parameter, so anime ride the tvdb id or text; alias rows are not offered as typed queries. **Goal:** the right release is in the result set so the gate has something to accept. **Files/functions (`indexerClient.ts`):** cached `t=caps` probe per indexer (one call per indexer per process start, not counted against the hourly budget); `searchTorznabNewznab:233-259` uses `t=tvsearch&tvdbid&season&ep` / `t=movie&imdbid|tmdbid` when caps allow, else `t=search`; the external id sent per type is explicit: movie → `imdbid` then `tmdbid`; series → `tvdbid`; anime → `tvdbid` when the item has one (AniList is the primary provider, `mediaTypes.ts:105`, and AniList ids are not a Torznab parameter), else `t=search`; `generateSceneVariants` strips parenthetical qualifiers and runs the first two alias rows in parallel (bounded, deduped by GUID) rather than only on zero results (`:552-622`). Each variant is one query against `indexers.query_limit_per_hour` (`db/client.ts:91`) and the budget check runs per variant, so variants are skipped (not queued) when the budget is exhausted; `searchIndexerCached` (`:586`) keys the cache by `(indexer, query string, mediaType, externalIds)`, so each variant has its own cache entry and a typed query and its free-text fallback never share one. `episodeSearchFor` returns `externalIds` so `searchEpisode:1437`, `searchAndGrabTarget`, `retryFailedGrab:2140` and `routes/search.ts:154` pass them. **Tests (`indexerClient.test.ts`):** "The Office (US)" S03E01 emits `The Office S03E01` and `The Office US S03E01`; tvsearch used when caps list it; anime without tvdbid falls back to `t=search`; merged results deduped; variants skipped when `query_limit_per_hour` is exhausted; cache entries distinct per variant. **Risk:** more indexer calls on rate-limited trackers; cap at 3 variants per indexer; typed search opt-in per indexer (Q4). ### Round 6 — Scan identity, bulk add, follow-ups (M) **Status: shipped, with two items deferred.** `libraryScan.ts`: `pickTitleMatch` is now `matchExistingItem(items, title, year, episodic)`. A single-shape file (movie/ppv/rom) takes the sole same-titled row unless both years are known and more than one apart (`It.2017.mkv` gets its own row beside `It` (1990)); an episodic file takes the sole same-titled show whatever year it names (`Charmed.2018.S01E05`, `The.Daily.Show.2024.05.01`). With several same-titled rows the file's year picks one (exact, then within one); a file with no year, or none that fits, is skipped with a reason naming the candidates when episodic and a new movie when single-shape (unless exactly one candidate has no year); a per-item scan on one of the candidates is that one's. The file year is `titleYear` (the year the file gives its own title) rather than the first year anywhere, and a movie file's title comes from the release parser, so `Blade.Runner.2049.2017.mkv` matches the sole "Blade Runner 2049" (2017) instead of being cut at 2049. A file numbered only by an absolute number (`[SubsPlease] One Piece - 1085`) or an air date (`The.Daily.Show.2024.05.01`) is placed through the show's stored `episodes.absolute_episode_number` / `air_date` when exactly one episode answers (`episodeFromList`; the show is found by the file's own title, then the folder's, and is never created from such a file); otherwise it is skipped with a reason. The enrichment of a show created by a scan and Refresh's title search take the hit through `pickVerifiedProviderHit` (moved to `titleMatch.ts`), so two same-titled hits with no year to tell them apart enrich nothing instead of the first. `titleMatch.verifyProviderHit` (the hit; several same-titled hits; or nothing trustworthy, with a subtitle-only difference accepted when unique and year-agreeing, and optionally the top hit when no hit shares the title but its year agrees: "Sorcerer's Stone" for "Philosopher's Stone") now decides the IMDb import list, the watchlist import (new `needs_review` row status, queued in `import_review_items`, shown on the Watchlist Import page) and Discord `/request` (a trailing year is the year looked for; an unresolved request lists up to four candidates and asks for the exact title and year instead of adding the first). `duplicateCheck.normalizeTitle` and `mediaServerImport.normalizeForMatch` use the scan's `normalizeForMatch`, so `進撃の巨人` and `Amélie`/`Amelie` rows are seen as duplicates. **Follow-up (independent review, reproduced by running it):** a movie file's title is the parser's only where it extends the old `guessTitleFromText` guess (or that found nothing), so `Spider-Man`, `WALL-E`, `Toy Story - 2 (1999)`, `(500) Days of Summer`, `Plan A`, `The Final Cut`, `Fahrenheit.9.11.2004` and underscore names keep the title they matched by before while `Blade.Runner.2049.2017` and `1917.2019` gain theirs; a year after a dash (`Dune - 2021`) is a year, not absolute episode 2021; an absolute-numbered file in a season folder that disagrees with the season the number falls in is skipped; Discord `/request` tries a title that ends in a year-like number whole when the title-and-year reading verifies nothing (`Blade Runner 2049`). **Deferred:** `detectSeasonEpisode` stays synchronous (its absolute/air-date resolution is `episodeFromList`, called by the scan loop once the show is known; the importer's pack mapping still maps by aired number alone); `filenameLooksMismatched` for episodes and sub-items (the library filter is only offered for single-shape libraries, and an album or book child's title is not in its file name). **Files/functions:** `libraryScan.ts` `pickTitleMatch:107-111` — sole match with contradicting years (both known, |Δ|>1) → new row, ONLY for `single`-shape files or files with no season/episode/airDate, and using `parsed.titleYear` (air-date excluded); the single-shape scan keeps `year = movieSidecar?.year ?? parsed.titleYear` (`:1578`, sidecar precedence kept) so `Blade.Runner.2049.2017.mkv` is not split from a sole "Blade Runner 2049 (2017)" row; episodic files keep accepting the sole match (the episodic path already nulls the year for air-dated files at `:1127`, matching the `:103-106` comment); multi-match year-less → alias/qualifier row else skip with review log; enrichment `:1176-1182` and `refreshOneItem:2064-2085` → `pickVerifiedProviderHit`, scoped to the multi-hit/unknown-year ambiguity (a remake and original both returned, file has no year); `detectSeasonEpisode:306-321` becomes async with an `itemId` parameter so it can resolve `parsed.absoluteEpisode` via stored `episodes.absolute_episode_number` and `parsed.airDate` via `episodes.air_date` — its importer call sites `planSeasonPackFiles:1919` and `placeFile:1137` change with it; per-file title attribution on the scan path uses `parsed.title` (fansub group stripped) so `[SubsPlease] One Piece - 1085` reaches the One Piece row before the absolute number is mapped. `importLists.ts:470`, `routes/watchlistImport.ts:62`, `routes/discordInteractions.ts:152` pass `year` to `searchMetadata` and use `pickVerifiedProviderHit`, queuing ambiguous hits in `import_review_items`. `mediaQuery.ts` `filenameLooksMismatched:99-115` extended to episodes/sub_items; `duplicateCheck.ts` `normalizeTitle:9-14` and `mediaServerImport.ts` `normalizeForMatch:32-37` delegate to `libraryScan.normalizeForMatch`. **Tests (`libraryScan.test.ts`):** `It.2017.mkv` not attached to sole It (1990); `Blade.Runner.2049.2017.mkv` attached to sole Blade Runner 2049 (2017); `Charmed.S01E05` with two rows → skipped; `Charmed.S01E05` with one row → attached regardless of year; `[SubsPlease] One Piece - 1085` imports; `The.Daily.Show.2024.05.01` imports into the sole The Daily Show (1996) row, no new row; `Halloween` year-less watchlist row → review; `進撃の巨人` duplicates detected. ### Round 7 — Stop the wrong grabs: parser and rejections (M) **Status: shipped.** First round of the Starr parity plan (vault note "AoNarr Matching Parity Plan 2026-10-24"). `releaseParser.ts`: theatrical captures and screeners (`HQCAM`, `HDCAMRip`, `NEWCAM`, `TSRip`, `HD-TS`, a bare `TS`/`TC` as a separate token, `TELESYNCH`, `SCREENER`) grade `Unknown` instead of falling through to WEB-DL (P1); disc images (Radarr's BR-DISK heuristic, `BD-25/50/66/100`, `UNTOUCHED`, `Full Blu-ray`, a film's `COMPLETE.UHD.BLURAY`, `DVD5/9/-R`) set the new `rawDisc` flag and grade `Unknown` whatever resolution they name, while a season's `COMPLETE` of encodes stays a pack (P2); `S02.Part.1`/`Vol.1`/`P1` set `seasonPart` and `S01.EXTRAS`/`SUBPACK`/`Special` set `seasonExtra` (neither is `isFullSeason`, so neither matches an episode; `S00 Specials` stays a pack), while `S01.Ep06`, `S01.E.01`, `S03 - EP14`, `S2 - Episode 4`, `S2 [05]`, `(Season 2) - 33` and `Season 01 Episode 05-06` are episodes (P3); `S01-05`, `S01 S04` and `S01 S02 S03` set `seasonRange` (P22); underscores read as spaces everywhere except the hashed-name check (P4); `S01E05v2` and `1x05v2` parse (P5); explicit resolutions are read before `UHD`/`4K`, and `1080i`, `1920x1080`, `1280x720`, `FHD`, `1440p` and `960p` are read (P6); `BD`, `BD1080p`, `BDRemux`, `BrRip`, `WEB-Rip`/`WEBMux`, `MBluRay`, `UHDBD`, `HDDVDRip` and `BDMux` are sources, a DVD source with an HD resolution grades `DVD`, and a 720p remux grades `Bluray-720p` (P7); an ambiguous `NN.NN.YYYY` date is no air date, and month and day take two digits and name a real day no more than 14 days ahead (P9, P10); a new `hardcodedSubs` flag follows Radarr's regex. `Bluray-720p` is on the ladder (`quality.ts`, both seeds, the seeded "Any" profile) and `ensureBluray720pQuality()` adds it once to an existing install, right after WEBDL-720p, adding it to a profile that already allows both WEBDL-720p and Bluray-1080p and never touching a cutoff (P8). New `services/releaseRejections.ts` is one verdict shared by `chooseBestResult`, the IRC path and the interactive search's `rejected`/`rejectReason`: a multi-season pack (D2), a split season or extras pack (P3), a raw disc (D10), a sample under 70 MB with a known size (D11), and burned-in subtitles for a single-shape type (D12). The multi-season, raw-disc, sample and hard-coded-subtitle rules each have an off switch (settings `rejectMultiSeasonPacks`, `rejectRawDiscs`, `rejectSamples`, `rejectHardcodedSubs`; any value but `0` keeps the rule on); a manual grab is never blocked. N9 (pulled forward from Round 14): a ROM import sets `mayHoldMore`, so the client no longer deletes a multi-disc game's other discs after one is imported. The corpus gained 76 rows (the audit's probe names; 71 differ on the old parser, 5 guard against regressions), its round regex accepts `R1`-`R14`, and the year-last air-date test was changed because it enshrined the month-first guess. **Follow-up (independent review, which ran the old and new parser over the 2,645 Sonarr/Radarr test names and about 200 adversarial names):** 9 regressions found and fixed. Radarr's film BR-DISK heuristic (Blu-ray plus AVC/HEVC) now applies to non-episodic, non-bracket-led names only and exempts German/MULTi/French names (their `AVC` is an x264 re-encode); episodes, packs and fansub names keep only the named disc layouts and DVD5/9/-R; library files parse with `FILE_NAME` and are never discs; `-FHD` as a release group is no resolution; `ESubs`/`NLSubs`/`EngSubs`/`DualSubs`/`Fansubs` and a leading `[Group]` are no hard-coded subs; a range ending before it starts is one episode; a bare `BD` loses to a named source and `-BD`/`-TS` group suffixes are no sources; `S01.Special.Edition` is no extras pack; an ambiguous date still ends the title; a failed `ensureBluray720pQuality` only logs. Result: 0 unintended regressions across 2,615 names, 427 changed names (about 420 improvements, about 6 intended losses: DVD-R and ambiguous dates). **Known minor gaps left:** a film with "Disc" in its title and BluRay (`Movie.Disc.2020.1080p.BluRay`) grades `Unknown` (Radarr parity); `The.TS.Experiment` reads as telesync; a title word "Subbed" reads as hard-coded subs; a date more than 14 days ahead leaves its digits in the title. **Decisions (plan numbers 3, 8, 9), confirmed by the owner 2026-10-24:** reject multi-season packs, reject hard-coded subtitles for films, refuse ambiguous European dates. **Not done in this round:** the `revision` model (`v2` is parsed, not recorded), BR-DISK as a tier of its own, the IRC season-pack path (I13), and queue/import bookkeeping for a pack that spans seasons. ### Round 8 — Identity fixes that are live today (M) **Status: shipped.** Stacked on Round 7 (branch `round-8-identity-fixes`, from the Round 7 commit). `releaseIdentity.ts`: `title-main` (the part before the subtitle) only for `single`-shape types, so `FBI.S06E05` is not "FBI: Most Wanted" and `Dexter.S01E01` not "Dexter: New Blood" (I1; a show needs an alias row for a shorter name); `IDENTITY_RANK` (id, title, alias, title-noyear, title-region, title-main) and `resolveReleaseItem` keeps only the best rank before calling a release ambiguous, so `NCIS.S20E05` is NCIS (I2); a scene region tag (`The.Office.US`) counts only for the item whose stored `originCountry` includes it, or, with nothing stored, when no same-titled item is in the library (I3); the anime season fallback compares season numbers on the release, the title and every alias, and a season only one side names is season 1, so `Mushoku Tensei - 05` is not the Season 2 item's (I5, and the interim I4 safety: anime is capped at `log` under the bare setting again); a show with a same-titled sibling must match the year exactly, and a release with no year belongs to the older show (`hasSameBaseSibling`, `hasEarlierSameTitle`, from `identityContextFor`/`sameTitleSiblings`; I6, softened from the plan's exact year for every show because TMDB and TVDB years differ by one often enough; a release that writes the item's qualifier, "The Office US", is not held to the bare-title rule); `identityKey` folds ß/ð/þ/æ/œ/ø/ł, writes `%` as "percent", reads a lone I/V/X as a numeral only at the end of a title or of its part before a subtitle (and `sameTitle` also compares with lone numerals unread, since a release has no separator), and a second transliterated key reads ä/ö/ü as ae/oe/ue (I14); dual titles split on ` / `, ` | `, ` AKA `. `releaseParser.ts` (I15, P13): a dangling article is cut only where a pack word cut the title (`Plan.A.2021`, `To.Live.and.Die.in.L.A.` keep their last word), a scene "X The" gets `titleAlternates` of "X" and "The X", an edition phrase is taken off unless that leaves no title (`The.Final.Cut`) and the edition vocabulary is wider (Director's, anniversary, restored, ...), a leading `(500)` or a `[REC]` with nothing else before the year stays in the title (a dotted `[ENG].Movie.Name` does not), ` - 05 - ` before an SxxEyy is dropped, and `Cars.2.German.DL` also offers `Cars 2`. `releaseAliases.ts`/`aliasBackfill.ts`/`metadata.ts`: one TMDB request (`append_to_response=alternative_titles,translations`) stores the alternative titles, the original-language title and up to 40 titles, and the origin country (`extra_metadata.originCountry`, with `aliasesVersion: 2`; TMDB items checked before are asked again by the existing background job); a provider refresh replaces that source's rows (I19); a rematch also drops `original` rows and the lookup's markers (I19); PATCH of a title re-keys the aliases (I18). `resolveReleaseItemInLibrary` (IRC) judges the wanted items alone, then re-judges the few named with the library's same-titled items and origin countries; a cheap word test (`couldNameItem`) ahead of the gate cuts an announce against 3,000 wanted items with 40 aliases each from 3.4 s to 44 ms warm. The importer's target ignores `hasEarlierSameTitle` (a yearless file name is not refused again). **Owner decisions (2026-10-24):** exact year only with a same-titled sibling, re-ask every TMDB item for the new alias data, accept the anime shorter-name recall loss until Round 12. **Independent review** (old vs new gate over 2.2M release-by-item pairs, and the 2,698 Starr names): found a trailing "The" no longer repaired, a "(US)" show refusing its own release, a lone-numeral miss before a subtitle, dotted leading tags kept in the title, and the announce cost, all fixed here. **Known gaps:** `%` and `3` no longer match each other (intended; there is no scene-mapping fallback yet); region-tag releases are refused for an item with a sibling until the v2 alias job has stored its `originCountry`; shortened show names (`CSI`, `Law.and.Order.SVU`) need alias rows nothing supplies automatically; anime season-scoped aliases are Round 12; the first IRC announce after a start tokenizes every wanted name (about 1.3 s for 123k names). ### Round 9 — Search hygiene and recall (M) **Status: shipped.** Stacked on Round 8 (branch `round-9-search-hygiene`, from the Round 8 commit `938fe07`). One `SearchPlan` per target (`searchPlan.ts`) builds the main query, the other queries, the typed-search hints, the ids and the release-match target, and the automatic search, the bulk search, the retry path and the interactive search route all use it, so a fix reaches every path (S3, S4, S6, S7, S18). **Queries** (`searchQueries.ts`): the text query is Sonarr's `GetCleanSceneTitle` form (no leading "The" unless a number follows, `&` as "and", no apostrophes, dots, brackets or accents, other scripts kept whole: `Office US S04E02`, `Marvels Agents of SHIELD S01E01`; S13), with the title's dots as spaces (`S W A T`) and the ampersand joined or spelled out (`ATT`, `AT and T`, `ATandT`) as alternates, up to four alternate queries; a film is ` ` and a ROM only its title (N6); an anime episode is `Show 25` by its absolute number, with `Show S01E25` asked beside it and a typed `tvsearch tvdbid&q=25` (S3); a daily episode is `Show 2024 08 25`, with the dashed date as an alternate and a typed `season=2024&ep=08/25` (S6, S7); a special is also asked by its episode title and sent as `season=00` (S18); a book is `Author Main Title` (the part before `:` or `(`) then the title alone, an album has its bracketed edition notes taken off, "Various Artists" is `VA`, a self-titled album gets its year (and is asked without it too), a comic issue is `Series 2012 012` (the volume's year when the series name has one) then `Series 012` and `Series 12`, a manga chapter `Series 1085` (N5); manga searches category `7030,7000` (N11). **Ids** (S4, S5, S19): an episode or season search sends the show's tvdb id alone (imdb or tmdb only when it has no tvdb id and the indexer's tv-search takes them), no text request carries ids for a show, and a film's imdb/tmdb ids go on a text request only when the indexer's capabilities could not be read. **Indexer requests** (`indexerClient.ts`): `extended=1` on every request and up to three pages while a page comes back full (page size from the capabilities' ``, each page counted against the hourly limit, a repeated page ends it) (S8); attributes are read like Sonarr's parsers do (case-insensitive names, `size` before the enclosure length, `tvdbid` into `SearchResult.tvdbId`, the gate and `matchTierFor`, imdb as `tt` plus seven digits only above 0, `usenetdate`, `infohash`, `magneturl`, upload factor and tags; an item with no title or URL is dropped) (S9); a tv-search indexer that takes `q` is asked `t=tvsearch&q=&season&ep` after the id search, and its empty answer ends the search (S13); a refused form of a typed search is remembered for six hours, per form (ordinary episode, special, date, absolute number, title) and id (S5); a plain RSS feed's items are matched by parsed title and numbers, not by substring, and the feed is downloaded once per five minutes (S12); the other titles and the scene-name forms go only to an indexer the main query found nothing at, one form at a time per indexer (S13, S15); requests to one indexer go one at a time with a 1 s pause (`AONARR_INDEXER_REQUEST_SPACING_MS`, 0 turns it off; a search the admin waits for takes its turn without the pause) (S15); a server error ends the search at once instead of asking the indexer's other tiers; an indexer that fails is left alone for 5 minutes, then 15, 1 h, 3 h, 6 h and 24 h (searches that ran into the same outage count once, a request queued behind the failed one is not made, a search the admin waits for may still try it), a success starts the count over, a passing health check ends it, a Test or an edit ends it and makes the capabilities and the refused forms be asked again, a request that never left the queue is no failure and leaves no health row, and `Retry-After` is honoured (S16). **Other:** alias titles are ranked for search (the admin's and former names, then AniList, then TMDB; Latin script first; titles of three letters or fewer left out) and the providers' lists are no longer sorted alphabetically (S14); an episode of a show or an anime with no air date is not searched unless a later episode of its season, or a later season, has aired (the way `upcomingEpisodes` reads them) (S17); mIRC colour and bold codes are stripped from IRC lines (S10); a synced indexer's media types come from the categories it lists and follow later changes until the admin edits them, and an indexer's own `categories` setting applies only to the types it covers (S21). A book's main title no longer names a book when another book of the author is called that or starts with it (`Dune` is not `Dune: Messiah`; only an author's or audiobook's siblings are read), and a comic release dated before the volume the series name gives (or long after the issue can have come out) is refused. **Independent review** (old vs new on 100+ titles, request counts per scenario, a stress of the lane and backoff; run twice): found undated episodes dropped, dotted titles lost on all-words indexers, concurrent failures multiplying a backoff, the Test button blocked, a rejected typed form disabling its siblings, wrong-grab paths for comics and books, mangled non-Latin titles, paging on an indexer that ignores `offset`, false health rows from queued requests and smaller parsing slips; all fixed here, and the second run's remaining items fixed after it. **Known gaps:** an undated episode is still not searched when nothing in its show has aired by date (Sonarr needs a date too); several ids in one request (S19) and `rageid`; a book whose siblings are not in the library (`Dune: Messiah` with only `Children of Dune` listed) can still be matched by the main title (Round 11); custom-format conditions for internal, scene, double-upload and 25%/75% freeleech (the fields are read, not scored); TMDB alternate titles per region (English-speaking regions first, no region setting); a ROM's system as an alternate query; an anime episode's absolute number is still the row count of `computeAbsoluteEpisodeNumber`, not the stored column (Round 13); stored alias rows keep their old (alphabetical) provider order until the next refresh, ranking corrects the worst of it; with the pause a full cycle over hundreds of missing episodes takes much longer per indexer, until Round 10 (season search, per-leaf backoff, RSS sync) cuts the requests; the first sync after the upgrade narrows the media types of indexers still on the default list. ### Round 10 — Season search and search load (M/L) **Status: shipped.** Stacked on Round 9 (branch `round-10-season-search`, from the Round 9 commit `bfdb379`). **Season search** (S2, D3): the automatic search, the bulk search and the auto-upgrade job look for a season as a pack before they search its episodes one by one. A season is searched (`Show S02`, by the scene's season number where TheXEM gives one) when a series or anime that is not a daily show has two or more of its episodes wanted in it, the whole season has aired, none of it is unmonitored, enough of it is wanted for a pack to pass the guards, and a queued download does not already cover it. A pack that is grabbed is queued against the season (`queue.season_number`), `isAlreadyQueued` then treats the season's other episodes as covered (a pack calls the season by the scene's number, as `releaseMatchesEpisode` does), and only what is left is searched alone. The pack guards (`seasonPacks.ts`, `judgeSeasonPack`) also apply to a pack that an episode search, a retry or the RSS sync turns up, as Sonarr's FullSeason, MonitoredEpisode and UpgradeDisk specifications do: every episode the pack covers has aired and is monitored, and the pack fills or upgrades at least `seasonPackShare` percent of them (default 50); settings `seasonPackGuards` (`0` turns the guards off) and `seasonPackShare`. A season is looked for twice, then daily, every three days and weekly (table `season_searches`, keyed by the scene's season number), and a pack for one aired season is sent once (`grab` re-checks under the show's chain; an aired season that a scene splits takes a pack of each part). **Search backoff** (S1 short term, `searchBackoff.ts`): `auto_search_misses` on `media_items`, `episodes` and `sub_items` counts the searches in a row that found nothing; three are free, then the target waits a day, three days, a week, except for an episode or film within 14 days of its air or release date. Only a search that an indexer answered counts as a miss: a search that no indexer answered (backed off, failing, over its hourly limit), one that nothing could be sent for (no client for the protocol, a client down) and one whose release only the delay profile holds back do not. A search no indexer could answer is not made at all (no slot of the cycle's cap is used) and is not stamped as searched, so the target is next in line when an indexer is back. A release the delay profile holds back wakes its target (misses back to 0), and so does a held IRC announce. The count starts over when something is grabbed for the target and when the admin edits the item, an episode, a season's monitoring, an alias, a rematch, a quality, delay or release profile (also deleting one), a custom format or its score, an indexer (add, edit, or a Jackett/Prowlarr sync that adds one or changes its types) or a download client, when a file goes missing, and when the RSS sync sees a release for it. The `searchBackoff` setting `0` turns the backoff off. **Auto-upgrade limits** (D15): the upgrade job skips targets the backoff holds, searches the longest-unsearched first, no more than `autoSearchMaxPerCycle`, stamps what it searches and groups a season's candidates into one pack search. **Interactive season search** (S22): the route asks under every season number the scene gives the aired season (lowest first), lists a release found by two of the queries once, and takes the packs of each as its own. **RSS sync** (S1 long term, `rssSync.ts`, a scheduled job every `AONARR_RSS_SYNC_INTERVAL_MINUTES`, default 15, `0` turns it off): each enabled Torznab/Newznab indexer's newest releases are read with no query (the type's categories, the tv or movie feed where the capabilities have one, up to three pages back to the newest release the last run saw, one page on the first run; table `rss_sync_state` keeps where each feed was read to) and a plain RSS indexer's feed is read whole. Each release is matched to the wanted item it names (`resolveReleaseItemInLibrary`, the way an IRC announce is), judged with exactly the rules of a search (`chooseBestResult`: identity, rejections, quality, formats, delay profile, pack guards) and grabbed (`grab_source` `rss`; upgrades too when `autoUpgradeEnabled` is on). While the sync runs, an automatic search leaves the plain `rss` indexers out; an admin's search, a bulk search and a retry still look through them. A feed's marker is kept only once the releases above it were judged: a run cut short (aborted, or a download client down) reads them again, a feed that was empty on its first read stores none, and a marker is never dated after now. **Independent review and the Postgres run:** the Postgres run found a defect that SQLite hid (the Postgres driver appends `RETURNING id` to every insert, and the two new tables have no `id` column, so every backoff write and marker save would have failed; both are now in `TABLES_WITHOUT_ID` in `asyncDb.ts`) and tests that assumed concurrent episode searches run in order. The reviewer (12 findings, all traced in code) found: searches no indexer answered counted as misses and stamped; delay-held releases counted as misses; markers saved before the releases were judged; scene and aired season numbers mixed up (queue coverage, retry, RSS group key, season backoff key); many edits that change what a search finds not resetting the backoff; whole-table backoff resets; a pack sent twice by the search and the RSS job in the same minute, and a multi-episode release sent once per episode in one RSS run; admin and retry searches no longer reaching plain RSS feeds; season searches for sports, course and adult shows, and a manual search advancing the automatic backoff; smaller marker and ordering slips. All were fixed, with tests. **Known gaps:** the RSS sync reads the quality-tier types only (series, anime, films and the like), not books, music or comics; a release that only the delay profile holds back is found again by the target's own search after the wake, since a feed does not list it twice (there is no pending-release store, unlike Sonarr); no UI for `seasonPackGuards`, `seasonPackShare`, `searchBackoff` or the env var; a multi-episode release is still queued against its first episode only (a sibling's own search may fetch it again, as before); while the sync runs the back catalogue of a plain `rss` indexer is not searched; `loadWanted` reads every wanted episode each run (every monitored episode with upgrades on); a TVDB-numbered pack for a show with scene numbering is not matched (scene numbers first, as `releaseMatchesEpisode` does). ## Regression corpus `server/tests/fixtures/release-corpus.jsonl`, one JSON object per line in the Round 0 shape (`title`, `kind`, `expect`, optional `identity`, optional `todo`), with `server/tests/fixtures/identity-library.json` holding the fixture items. Seed in Round 0 from the CHANGELOG failure catalogue (false-positive grabs at CHANGELOG.md:5938-5945, `Extraction`/`Extraction 2` at :5853-5858, scene/absolute at :5471-5478, another-show episode at :387-433, "Annotated Hobbit" at :245-266) and the surface-map scenarios listed in Round 0. `releaseCorpus.test.ts` runs `parseReleaseTitle` and, from Round 1, `releaseIdentifiesTarget` against the fixture library, asserting `matchType` and `reason`. Each later round appends its own names and removes `todo` markers; each resolved manual-import or blocklist entry on a dev build may append a row behind `MATCH_CORPUS_APPEND=1`. ## Not changing - Quality tiers, custom-format scoring, delay profiles, size bounds, seeder/age sort and the sort order itself (`scheduler.ts:697-778`, `quality.ts`): the identity gate slots before them; `totalScore` stays first and `matchTier` stays the tiebreaker. - DownloadId-keyed queue association in `applyClientStatuses` (`scheduler.ts:2441`; the equality is at `:2455`): already Starr-shaped; only the by-name rekey gets the predicate. - `normalizeForMatch`'s Unicode handling (`libraryScan.ts:44-55`, Latin/Greek-only combining-mark strip) and `titlesMatch` exact-only semantics (`:65`): they become the first stage of `cleanTitle`; other normalizers adopt it, not the reverse. - `isSharedDownloadFolder`'s empty-token return (`importer.ts:397-398`): the *24*/*ER* fix lives in `pickDownloadedFile`, so `collectSeasonPackFiles` keeps working for short titles. - No wholesale Sonarr ordered-regex parser port: the existing if-chain plus a `title` field and corpus-driven additions is cheaper. - Album/book placement (`placeAlbumFiles`), Lidarr fingerprinting: out of scope; the music fix is the narrow different-parent rejection in `releaseNamesTarget` (Round 3), not a parent requirement. - Scan still creates items from folders; it just stops enriching from ambiguous hits and stops attaching single-shape files to year-contradicting rows. - `pathTail` media-server heuristics and blocklist per-item scope: follow-ups. - Manual grab/import stay overridable (soft 409 + `force`), never hard-blocked. ## Decisions (answered 2026-10-24) 1. **Movie year tolerance: ±1**, the IRC path's current convention (`ircAnnounce.ts:79`). No `secondary_year` column. 2. **sports/ppv/course/adult/video: whole-word containment of the item title, in `log` mode** until a corpus of real announces shows the rule is safe. movie/series keep the exact/alias gate; anime deferred to Round 3 as planned. 3. **Every result rejected: log only**, Sonarr-style. No `import_review_items` row. 4. **Typed search support: probe `t=caps` on every indexer**, cached per process start, automatic free-text fallback. No per-indexer opt-in. 5. **Blocked imports keep seeding until acted on.** The row stays in Activity flagged for manual import with the data kept; the stalled-cleanup timer does not apply to them. 6. **Alias backfill: a background job after the Round 3 migration**, one provider call per item through the existing Background Jobs widget, rate-limited. Refresh keeps aliases current afterwards. 7. **Anime numbering: AniList absolute first, TVDB/XEM fallback** when the release carries SxxEyy. `episodes.absolute_episode_number` is the stored source. 8. **Indexer ids: unknown; Round 1 logs per indexer how many results carried `imdbid`/`tmdbid`/`tvdbid`.** The Round 2 id-only import block is kept or dropped from that data. 9. **`.iso`/`.img` single-file downloads: left for manual import** with reason `unsupported-extension`. Movie extensions unchanged. 10. **Scene numbering: scene-first precedence with aired fallback**, replacing the Round 195 OR, behind a `sceneNumberingMode=or` rollback setting.