Files
playlink/web/src/api.ts
T
cdnutterandClaude Opus 5.5 1da0132d5e Add a setting to hide the quarantine from Plex
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>
2026-09-28 21:57:56 -07:00

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 };