Skip to content

Instantly share code, notes, and snippets.

@bfrancom
Created July 17, 2026 16:59
Show Gist options
  • Select an option

  • Save bfrancom/8b2763fc79e30a96153be17ac312f32e to your computer and use it in GitHub Desktop.

Select an option

Save bfrancom/8b2763fc79e30a96153be17ac312f32e to your computer and use it in GitHub Desktop.
Containerized terminal music player: Mopidy + YouTube + ncmpcpp on Apple container (macOS)

Containerized Terminal Music Player (Mopidy + YouTube + ncmpcpp on macOS)

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).

What it is

  • 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 music launcher 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.

Architecture

+----------------------------- 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)  |                             |
|  +---------------------------------------+                            |
+-----------------------------------------------------------------------+

Requirements

  • macOS 26 (Tahoe) or later, Apple silicon
  • Apple container runtime
  • mpv on the host (brew install mpv)

Install

  1. 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
  2. Create the stack directory (e.g. ~/dotfiles/containers/music/) with four files: Dockerfile.mopidy, mopidy.conf, Dockerfile.ncmpcpp, and the music launcher (all below).

  3. Symlink the launcher onto your PATH:

    chmod +x ~/dotfiles/containers/music/music
    ln -s ~/dotfiles/containers/music/music ~/.local/bin/music
  4. 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
    
  5. Run music. First run builds both images (a few minutes), then drops you into ncmpcpp.

Files

Dockerfile.mopidy

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"]

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 = false

Dockerfile.ncmpcpp

FROM 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"]

music (launcher)

#!/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

Usage

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.

Keyboard shortcuts (ncmpcpp)

Screens

Key Screen
1 Playlist (the queue)
2 Browser
3 Search engine (this is the YouTube search)
F1 Full built-in help

Searching YouTube (screen 3)

  • Put the query in the Any field only. Tag-scoped fields (Artist, Title, ...) return nothing from the YouTube backend.
  • Enter on 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

Playback

Key Action
p Pause / resume
s Stop
> / < Next / previous track
f / b Seek forward / backward
+ / - Volume
r / z / R Repeat / random / consume mode

Queue (screen 1)

Key Action
Enter Play highlighted track
Delete Remove track
c Clear queue
m Move track

Quirks and troubleshooting

  • 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_timeout is 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, rerun music. 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 4g for this reason. Diagnose dead containers with container 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment