An extensible music server written in Rust , single binary, zero dependency ππ΅β¨
512
stars
635
commits
Rust
primary language
Sep 6, 2026
updated
An extensible music player daemon, server and client, written in Rust β like mpd or Mopidy.
Audio decoding and playback are powered by the Rockbox firmware's battle-tested engine, via the rockbox-playback, rockbox-dsp and rockbox-metadata crates: 40+ audio formats, gapless-grade buffering, EQ/crossfade/ReplayGain DSP, and native HTTP streaming. The daemon indexes your library into SQLite (with FTS5 full-text search) and exposes it over gRPC, GraphQL and a web UI β controllable from the terminal UI, the browser, or the Tauri desktop app.
[!NOTE] Looking for more? If you're interested in this project, you might want to check out rockboxd, a music player daemon built on the Rockbox Open Source Firmware. It offers advanced audio playback features, bringing the best of Rockbox to modern platforms with the power of Zig and Rust.
? help overlayWith curl (downloads the prebuilt binary for your platform from GitHub releases):
curl -fsSL https://raw.githubusercontent.com/tsirysndr/music-player/master/install.sh | sh
# pin a specific release:
curl -fsSL https://raw.githubusercontent.com/tsirysndr/music-player/master/install.sh | MUSIC_PLAYER_VERSION=v0.2.1 sh
# also install the Slint desktop app (music-player-desktop):
curl -fsSL https://raw.githubusercontent.com/tsirysndr/music-player/master/install.sh | MUSIC_PLAYER_DESKTOP=1 sh
Using npm (downloads the prebuilt binary from GitHub releases):
npm install -g @tsiry/music-player # or: npx @tsiry/music-player
# pin a specific release: MUSIC_PLAYER_VERSION=v0.2.1 npx @tsiry/music-player
Using Homebrew (macOS/Linux):
brew install tsirysndr/tap/musicplayer
On Debian, Ubuntu, and other APT-based systems (amd64 and arm64):
echo "deb [trusted=yes] https://apt.fury.io/tsiry/ /" \
| sudo tee /etc/apt/sources.list.d/music-player.list
sudo apt-get update
sudo apt-get install music-player
On Fedora, RHEL, Rocky Linux, AlmaLinux, and other DNF-based systems
(x86_64 and aarch64):
sudo tee /etc/yum.repos.d/music-player.repo <<'EOF'
[music-player]
name=Music Player
baseurl=https://yum.fury.io/tsiry/
enabled=1
gpgcheck=0
EOF
sudo dnf install music-player
The Debian and RPM packages include both music-player and the Slint desktop
application, music-player-desktop. Launch the desktop app from your
application menu or run music-player-desktop.
Using Nix (macOS/Linux):
cachix use tsirysndr
nix profile install --experimental-features "nix-command flakes" github:tsirysndr/music-player
Or download the latest release for your platform here.
Without Nix:
# Install dependencies
brew install protobuf # macOS
sudo apt-get install -y libasound2-dev protobuf-compiler # Ubuntu/Debian
choco install protoc # Windows using Chocolatey Package Manager
# Compile
git clone https://github.com/tsirysndr/music-player.git
cd music-player/webui/musicplayer
nvm install # install node version specified in .nvmrc (optional on windows)
bun install && bun run build # build webui
cd ../..
cargo install --path .
With Nix:
git clone https://github.com/tsirysndr/music-player.git
cd music-player
nix develop --experimental-features "nix-command flakes"
cd webui/musicplayer
bun install && bun run build # build webui
cd ../..
cargo install --path .
Latest (Desktop):
Mac: arm64: music-player-desktop_v0.2.1_aarch64-apple-darwin.tar.gz intel: music-player-desktop_v0.2.1_x86_64-apple-darwin.tar.gzLinux: amd64: music-player-desktop_v0.2.1_x86_64-unknown-linux-gnu.tar.gz arm64: music-player-desktop_v0.2.1_aarch64-unknown-linux-gnu.tar.gzLatest (CLI):
Mac: arm64: music-player_v0.2.1_aarch64-apple-darwin.tar.gz intel: music-player_v0.2.1_x86_64-apple-darwin.tar.gzLinux: music-player_v0.2.1_x86_64-unknown-linux-gnu.tar.gzWindows: music-player_x86_64-pc-windows-gnu.tar.gzmusic-player
The daemon scans your music directory ($HOME/Music by default), serves gRPC on :5051, WebSocket events on :5052, and the web UI + GraphQL on :5053.
The same binary is both the server and the client β the first instance starts the daemon, and any later invocation detects it and talks to it:
# terminal 1 β becomes the server
music-player
# terminal 2 β detects the daemon, acts as a client
music-player scan
USAGE:
music-player [SUBCOMMAND]
OPTIONS:
-h, --help Print help information
-V, --version Print version information
SUBCOMMANDS:
albums List all albums
artists List all artists
help Print this message or the help of the given subcommand(s)
next Play the next song
pause Pause the current song
open Open audio file
playlist Manage playlists
prev Play the previous song
queue Manage the queue
play Resume the current song
scan Scan music library: $HOME/Music
search Search for a song, album, artist or playlist
stop Stop the current song
tracks List all tracks
Run music-player while a daemon is running (or connect to a remote one with music-player connect -s <host>) to open the TUI. It ships with:
/) over tracks, albums and artists, ranked as you type with match highlighting?Main keys:
| Key | Action |
|---|---|
? | Help overlay with all keybindings |
/ | Fuzzy search (Tab switches Tracks/Albums/Artists scope) |
Space | Play / pause |
n / p | Next / previous track |
< / > | Seek β5s / +5s |
+ / - | Volume up / down |
z | Add selected track to the queue |
q / Esc | Back / quit |
The web UI is served by the daemon at http://localhost:5053 β React 18, TanStack Query and Jotai, with live playback position (GraphQL subscriptions) and a seekable progress bar.
The desktop app wraps the same UI with Tauri 2:
cd webui/musicplayer
bun install
bun run tauri dev # or: bun run tauri build
The Tauri app reuses an existing local music-player daemon when one is available; otherwise it starts the daemon in-process so playback and the GraphQL, gRPC, websocket, and web UI endpoints are available immediately.
# Start the server
music-player
Open http://localhost:5053/graphiql in your browser.
The library is indexed into SQLite FTS5 virtual tables that are kept in sync by database triggers β no separate index to maintain, and search works instantly over tracks (title/artist/album/genre), albums and artists with prefix matching:
music-player search "fire" # CLI
# GraphQL: query { search(keyword: "fire") { tracks { title } albums { title } artists { name } } }
Settings live in ~/.config/music-player/settings.toml (created on first run). Every key can also be set through a MUSIC_PLAYER_* environment variable (e.g. MUSIC_PLAYER_HTTP_PORT=5053).
music_directory = "/home/me/Music"
port = 5051 # gRPC
ws_port = 5052 # WebSocket events
http_port = 5053 # Web UI + GraphQL
device_name = "Music Player"
library_refresh_interval = 30 # rescan the music directory every N minutes (0 = off)
radio_browser_url = "https://de1.api.radio-browser.info"
tunein_url = "https://opml.radiotime.com"
scrobble = true # Rocksky scrobbling
atproto = true # AT Protocol sync (bookmarks, likes, listening status)
atproto_car_max_age_hours = 24 # re-download the atproto repo archive at most once a day
The library can also be refreshed manually at any time β music-player scan from the CLI, or the scan mutation in GraphQL. Re-scans only pick up what's new; existing entries are untouched.
By default audio goes to the system output device. audio_output redirects the decoded stream (raw S16LE stereo) somewhere else:
audio_output = "cpal" # system audio device (default)
audio_output = "stdout" # raw PCM to stdout
audio_output = "fifo:/tmp/mp.pcm" # named pipe
audio_output = "unix:/tmp/mp.sock" # unix socket
audio_output = "tcp:0.0.0.0:9000" # tcp socket, e.g.: ffplay -f s16le -ar 44100 -ac 2 tcp://host:9000
Browse and stream your remote library from any Subsonic-compatible server (Navidrome, Airsonic, gonic) or Jellyfin:
subsonic_url = "https://music.example.com"
subsonic_username = "alice"
subsonic_password = "secret"
jellyfin_url = "https://jellyfin.example.com"
jellyfin_username = "alice"
jellyfin_password = "secret"
Restart the daemon: the servers show up as source devices (in the web UI's device picker, or listDevices in GraphQL). Connect to one and its artists/albums/tracks/playlists are browsable, with tracks streamed straight from the server.
If you're logged into Rocksky (rocksky login writes ~/.rocksky/token.json), the daemon scrobbles what you play β using the classic rule (half the track, or 4 minutes, whichever comes first). Disable it with:
scrobble = false
Your radio bookmarks and liked songs live in your own atproto repo, so they follow you between devices and clients (atradio.fm, Rocksky, music-player).
Nothing here is required: with no account linked, music-player skips all of it and never touches the network for it. Turn the whole integration off with:
atproto = false
Linking an account. Reading your repo only needs your identity, which comes from rocksky login (~/.rocksky/token.json). Writing back needs a session β either atradio login, or password credentials in the environment:
export ATPROTO_IDENTIFIER=alice.bsky.social # handle, DID or email
export ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
The session file is shared with the atradio CLI, so signing in once covers both. The daemon logs at startup whether it is authenticated, and what is missing if not.
What syncs.
| Radio bookmarks | fm.atradio.favorite records are imported into your local bookmarks on startup, and bookmarking a station writes the record to your PDS (unbookmarking deletes it). Bookmarks that only existed locally are pushed up. |
| Liked songs | app.rocksky.like records are imported, then matched against your library on title + artist + album (case-insensitive, indexed). A match links the track to the song record through the new aturi column on track / album / artist. A like whose file isn't in the library yet is kept and re-matched after the next scan. |
| Listening status | While a station plays, it is published as your fm.atradio.actor.status record, written straight to your PDS; the record is deleted when playback stops. |
How it reads and stays in sync. The initial import downloads your repo once as a CAR archive (com.atproto.sync.getRepo) and walks its Merkle Search Tree, so one request covers every collection β listRecords is the fallback. After that the daemon subscribes to several public Jetstream instances at once and de-duplicates events by repo revision, so a like or bookmark added on another device shows up here within seconds without depending on any single instance staying up.
Downloading the repo again. Bookmarks and likes share the same archive, so a start pulls it at most once, and the date of the last successful download is kept in the database: a restart within atproto_car_max_age_hours keeps what is already imported instead of pulling the whole repo again (Jetstream has been applying changes in the meantime). To force a fresh download:
music-player --force-car-sync
atproto_force_car_sync = true # force it on every start
atproto_car_max_age_hours = 24 # otherwise re-download only once a day (0 = every start)
Playback isn't limited to the machine running the daemon β from the web UI or GraphQL you can cast to:
Rust
60.8%
TypeScript
27.3%
Slint
9.1%
Starlark
1.2%
An extensible music server written in Rust , single binary, zero dependency ππ΅β¨
512
stars
635
commits
Rust
primary language
Sep 6, 2026
updated
An extensible music player daemon, server and client, written in Rust β like mpd or Mopidy.
Audio decoding and playback are powered by the Rockbox firmware's battle-tested engine, via the rockbox-playback, rockbox-dsp and rockbox-metadata crates: 40+ audio formats, gapless-grade buffering, EQ/crossfade/ReplayGain DSP, and native HTTP streaming. The daemon indexes your library into SQLite (with FTS5 full-text search) and exposes it over gRPC, GraphQL and a web UI β controllable from the terminal UI, the browser, or the Tauri desktop app.
[!NOTE] Looking for more? If you're interested in this project, you might want to check out rockboxd, a music player daemon built on the Rockbox Open Source Firmware. It offers advanced audio playback features, bringing the best of Rockbox to modern platforms with the power of Zig and Rust.
? help overlayWith curl (downloads the prebuilt binary for your platform from GitHub releases):
curl -fsSL https://raw.githubusercontent.com/tsirysndr/music-player/master/install.sh | sh
# pin a specific release:
curl -fsSL https://raw.githubusercontent.com/tsirysndr/music-player/master/install.sh | MUSIC_PLAYER_VERSION=v0.2.1 sh
# also install the Slint desktop app (music-player-desktop):
curl -fsSL https://raw.githubusercontent.com/tsirysndr/music-player/master/install.sh | MUSIC_PLAYER_DESKTOP=1 sh
Using npm (downloads the prebuilt binary from GitHub releases):
npm install -g @tsiry/music-player # or: npx @tsiry/music-player
# pin a specific release: MUSIC_PLAYER_VERSION=v0.2.1 npx @tsiry/music-player
Using Homebrew (macOS/Linux):
brew install tsirysndr/tap/musicplayer
On Debian, Ubuntu, and other APT-based systems (amd64 and arm64):
echo "deb [trusted=yes] https://apt.fury.io/tsiry/ /" \
| sudo tee /etc/apt/sources.list.d/music-player.list
sudo apt-get update
sudo apt-get install music-player
On Fedora, RHEL, Rocky Linux, AlmaLinux, and other DNF-based systems
(x86_64 and aarch64):
sudo tee /etc/yum.repos.d/music-player.repo <<'EOF'
[music-player]
name=Music Player
baseurl=https://yum.fury.io/tsiry/
enabled=1
gpgcheck=0
EOF
sudo dnf install music-player
The Debian and RPM packages include both music-player and the Slint desktop
application, music-player-desktop. Launch the desktop app from your
application menu or run music-player-desktop.
Using Nix (macOS/Linux):
cachix use tsirysndr
nix profile install --experimental-features "nix-command flakes" github:tsirysndr/music-player
Or download the latest release for your platform here.
Without Nix:
# Install dependencies
brew install protobuf # macOS
sudo apt-get install -y libasound2-dev protobuf-compiler # Ubuntu/Debian
choco install protoc # Windows using Chocolatey Package Manager
# Compile
git clone https://github.com/tsirysndr/music-player.git
cd music-player/webui/musicplayer
nvm install # install node version specified in .nvmrc (optional on windows)
bun install && bun run build # build webui
cd ../..
cargo install --path .
With Nix:
git clone https://github.com/tsirysndr/music-player.git
cd music-player
nix develop --experimental-features "nix-command flakes"
cd webui/musicplayer
bun install && bun run build # build webui
cd ../..
cargo install --path .
Latest (Desktop):
Mac: arm64: music-player-desktop_v0.2.1_aarch64-apple-darwin.tar.gz intel: music-player-desktop_v0.2.1_x86_64-apple-darwin.tar.gzLinux: amd64: music-player-desktop_v0.2.1_x86_64-unknown-linux-gnu.tar.gz arm64: music-player-desktop_v0.2.1_aarch64-unknown-linux-gnu.tar.gzLatest (CLI):
Mac: arm64: music-player_v0.2.1_aarch64-apple-darwin.tar.gz intel: music-player_v0.2.1_x86_64-apple-darwin.tar.gzLinux: music-player_v0.2.1_x86_64-unknown-linux-gnu.tar.gzWindows: music-player_x86_64-pc-windows-gnu.tar.gzmusic-player
The daemon scans your music directory ($HOME/Music by default), serves gRPC on :5051, WebSocket events on :5052, and the web UI + GraphQL on :5053.
The same binary is both the server and the client β the first instance starts the daemon, and any later invocation detects it and talks to it:
# terminal 1 β becomes the server
music-player
# terminal 2 β detects the daemon, acts as a client
music-player scan
USAGE:
music-player [SUBCOMMAND]
OPTIONS:
-h, --help Print help information
-V, --version Print version information
SUBCOMMANDS:
albums List all albums
artists List all artists
help Print this message or the help of the given subcommand(s)
next Play the next song
pause Pause the current song
open Open audio file
playlist Manage playlists
prev Play the previous song
queue Manage the queue
play Resume the current song
scan Scan music library: $HOME/Music
search Search for a song, album, artist or playlist
stop Stop the current song
tracks List all tracks
Run music-player while a daemon is running (or connect to a remote one with music-player connect -s <host>) to open the TUI. It ships with:
/) over tracks, albums and artists, ranked as you type with match highlighting?Main keys:
| Key | Action |
|---|---|
? | Help overlay with all keybindings |
/ | Fuzzy search (Tab switches Tracks/Albums/Artists scope) |
Space | Play / pause |
n / p | Next / previous track |
< / > | Seek β5s / +5s |
+ / - | Volume up / down |
z | Add selected track to the queue |
q / Esc | Back / quit |
The web UI is served by the daemon at http://localhost:5053 β React 18, TanStack Query and Jotai, with live playback position (GraphQL subscriptions) and a seekable progress bar.
The desktop app wraps the same UI with Tauri 2:
cd webui/musicplayer
bun install
bun run tauri dev # or: bun run tauri build
The Tauri app reuses an existing local music-player daemon when one is available; otherwise it starts the daemon in-process so playback and the GraphQL, gRPC, websocket, and web UI endpoints are available immediately.
# Start the server
music-player
Open http://localhost:5053/graphiql in your browser.
The library is indexed into SQLite FTS5 virtual tables that are kept in sync by database triggers β no separate index to maintain, and search works instantly over tracks (title/artist/album/genre), albums and artists with prefix matching:
music-player search "fire" # CLI
# GraphQL: query { search(keyword: "fire") { tracks { title } albums { title } artists { name } } }
Settings live in ~/.config/music-player/settings.toml (created on first run). Every key can also be set through a MUSIC_PLAYER_* environment variable (e.g. MUSIC_PLAYER_HTTP_PORT=5053).
music_directory = "/home/me/Music"
port = 5051 # gRPC
ws_port = 5052 # WebSocket events
http_port = 5053 # Web UI + GraphQL
device_name = "Music Player"
library_refresh_interval = 30 # rescan the music directory every N minutes (0 = off)
radio_browser_url = "https://de1.api.radio-browser.info"
tunein_url = "https://opml.radiotime.com"
scrobble = true # Rocksky scrobbling
atproto = true # AT Protocol sync (bookmarks, likes, listening status)
atproto_car_max_age_hours = 24 # re-download the atproto repo archive at most once a day
The library can also be refreshed manually at any time β music-player scan from the CLI, or the scan mutation in GraphQL. Re-scans only pick up what's new; existing entries are untouched.
By default audio goes to the system output device. audio_output redirects the decoded stream (raw S16LE stereo) somewhere else:
audio_output = "cpal" # system audio device (default)
audio_output = "stdout" # raw PCM to stdout
audio_output = "fifo:/tmp/mp.pcm" # named pipe
audio_output = "unix:/tmp/mp.sock" # unix socket
audio_output = "tcp:0.0.0.0:9000" # tcp socket, e.g.: ffplay -f s16le -ar 44100 -ac 2 tcp://host:9000
Browse and stream your remote library from any Subsonic-compatible server (Navidrome, Airsonic, gonic) or Jellyfin:
subsonic_url = "https://music.example.com"
subsonic_username = "alice"
subsonic_password = "secret"
jellyfin_url = "https://jellyfin.example.com"
jellyfin_username = "alice"
jellyfin_password = "secret"
Restart the daemon: the servers show up as source devices (in the web UI's device picker, or listDevices in GraphQL). Connect to one and its artists/albums/tracks/playlists are browsable, with tracks streamed straight from the server.
If you're logged into Rocksky (rocksky login writes ~/.rocksky/token.json), the daemon scrobbles what you play β using the classic rule (half the track, or 4 minutes, whichever comes first). Disable it with:
scrobble = false
Your radio bookmarks and liked songs live in your own atproto repo, so they follow you between devices and clients (atradio.fm, Rocksky, music-player).
Nothing here is required: with no account linked, music-player skips all of it and never touches the network for it. Turn the whole integration off with:
atproto = false
Linking an account. Reading your repo only needs your identity, which comes from rocksky login (~/.rocksky/token.json). Writing back needs a session β either atradio login, or password credentials in the environment:
export ATPROTO_IDENTIFIER=alice.bsky.social # handle, DID or email
export ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
The session file is shared with the atradio CLI, so signing in once covers both. The daemon logs at startup whether it is authenticated, and what is missing if not.
What syncs.
| Radio bookmarks | fm.atradio.favorite records are imported into your local bookmarks on startup, and bookmarking a station writes the record to your PDS (unbookmarking deletes it). Bookmarks that only existed locally are pushed up. |
| Liked songs | app.rocksky.like records are imported, then matched against your library on title + artist + album (case-insensitive, indexed). A match links the track to the song record through the new aturi column on track / album / artist. A like whose file isn't in the library yet is kept and re-matched after the next scan. |
| Listening status | While a station plays, it is published as your fm.atradio.actor.status record, written straight to your PDS; the record is deleted when playback stops. |
How it reads and stays in sync. The initial import downloads your repo once as a CAR archive (com.atproto.sync.getRepo) and walks its Merkle Search Tree, so one request covers every collection β listRecords is the fallback. After that the daemon subscribes to several public Jetstream instances at once and de-duplicates events by repo revision, so a like or bookmark added on another device shows up here within seconds without depending on any single instance staying up.
Downloading the repo again. Bookmarks and likes share the same archive, so a start pulls it at most once, and the date of the last successful download is kept in the database: a restart within atproto_car_max_age_hours keeps what is already imported instead of pulling the whole repo again (Jetstream has been applying changes in the meantime). To force a fresh download:
music-player --force-car-sync
atproto_force_car_sync = true # force it on every start
atproto_car_max_age_hours = 24 # otherwise re-download only once a day (0 = every start)
Playback isn't limited to the machine running the daemon β from the web UI or GraphQL you can cast to:
Rust
60.8%
TypeScript
27.3%
Slint
9.1%
Starlark
1.2%