The artist folder can be left empty for Album/01 Title (no artist tag needed), and folders such as Video Games can have their own formats. Files in a rule folder are only renamed inside it; other files are never moved into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
18 KiB
Markdown
246 lines
18 KiB
Markdown
# 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](https://github.com/autobrr/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.
|
||
|
||
**Tag editor.** The *Tags* tab browses either library. Click a song to edit its tags (title, artist, album artist, album, track and
|
||
disc numbers with totals, year, genre, composer, comment), or tick several, such as a whole album, to edit the tags they share. It
|
||
works for MP3, FLAC, M4A, OGG and Opus; other tags and cover art are kept. Review's compare panel also has **Slave tags** / **Master
|
||
tags** buttons. Edits are safe with hardlinks:
|
||
|
||
- A file used by only one library is rewritten to a new file that atomically replaces it; the old file goes to quarantine.
|
||
- A file hardlinked to the other library is updated in place, so both libraries see the new tags and stay linked; a verified backup
|
||
copy goes to quarantine first. You can instead choose to change only one library's copy, which breaks the link.
|
||
- Every written file is verified: the new values must read back, and the decoded audio must be identical to the original.
|
||
- Each edit is listed in History (old → new values) and can be undone; an in-place edit interrupted by a crash is rolled back on
|
||
startup. Dry run applies.
|
||
|
||
**Album editor.** Every folder in *Tags* has an **Edit album** button (like AudioShell's folder view). It shows the album fields
|
||
once (album, album artist, year, genre, discs, composer, comment); a change there updates every song in the album, including songs in
|
||
`Disc 01`/`Disc 02` subfolders. Below is a track grid to edit each song's disc, track number, title and artist, with **Number tracks
|
||
in order** (fills track and disc totals too) and **Artist = album artist**. All songs are saved as one job with the same safety
|
||
checks as above.
|
||
|
||
**Album art.** The song and album editors can swap the cover. Search iTunes, Deezer, MusicBrainz (Cover Art Archive), DuckDuckGo
|
||
or Google by artist and album or by free text; only square images are shown, and results under a minimum size are hidden. You can
|
||
also paste an image address or upload a file. The chosen image is converted to JPEG at the saved size (1000 × 1000 by default) and
|
||
can be embedded in the songs, saved as `cover.jpg` in the album folder, or both. OGG and Opus can't hold embedded art here, so they
|
||
use the folder image. A replaced `cover.jpg` goes to quarantine, and every change can be undone from History.
|
||
|
||
- **Auto-match art…** walks the current folder, looks up each album on iTunes, Deezer and MusicBrainz, and applies a cover only when
|
||
the artist and album clearly match (release suffixes like "Deluxe Edition" are ignored). Albums that already have art can be
|
||
skipped; albums without a confident match are listed and left untouched.
|
||
- Sources are chosen in *Settings → Album art*. Google needs an API key with the Custom Search API and a Programmable Search Engine
|
||
id (cx). DuckDuckGo has no official API and may stop working. Images are downloaded by playlink itself and never from addresses on
|
||
your local network.
|
||
|
||
**Organize (rename files from tags).** The *Organize* tab sets how files are named, in the style of Lidarr: an **artist folder**, an
|
||
**album folder**, a **track format** and a separate **multi-disc track format**, with presets. The default is
|
||
`{Artist Name}/{Album Title}/{track:00} {Track Title}`, and multi-disc albums get `{Medium Format} {medium:00}/…` → `Disc 01`, `Disc 02`.
|
||
Leave the artist folder empty for `Album/01 Title`; songs then don't need an artist tag.
|
||
|
||
**Folders with their own naming.** Part of a library can use different formats, such as game soundtracks in
|
||
`Video Games/{Album Title}/{track:00} {Track Title}` while the rest stays `Artist/Album/01 Title`. Files inside such a folder are only
|
||
renamed within it, and other files are never moved into it (they are skipped with a reason instead). Folder rules can't be nested.
|
||
|
||
Tokens: `{Artist Name}`, `{Artist NameThe}` (The Beatles → Beatles, The), `{Artist CleanName}`, `{Artist NameFirstCharacter}`,
|
||
`{Album Title}`, `{Album TitleThe}`, `{Album CleanTitle}`, `{Release Year}`, `{track:0}`/`{track:00}`, `{medium:0}`/`{medium:00}`,
|
||
`{Medium Format}`, `{Track Title}`, `{Track CleanTitle}`, `{Track ArtistName}`, `{Genre}`. Like Lidarr, `{Artist.Name}` or `{Artist_Name}`
|
||
puts dots or underscores between words and `{ARTIST NAME}` / `{artist name}` change the case. The artist is the album artist, falling back
|
||
to the artist. Colons can be replaced smartly (`A: B` → `A - B`), with a dash, space dash, space dash space, or deleted; other characters
|
||
Windows/SMB rejects are replaced (`AC/DC` → `AC-DC`) or removed. Brackets and separators left empty by a missing value are cleaned up
|
||
(`Album ({Release Year})` without a year → `Album`). Examples are rendered by the same code that renames files.
|
||
|
||
**Rename now…** checks the slave, the master or both, then shows every change grouped by destination folder (old → new, selectable per
|
||
file or folder), plus skipped files with the reason and a *Fix tags* button. Files are only moved within their library, so contents and
|
||
hardlinks never change; lyrics (`.lrc`) move with their song, cover art follows a whole folder, and emptied folders are removed. Each
|
||
rename is in History and can be undone, and the library is rescanned afterwards. **Organize automatically** (off by default) applies the
|
||
same renames during every scan. A slave file that is a hardlink of a master file at a different path is still recognised as linked.
|
||
|
||
**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.
|
||
|
||
**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).
|
||
|
||
**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.
|
||
- **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)
|
||
|
||
```yaml
|
||
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/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 and tag editing are disabled. |
|
||
| `PLAYLINK_FFMPEG` | `ffmpeg` | Path to ffmpeg, used by the tag editor. |
|
||
| `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, approximate matching and its threshold, scan interval, quarantine folder,
|
||
retention and settle time.
|
||
|
||
## Development
|
||
|
||
Requires Go 1.27+ and Node 22+.
|
||
|
||
```sh
|
||
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
|
||
```
|