Rewrite README in plainer language

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-13 19:45:57 -04:00
1 parent b3e86b6013
commit 0664af5b8a
1 file changed
+161 -147
+161 -147
View File
@@ -2,216 +2,230 @@
# 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.
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 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
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](https://github.com/autobrr/qui).
## What it does
## How matching works
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.
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 | 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`. |
| 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** 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:
### 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 → 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` |
| 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 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.
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.
Safeguards:
What it won't do:
- 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.
- 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.
**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:
### Reviewing matches
- **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.
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:
**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.
- 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.
**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.
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.
**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).
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.
**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.
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.
**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.
### Duplicates inside the slave library
**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.
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.
**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.
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.
**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.
### Build mode
**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 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.
**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.
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.
## Safety model
### Cleaning up empty folders
Playlink is built so that no user data is ever lost, even if it crashes or loses power mid-operation:
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.
- **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.
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.
## Quick start (Docker / Unraid)
### Notifications
`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](https://github.com/ofkm/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:
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](https://github.com/ofkm/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:
```sh
cp .env.example .env # then edit it
docker compose up -d --build
```
The main settings (all listed with defaults in `.env.example`):
The main settings (all of them, with defaults, are in `.env.example`):
| Variable | Default | Meaning |
| Variable | Default | What it's for |
|---|---|---|
| `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_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` | `true` behind HTTPS. |
| `PLAYLINK_SECURE_COOKIES` | `false` | Set to `true` behind HTTPS. |
`.env` is ignored by Git, so your settings stay local while the repository syncs.
`.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:
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, check the Review tab, and turn off dry run once the planned actions in Activity look right.
Run a scan, look over the Review tab, and turn off dry run once the planned actions in Activity look right.
### Hardlink requirements
### Hardlink gotchas
- **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.
- 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 | Description |
| 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 live in. `/` is refused. |
| `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, quality comparison is disabled but everything else works. |
| `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. |
**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.
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 (in the UI) are file extensions, auto-link, automatic cleanup, approximate matching and its threshold, scan interval,
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
Requires Go 1.27+ and Node 22+.
You'll need Go 1.27+ and Node 22+.
```sh
make test # go vet, go test -race, tsc