Files
playlink/README.md
T
cdnutterandClaude Opus 5.5 ac47e61b08 Initial commit: playlink music library hardlink deduplicator
Go backend (chi, SQLite) with an embedded React/TypeScript UI that finds
songs present in both a host and a slave music library and replaces the
slave copy with a hardlink to the host copy.

- Path-first matching with format/title/album-folder fallbacks
- Auto-linking of byte-identical files, re-verified with cmp before linking
- Manual review with ffprobe quality comparison, downgrade guard and
  promote-to-host override
- Build mode to populate an empty slave library from the host
- Dry run by default, atomic stage+rename replacement, quarantine with
  retention, undo, path confinement and authenticated UI
- Dockerfile and compose file for Unraid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:38:25 -07:00

130 lines
7.4 KiB
Markdown

# playlink
Deduplicate music libraries with hardlinks. playlink finds songs that exist in both a **host** 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 host 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
| Situation | What playlink does |
|---|---|
| Same `Artist/Album/Title.ext` 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 artist and title, but in a **different album folder** | Status `similar` (other album folder). Manual review, plus a layout warning. |
| Only exists in the slave library | Status `unmatched`. Never touched. |
| Already the same inode | Status `linked`. |
**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:
- **Link to host copy.** The default. The host is kept because it belongs to a complete album. If the host is *lower* quality, playlink
warns you and asks for explicit confirmation.
- **Use slave copy as host.** The override. The slave's better file replaces the host file, the old host 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 host library by artist and album, tick
what you want, and playlink hardlinks it in at the same path. **Remove** deletes slave files, but only ones that are hardlinks of the
host 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 host 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 host 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:
- Host: `/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 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, 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
```