Settings -> Media servers -> "Hide quarantine from Plex" writes a .plexignore into every pair's quarantine folder (.playlink-trash by default), with patterns for every folder depth quarantined files live at. Quarantine folders opened later get the file automatically. The file carries a marker line: turning the option off removes only playlink's own file, and an existing .plexignore written by someone else is never overwritten or deleted (reported instead). Writes go through a temporary file and a rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
4.9 KiB
TypeScript
214 lines
4.9 KiB
TypeScript
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;
|
|
plex_ignore: 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;
|
|
upgrades: number;
|
|
ignored: number;
|
|
reclaimable_bytes: number;
|
|
linked_bytes: number;
|
|
};
|
|
|
|
export type Pair = {
|
|
id: number;
|
|
name: string;
|
|
master_root: string;
|
|
slave_root: string;
|
|
quarantine_dir: string;
|
|
extensions: string[];
|
|
auto_link_identical: boolean;
|
|
scan_interval_minutes: number;
|
|
retention_days: number;
|
|
settle_minutes: number;
|
|
approx_matching: boolean;
|
|
approx_threshold: number;
|
|
last_scan_at: number | null;
|
|
last_scan_error: string;
|
|
last_scan_warnings: ScanWarning[];
|
|
summary: Summary;
|
|
job: Job | null;
|
|
};
|
|
|
|
export type PairInput = Pick<
|
|
Pair,
|
|
| "name"
|
|
| "master_root"
|
|
| "slave_root"
|
|
| "quarantine_dir"
|
|
| "extensions"
|
|
| "auto_link_identical"
|
|
| "scan_interval_minutes"
|
|
| "retention_days"
|
|
| "settle_minutes"
|
|
| "approx_matching"
|
|
| "approx_threshold"
|
|
>;
|
|
|
|
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: "master" | "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_master: string;
|
|
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "none";
|
|
status: Status;
|
|
master_size: number;
|
|
master_mtime: number;
|
|
slave_size: number;
|
|
slave_mtime: number;
|
|
master_probe?: ProbeInfo;
|
|
slave_probe?: ProbeInfo;
|
|
verdict?: Verdict;
|
|
note: string;
|
|
reason: string;
|
|
link_target: string;
|
|
ignored: boolean;
|
|
};
|
|
|
|
export type Operation = {
|
|
id: number;
|
|
pair_id: number;
|
|
action: "link" | "promote" | "build" | "remove" | "rename";
|
|
rel_master: 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 };
|
|
|
|
export type AlbumGroup = {
|
|
folder: string;
|
|
ids: number[];
|
|
songs: number;
|
|
identical: number;
|
|
review: number;
|
|
upgrades: number;
|
|
downgrades: number;
|
|
approximate: number;
|
|
other: number;
|
|
bytes: number;
|
|
master_folders: string[];
|
|
};
|
|
|
|
export type BulkAction = "link" | "promote" | "ignore" | "unignore";
|
|
|
|
export type BulkPreview = {
|
|
action: BulkAction;
|
|
selected: number;
|
|
albums: number;
|
|
process: number;
|
|
skip: Record<string, number>;
|
|
identical: number;
|
|
quarantined: number;
|
|
upgrades: number;
|
|
downgrades: number;
|
|
approximate: number;
|
|
moves_folder: number;
|
|
renames: number;
|
|
duration_mismatch: number;
|
|
bytes_freed: number;
|
|
examples: Record<string, string[] | undefined>;
|
|
};
|
|
|
|
export type ScanWarning = {
|
|
code?: "renamed_folders";
|
|
title: string;
|
|
detail?: string;
|
|
items?: { slave: string; master: string; count?: number }[];
|
|
total?: number;
|
|
fixable?: number;
|
|
};
|
|
|
|
export type FolderRename = { slave: string; master: string; ready: boolean; reason?: string };
|