Skip to content

Stored series key, Health: series spelled several ways, paged Authors/Narrators - #126

Merged
KodeStar merged 6 commits into
mainfrom
feat/series-key-column
Oct 12, 2026
Merged

KodeStar merged 6 commits into
mainfrom
feat/series-key-column

Conversation

@KodeStar

@KodeStar KodeStar commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Follow-ups 1-3 from #123 (series spellings). Docs: KodeStar/audiosilo-docs#127.

1. The series key is stored

Catalog.spellings read every spelling of a series on each request. The key is now stored, so every series filter is an index seek in any spelling.

  • Migration 0041:
    • books.series_key: seriesKey(series), '' for none.
    • book_more_series(book_id, library_id, series_key, position): a book's other series, one row per key (PK (book_id, series_key), cascades with the book, index (series_key, library_id, position)).
    • Both are written by Go in refreshEffective beside series, never computed in SQL. UpsertBook writes series, then calls refreshEffective in the same transaction, which writes the key.
  • Filters by key: the admin list and facets, the player's /books?series=, /series/books, the series order (seriesOrderKeys), the next book (numberedSeriesMembers) and the rails (SeriesBooks/seriesMembers). TestNextInSeriesPlan asserts the seeks.
  • Deleted:
    • the spellings path: Catalog.spellings/spellingsOf/distinctNames, seriesNames, sqlList, ftsPhrases, spellSeries
    • the hidden BookFilter.series and ListOptions.series fields
    • memberIDs
  • Backfill (Catalog.KeySeries): runs at start before the listener, one transaction.
    • It first puts every stored more_series override through normalizeMoreSeries and rebuilds those books, so a list from before Series: one series per spelling, numbers from titles, paged and searchable Series page #123 that names a series twice loses the second entry.
    • Then it keys every book.
    • server_state.series_keys holds a version (key rules number + Go's Unicode version + x/text's NFKD version). A toolchain or rule change re-keys once instead of leaving filters on stale keys.
    • A failure stops the start, as a failed migration does, since every filter reads only the keys.

Two deviations from the plan

  • The index is (library_id, series_key, series, series_index), not (library_id, series_key, series_index). With series next to the key, a series' spellings are adjacent, so the Health check below is two range seeks per book. With the planned shape, a 200-book series of one spelling made the Health count quadratic: 2.5 s over 50,000 books, now 86 ms. Filters and the next book use the prefix as planned.
  • Indexes dropped: idx_books_series (0001) and idx_books_more_series (0039). Nothing reads either any more.

Measured: 50,000 books, 2,000 series, 3 spellings each (TestSeriesKeyScale)

Read Time
Keying them all 0.41 s
Player list of one series 0.4 ms
Admin list in series order, every library 0.9 ms
A list-only series 3.5 ms
Next in series 0.2 ms
Health count 86 ms

The old spellings read cost 9-20 ms per request. Under -race the test uses 5,000 books and only logs the times.

2. Health: series spelled several ways

  • The issue: a book kind, series_spellings: another book of its library has the same series_key spelled differently. It shares one predicate between the count, the ?issue= list and the new GET /admin/issues/series-spellings, which returns one group per series and library: suggested (seriesName) and each spelling with its books.
  • The console: one notice per series, reusing the author merge (useMerge, a bulk series edit, with Undo).
    • You can pick another spelling to keep, and the count updates.
    • The books of a series are fetched once, not once per spelling.
    • Ignore covers every book of the series.
  • Strings are in all six locales.

The server never writes files. The rename makes every book say the same in AudioSilo (the players' book details, the Books list, the export), not in other apps that read the tags. The issue brief described it as making the files' tags consistent, so I've said so here.

3. Cleanups

  • match.SameSeries replaces the catalog's and matchrun's copies. A blank name is no series, and a name with no key is only itself. The matchrun copy called "" and "!!" one series.
  • Library > Authors and Narrators use the shared SearchBox and Pager, with q and page in the URL. A new search or library starts on page 1, and Back from the Books list returns to the search. They show 120 per page; "Show N more" is gone.
    • The Series page and these share useSearchPage/pageOf, added in the code review.

Verified in a real browser

Scratch copy of audiosilo-server/data (1,168 books; HWFWM as on disk: 8 books "With", 5 "with"), headless Chromium:

  • Start: keyed 1,168 books before the listener, then placed 25 by title.
  • Health: the card shows 13, and the notice reads "“He Who Fights With Monsters” looks like He Who Fights with Monsters" with "8 books are tagged with the other spelling".
    • Choosing the other spelling shows 5.
    • "Use this spelling" sets 8 books. The count went to 0, and all 13 kept positions 1-13.
    • Ignore moves the series to "Show ignored (13)", and Show again brings it back.
    • Light, dark and 375 px: no sideways scroll.
  • Series: one HWFWM card, 13/13 "Complete". Paging: page 2 to 3, Back returns to 2.
  • Authors and Narrators: pages 1-2, the search in the URL resets the page, and Back from the Books list keeps the search.

Gates

  • scripts/build-admin.sh: 682 tests.
  • go build ./... && go vet ./... && go test -race ./... && golangci-lint run: pass, 0 issues.
  • /simplify and /code-review xhigh --fix ran. Applied: the dead index, one series fetch per merge, prepared updates in the backfill, versioned keys, a handler test for the new endpoint, and shared URL paging.

Skipped from the review:

  • The Health card counts books, not series groups, as the other book kinds do.
  • The notices don't name their library when every library is shown, which would need new copy.
  • SameSeries isn't memoized; it only runs for books with lists.
  • The rename isn't one transaction: it's the author merge's bulk edits.

…he per-request spellings read

Every series filter (the admin list, the player's /books?series=, /series/books,
the series order and the next book) now seeks by key on idx_books_series_key or
book_more_series' key index. KeySeries keys older rows once at start (about a
second per 50,000 books) and puts stored more_series lists through
normalizeMoreSeries first.
… the URL

match.SameSeries replaces the catalog's and matchrun's copies (a blank name is
no series, a name with no key only itself). Library > Authors/Narrators use the
shared SearchBox and Pager with q and page in the URL, like Series.
A book kind whose predicate is two range seeks on idx_books_series_key, which
now holds series before series_index and replaces the unused idx_books_series;
GET /admin/issues/series-spellings lists the groups with the spelling to offer.
One notice per series, the author merge's bulk edit with a choice of spelling
(the Series page's name first) and an Ignore for every book of it; strings in
all six locales.
…dropped

idx_books_more_series goes with 0041 (nothing reads it now); KeySeries prepares
its update and fails the start; Health checks an ignore before the costly
predicate; the console's grouped kinds are one list.
series_keys holds the key rules' version and the Unicode versions behind them,
so an upgrade that reads a name another way keys the books again. Series and
Authors/Narrators share useSearchPage and pageOf; the series-spellings endpoint
gets its 400, shape and ignored tests; comments and CLAUDE.md say the new truth.
@KodeStar
KodeStar merged commit 54de7a3 into main Oct 12, 2026
3 checks passed
@KodeStar
KodeStar deleted the feat/series-key-column branch October 12, 2026 22:49
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