Skip to content

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 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:

  1. Load the folder into Picard.
  2. 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.
  3. Cluster, then match clusters to the MusicBrainz release.
  4. Enable the cover art script so front (and back) images embed into every track, not just a folder image.
  5. 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.

pipx install beets
beet import /path/to/new/rip

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.

Comments