A request, end to end

Two journeys through the system: somebody on a train pressing play on a song in the Android app, and a search typed on Home that asks every backend at once. Each step names the package or function that does it, so the rest of the site can be read against this one.

Pressing play, away from home

1. The phone already knows it is away

The app is pointed at the install's away-from-home name (<id>.net.soundstorm.dev, or a Tailscale ts.net name). On opening, the page times 128 KB from /api/probe once a session; under 1.2 Mbps it starts songs at 128 kbps from the first one, and remembers a slow address for six hours. Otherwise it asks for the original.

On Android the page does not play the song itself. PageScript gave the page's <audio id="audio-player"> a stand-in, so setting its src becomes an "audio" message to NativeAudio, which hands the URL to Media3's ExoPlayer in AudioService. The native player carries the web view's cookies (ResolvingDataSource), so to the server it is the same signed-in person. Doing the playing natively is what lets Android treat the app as a music player: it survives the screen going off, pauses for an alarm and resumes after, and gives the lock screen its controls.

2. One port, two protocols

The published port speaks both plain HTTP (for http://localhost:8099 and a first visit by LAN address) and TLS (for the trusted name). servetls.Listener peeks at the first byte: a TLS handshake starts with 0x16 and no HTTP request does. It hands net/http a real *tls.Conn, not a wrapper, because that type check is what fills in r.TLS - and r.TLS is what makes the session cookie Secure. The certificate is a real Let's Encrypt one obtained through the names service, or the local authority's as a fallback.

3. The outer middleware

Every route, signed in or not, passes through the same chain built at the end of Routes(): logging, security headers, sameOrigin (cross-site writes refused on origin, not merely site, because every install shares soundstorm.dev) and bodyDeadline. There is deliberately no global read or write timeout: a deadline that expired mid-response would cancel the request's context, and a film would stop with it.

4. Who is asking, and what may they reach

auth.Require finds the session. Over TLS the cookie is __Host-soundstorm_session, which a browser refuses to accept with a Domain - so a server under another install's name cannot plant a cookie for the whole zone. The server looks the token up by its SHA-256 hash; the raw token is never on disk.

Then withUserContext puts two things on the request context: source.WithUserID (so an adapter can act as this person without depending on how sign-in works) and source.WithAccess, the set of media kinds this account may see.

The restriction lives in the context, and Registry.All, Matching and ByID all take a context - so a handler physically cannot reach a source without passing the request's permissions along. When those signatures changed, the compiler found every one of the ten call sites; remembering to check in each handler would not have. A test then walks every endpoint that can hand over bytes, metadata or a playable URL and asserts a restricted account is refused - /api/stream included, because hiding a search result is not a permission if the URL can be guessed.

5. Finding the source

handleStream reads ?kbps=, honouring only 96, 128, 192, 256 or 320 so a request cannot ask the music server for anything odd, and calls stream.Proxy.ServeMedia. That asks Registry.ByID(ctx, "navidrome") - which answers "unknown source" for an account without music - and checks the source implements source.Streamer.

6. The adapter builds the upstream request

The Subsonic adapter's StreamTarget returns a source.Target: a URL for /rest/stream.view with SoundStorm's own Navidrome credential in the query (that is the Subsonic protocol), plus headers. For a reduced bitrate it adds maxBitRate, format=mp3 (Navidrome's default, Opus, is patchy on iPhones) and estimateContentLength=true, which gives the converted stream a length and Range support. A Target can also be a local file or bytes in memory; for Jellyfin it carries an OnDone that stops the transcode when the player goes away.

The credential never reaches the device. Transport errors pass through httpx.Redact before they can be logged or shown, because a Subsonic URL carries its credential.

7. The proxy: slim, paced, seekable

Slim headers. An iTunes M4A carries roughly 600 KB before its first note, and a browser's media engine treats an embedded cover as a second stream and reads megabytes ahead before playing. Measured at 0.6 Mbps on a real song: 34 seconds to start as it was, 2.5 seconds with the tags and artwork removed. So serveSlim builds a header in memory - the moov without udta/meta, padding dropped, every stco/co64 chunk offset shifted - then fetches the original's audio from Navidrome by byte range. For an MP3 it starts at the first frame, skipping the ID3 tags. The files on disk are never changed.

Range. Any byte range of the slim file maps onto the original, so seeking still works (parseRange). A layout is cached per song for an hour, one fetch shared among concurrent requests.

Pacing. Over a slow link, handing a whole song to the network at once fills every buffer between server and phone, and everything after it - a skip, the next song, a cover - waits behind bytes nobody will play. So a request arriving by an away-from-home name (awayFromHome) is paced (paceFor, pacedCopy): a burst of the header plus eight seconds of audio, then 1.5 times the song's own bitrate, read from the file. At home nothing is paced, and nor are films.

Guards. Every stream path refuses a Sec-Fetch-Dest of script, worker or style (RefusedDestination), and GuardActiveContent demotes any script content type to text/plain. Audio is sent no-store, because a cached song once made the next song wait exactly 20 seconds on a browser cache lock.

8. Back on the phone

ExoPlayer plays; its state comes back to the page as the element's own events every half second, so lyrics, the timeline and the visualizers keep reading currentTime. A few seconds into the song the page hands the native player the next ten songs, with their titles and covers, so playback carries on gaplessly even if Android ends the page in the background. At half the song (or four minutes) the page records a play, which also queues a ListenBrainz scrobble if the person connected one.

Typing "dune" on Home sends GET /api/search?q=dune. The handler builds a media.Query and calls federate.Search(ctx, reg, query, perSourceTimeout).

Fan out, with a deadline each

Registry.Matching returns only the sources this account may see that serve the asked-for kinds. Each runs in its own goroutine with its own deadline - SOUNDSTORM_PER_SOURCE_TIMEOUT, 5 seconds by default - and its own error slot. A panic in an adapter is caught by recoverInto and reported as that one source failing, the same shape as an error.

The answer is always 200. If Jellyfin is still starting, the result carries degraded: true and says which source is missing, and the books and songs still come back. A dead backend never takes the search down.

Each adapter speaks its own dialect

The backends disagree even about an empty query: Navidrome's search3 returns everything, Jellyfin needs searchTerm omitted rather than blank, and Audiobookshelf's search matches nothing, so browsing uses its items endpoint instead. Each adapter returns media.Items, the shared vocabulary.

Merge, rank, and page correctly

federate.Relevance scores each item against the query and sortItems merges them; with an empty query everything scores 0 and the list falls through to alphabetical. Infinite scroll asks for ?offset=, and the merge asks every source for offset + window items and slices after merging.

Asking each backend for "its page two" looks cheaper and is wrong: each source's second page starts over in its own order, so items sort in behind ones already on screen. That showed up as 1,760 repeated songs in 40 pages of music, because Navidrome lists songs in an order of its own. Now there is one comparator, media.Less (sort key, then title, then id), browsing adapters fetch their whole shelf and order it with that, and media.ShelfCache keeps the listing for 30 seconds so a scroll fetches a shelf once. After the fix: 2,000 distinct songs, 0 repeats, and the same 40 pages in 3.2 s instead of 28.6 s.

Photos are the one deliberate exception: they come newest first, Immich's own order, and each photo's SortKey is its rank in it. HasMore treats a source that returned exactly what was asked for as probably truncated, and paging stops at a depth of 10,000.

Back in the app

Cards draw with covers from /api/art/..., which come card-sized (400 px by default) unless the big Now Playing cover asks for more. Tapping a result goes back down the first journey.