Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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: Enter in 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 tui on 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 daemon runs on an interval and listens on a Unix socket; every other td command talks to it if it’s running and does the work itself if not.
  • --json everywhere. 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-dlp and ffmpeg on PATH, 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.toml as user and group tapedeck (both created; override user/group to use existing ones), with extraGroups as supplementary groups. package, ytdlpPackage and ffmpegPackage work as in the Home Manager module (no mpv on a server).
  • database defaults to /var/lib/tapedeck/tapedeck.db and socket to /run/tapedeck/tapedeck.sock; both directories are mode 0770 and the unit’s umask is 0002, so members of the group can run td status or td tui and have their actions go through the daemon. Downloaded files come out group-writable as well, which is what Plex and Samba want.
  • Puts td in environment.systemPackages and sets TAPEDECK_CONFIG to 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=strict with 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.service automatically so the secret exists when td starts.

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.

td browsing the library and the playlists

Keys

KeyDoes
Tab, [, ], 1, 2, 3Switch tabs
h j k l, arrowsMove between panes and rows; Enter or l drills into the next column
^D ^U, ^F ^B, g GHalf page, page, ends
/Filter by artist, title or album; ^U wipes it while typing
iTrack info: tags, what is on disk, ListenBrainz player and MusicBrainz links, source URL
Enter on a trackPlay it and queue the rest of the list
space, n N, , ., + -, xPause, next/previous, seek 10 s, volume, stop
SFull sync
RRe-download the selected track from a fresh search
sPin a download URL for the selected track
mMake the selected track another MusicBrainz recording (fix a wrong match)
p, uPush the selected playlist to the targets, or remove it from them
dDelete the selected track and its file, or a remote playlist
r, qReload, 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:

KeyDoes
EnterPlay from the selected row on
eJump to 5 s before where the recording should end: music or a skit going on means a video cut
EJump to the last 30 s: cut off, or a proper ending?
g b r uGood, bad, relink (to the recording in the note), undecided; each moves on, and plays the next one if something was playing
FFix what is flagged: bad files are downloaded again (old ones kept in .tapedeck-backup/), relink ones retagged
LCheck 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 songLyrics extension, plain through getLyrics, 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
KeyDefaultMeaning
paths[]Folders to index. Nothing under them is ever written, moved, retagged or deleted.
scan_interval1hHow often the daemon rescans them, independently of sync.interval. Every sync scans too. 0 disables the periodic scan.
prefer_externalaskWhat 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_template place, with their tags as they are.
  • Ids are synthetic (steam:<app id or folder>), so td retag and the MusicBrainz renaming logic leave these rows alone, and Subsonic clients get the embedded picture or a cover.jpg next 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 after retry_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 status shows 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 .lrc files 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 and td external dedupe take the .lrc/.txt along.
  • Subsonic clients get synced lyrics through the OpenSubsonic songLyrics extension (getLyricsBySongId) and plain ones through getLyrics, 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.

  1. Back up the playlists Explo made on Plex: td remote backup --to plex writes one m3u8 plus a JSON sidecar per playlist under playlists/backup/plex/.
  2. 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.
  3. Apply: td import /path/to/explo --apply copies the kept files into the library and leaves the source alone (--move moves instead; --delete-duplicates only with --move). Backups are rewritten to point at the library copies.
  4. Relink what still points into the old folder: td remote relink --from /path/to/explo.
  5. Fix the odd match with td retag ID --recording MBID, found in the TUI with i.
  6. Delete the old folder yourself, then td remote rm "Weekly-Jams-*" --yes for 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

CommandWhat 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 statusTrack counts by status (wanted / ready / failed)
td search QUERYSearch the library by artist, album or title
td playlistsFetched playlists and where they’ve been synced
td artists / td artist NAME --folder FAlbum 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] --yesRemove 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 IDDelete one track’s file and forget it
td source ID URLPin the download URL for one track and queue it
td retag ID --recording MBIDCorrect 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|noneJudge a finding after listening (the TUI’s Review tab does the same with g b r u)
td lengths [TRACK] --fixAct 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 FILESave 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 syncSteam 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 DIRPoint backup entries under DIR at the library’s copy of the same recording; run before deleting an imported folder
td daemonRun as a service: listen on the socket, sync every sync.interval
td / td tuiBrowse 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

KeyDefaultMeaning
libraryrequiredRoot 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.
themecatppuccin-mochaTUI 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>/playlistsWhere 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.dbSQLite file.
socket$XDG_RUNTIME_DIR/tapedeck.sockDaemon socket. Falls back next to the database if XDG_RUNTIME_DIR is unset.

Path template

Lidarr-style tokens inside {}:

TokenValue
{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]

KeyDefaultMeaning
userrequiredListenBrainz username whose created-for playlists to follow.
tokennoneUser token. Only needed if the playlists are private.
token_filenonePath 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]

KeyDefaultMeaning
backendytdlpDownload backend. Only ytdlp exists today.
formatopusPassed to yt-dlp --audio-format.
ytdlp_binyt-dlpExecutable name or path.
ffmpeg_binnonePassed 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]

KeyDefaultMeaning
interval6hHow often the daemon runs sync. Suffixes: s, m, h, d.
on_starttrueSync 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]

KeyMeaning
urlServer URL, e.g. http://plex.local:32400.
tokenX-Plex-Token. Find it via Plex’s “Get Info → View XML” on any item.
token_filePath to a file containing the token, instead of token. One of the two is required.
sectionName of the music library section, usually Music.
path_mapLocal 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:

  1. 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.
  2. 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 in playlist_tracks.
  3. Download every wanted track 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 → ready with its relative path; failure → failed with the error text. td retry flips failed back to wanted.
  4. 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 to archive/<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 follow path_template and the targets show playlist_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.
  5. 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 Command is serialized as one JSON line, the daemon streams back Event JSON lines and closes. The client renders them.
  • Not connected: the same App::run executes 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.