A Lossless Music Library That Lives on My Own Server
The service that was overdue
The laptop hums in the corner, the data is backed up, and I’d stopped reaching for cloud apps out of habit. So the server kept doing what it keeps doing and earned one more service, and this one had been waiting longer than any of the others. Music is the hobby I’ve put the most hours into, and somehow it was the last media I hadn’t brought home. Time to fix that.
The push came from a post I’d already written. A while back I built automating music discovery with an LLM, a little pipeline that turns my own listening history into new tracks and downloads them to carry around. That’s the hunt, the part I love. But the tracks it found had nowhere permanent to land. They drifted onto an iPod, or worse, back into Apple Music. Discovery finds the music. I needed something to actually own and serve it.
What this solves
- Gives the tracks my discovery pipeline finds a permanent, lossless home.
- Replaces Apple Music with FLAC playback I control end to end.
- Lets me set custom start and end times per track, which no app supports.
- Fits the existing
/srvlayout and backups with no special handling.
Why music was the gap, not the start
Audio wasn’t new to the server. Back in part 4 I set up Audiobookshelf for audiobooks and podcasts, so spoken-word audio already had a home. Music just never made the list. It lived in Apple Music, which I’d quietly grown to resent for a few specific reasons.
Apple Music has no real FLAC support and re-encodes what you add. It gives you no control over how a track plays. And the whole library is a license inside someone else’s ecosystem, not a folder of files on hardware I own. None of that is a crisis. It just runs against the entire point of this project, which is that the easy default should lead to things I control. So music finally earned its spot.
The design, before any commands
The stack is four small pieces, and the design matters more than any single install step. Beets organizes incoming files and tags them. The clean originals get preserved untouched. A tiny ffmpeg script generates a parallel, trimmed copy of the library. Navidrome serves those trimmed copies, and Arpeggi plays them on my phone. One direction of flow, no juggling.
Everything lives under the /srv split I committed to back in part 5: user media under /srv/storage, app config and data elsewhere. So the music sits at /srv/storage/music, divided into two folders that do all the interesting work:
/srv/storage/music/
├── originals/ # beets-managed, the untouched source of truth
└── trimmed/ # generated copies, the only folder Navidrome sees
That split is the whole trick, and I’ll come back to why.
Navidrome, the jukebox that stays out of the way
Navidrome is the music server: a single Go binary that scans a folder, exposes the Subsonic API, and otherwise asks nothing of me. It runs the way every long-lived service in this series runs, as a dedicated user under a small systemd unit, so it comes back after a reboot.
The config is four lines of TOML, and the one that matters points at the trimmed folder, never the originals:
MusicFolder = "/srv/storage/music/trimmed"
DataFolder = "/var/lib/navidrome"
Port = 4533
LogLevel = "info"
[Service]
User=navidrome
ExecStart=/opt/navidrome/navidrome --configfile /etc/navidrome/navidrome.toml
Restart=on-failure
There’s no upload feature, which I like. Files arrive in the folder by other means, Navidrome notices on its next scan, and FLAC plays untranscoded on the LAN. It’s a reader, not an inbox.
Beets keeps the metadata honest
Before Navidrome ever sees a track, Beets has already cleaned it up. Beets is a command-line organizer that matches each file against MusicBrainz, writes correct tags, fetches album art, and files everything into a tidy Artist/Album/Track structure. Adding music is essentially one command:
beet import /srv/staging
It walks me through any ambiguous matches, then moves the finished files into /srv/storage/music/originals. That folder is the source of truth, and Beets is the only thing allowed to write to it. It never touches the trimmed copies. This is where the tracks from the discovery pipeline finally settle down: messy downloads in, clean lossless library out.
The interesting part: trimming tracks no client can trim
Here’s the piece I actually find satisfying, and the reason this post exists at all. I want some tracks to start a few seconds in, or end before a long dead-air outro. A cold open, a skipped intro, a clean cut. The problem: no Subsonic-compatible server or client supports per-track playback offsets. The API has no concept of it, so it can’t be bolted on at the app level either.
So I stopped trying to do it at playback and did it at the file instead. A small Python script reads a JSON list of tracks with optional start and end times, then uses ffmpeg to write trimmed copies. For FLAC the trim is lossless and near-instant, because -c copy rewrites the container without re-encoding the audio. The config is plain to read:
[
{ "file": "Artist/Album/01 - Track.flac", "start": "0:32", "end": "3:45" },
{ "file": "Artist/Album/02 - Another.flac", "start": "0:08" }
]
The script does two things in order. First it mirrors originals into trimmed with rsync, so the two folders match. Then it walks the config and overwrites just the listed files with their trimmed versions, the core of it being one ffmpeg call:
ffmpeg -y -i "$src" -ss "$start" -to "$end" -c copy "$dest"
That’s the clever bit, and it’s almost embarrassingly simple. The originals stay pristine and untouched. The trimmed folder is fully disposable, regenerated from scratch any time I want. Navidrome only ever sees the trimmed copies, so every client gets my edits for free, with no app dependency and nothing to configure on the phone. A workaround, sure, but the kind that’s more robust than the feature it replaces.
Playing it on my phone with Arpeggi
For iOS I landed on Arpeggi, a native SwiftUI Navidrome client. It browses the library, downloads albums for offline listening, and connects to the server with just a URL and my Navidrome login. On home Wi-Fi that URL is the server’s LAN address.
What it costs to keep
Every service has to earn its ongoing cost, and this one is cheap to live with. Because the whole library sits under /srv/storage, it’s already inside the backup job from part 11 with zero extra config. And I only really need to protect two things: the originals folder and the small trim_config.json. The trimmed folder doesn’t matter at all, since one run of the script rebuilds it. That’s a genuinely calm thing to back up.
The daily rhythm is short. Drop new files in staging, run beet import, add a trim entry if a track needs one, run the trim script, and let Navidrome rescan. The track shows up in Arpeggi on the next sync. Lossless, mine, and exactly the length I wanted it to be.
What’s next
This one closes a real loop I’d left open for months: the discovery pipeline finds the music, I purchase the songs I like, and now the server owns and plays it, in FLAC, on my terms, with no label on the file that says I’m only renting it.
For the full map of how the whole thing came together, the series index collects every part in order.