Search and browsing
One search box over six backends. internal/federate asks every
shelf at once and merges the answers into one ordered list; browsing a shelf is the same call
with nothing typed. Around it sit the server-side views that make the app feel like one
library: Home, the Continue row, genres, authors, series and Read Along.
Asking everyone at once
federate.Search takes a query, asks the registry which sources match it (by kind,
and by what this account may see), and queries each in its own goroutine with its own deadline -
five seconds by default (SOUNDSTORM_PER_SOURCE_TIMEOUT). It returns once every
source has answered, failed or run out of time, and it never returns an error.
{
"items": [ ...media.Item, merged and ordered... ],
"sources": [ {"sourceId": "navidrome", "kind": "music", "ok": true, "count": 42, "tookMs": 31},
{"sourceId": "jellyfin", "kind": "video", "ok": false, "error": "did not answer", "count": 0} ],
"degraded": true,
"offset": 0,
"hasMore": true
}
That shape is the first of the three rules the design rests on: a dead backend must
never take the search down. Every source has a deadline and an error slot of its own,
the answer is always 200, and degraded says what is missing. The items that did
arrive are valid; they are just incomplete. Tests in internal/federate and
internal/httpapi protect this.
recoverInto catches a panic and reports
that one source as failed, the same shape as an ordinary error. HealthAll is guarded
the same way.The second rule is normalization at the edge. Only an adapter knows its
backend's vocabulary - Subsonic's search3, Jellyfin's /Items,
Audiobookshelf's library items, Immich's smart search. Everything past the adapter speaks
media.Item: an id, a kind, a title, a sort key, artwork, and an Extra
map for what only some kinds have (an album, a genre, a series and its number).
Ranking is federate.Relevance, deliberately simple: every backend has already done
its own matching, and the only job left is deciding whose hits go near the top. An exact title
scores 1.0, a title starting with the query 0.85, containing it 0.7; then a matching creator
(0.65, or 0.5 for a partial match), then the subtitle - the album for music, the series for a
book - at 0.45, which matters for live recordings where almost nothing matches on the track
title; then how many of the query's words the title covers. Ties fall through to the title order
described below.
Browsing is searching for nothing
An empty query is a request to see the shelf, not a request for nothing. Picking Audiobooks
with nothing typed lists every audiobook; typing narrows from there. /api/search?q=
used to answer 400, which made that impossible to ask for.
Each adapter turns an empty query into whatever listing call its backend offers, and none of them agree - checked against real servers, not their documentation:
| Backend | Empty query becomes |
|---|---|
| Navidrome | search3.view?query= already answers with everything - no change needed, the opposite of what was expected. |
| Jellyfin | searchTerm omitted, not blank, plus SortBy=SortName - only when browsing, or it would override relevance on a search. Browsing the TV shelf lists series only. |
| Audiobookshelf | Its search matches nothing for an empty q, so browsing uses /api/libraries/{id}/items - same item shape, one wrapper shallower. |
| Local books | The folder scan's own list; matches() accepts everything with no terms. |
| Immich | Its own newest-first timeline. |
Nothing in federate changed for browsing: Relevance scores every
item 0 for an empty query, so the sort falls through to its title tiebreak and a browse comes
back alphabetical for free.
Paging a merged list
Infinite scroll asks for ?offset=. federate.Search answers by asking
every source for offset + window items and slicing after the merge - so page
two asks each backend for 200 and throws the first 100 away. That looks wasteful and is not.
Per-source paging is the obvious alternative and is wrong: each source's second page starts at
the top of its own order, so those items sort in behind ones already on screen.
TestPagesWalkTheMergedOrderExactly holds the property that matters: walking every
page reproduces the single sorted list, nothing repeated, nothing skipped.
That only holds if every source returns its own first N in exactly the merge's order - the globally first N can only come from the union of each source's first N. It is a requirement on the adapters, and no backend's own sort satisfies it. It was reported as "I'm seeing repeats in music" and measured on a real library by scrolling forty pages through the real merge:
- Navidrome: 240 distinct songs and 1,760 repeats.
search3lists songs in an order of its own; of the first 50 by title in a 4,413-song library, its first 50 held none. - Audiobookshelf: 4 repeats and 4 books never shown, of 113. Its title sort ignores case, so "How to Fast" and "How To Overcome" swap.
- Jellyfin:
SortNamedrops a leading "The" - the same shape.
So there is one comparator, media.Less: OrderKey (the item's
SortKey, else its title) and then the id, so two songs called "Intro" cannot trade
places between pages. The adapters that browse fetch the whole shelf, order it
with media.Less, and cut. Searches return every match and let the merge rank them,
since a cut in the backend's relevance order is a cut in the wrong order.
Fetching a whole shelf for every page would be slow, so media.ShelfCache keeps a
listing for thirty seconds and a rescan clears it. The same forty pages of music went from 28.6s
to 3.2s, and after the fix: 2,000 distinct songs, 0 repeats; all 113 audiobooks.
GetOrFetch shares one fetch among concurrent callers of the same key (a cold cache
opened from two devices was a stampede), and eviction is least-recently-used, so the shelf a
scroll is paging through is not evicted by a burst of one-off searches. A fetch that panics
becomes that fetch's error rather than a key left pending for ever.
SortKey is
its rank in that order, behind a ~ so that in a browse of everything photos follow
the titled media. The merge property holds because the key is the source's own order.Two more details: HasMore is not just "this page was full" - a source returning
exactly what it was asked for was probably truncated, so there is more behind it even when the
merged page came up short. And MaxDepth (10,000) stops paging rather than serving
pages that never arrive; it was 2,000, which cut a real music shelf off halfway, and with
shelves cached a deep page is a sort in memory rather than a bigger backend request.
internal/source/*/paging_test.go scroll each adapter through the real merge
against a fake that orders the way its backend does - scrambled, case-blind, "The"-stripped -
and require every item once, in order. The music test fails against the code from before the
fix, with the same repeat the user saw.
Home
Home is a front page, not "everything, alphabetically". The app builds most of it from
existing calls - Continue, recently played, favorites, new music - plus GET /api/home,
the newest items per shelf. That asks every source at once through
source.RecentLister, five seconds each, and leaves out a shelf that fails: the
search's rule again. Each backend orders by its own record of arrival:
- Jellyfin by
DateCreated, and a series byDateLastContentAdded, so a new season brings it forward; - Audiobookshelf by
sort=addedAt&desc=1; - Immich by its own newest-first order;
- the folders SoundStorm reads itself by the file's modification time.
The Continue row
GET /api/continue is what this person is part way through, newest first. Two
places know, and neither is new:
- Books, films and episodes are SoundStorm's own
state.Progress, keyeduserID/sourceID/itemID. A video's location is its time (t=1234.5), so adding video did not change the state format. - Audiobooks belong to Audiobookshelf, per person, through the per-member
account - so a book started in its own phone app shows up here.
source.InProgressListeris the optional interface for a backend that remembers; for Audiobookshelf it is/api/me/items-in-progressplus the fractions in/api/me, leaving out finished, hidden and missing books and podcast episodes.
A position under half a percent is "not started" and over 98.5% is "finished"; for a film or
episode the top is 93%, because the credits are the end. source.ItemGetter turns a
stored id back into a card, through the registry - so a shelf an account can no longer see drops
out of the row.
HasBook for local books, the cached HasItem ownership check for
Jellyfin).Genres
GET /api/genres?kinds= groups the shelves the server already lists and caches,
rather than asking each backend for its genre list. Every item carries its genre in
Extra["genre"] (from Navidrome, Jellyfin and Audiobookshelf; an EPUB's subjects as
tags). Audiobookshelf's are often several joined with commas ("Action & Adventure,
Dystopian"), so they are split, and grouped as one genre per name whatever its case. A genre's
items come back with &name=.
Authors, series and Read Along
The Books tab looks across two shelves at once - ebooks and audiobooks - so these views are
built in the server from both listings (listBookShelves, through the registry so
access applies) and grouped on each request. Nothing is stored.
Authors and series
GET /api/books/authors groups on a key that reads "Herbert, Frank" and "Frank
Herbert" alike, shows the reading form, and sorts by surname. A comma is a list (Audiobookshelf
joins co-authors with one) unless one side of a single comma is one word - the sort form.
GET /api/books/series comes from Calibre's series and index or Audiobookshelf's
series name, which carries the number ("Dune #2"). With ?key= either returns one
author's or series' page: an author's series in order, then their other books.
Read Along pairing
GET /api/books/pairs lists what somebody has as both an ebook and an audiobook.
It matches on a key with the edition noise taken out - anything bracketed (an ASIN,
"Unabridged", "Full-Cast Edition"), a subtitle after a colon, a trailing "Book 1", curly quotes,
punctuation, a leading article - plus a shared author surname. Each edition of an audiobook is
its own pair. On a real library, 1,663 ebooks and 113 audiobooks gave four pairs, and a scan for
same-author near-misses found none.
The owner can correct it: Not the same book leaves a pair out for everyone
and deletes what Storyteller made of it (kept as notPairs in the state file - a
decision, not a fact about the media), and Pair with its audiobook makes a match
by hand (manualPairs). What happens to a pair next - syncing the text to the
recording - is on the books page.