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
+160
@@ -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 };
|
||||
Reference in new issue
Block a user