Self-Hosted Music Stack¶
The music side of the media-vm is four containers that share one folder:
/mnt/md0/apps/jellyfin/media/music (about 207 GB, roughly 6,400 tracks).
Navidrome serves it over the Subsonic API, Aurral and Lidarr feed new music
into it, Pinchflat pulls video from YouTube into the same media tree, and the
metadata work happens before files ever land. This page is the infrastructure
and the config, the way it actually runs.
The layout¶
/mnt/md0/apps/jellyfin/media/
├── music/ # the library — read-only mount into navidrome
└── downloads/ # qBittorrent + Lidarr staging, visible to Aurral
One path, mounted everywhere. Every app that touches music sees the same host folder at the same container path, which removes the remote-path-mapping class of bugs entirely. That convention came from the Jellyfin-arr side of the stack and it carries over.
Navidrome¶
Navidrome is the server. It's a single container, SQLite database, no external dependencies.
services:
navidrome:
container_name: navidrome
image: deluan/navidrome:latest
user: 1000:1000 # should be owner of volumes
ports:
- "4533:4533"
restart: unless-stopped
environment:
ND_ENABLESHARING: "true"
volumes:
- "./data:/data"
- "/mnt/md0/apps/jellyfin/media/music:/music:ro"
The music mount is read-only on purpose. Navidrome never writes to the
library; all tagging happens upstream of it. The ./data folder holds
everything stateful: navidrome.db (plus WAL/SHM), artwork/, cache/,
and plugins/ — about 460 MB on my install. That folder is the whole backup
story; the music itself is backed up separately with the media tree.
Version pinning
This runs :latest and the running version is checked with
docker logs navidrome | grep -i version (currently 0.64.1). The 0.64.1
bump was a security release fixing five vulnerabilities including a
high-severity unauthenticated Subsonic brute-force issue (CVSS 7.4), so
"it's just a music player" is not a reason to skip security patches on
anything that speaks an auth API.
Exposed through SWAG at navidrome.thomasjwilde.com (LAN-only server block)
with a second music.thomasjwilde.com redirect. The Subsonic API lives at
/rest/ under the same domain, which is what every client below talks to.
Clients¶
The server speaks Subsonic/OpenSubsonic, so the client choice is per-device.
| Client | Platform | Open source? | Cost | Source |
|---|---|---|---|---|
| Symfonium | Android | No | ~$15 one-time | symfonium.app |
| Amperfy | iOS | Yes (1.8k stars) | Free | BLeeEZ/amperfy |
| DSub | Android | Yes | Free | daneren2005/Subsonic |
| Ultrasonic | Android | Yes | Free | gitlab.com/oxzica/ultrasonic-old-tree |
| gonic | Server alternative | Yes | Free | sentriz/gonic |
Symfonium is the one I pay for on Android — offline sync, gapless, replay gain, and it handles the OpenSubsonic extensions Navidrome exposes. On iOS the honest answer is that the open-source options are younger: Amperfy is actively maintained (last commit August 2026) and works against Navidrome, but it's a step behind Symfonium on polish. Jellyfin's own mobile app works for music too if you'd rather run one server for everything — see the Jellyfin section below.
Metadata: the part that actually matters¶
A Subsonic server is only as good as its tags. Disc/track numbers,
albumartist vs artist (compilations break without the distinction),
MusicBrainz Release IDs, and embedded cover art are what make a 6,400-track
library browsable instead of a pile of files.
File formats, briefly¶
| Format | Lossy? | Tag container | Notes |
|---|---|---|---|
| MP3 | Lossy | ID3v2 (v2.3/2.4) | Universal; ID3 versions cause tool friction |
| AAC/M4A | Lossy | MP4 atoms (©nam, ©ART) |
iTunes/Amazon heritage; Picard writes these fine |
| FLAC | Lossless | Vorbis comments + pictures | The archive format; embedded covers are native |
| OGG/Opus | Lossy | Vorbis comments | Clean tags, weaker device support |
The practical rule: FLAC for anything you rip yourself, keep what you bought in its native format, and let the tagger normalize the metadata regardless of container.
Manual: MusicBrainz Picard¶
This is the process I've used for years on the Fedora workstation (Picard 2.13 from the distro repos; 3.0 shipped October 2026). The workflow for a ruined CD rip — missing tracks, garbage filenames, no tags:
- Load the folder into Picard.
- Scan → Lookup with AcoustID fingerprinting enabled. This is the step that saves terrible rips: it fingerprints the actual audio and matches against the AcoustID database, so it works even when the filenames and existing tags are useless.
- Cluster, then match clusters to the MusicBrainz release.
- Enable the cover art script so front (and back) images embed into every track, not just a folder image.
- Run a file-naming script (
%albumartist%/%album%/%discnumber%.%tracknumber% %title%) and save.
Fingerprinting is the whole trick
Picard's fingerprinting needs fpcalc (the libchromaprint /
acoustid package) and an AcoustID API key. On Fedora:
sudo dnf install picard pulls the app; grab fpcalc too, then paste a
free API key from acoustid.org into Options → Fingerprinting. Without
it, Picard falls back to filename/tag lookup, which is exactly what
fails on a bad rip.
Automated: beets¶
The question I kept asking: can the MusicBrainz match happen without me clicking through albums? Yes — beets (beetbox/beets, actively maintained, v2.14.1 September 2026) is a command-line auto-tagger built on the same MusicBrainz + AcoustID data.
beet import fingerprints every track, identifies the album, applies the
tags, and moves the files into a path template you define. It keeps its own
SQLite library as the source of truth. The matching is the same data Picard
uses, but the decision is automated: high-confidence albums get tagged with
no interaction, ambiguous ones drop into a per-album prompt (or --pretend
to dry-run, --resume to pick up interrupted sessions).
Where the two fit:
- beets for volume — new rips, bulk cleanup, anything where most albums will match cleanly. It's the tool for "here's 40 albums, sort them."
- Picard for the stragglers — the box set beets couldn't cluster, the live album with wrong disc numbers, anything where you want to see and approve every field.
A sane pipeline is beet import first, Picard second for whatever beets
flagged. If you want the fully automated version, beets runs from a cron
job or a systemd timer pointed at a staging folder, and only its
low-confidence output needs a human.
beets owns the files once you let it
beets' default import copies files into its library directory. If your
server already serves the folder, set destination to that folder and
import.copy: no (or use link/hardlink) so you don't double the
207 GB. Decide this before the first import; unwinding a duplicated
library is worse than the tagging.
Discovery and requests: Aurral¶
Aurral (lklynet/aurral, active, ~1.7k stars as of October 2026) is the music-request/discovery layer: browse recommendations, request an album, and it drives Lidarr/Prowlarr to fetch it. It's the closest thing the music side has to what Jellyseerr/Seerr is for video.
services:
aurral:
image: ghcr.io/lklynet/aurral:2.10.0
container_name: aurral
restart: unless-stopped
ports:
- "3001:3001"
environment:
- PUID=1000
- PGID=1000
- TZ=America/Denver
volumes:
- /mnt/md0/apps/jellyfin/media:/data
- /mnt/md0/apps/jellyfin/media:/media
- /mnt/md0/apps/jellyfin/downloads:/downloads
networks:
- default
- jellyfin
networks:
jellyfin:
name: jellyfin_default
external: true
The mount trick: the same host folder is mounted at both /data and
/media because that's what Lidarr and qBittorrent already report. Aurral's
docs say "use one path everywhere" and mirroring the existing paths is how
that works without remote-path mapping.
The arrs hide behind Gluetun
Lidarr and Prowlarr run with network_mode: "service:gluetun", so on
the shared jellyfin_default network they resolve only as
gluetun:<port>. In Aurral's settings the URLs are
http://gluetun:8686 (Lidarr) and http://gluetun:9696 (Prowlarr) —
http://lidarr:8686 does not resolve. The Aurral image has no curl or
wget, so test in-network reachability with a throwaway container:
docker run --rm --network jellyfin_default alpine wget -qO- http://gluetun:8686.
The arrs for music¶
Lidarr is in the main Jellyfin compose, VPN-bound like the rest of the arr stack:
lidarr:
image: lscr.io/linuxserver/lidarr:latest
container_name: lidarr
network_mode: "service:gluetun" # Route through VPN
depends_on:
- gluetun
volumes:
- ./lidarr:/config
- ${MEDIA_LOCATION}:/media
- ${DOWNLOAD_LOCATION}:/downloads
The honest assessment: the *arr pipeline for music is weaker than for
video. Indexer coverage for albums is thinner, and automatic quality
decisions (which rip of which pressing) are harder to encode than for
movies. For new music the arrs earn their keep; for catalog cleanup,
beet import + Picard beats automation more often than not.
yt-dlp import apps¶
For pulling YouTube into the library, the field in October 2026:
| App | What it does | Maintenance (Oct 2026) | Source |
|---|---|---|---|
| Pinchflat | Playlists/channels → yt-dlp → Jellyfin library, with match filters | Last release v2025.09.26, last commit Dec 2025 | kieraneglin/pinchflat |
| Tubular | yt-dlp web UI, channel subscriptions | Active (last commit Jul 2026), 3.1k stars | polymorphicshade/Tubular |
| OpenTube | Minimal yt-dlp download queue UI | Forks active, original repo gone | e.g. jnsougata/opentube |
Pinchflat is what runs here:
services:
pinchflat:
container_name: pinchflat
image: ghcr.io/kieraneglin/pinchflat:latest
user: 1000:1000
restart: unless-stopped
environment:
- TZ=America/Denver
- UMASK=002 # group-writable files (thomas:thomas)
ports:
- '8945:8945'
volumes:
- ./config:/config
- /mnt/md0/apps/jellyfin/media:/downloads
Downloads land inside the Jellyfin media tree, so a library scan picks them
up like any other media. The UMASK=002 is there so the arrs and Jellyfin
can move files Pinchflat created. Pinchflat's release cadence has slowed
(last release September 2025) — it still works, but Tubular is the more
actively maintained option if you're choosing today. The Bonob container
(simojenki/bonob) skins the Pinchflat UI; it's cosmetic, not load-bearing.
Jellyfin as the music server instead?¶
Jellyfin serves the same folder and its clients work for music. The tradeoff: Navidrome's Subsonic API has better client depth (Symfonium, Amperfy, DSub all speak it), faster scanning on large libraries, and transcoding tuned for audio. Jellyfin wins when you want one server, one user system, and one app on every TV. Running both over the same read-only folder — which is what I do — costs nothing but a library scan.
Related¶
- Jellyfin + the arr stack — the video side, Gluetun wiring, DVR
- SWAG — how
navidrome.thomasjwilde.comgets its cert and block