A terminal music player for local libraries. FLAC first, plus MP3/WAV/OGG/M4A. Your folders are your playlists. Nothing leaves your machine.
The command-line sibling of Mufify, an offline Android music player.
Status: 1.0.0. Scanning, configuration, the mpv engine, the terminal interface, listening statistics, shuffle and key bindings all work, and have been used against real FLAC libraries on macOS and Windows. That is the author's own use, not a crowd's — see Platform verification for what is proven and what is only derived, and HANDOFF.md for the long version.
- Node.js 22 or newer
- mpv — the audio engine, and a real dependency you must install yourself
mufify-cli does not bundle an audio decoder. It drives mpv over its JSON
IPC socket, which is why it plays FLAC, MP3, WAV, OGG, Opus, M4A and the rest without any
native compilation at install time.
brew install mpv # macOS
choco install mpv # Windows — see the note below
sudo apt install mpv # Debian, Ubuntu
sudo dnf install mpv # Fedora
sudo pacman -S mpv # ArchOn Windows, winget install mpv.mpv does not fetch mpv, and scoop needs scoop update
before scoop install mpv will work. Chocolatey is the one that installs it without a
dance. Reported from a real Windows run.
Full instructions: https://mpv.io/installation/
mufify-cli will never install mpv for you. Installing system software on your behalf is not something a music player should do quietly. If mpv is missing you get the command above, not a stack trace.
Why an external player instead of a native Node audio binding? Because the native options
were measured and they do not survive a modern npm install — npm 12 blocks package install
scripts by default, so every prebuilt binary in that ecosystem silently fails to arrive. The
evidence is written up in
ADR 001.
npm install -g mufify-cliThe package has no install scripts. Nothing is compiled or downloaded when you install it.
mufify-cli config set musicDir ~/Music
mufify-cliThat last one opens your library: playlists, albums, artists, all songs and favourites.
Move with ↑↓ or j/k, open with ⏎, go back with ⌫, quit with q.
| Command | What it does |
|---|---|
mufify-cli |
Open the library |
mufify-cli play [playlist] |
The same, or jump straight into one playlist |
mufify-cli stats |
Show listening statistics |
mufify-cli keybindings |
Change any shortcut |
mufify-cli favorites list |
Show every marked track |
mufify-cli favorites add <path> |
Mark a track |
mufify-cli favorites remove <path> |
Unmark a track |
mufify-cli doctor |
Check that mpv is installed and usable |
mufify-cli scan [path] |
Walk the music root and report the playlists it finds |
mufify-cli config list |
Show every setting |
mufify-cli config get <key> |
Print one setting |
mufify-cli config set <key> <value> |
Change one setting |
mufify-cli config path |
Print the config file location |
Global flags override persisted settings for one run only:
mufify-cli --shuffle balanced --volume 80 scan--music-dir <path>, --shuffle off|pure|balanced|discovery|favorites|album,
--repeat off|all|one,
--volume 0-100. Add -y / --yes to scan to skip confirmation prompts in scripts.
Run mufify-cli --help or mufify-cli <command> --help for the full reference.
| Default | Does |
|---|---|
| ↑↓ or j/k | move (wraps at both ends) |
| ⏎ or → | open |
| ⌫ or ← | back — at the top level this quits |
| PgUp/PgDn | jump a screenful |
| g / e | first / last row |
| f | mark the highlighted track as favourite |
| / | search — filters as you type |
| tab | open the player |
| q | quit |
Every shortcut here and in the player can be changed: Key bindings in the menu, or
mufify-cli keybindings. Pick a row, press ⏎, then press the key you want; x unbinds one
and d puts it back. The footer is drawn from your bindings, so it always shows the keys you
actually have. Changes go to keybindings.json next to your config, and only the ones you
changed are stored — a later release that improves a default still gives it to you.
Hidden actions are in that list too. Page up and down, jump to first and last, "back to the playing track": all rebindable, even though the footer has no room to advertise them.
| Default | Does |
|---|---|
| space | play / pause |
| n / p | next / previous (previous restarts after 10s in) |
| ← / → | seek 5 seconds |
| ↑ / ↓ | volume 5% |
| r | cycle repeat: off → all → one |
| f | mark / unmark the current track as a favourite |
| tab | back to the library |
| j / k | move down / up the queue |
| ⏎ | play the highlighted track |
| g | put the cursor back on what is playing |
| q | quit |
Holding an arrow key is collapsed into a single command rather than flooding the player, so seeking stays responsive instead of chasing the keyboard. Held keys that arrive several at a time are each honoured rather than dropped.
The queue scrolls to keep the cursor in view and grows to fill the terminal. j and k
browse it — the arrows stay on seek and volume — and the cursor stops following playback
until you press g or play something with ⏎, so the list never jumps out from under you
mid-read.
mufify-cli config set shuffle balanced # persists
mufify-cli --shuffle album play # this run only| Mode | Does |
|---|---|
off |
Plays in library order |
pure |
Uniform random. Honest, and it clusters — that is what uniform means |
balanced |
Spreads each artist evenly, so nothing plays three deep by one act |
discovery |
Weights toward what you have played least, without excluding the rest |
album |
Shuffles the albums and leaves the running order inside each one alone |
Favourites are marked with f while playing, or with mufify-cli favorites add. They are
stored beside the play counts, and a track can be marked before it has ever been played.
A track with no artist tag is its own artist, and one with no album is its own album — untagged files are usually unrelated, so treating them as a single act would spread them as if they were one. Album order inside an album is the order the files sort in; neither mufify-cli nor Mufify reads the track-number tag.
play needs an interactive terminal. Piped into anything it exits with a message rather
than drawing escape codes into a file — use scan and stats for that, both of which pipe
cleanly.
An album plays in the order it was recorded in: disc number, then track number, read from the tags. Files with nothing to go on sort last rather than being guessed at, and fall back to the title. An artist plays one album at a time, each in its own order — not every album's track 1 followed by every album's track 2.
Playlists are the exception, on purpose. A playlist is a folder, and a folder can be a mixtape whose files you numbered yourself; those files carry the track numbers of the albums they came from, so tag order would undo a sequence you chose. Playlists stay in filename order. If your folder is an album, open it under Albums to hear it in order. ADR 014.
Every first-level folder inside your music root is one playlist, named after the folder.
~/Music/
├── Chill/ → playlist "Chill"
│ ├── Disc 1/ → not its own playlist; its tracks belong to "Chill"
│ └── Disc 2/
├── Metal/ → playlist "Metal"
└── stray.flac → playlist "Music", named after the root folder
- Nested folders are not separate playlists. Their tracks are collected upward into the first-level folder above them.
- A first-level folder with no audio anywhere beneath it produces no playlist.
- Files loose in the root become one playlist named after the root folder itself.
- The number of playlists always equals the number of non-empty first-level folders.
Symlinks work, and are a good way to curate: Chill/ambient -> Albums/Ambient One puts that
album in the Chill playlist as well as Albums. Link loops are detected and skipped rather
than followed forever, and a link pointing outside the music root is ignored.
Full reasoning: ADR 005.
If the music root looks far too broad — a drive root, your home directory, Desktop,
Downloads, a system directory — or if it holds more than 50,000 audio files, scan stops
and asks before walking it. The threshold is configurable:
mufify-cli config set scanFileLimit 200000| Platform | Location |
|---|---|
| Linux | ~/.config/mufify-cli/ |
| macOS | ~/Library/Preferences/mufify-cli/ |
| Windows | %APPDATA%\mufify-cli\Config\ |
config.json holds your settings; stats.json will hold listening statistics. Both are
written atomically — a crash or Ctrl+C mid-write cannot corrupt them — and both carry a
schema version so future releases can migrate them.
Override the whole directory with MUFIFY_CONFIG_DIR.
This table is a factual claim about what has actually been run, not a compatibility promise. Please keep it honest.
| Platform | Status |
|---|---|
| macOS (arm64) | Developed and run here. Scanning, config, and playback against a real mpv 0.41.0 verified end to end. |
| Linux | Not run. Same POSIX paths and the same process watchdog as macOS; no known reason to fail. |
| Windows | Run. The named pipe, playback, browsing and key bindings all work. Two gaps remain, listed below. |
Windows was written blind — the platform is passed in as an argument so it could be unit-tested from macOS — and has since been run for real, including the whole test suite. That run found a genuine bug: replacing a file by renaming over it is atomic on POSIX but fails on Windows when anything else holds the target open, so statistics writes are now retried. What the unit tests prove is still only that the derivation is right.
Two of the suite's tests are skipped on Windows, visibly rather than quietly. chmod
there does nothing to a directory, and one process cannot send another a real SIGINT —
Node turns it into a hard kill. Neither is a gap in the program: a real Ctrl+C in a console
does take the graceful path, and that ordering is pinned in-process on every platform. See
ADR 004.
On macOS and Linux, mpv is given an inherited pipe and started with
--input-ipc-client, so it quits by itself the moment our end closes — even if
mufify-cli is SIGKILLed and no cleanup code of ours can run. mpv's manual requires a
specially constructed Win32 named pipe for that mechanism, which we cannot build without a
machine to test it on.
So on Windows, a hard kill of mufify-cli may leave an mpv process running. Ordinary exits and Ctrl+C are handled everywhere; it is only the uncatchable case that differs. See ADR 003.
npm install
npm run dev -- --help
npm run lint
npm run typecheck
npm test
npm run buildmpv must be installed to run the tests. The audio engine is verified against a real
mpv process, and those tests fail loudly rather than skipping when it is missing — a
skipped process-lifecycle test reports green while proving nothing. Test playback runs
with --ao=null, so the suite decodes real audio without making any noise.
Start with CONTRIBUTING.md. House rules are in AGENTS.md, and the reasoning behind the awkward parts is in docs/adr/.
MIT — see LICENSE.