State and data
SoundStorm started out stateless and had to learn to remember. What it remembers is kept small and deliberately shaped: credentials, accounts and decisions in one file, each person's lists in their own, rebuildable caches beside them - and never a fact about the media, which belongs to the backends and the folders.
The line that matters
Per-person things that are SoundStorm's own - favorites, playlists, film positions - are stored as references (a source and an id, with a snapshot of how the backend described the item), and are filtered through the registry each time they are shown, so a deleted song simply fails to play and a shelf an account has lost drops out.
Where everything lives
| Where | What | If lost |
|---|---|---|
state.json | Backend credentials, accounts, sessions, reading positions, a few decisions | Cannot be regenerated - the backends hold accounts whose passwords nobody else knows |
collections/<user>.json | Favorites, playlists, history counts, preferences, scrobble queue | That person's lists |
collections/listens/ | Every play, with its moment, one file per person per year | The year in music |
collections/art/<user>/ | Covers people chose themselves | Those choices |
beats/, lyrics/, discover/ | Caches: song analysis, LRCLIB answers, artist info | Nothing - rebuilt |
tls/ | Local authority, server certificate, Let's Encrypt certificate | Devices that installed the authority must install it again |
training/ | The developer's own taps for training the looks (one install only) | Recordings |
| Library folders | The media, plus hidden .uploads, .trash, .imports | The media - which nothing in SoundStorm ever deletes outright |
| Backend volumes | Each backend's own database, thumbnails, transcode cache | Re-provisioning, or a lost password |
| The browser | Downloads, the offline shell, small per-device settings | That device's downloads |
Everything in the first seven rows lives under the state directory,
SOUNDSTORM_STATE_DIR, which is /var/lib/soundstorm in the container,
backed by the soundstorm-state named volume.
state.json
Owned by internal/state, version 3 of its schema. It holds:
- Backends - the generated admin credentials for each media server.
- Users - accounts with a PBKDF2 hash (600,000 rounds), a role (owner or
member), which media kinds they may see, their photo limit, an optional PIN for switching to them
on a shared device, and whether they must choose a new password. An empty
librarieslist is written as[], never dropped: read back as nil it would mean every library, the one way this field must never fail. - Identities - per-person accounts on backends that need them (Audiobookshelf for listening positions, Immich for each member's own photos).
- Sessions, keyed by a SHA-256 hash of the token. They used to be keyed by the token itself, which made a backup or an abandoned volume enough to sign in as anyone; the migration hashed the keys in place, so nobody was signed out. Capped at 50 per account.
- Progress - ebook positions (an EPUB CFI) and film and episode positions
(
t=1234.5), kept only for items that exist and rate limited. - Setup secrets - each password, key and token a backend's setup makes, kept before the backend is told it, so a setup that fails half way finishes on the next attempt; cleared once the backend is saved.
- Devices - who may be switched to on each shared device ("Who's listening?"), keyed by a hash of the device's profile cookie.
- Decisions: the starter library has unpacked, new devices need approval, remote access on or off,
online lyrics and discovery allowed, read-along syncing manual, wrong read-along matches
(
notPairs) and hand-made ones, and the key that signs device cookies.
The file is rewritten whole, to a temporary name and renamed, on every sign-in and session
change, under a lock every request takes. That is why the large per-person data is
not in it, and why every write path into it is bounded. Each save also leaves a
.bak, which covers a bad write and nothing else.
state.Open migrates and saves every time it opens the file, so any
process that opens it as another user takes ownership of it - and the server then crash-loops on
"permission denied". The password-reset and restore commands stat the file first and hand
ownership back; backup uses the read-only state.Inspect and
state.CopyTo instead.Each person's collections
internal/collections keeps one small file per person: favorites (up to 5,000),
playlists (200, of up to 5,000 songs each, 25,000 in all), listening history (count, first and last
play, 5,000 songs), preferences such as the pill order and Now Playing look, and the ListenBrainz
queue. One change writes one small file. Removing a person removes their file, art and
listens.
Favorites and playlists are SoundStorm's rather than Navidrome's, although Navidrome has both:
the house shares one Navidrome account, so lists kept there would be everybody's at once. The same
reasoning keeps film positions in state.json rather than in Jellyfin.
Listens are append-only JSON lines, one file per person per year
(listens/<user>-2026.jsonl), capped at 32 MB a year. Recording costs one
small write; a damaged last line from a crash is skipped.
Art is pictures people chose for a song, an album or a playlist, cropped and shrunk on the device, named by content so one picture for an album is stored once; 5,000 covers, 1,000 pictures and 300 MB per person, served only to its owner.
Caches
beats/- what the server heard in each song (beats, loudness, hit lanes, about 11 KB a song), made by a background pass. A cache that can always be made again; never written into the music folders.lyrics/- every LRCLIB answer, "none" included (re-asked after 30 days). Only when the owner has turned online lyrics on.discover/- MusicBrainz, ListenBrainz and Wikipedia answers, kept for months. Only with discovery on.
In memory only: shelf listings (30 seconds), slim song layouts (an hour), shrunk covers, and a Plex token during an import (30 minutes, never written down).
The library folders
The media is the person's, in plain folders: music/, movies/,
tv/, audiobooks/, ebooks/, documents/ and
pictures/, under SOUNDSTORM_LIBRARY_PATH. Three hidden top-level
folders sit beside them, hidden for the same reason: no backend has a top-level dot-directory
mounted, so whatever is in them is invisible to every scanner.
.uploads- bytes of an upload in progress, renamed into place only once complete, so no scanner ever indexes half a file. Swept at boot..trash- deleted items, kept 30 days with anentry.jsondescribing them, so Undo can put them back. Owner only..imports- Google Takeout and iCloud zips being uploaded and sorted into a person's photo folder, deleted once sorted.
A README.txt in each shelf is load-bearing: Jellyfin refuses to remove items when
a library folder comes back empty (it cannot tell "everything deleted" from "the drive did not
mount"), so an empty folder would keep deleted films searchable for ever.
The backends' own volumes
Each backend keeps its database and caches in named volumes - navidrome-data,
jellyfin-config, abs-config, immich-db and so on. These are
the backends' business; SoundStorm never reads them. Immich's Postgres in particular must sit on a
named volume, because it must not live on a network share or an NTFS drive. Caches and downloaded
models (jellyfin-cache, immich-models, storyteller-models)
are left out of a move to another computer.
The browser and the apps
- Downloads live in the Cache API (
soundstorm-offline-v1), with an index in localStorage, keyed by the address the app would ask the server for. Songs, books, films (as a file, or as an HLS playlist and its pieces) and photos. - The offline shell (
soundstorm-offline-shell-v1) lets the app open with no connection, only on*.soundstorm.devnames with a trusted certificate. - Per-device settings in localStorage: streaming quality, film quality, visualizer timing, the station tuner, keep-the-screen-on.
- Heard songs, the last 500 analyses, so a song played before follows its beats from the first second.
Signing out clears downloads; the device also remembers whose they were and clears them for anybody else who signs in.
Backup and restore
soundstorm backup writes the state file plus a collections field with
everyone's lists. A field rather than a wrapper, so a backup still is a state file: an older
SoundStorm restores the accounts and ignores the lists, and with no lists the backup is the state
file byte for byte. Pictures and listens are not included; moving to another computer copies the
whole state volume instead.
(umask 077; docker compose run --rm -T soundstorm backup - > soundstorm-backup.json)
docker compose run --rm -T soundstorm restore - < soundstorm-backup.json
docker compose up -d
With - the backup goes to standard output, so the host shell creates the file as
the user, under umask 077 - a file written through a bind mount by the container's
own user would be unreadable to whoever asked for it on Linux (Windows, where Docker Desktop
presents bind-mounted files as the user's, uses a bind mount and a locked-down ACL instead).
backup - refuses a terminal so
credentials never scroll past on screen. Restore checks the lists before touching anything, so a
damaged backup changes nothing. The uninstaller takes a backup before down -v,
the one moment the credentials would otherwise stop existing anywhere.