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>
This commit is contained in:
commit
ac47e61b08
42 files changed
+8892
No files matched your search
@@ -0,0 +1,129 @@
|
||||
# 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
|
||||
```
|
||||
Reference in new issue
Block a user