Files
2026-10-13 19:45:57 -04:00

16 KiB

⚠️ THIS PROJECT WAS ENTIRELY VIBECODED AND IS SLOP. DO NOT PROCEED IF YOU DO NOT ALIGN WITH THESE VALUES. DO NOT COMPLAIN ABOUT IT IN PULL RELEASES, ISSUES, ETC. UNLESS THERE ARE SEVERE SECURITY RISKS. THEY WILL BE REMOVED. PROCEED AT YOUR OWN RISK! ⚠️

Playlink

Playlink dedupes music libraries with hardlinks. You give it a master library (say, your full Albums folder) and a slave library (say, a curated Singles folder). It finds songs that are in both and swaps the slave copy for a hardlink to the master copy. The song takes up disk space once, and Plex, Navidrome, Jellyfin and friends still see both libraries exactly as before.

It's one Go binary with a React UI and SQLite baked in, shipped as a small Docker image. The setup is modeled on qui.

How matching works

Songs are matched by their path relative to each library root. The folder layout doesn't matter (Artist/Album/Title.ext, Genre/Artist/Title.ext, flat) as long as both libraries use the same one. A few fallbacks catch small differences, and a scan warns you if the two layouts don't line up.

Situation Status What happens
Same path, byte-identical identical Link it with one click, in bulk, or automatically after each scan.
Same path, different bytes (re-encode, different tags) different You review it, with codec, bitrate, bit depth and duration side by side.
Same title, different format (Song.mp3 vs Song.flac) similar (other format) You review it.
Same title once track numbers and punctuation are ignored (01 - Song!.flac vs Song.mp3) similar (similar title) You review it.
Same top-level folder and title, different subfolder similar (same top folder, other subfolder) You review it.
Close match somewhere else, like a single vs. the album version or a disc folder (opt-in) similar (approximate match) You review it, and the reason is shown.
Only in the slave library unmatched Left alone.
Already the same inode linked Nothing to do.

Approximate matching

This is off by default and turned on per pair. It catches songs whose file name or folder doesn't quite line up with the master, and suggests linking them to the master's album version:

Case Slave Master
Single to 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 looks in the same album first (ignoring disc folders), then the artist's other albums, and only then the whole master library. Titles are compared by similarity against a threshold you set (85% by default), and labels like Single Version, Remastered and Explicit are ignored. If a song is on several releases, the studio album wins over singles, EPs and compilations, even when the master also has the single. When you link one of these, the file goes to the master's path in the slave library and the emptied single folder is removed. You can also keep it where it is.

What it won't do:

  • Match different recordings. Live, Remix, Acoustic, Instrumental and similar versions stay separate, including whole live or acoustic albums.
  • Match outside the artist's folder unless both files' artist tags agree.
  • Guess. Ambiguous candidates are skipped.
  • Link approximate matches automatically.

Reviewing matches

Review shows both files side by side and ranks them: lossless beats lossy, then bit depth and sample rate, then bitrate weighted by codec (Opus and AAC get more credit per kbps than MP3). You've got three options:

  • Upgrade / link to master copy. This is the default. If the master is better, the match is flagged as an upgrade and shows up under the Upgrades filter. The master wins because it's part of a full album. If the master is worse, Playlink warns you and asks you to confirm.
  • Use slave copy as master. The slave's better file replaces the master, the old master goes to quarantine, and both libraries share the better file.
  • Ignore. Hides the pair until you un-ignore it.

Before reviewing anything, fix folder spelling. If a scan finds slave folders that match a master folder except for 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 match so their songs line up by path. You get a preview of every rename with the differences highlighted. Conflicts, where the slave already has a folder with the master's name, are skipped and explained. Only slave folders get renamed. Nested folders that turn up after a rename are handled in the same run. Every rename lands in History and can be undone, and the library is rescanned afterwards.

To review in bulk, switch the Review list from Songs to Albums. You get one row per album (disc folders folded in) with counts of upgrades, identical files, songs where the master is worse, and approximate matches. Tick whole albums or expand one and pick songs, then Link, Use slave copies as master, Ignore or Un-ignore them all at once. Before anything runs you see how many songs will be processed, which get skipped and why, and examples of downgrades, approximate matches, duration mismatches and files that would move folders. Downgrades are skipped unless you tick a box, and replacing master files needs an extra confirmation. Each song still goes through the same checks as a single action.

Export CSV on the Review tab downloads either the current view (with your filter and search) or the whole scan. Each row has the status and match type, a recommendation, both paths, where linking would put the file, codec, bitrate, sample rate, bit depth and duration for both sides, the quality verdict and any notes. It's UTF-8 and escapes anything a spreadsheet would read as a formula, so it opens fine in Excel, Numbers and LibreOffice.

Duplicates inside the slave library

The Duplicates filter on the Review tab has nothing to do with master/slave matching. It looks only inside the slave library for songs filed more than once: a single next to its album version, a compilation track, the same 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 labels like Single Version or Remastered ignored; Live, Remix, Acoustic and the like kept apart). With ffprobe installed, copies with different artist tags are kept apart too, and each copy shows its quality and length, with a warning if lengths differ by more than 10 seconds. Every song has a play button so you can compare.

Playlink suggests one copy to keep (the album version first, then one already linked to the master, then the better quality) and pre-selects the rest. Remove re-checks that each selected copy is unchanged and that another copy still exists, then moves it to quarantine. One copy of every song is always kept, emptied folders are removed, dry run applies, and every removal can be restored from History.

Build mode

Build mode is for starting fresh. 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.

Every song in the Build tab has a play button. A player along the bottom plays the folder in order, with seeking, previous/next, volume, and space to pause. Files are streamed from the master library as-is with no transcoding, so they play if your browser supports the format. Current browsers handle FLAC, MP3, AAC/M4A, Ogg/Opus and WAV. Things like WMA or APE show a message instead.

Cleaning up empty folders

On the Cleanup tab, pick the slave or master library and hit Find folders. Playlink lists the top-most folders that have no music anywhere inside them (music meaning the pair's file extensions), like album folders left empty after linking or ones holding only a cover or a playlist, along with whatever they still contain. Truly empty folders are pre-selected, the others aren't. Remove asks you to confirm (and confirm again if the folders still hold files), re-checks each one for music right before touching it, and moves it to quarantine instead of deleting it. You can restore it from History until the retention period purges it. The library root, the quarantine and hidden folders never show up here, and dry run applies.

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

Notifications

Settings → Notifications can send a message to Discord, Pushover, ntfy, Gotify or any JSON webhook when a scan finds new songs to review, when auto-link replaced identical files, or when automatic cleanup removed empty folders. Each event can be turned off, and each service has a Test button. If you fill in Playlink's address, every message links back to the right page.

Backups

Settings → Backups works a lot like Sonarr's. A backup is a zip of Playlink's database (settings, library pairs, scan results and history, not your music), taken safely while the app is running. You choose a folder (default /config/backups), a schedule (off, or every 1, 3, 7 or 14 days), and how long to keep scheduled backups. Back up now makes a manual one that's kept until you delete it.

Backups can be downloaded, deleted or restored, including from an uploaded zip. A restore is checked first, and damaged backups or ones from a newer version are refused. Playlink then restarts to swap the database in and keeps the old one as playlink.db.pre-restore-…. Docker's restart policy brings it back up on its own.

You can also have it back up before every action in live mode. That's one backup per action you start (and before scans change files), not one per file. If the backup fails, the action doesn't run. The newest 10 are kept.

Safety

The goal is that nothing gets lost, even if Playlink crashes or the power goes out mid-operation.

  • Dry run is on by default. Every action shows up in Activity as planned until you switch to live mode.
  • Files are re-checked before any change. Both must still be regular files with the same inode, size and mtime the scan saw, or the action is refused ("changed since scan").
  • Identical files get a full byte-for-byte compare (like cmp) right before linking, which also catches files that change while being read.
  • Replacements are atomic. The new link is staged next to the target (.playlink-tmp-*), checked by inode, then rename(2)d over the target, so the path always points at a complete file.
  • Nothing non-identical is deleted outright. Before a non-identical file is replaced, it's hardlinked into the quarantine (<slave>/.playlink-trash/<op-id>/…). You can restore it with one click until the retention period runs out (30 days by default) or you purge it.
  • Everything can be undone. Undoing an identical link writes a separate, verified copy back. Undoing a quarantined file restores the original.
  • Build mode's Remove only deletes a slave file that shares an inode with the master file, so it can never delete the last copy of anything.
  • Paths are locked down. Libraries and the quarantine have to be inside PLAYLINK_ALLOWED_ROOTS. Symlinks are never followed or replaced, .. is rejected, and master and slave can't overlap.
  • Only one filesystem change runs at a time, and only one job per pair.
  • Auto-link only touches files with the exact same path, and skips anything modified within the settle time (10 minutes by default) so it won't grab a download in progress. Automatic cleanup only removes slave folders that are completely empty and haven't changed within the settle time, and never touches the master library.
  • If Playlink crashes mid-operation, that operation is marked interrupted on the next start and its staging files are cleaned up.
  • The web UI needs a login. Passwords are bcrypt-hashed, sessions use SameSite=Strict HttpOnly cookies, and anything that changes state needs a custom request header and a same-origin check. Logins are rate-limited and a strict CSP is sent.

Running it (Docker / Unraid)

docker-compose.yml pulls every host-specific value from a .env file next to it, so you never have to edit the compose file. That works well with Arcane: point a project's Git Sync at this repo, fill in the values in Arcane's .env editor, and 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 of them, with defaults, are in .env.example):

Variable Default What it's for
PLAYLINK_DATA_PATH /mnt/user/Sapphire Host folder that contains 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's nobody:users).
PLAYLINK_PORT 7480 Host port for 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 Set to true behind HTTPS.

.env is git-ignored, so your settings stay local while the repo syncs.

Then open http://<server>:7480, create your account and add a pair, for example:

  • Master: /data/Music/Albums
  • Slave: /data/Music/Instances/cdnutter/Singles

Run a scan, look over the Review tab, and turn off dry run once the planned actions in Activity look right.

  • Both libraries have to be visible through one bind mount. Docker bind mounts are separate mount points, and link(2) fails across them (EXDEV) even on the same disk. Mount a shared parent folder like the example above.
  • Both libraries have to be on the same filesystem. Playlink checks this when you save a pair and before every operation.
  • On Unraid, hardlinks through /mnt/user need Settings → Global Share Settings → Tunable (support Hard Links) set to Yes, which is the default. If the share uses a cache pool, make sure your mover keeps hardlinks intact. Every link is verified by inode, and if one fails the original is left alone.

Configuration

Variable Default What it's for
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 be inside. / isn't allowed.
PLAYLINK_SECURE_COOKIES false Set to true when served over HTTPS.
PLAYLINK_FFPROBE ffprobe Path to ffprobe. Without it you lose quality comparison, but everything else still works.
PLAYLINK_LOG_LEVEL info debug for verbose logs.

Settings → Media servers has a Hide quarantine from Plex option. It writes a .plexignore into each pair's quarantine folder so Plex doesn't pick up quarantined songs, and new quarantine folders get one automatically. Turning it off removes the file, but only if Playlink created it.

Per-pair settings live in the UI: file extensions, auto-link, automatic cleanup, approximate matching and its threshold, scan interval, quarantine folder, retention and settle time.

Development

You'll need 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