RFC: Self-Hosted Music Streaming (Replacing YouTube Music)
Status: Draft Date: 2026-04-24
Problem
15 years on YouTube Music. Solid local music collection exists but no self-hosted way to stream it with discovery, recommendations, iOS app, CarPlay, offline sync, and the other features that make YouTube Music sticky.
Requirements
- Stream local music collection from the homelab
- iOS app with CarPlay, gapless playback, offline downloads
- Music discovery / recommendations (the hardest gap to fill)
- Lyrics (synced preferred)
- Scrobbling (Last.fm / ListenBrainz)
- Lightweight — runs in a Debian LXC alongside the existing stack
- Fits the IaC pattern: Ansible + Forgejo Actions + Vault + Caddy + Alloy
Nice-to-haves
- OIDC/SSO via PocketID
- Smart playlists
- YouTube Music bridge (for music not in local collection)
- Multi-user
Server Comparison
Tier 1 — Serious Contenders
| Navidrome | Jellyfin (music mode) | Gonic | |
|---|---|---|---|
| Language | Go | C# | Go |
| GitHub stars | 20.7k | huge | 2.4k |
| Latest release | v0.61.2 (2026-04-12) | active | v0.20.1 (2026-01-30) |
| RAM footprint | ~50MB | ~300-500MB | ~50MB |
| Subsonic API | Full (OpenSubsonic) | No (own API) | Full |
| OIDC/SSO | No (rejected, #858) | Plugin (alpha, tested w/ PocketID) | No |
| Smart playlists | Yes (iTunes-style rules) | No (for music) | No |
| Scrobbling | Last.fm + ListenBrainz + Maloja | Via plugins | Last.fm + ListenBrainz |
| Lyrics | Embedded + .lrc | .lrc files | No |
| Recommendations | No native | AudioMuse-AI plugin | No |
| Artist metadata | Last.fm / Spotify / Deezer | MusicBrainz / fanart.tv | No |
| Transcoding | On-the-fly, per-user | FFmpeg (full) | FFmpeg |
| Multi-user | Yes | Yes | Yes |
| Deployment | Single binary or Docker | Docker or bare metal | Single binary or Docker |
Tier 2 — Evaluated, Not Recommended
| Server | Why not |
|---|---|
| Koel (17.1k stars) | PHP/Laravel stack, SSO locked behind premium, no Subsonic API |
| Funkwhale | Federation overkill, Python/Django + PostgreSQL + Redis + Celery = heavy |
| Ampache (3.8k stars) | Aging PHP, lead dev on reduced hours, uncertain future |
| Airsonic-Advanced | Last stable release April 2020, Java/JVM 500MB+ RAM |
| Polaris (2.6k stars, Rust) | No Subsonic API, no scrobbling, no lyrics — too basic |
| Black Candy (4.2k stars, Ruby) | No Subsonic API, no smart playlists, limited features |
| Euterpe (572 stars, Go) | Extremely minimal, no scrobbling/playlists/lyrics |
| Supysonic (289 stars, Python) | Bare-bones, just use Navidrome instead |
| mStream (Node.js) | Basic personal streamer, not competitive |
| LMS/Lyrion (Perl) | Great for Squeezebox hardware, wrong fit for mobile streaming |
iOS App Comparison
Subsonic-Compatible (works with Navidrome / Gonic)
| App | Price | CarPlay | Gapless | Offline | Scrobbling | Last Updated | Status |
|---|---|---|---|---|---|---|---|
| play:Sub | $4.99 | Yes (w/ queue) | Yes | Yes | Last.fm | 2026-04-22 | Best overall, mature |
| Arpeggi | Free (OSS) | Yes | Yes (AAC/FLAC/ALAC/Opus) | Yes (bulk) | Yes (offline queue) | v0.6.4 beta | TestFlight only, at capacity |
| Narjo | Free (beta) | Yes | Yes | Yes | Unknown | TestFlight beta | Unique: Subsonic + Emby, DLNA/Sonos |
| Amperfy | Free (OSS) | Yes | No (micro-gaps FLAC) | Yes | Yes | 2025-03 | Stable, no Opus support |
| SubStreamer | Free | Unknown | Unknown | Yes | Analytics | 2026-04-20 | Active |
| iSub | Free | No | Yes | Yes | No | 2021-03 | Abandoned |
Jellyfin-Only
| App | Price | CarPlay | Gapless | Offline | Last Updated | Status |
|---|---|---|---|---|---|---|
| Finamp | Free (OSS) | Not yet | Yes | Yes | 2024-11 | Active redesign |
Key Takeaways
- play:Sub is the safe pick — mature, CarPlay, everything works, $5
- Arpeggi is the most promising free option — true gapless on all formats including Opus, open source, but beta-only and TestFlight full
- Narjo is interesting if you have Sonos/DLNA speakers or want Emby support alongside Navidrome
- Amperfy is solid but the FLAC gapless gap and no Opus support are real limitations
- Jellyfin locks you to Finamp only — no CarPlay yet, smaller ecosystem
YouTube Music Gap Analysis
What you lose and how to mitigate
| Feature | YouTube Music | Self-Hosted Mitigation |
|---|---|---|
| Discovery / recs | Algorithm-driven mixes | ListenBrainz collaborative filtering + Navidrome smart playlists |
| Radio from song | "Start Radio" | Subsonic "Similar Songs" API (works in play:Sub/Amperfy) |
| Synced lyrics | Built-in | Embedded tags + .lrc files (batch via LRCLIB) |
| Artist bios | Rich pages | Navidrome auto-enriches from Last.fm/Spotify/Deezer |
| Scrobbling | YouTube history | Last.fm + ListenBrainz (native in Navidrome) |
| Music you don't own | Unlimited catalog | See "Acquisition Bridge" below |
The Acquisition Bridge: ytmusicapi + Yubal
The biggest gap: YouTube Music has every song ever. Self-hosted only has what you have.
ytmusicapi (v1.11.6) is a Python library that talks to YouTube Music's internal API: - Fetch liked songs, playlists, listening history, recommendations - Search, get artist info, get lyrics - OAuth authentication
Yubal bridges ytmusicapi + yt-dlp + Navidrome:
- Paste a YouTube Music album/playlist URL -> downloads, tags, organizes into Artist/Year-Album/Track
- Generates M3U playlists + synced .lrc lyrics (via lrclib.net)
- Scheduled sync — subscribe to YTM playlists, auto-sync on cron
- ReplayGain tagging, deduplication
- Browser extension (Firefox + Chrome) for one-click downloads
- Docker deployment
NavidroFM automates the discovery loop: - Reads Navidrome scrobbles -> queries YouTube Music for similar music -> downloads + imports - Closest thing to YouTube Music's "Start Radio" but self-hosted
Practical workflow:
Listen on Navidrome -> Scrobble to ListenBrainz -> Get recommendations
-> Yubal/NavidroFM auto-downloads from YouTube Music -> Navidrome rescans
-> New music appears in library -> Repeat
This turns the "I don't have that song" problem into an automated pipeline. ListenBrainz recommends Creed after Linkin Park -> NavidroFM fetches it from YouTube Music -> it appears in your library next scan.
SSO Trade-off
Navidrome explicitly rejected OIDC (GitHub #858). Reverse proxy header auth exists but breaks Subsonic API auth — mobile apps authenticate with username/password tokens directly to the Subsonic API, bypassing the reverse proxy auth layer.
Practical approach: - Caddy reverse proxy for the web UI (restrict via NetBird VPN or IP allowlist) - Strong password set directly in Navidrome - Mobile apps authenticate via Subsonic API tokens (password-based) - This is standard practice in the Navidrome community
If SSO is a hard requirement: Jellyfin + SSO plugin (tested with PocketID) + Finamp. But you lose CarPlay, the Subsonic app ecosystem, and the lightweight footprint.
Recommendation: Accept the SSO gap. Music streaming is a personal-use service where a strong password is sufficient. The trade-off (losing the entire Subsonic mobile ecosystem) isn't worth it for SSO on a single-user music server.
Decision: Navidrome
Why
- Lightest footprint — single Go binary, ~50MB RAM, SQLite. Perfect for a Debian LXC
- Best mobile ecosystem — OpenSubsonic API unlocks play:Sub, Arpeggi, Amperfy, SubStreamer, and dozens more
- Most active development — 20.7k stars, v0.61.2 released 12 days ago
- Feature-rich — smart playlists, scrobbling, lyrics, artist metadata, multi-user, transcoding
- Simple deployment — Docker, trivial Ansible role, no database server needed
- Handles scale — tested with 900k+ songs
- Acquisition bridge — Yubal + NavidroFM solve the "music I don't have" problem
Proposed Infrastructure
| Component | Value |
|---|---|
| LXC ID | 132 |
| IP | 192.168.1.132 |
| Hostname | navidrome |
| Storage pool | apps-pool |
| Disk | 4G (rootfs only — music on bind mount) |
| URL | music.eva-00.network |
| Music storage | Bind mount from host (location TBD — filedump or unohana) |
| Containers | navidrome (+ yubal optional) |
Companion Stack
| Component | Purpose |
|---|---|
| Navidrome | Music server (OpenSubsonic API) |
| play:Sub (iOS) | Primary mobile client ($4.99) |
| Arpeggi (iOS) | Secondary client (free, beta — try both) |
| ListenBrainz | Open scrobbling + recommendation engine |
| Last.fm | Social scrobbling + metadata enrichment |
| Yubal (Docker) | YouTube Music bridge — download, tag, organize, sync |
| MusicBrainz Picard | Initial library tagging (run on Mac, one-time) |
| LRCLIB | Batch synced lyrics download |
Resolved Questions
- Music storage: unohana (sdc, 4TB) via bind mount from
/mnt/all-might. Same disk as Jellyfin, Grimmory, Shoko media. Music files + Yubal-downloaded .lrc lyrics live here. Navidrome's own data (SQLite DB, cover art cache, metadata) stays in LXC rootfs at/opt/navidrome/data/. - Yubal deployment: Colocated on LXC 132 with Navidrome. Tightly coupled — Yubal writes directly to Navidrome's music directory. Both lightweight.
- YouTube Music: Keep active during migration. Yubal pulls the entire YTM library first, then evaluate whether to cancel.
- iOS apps: User is in Arpeggi + Narjo betas. Evaluate alongside play:Sub before committing to a purchase. No Sonos/DLNA speakers, so Narjo's unique feature is less relevant.
- LXC 132: Dedicated to Navidrome + Yubal. Nothing else runs here.
- Backup: Music files on unohana are already covered by sazabi (18TB offline rsync). Navidrome app data (SQLite DB, config, playlists) backed up via PBS like other LXCs. Yubal config backed up alongside.
- No SSO: Accept the SSO gap. Strong password in Navidrome, Caddy reverse proxy for web UI access. Mobile apps use Subsonic API tokens directly.
Implementation Plan
Infrastructure
| Component | Value |
|---|---|
| LXC ID | 132 |
| IP | 192.168.1.132 |
| Hostname | navidrome |
| Storage pool | apps-pool |
| Disk | 4G (rootfs — music on bind mount) |
| URL | music.eva-00.network |
| Music bind mount | /mnt/all-might -> /music |
| Containers | navidrome (port 4533), yubal (port 8000), alloy |
IaC Artifacts (chizuru-v2)
| Artifact | Path |
|---|---|
| Inventory | ansible/inventory/hosts.yml |
| Playbook | ansible/playbooks/navidrome.yml |
| Workflow | .forgejo/workflows/navidrome.yml |
| Docker Compose | services/navidrome/docker-compose.yml |
| Navidrome config | services/navidrome/navidrome.toml |
| Caddy entry | services/caddy/Caddyfile > music.eva-00.network |
| Glance entries | services/glance/glance.yml > Media bookmarks, monitor, Proxmox, releases |
Vault Secrets
| Path | Keys |
|---|---|
secret/navidrome |
admin_password, lastfm_api_key, lastfm_secret |
Glance Icons (selfh.st)
| Service | URL |
|---|---|
| Navidrome | https://cdn.jsdelivr.net/gh/selfhst/icons@main/svg/navidrome.svg |
| Yubal | https://cdn.jsdelivr.net/gh/selfhst/icons@main/svg/yubal.svg |
Documentation (homelab-docs)
| File | Purpose |
|---|---|
docs/services/navidrome/setup.md |
Setup, IaC, Vault, Yubal, iOS clients |
docs/services/navidrome/runbook.md |
Operations, troubleshooting |
mkdocs.yml |
Nav entry under Media section |
Backup Strategy
- LXC 132 rootfs -> PBS automatic backup (covers SQLite DB, playlists, favorites, cover art cache, Yubal state)
- Music files on unohana -> Already in sazabi 18TB offline rsync
- Vault secrets -> Already backed up with Vault storage backend
- No additional backup config needed
Post-Deploy Manual Steps
- Browse
https://music.eva-00.network, create admin account with Vault password - Register Last.fm API key at https://www.last.fm/api/account/create, store in Vault
- Set up Yubal YouTube Music OAuth (one-time browser auth flow)
- Install Yubal browser extension (Firefox/Chrome) for one-click downloads
- Connect iOS apps (play:Sub / Arpeggi / Narjo) to
music.eva-00.network - Transfer music collection to
/mnt/all-might/music/on chizuru