Matching: - Prefer the studio album over singles, EPs and compilations when a song exists on several releases, including when the master also has the single at the slave's exact folder. - Ignore release labels (Single Version, Remastered, Explicit, Deluxe) inside brackets or a trailing " - ..." part; "Clean" stays a version. - Treat live/acoustic/remix/... album folders as different recordings; the strict top-folder fallback now respects this too. - Add a last-resort library-wide search that only accepts candidates whose artist tag agrees with the slave file. Linking: - Cross-folder matches now mirror the master's path in the slave (e.g. a single moves into the album folder) and prune the emptied folder; a "keep in current folder" option preserves the old behaviour. Undo recreates and prunes folders accordingly. - Operations record the match kind and reason, so undo (and promotes that rename the master file) restore the correct pairing instead of leaving the file unmatched until the next scan. UI: rewrite the approximate matching description with Avenged Sevenfold examples and clear safeguards, and show where linking will place a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
playlink
Deduplicate music libraries with hardlinks. playlink finds songs that exist in both a master library (e.g. your full Albums) and a
slave library (e.g. a curated Singles library) and replaces the slave copy with a hardlink to the master copy. Each song then takes
up disk space only once, while both libraries keep looking exactly the same to Plex, Navidrome, Jellyfin and other media servers.
It ships as a single Go binary with an embedded React web UI and SQLite state, packaged as a small Docker image, in the style of qui.
What it does
Files are matched by their path relative to each library root. Any folder layout works (Artist/Album/Title.ext,
Genre/Artist/Title.ext, flat, …) as long as both libraries use the same one, so that a song sits at the same relative
path on both sides. The fallbacks below catch small differences, and a scan warns when the two layouts don't line up.
| Situation | What playlink does |
|---|---|
| Same relative path, byte-identical | Status identical. Link with one click, in bulk, or automatically after each scan. |
| Same path, different bytes (re-encode, different tags) | Status different. Manual review with a codec/bitrate/bit-depth/duration comparison. |
Same title, different format (Song.mp3 vs Song.flac) |
Status similar (other format). Manual review. |
Same title after ignoring track numbers/punctuation (01 - Song!.flac vs Song.mp3) |
Status similar (similar title). Manual review. |
| Same top-level folder and title, but a different subfolder | Status similar (same top folder, other subfolder). Manual review. |
| (opt-in) Approximately the same song in another folder, e.g. a single vs. the album version or a disc folder | Status similar (approximate match), with the reason shown. Manual review. |
| Only exists in the slave library | Status unmatched. Never touched. |
| Already the same inode | Status linked. |
Approximate matching is an opt-in setting on each pair. It finds songs whose file name or folder doesn't line up exactly with the master library and recommends linking them to the master's album version:
| Case | Slave | Master |
|---|---|---|
| Single → album version | Avenged Sevenfold/Hail to the King - Single/Hail to the King.mp3 |
Avenged Sevenfold/Hail to the King/01 Hail to the King.flac |
| Disc folders ignored | Avenged Sevenfold/Nightmare/04 Buried Alive.mp3 |
Avenged Sevenfold/Nightmare/Disc 1/04 Buried Alive.flac |
| Small title differences | Avenged Sevenfold/City of Evil/Bat Country (Remastered).mp3 |
Avenged Sevenfold/City of Evil/03 Bat Country.flac |
It searches the same album first (disc folders ignored), then the artist's other albums, then, as a last resort, the whole master library. Titles are compared by similarity against a threshold you choose (85% by default), and release labels such as Single Version, Remastered or Explicit are ignored. When a song exists on several releases, the studio album wins over singles, EPs and compilations, even if the master library also has the single. Linking such a match places the file at the master's path in the slave library (the emptied single folder is removed), or you can keep it in the slave's current folder.
Safeguards:
- Different recordings never match. Live, Remix, Acoustic, Instrumental and similar versions are kept apart, including whole live or acoustic albums.
- Matches found outside the artist's folder require both files' artist tags to agree.
- Ambiguous candidates are skipped rather than guessed.
- Approximate matches are never linked automatically.
Manual review shows both files side by side and ranks their quality: lossless beats lossy, then bit depth and sample rate, then codec-weighted bitrate (Opus and AAC count for more per kbps than MP3). You then have three choices:
- Upgrade / link to master copy. The default. When the master copy is better, the match is flagged as an upgrade and listed under the Upgrades filter. The master is kept because it belongs to a complete album. If the master is lower quality, playlink warns you and asks for explicit confirmation.
- Use slave copy as master. The override. The slave's better file replaces the master file, the old master file is quarantined, and both libraries share the better file.
- Ignore. The pair is hidden from review until you un-ignore it.
Build mode covers the "start fresh" workflow. Point the slave at an empty folder, browse the master library, tick the folders or songs you want, and playlink hardlinks them in at the same relative path. Remove deletes slave files, but only ones that are hardlinks of the master file.
Safety model
playlink is built so that no user data is ever lost, even if it crashes or loses power mid-operation:
- Dry run is on by default. Every action is recorded in Activity as planned until you switch to live mode.
- Files are re-verified before any change. Both files must still be plain regular files with the same inode, size and mtime that the scan recorded, or the action is refused ("changed since scan").
- Identical files are compared again right before linking. The check is a full byte-for-byte compare, like
cmp, and it also detects files that change while being read. - Replacements are atomic. The new link is staged next to the target (
.playlink-tmp-*), checked by inode, and thenrename(2)d over the target. The path always points at a complete file. - Nothing non-identical is ever deleted directly. Before a non-identical file is replaced, a hardlink to it is created in the
quarantine (
<slave>/.playlink-trash/<op-id>/…). It can be restored with one click until the retention period (default 30 days) expires, or until you purge it. - Every operation can be undone. An identical link is undone by writing an independent, verified copy back. A quarantined file is undone by restoring the original.
- Removal is limited to hardlinks. Build/Remove only deletes a slave file that shares an inode with the master file, so the last copy of any data is never deleted.
- Paths are confined. Libraries and the quarantine must be inside
PLAYLINK_ALLOWED_ROOTS. Symlinks are never followed or replaced,..is rejected, and master and slave may not overlap. - Mutations are serialized. Only one filesystem mutation runs at a time, and only one job runs per pair.
- Auto-link is conservative. It only acts on files with the exact same path, and it skips files modified within the settle time (default 10 minutes) so it never touches a download in progress.
- Interrupted operations are cleaned up. Operations cut short by a crash are marked interrupted on startup, and their staging files are removed.
- The web UI requires login. Passwords are hashed with bcrypt, sessions use
SameSite=StrictHttpOnly cookies, and state-changing requests need a custom request header and a same-origin check. Logins are rate-limited, and a strict CSP is sent.
Quick start (Docker / Unraid)
services:
playlink:
build: . # or your published image
container_name: playlink
user: "99:100" # owner of your music files (Unraid nobody:users)
ports: ["7480:7480"]
environment:
- PLAYLINK_ALLOWED_ROOTS=/data
volumes:
- /mnt/user/appdata/playlink:/config
- /mnt/user/Sapphire/Media/Music:/data/Music # ONE mount containing both libraries
restart: unless-stopped
Then open http://<server>:7480, create your account, and add a pair:
- Master:
/data/Music/Albums - Slave:
/data/Music/Instances/cdnutter/Singles
Run a scan, check the Review tab, and turn off dry run once the planned actions in Activity look right.
Hardlink requirements
- Both libraries must be visible through one bind mount. Docker bind mounts are separate mount points, and
link(2)fails across them (EXDEV) even when they are on the same disk. Mount a common parent folder, as shown above. - Both libraries must be on the same filesystem. playlink checks this when you save a pair and before every operation.
- Unraid: hardlinks through
/mnt/userrequire Settings → Global Share Settings → Tunable (support Hard Links) to be Yes (the default). If the share uses a cache pool, make sure your mover preserves hardlinks. Every link is verified by inode, and a failed link aborts safely with the original untouched.
Configuration
| Variable | Default | Description |
|---|---|---|
PLAYLINK_ADDR |
:7480 |
Listen address. |
PLAYLINK_DATA_DIR |
/config (Docker) or ./config |
Where the SQLite database lives. |
PLAYLINK_ALLOWED_ROOTS |
/data |
Comma-separated folders that libraries and the quarantine must live in. / is refused. |
PLAYLINK_SECURE_COOKIES |
false |
Set to true when served over HTTPS. |
PLAYLINK_FFPROBE |
ffprobe |
Path to ffprobe. Without it, quality comparison is disabled but everything else works. |
PLAYLINK_LOG_LEVEL |
info |
debug for verbose logs. |
Per-pair settings (in the UI) are file extensions, auto-link, approximate matching and its threshold, scan interval, quarantine folder, retention and settle time.
Development
Requires Go 1.27+ and Node 22+.
make test # go vet, go test -race, tsc
make build # builds web/dist and the ./playlink binary (UI embedded)
cd web && npm run dev # Vite dev server on :5173, proxies /api to :7480
Layout:
cmd/playlink entrypoint, config, scheduler
internal/fsops safety-critical primitives (confined paths, cmp, atomic link/replace)
internal/engine scanning, matching, link/promote/build/remove/undo, jobs
internal/probe ffprobe wrapper and quality ranking
internal/store SQLite schema and queries
internal/api HTTP API, auth, embedded UI serving
web/ React + TypeScript + Tailwind UI