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.

The same goroutine-per-source design that keeps a slow backend from holding up the others meant a panicking adapter would take the whole process down - worse than the dead-backend case the package exists for. 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:

BackendEmpty query becomes
Navidromesearch3.view?query= already answers with everything - no change needed, the opposite of what was expected.
JellyfinsearchTerm omitted, not blank, plus SortBy=SortName - only when browsing, or it would override relevance on a search. Browsing the TV shelf lists series only.
AudiobookshelfIts search matches nothing for an empty q, so browsing uses /api/libraries/{id}/items - same item shape, one wrapper shallower.
Local booksThe folder scan's own list; matches() accepts everything with no terms.
ImmichIts 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:

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.

Photos are the deliberate exception. Nobody browses a camera roll alphabetically, so Immich returns newest first and each photo's 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:

The Continue row

GET /api/continue is what this person is part way through, newest first. Two places know, and neither is new:

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.

Film positions are kept by SoundStorm, not Jellyfin, because the house shares one Jellyfin account: a position kept there would be everybody's - one person's half-watched film in another's row. Per-member Jellyfin accounts would fix that at the cost of a second provisioning path, and buy nothing, since nobody ever sees Jellyfin's own resume. To stop a made-up id growing the state file, a position is accepted only for an item that exists (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.