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:
cdnutterandClaude Opus 5.5 committed 2026-09-22 15:38:25 -07:00
commit ac47e61b08
42 files changed
+8892

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`/api${path}`, {
method,
credentials: "same-origin",
headers: {
"X-Playlink-Request": "1",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(res.status, data.error ?? res.statusText);
return data as T;
}
export const api = {
get: <T>(p: string) => request<T>("GET", p),
post: <T>(p: string, b: unknown = {}) => request<T>("POST", p, b),
put: <T>(p: string, b: unknown) => request<T>("PUT", p, b),
del: <T>(p: string) => request<T>("DELETE", p),
};
export type AuthState = { setup_required: boolean; authenticated: boolean; username?: string };
export type Info = {
version: string;
allowed_roots: string[];
default_extensions: string[];
dry_run: boolean;
};
export type Job = {
id: number;
pair_id: number;
kind: string;
state: "running" | "done" | "failed" | "cancelled";
phase: string;
done: number;
total: number;
message: string;
error: string;
results: Record<string, number>;
skipped: string[];
started_at: number;
finished_at?: number;
};
export type Summary = {
counts: Record<string, number>;
review: number;
ignored: number;
reclaimable_bytes: number;
linked_bytes: number;
};
export type Pair = {
id: number;
name: string;
host_root: string;
slave_root: string;
quarantine_dir: string;
extensions: string[];
auto_link_identical: boolean;
scan_interval_minutes: number;
retention_days: number;
settle_minutes: number;
last_scan_at: number | null;
last_scan_error: string;
last_scan_warnings: string[];
summary: Summary;
job: Job | null;
};
export type PairInput = Pick<
Pair,
| "name"
| "host_root"
| "slave_root"
| "quarantine_dir"
| "extensions"
| "auto_link_identical"
| "scan_interval_minutes"
| "retention_days"
| "settle_minutes"
>;
export type ProbeInfo = {
codec: string;
codec_long?: string;
format?: string;
lossless: boolean;
bitrate: number;
sample_rate: number;
bit_depth?: number;
channels: number;
duration: number;
title?: string;
artist?: string;
album?: string;
error?: string;
};
export type Verdict = { better: "host" | "slave" | "equal" | "unknown"; duration_delta: number; notes: string[] | null };
export type Status = "linked" | "identical" | "different" | "similar" | "unmatched" | "error";
export type Match = {
id: number;
pair_id: number;
rel_slave: string;
rel_host: string;
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "none";
status: Status;
host_size: number;
host_mtime: number;
slave_size: number;
slave_mtime: number;
host_probe?: ProbeInfo;
slave_probe?: ProbeInfo;
verdict?: Verdict;
note: string;
ignored: boolean;
};
export type Operation = {
id: number;
pair_id: number;
action: "link" | "promote" | "build" | "remove";
rel_host: string;
rel_slave: string;
rel_new: string;
quarantine_path: string;
quarantine_side: string;
bytes: number;
dry_run: boolean;
trigger: string;
status: "running" | "done" | "failed" | "planned" | "interrupted";
error: string;
created_at: number;
finished_at: number | null;
undone_at: number | null;
purged_at: number | null;
};
export type TreeEntry = {
name: string;
rel: string;
dir: boolean;
size?: number;
state?: "linked" | "conflict" | "missing";
linked?: number;
};
export type Page<T> = { items: T[]; total: number };