A single standalone Rust daemon that (a) speaks the MPD text protocol on TCP so ncmpcpp and every other MPD client keeps working unchanged, and (b) is itself an OpenSubsonic client + audio player.
The name is from the Roman hypocaust (Greek hypo-, "beneath" + kaustos,
"burnt") - the furnace and flue chamber below a bath that heated the room above,
tended out of sight. hypodj is the DJ underneath: it does the real work
(browsing, streaming, playing your Navidrome library) hidden below, while your
MPD client lounges in the warm room.
It replaced the mopidy + mopidy-subidy Python stack entirely - no Python, no
mopidy core, no MPRIS/GStreamer glue. ncmpcpp connects to hypodj exactly as it
connected to mopidy; hypodj translates MPD commands into OpenSubsonic REST calls
(browse / search / star / scrobble) and drives a local audio engine that streams
the resolved URLs.
ncmpcpp ──MPD text protocol──▶ hypodj ──OpenSubsonic REST──▶ Navidrome
(unchanged) (TCP) │ daemon (browse/search/ (or any
│ stream/scrobble) OpenSubsonic
▼ server)
libmpv audio
(streams the resolved URLs)
One process. ncmpcpp thinks it is talking to MPD; the daemon browses/searches a Subsonic library and plays the streams through mpv.
- Phase 0 - FOUNDATION. DONE. Cargo workspace, nix devshell, config, the
OpenSubsonic client wrapper, the internal domain model, the player actor
boundary (
PlayerHandle) with a headlessNullPlayer, and the MPD command/response/handler interface. Theprobebinary proves the slice against a live server: config -> auth/ping -> browse -> resolve stream URL. - Phase 1 - MpvPlayer + real browse. DONE. Real playback behind the same
PlayerHandle: a dedicated thread owninglibmpv2::Mpv, driven by the command channel, pushingtime-pos/eofback out asPlayerEvents (which drive queue-advance + scrobble). Browse mapping is real. Proven live + headless by theplay-probebinary. - Phase 2 - MPD server loop. DONE. The tokio TCP accept loop + line parser
(quoted args) + dispatch, bound to
127.0.0.1:6601in dev. AHypodjHandlerbacks the ncmpcpp-critical command subset with live Subsonic browse/search + the player over a shared in-memory queue. Syntheticartist/<id>/album/<id>/song/<id>URIs bridge MPD's path model to Subsonic ids;lsinfodrills root -> artists -> albums -> songs.command_list[_ok]batching +idle/noidle. Verified live against Navidrome with a real MPD client. - Phase 3 - feature parity. DONE. All 9 shipped Python features ported and
live-verified against Navidrome (scrobble, cover art, star/love + rating via
MPD
sticker, similar/radio/top, smart album lists, genres, search3 with typed-tag filtering + richer metadata, TTL+LRU listing cache, OpenSubsonic extension negotiation). See the honest feature-status section below. - Nix packaging. DONE. The flake exposes
packages.default(the daemon, libmpv wrapped),nixosModules.default,homeManagerModules.default, and anoverlays.default- see Usage. Password stays out of the store (systemdLoadCredential+ a runtime-rendered config). - Phase 4 - cut over. DONE. hypodj is the daily driver: the deployment binds
127.0.0.1:6600(viaservices.hypodj.mpd.bind) and mopidy + mopidy-subidy are retired. Note the in-codeconfig.rsdefault is still6601; production sets6600explicitly.
Parity is done; the project is now growing hypodj from a protocol proxy into a
music server that understands human intent. The design is a deterministic
capability core + a typed Plan-IR trigger/executor over stable primitives, with
an optional natural-language translator that only ever emits a validated plan,
and embeddings reserved for content selection. Phases: P0 fade primitive
(cancellable dB-domain volume envelope - done) -> P1 event substrate ->
P2 Plan IR + executor + DSL -> P3 natural-language front-end ->
P4 mood/energy selection. Post-parity MPD additions already landed:
findadd/searchadd, count, filtered list <tag> <filter>, composer/performer
tags, and a fade command.
The 9 Python-parity features are ported and reachable through the live MPD serve
loop (the in-code default bind is 6601; the live deployment binds 6600):
- scrobble - now-playing + threshold-gated completed-play submission, fired off the player event loop.
- cover art -
albumart/readpicture-> getCoverArt, chunked tobinarylimit, cached. - star / love - the synthetic
Starredplaylist:playlistadd Starred song/<id>stars, position-basedplaylistdeleteunstars. - rating - WIRED via the MPD
stickercommand (ncmpcpp's rating path):sticker set song song/<id> rating <0-5>-> SubsonicsetRating;sticker get/listread backuserRatingassticker: rating=<n>;sticker deleteclears it (setRating 0). Proven live against Navidrome: an MPDset rating 4is confirmed bygetSongreturninguserRating=4, and cleared bydelete. Only theratingsticker is backed (no generic sticker store). - similar / radio / top -
radio/random,radio/similar/<songId>,radio/top/<artist>browse dirs. LIMITATION:similar/topreturn only what the server's last.fm-backed data provides; on a server without that data they can legitimately be empty (not a client bug). - smart album lists -
Lists/{frequent,newest,recent,highest,random}. - genres -
Genresbrowse dir +list genre. - search3 -
find/search-> search3 (full-text) + client-side MPD-tag post-filter for precision. - listing cache - TTL+LRU over stable listings; freshness-critical listings
(
Starred,random) are never cached; a rating/star bust invalidates the listings whose per-song flags could change. - OpenSubsonic extension negotiation - probed + logged once at connect. LIMITATION: the advertised set is currently only recorded (no behaviour is gated on it yet, because every shipped feature is core Subsonic).
Known honest limitation in idle: it always emits changed: player on any
change (single change-notifier; no per-subsystem tracking yet). ncmpcpp re-reads
status/currentsong/plchanges on any changed: line, so its view still refreshes.
Everything below is built, compiles, and is tested (the full workspace passes
cargo test; the feature paths are additionally live-verified against Navidrome
via the probes and a real MPD client). Parity and the cut-over are done; new work
follows the smart-server roadmap above.
config.rs- TOML config; creds read from a file, never hardcoded. Default MPD bind127.0.0.1:6601; the live deployment overrides this to6600.model.rs- internal domain types (SongId/AlbumId/ArtistIdnewtypes,Artist/Album/Song/Genre), decoupled from theopensubsonicwire types.subsonic.rs-SubsonicClientoveropensubsonic::Client: connect (token auth, configurable client name), ping, browse (artists/albums/songs), search3, scrobble, star/unstar/set_rating, get_starred2, similar/top/random songs, album lists by type, genres + songs-by-genre, cover art bytes, and OpenSubsonic extension negotiation. Wire->model mapping is unit-tested against the real wire structs and exercised live.player.rs- the player actor boundary (PlayerHandle) andMpvPlayer, a real libmpv-backed actor (dedicated thread owninglibmpv2::Mpv) that pushestime-pos/eofevents driving queue-advance + scrobble.AudioOutselects headless (Null/File) orDeviceoutput; init failure falls back toNullPlayerand never panics the daemon.mpd.rs+handler.rs- the MPD server: accept loop, line parser (quoted args), response/binary/ack framing, and dispatch of the ncmpcpp command set (status/currentsong/idle, playback, queue,lsinfobrowse of the synthetic Genres/Lists/Radio/Starred dirs,albumart/readpicture,stickerratings,list/search/findwith typed-tag filtering, the editableStarredplaylist star trigger).cache.rs- the bounded TTL+LRU listing cache (freshness-critical listings stay uncached; star/rating busts affected listings).scrobble.rs- now-playing + threshold-gated completed-play submission off the player event loop.nix/+flake.nix-packages.default(daemon, libmpv wrapped),nixosModules.default,homeManagerModules.default,overlays.default, and the devshell. See Usage.probe.rs/play-probe- the "test with a real server, not mocks" provers.
The probe binary is the "test with a real server, not mocks" proof:
nix develop
# create a config with your server creds (see subsonity.toml.example)
cargo run -j2 --bin probe -- ./my-config.toml <song-id>
It: (1) loads the TOML config, (2) authenticates + pings, (3) browses (lists
artists + newest albums, prints real counts + a sample name), (4) resolves a
real stream URL for the given song id. Step 4 deliberately stops at the resolved
URL - that is the exact handoff point to MpvPlayer.play_url(song, url).
Verified live against Navidrome: ping OK, 89 artists / 20 albums with real
names, and a resolved stream URL that independently returns audio/flac
(HTTP 206, range-capable - what mpv needs for seeking). The stream URL carries
the auth token in its query string, so the probe prints only scheme/host/path
and redacts the query.
play-probe extends the slice through REAL, headless audio decode:
nix develop
cargo run -j2 --bin play-probe -- ./my-config.toml /tmp/out.wav 6
It browses -> lists the first album's songs (get_album) -> picks a track ->
resolves its stream URL -> hands it to MpvPlayer configured with
AudioOut::File (mpv encodes decoded PCM to a WAV; no audio device is ever
opened) -> plays a few seconds -> stops -> asserts the WAV grew to real size.
Verified live + headless against Navidrome: mpv reached Playing, time-pos
advanced to ~27s, and a 4.7 MB WAV was captured. The file independently
re-decodes as pcm_s16le 2ch 44100 Hz under mpv, confirming real audio (not a
stub). songrec did not recognize the sample track (niche electronic release,
not in Shazam's DB), so the proof is the bytes-decoded + re-decode check - the
sanctioned fallback. Nothing was ever sent to the speakers.
- Wire<->model boundary (
model.rs+subsonic.rs). Nothing outsidesubsonic.rstouches theopensubsoniccrate's structs. Swapping or version- bumping the client crate is a one-file blast radius.opensubsonicis pinned to=0.3.0(see "Accepted risks"). - Player is an actor, not a
&mut selftrait.libmpv2::Mpvis not freelySend/Syncand mpv's event loop is a blocking pull that must be drained. The boundary is a cloneablePlayerHandle(commands over a channel, state viawatch, events out via a channel). This is settled in Phase 0 - before the MPD server is written on top of it - so the trait shape does not have to break when the real mpv thread lands in Phase 1.NullPlayerexercises the exact boundary today. - MPD state is shared, so
MpdHandler::handleis&self. MPD's queue / current song / volume / idle subscriptions are shared across all client connections. The handler isArc-shared behind interior mutability / the player actor, never&mut self(which would wrongly imply per-connection state). - ncmpcpp-blocking commands are first-class. ncmpcpp does not tolerate ACK
for every unknown command: if
listplaylists/listplaylistinfo/loaderror it can infinite-loop and freeze, and a badplchangesshape blanks its playlist (verified from the beets/bpd port). So those, plus the capability probes (commands/tagtypes/outputs/decoders/urlhandlers), are explicitMpdCommandvariants now - the dispatch author cannot forget them. - Binary sub-protocol modeled now.
albumart/readpictureare notkey: valuepairs - they are framedsize:/binary:/<raw bytes>/OKchunked tobinarylimit.MpdResponse::Binaryrepresents that in the foundation (andget_cover_artreturns ownedBytes, exactly what chunking needs). - Advertised MPD version tracks the implemented surface. The greeting version
(
ADVERTISED_MPD_VERSION) is0.23.0- it promises the binary (albumart/readpicture) and modern filter-expression syntax the server actually backs. Bump it in lockstep with the implemented surface, never ahead of it. - TLS is rustls, not OpenSSL.
opensubsonic 0.3pullsreqwest 0.13withdefault-features = false+rustls. The devshell intentionally does NOT ship openssl - nothing would link it.
opensubsonic 0.3.0is young and single-maintainer (first published Feb 2026, low download count). Mitigation: theSubsonicClientwrapper + theSubsonicError-as-string boundary mean a fallback to hand-rolledreqwestcalls is survivable, and the dep is pinned to=0.3.0(withCargo.lockcommitted) so a silent minor bump cannot reshape the wire types this layer maps.- libmpv is a C system dependency (now linked via
libmpv24.1 /libmpv2-sys). Mitigation: the nix devshell provides it reproducibly (mpv-unwrapped+pkg-config). De-risked:play-probeconstructs a reallibmpv2::Mpvand decodes a live stream URL to a WAV through the channel wrapper. If libmpv is missing at runtime,MpvPlayer::spawnlogs and falls back toNullPlayerrather than panicking. - The MPD server is hand-rolled (no server-side crate exists;
mpd/mpd_clientare clients,mpd_protocolis a client codec). The risk is the scope of the command set, not per-command difficulty - which is why the ncmpcpp-critical surface is enumerated in the interface now.
The flake ships a package plus a NixOS module and a Home-Manager module - ONE
shared services.hypodj definition. Import the module for your system, point it
at your OpenSubsonic/Navidrome server, and connect ncmpcpp to the configured
mpd.bind (the live deployment uses 127.0.0.1:6600).
The Navidrome password is read from passwordFile (or passwordCommand) at
service start into a 0600 runtime config under /run - it is never written to
the Nix store. Only a template with a @HYPODJ_PASSWORD@ placeholder lives in
the store; the real password is substituted at start.
{
inputs.hypodj.url = "github:FamiliarTools/hypodj"; # or path:/tmp/hypodj while local
# in your host module (config is in scope here):
imports = [ hypodj.nixosModules.default ];
# sops-nix secret (assumed you already run sops-nix); any 0600 file works too.
sops.secrets."hypodj/password" = { };
services.hypodj = {
enable = true;
server.url = "https://navidrome.example.com";
server.username = "guilherme";
server.passwordFile = config.sops.secrets."hypodj/password".path;
# mpd.bind stays 127.0.0.1:6601 ; audio stays "null" (headless).
};
}The NixOS module wires hypodj.overlays.default automatically, so
services.hypodj.package defaults to pkgs.hypodj. The system service runs
under DynamicUser with LoadCredential (credential name hypodj-password),
so a root-owned sops secret is read without any ownership juggling: systemd
stages the file into $CREDENTIALS_DIRECTORY readable by the (dynamic) service
user, and the pre-start render step reads it there - this works despite
DynamicUser + ProtectHome=true.
passwordFile is typed str, not path, on purpose: pass a runtime path
string such as config.sops.secrets."hypodj/password".path. It is used verbatim
as a runtime path and is never copied into the Nix store.
{
inputs.hypodj.url = "github:FamiliarTools/hypodj";
# add the overlay so pkgs.hypodj (the module's default package) resolves:
nixpkgs.overlays = [ hypodj.overlays.default ];
imports = [ hypodj.homeManagerModules.default ];
services.hypodj = {
enable = true;
server.url = "https://navidrome.example.com";
server.username = "guilherme";
server.passwordFile = "/run/secrets/hypodj-password"; # any 0600 file you own
};
}Exactly one of passwordFile / passwordCommand must be set. The command's
stdout is the password, read at start:
services.hypodj.server.passwordCommand = [ "pass" "show" "navidrome/guilherme" ];Note: passwordCommand runs in ExecStartPre with the service's own
privileges and sandbox (on NixOS that means DynamicUser + ProtectHome=true).
It must not depend on reading anything the hardened service cannot reach. For a
sops secret under a user home or root-owned, prefer passwordFile, which is
staged via systemd LoadCredential and is readable regardless of the sandbox.
ncmpcpp -h 127.0.0.1 -p 6601
# or: mpc -h 127.0.0.1 -p 6601 status
Set services.hypodj.audio = "device" only when you actually want hypodj to own
audio output; the default "null" keeps it headless so it never grabs your
speakers while mopidy runs.
crates/hypodj-core/ library: config, model, subsonic, player, mpd
crates/hypodj-daemon/ binaries: `hypodj` (daemon) + `probe` (slice prover)
flake.nix packages.default, devShell, nixos/home-manager modules
nix/package.nix buildRustPackage (daemon bin, libmpv wrapped)
nix/hypodj-module.nix ONE shared services.hypodj module (nixos + home-manager)
nix/overlay.nix overlays.default -> pkgs.hypodj
Builds are capped at -j2 (CARGO_BUILD_JOBS=2, set in the devshell). Dev
profile uses opt-level = 0 to keep foundation builds cheap.
nix develop --command cargo build -j2
nix develop --command cargo test -j2