Favorites, playlists, history

Almost nothing in SoundStorm differs per person: a search returns the same results and a film is the same bytes whoever asks. The exceptions are what a person collects and does - favorites, playlists, what they played and when, their own covers, their preferences. Those live in internal/collections, one file per person.

Why SoundStorm keeps them, not Navidrome

Navidrome has favorites and playlists built in. But the household shares one Navidrome account - an account each would be several times the provisioning for no visible gain - so anything stored there would be everybody's at once. The same reasoning keeps film positions in SoundStorm rather than Jellyfin. The notes had always said "revisit when favorites reach the UI"; this is that moment, answered by keeping them above the backends rather than giving every person a backend account.

A file per person, not state.json

state.json holds credentials, accounts and sessions. It is rewritten whole on every sign-in and session change, under a lock every request takes. A household's playlists in it would slow all of those. So each person has a collections file of their own: one change writes one small file, to a temporary name, renamed over.

LimitValue
Favorites5,000
Playlists200, up to 5,000 songs each, 25,000 songs in all
Names100 characters
Listening history5,000 songs (count, first and last play)
Own covers5,000 covers, 1,000 pictures, 300 MB

Writes are rate limited per person (a burst of 60, then one a second) because the file is rewritten under a lock everyone shares - one member scripting changes must not slow the house. Removing a person removes their file.

Snapshots, not ids alone

Each entry is a snapshot of the item as its backend described it, fetched through source.ItemGetter - never what the browser sent. That makes a list of 500 songs one file read rather than 500 backend calls. Playing still goes by id, so a deleted song fails to play rather than playing something else. Lists are filtered through the registry every time they are shown, so a favorite on a shelf an account has since lost is hidden: a list is not a way back in.

Favorites are for anything; playlists are songs only. A playlist plays as a queue, and a playlist of films has no player to play it in.

Playlists: order and sort

A playlist holds each song once. Adding one already there is a 409 ("already in this playlist"); adding several skips those and says how many. Each playlist has its own sort: A to Z (the default, stored as nothing), Artist, Recently added, or Custom order. Items always keeps the order the songs were added and dragged into, whatever the sort, so choosing Custom again brings a hand-made order back. The server returns songs already sorted (Playlist.Ordered), each carrying its position in Items for removing and moving. Removing has an Undo: the add endpoint answers the new count, so the song goes back and is moved into place.

A playlist can have a picture of its own, stored like a song's own cover under the key playlist:<id>, accepted only for the person's own playlist and deleted with it.

Importing M3U

Asked for as bringing somebody's playlists across from another player. Most players can write an M3U, so POST /api/playlists/import takes one as the request body (the app decodes UTF-8 strictly, else Windows-1252, for old iTunes files). Each song is found on the music shelf:

  1. By the tail of its path - the last three, two or one segments - so the same files under another root match. source.SongFileLister answers from the folder view's cached song list with real paths.
  2. Else by artist and title from #EXTINF (or read off an Artist/Album/01 Title path), with case, punctuation, a leading "The" and a trailing "(Remastered)" set aside, and the length choosing between versions.
  3. A file name alone, or a title alone, must agree on length when one is given.

What is not found is named back to the person, never guessed. A file becomes one playlist in one write, in the file's order. Names are cut to 300 characters before matching: stripping trailing brackets one pass at a time over a title of thousands of "()" once pinned the CPU for minutes.

Importing straight from Plex

Plexamp keeps playlists on the Plex server, which has no export button - so the M3U route meant a computer and a script. internal/plex lets a phone do it. SoundStorm asks plex.tv for a PIN, the person signs in on Plex's own page in a new tab, and the app polls /api/plex/status until plex.tv hands over the account token. The server then lists the account's servers, finds an address one answers on (home network first, Plex's relay last), reads the audio playlists, and runs them through the same matching as M3U, trying both track and album artist.

The token lives in memory, per person, for 30 minutes of use. It is never sent to a browser or written down, and nothing talks to plex.tv unless somebody taps. A server's addresses are plex.tv's say-so - which means anyone with a Plex account could publish internal addresses - so only scheme and host are kept, eight at most, and each is checked again as it is dialed: loopback, link-local, carrier-grade NAT ranges, Docker Desktop's host range and any network the container is on are refused. A home-network Plex server is not.

Tested against a stand-in plex.tv and Plex server through the real routes; not yet against a real Plex account.

Covers of one's own

"Change cover" on a song or album keeps a picture per person (collections/art.go, /api/myart), cropped square and shrunk to 1000px on the device, stored under the state directory's art/<account>/ and named by content, so one picture for an album is stored once. Keys name what is replaced: song:<source>/<id> or art:<source>/<art id> with Navidrome's version suffix dropped, so an edited file keeps the choice. Only JPEG, PNG and WebP are accepted, judged by their bytes (never SVG), up to 4 MB; pictures are served only to their owner, sandboxed and nosniff; unused ones are deleted.

Listen logs

History keeps a count per song, which says what but not when. Every play is now also logged with its moment (collections/listens.go): one append-only file per person per year, a line per play carrying what the song was. Recording costs one small write; a year is one file. A play counts at half the song or four minutes. A damaged last line - a crash mid-write - is skipped. Plays are rate limited (a burst of 20, then one per 10 seconds, quietly dropped past it) and a year's log stops at 32 MB, so a client cannot grow the disk without bound.

Scrobbling to ListenBrainz

ListenBrainz needs no app key, so each person pastes their own user token in Settings. It is checked with validate-token - which answers 200 with valid: false for a bad one, checked live, so the status code alone proves nothing. Plays are sent from the server, so every device counts and the token never reaches a browser; the API never returns it.

Last.fm is not built: it needs an API key and secret registered to the project.

The year in music

GET /api/recap powers a story of slides: minutes, plays, top artists, songs and albums, genres, a month chart, the busiest weekday, a listener kind from the peak hour, the longest streak, artists new that year and the first song - and, with the sound analysis, the share of plays whose strongest mood was each. For this year, any year with listens, or all time.

The app sends its time-zone offset, because the server runs in UTC and a 10pm play belongs to its own evening; a local year reads the UTC files either side. All time comes from History's counts, so it says something from the first day.

Preferences

Settings that should follow a person rather than a device - the order of the category pills and which are put away, the Now Playing look, audiobook speed, the read-along highlight - are kept in the same file behind GET/PATCH /api/prefs, bounded. One subtlety: a row with no saved list means "defaults", which hide Genres, so an empty list is stored as an empty list, never null - otherwise "I added Genres back" would read as "defaults" and hide it again.

Backup

Collections travel in soundstorm backup as one more field, collections, keyed by account id, beside the state. A field rather than a wrapper, so the backup still is a state file:

Restore validates the lists before touching anything, so a damaged backup changes nothing, and gives the list files the same owner as the state file. Own-cover pictures and listen logs are not in the backup; moving to another computer carries the whole state folder, so they go with it there.