tapedeck
ListenBrainz builds you a fresh Weekly Jams and Weekly Exploration every week.
tapedeck turns them into music you own. It looks every track up on MusicBrainz, downloads it with yt-dlp, writes proper tags, files it into a clean Artist/Album/Track tree, keeps an m3u8 per playlist and pushes the playlist to Plex. Run it once, or let the daemon do it every few hours.
Vibe coded, built for one person. tapedeck is written with AI assistance for my own setup and comes with no guarantees. You are welcome to try it and to file issues; I will try to fix them.
ListenBrainz ──▶ MusicBrainz ──▶ yt-dlp ──▶ tags ──▶ Artist/Album (Year)/01 - Title.opus
playlists metadata audio lofty │
├──▶ playlists/*.m3u8
└──▶ Plex playlist
I used Explo for this and wanted a few things it
doesn’t do: a real library layout instead of a flat folder of files, a database so a song
that shows up in three weekly playlists is downloaded once, a daemon with a CLI that talks
to it, and output that scripts (or an AI agent) can parse. tapedeck is that, in Rust. The
binary is called td.
What you get
- A library, not a dump. Lidarr-style path templates:
{album_artist}/{album} ({year})/{track:02} - {title}.{ext}by default, yours if you prefer. - Metadata from MusicBrainz, keyed by the recording IDs ListenBrainz already gives you. Consistent artist, album, year and track number, and a hard guarantee against duplicates.
- Tags written by tapedeck, not by whatever the download site had. Album artist and track artist are kept apart, so featured guests don’t split an album across folders.
- An m3u8 per playlist, kept in step with the playlist on Plex and readable by any player, with last week’s copy archived. Tracks that drop out of a playlist stay in your library.
- Downloads from YouTube Music’s song results first, matched by length against the MusicBrainz recording and checked again after the download, so you get the album audio rather than the music video with the two-minute intro.
- Plex sync that scans the library, matches files and recreates the playlist.
- Plays, too:
Enterin the TUI plays a track and queues the rest through mpv, locally or streamed from the NAS, and scrobbles to ListenBrainz so next week’s playlists know. - Use it from your laptop: the daemon serves its commands and the audio over HTTP behind a
token, so
td tuion another machine browses and controls the NAS. - Song requests from Telegram: send a link or “Artist - Title”, get the proper recording filed and a yearly Requests playlist on Plex; it asks only when it is not sure.
- Other libraries respected: point it at Lidarr’s folder and a recording that exists there is never downloaded again; playlists use the better copy, and own duplicates can be swapped out.
- Daemon or one-shot, same CLI.
td daemonruns on an interval and listens on a Unix socket; every othertdcommand talks to it if it’s running and does the work itself if not. --jsoneverywhere. One JSON object per line, for scripts and agents.- Secrets stay out of the config with
token_file, and SQLite is the single source of truth. - Nix first-class: flake, binary cache, Home Manager module with a systemd user service.
Built with
Rust · clap · rusqlite · lofty · reqwest · tokio · yt-dlp · MusicBrainz · ListenBrainz
Where to go next: Installation, then Quick start. The reference has every configuration key and every command. The code is at github.com/arunoruto/tapedeck.
Installation
Prerequisites
- A ListenBrainz account with listening history, so it has recommendations to generate.
yt-dlpandffmpegonPATH, unless you install through Nix, which bundles them.- Optionally a Plex server and its token.
Install the binary
Prebuilt binaries for Linux and macOS (x86_64 and arm64) are attached to each release. With bin:
bin install github.com/arunoruto/tapedeck
Nix, with x86_64-linux builds served from arunoruto.cachix.org (the flake advertises
the cache; accept it with --accept-flake-config or add it to your substituters):
nix run github:arunoruto/tapedeck -- --help
Cargo:
cargo install --git https://github.com/arunoruto/tapedeck
Container
Images for amd64 and arm64 are on the GitHub registry, built from the same Nix package as
the flake (td, yt-dlp, ffmpeg, no mpv). latest always points at the newest release;
each release is also tagged with its version (0.1.0), and manual builds from main appear
as sha-<short sha> only, so latest never moves to an untested commit.
docker run -d --name tapedeck \
-v /srv/music:/music -v tapedeck-data:/data -p 8687:8687 \
-e TAPEDECK_LIBRARY=/music -e TAPEDECK_LISTENBRAINZ__USER=you \
-e TAPEDECK_SYNC__TARGETS='["plex"]' -e TAPEDECK_SYNC__PLEX__URL=http://plex:32400 -e TAPEDECK_SYNC__PLEX__SECTION=Music \
-e TAPEDECK_SYNC__PLEX__TOKEN_FILE=/run/secrets/plex-token -v ./plex-token:/run/secrets/plex-token:ro \
ghcr.io/arunoruto/tapedeck:latest
docker exec -it tapedeck td status # any command goes through the daemon's socket
Or as a compose.yaml, with the secret as a file next to it:
services:
tapedeck:
image: ghcr.io/arunoruto/tapedeck:latest
container_name: tapedeck
restart: unless-stopped
ports:
- "8687:8687"
volumes:
- /srv/music:/music
- tapedeck-data:/data
# - ./config.toml:/config.toml:ro # instead of, or on top of, the variables below
# - ./themes:/themes:ro
environment:
TAPEDECK_LIBRARY: /music
TAPEDECK_LISTENBRAINZ__USER: you
TAPEDECK_LISTENBRAINZ__PLAYLISTS: '["weekly-jams", "weekly-exploration", "daily-jams"]'
TAPEDECK_SYNC__TARGETS: '["plex"]'
TAPEDECK_SYNC__PLEX__URL: http://plex:32400
TAPEDECK_SYNC__PLEX__SECTION: Music
TAPEDECK_SYNC__PLEX__TOKEN_FILE: /run/secrets/plex-token
secrets:
- plex-token
secrets:
plex-token:
file: ./plex-token
volumes:
tapedeck-data:
docker compose up -d
docker compose exec -it tapedeck td status
For podman with systemd, a Quadlet
in ~/.config/containers/systemd/tapedeck.container (rootless) or
/etc/containers/systemd/tapedeck.container (system) does the same. The token goes into a
podman secret first, so it is never part of the unit:
podman secret create plex-token ./plex-token
[Unit]
Description=tapedeck music sync daemon
After=network-online.target
Wants=network-online.target
[Container]
Image=ghcr.io/arunoruto/tapedeck:latest
ContainerName=tapedeck
# Follows new releases when podman-auto-update.timer is enabled; latest only ever moves to a release.
AutoUpdate=registry
PublishPort=8687:8687
Volume=/srv/music:/music
Volume=tapedeck-data:/data
# Volume=%h/.config/tapedeck/config.toml:/config.toml:ro # instead of, or on top of, the variables
Environment=TAPEDECK_LIBRARY=/music
Environment=TAPEDECK_LISTENBRAINZ__USER=you
Environment=TAPEDECK_LISTENBRAINZ__PLAYLISTS=["weekly-jams", "weekly-exploration", "daily-jams"]
Environment=TAPEDECK_SYNC__TARGETS=["plex"]
Environment=TAPEDECK_SYNC__PLEX__URL=http://plex:32400
Environment=TAPEDECK_SYNC__PLEX__SECTION=Music
Environment=TAPEDECK_SYNC__PLEX__TOKEN_FILE=/run/secrets/plex-token
Secret=plex-token,type=mount
[Service]
Restart=always
[Install]
WantedBy=default.target
systemctl --user daemon-reload && systemctl --user start tapedeck # drop --user for a system unit
journalctl --user -u tapedeck -f
podman exec -it tapedeck td status
For a rootless unit that should run without you logged in, loginctl enable-linger $USER;
for a system unit use WantedBy=multi-user.target.
On NixOS the same container is a few lines of virtualisation.oci-containers, with the token
coming straight from sops-nix (the native module below is the better fit on NixOS,
but this keeps tapedeck next to other containers you already run this way):
virtualisation.oci-containers = {
backend = "podman";
containers.tapedeck = {
image = "ghcr.io/arunoruto/tapedeck:latest";
ports = [ "8687:8687" ];
volumes = [
"/srv/music:/music"
"tapedeck-data:/data"
"${config.sops.secrets."tapedeck/plex-token".path}:/run/secrets/plex-token:ro"
# "/etc/tapedeck/config.toml:/config.toml:ro" # instead of, or on top of, the variables
];
environment = {
TAPEDECK_LIBRARY = "/music";
TAPEDECK_LISTENBRAINZ__USER = "you";
TAPEDECK_LISTENBRAINZ__PLAYLISTS = ''["weekly-jams", "weekly-exploration", "daily-jams"]'';
TAPEDECK_SYNC__TARGETS = ''["plex"]'';
TAPEDECK_SYNC__PLEX__URL = "http://plex:32400";
TAPEDECK_SYNC__PLEX__SECTION = "Music";
TAPEDECK_SYNC__PLEX__TOKEN_FILE = "/run/secrets/plex-token";
};
extraOptions = [ "--label=io.containers.autoupdate=registry" ];
};
};
That becomes podman-tapedeck.service; journalctl -u podman-tapedeck -f and
podman exec -it tapedeck td status work as above.
The image runs td daemon. Configuration comes from environment variables,
from a file mounted at /config.toml, or both (environment wins); themes go next to the
file, in /themes. The database and the socket live under /data, so give that a volume.
The HTTP API listens on 8687 by default (Lidarr’s port plus one), so td on another machine
and the TUI’s playback work with remote.url = "http://<host>:8687"; set
TAPEDECK_SERVER__LISTEN= (empty) to switch it off, or TAPEDECK_SERVER__TOKEN_FILE to
require a token for admin commands. The container runs as root unless you pass
--user <uid>:<gid> matching the owner of the library.
NixOS
On a server tapedeck should run like Plex or Lidarr do: as its own system user from boot,
secrets from the system’s sops-nix, files shared with the media group, logs in
journalctl -u tapedeck. The flake exports a NixOS module for that. Add the flake as an input,
import tapedeck.nixosModules.default, then:
services.tapedeck = {
enable = true;
extraGroups = [ "media" ]; # the group that owns the library
settings = {
library = "/srv/music/tapedeck";
listenbrainz = { user = "you"; playlists = [ "weekly-jams" "weekly-exploration" "daily-jams" ]; };
sync.targets = [ "plex" ];
sync.plex = {
url = "http://127.0.0.1:32400";
token_file = config.sops.secrets."tapedeck/plex-token".path;
section = "Music";
};
};
};
sops.secrets."tapedeck/plex-token".owner = config.services.tapedeck.user;
users.users.you.extraGroups = [ "tapedeck" ]; # to use td / td tui against the daemon
The token file can stay readable by the service user alone (sops’ default mode 0400): td
reads it only when it talks to a target itself, and with the daemon running every command
from a shell goes through the socket instead. Group membership and TAPEDECK_CONFIG apply to
new login sessions only, so log out and in once after the first switch; until then td falls
back to ~/.config/tapedeck/config.toml.
What the module does:
- Runs
td daemon --config /etc/tapedeck/config.tomlas user and grouptapedeck(both created; overrideuser/groupto use existing ones), withextraGroupsas supplementary groups.package,ytdlpPackageandffmpegPackagework as in the Home Manager module (no mpv on a server). databasedefaults to/var/lib/tapedeck/tapedeck.dbandsocketto/run/tapedeck/tapedeck.sock; both directories are mode 0770 and the unit’s umask is 0002, so members of the group can runtd statusortd tuiand have their actions go through the daemon. Downloaded files come out group-writable as well, which is what Plex and Samba want.- Puts
tdinenvironment.systemPackagesand setsTAPEDECK_CONFIGto the system config, so every shell uses the same config, database and socket. - Restarts on failure and on a changed config, waits for
network-online.target, and is sandboxed (ProtectSystem=strictwith the library and playlist folder writable, no home, private tmp and devices, no new privileges).
Make the library writable by the service before the first start. The 2 in the mode is the
setgid bit: new files and folders then inherit the media group instead of the daemon’s own,
so Samba users in that group can edit them (the daemon’s umask already makes them
group-writable). Alternatively set group = "media" on the module to run with that group as
the primary one.
sudo install -d -o tapedeck -g media -m 2775 /srv/music/tapedeck
# an existing library:
sudo find /srv/music/tapedeck -type d -exec chmod g+s {} +
External libraries ([external] paths) need no extra permissions: the service only reads them,
and ProtectSystem=strict allows that everywhere.
Moving from the Home Manager daemon on the same machine: disable services.tapedeck in Home
Manager and switch, then
sudo install -d -o tapedeck -g tapedeck -m 770 /var/lib/tapedeck
sudo mv ~/.local/share/tapedeck/tapedeck.db /var/lib/tapedeck/
sudo chown tapedeck:tapedeck /var/lib/tapedeck/tapedeck.db
sudo chown -R tapedeck:media /srv/music/tapedeck && sudo chmod -R g+rwX /srv/music/tapedeck
and switch to the NixOS configuration. The playlist folder inside the library and its backups
come along with the library. Set sync.on_start = false if you would rather the first sync
waited for the interval than ran at boot.
Home Manager
The flake exports a Home Manager module. Add the flake as an input, import
tapedeck.homeModules.default, then:
programs.tapedeck = {
enable = true;
settings = {
library = "/srv/music";
listenbrainz.user = "me";
sync.targets = [ "plex" ];
sync.plex = {
url = "http://plex.local:32400";
token_file = config.sops.secrets.plex-token.path;
section = "Music";
};
};
};
services.tapedeck.enable = true; # systemd user unit running `td daemon`
settings is written verbatim to ~/.config/tapedeck/config.toml, so every key in this
document applies. The package defaults to the flake’s own build; programs.tapedeck.package
overrides it.
yt-dlp and ffmpeg come from your nixpkgs (programs.tapedeck.ytdlpPackage,
programs.tapedeck.ffmpegPackage, defaulting to pkgs.yt-dlp and pkgs.ffmpeg-headless),
not from the flake’s pin, so they are shared with the rest of your system instead of
duplicated. The flake publishes tapedeck-unwrapped (the Rust build, what the cache holds)
and tapedeck (a thin wrapper that puts the two tools on PATH); the module re-wraps, so
changing the tools never rebuilds the Rust part.
Two things to know about the user service on a headless machine:
- It only runs while that user has a session, unless the NixOS side sets
users.users.<name>.linger = true. - If the token comes from Home Manager’s sops-nix, the unit is ordered after
sops-nix.serviceautomatically so the secret exists whentdstarts.
Without Nix, as a service
systemd/tapedeck.service
is a user unit for a cargo-installed td. Either way, td status from a shell talks to the
running daemon over its socket.
Quick start
First sync
mkdir -p ~/.config/tapedeck
curl -o ~/.config/tapedeck/config.toml \
https://raw.githubusercontent.com/arunoruto/tapedeck/main/config.example.toml
$EDITOR ~/.config/tapedeck/config.toml # library path, ListenBrainz user, Plex token
td sync --no-push # download and file, skip Plex
td sync # then with Plex
A minimal config:
library = "/srv/music"
[listenbrainz]
user = "you"
playlists = ["weekly-jams", "weekly-exploration"]
[sync]
targets = ["plex"]
[sync.plex]
url = "http://plex.local:32400"
token_file = "/run/secrets/plex-token" # or token = "..."
section = "Music"
Everything else has a default. The full reference is in Configuration.
What a sync does
Every run fetches the newest playlist of each configured kind from ListenBrainz, resolves
new recordings on MusicBrainz, downloads what is missing (YouTube Music first), tags and
files the audio, writes one m3u8 per playlist kind and pushes each playlist to the sync
targets. Running it twice downloads nothing new. td daemon does the same on a timer and
answers the other commands over a socket; see Playlists for what ends up
where and Commands for the rest.
After a sync the library looks like this:
/srv/music/
├── Bicep/
│ └── Isles (2021)/
│ └── 01 - Apricots.opus
├── Bonobo/
│ └── Fragments (2022)/
│ └── 06 - Polyghost.opus
└── playlists/
├── weekly-jams.m3u8
├── weekly-exploration.m3u8
└── archive/
└── weekly-jams/
└── 2026-w41.m3u8
Playlists and Plex
One current m3u8 per playlist kind lives in playlists/ (weekly-jams.m3u8,
weekly-exploration.m3u8, daily-jams.m3u8), replaced on every sync and mirroring the
playlist on Plex. The week it replaces is kept under playlists/archive/<kind>/<period>.m3u8,
ISO week for weekly kinds and the date for daily ones, never pushed unless you ask. Tracks that
drop out of a playlist are never deleted: they stay in the library, only the playlist changes.
Playlist folders and files are lowercase slugs; your library’s own paths follow
path_template and the targets show playlist_title.
td push takes a fetched playlist or any m3u8 file (the archive, a backup, something
hand-written); a same-titled playlist on the target is backed up first and replaced.
td unpush removes a playlist from a target only, backup first. td remote ls, rm,
backup and restore manage what another tool left on the target.
YouTube playlists
td import <link> takes a YouTube or YouTube Music playlist (any link with list=, album
lists OLAK5uy_… included). Each video is matched to a MusicBrainz recording the way a
request is, then downloaded through the normal path, YouTube Music’s song first, length
checked. So a playlist of music videos still ends up as album audio without the intros and
skits. The playlist keeps YouTube’s title and order, gets its own m3u8
(youtube-<list id>.m3u8) and is pushed like any other. Running it again later picks up
new entries; a video imported before keeps the recording it got then, and a row an earlier
run added that is no longer on the playlist (and was never downloaded) is dropped. An import
downloads its own playlist only, not the rest of the queue. For an album’s playlist, a
recording from the release the playlist is named after wins over the same song on a best-of.
td import "https://www.youtube.com/playlist?list=PL…" # dry run: what matches, what is unsure
td import "https://www.youtube.com/playlist?list=PL…" --apply # import the sure ones
td import "https://www.youtube.com/playlist?list=PL…" --apply --also 4,9 # and the top match of 4 and 9
An entry is sure when title, artist and version match its MusicBrainz recording and the
uploader is the artist, a label or Topic channel, or the playlist’s own curator. Everything
else is listed with the reason. Close matches are asked about too, never taken on their own:
an entry found only under the primary artist (“S.A.R.S.” for “S.A.R.S. & Ničim izazvan”),
or, for an album’s playlist, the album track at the same position with a similar title or a
fitting length (“Razguliana” ≈ “Razgulijana”, track 7). Their note says why, and --also
takes the match it found. What an external library (Lidarr) already has is not
downloaded. Mixes (RD… lists, which YouTube makes up as you go) and private lists (liked
music, watch later; they need a signed-in account) are refused; unlisted playlists work.
Plex
The library (as Plex sees it, after path_map) has to be inside one of the folders the
section scans, or Plex will never index the files. tapedeck checks this before pushing and
fails with the section’s folder list if it isn’t; add the library as another folder of
the section in Plex’s settings.
On each push tapedeck triggers a scan of the section, waits for it to finish, matches
files by path, deletes any existing playlist with the same name and recreates it. The
Plex playlist is named after the playlist kind (Weekly Jams), so it is replaced each
week rather than piling up. The m3u8 on disk (playlists/weekly-jams.m3u8) follows the same
rule; the week it replaces is kept under playlists/archive/weekly-jams/2026-w41.m3u8. Tracks
that leave a playlist stay in the library.
The TUI
Plain td opens it. Left to right: artists, albums, songs; ] switches to the playlists,
local and remote, and once more to the length review. Every action asks before it touches
anything.

Keys
| Key | Does |
|---|---|
Tab, [, ], 1, 2, 3 | Switch tabs |
h j k l, arrows | Move between panes and rows; Enter or l drills into the next column |
^D ^U, ^F ^B, g G | Half page, page, ends |
/ | Filter by artist, title or album; ^U wipes it while typing |
i | Track info: tags, what is on disk, ListenBrainz player and MusicBrainz links, source URL |
Enter on a track | Play it and queue the rest of the list |
space, n N, , ., + -, x | Pause, next/previous, seek 10 s, volume, stop |
S | Full sync |
R | Re-download the selected track from a fresh search |
s | Pin a download URL for the selected track |
m | Make the selected track another MusicBrainz recording (fix a wrong match) |
p, u | Push the selected playlist to the targets, or remove it from them |
d | Delete the selected track and its file, or a remote playlist |
r, q | Reload, quit |
Everything that deletes, re-downloads, pushes or syncs asks [y/N] first.
Playback
The TUI plays through mpv: Enter on a track plays it and queues the rest of the list (the
album in the Library tab, the playlist in the Playlists tab), space pauses, n/N skip,
,/. seek 10 s, +/- change the volume, x stops. A line above the help shows what is
playing with a position bar. In remote mode the audio is streamed from the daemon.
[player]
mpv_bin = "mpv" # default; the Home Manager wrapper puts its mpvPackage on PATH
scrobble = true # report plays to ListenBrainz
Plays are reported to ListenBrainz the way it asks: “playing now” when a track starts, a
listen after half the track or four minutes, whichever comes first, with the recording id
attached. The daemon does the submitting with its listenbrainz.token, so a laptop in remote
mode never needs the token; the listen scope covers it. Listens that cannot be delivered
(offline, ListenBrainz down) are queued in the database and sent on the next play or daemon
start. Without a token on the daemon nothing is sent and nothing is queued.
Review
The third tab lists what td lengths found: files that are not as long as their MusicBrainz
recording, worst first, with what tapedeck thinks they are (padded, metadata, release, broken,
longer, shorter) and the reference it found. It is for judging by ear before anything is
replaced:
| Key | Does |
|---|---|
Enter | Play from the selected row on |
e | Jump to 5 s before where the recording should end: music or a skit going on means a video cut |
E | Jump to the last 30 s: cut off, or a proper ending? |
g b r u | Good, bad, relink (to the recording in the note), undecided; each moves on, and plays the next one if something was playing |
F | Fix what is flagged: bad files are downloaded again (old ones kept in .tapedeck-backup/), relink ones retagged |
L | Check every file again; verdicts are kept while a file stays the same |
/ | Filter, e.g. longer, bad, undecided or an artist |
Good files stay out of td lengths from then on. In remote mode flagging needs the review
scope (or the token) and fixing needs the token.
Themes
The TUI takes its colours from a theme. Two are built in, catppuccin-mocha (default) and
catppuccin-latte for light terminals. To make your own, drop a TOML file into themes/ next
to config.toml (so ~/.config/tapedeck/themes/mine.toml by default) and set
theme = "mine". A user file with a built-in’s name replaces it.
Every key is required; colours are "#rrggbb" or terminal names such as "blue",
"lightred", "gray". The built-in files in the repository’s themes/ folder are the
reference; this is Mocha:
name = "Catppuccin Mocha"
text = "#cdd6f4" # normal text
muted = "#6c7086" # help line, secondary text, remote playlists
accent = "#89b4fa" # focused pane border, active tab, key hints
border = "#45475a" # unfocused pane borders
title = "#b4befe" # pane titles
selection_bg = "#313244" # highlighted row
selection_fg = "#f5e0dc"
ready = "#a6e3a1" # status colours
failed = "#f38ba8"
wanted = "#f9e2af"
warning = "#fab387" # confirmations, status messages
stripes = ["#f38ba8", "#fab387", "#f9e2af"] # the cassette stripes, any number of colours
The terminal’s own background is left alone, so a theme only needs to pick colours that read
on it. With Home Manager, xdg.configFile."tapedeck/themes/mine.toml".source = ./mine.toml;
puts a theme next to the generated config.
Remote use
The daemon can serve the same commands it takes on its socket over HTTP, so td and td tui
on a laptop can talk to the NAS. On the daemon:
[server]
listen = "0.0.0.0:8687" # or one address; nothing is served without `listen`
anonymous = ["read", "stream", "listen"] # default: what a client without a token may do
token_file = "/run/secrets/tapedeck/api-token" # optional: the admin token
On the laptop:
library = "/unused" # still required; not touched in remote mode
[listenbrainz]
user = "you"
[remote]
url = "http://sado:8687"
# token_file = "…" # only for admin commands
With [remote] url set, every command and the TUI’s data go to that daemon; no local database
or socket is used. The API is POST /cmd (a Command as JSON in, Events as NDJSON out, the
socket protocol) and GET /stream/<track id> (the track’s file with Range support, own and
external files alike).
Two levels of access. Without a token a client gets the anonymous scopes: read (status,
search, the listings the TUI shows), stream (the audio), listen (scrobbles); request
(td request), sync and review (flagging td lengths findings good/bad/relink, as the
TUI’s Review tab does; fixing them stays admin) can be added to the list. Everything else (delete, retag, push,
unpush, import, refile, remote rm, external dedupe, …) needs the admin token and answers
403 with a plain message otherwise; the TUI shows it in its status line. A server with no
token configured simply has no admin access over HTTP; td on the server itself, through the
socket, keeps full rights.
Plain HTTP, so keep the port to the LAN or Tailscale. Note that the default anonymous scopes expose the library listing and the audio to anyone who can reach the port.
Players that speak Subsonic (cliamp, Symfonium, Feishin, …) use the same port; see Subsonic clients.
Subsonic clients
tapedeck’s daemon speaks the Subsonic API, the protocol Navidrome and Airsonic use, so any Subsonic app plays your library and your ListenBrainz playlists straight from tapedeck and scrobbles through it. There is nothing to install besides the client: cliamp in a terminal, Symfonium, Tempo or DSub on Android, play:Sub on iOS, Feishin or Supersonic on the desktop. Plex stays optional.
Switch it on
The Subsonic endpoints share the HTTP API’s port, so [server] listen is all it takes:
[server]
listen = "0.0.0.0:8687"
In the client, add a Navidrome or Subsonic server with the URL http://<host>:8687 and any
username and password: without [server.subsonic] the daemon treats every client as
anonymous, which on a LAN or a Tailscale network is all you need. Anonymous clients get the
[server] anonymous scopes, by default read, stream and listen, which is exactly what a
player uses.
If the port faces the internet, require a sign-in:
[server.subsonic]
user = "mirza"
password_file = "/run/secrets/subsonic-password" # or password = "…" for a quick test
Clients then authenticate the Subsonic way (a salted MD5 token or the password itself) and, once signed in, may do everything; the anonymous scopes no longer matter for them. The admin bearer token of the HTTP API passes too.
What a client sees
- Playlists: every ListenBrainz playlist tapedeck has, the current ones under the title
Plex shows (
Weekly Jams), older editions under their ListenBrainz name, plus the Requests playlists from the Telegram bot. - Artists and albums: the library grouped by MusicBrainz release and artist, so names follow MusicBrainz and ids survive renames, plus every file of the external libraries (Lidarr’s), grouped by its tags. Search covers artists, albums and titles.
- Cover art: from the Cover Art Archive by release id, fetched once per size and kept in
covers/next to the database. - Streaming: the original files with seeking, own and external ones alike.
- Lyrics: synced through the OpenSubsonic
songLyricsextension, plain throughgetLyrics, from what lyrics has gathered. - Scrobbling: the client’s “now playing” and “scrobble” calls become listens on your ListenBrainz account through the daemon’s token, with the same queue and retry as the TUI.
Favourites and ratings are accepted and dropped, since tapedeck keeps no stars; playlist
editing from the client is not offered, playlists come from ListenBrainz. Artist and album
info pages, lyrics, podcasts, bookmarks and the play queue answer empty, so clients that ask
for them after login carry on. Both the ID3 (getArtists) and the folder (getIndexes,
getMusicDirectory) browsing styles work, and responses are XML by default or JSON with
f=json, as the protocol says.
cliamp
cliamp setup # pick Navidrome, enter http://sado:8687 and any user and password
cliamp # the tapedeck playlists are in the provider panel, N opens the browser
cliamp reports plays back to the server, so listening in cliamp scrobbles to ListenBrainz without a plugin.
For other projects
The protocol layer is its own crate, crates/subsonic-server:
you implement a Library trait (songs, playlists, files, cover art, scrobbles) and mount
the axum router it gives you. tapedeck’s own implementation is src/subsonic.rs, about
two hundred lines, and the crate has no tapedeck types in it, so it can move to a
repository of its own when someone wants it.
Song requests
Songs you hear elsewhere can be sent to tapedeck as a link or as “Artist - Title”, from a
Telegram chat or with td request. tapedeck works out the recording on MusicBrainz, fetches
the best audio for it (YouTube Music first, the link itself as the fallback), files and tags it
like any other track, and puts it on this year’s Requests playlist (requests-2026, so
playlists/requests-2026.m3u8 and “Requests 2026” on Plex, newest first; a new one starts
each January and the old one stays).
[telegram]
token_file = "/run/secrets/tapedeck/telegram-token" # or token = "…"
allowed_users = [123456789] # Telegram user ids; everyone else is ignored
The bot runs inside the daemon with long polling (no inbound port) and answers only the
listed users. It asks only when it is not sure: no MusicBrainz match, a title or artist that
does not line up, a remix or live version the recording lacks, an upload by someone who is not
the artist (the classic no-name cover). Then it offers up to three candidates, “Exactly this
video” (download the link as sent, tagged from its own metadata) and Cancel as buttons. A
confident answer comes back as “✅ Added: Artist – Title (Album, 3:07)” with “👍 Correct”,
“👎 Wrong song” and Undo buttons; a 👍 or 👎 reaction on that message, or a /wrong reply,
does the same as the buttons. Confirming settles the message (the buttons go) and is
remembered; td status shows how many requests are still unconfirmed. Open questions live in memory, so a daemon restart forgets them; send the link
again. On the command line, td request URL does the same and, when unsure, prints the
candidates with numbers for td request URL --pick N or td request URL --video.
Playlists
A playlist link (anything with list=) is td import in
the chat. The bot reads the playlist and edits one message as it matches, then sums up:
“📃 Name: 37 videos, ✅ 30 new, 4 already here, 🤔 3 unsure”. The buttons are Import, Review
and Cancel. Review steps through the unsure entries one at a time, offering the top
candidates or Skip. A close match (found under the primary artist, or as the album track at
that position) leads with “✅ That one”, and the summary offers “✅ Take N close” to accept them
all at once. It never offers “exactly this video”, because playlists are mostly music
videos. Import downloads the sure entries plus what review picked, edits the same message
with progress, and finishes with the count, the sync targets the playlist went to, and any
failures by name. A song shared from inside a playlist (watch?v=…&list=…) first asks “Just
this song, or the whole playlist (37)?”. A mix or private list with a song in it is treated
as that song. Sending a playlist again merges its new entries.
External libraries
External files are also served to Subsonic clients, with lyrics from the database, so a Lidarr library is browsable and playable through tapedeck too.
Music managed by something else, say Lidarr’s folder, is often better quality than a YouTube rip and should not be downloaded again. List those folders and tapedeck treats them as read-only sources of recordings:
[external]
paths = ["/mnt/flash/music/releases", "/mnt/flash/music/singles"]
scan_interval = "1h" # the daemon rescans on its own clock; "0" turns that off
prefer_external = "ask" # auto | ask | never, see below
| Key | Default | Meaning |
|---|---|---|
paths | [] | Folders to index. Nothing under them is ever written, moved, retagged or deleted. |
scan_interval | 1h | How often the daemon rescans them, independently of sync.interval. Every sync scans too. 0 disables the periodic scan. |
prefer_external | ask | What happens when a scan finds an external copy of a track the library already has. auto: point the track at the external file and delete the own copy. ask: only report it; td external dedupe does the swap. never: leave own copies alone. |
Files are indexed by their MusicBrainz recording id (Picard and Lidarr write it) with a
fallback on artist, title and length, incrementally by size and modification time, and a file
changed in the last minute is left for the next scan so a half-copied FLAC is not indexed. A
playlist track whose recording is in the index is marked external and never downloaded; its
m3u8 line and its Plex entry use the external file’s path. When an external file moves (Lidarr
upgrading an MP3 to a FLAC under a new name) the track follows it and the affected playlists
are pushed again; when it vanishes with no other copy the track becomes wanted and the next
sync downloads it. td external scan runs the scan by hand, td external dedupe [--dry-run]
replaces own tracks that exist externally, td import treats such recordings as duplicates,
and td status shows how many files are indexed and when. Refile, retag, retry and rm never
touch an external file.
Steam soundtracks
Steam sells game soundtracks as “Music” apps: plain MP3 or FLAC files, tagged, often with the artwork embedded, no DRM. MusicBrainz knows few of them, so tapedeck treats Steam as a source of its own: the files come in as they are, tags trusted and kept, nothing retagged, and from then on they are ordinary tracks in the library, in Plex, in the TUI and for Subsonic clients, with the game’s artwork as the cover.
Two ways in
The Steam client’s folder. If a machine with Steam installed the soundtracks, point
tapedeck at steamapps/music; every subfolder is one soundtrack:
[steam]
path = "/home/you/.local/share/Steam/steamapps/music"
The account, through steamcmd. Without a Steam client, tapedeck can list what the account owns, pick out the soundtracks and download new ones itself:
[steam]
username = "you"
# steamcmd_bin = "steamcmd" # or a full path; on NixOS "${pkgs.steamcmd}/bin/steamcmd" (unfree)
# staging = "/var/lib/tapedeck/steam" # downloads land here before import; the default
# interval = "24h" # how often the daemon looks; "0" turns it off
steamcmd must be logged in once by hand, because Steam Guard asks for a code; after that
its cached session works unattended. Do it as the user tapedeck runs as, with the same
HOME:
sudo -u tapedeck HOME=/var/lib/tapedeck steamcmd +login you +quit
No Web API key is needed: steamcmd already knows which licenses the account holds, and the store answers what kind of app each one is. The first pass asks the store once per owned app, slowly enough to stay under its rate limit, and remembers the answers, so a library with a few hundred games takes a few minutes the first time and seconds after that.
Both can be set at once.
Commands
td steam list # the folders under `path`, the owned soundtracks, what is imported
td steam sync # import new soundtracks (downloading first when `username` is set)
The daemon runs sync on interval. Imports copy; the Steam folder is never touched, and a
staged download is removed once it is in the library.
How they are filed
- The album is the folder, with tags deciding the name and the album artist (the value most files agree on, so one odd tag does not split an album); an untagged file gets its title and track number from the file name and the folder as artist and album.
- Files are copied into the usual
path_templateplace, with their tags as they are. - Ids are synthetic (
steam:<app id or folder>), sotd retagand the MusicBrainz renaming logic leave these rows alone, and Subsonic clients get the embedded picture or acover.jpgnext to the files as the cover. - They do not join ListenBrainz playlists, and are not pushed anywhere as playlists; they show up as albums wherever the library is browsed.
Lyrics
tapedeck fetches lyrics from LRCLIB, an open database of synced and plain lyrics that needs no account, and keeps them in two places: as sidecar files next to the audio, which is the only way Plex and Plexamp read lyrics, and in its own database, which is what Subsonic clients and the TUI use. It is on by default and asks for nothing: LRCLIB is open data, and files are written only into tapedeck’s own library.
What happens
- After every download, and on
[lyrics] interval(default every six hours) for anything still without lyrics, tapedeck looks each song up by artist, title, album and length, then by search, then both again with the primary artist (“Calvin Harris feat. Ellie Goulding” becomes “Calvin Harris”) and with video junk such as “(Official Music Video)” removed from the title; one request a second. A miss is remembered and asked again afterretry_after(default thirty days), because LRCLIB grows. A provider hiccup leaves the song untried for the next pass. - Matching is strict, because LRCLIB’s search happily offers the acoustic version or the
wrong length: a hit needs the same title (no extra “acoustic”, “live”, “remix”…), an artist
in common, a known length within two seconds of ours, and synced lyrics whose last line
lies inside the song; otherwise the plain text is used. How each song was matched and the
length difference are kept, so
td lyrics statusshows how many came from a search. - A live, remix, acoustic or otherwise versioned title with no entry of its own gets the base
song’s plain text as
.txt(the words match, the timings would not); a title that says “Instrumental” is marked instrumental without a lookup. When LRCLIB answers errors, calls slow down to one every four seconds for a while and the pass stops after five in a row; the next pass takes those songs again. - Synced lyrics become
<same name>.lrc, plain ones<same name>.txt, written atomically next to the file. An existing sidecar is never overwritten (unless--force) and is indexed instead, so a library that already has.lrcfiles is simply read. Audio files are never touched. Instrumentals are marked and get no file. - Plex is asked to refresh the metadata of each album that gained a sidecar, so Plexamp shows the lyrics without a manual refresh.
- Sidecars follow their track:
td retag’s moves, renames, deletes andtd external dedupetake the.lrc/.txtalong. - Subsonic clients get synced lyrics through the OpenSubsonic
songLyricsextension (getLyricsBySongId) and plain ones throughgetLyrics, for every track with lyrics in the database, including external ones nothing was written next to.
External libraries
Every file of an external library such as Lidarr’s is covered, not only the
ones that match a tapedeck track. The default is external = "db-only": lyrics go into the
database only, so Subsonic apps and the TUI show them, Plexamp does not, and nothing is
written into folders tapedeck does not own. Lidarr’s own .lrc files are read and indexed
either way.
Set external = "missing-sidecars" to also write .lrc/.txt next to external files that
have none. Existing sidecars and audio are still never touched. On NixOS the external paths
then need to be in the service’s ReadWritePaths, and when Lidarr renames or upgrades a track
the old sidecar stays behind until you clean it up.
Commands
td lyrics status # coverage per library: found, missing, instrumental, untried, sidecars
td lyrics show 123 # what is known for one track, and whether a sidecar is on disk
td lyrics fetch # tracks without lyrics that are due (what the daemon runs)
td lyrics fetch --all # every track without lyrics, ignoring the retry schedule
td lyrics fetch --external # only external tracks
td lyrics fetch 123 --force # ask again, forget rejections and overwrite the sidecar
td lyrics reject 123 # wrong lyrics: drop the sidecar tapedeck wrote, blocklist the source, try the next
td lyrics check [--fix] # synced lyrics that run past the end of their song; --fix keeps the plain text as .txt for small overruns, rejects larger ones
Configuration
[lyrics]
enable = true
write_sidecars = true # .lrc/.txt next to own tracks
external = "db-only" # or "missing-sidecars"
retry_after = "30d"
interval = "6h" # daemon pass; "0" turns it off
LRCLIB is the only source. Musixmatch, Genius, Spotify and Apple need scraped tokens or break
their terms, so they are out; a further open source can be added behind [lyrics] later.
Migrating from Explo
If a folder of downloads already exists, tapedeck can take it over without touching it.
- Back up the playlists Explo made on Plex:
td remote backup --to plexwrites one m3u8 plus a JSON sidecar per playlist underplaylists/backup/plex/. - Dry run the import:
td import /path/to/explo. Every file is identified by its MusicBrainz id when tagged, otherwise by a search on artist, title and length; copies of the same recording are grouped, the one closest to the recording’s length is kept, and a recording more than 20 s off is a miss rather than a wrong cut. - Apply:
td import /path/to/explo --applycopies the kept files into the library and leaves the source alone (--movemoves instead;--delete-duplicatesonly with--move). Backups are rewritten to point at the library copies. - Relink what still points into the old folder:
td remote relink --from /path/to/explo. - Fix the odd match with
td retag ID --recording MBID, found in the TUI withi. - Delete the old folder yourself, then
td remote rm "Weekly-Jams-*" --yesfor Explo’s playlists on Plex (backed up first).
td refile re-resolves every track against MusicBrainz, retags and moves files whose path
changed; --dry-run shows the moves, including artist folders that MusicBrainz renamed
(td artist NAME --folder F pins a name you’d rather keep).
Commands
| Command | What it does |
|---|---|
td sync [--playlist KIND] [--no-push] [--no-fetch] | Fetch playlists, download what’s missing, tag, file, write m3u8, push; --no-fetch skips ListenBrainz and only downloads what is already wanted |
td status | Track counts by status (wanted / ready / failed) |
td search QUERY | Search the library by artist, album or title |
td playlists | Fetched playlists and where they’ve been synced |
td artists / td artist NAME --folder F | Album artists as MusicBrainz names them now; pin a folder name where you disagree |
td push PLAYLIST|FILE.m3u8 [--to TARGET] [--title T] | Push a fetched playlist, or any m3u8 file (archive, backup, your own); a same-titled remote copy is backed up and replaced |
td unpush NAME|FILE.m3u8 [--to TARGET] --yes | Remove a playlist from a target, backup first; the local file is never touched |
td retry [ID] | Reset failed downloads so the next sync tries again; with an id, re-download that one track |
td rm ID | Delete one track’s file and forget it |
td source ID URL | Pin the download URL for one track and queue it |
td retag ID --recording MBID | Correct a wrong match: make the track that recording, retag, move if the path changes |
td refile [--dry-run] | Re-check every track against MusicBrainz, retag, move files whose path changed |
td lengths [TRACK] [--list] [--verdict V] | Files whose length is off their recording by more than max(10 s, 5 %), sorted into padded, external, metadata (a better-fitting recording to relink to), release (Deezer has a version this long, so MusicBrainz is off), broken, longer and shorter. Stored for review; --list shows the stored audit without checking again; good ones are hidden unless --verdict good (or all) |
td lengths TRACK --mark good|bad|relink|none | Judge a finding after listening (the TUI’s Review tab does the same with g b r u) |
td lengths [TRACK] --fix | Act on the verdicts only: bad files are downloaded again (kept only when closer, old file under <library>/.tapedeck-backup/), relink ones retagged |
td import DIR [--apply] [--move] [--delete-duplicates] | Bring an existing folder (say, Explo’s downloads) into the library; dry run by default, --apply copies and leaves the source alone, --move moves |
td import PLAYLIST-URL [--apply] [--also N,M] | A YouTube / YouTube Music playlist: each video matched to a MusicBrainz recording and downloaded as album audio, written as a playlist in YouTube’s order; dry run by default, unsure entries only with --also; see Playlists |
td remote ls / td remote rm GLOB [--yes] | List or bulk-delete playlists on a sync target; rm backs up first |
td remote backup [GLOB] / td remote restore FILE | Save target playlists as m3u8 + JSON, or recreate one from such a file |
td request URL|"Artist - Title" [--pick N] [--video] | Request one song: identify, fetch, file, add to this year’s Requests playlist |
td lyrics fetch [--all] [--external] [--force] [TRACK] / show TRACK / status / reject TRACK / check [--fix] | Lyrics from LRCLIB as sidecars and in the database; see Lyrics |
td steam list / td steam sync | Steam soundtracks: what the client installed or the account owns, and import the new ones; see Steam soundtracks |
td external scan / td external dedupe [--dry-run] | Index read-only libraries (Lidarr) and replace own copies of recordings they hold |
td remote relink --from DIR | Point backup entries under DIR at the library’s copy of the same recording; run before deleting an imported folder |
td daemon | Run as a service: listen on the socket, sync every sync.interval |
td / td tui | Browse the library (artist › album › song) and playlists; i shows a track’s tags, file quality and MusicBrainz/ListenBrainz/source URLs; sync, re-download, pin a source, fix a match, push, delete, each behind a [y/N]. Catppuccin out of the box, your own colours via a TOML theme |
Output is a table or plain lines; add --json to any command for newline-delimited JSON
events instead, which is what scripts and agents should parse.
Every command sends newline-delimited JSON events with --json. With a daemon running, a
command goes through its socket (or, with [remote] url, its HTTP API); without one it runs
in-process. Read-only commands never wait for a running sync.
Configuration
tapedeck reads one TOML file: --config PATH, $TAPEDECK_CONFIG, or
$XDG_CONFIG_HOME/tapedeck/config.toml (~/.config/tapedeck/config.toml).
Unknown keys are an error, so typos fail at startup.
A complete example lives in config.example.toml.
Top level
| Key | Default | Meaning |
|---|---|---|
library | required | Root of the music library. Downloads land under it. |
path_template | {album_artist}/{album} ({year})/{track:02} - {title}.{ext} | Where a track is filed, relative to library. See below. |
theme | catppuccin-mocha | TUI colours. A built-in name (catppuccin-mocha, catppuccin-latte) or the stem of a file in themes/ next to config.toml. See Themes. |
playlist_dir | <library>/playlists | Where the m3u8 files go: one current file per playlist kind (weekly-jams.m3u8), superseded ones under archive/<kind>/, playlist backups under backup/<target>/. Lowercase slugs throughout. |
database | $XDG_DATA_HOME/tapedeck/tapedeck.db | SQLite file. |
socket | $XDG_RUNTIME_DIR/tapedeck.sock | Daemon socket. Falls back next to the database if XDG_RUNTIME_DIR is unset. |
Path template
Lidarr-style tokens inside {}:
| Token | Value |
|---|---|
{artist} | Track artist credit as MusicBrainz prints it (A feat. B). |
{album_artist} | Artist credit of the release the track was filed under. Differs from {artist} on compilations and guest features. |
{album} | Release title. |
{year} | Release year, or Unknown. |
{track} / {track:02} | Track number, plain or zero-padded. |
{title} | Recording title. |
{ext} | File extension from the download. |
Every token value is sanitized: / \ : * ? " < > | and control characters become _,
so a title can never create a directory. Literal text outside braces is kept as is,
including slashes, which is how you shape the directory tree. An unknown token is a
startup error.
[listenbrainz]
| Key | Default | Meaning |
|---|---|---|
user | required | ListenBrainz username whose created-for playlists to follow. |
token | none | User token. Only needed if the playlists are private. |
token_file | none | Path to a file containing the token, instead of token. See Secrets. |
playlists | ["weekly-jams", "weekly-exploration"] | Which generated playlists to track, by ListenBrainz source_patch name. daily-jams is the other common one. |
[download]
| Key | Default | Meaning |
|---|---|---|
backend | ytdlp | Download backend. Only ytdlp exists today. |
format | opus | Passed to yt-dlp --audio-format. |
ytdlp_bin | yt-dlp | Executable name or path. |
ffmpeg_bin | none | Passed to yt-dlp as --ffmpeg-location. Default: whatever yt-dlp finds on PATH. |
yt-dlp is searched with ytsearch1:<artist> - <title> unless a track has an explicit
source_url in the database. ffmpeg must be available for the audio extraction.
[sync]
| Key | Default | Meaning |
|---|---|---|
interval | 6h | How often the daemon runs sync. Suffixes: s, m, h, d. |
on_start | true | Sync as soon as the daemon starts, and after every restart (which includes a Plex push). false waits one interval first. |
targets | [] | Enabled targets, in push order. Only plex exists today. |
playlist_title | {name} | Playlist name on the targets. {name} is the kind in title case (Weekly Jams), {kind} the raw kind (weekly-jams). Use something like {name} (tapedeck) if another tool already owns the plain names. |
[sync.plex]
| Key | Meaning |
|---|---|
url | Server URL, e.g. http://plex.local:32400. |
token | X-Plex-Token. Find it via Plex’s “Get Info → View XML” on any item. |
token_file | Path to a file containing the token, instead of token. One of the two is required. |
section | Name of the music library section, usually Music. |
path_map | Local prefix → the prefix Plex sees, e.g. { "/srv/music" = "/music" } for a container. Longest match wins. Leave out if identical. |
[lyrics]
Lyrics from LRCLIB; see Lyrics. enable (default true), write_sidecars
(default true, .lrc/.txt next to own tracks), external ("db-only", the default, or
"missing-sidecars" to also write next to external files that have none), retry_after
(default 30d) and interval (default 6h, "0" off).
[steam]
Game soundtracks bought on Steam, imported as they are; see Steam soundtracks.
path is the Steam client’s steamapps/music; username a steamcmd account (logged in
once by hand) whose owned soundtracks are downloaded. Either or both. steamcmd_bin
(default steamcmd), staging (default steam/ next to the database) and interval
(default 24h, "0" off) tune the download side.
[steam]
path = "/home/you/.local/share/Steam/steamapps/music"
username = "you"
[server.subsonic]
Sign-in for Subsonic clients on /rest/, served on the [server] listen port.
Without user, every Subsonic client is anonymous and gets the anonymous scopes (enough on
a LAN). With user and password or password_file, clients must sign in and then may do
everything.
[server.subsonic]
user = "mirza"
password_file = "/run/secrets/subsonic-password"
Environment variables
Every key can also come from the environment, which is how a container gets its paths and
secrets without a mounted file. The name is TAPEDECK_ followed by the key path with __
(two underscores) between levels, in any case:
TAPEDECK_LIBRARY=/music
TAPEDECK_LISTENBRAINZ__USER=you
TAPEDECK_LISTENBRAINZ__PLAYLISTS='["weekly-jams", "daily-jams"]'
TAPEDECK_SYNC__TARGETS='["plex"]'
TAPEDECK_SYNC__PLEX__URL=http://plex:32400
TAPEDECK_SYNC__PLEX__SECTION=Music
TAPEDECK_SYNC__PLEX__TOKEN_FILE=/run/secrets/plex-token
TAPEDECK_SERVER__LISTEN=0.0.0.0:8687
Environment wins over the file. true/false and values starting with [, { or " are
read as TOML (arrays, inline tables, quoted strings); everything else is taken as a string, so
paths, URLs and durations need no quotes. An empty value removes the key, which switches off a
default the container image sets (TAPEDECK_SERVER__LISTEN=). When at least one TAPEDECK_*
variable is set, the config file may be missing altogether; TAPEDECK_CONFIG itself names the
file and is not a key.
Secrets work the same way: TAPEDECK_LISTENBRAINZ__TOKEN=… or, better, a TOKEN_FILE pointing
at a mounted secret, so the token never shows up in docker inspect.
Secrets
Every token has a token_file twin. tapedeck reads the file when it is about to talk to
that service (the daemon at start, an in-process command when it needs the target; never for
commands that go through a running daemon’s socket), strips the
trailing newline, and from then on behaves as if token had been set. Setting both is an
error. This is how you keep tokens out of a config that lives in Git or in the Nix store:
point token_file at whatever sops-nix, agenix or systemd-creds puts on disk.
The sections for [player], [server]/[remote], [telegram] and [external] are on their
own pages: The TUI, Remote use, Song requests
and External libraries. The NixOS and Home Manager modules are under
Installation.
Architecture
Single Rust crate, one binary (td) that is both the CLI and the daemon.
src/
main.rs parse args; connect to the daemon or run in-process
cli.rs Command enum: clap subcommands, also the socket wire format
daemon.rs Unix socket server + periodic sync tick
app.rs App: executes a Command, streams Events
config.rs TOML config and derived paths
db.rs rusqlite wrapper, schema in schema.sql
library.rs path template, tagging (lofty), moving files into place
m3u.rs m3u8 writer
listenbrainz.rs created-for playlists (JSPF)
musicbrainz.rs recording lookup
download/ Downloader trait, yt-dlp implementation
sync/ SyncTarget trait, Plex implementation
tui.rs `td tui` / plain `td`: ratatui browser; actions reuse the CLI commands
theme.rs TUI colours from TOML; built-ins in themes/
external.rs read-only external libraries (Lidarr): index, dedupe, reconcile
api.rs HTTP API (POST /cmd, GET /stream) and the client side of [remote]
player.rs mpv over JSON IPC for the TUI
request.rs song requests: analyse a link, judge, fetch, file, Requests playlist
telegram.rs the Telegram bot (long polling) on top of request.rs
render.rs tables / key-value lines for human output
Sync flow
td sync does, in order:
- Fetch the user’s created-for playlists from ListenBrainz, keep the kinds listed in
listenbrainz.playlists, and of each kind only the newest. ListenBrainz keeps weeks of history and now and then generates the same week twice. - For each playlist, walk its tracks. A recording MBID we already have is reused;
a new one is looked up on MusicBrainz and inserted as
wanted. The playlist’s track list is replaced inplaylist_tracks. - Download every
wantedtrack with the configured backend into<library>/.tapedeck-tmp, write tags from the MusicBrainz data, render the path template and rename the file into place. Success →readywith its relative path; failure →failedwith the error text.td retryflipsfailedback towanted. - Write one current m3u8 per playlist kind (
playlists/weekly-jams.m3u8), replaced on every run; the full ListenBrainz title is inside the file. The playlist it replaces is written toarchive/<kind>/<period>.m3u8(archive/weekly-jams/2026-w41.m3u8, ISO week for weekly kinds, the date for daily ones). Playlist folders and files are lowercase slugs; the library’s own paths followpath_templateand the targets showplaylist_title. When ListenBrainz regenerates a week (two playlists, same period), the newer one is current and the earlier edition is not archived; only a different period is history. An older folder layout (Playlists/, dated or long-titled files, capitalised backups) is moved into this one on the first sync, nothing deleted and nothing overwritten: two backups whose titles slug the same keep both, the second with its remote id. - Push each of those playlists to every enabled sync target and record the result in
playlist_syncs.
Tracks that drop out of a playlist are never deleted or pruned: they stay as ordinary library tracks, and only the playlist membership changes. The music lives on the device; the playlists are what moves.
Steps 2 and 3 are idempotent per MBID. Running sync twice downloads nothing new.
Daemon and client
td daemon binds socket and runs the sync flow every sync.interval. Every other
td command first tries to connect to that socket:
- Connected: the
Commandis serialized as one JSON line, the daemon streams backEventJSON lines and closes. The client renders them. - Not connected: the same
App::runexecutes in the CLI process.
Command is the protocol. There is no second schema to keep in sync with the CLI,
and --json output is exactly what goes over the socket.
Events are info (progress text), data (a command’s structured result) and error.
A non-zero exit means at least one error event was emitted.
The daemon serializes commands with a single lock. A td search issued while a sync
is downloading waits for the sync. That is deliberate for now: it keeps SQLite access
trivially correct and the download queue is the slow part anyway.
Extension points
Two traits, both small on purpose:
#![allow(unused)]
fn main() {
trait Downloader {
async fn download(&self, track: &Track, dest_dir: &Path) -> Result<PathBuf>;
}
trait SyncTarget {
async fn sync_playlist(&self, title: &str, files: &[PathBuf]) -> Result<String>;
}
}
A downloader only has to produce a file. Tagging and filing happen afterwards in
library.rs, so a Soulseek or Bandcamp backend does not have to know about templates.
A sync target only sees a title and absolute paths. Jellyfin, Subsonic or an
“export m3u to this folder” target are each one file in sync/.
Backends are selected by name in download.backend and sync.targets; the match
statements in download/mod.rs and sync/mod.rs are the registry.
Refiling and importing
td refile runs every track through the MusicBrainz lookup again, writes the result back
(which is how a ranking fix reaches tracks filed before it), retags the file and moves it if
the templated path changed, pruning directories left empty. The m3u8 files are rewritten
afterwards because their paths may have changed. --dry-run prints the moves and touches
nothing. One lookup per track at one request per second, so a thousand tracks take a quarter
of an hour.
td retag ID --recording MBID is the manual override for a wrong match: the row becomes that
recording, metadata is refetched, the file retagged and moved if its templated path changed, and
its playlists’ m3u8s rewritten, and playlist backups follow the moved file, as they do after
td refile moves. In the TUI it is m on a track. td refile cannot do this, it
re-resolves the recording id the row already has.
td import DIR is for a folder that predates tapedeck. Each audio file is identified by its
MUSICBRAINZ_TRACKID tag when present, otherwise by a MusicBrainz search on artist and
title (from tags, else from an Artist - Title file name) scored by how close the file’s
length is to the recording’s. A resolved recording more than 20 s away from the file’s
own length is not accepted, whether the tag or the search named it: the search is asked again
for the file’s length, and if that finds nothing the file is a miss rather than a wrong cut.
A file the search cannot place still joins the group of a
resolved file with the same artist, title and length, which is how an untagged copy of a tagged
file finds its recording. Files are then grouped by recording, because Explo drops the same
song into every weekly folder it appears in. A recording the library already has makes
every copy a duplicate; otherwise the copy closest to the recording’s length is filed exactly
like a download would be and the rest are duplicates. Duplicates are reported, or deleted with
--apply --move --delete-duplicates. The default is a dry run, which reports the same groups
--apply would act on. --apply copies the files in and never modifies or deletes anything
in the source: the least surprising default for a folder another tool still owns. --move
moves them instead (then --delete-duplicates is allowed), and needs write access to the
source, which is checked before the long resolving pass. A file is tagged only once it sits in the
library, so a failed move or copy leaves the source and the database as they were, is counted
as failed, and the run goes on. Search results are cached per file (path, size, mtime), so the
dry run pays for MusicBrainz once and --apply reuses them. Two different recordings can render the same path (two versions of one song on one single):
a destination is never overwritten; if the file already there has the same length the newcomer
is a duplicate of it, otherwise it gets “ (2)“ before the extension. td status warns if two
rows ever end up sharing a file, and td rm keeps the file while another row still uses it.
Afterwards --apply rewrites any
playlist backup
under <playlist_dir>/backup whose paths pointed at files that were moved, copied or deleted (every copy of a recording
points at the kept one), so td remote restore still finds them.
td push takes a fetched playlist (id or kind) or any m3u8 file: the archive, a backup,
something hand-written. File lines are resolved against the file’s folder, lines that are not
on disk are reported and skipped, and the title comes from --title, the file’s database row
(the kind’s title for the current playlist, the ListenBrainz title for an archived one), its
#PLAYLIST line, or the file name, in that order. Before any push the same-titled remote
playlist, if there is one, is backed up like remote rm does. td unpush removes a playlist
from the targets only, backup first; nothing on disk changes. In the TUI these are p and u
on a playlist row, and a ✓ plex mark shows which targets currently hold a playlist under
that title.
td remote relink --from DIR is for the end of a migration: every distinct backup entry under
DIR (or anywhere outside the library, without --from) is resolved to a library track, by
the file’s MusicBrainz tag, by the cached import lookup, or by artist, title and length against
an index of the library built once, and rewritten. Smart playlists’ backups are left alone by
relink (Plex regenerates them from a filter), but moves made by import, refile and retag are
followed in them too, since that is harmless.
Backup files are replaced atomically. After that the imported folder can go without any
backup dangling.
td remote ls and td remote rm GLOB talk to the sync targets’ own playlist lists, so the
history another tool left behind (Weekly-Jams-2026-Week*) can be cleared without opening
Plex. rm only lists matches until --yes is given, skips the server’s own smart playlists
unless told otherwise, and first writes each playlist as an m3u8 (local paths) plus a JSON
sidecar under <playlist_dir>/backup/<target>/<slug>.m3u8 (the real title stays in the JSON;
a slug already taken by a playlist with another id gets the id appended, so “Weekly Jams” and
Explo’s “Weekly-Jams” keep separate backups, and the backup a push writes beforehand already
carries the paths of files this run moved); td remote backup does only that, and
td remote restore FILE.json recreates a playlist from the sidecar.
Long loops (import’s resolving pass, refile) report a progress event per item. On a terminal
it overwrites itself on one line; in a pipe or the journal it is dropped; with --json it is one
more NDJSON line, so a script can show its own counter.
Playback
player.rs drives mpv over its JSON IPC socket: mpv --no-video --idle is started on the
first play with --input-ipc-server, given file paths (local) or /stream/<id> URLs (remote,
with the bearer header fixed at launch), and polled for position and state every other TUI
tick. The queue is tapedeck’s, so the bar shows “Artist – Title” rather than a URL. Nothing
decodes audio in tapedeck; mpv handles formats, gapless playback and prefetching. Scrobbling
follows ListenBrainz’s rule from the same poll: the TUI fires listen --now when the track id
changes and listen once position passes half the length or four minutes, and the daemon
submits with its token, queueing failures in listens and flushing them at the next listen
or start. That keeps the token on the server and makes the listen scope safe to leave open.
Remote use
api.rs. [server] listen adds an HTTP face to the daemon with the socket’s protocol:
POST /cmd takes a Command and streams Events as NDJSON, GET /stream/<id> serves a
track’s file with Range support (tower-http’s ServeFile). A bearer token, when configured,
grants everything; without it a request gets the [server] anonymous scopes (scope_of maps
each command to read, request, sync or admin), and a command outside them is a 403 naming
what it needs. On a
client, [remote] url makes daemon::dispatch send every command there instead of the socket
or an in-process App, and the TUI loads its data through commands (tracks, playlists,
playlist-tracks, remote-playlists, hidden from --help) rather than opening the database,
so the same binary works against a local or a remote daemon.
Song requests
request.rs is the pipeline, telegram.rs one way in, td request the other. A link is
probed with yt-dlp --dump-json; the title is cleaned of “(Official Video)”-style noise and
split into artist and title with the channel name deciding which side is which, YouTube
Music’s own track/artist fields win when present. MusicBrainz is asked for candidates with
the same ranking the sync uses, and judge says whether the top one can be taken without
asking: same normalised title, compatible artist, identical version words (remix, live, …),
uploaded by the artist or a label/Topic channel, and for a music track a length within 20 s.
Fulfilling inserts the recording’s row, downloads through the usual YouTube-Music-first path
(falling back to the link’s own audio when that fails), files it, and prepends it to the
requests-<year> playlist, which is an ordinary playlist kind, so it gets its own current
m3u8 and Plex playlist and the sync’s archive logic never touches it. “Exactly this video”
files the link as sent under a youtube:<id> recording id; refile skips such rows. The bot
keeps open questions in memory keyed by its own message ids; buttons, 👍/👎 reactions and
/wrong replies all route back to them, and a confirmation is written to requests. Every fulfilment runs under the daemon’s busy lock.
External libraries
external.rs. Folders someone else manages (Lidarr) are indexed into external_files by
recording id, artist, title and length, incrementally by size and mtime, with a one-minute
settle time for files still being written. The index is consulted wherever a recording would
otherwise be fetched or kept: the sync marks a wanted track external (status external,
path absolute) instead of downloading, import treats the recording as a duplicate, and
td external dedupe swaps an own copy for the external one. A scan also reconciles: a vanished
file sends its tracks to another external copy or back to wanted, a new file covers wanted
and failed tracks at once and own copies according to prefer_external, and playlists whose
paths changed are pushed again. The daemon scans on external.scan_interval and at every sync.
Everything that writes files (refile, retag, retry, rm) checks is_external first and leaves
those alone; m3u8 and Plex see the external path like any other.
Builds and the cache
CI builds the Nix package on x86_64-linux (the NAS) and aarch64-darwin (the MacBook) and pushes
both to arunoruto.cachix.org, so a nixos-rebuild or darwin-rebuild against a pinned
commit substitutes instead of compiling. Nix caches a cache miss for an hour, so a lookup that
raced the push makes a machine compile once; --option narinfo-cache-negative-ttl 0 forces a
fresh look.
Running as a service
The daemon is one process: a Unix socket for commands, a ticker that runs sync every
sync.interval (immediately on start unless sync.on_start = false), and a second ticker for
the external-library scan. Events of socket-driven commands are logged to the journal too, so
a sync started from the TUI is traceable afterwards. Two modules wrap it.
The Home Manager one is a user unit for a desktop. The NixOS one is for a server: its own
system user and group, StateDirectory/RuntimeDirectory with mode 0770 and UMask=0002 so
group members can use the CLI and TUI against the running daemon and the files it writes are
group-writable for Plex and Samba, TAPEDECK_CONFIG pointing every shell at the system config,
and the usual systemd sandboxing with the library and playlist folder as the only writable
paths.
TUI
td tui (and plain td) opens the database directly (no daemon round-trip) and fetches each
sync target’s playlist list once at start. Two tabs. Library is a three-column browser,
artists › albums › songs, regrouped from the track table on every draw (cheap at this size;
cache it if that ever shows). Playlists lists local playlists with their sync marks and remote
playlists dimmed, with the tracks of the selected one on the right. / filters by artist, title
or album in both tabs; the pane below shows the selected track’s id, path, source URL and error,
plus the last lines of whatever action is running. Colours come from a Theme (theme.rs): a small TOML of named colours, two Catppuccin
flavours compiled in and user files under <config dir>/themes/ taking precedence, selected by
theme in the config. The TUI never sets a background, so it sits on whatever the terminal
has. The header is a four-line cassette drawn from the theme’s stripe colours, with the tabs and
the library’s size beside it, nothing else. assets/demo.tape is a vhs
script that records the README’s GIF and three PNGs against a scratch config: run
TAPEDECK_CONFIG=... vhs assets/demo.tape with td on PATH. Keys follow vim and less: hjkl, ^D/^U
half page, ^F/^B page, g/G, Tab, [/] or 1/2 for tabs. i (or Enter on a
track) opens a popup with the tags, what is on disk (format, bitrate, sample rate, length, size,
read from the file’s header on the spot, so it reflects the real download), the source URL, and
the ListenBrainz player, MusicBrainz recording, release and artist URLs. Playback is not in the
TUI; those links are how a track is checked for now.
Actions are the CLI’s own Commands, nothing more: S is td sync, R is td retry ID
followed by td sync --no-fetch, s asks for a URL and runs td source plus the same sync,
m asks for a recording id and runs td retag, p is td push and u is td unpush, d is td rm ID for a track or td remote rm TITLE --yes for a remote
playlist (backup first, as always). They go through daemon::dispatch, so with the service
running the daemon does the work under its lock and the TUI only streams the events; without
it the command runs in-process. One action at a time. Everything that deletes or re-downloads
asks [y/N] first, including S and the source prompt after its URL, so a stray key never
starts a job, and the TUI reloads from the database when the action ends. Local playlists
cannot be deleted from here: they mirror ListenBrainz and would come back on the next sync.
Data model
artists id, mbid, name, folder
albums id, mbid, artist_id, title, year
tracks id, recording_mbid, album_id, title, artist, artist_mbid, track_no, duration_ms,
path, status, source_url, error
playlists id, lb_mbid, name, kind, m3u_path, date, fetched_at
requests track_id, source, requested_at, confirmed_at
external_files path, root, size, mtime, recording_mbid, artist, title, duration_ms
playlist_tracks playlist_id, track_id, position
playlist_syncs playlist_id, target, remote_id, synced_at
tracks.recording_mbid is unique. That single constraint is what stops the library
filling up with duplicates when the same song shows up in several weekly playlists.
tracks.path is relative to library, so the library can move.
Design notes
Choices that are not obvious from the code, and why.
MusicBrainz is the source of truth for metadata, not yt-dlp. ListenBrainz hands us recording MBIDs for free. Looking each one up once gives consistent artist, album, year and track number, which is what makes a path template and clean tags possible. The download is just the audio.
Which upload gets downloaded. Explo’s weak spot was the music video: a six-minute upload
with three minutes of song. tapedeck asks YouTube Music’s song results first (album audio,
real lengths), looks the top hits up once more for their durations and takes the one within
20 s of the MusicBrainz recording, preferring an exactly matching title; only when none fits
does it fall back to the plain YouTube search with the same closest-length rule. After the
download the file’s own length is checked against the recording and anything more than 20 s
off is deleted and reported as failed, so a wrong cut never lands in the library. Both
candidates are tried in turn, since YouTube refuses the odd download with a 403. td retry ID
drops the remembered URL and searches afresh; td source ID URL pins a URL for the cases
nothing automatic gets right.
yt-dlp as a subprocess, not a library. It changes weekly to keep up with sites;
shelling out means yt-dlp -U fixes things without a rebuild.
One current m3u8 per playlist kind, plus an archive. playlists/weekly-jams.m3u8
mirrors what Plex shows: the current week, replaced on every run, usable by any player
without tapedeck. The week it replaced moves to playlists/archive/weekly-jams/2026-w41.m3u8,
kept just in case and never pushed unless asked. The database holds the same history
(td playlists, the TUI), and the tracks themselves stay in the library whatever the
playlists do.
SQLite only, no JSON store. One source of truth. --json on any command gives a
machine-readable dump when needed.
Track artist and album artist are stored separately. tracks.artist is the
recording’s credit (Bonobo feat. Nicole Miglis); artists holds the artist entity
(Bonobo, by MusicBrainz id) and is what albums hang off.
Artist folders follow the MusicBrainz entity, with a per-artist pin. Bands rename
(Eskimo Callboy → Electric Callboy) and a folder per spelling is the thing nobody wants, so the
folder name is the entity’s current name, refreshed on every lookup; td refile reports
“artist folder changed: X → Y (N tracks)” (or “would change” in a dry run) and moves the files. When the entity name is not what
belongs on disk (Miley Cyrus became “MILEY” in 2026), td artist "MILEY" --folder "Miley Cyrus"
pins a name for that artist (--unpin follows MusicBrainz again); td artists lists both
columns. Pins live in artists.folder and win everywhere the album artist is rendered. Filing by {album_artist} keeps an album in one
folder even when half its tracks have guests.
Plex playlists are recreated, not diffed. Deleting and recreating is a handful of requests and has no edge cases. Playlist history lives in the database, not in Plex.
The release picked for a recording is ranked: official status, then no secondary
types (compilation, live, DJ-mix, soundtrack), then released by the track’s own artist,
then album before EP before single, then earliest date, with dates compared as
(year, month, day) so a bare 1993 doesn’t beat 1993-02-22 and an unknown date sorts
last. Before the secondary-type and artist checks, Creep ended up on a Belgian
compilation and Nightcall in a DJ mix.
Downloads are matched by length. ytsearch1: returns whatever YouTube ranks first,
which is often the music video with a long intro or a radio edit. tapedeck asks for five
hits and takes the one closest to the MusicBrainz recording length; if even that is more
than 20 seconds off it fails the track instead of filing the wrong audio, and td source
lets you pin a URL by hand. The chosen URL is kept in tracks.source_url.
Contributing
nix develop # rust toolchain, yt-dlp, ffmpeg
cargo test # offline suite
cargo test -- --include-ignored # also the live ListenBrainz / MusicBrainz tests
nix build # what CI and the cache build; runs the offline suite
The repository is a Cargo workspace: tapedeck itself at the root and
crates/subsonic-server, the Subsonic protocol layer, which depends on nothing in tapedeck.
CI runs fmt, clippy and the full test suite, builds the Nix package on x86_64-linux and
aarch64-darwin and pushes both to arunoruto.cachix.org, and publishes this book to GitHub
Pages from docs/. Pushing a v* tag that matches Cargo.toml cuts a release with
prebuilt binaries and pushes the container to ghcr.io/arunoruto/tapedeck as <version> and
latest; running the Container workflow by hand pushes sha-<short sha> only, for trying a
commit without moving latest. Sync targets and download backends are each one small trait; the
architecture page says where. Keep the docs human-sounding;
design decisions go into the architecture page’s design notes.
Commit messages follow Conventional Commits:
feat(lyrics): …, fix(subsonic): …, docs: …, ci: …, refactor: …, chore: …, with a
! or a BREAKING CHANGE: footer for anything that changes behaviour people rely on, so a
changelog can be generated for a release.