Building My Own Self-Hosted Music Library

From format wars to streaming anywhere, on my own terms

There’s a specific kind of dissatisfaction that comes with streaming services. The music is there, the app is polished, but none of it is really yours. The moment you stop paying, it disappears. The algorithm decides what comes next. Your listening history is someone else’s data.

I had a collection of digital audio files sitting around and I wanted to stream them to my phone from anywhere. Not through Spotify. Not through YouTube Music. Just my files, my server, my player.

So I built the whole pipeline from scratch — format conversion, cover art, playlists, and a self-hosted music server. This post covers all of it.


Why Opus?

Before writing a single line of code, I had to settle the format question.

The short answer: FLAC for archival, Opus for everyday listening.

FLAC is lossless and open source — the master copy that doesn’t throw away any information. Opus is a modern lossy codec that beats MP3 and AAC at equivalent bitrates. At 160kbps, Opus is considered perceptually transparent — meaning in blind listening tests, people can’t reliably tell it apart from lossless. I verified this myself using Spek to compare spectrograms side-by-side.

The high-frequency rolloff above ~20kHz is visible on the spectrogram, but it’s inaudible. No amount of bitrate will make a lossy file match a lossless one on a graph — that’s the nature of lossy compression. The point is that 160k Opus sounds identical in practice, and the files are a fraction of the size.


Opus vs OGG — A Quick Detour

These two are often confused, and I ran into this confusion head-first while writing the conversion scripts.

Opus is the audio codec — the algorithm that compresses the audio data. OGG is a container format — the file wrapper that holds the audio, metadata, and optionally cover art.

The .opus extension uses a strict Opus container that only supports audio streams. No embedded images. The .ogg extension is more flexible and can carry Opus audio alongside cover art — but FFmpeg’s OGG muxer doesn’t actually support writing embedded images either. After a lot of trial and error, I gave up on embedded art entirely and went with the standard approach:

  • .opus files for audio
  • cover.jpg in each album or single directory

This is actually cleaner anyway. Navidrome picks up folder-level cover art natively, and it works everywhere without container compatibility headaches.


Directory Structure

Each release gets its own directory, whether it’s an album, an EP, or a single:

Music/
Artist Name/
Album Name/
01 - Track Title.opus
02 - Track Title.opus
cover.jpg
Album Name.m3u

Singles follow the same pattern — their own folder, their own cover.jpg. No flat dumps. No shared cover art across multiple releases.


The Conversion Pipeline

I wrote two scripts: a Python script that handles the actual conversion and cover art extraction, and a Fish shell function that wraps it for batch use.

flac2opus.py

The Python script does three things: converts FLAC to Opus using FFmpeg, extracts the embedded cover art from the FLAC file, and saves it as cover.jpg in the output directory. It skips cover extraction if a cover.jpg already exists, so running it twice on the same directory is safe.

#!/usr/bin/env python3
"""Convert FLAC to Opus with folder-based cover art."""
import sys
import subprocess
from pathlib import Path
def convert(input_path: Path, output_path: Path, bitrate: str = "160k"):
from mutagen.flac import FLAC
flac = FLAC(input_path)
pictures = flac.pictures
subprocess.run([
"ffmpeg", "-i", str(input_path),
"-map", "0:a",
"-c:a", "libopus",
"-b:a", bitrate,
"-y", str(output_path)
], check=True)
if pictures:
import io
from PIL import Image
cover_path = output_path.parent / "cover.jpg"
if not cover_path.exists():
img = Image.open(io.BytesIO(pictures[0].data)).convert("RGB")
w, h = img.size
if w > h:
left = (w - h) // 2
img = img.crop((left, 0, left + h, h))
img.save(cover_path, format="JPEG", quality=90)
print(f" Cover art saved ({img.width}x{img.height})")
else:
print(f" cover.jpg already exists, skipping")
else:
print(f" No cover art found")
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: flac2opus.py <input.flac> <output.opus> [bitrate]")
sys.exit(1)
input_path = Path(sys.argv[1])
output_path = Path(sys.argv[2])
bitrate = sys.argv[3] if len(sys.argv) > 3 else "160k"
convert(input_path, output_path, bitrate)

The center-crop on wide images handles YouTube Music thumbnails — they come in as landscape JPEGs, so the script crops them to a square before saving.

Full source: scripts/flac2opus.py

flac2opus Fish function

The Fish function wraps the Python script and adds batch processing, skip detection, and M3U playlist handling per directory. It uses uv run --with to pull in mutagen and pillow inline — no virtualenv, no manual dependency installation needed. uv handles it on the first run and caches it after that.

Terminal window
function flac2opus --description "Batch convert FLAC to Opus"
set bitrate "160k"
set dir "."
set script ~/.config/fish/scripts/flac2opus.py
for arg in $argv
switch $arg
case '-b' '--bitrate'
set bitrate $argv[(math (contains -i -- $arg $argv) + 1)]
case '*.flac'
set out (string replace -r '\.flac$' '.opus' $arg)
if uv run --with mutagen --with pillow $script $arg $out $bitrate
_flac2opus_handle_m3u (dirname $arg)
end
return
case '*'
set dir $arg
end
end
set files (find $dir -name "*.flac")
set total (count $files)
set current 0
set converted_dirs
for file in $files
set current (math $current + 1)
set out (string replace -r '\.flac$' '.opus' $file)
if test -f $out
echo "[$current/$total] Skipping (already exists): $file"
continue
end
echo "[$current/$total] Converting: $file"
if uv run --with mutagen --with pillow $script $file $out $bitrate
set file_dir (dirname $file)
if not contains $file_dir $converted_dirs
set -a converted_dirs $file_dir
end
end
end
for d in $converted_dirs
_flac2opus_handle_m3u $d
end
echo "Done."
end
function _flac2opus_handle_m3u --description "Create or update M3U playlist in a directory"
set d $argv[1]
set dirname (basename $d)
set existing_m3u (find $d -maxdepth 1 -name "*.m3u" 2>/dev/null | head -1)
if test -n "$existing_m3u"
set new_m3u "$d/$dirname.m3u"
if test "$existing_m3u" != "$new_m3u"
mv $existing_m3u $new_m3u
echo " Renamed M3U: "(basename $existing_m3u)" → $dirname.m3u"
end
sed -i 's/\.flac/.opus/g' $new_m3u
echo " Updated M3U: $dirname.m3u"
else
set opus_files (find $d -maxdepth 1 -name "*.opus" | sort)
if test (count $opus_files) -gt 0
set m3u_path "$d/$dirname.m3u"
echo "#EXTM3U" > $m3u_path
for f in $opus_files
echo (basename $f) >> $m3u_path
end
echo " Created M3U: $dirname.m3u"
end
end
end

Full source: functions/flac2opus.fish

The M3U logic handles two cases: if a playlist already exists (possibly with a mismatched name or .flac references), it renames it to match the directory name and rewrites the extensions. If none exists, it creates one from the converted files.

Usage

Terminal window
flac2opus # convert all FLAC in current directory
flac2opus ~/Music # specific directory
flac2opus song.flac # single file
flac2opus ~/Music -b 192k # custom bitrate

After converting, remove the original FLAC files:

Terminal window
find . -name "*.flac" -print -delete

Downloading from YouTube Music

I also wrote ytm2opus — a Fish function that downloads from YouTube Music and converts to Opus in one step, using yt-dlp under the hood.

It downloads at the highest available source quality (up to 262kbps Opus with premium), converts via flac2opus.py (same script, same uv inline dependencies), creates a named output directory automatically, saves cover.jpg, and generates an M3U playlist. The source is always FLAC — download first at best quality, then re-encode. Never transcode from an already-lossy source.

It uses the bgutil-ytdlp-pot-provider plugin for YouTube Music premium quality, and requires a cookies file exported from your browser. Setup instructions are in the script header.

Full source: functions/ytm2opus.fish

Terminal window
ytm2opus 'https://music.youtube.com/watch?v=...' # single track
ytm2opus 'https://music.youtube.com/playlist?list=...' # album or playlist
ytm2opus -g 'Reggae' 'https://...' # with genre tag

Self-Hosting with Navidrome

Navidrome is a lightweight, open source music server that implements the Subsonic API. It runs comfortably on a modest VPS, has a clean web UI, and the Subsonic API means a wide choice of Android clients. I use Musly on Android.

I run it on my Netcup VPS behind Traefik v3:

services:
navidrome:
image: deluan/navidrome:latest
restart: unless-stopped
volumes:
- ./data:/data
- ./music:/music:ro
environment:
ND_MUSICFOLDER: /music
ND_DATAFOLDER: /data
ND_LOGLEVEL: info
ND_SCANSCHEDULE: 1h
ND_SESSIONTIMEOUT: 24h
labels:
- "traefik.enable=true"
- "traefik.http.routers.navidrome.rule=Host(`music.dhemasnurjaya.com`)"
- "traefik.http.routers.navidrome.entrypoints=websecure"
- "traefik.http.routers.navidrome.tls.certresolver=letsencrypt"
- "traefik.http.services.navidrome.loadbalancer.server.port=4533"
networks:
- traefik-proxy
networks:
traefik-proxy:
external: true

Upload your music with rsync:

Terminal window
rsync -avz --progress ~/Music/ user@your-vps:/path/to/navidrome/music/

On first run Navidrome will prompt you to create an admin account, then scan the music folder automatically.


The Full Pipeline

Put it all together and it looks like this:

  1. Rip or download FLAC (lossless master)
  2. Run flac2opus — converts to Opus 160k, extracts cover.jpg, generates M3U
  3. Delete original FLAC files
  4. rsync to VPS
  5. Navidrome picks it up on the next scan

It’s not the simplest setup. But it’s fully open source, self-hosted, and mine. No subscriptions, no tracking, no algorithm deciding what comes next.

That’s the whole point.