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
GET /api/stream/navidrome/<id> over TLS ↓/rest/stream.view with SoundStorm's own credential ↓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.
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.
A search across every backend
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.
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.
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.