Skip to content

Series robustness: series rules for matches and rails, Series page rail picking - #122

Merged
KodeStar merged 3 commits into
mainfrom
claude/admin-series-robustness-1t0y7m
Oct 11, 2026
Merged

KodeStar merged 3 commits into
mainfrom
claude/admin-series-robustness-1t0y7m

Conversation

@KodeStar

@KodeStar KodeStar commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Summary

The community metadata has recording-variant series ("Harry Potter (Full-Cast Editions)", "Discworld (abridged)"), translations and duplicate works, which a parallel audiosilo-meta session is cleaning up. This PR makes the server and the admin console robust to that data instead of turning it into a bad UI.

Cleanup of books that already carry wrong series is in the revised #121, which is stacked on this branch. Docs: KodeStar/audiosilo-docs#121.

1. Series page showed the wrong series' gaps

  • Root cause, found in the real data:

    • "A Hat Full of Sky" is in 6 community series, sorted by id: Discworld, Discworld (abridged), Discworld: For Kids, Discworld: For Kids (abridged), Discworld: Tiffany Aching, Discworld: Tiffany Aching (abridged).
    • /meta builds at most 3 rails (maxSeriesRails), so the "Discworld: Tiffany Aching" rail was never fetched.
    • The card's pickRail then fell back to rails[0] (Discworld, 49 books). The names matched fine (seriesKey agrees on "Discworld: Tiffany Aching").
  • Server fix: rails now follow the series rules below before the cap.

    • An edition is kept only for the recording the lookup named, and then goes first.
    • A series already known to be foreign is left out.
    • A main view found foreign once fetched hands its slot to the next series, at most twice.
    • The Tiffany Aching book now gets Discworld, For Kids and Tiffany Aching rails.
  • Console fix (pickRail): the card picks, in order:

    1. the rail named like the series, or one of its reading orders;
    2. one named like it give or take a leading article or a trailing "Series" (as match.NormalizeSeries);
    3. the rail holding at least 2 of the series' resolved works and more than half of them;
    4. otherwise no rail: the card shows local numbers and no ghosts.

    A rail named like the series' head before a colon or dash is never used ("Discworld" for "Discworld: Tiffany Aching"); that is the parent series.

  • Why that threshold: a book's work sits on every series it is in, so one shared book can't tell series apart (that was the bug). Two books that make up most of the series are this series spelled another way. Ties go to the smaller rail, the more specific series.

2. Bobiverse: unnumbered, untagged books

  • An end-slot book that isn't resolved to another work now takes an unheld entry whose title it is. The comparison ignores a leading numbering ("BV01 - ", "01 - ", "Book 3: ", "DF15.5 ") and a trailing bracketed part. Such a book holds the entry and isn't counted as missing.
  • Matcher check: the active path (works/match) already sends titles raw, and metaserve cuts these prefixes itself (leadCodeRE and friends in its matchscore.go). Match automatically therefore finds "BV01 - We Are Legion (We Are Bob)" as is.
  • The frozen works/search fallback does not strip them. I left it frozen per CLAUDE.md, since it is slated for deletion.

3. Match stamped every series of a work onto the book

New internal/meta/seriesrules.go. A match candidate and the rails never treat these as a series of the book:

  • a variant reading order (ordering_of);
  • a translation (translation_of) of another of the work's series that is in the work's language;
  • a series whose derived language differs from the work's (primary subtags; unknown agrees);
  • an edition whose recording the book isn't.

How editions are read: the community API links no series to a recording (series sources aren't served), so they are read from the name. An edition is a series named exactly like another of the work's series plus a bracketed qualifier, or a "narrated by" tail. The qualifier says which recordings hold it:

  • abridged;
  • a full cast (4+ narrators, or one named "Full Cast");
  • the named narrator.

"(Unabridged)", language editions and ordering notes hold none. A name that only extends another ("Discworld: Tiffany Aching") is its own series.

Main series: for the book's recording, an edition named for it is the main series; otherwise the work's primary series is.

Wire changes (additive):

  • MatchSeries.id.
  • MatchRecording.series: the list for a book that is that recording, main first. The match dialog and matchrun.Propose read it, so the console has no copy of the rule.
  • MatchCandidate.series is now the list for every recording (no editions).

Series facts (language, translation_of) come from series/{id}, which the server already fetched for rails. They are read once per series per request, concurrently and bounded (16 records, 5 s), and cached in a new s: key space. A repick reads none.

Frontend repo

No change is needed in KodeStar/audiosilo-frontend src/api/types.ts. The player-facing /meta envelope keeps its shape; only which rails it holds, and their order, change. The new fields are on admin-only match endpoints.

Verified

  • Gates: scripts/build-admin.sh (npm ci; check = typecheck + eslint + prettier + 665 vitest tests; build), go build ./..., go vet ./..., go test -race ./..., golangci-lint run (v2.14, 0 issues).
  • Real browser (Chromium via Playwright), against the built binary and a local fake community service seeded with the Tiffany Aching, Bobiverse and Harry Potter shapes:
    • Series page: "Discworld: Tiffany Aching" shows "You have 2, 4, 5 of 5; missing 1, 3", where it used to show the Discworld main series' 41 entries.
    • Bobiverse: "BV01 - ..." and "BV03 - ..." sit on entries 1 and 3, showing "You have 1-3 of 4; missing 4".
    • Match dialog for a Harry Potter book: the Jim Dale recording offers "Harry Potter" alone. The full-cast recording keeps the book's own "Harry Potter" and adds "Harry Potter (Full-Cast Editions) Demo mode #2" as another series.
    • No console errors.
  • Not checked in a browser:
    • the player (/web, not vendored here);
    • rails in /meta against the real meta.audiosilo.app (network blocked here; covered by tests and the fake service);
    • mirror mode.

Left for a human

  • Belongs in audiosilo-meta: state on each series which recordings it holds (or an edition_of link), and put language/translation_of on a work's series refs. Then the name heuristic and the extra series fetches can go.
  • Works in more than 3 genuine series (after the rules) can still miss a rail on the Series page, because the card reads one matched book's /meta. A sturdier fix is an admin endpoint that returns the rail of the series the card draws.

…il picking

The community lists every series a work is in, including variant reading
orders, translations and editions named for one kind of recording
("Harry Potter (Full-Cast Editions)", "Discworld (abridged)"). A match
stamped all of them onto a book, and the rail cap (3) let editions push a
book's real sub-series out of /meta.

- meta: series rules (seriesrules.go). Match candidates' series leave out
  variant orders, translations, other-language series and editions no
  recording is; an edition carries the recordings it holds, and
  SeriesFor picks the book's series for its recording. Rails apply the
  rules before the cap.
- matchrun: Propose takes the book's series for the chosen recording.
- meta: the frozen search fallback cuts a numbering prefix ("BV01 - ").
- console: the match dialog applies the same rules (seriesFor); the Series
  card picks its rail by name, a reading order's name, a looser name, or
  most of its own books, else none (no more falling back to the book's
  first rail); an unnumbered book fills the missing entry its title is.
- recordings carry their own series (MatchRecording.series), so the
  console and matchrun read the rule's result instead of copying it
- one edition-kind classifier; NamesMatch for narrators, the meta
  module's PrimarySubtag for languages
- series facts read once per series across a match's candidates,
  concurrently; a repick reads none; smaller cache quota; facts the
  cache knows filter rails before the cap
- the frozen search fallback is left as it was (works/match cuts the
  numbering prefix)
- console: loose series key as the server's NormalizeSeries; title keys
  computed once
- a narrator edition after a colon ("Harry Potter: Narrated by ..."),
  and a narrator named beside "Unabridged", read as editions
- a translation is foreign only when its original is in the work's
  language (a German work keeps Scheibenwelt)
- rails pick editions by the recording the lookup named, never the first
  recording guessed; the book's own edition rail goes first, as it is its
  main series
- a main view found foreign hands its rail slot on (bounded)
- series facts: at most 16 records, 5 s, none for a work they can't rule on
- console: only a name's head before a colon or dash is a parent series
  ("Mistborn Trilogy" can take the Mistborn rail)
@KodeStar
KodeStar merged commit ffec1d6 into main Oct 11, 2026
3 checks passed
@KodeStar
KodeStar deleted the claude/admin-series-robustness-1t0y7m branch October 11, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant