Files
playlink/README.md
T
cdnutterandClaude Opus 5.5 faebc5dfb0 Notifications, and automatic cleanup of empty folders
Settings → Notifications sends a message to Discord, Pushover, ntfy,
Gotify or a JSON webhook when a scan finds songs that newly need
review, when auto-link replaced identical files, and when an automatic
cleanup ran. Each event can be turned off and each service tested; an
optional Playlink address adds a link back to the right page.

A new per-pair option removes empty slave folders after each scan. Only
folders with no file at all inside are removed (into the quarantine,
recorded in History); folders holding any file wait in Cleanup, folders
changed within the settle time are skipped, and dry run removes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:36:04 -07:00

17 KiB

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.

Rename folders to match (recommended first step). When a scan finds slave folders that are the same as a master folder with a different spelling (City Of Evil vs City of Evil, AC-DC vs AC_DC, look-alike hyphens), the warning offers to rename the slave folders to the master's spelling so their songs match by path. Do this right after a scan, before reviewing matches. A preview lists every rename with the differences highlighted; conflicts (the slave already has a folder with the master's name) are skipped with the reason. Only slave folders are renamed, nested folders revealed by a rename are handled in the same run, every rename appears in History and can be undone, and the library is rescanned afterwards.

Bulk review. Switch the Review list from Songs to Albums to see one row per album (disc folders are folded in), with counts of upgrades, identical files, songs where the master is lower quality and approximate matches. Tick whole albums, or expand an album and tick individual songs, then Link, Use slave copies as master, Ignore or Un-ignore them in one go. Before anything runs, a preview shows how many songs will be processed and which are skipped and why, with warnings and examples for downgrades, approximate matches, duration mismatches and files that move folders. Downgrades are skipped unless you tick a box, and replacing master files needs an explicit acknowledgement. Each song then goes through the same checks as a single action.

Export. The Review tab's Export CSV button downloads either the current view (filter and search applied) or the entire scan. Each row has the match status and type, a recommendation, both paths, where linking would place the file, codec, bitrate, sample rate, bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice (UTF-8, and cells a spreadsheet would treat as formulas are escaped).

Duplicates within the slave library. The Review tab's Duplicates filter is separate from master/slave matching: it looks only inside the slave library for songs filed more than once, such as a single next to its album version, a compilation track, or one song in two formats. Find duplicates groups songs in the same top-level folder by title, using the same rules as approximate matching (track numbers, disc folders and release labels such as Single Version or Remastered are ignored; Live, Remix, Acoustic and similar versions are kept apart). With ffprobe, copies whose artist tags disagree are kept apart too, and each copy shows its quality and length, with a warning when lengths differ by more than 10 seconds. Each song has a play button to compare the copies. One copy is recommended to keep (the album version, then one already linked to the master, then the better quality) and the others start selected. Remove moves the selected copies into the quarantine after re-checking that each is unchanged and that another copy is still there; one copy of every song is always kept, emptied folders are removed, dry run applies, and every removal can be restored from History.

Listening in build mode. Every song in the Build tab has a play button. A mini-player along the bottom of the page plays the folder in order, with seeking, previous/next, volume and space to pause. Songs are streamed from the master library as they are (nothing is converted), so they play when the browser supports the format: FLAC, MP3, AAC/M4A, Ogg/Opus and WAV in current browsers, while formats such as WMA or APE show a message instead.

Folders without music (the Cleanup tab). Choose the slave or master library and Find folders: Playlink lists the top-most folders with no music file anywhere inside them (music = the pair's file extensions), such as album folders left empty after linking or ones holding only a cover or playlist, with what they still contain. Empty folders are pre-selected, folders that still hold files are not. Remove asks for confirmation (and an extra acknowledgement when the folders still hold files), re-checks each folder for music right before touching it, and moves it into the quarantine instead of deleting it: it can be restored from History until the retention period purges it. The library root, the quarantine and hidden folders are never listed, and dry run applies.

Automatic cleanup (per pair, off by default) runs the same search on the slave library after every scan and removes only folders with nothing at all inside them. A folder holding any file, even a cover or a .DS_Store, is left for you in Cleanup, and folders changed within the settle time are skipped. Removals go to the quarantine and History like manual ones; in dry run nothing is removed.

Notifications (Settings → Notifications) send a message to Discord, Pushover, ntfy, Gotify or any JSON webhook when a scan finds songs that newly need review, when automatic linking replaced identical files, and when an automatic cleanup removed empty folders. Each event can be turned off, and each service has a Test button. Set the optional Playlink address to get a link back to the right page in every message.

Backups (Settings → Backups), similar to Sonarr. A backup is a zip of Playlink's database (settings, library pairs, scan results and history, not music files) taken safely while the app runs. Choose a folder (default /config/backups), a schedule (off, every 1, 3, 7 or 14 days) and a retention period for scheduled backups; Back up now makes a manual one (kept until deleted). Backups can be downloaded, deleted, or restored, also from an uploaded zip. A restore is validated first (damaged backups and ones from a newer version are refused), then playlink restarts to swap the database in and keeps the replaced one as playlink.db.pre-restore-…; Docker's restart policy brings it back automatically. Optionally, back up before every action in live mode: one backup per action you start (and before scans change files), never one per file; if that backup fails the action does not run, and the newest 10 are kept.

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 then rename(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. Automatic cleanup likewise only removes slave folders that are completely empty and unchanged for the settle time, and never touches the master library.
  • 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=Strict HttpOnly 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)

docker-compose.yml takes every host-specific value from a .env file next to it, so the compose file itself never needs editing. That suits Arcane: point a project's Git Sync at this repository and set the values in Arcane's .env editor; Arcane builds the image from the Dockerfile. With plain Compose:

cp .env.example .env    # then edit it
docker compose up -d --build

The main settings (all listed with defaults in .env.example):

Variable Default Meaning
PLAYLINK_DATA_PATH /mnt/user/Sapphire Host folder containing both libraries, mounted at /data.
PLAYLINK_CONFIG_PATH /mnt/user/Aqua/playlink Host folder for the database and backups, mounted at /config.
PUID / PGID 99 / 100 User and group that own your music (Unraid nobody:users).
PLAYLINK_PORT 7480 Host port of the web UI.
PLAYLINK_NETWORK chromanet Existing Docker network to join.
TZ America/Los_Angeles Time zone for logs and backup schedules.
PLAYLINK_SECURE_COOKIES false true behind HTTPS.

.env is ignored by Git, so your settings stay local while the repository syncs.

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.

  • 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/user require 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.

Hide quarantine from Plex (Settings → Media servers) writes a .plexignore into each pair's quarantine folder so Plex does not pick up quarantined songs; new quarantine folders get it automatically. Turning it off removes the file again, but only if Playlink created it.

Per-pair settings (in the UI) are file extensions, auto-link, automatic cleanup, 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