A terminal music player that streams YouTube audio, with every YouTube-facing component isolated in containers using Apple's native container runtime (macOS 26+). Nothing music-related is installed on the host except mpv, which plays the audio (containers cannot reach CoreAudio).
- Mopidy (music server speaking the MPD protocol) + Mopidy-YouTube + yt-dlp, running in one container VM
- ncmpcpp (the terminal UI) running in a second container
- mpv on the host, playing an MP3 stream that Mopidy serves over TCP
- A
musiclauncher script that orchestrates all of it
Each container runs in its own lightweight VM (Apple Containerization framework), so isolation is stronger than Docker Desktop style shared-VM containers. YouTube parsing code (yt-dlp) never executes on macOS.
+----------------------------- macOS host ------------------------------+
| |
| music (launcher) mpv <-- audio --- tcp://127.0.0.1:8001 |
| | |
| +---- container VM: mopidy ----------------------------------+ |
| | Mopidy + Mopidy-MPD + Mopidy-YouTube + yt-dlp | |
| | MPD protocol :6600 GStreamer lamemp3enc -> :8001 | |
| +--------------------------------------------------------------+ |
| ^ MPD protocol (container IP) |
| +---- container VM: ncmpcpp-ui --------+ |
| | ncmpcpp (terminal UI, interactive) | |
| +---------------------------------------+ |
+-----------------------------------------------------------------------+
- macOS 26 (Tahoe) or later, Apple silicon
- Apple
containerruntime mpvon the host (brew install mpv)
-
Install the Apple container runtime (signed pkg from the releases page):
curl -sSLO https://github.com/apple/container/releases/download/1.1.0/container-1.1.0-installer-signed.pkg pkgutil --check-signature container-1.1.0-installer-signed.pkg # verify Apple signature sudo installer -pkg container-1.1.0-installer-signed.pkg -target / container system start container system kernel set --recommended # non-interactive first-run kernel install
-
Create the stack directory (e.g.
~/dotfiles/containers/music/) with four files:Dockerfile.mopidy,mopidy.conf,Dockerfile.ncmpcpp, and themusiclauncher (all below). -
Symlink the launcher onto your PATH:
chmod +x ~/dotfiles/containers/music/music ln -s ~/dotfiles/containers/music/music ~/.local/bin/music
-
ncmpcpp config at
~/.config/ncmpcpp/config(bind-mounted into the UI container). The important line:# YouTube searches can take 15-20s; the 5s default drops the connection mpd_connection_timeout = 30 -
Run
music. First run builds both images (a few minutes), then drops you into ncmpcpp.
FROM debian:bookworm-slim
# GStreamer stack + Python GObject bindings; -ugly provides lamemp3enc for the TCP audio stream
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-venv python3-gi python3-gst-1.0 \
gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \
mpc ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Mopidy + extensions in a venv that can see the apt-installed gi/gst bindings
RUN python3 -m venv --system-site-packages /opt/mopidy \
&& /opt/mopidy/bin/pip install --no-cache-dir \
Mopidy Mopidy-MPD Mopidy-YouTube yt-dlp ytmusicapi
RUN useradd --create-home --uid 1000 mopidy
USER mopidy
COPY mopidy.conf /etc/mopidy/mopidy.conf
EXPOSE 6600 8001
CMD ["/opt/mopidy/bin/mopidy", "--config", "/etc/mopidy/mopidy.conf"][core]
cache_dir = /data/cache
config_dir = /etc/mopidy
data_dir = /data
[logging]
verbosity = 0
# Encode to MP3 and serve the stream over TCP; host mpv plays tcp://127.0.0.1:8001
[audio]
output = audioresample ! audioconvert ! lamemp3enc bitrate=320 ! tcpserversink host=0.0.0.0 port=8001 sync-method=latest recover-policy=keyframe
[mpd]
enabled = true
hostname = 0.0.0.0
port = 6600
[file]
enabled = true
media_dirs = /music|Local
[m3u]
enabled = true
playlists_dir = /data/playlists
[youtube]
enabled = true
allow_cache = true
youtube_dl_package = yt_dlp
autoplay_enabled = falseFROM alpine:3.22
RUN apk add --no-cache ncmpcpp ncurses-terminfo
RUN adduser -D -u 1000 music
USER music
ENV TERM=xterm-256color
# Config is bind-mounted at runtime; MPD_HOST/MPD_PORT come from the launcher
ENTRYPOINT ["ncmpcpp"]#!/usr/bin/env bash
# Terminal music stack: Mopidy (YouTube + MPD) in an Apple `container` VM,
# ncmpcpp in a second container, host mpv playing the TCP audio stream.
# Usage: music [up|down|status|rebuild|logs] (default: up + attach ncmpcpp)
set -euo pipefail
DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
MOPIDY_IMG="local/mopidy-music"
NCMPCPP_IMG="local/ncmpcpp"
STATE_DIR="$HOME/.local/state/music-stack"
STREAM_URL="tcp://127.0.0.1:8001"
ensure_system() {
container system status >/dev/null 2>&1 || container system start
}
build_images() {
container image list 2>/dev/null | grep -q "$MOPIDY_IMG" || \
container build --tag "$MOPIDY_IMG" --file "$DIR/Dockerfile.mopidy" "$DIR"
container image list 2>/dev/null | grep -q "$NCMPCPP_IMG" || \
container build --tag "$NCMPCPP_IMG" --file "$DIR/Dockerfile.ncmpcpp" "$DIR"
}
mopidy_running() {
container list 2>/dev/null | grep -q '^mopidy '
}
start_mopidy() {
mkdir -p "$STATE_DIR"
mopidy_running || container run -d --name mopidy --rm \
--memory 4g \
--publish 127.0.0.1:6600:6600 \
--publish 127.0.0.1:8001:8001 \
--volume "$STATE_DIR:/data" \
--volume "$HOME/Music:/music" \
"$MOPIDY_IMG"
}
mopidy_host() {
# ncmpcpp reaches mopidy by container IP (robust regardless of DNS setup)
container inspect mopidy 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
d = d[0] if isinstance(d, list) else d
print(d['status']['networks'][0]['ipv4Address'].split('/')[0])" 2>/dev/null
}
start_mpv() {
# Retry loop: the TCP stream only exists while a track plays, and drops
# between tracks; mpv exits on each drop and must reconnect.
pgrep -f "music-stream-loop" >/dev/null || \
(nohup bash -c '# music-stream-loop
while true; do
mpv --no-video --really-quiet "'"$STREAM_URL"'" >/dev/null 2>&1
sleep 1
done' >/dev/null 2>&1 &)
}
stop_mpv() {
pkill -f "music-stream-loop" 2>/dev/null || true
pkill -f "mpv.*$STREAM_URL" 2>/dev/null || true
}
attach_ncmpcpp() {
local host; host="$(mopidy_host)"
[ -n "$host" ] || { echo "could not determine mopidy container IP" >&2; exit 1; }
container run -it --rm --name ncmpcpp-ui \
--env MPD_HOST="$host" --env MPD_PORT=6600 \
--volume "$HOME/.config/ncmpcpp:/home/music/.config/ncmpcpp" \
"$NCMPCPP_IMG"
}
case "${1:-up}" in
up)
ensure_system; build_images; start_mopidy
sleep 1; start_mpv; attach_ncmpcpp ;;
down)
stop_mpv
container stop ncmpcpp-ui 2>/dev/null || true
container stop mopidy 2>/dev/null || true
echo "music stack stopped" ;;
status)
container list
pgrep -f "music-stream-loop" >/dev/null && echo "audio loop: running" || echo "audio loop: NOT running" ;;
rebuild)
container image delete "$MOPIDY_IMG" "$NCMPCPP_IMG" 2>/dev/null || true
build_images ;;
logs)
container logs mopidy ;;
*)
echo "usage: music [up|down|status|rebuild|logs]" >&2; exit 1 ;;
esac| Command | What it does |
|---|---|
music |
Start everything and open ncmpcpp |
music down |
Stop containers and the audio loop |
music status |
Show containers and audio loop state |
music rebuild |
Rebuild both images (e.g. to update yt-dlp) |
music logs |
Tail the Mopidy container logs |
Quitting ncmpcpp (q) leaves the music playing; run music again to re-attach.
| Key | Screen |
|---|---|
1 |
Playlist (the queue) |
2 |
Browser |
3 |
Search engine (this is the YouTube search) |
F1 |
Full built-in help |
- Put the query in the Any field only. Tag-scoped fields (Artist, Title, ...) return nothing from the YouTube backend.
Enteron the Any field, type the query,Enter- Arrow down to Search,
Enter. The first search takes 15 to 20 seconds; it is not frozen. - On a result:
Enter= add and play,Space= add to queue - Reset clears the form
| Key | Action |
|---|---|
p |
Pause / resume |
s |
Stop |
> / < |
Next / previous track |
f / b |
Seek forward / backward |
+ / - |
Volume |
r / z / R |
Repeat / random / consume mode |
| Key | Action |
|---|---|
Enter |
Play highlighted track |
Delete |
Remove track |
c |
Clear queue |
m |
Move track |
- Audio lags the UI by a few seconds (TCP stream buffering), and there is a ~1s gap between tracks while mpv reconnects. Normal.
- "timeout / No active MPD connection": your
mpd_connection_timeoutis too low; set it to 30 (YouTube searches are slow). - Search returns nothing: you searched a tag field; use Any.
- Says playing but silent: check
music status. If the audio loop is dead, rerunmusic. If the mopidy container is gone, it was likely killed; see next item. - Mopidy container vanishes mid-playback: exit code 137 (OOM kill) under the default 1GB VM memory cap. The launcher runs it with
--memory 4gfor this reason. Diagnose dead containers withcontainer system logs(the container itself self-removes because of--rm). - Resolving stops working: YouTube changed something; rebuild to pick up the latest yt-dlp:
music rebuild. - Harmless log noise:
FileBackend/StreamBackend returned bad data: Expected a SearchResult instance, not []comes from Mopidy's built-in backends returning empty results. - The queue lives in the Mopidy container's memory; it does not survive a container restart.