The library and dropping files in

The library is a folder with a subfolder per kind of media. It is the one part of SoundStorm a person can touch without the app, so it is created for them, named for what people call things, and kept tidy by the server. Dragging files onto the window does what dragging them into the right folder would have done - including working out which folder that was.

The folders

Installing SoundStorm creates library/ with music/, movies/, tv/, audiobooks/, ebooks/, documents/ and pictures/ - "movies", not "video", because that is the word people use. Each backend has exactly one of them mounted (read-only wherever it can be), so nothing scans the same folder twice. SOUNDSTORM_LIBRARY_PATH in .env moves the whole library, usually to an external drive, and every mount in the compose file reads the same variable so the shelves cannot drift apart.

internal/library owns the folders. It reads the directory for very few reasons - creating it, counting files so the UI can tell "nothing added yet" from "a scan is still running", and placing uploads. It does not index. That line matters: the moment it keeps a list of what is on disk, that list can go stale, and the backends already keep one each.

Each folder holds a generated README.txt. It is documentation, and it is also load-bearing: Jellyfin refuses to remove items when a library folder comes back empty (it cannot tell "everything was deleted" from "the drive did not mount"), so a folder that is never empty is a folder where deletions are noticed. See telling the backends to look.

The shelf layout is enforced

Music is always Artist/Album/track, audiobooks always Author/Title/part, and ebooks Author/Title/book. A loose track at the top of music/ is not broken - Navidrome reads tags, not paths - but it is exactly the mess the folders exist to prevent, and it accumulates one file at a time.

Always exactly two levels - a reversal

The first rule only filled in missing levels and left anything already two folders deep alone, on the theory that a folder somebody chose beats a tag. Then a drop of an audiobook tool's Books folder - Books/<Title> [ASIN]/<file>.m4b - filed ninety-four audiobooks under an author called "Books". Whatever sits above an album in a drop is at least as often a container as a name. So now:

Ebooks follow the music rule

Ebooks used to be flat, which was fine while nothing read the folders - until a Calibre library arrived as Author/Title (id)/ with its metadata.opf and cover.jpg, and every other upload landed loose beside it. Flattening it would have parted over sixteen hundred books from the sidecars that carry their curated metadata. So ebooks take the folder above first (a Calibre author folder is curated, while the name inside an EPUB is often the sort form, "Herbert, Frank"), then the book's own metadata, then the file name - which for a PDF is often all there is ("Title - Author (2017).pdf").

What describes itself is decided per shelf, not per extension. A PDF is a book on the ebook shelf and a companion on the audiobook one; counted as self-describing everywhere, an Audible PDF would read its own metadata and land under a different author from its m4b.

Not restructured

Films and television keep their dropped shape: Jellyfin matches on the name, not the depth, and anything dropped there is already folder-shaped. Documents keep theirs too - a tax form has no author, and Taxes/2024/ is exactly how somebody finds it again. Pictures are sorted by date into each person's own folder, which has its own page.

Reading just enough

internal/tags reads album artist, artist, album and title - from ID3v2 in MP3, iTunes atoms in M4A/MP4 and Vorbis comments in FLAC - and nothing else. It is not a tag library and must not become one: duration, artwork and replay gain belong to the backends, which are much better at them. Three details were verified against real files rather than reasoned about:

Tags are somebody else's text, so they go through cleanRelPath like any path. Separators are replaced rather than refused - an album really is called "AC/DC Live" - and ../../etc/passwd as an album name becomes the single harmless segment ..-..-etc-passwd.

Two steps: plan, then save

A drop is not one multipart request. POST /api/upload/plan takes the list of paths and answers where each would go; PUT /api/upload?path=&kind= takes one file as the whole request body.

The split buys three things a streamed multipart upload cannot have: the UI can say "14 files, 2 skipped, all going to Films" before a gigabyte moves; the grouping can see the whole list at once; and a body that is nothing but the file means no parser between the socket and the disk.

Plan and Save share the rule. Plan runs it with empty tags because the bytes have not arrived; Save runs it again with the file's own. They agree for an untagged file, and where they differ Save is better informed, never worse. So the drop panel replaces its planned destination with the one each upload returns.

The staged file is called something like part-123456789, so anything that looks at a name - the PDF title parser, the duplicate check's format - must use the dropped name. Twice a feature first read the staged name and got it wrong; both are now tested.

On the browser side, walkEntry must call readEntries until it returns an empty batch. It hands back at most a hundred at a time, and reading once silently truncates a large folder - the classic way to lose half an album.

Placement is per dropped item

A film folder holds an .mkv, a .srt and a poster. The subtitle is useless in the ebook shelf and invisible anywhere but beside its film. So the first file in a group that can name a shelf decides for all of them, and companions - subtitles, artwork, .nfo, .opf - get no vote and inherit the answer. A folder of nothing but companions names no shelf and is skipped: a lone .srt has no home.

Some evidence is decisive: .epub is a book, .m4b an audiobook, a path mentioning audiobooks is believed, and S01E01, 1x02 or a Season 01 folder is television. A group of only images is pictures; images beside audio or a book are artwork. A video named the way a camera names it (MVI_, PXL_, GOPR, a date and time) is a clip for the photo shelf even on its own - the first test filed a camera's .MOV as a film.

When it cannot tell, it asks

There is one drop zone and no shelf targets. Guessing wrong costs somebody moving files on disk, so the bar for guessing is "there is real evidence", not "one of them is more likely". The list of things worth asking about is deliberately short, because a question on every album drop would be worse than the occasional wrong guess:

CaseWhy it is ambiguous
mp3, and only mp3flac, wav and alac are music in practice; m4a is music because audiobooks use m4b. Mp3 really is both - every LibriVox recording is one.
Three or more videos in a folder with no episode numberingOne or two unnumbered .mkv files is a film and its extras; six is a series somebody named badly.
A loose PDF with no evidenceA PDF is a book or a gas bill. Beside a metadata.opf or under Books it is a book; under Taxes or Manuals a document. Otherwise asked, once per dropped folder.

A question is asked per group, so a thirty-chapter audiobook is one decision, and the answer goes back to the plan endpoint, which re-plans - the destination shown is always the one the server will use. A question offers only shelves the account may see, and with one option left it stops being a question. Uploading follows the same permission as reading.

Nothing appears until all of it is there

Navidrome and Audiobookshelf watch these folders, and a half-written file is exactly what a scanner indexes as a corrupt track. Bytes land in library/.uploads, a top-level dot-directory no backend has mounted, and are placed once complete. ClearStaging sweeps it at boot for the crash case.

The same recording under another name

An iTunes library keeps repurchases side by side: 03 Heathens.m4a and 03 Heathens 1.m4a. Measured on a real library before building anything: of nine such pairs, none were identical files - iTunes writes a catalog id, a purchase date and its own copy of the artwork into each - and five had identical audio.

So duplicate.go fingerprints audio by the audio alone: the mdat of an MP4, the frames of an MP3 between its ID3 tags, the frames of a FLAC after its metadata blocks. A file whose structure does not parse is compared whole, which errs toward keeping both. A clean and an explicit version differ in their audio and are both kept.

The scope is deliberately narrow: only the destination folder, only the same format, only uploads, only music and audiobooks, and only siblings of exactly the same audio length are ever hashed. The same song on an album and a compilation is not a duplicate. A skip comes back as a 409 "already in your library", which the drop panel shows as skipped, not failed.

Every path is attacker-supplied

One trap cost a real bug, caught by its own test: TrimRight(segment, ". ") ran before the .. check, so ".." became "" and was skipped - quietly turning ../../etc/passwd.mp3 into etc/passwd.mp3. No escape, but a strange file in somebody's music folder. Dot runs are now refused before anything is trimmed, and Save re-checks that the joined path is still inside the folder - worth doing twice. Save also applies the plan's file-type test itself, because the upload endpoint can be called without a plan. Filesystem errors are stripped of the container's absolute paths before they reach a browser.

Deleting, into a bin

The owner - and only the owner, since everyone else shares these shelves - can delete from an item's menu. The server first answers a preview ("1 file, 321 KB. It stays in the bin for 30 days"), then moves the files to library/.trash/<entry>/files/<path> with an entry.json saying what they were. Undo puts them back; a daily sweep empties entries older than library.BinKeep (30 days). Undo never overwrites: a path whose place has been taken since stays in the bin and is reported.

Which files an item is is normalization at the edge again: source.FileLister asks each adapter, because Navidrome, Audiobookshelf, Jellyfin and Immich each report paths differently. library.Resolve then decides what goes with it: a folder goes whole; a file takes its same-named companions (Dune.mkv takes Dune.en.srt but not Dune Part Two.mkv); and when nothing of the shelf's kind would be left in the folder, the folder goes instead, so the last track of an album does not leave its cover behind. Emptied parent folders are removed up to, never including, the shelf.

Free space

Free space is read from the filesystem the library is on (statfs). On Docker Desktop for Windows that is the real drive, not Docker's disk - measured, not assumed. Low is under 25 GB and under a fifth of the disk (the fraction keeps a small card that is simply small from warning forever); critical is under 5 GB. Uploads always leave 1 GB free, refused up front when the size is known and mid-copy when it is not, because the library is often Docker's disk too, and a backend database that cannot write is a broken server, not a full shelf.

Telling the backends to look

Every backend indexes on its own timer, so an uploaded file could sit unsearchable for up to two minutes. source.Rescanner is the optional "look now" call, checked against each running server: Navidrome's startScan (a quick scan of what changed), Jellyfin's POST /Library/Refresh, Audiobookshelf's per-library scan, and for ebooks simply the scan the ticker would have run. Measured after: an uploaded ebook was searchable in five seconds.

The debounce is the load-bearing part. A dropped album is one upload per file; triggering per file would ask Navidrome to scan thirty times. scheduleRescan keeps one timer per kind and pushes it back on each upload (two seconds), and minRescanInterval floors the gap between two real scans at 30 seconds however many requests ask - the manual "check for new files" button is open to every member. Before scanning, EnsurePlaceholders writes back any missing README, because the scan that matters most is the one right after somebody emptied a folder from their file manager.

Only one of four backends needed no help with deleted files. Navidrome flags them missing and drops them from search; Jellyfin removes them only if the folder is not empty; Audiobookshelf marks them isMissing and keeps serving them, so the adapter filters them out. See each backend's page.