Fix engine analysis, drain flow, and settings UI regressions

This commit is contained in:
2026-03-29 19:56:21 -04:00
parent 3c7bd73bed
commit 3f28728b3e
33 changed files with 2442 additions and 700 deletions

View File

@@ -206,9 +206,9 @@ export default function Dashboard() {
<div className="rounded-lg border border-helios-solar/20 bg-helios-solar/10 px-4 py-3 flex items-center gap-3">
<span className="text-helios-solar shrink-0 text-xs font-semibold">ENGINE PAUSED</span>
<span className="text-sm text-helios-ink">
The queue can fill up but Alchemist won't start encoding until you click
<span className="font-bold"> Start</span>
{" "}in the header.
Analysis runs automatically. Click{" "}
<span className="font-bold">Start</span>
{" "}in the header to begin encoding.
</span>
</div>
)}

View File

@@ -99,22 +99,6 @@ export default function HeaderActions() {
}
};
const handlePause = async () => {
setEngineLoading(true);
try {
await apiAction("/api/engine/pause", { method: "POST" });
await refreshEngineStatus();
} catch {
showToast({
kind: "error",
title: "Engine",
message: "Failed to update engine state.",
});
} finally {
setEngineLoading(false);
}
};
const handleStop = async () => {
setEngineLoading(true);
try {
@@ -131,22 +115,6 @@ export default function HeaderActions() {
}
};
const handleCancelStop = async () => {
setEngineLoading(true);
try {
await apiAction("/api/engine/stop-drain", { method: "POST" });
await refreshEngineStatus();
} catch {
showToast({
kind: "error",
title: "Engine",
message: "Failed to update engine state.",
});
} finally {
setEngineLoading(false);
}
};
const handleLogout = async () => {
try {
await apiAction("/api/auth/logout", { method: "POST" });
@@ -174,8 +142,8 @@ export default function HeaderActions() {
</span>
</div>
{/* Start — shown when paused or draining */}
{(status === "paused" || status === "draining") && (
{/* Single action button — changes based on state */}
{status === "paused" && (
<button
onClick={() => void handleStart()}
disabled={engineLoading}
@@ -186,19 +154,6 @@ export default function HeaderActions() {
</button>
)}
{/* Pause — shown when running */}
{status === "running" && (
<button
onClick={() => void handlePause()}
disabled={engineLoading}
className="flex items-center gap-1.5 rounded-lg border border-helios-line/20 px-3 py-1.5 text-xs font-medium text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors disabled:opacity-50"
>
<Pause size={13} />
Pause
</button>
)}
{/* Stop — shown when running */}
{status === "running" && (
<button
onClick={() => void handleStop()}
@@ -210,15 +165,13 @@ export default function HeaderActions() {
</button>
)}
{/* Cancel Stop — shown when draining */}
{status === "draining" && (
<button
onClick={() => void handleCancelStop()}
disabled={engineLoading}
className="flex items-center gap-1.5 rounded-lg border border-blue-400/30 px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-400/10 transition-colors disabled:opacity-50"
disabled
className="flex items-center gap-1.5 rounded-lg border border-helios-line/20 px-3 py-1.5 text-xs font-medium text-helios-slate/50 opacity-60 cursor-not-allowed"
>
<X size={13} />
Cancel Stop
<Square size={13} className="animate-pulse" />
Stopping
</button>
)}

View File

@@ -219,17 +219,19 @@ interface EncodeStats {
vmaf_score?: number;
}
interface LogEntry {
id: number;
level: string;
message: string;
created_at: string;
}
interface JobDetail {
job: Job;
metadata?: JobMetadata;
encode_stats?: EncodeStats;
job_logs?: Array<{
id: number;
level: string;
message: string;
created_at: string;
}>;
job_failure_summary?: string;
metadata: JobMetadata | null;
encode_stats: EncodeStats | null;
job_logs: LogEntry[];
job_failure_summary: string | null;
}
interface CountMessageResponse {
@@ -633,10 +635,6 @@ export default function JobManager() {
? humanizeSkipReason(focusedJob.job.decision_reason)
: null;
const focusedJobLogs = focusedJob?.job_logs ?? [];
const focusedFailureDetail = focusedJob?.job.decision_reason ?? focusedJob?.job_failure_summary ?? null;
const focusedFailureExplanation = focusedFailureDetail
? explainFailureSummary(focusedFailureDetail)
: null;
const shouldShowFfmpegOutput = focusedJob
? ["failed", "completed", "skipped"].includes(focusedJob.job.status) && focusedJobLogs.length > 0
: false;
@@ -1242,33 +1240,6 @@ export default function JobManager() {
</div>
)}
{focusedJob.job.status === "failed" && focusedFailureDetail && (
<div className="rounded-lg border border-status-error/20 bg-status-error/5 px-4 py-3 space-y-2">
<div className="flex items-center gap-2 text-status-error">
<AlertCircle size={14} />
<span className="text-sm font-semibold">What went wrong</span>
</div>
<p className="text-sm font-semibold text-status-error">
{focusedFailureExplanation}
</p>
<p className="break-all font-mono text-xs leading-relaxed text-helios-slate">
{focusedFailureDetail}
</p>
</div>
)}
{focusedJob.job.status === "failed" && !focusedFailureDetail && (
<div className="p-4 rounded-lg bg-status-error/5 border border-status-error/15">
<div className="flex items-center gap-2 text-status-error mb-2">
<AlertCircle size={14} />
<span className="text-sm font-semibold">What went wrong</span>
</div>
<p className="text-sm text-helios-slate leading-relaxed">
No error summary was recorded. Review the FFmpeg output below for the last encoder messages.
</p>
</div>
)}
{focusedJob.job.status === "skipped" && focusedJob.job.decision_reason && (
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/10">
<p className="text-sm text-helios-ink leading-relaxed">
@@ -1304,6 +1275,31 @@ export default function JobManager() {
</div>
)}
{focusedJob.job.status === "failed" && (
<div className="rounded-lg border border-status-error/20 bg-status-error/5 px-4 py-4 space-y-2">
<div className="flex items-center gap-2">
<AlertCircle size={14} className="text-status-error shrink-0" />
<span className="text-xs font-semibold text-status-error uppercase tracking-wide">
Failure Reason
</span>
</div>
{focusedJob.job_failure_summary ? (
<>
<p className="text-sm font-medium text-helios-ink">
{explainFailureSummary(focusedJob.job_failure_summary)}
</p>
<p className="text-xs font-mono text-helios-slate/70 break-all leading-relaxed">
{focusedJob.job_failure_summary}
</p>
</>
) : (
<p className="text-sm text-helios-slate">
No error details captured. Check the logs below.
</p>
)}
</div>
)}
{shouldShowFfmpegOutput && (
<details className="rounded-lg border border-helios-line/15 bg-helios-surface-soft/40 p-4">
<summary className="cursor-pointer text-xs text-helios-solar">

View File

@@ -147,10 +147,7 @@ export default function SystemStatus() {
role="dialog"
aria-modal="true"
aria-labelledby="system-status-title"
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.2 }}
layoutId={layoutId}
className="w-full max-w-lg bg-helios-surface border border-helios-line/30 rounded-xl shadow-2xl overflow-hidden relative outline-none"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { FolderOpen, Trash2, Plus, Folder, Play, Pencil } from "lucide-react";
import { FolderOpen, X, Play, Pencil } from "lucide-react";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
import ConfirmDialog from "./ui/ConfirmDialog";
@@ -62,18 +62,14 @@ export default function WatchFolders() {
const [dirs, setDirs] = useState<WatchDir[]>([]);
const [profiles, setProfiles] = useState<LibraryProfile[]>([]);
const [presets, setPresets] = useState<LibraryProfile[]>([]);
const [libraryDirs, setLibraryDirs] = useState<string[]>([]);
const [path, setPath] = useState("");
const [libraryPath, setLibraryPath] = useState("");
const [isRecursive, setIsRecursive] = useState(true);
const [dirInput, setDirInput] = useState("");
const [loading, setLoading] = useState(true);
const [scanning, setScanning] = useState(false);
const [syncingLibrary, setSyncingLibrary] = useState(false);
const [assigningDirId, setAssigningDirId] = useState<number | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingRemoveId, setPendingRemoveId] = useState<number | null>(null);
const [pickerOpen, setPickerOpen] = useState<null | "library" | "watch">(null);
const [pendingRemovePath, setPendingRemovePath] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState<boolean>(false);
const [customizeDir, setCustomizeDir] = useState<WatchDir | null>(null);
const [profileDraft, setProfileDraft] = useState<ProfileDraft | null>(null);
@@ -86,14 +82,40 @@ export default function WatchFolders() {
[profiles]
);
const fetchBundle = async () => {
const data = await apiJson<SettingsBundleResponse>("/api/settings/bundle");
setLibraryDirs(data.settings.scanner.directories);
};
const fetchDirs = async () => {
const data = await apiJson<WatchDir[]>("/api/settings/watch-dirs");
setDirs(data);
// Fetch both canonical library dirs and extra watch dirs, merge them for the UI
const [bundle, watchDirs] = await Promise.all([
apiJson<SettingsBundleResponse>("/api/settings/bundle"),
apiJson<WatchDir[]>("/api/settings/watch-dirs")
]);
const merged: WatchDir[] = [];
const seen = new Set<string>();
// Canonical roots get mapped to WatchDir structure (id is synthetic/negative, profile_id is null)
bundle.settings.scanner.directories.forEach((dir, idx) => {
if (!seen.has(dir)) {
seen.add(dir);
merged.push({ id: -(idx + 1), path: dir, is_recursive: true, profile_id: null });
}
});
// Extra watch dirs append (usually they would be stored in the DB)
watchDirs.forEach(wd => {
if (!seen.has(wd.path)) {
seen.add(wd.path);
merged.push(wd);
} else {
// If it exists in both, prefer the DB version so we have a real ID for profiles
const existing = merged.find(m => m.path === wd.path);
if (existing) {
existing.id = wd.id;
existing.profile_id = wd.profile_id;
}
}
});
setDirs(merged);
};
const fetchProfiles = async () => {
@@ -108,7 +130,7 @@ export default function WatchFolders() {
const refreshAll = async () => {
try {
await Promise.all([fetchDirs(), fetchBundle(), fetchProfiles(), fetchPresets()]);
await Promise.all([fetchDirs(), fetchProfiles(), fetchPresets()]);
setError(null);
} catch (e) {
const message = isApiError(e) ? e.message : "Failed to load watch folders";
@@ -138,19 +160,45 @@ export default function WatchFolders() {
}
};
const addDir = async (e: React.FormEvent) => {
e.preventDefault();
if (!path.trim()) return;
const addDirectory = async (targetPath: string) => {
const normalized = targetPath.trim();
if (!normalized) return;
if (dirs.some((d) => d.path === normalized)) {
showToast({ kind: "error", title: "Watch Folders", message: "Folder already exists." });
return;
}
try {
await apiAction("/api/settings/watch-dirs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: path.trim(), is_recursive: isRecursive }),
});
// Add to BOTH config (canonical) and DB (profiles)
const bundle = await apiJson<SettingsBundleResponse>("/api/settings/bundle");
if (!bundle.settings.scanner.directories.includes(normalized)) {
await apiAction("/api/settings/bundle", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...bundle.settings,
scanner: {
...bundle.settings.scanner,
directories: [...bundle.settings.scanner.directories, normalized],
},
}),
});
}
setPath("");
setIsRecursive(true);
try {
await apiAction("/api/settings/watch-dirs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: normalized, is_recursive: true }),
});
} catch (innerE) {
// If it's just a duplicate DB error we can ignore it since we successfully added to canonical
if (!(isApiError(innerE) && innerE.status === 409)) {
throw innerE;
}
}
setDirInput("");
setError(null);
await fetchDirs();
showToast({ kind: "success", title: "Watch Folders", message: "Folder added." });
@@ -161,49 +209,36 @@ export default function WatchFolders() {
}
};
const saveLibraryDirs = async (nextDirectories: string[]) => {
setSyncingLibrary(true);
const removeDirectory = async (dirPath: string) => {
const dir = dirs.find((d) => d.path === dirPath);
if (!dir) return;
try {
// Remove from canonical config if present
const bundle = await apiJson<SettingsBundleResponse>("/api/settings/bundle");
await apiAction("/api/settings/bundle", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...bundle.settings,
scanner: {
...bundle.settings.scanner,
directories: nextDirectories,
},
}),
});
setLibraryDirs(nextDirectories);
setError(null);
showToast({ kind: "success", title: "Library", message: "Library directories updated." });
} catch (e) {
const message = isApiError(e) ? e.message : "Failed to update library directories";
setError(message);
showToast({ kind: "error", title: "Library", message });
} finally {
setSyncingLibrary(false);
}
};
const filteredDirs = bundle.settings.scanner.directories.filter(candidate => candidate !== dir.path);
if (filteredDirs.length !== bundle.settings.scanner.directories.length) {
await apiAction("/api/settings/bundle", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...bundle.settings,
scanner: {
...bundle.settings.scanner,
directories: filteredDirs,
},
}),
});
}
const addLibraryDir = async () => {
const nextPath = libraryPath.trim();
if (!nextPath || libraryDirs.includes(nextPath)) return;
await saveLibraryDirs([...libraryDirs, nextPath]);
setLibraryPath("");
};
// Remove from DB if it has a real ID
if (dir.id > 0) {
await apiAction(`/api/settings/watch-dirs/${dir.id}`, {
method: "DELETE",
});
}
const removeLibraryDir = async (dir: string) => {
await saveLibraryDirs(libraryDirs.filter(candidate => candidate !== dir));
};
const removeDir = async (id: number) => {
try {
await apiAction(`/api/settings/watch-dirs/${id}`, {
method: "DELETE",
});
setError(null);
await fetchDirs();
showToast({ kind: "success", title: "Watch Folders", message: "Folder removed." });
@@ -215,6 +250,12 @@ export default function WatchFolders() {
};
const assignProfile = async (dirId: number, profileId: number | null) => {
// Can only assign profiles to DB-backed rows
if (dirId < 0) {
showToast({ kind: "error", title: "Profiles", message: "This directory must be re-added to support profiles." });
return;
}
setAssigningDirId(dirId);
try {
await apiAction(`/api/watch-dirs/${dirId}/profile`, {
@@ -239,6 +280,11 @@ export default function WatchFolders() {
};
const openCustomizeModal = (dir: WatchDir) => {
if (dir.id < 0) {
showToast({ kind: "error", title: "Profiles", message: "This directory must be re-added to support custom profiles." });
return;
}
const selectedProfile = profiles.find((profile) => profile.id === dir.profile_id);
const fallbackPreset =
presets.find((preset) => preset.preset === "balanced")
@@ -311,7 +357,16 @@ export default function WatchFolders() {
return (
<div className="space-y-6" aria-live="polite">
<div className="flex justify-end mb-6">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<h2 className="flex items-center gap-2 text-xl font-semibold text-helios-ink">
<FolderOpen size={20} className="text-helios-solar" />
Media Folders
</h2>
<p className="text-sm text-helios-slate">
Folders Alchemist scans and watches for new media.
</p>
</div>
<button
onClick={() => void triggerScan()}
disabled={scanning}
@@ -328,189 +383,129 @@ export default function WatchFolders() {
</div>
)}
<form onSubmit={addDir} className="space-y-3">
<div className="space-y-3 rounded-lg border border-helios-line/20 bg-helios-surface-soft/50 p-4">
<div>
<h3 className="text-sm font-bold text-helios-ink">Library Directories</h3>
<p className="text-xs text-helios-slate mt-1">
Canonical library roots from setup/TOML. These are stored in the main config file and synchronized into runtime watchers.
</p>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate/50" size={16} />
<input
type="text"
value={libraryPath}
onChange={(e) => setLibraryPath(e.target.value)}
placeholder="Add library directory..."
className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-10 pr-4 py-2.5 text-sm text-helios-ink placeholder:text-helios-slate/40 focus:border-helios-solar focus:ring-1 focus:ring-helios-solar/50 outline-none transition-all"
/>
</div>
<button
type="button"
onClick={() => setPickerOpen("library")}
className="rounded-lg border border-helios-line/30 bg-helios-surface px-4 py-2.5 text-sm font-medium text-helios-ink"
>
Browse
</button>
<button
type="button"
onClick={() => void addLibraryDir()}
disabled={!libraryPath.trim() || syncingLibrary}
className="bg-helios-solar hover:bg-helios-solar-dark text-helios-surface px-5 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 shadow-sm shadow-helios-solar/20"
>
<Plus size={16} /> Add Library
</button>
</div>
<div className="space-y-2">
{libraryDirs.map((dir) => (
<div key={dir} className="flex items-center justify-between rounded-lg border border-helios-line/10 bg-helios-surface px-3 py-2">
<span className="truncate font-mono text-sm text-helios-ink" title={dir}>{dir}</span>
<button
type="button"
onClick={() => void removeLibraryDir(dir)}
disabled={syncingLibrary}
className="rounded-lg p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
))}
{libraryDirs.length === 0 && (
<p className="text-xs text-helios-slate">No canonical library directories configured yet.</p>
)}
</div>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate/50" size={16} />
<input
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="Enter full directory path..."
className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-10 pr-4 py-2.5 text-sm text-helios-ink placeholder:text-helios-slate/40 focus:border-helios-solar focus:ring-1 focus:ring-helios-solar/50 outline-none transition-all"
/>
</div>
<button
type="button"
onClick={() => setPickerOpen("watch")}
className="rounded-lg border border-helios-line/30 bg-helios-surface px-4 py-2.5 text-sm font-medium text-helios-ink"
>
Browse
</button>
<button
type="submit"
disabled={!path.trim()}
className="bg-helios-solar hover:bg-helios-solar-dark text-helios-surface px-5 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 shadow-sm shadow-helios-solar/20"
>
<Plus size={16} /> Add
</button>
</div>
<label className="inline-flex items-center gap-2 rounded-lg border border-helios-line/20 bg-helios-surface px-3 py-2 text-sm text-helios-ink">
<input
type="checkbox"
checked={isRecursive}
onChange={(e) => setIsRecursive(e.target.checked)}
className="rounded border-helios-line/30 bg-helios-surface-soft accent-helios-solar"
/>
Watch subdirectories recursively
</label>
</form>
<div className="space-y-2">
{dirs.map((dir) => (
<div key={dir.id} className="flex flex-col gap-3 p-3 bg-helios-surface border border-helios-line/10 rounded-lg group hover:border-helios-line/30 hover:shadow-sm transition-all">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 overflow-hidden">
<div className="p-1.5 bg-helios-slate/5 rounded-lg text-helios-slate">
<Folder size={16} />
</div>
<span className="text-sm font-mono text-helios-ink truncate max-w-[400px]" title={dir.path}>
{dir.path}
</span>
<span className="rounded-full border border-helios-line/20 px-2 py-0.5 text-xs font-bold text-helios-slate">
{dir.is_recursive ? "Recursive" : "Top level"}
</span>
</div>
<button
onClick={() => setPendingRemoveId(dir.id)}
className="p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-all opacity-0 group-hover:opacity-100"
title="Stop watching"
>
<Trash2 size={16} />
</button>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<select
value={dir.profile_id === null ? "" : String(dir.profile_id)}
onChange={(event) => {
const value = event.target.value;
void assignProfile(
dir.id,
value === "" ? null : Number(value)
);
}}
disabled={assigningDirId === dir.id}
className="w-full rounded-lg border border-helios-line/20 bg-helios-surface-soft px-4 py-2.5 text-sm text-helios-ink outline-none focus:border-helios-solar disabled:opacity-60"
>
<option value="">No profile (use global settings)</option>
{builtinProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
{customProfiles.length > 0 ? (
<option value="divider" disabled>
</option>
) : null}
{customProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
<button
type="button"
onClick={() => openCustomizeModal(dir)}
className="inline-flex items-center justify-center rounded-lg border border-helios-line/20 bg-helios-surface px-3 py-2 text-helios-slate hover:text-helios-ink hover:bg-helios-surface-soft"
title="Customize profile"
>
<Pencil size={14} />
</button>
</div>
</div>
))}
{!loading && dirs.length === 0 && (
<div className="flex flex-col items-center justify-center py-10 text-center border-2 border-dashed border-helios-line/10 rounded-lg bg-helios-surface/30">
<FolderOpen className="text-helios-slate/20 mb-2" size={32} />
<p className="text-sm text-helios-slate">No watch folders configured</p>
<p className="text-xs text-helios-slate/60 mt-1">Add a directory to start scanning</p>
</div>
)}
{loading && (
<div className="text-center py-8 text-helios-slate animate-pulse text-sm">
Loading directories...
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={dirInput}
onChange={(e) => setDirInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void addDirectory(dirInput);
}
}}
placeholder="/path/to/media"
className="flex-1 rounded-lg border border-helios-line/40 bg-helios-surface px-4 py-2.5 font-mono text-sm text-helios-ink outline-none transition-colors focus:border-helios-solar"
/>
<button
type="button"
onClick={() => setPickerOpen(true)}
className="rounded-lg border border-helios-line/30 bg-helios-surface px-4 py-2.5 text-sm font-medium text-helios-slate transition-colors hover:border-helios-solar/40 hover:text-helios-ink"
>
Browse
</button>
<button
type="button"
onClick={() => void addDirectory(dirInput)}
disabled={!dirInput.trim()}
className="rounded-lg bg-helios-solar px-4 py-2.5 text-sm font-semibold text-helios-main transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
Add
</button>
</div>
{loading ? (
<div className="text-center py-8 text-helios-slate animate-pulse text-sm">
Loading folders...
</div>
) : dirs.length > 0 ? (
<div className="overflow-hidden rounded-lg border border-helios-line/30 bg-helios-surface">
{dirs.map((dir, index) => (
<div
key={dir.path}
className={`flex flex-col gap-3 px-4 py-3 ${
index < dirs.length - 1 ? "border-b border-helios-line/10" : ""
}`}
>
<div className="flex items-start gap-4">
<p
className="min-w-0 flex-1 break-all font-mono text-sm text-helios-slate"
title={dir.path}
>
{dir.path}
</p>
<button
type="button"
onClick={() => setPendingRemovePath(dir.path)}
className="shrink-0 rounded-lg p-1.5 text-helios-slate transition-colors hover:text-status-error"
aria-label={`Remove ${dir.path}`}
>
<X size={15} />
</button>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<select
value={dir.profile_id === null ? "" : String(dir.profile_id)}
onChange={(event) => {
const value = event.target.value;
void assignProfile(
dir.id,
value === "" ? null : Number(value)
);
}}
disabled={assigningDirId === dir.id || dir.id < 0}
className="w-full rounded-lg border border-helios-line/20 bg-helios-surface-soft px-4 py-2 text-sm text-helios-ink outline-none focus:border-helios-solar disabled:opacity-60"
>
<option value="">No profile (use global settings)</option>
{builtinProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
{customProfiles.length > 0 ? (
<option value="divider" disabled>
</option>
) : null}
{customProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
<button
type="button"
onClick={() => openCustomizeModal(dir)}
disabled={dir.id < 0}
className="inline-flex items-center justify-center rounded-lg border border-helios-line/20 bg-helios-surface px-3 py-2 text-helios-slate hover:text-helios-ink hover:bg-helios-surface-soft disabled:opacity-50"
title="Customize profile"
>
<Pencil size={14} />
</button>
</div>
</div>
))}
</div>
) : (
<div className="py-8 text-center">
<p className="text-sm text-helios-slate/60">No folders added yet</p>
<p className="mt-1 text-sm text-helios-slate/60">
Add a folder above or browse the server filesystem
</p>
</div>
)}
<ConfirmDialog
open={pendingRemoveId !== null}
title="Stop watching folder"
description="Stop watching this folder for new media?"
confirmLabel="Stop Watching"
open={pendingRemovePath !== null}
title="Remove folder"
description={`Stop watching ${pendingRemovePath} for new media?`}
confirmLabel="Remove"
tone="danger"
onClose={() => setPendingRemoveId(null)}
onClose={() => setPendingRemovePath(null)}
onConfirm={async () => {
if (pendingRemoveId === null) return;
await removeDir(pendingRemoveId);
if (pendingRemovePath === null) return;
await removeDirectory(pendingRemovePath);
setPendingRemovePath(null);
}}
/>
@@ -661,21 +656,13 @@ export default function WatchFolders() {
) : null}
<ServerDirectoryPicker
open={pickerOpen !== null}
title={pickerOpen === "library" ? "Select Library Root" : "Select Extra Watch Folder"}
description={
pickerOpen === "library"
? "Choose a canonical server folder that represents a media library root."
: "Choose an additional server folder to watch outside the canonical library roots."
}
onClose={() => setPickerOpen(null)}
open={pickerOpen}
title="Select Folder"
description="Choose a directory for Alchemist to scan and watch for new media."
onClose={() => setPickerOpen(false)}
onSelect={(selectedPath) => {
if (pickerOpen === "library") {
setLibraryPath(selectedPath);
} else {
setPath(selectedPath);
}
setPickerOpen(null);
setDirInput(selectedPath);
setPickerOpen(false);
}}
/>
</div>

View File

@@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { motion } from "framer-motion";
import { FolderOpen, FolderSearch, Plus, X } from "lucide-react";
import { ChevronLeft, ChevronRight, Folder, FolderOpen, X } from "lucide-react";
import { apiJson, isApiError } from "../../lib/api";
import ServerDirectoryPicker from "../ui/ServerDirectoryPicker";
import type { FsPreviewResponse, FsRecommendation, StepValidator } from "./types";
interface LibraryStepProps {
@@ -15,16 +14,38 @@ interface LibraryStepProps {
registerValidator: (validator: StepValidator) => void;
}
interface FsBreadcrumb {
name: string;
path: string;
}
interface FsDirEntry {
name: string;
path: string;
readable: boolean;
}
interface FsBrowseResponse {
path: string;
readable: boolean;
breadcrumbs: FsBreadcrumb[];
warnings: string[];
entries: FsDirEntry[];
}
export default function LibraryStep({
dirInput,
directories,
recommendations,
recommendations: _recommendations,
onDirInputChange,
onDirectoriesChange,
onPreviewChange,
registerValidator,
}: LibraryStepProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [browse, setBrowse] = useState<FsBrowseResponse | null>(null);
const [browseError, setBrowseError] = useState("");
const [browseLoading, setBrowseLoading] = useState(false);
const previewFailureMessage = (err: unknown) =>
isApiError(err)
@@ -55,17 +76,6 @@ export default function LibraryStep({
if (directories.length === 0) {
return "Select at least one server folder before continuing.";
}
let nextPreview: FsPreviewResponse | null;
try {
nextPreview = await fetchPreview();
} catch (err) {
return err instanceof Error
? err.message
: "Failed to preview the selected folders. Double-check the path and that the Alchemist server can read it.";
}
if (nextPreview && nextPreview.total_media_files === 0) {
return "Preview did not find any supported media files yet. Double-check the chosen folders.";
}
return null;
});
@@ -88,205 +98,315 @@ export default function LibraryStep({
onDirInputChange("");
};
const loadBrowse = useCallback(async (path?: string) => {
setBrowseLoading(true);
setBrowseError("");
try {
const query = path ? `?path=${encodeURIComponent(path)}` : "";
const data = await apiJson<FsBrowseResponse>(`/api/fs/browse${query}`);
setBrowse(data);
} catch (err) {
setBrowse(null);
setBrowseError(isApiError(err) ? err.message : "Failed to browse server folders.");
} finally {
setBrowseLoading(false);
}
}, []);
useEffect(() => {
if (!pickerOpen) {
return;
}
void loadBrowse();
}, [pickerOpen, loadBrowse]);
const removeDirectory = (path: string) => {
onDirectoriesChange(directories.filter((directory) => directory !== path));
};
const handleBrowseOpen = () => {
setBrowse(null);
setBrowseError("");
setPickerOpen(true);
};
const handleBrowseClose = () => {
setPickerOpen(false);
setBrowse(null);
setBrowseError("");
setBrowseLoading(false);
};
const currentBrowsePath = browse?.path ?? "";
const currentBrowseName =
currentBrowsePath.split("/").filter(Boolean).pop() || currentBrowsePath || "root";
const breadcrumbs = browse?.breadcrumbs ?? [];
const parentBreadcrumb =
breadcrumbs.length > 1 ? breadcrumbs[breadcrumbs.length - 2] : null;
const visibleEntries = browse?.entries.filter((entry) => entry.readable) ?? [];
return (
<>
<motion.div
key="library"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
{/* Step heading */}
<div className="space-y-1">
<h2 className="text-xl font-semibold text-helios-ink
flex items-center gap-2">
<FolderOpen size={20}
className="text-helios-solar" />
Library Selection
</h2>
<p className="text-sm text-helios-slate">
Choose the server folders Alchemist should scan
and keep watching.
</p>
</div>
<motion.div
key="library"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<div className="space-y-1">
<h2 className="flex items-center gap-2 text-xl font-semibold text-helios-ink">
<FolderOpen size={20} className="text-helios-solar" />
Library Selection
</h2>
<p className="text-sm text-helios-slate">
Choose folders Alchemist should scan and watch for new media.
</p>
</div>
{/* Recommendations — shown when server returns any */}
{recommendations.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-medium
text-helios-slate">
Suggested folders
</p>
<div className="space-y-2">
{recommendations.map((rec) => {
const alreadyAdded =
directories.includes(rec.path);
return (
<button
key={rec.path}
type="button"
onClick={() =>
!alreadyAdded &&
addDirectory(rec.path)
}
disabled={alreadyAdded}
className={`w-full flex items-center
justify-between gap-4 rounded-lg
border px-4 py-3 text-left
transition-all ${
alreadyAdded
? "border-helios-solar/30 bg-helios-solar/5 cursor-default"
: "border-helios-line/30 bg-helios-surface hover:border-helios-solar/40 hover:bg-helios-surface-soft"
}`}
>
<div className="min-w-0">
<p className="text-sm font-medium
text-helios-ink truncate">
{rec.label}
</p>
<p className="text-xs font-mono
text-helios-slate/70 truncate
mt-0.5"
title={rec.path}>
{rec.path}
</p>
</div>
{alreadyAdded ? (
<span className="text-xs
text-helios-solar shrink-0
font-medium">
Added
</span>
) : (
<Plus size={15}
className="text-helios-solar/60
shrink-0" />
)}
</button>
);
})}
</div>
</div>
) : (
/* Empty state — no recommendations */
<div className="rounded-lg border border-helios-line/20
bg-helios-surface-soft/40 px-5 py-8 text-center
space-y-3">
<p className="text-sm text-helios-slate">
No media folders were auto-detected on this
server.
</p>
<p className="text-xs text-helios-slate/60">
Use Browse below to navigate the server
filesystem manually.
</p>
</div>
)}
{/* Selected folders as chips */}
{directories.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-helios-slate">
Selected ({directories.length})
</p>
<div className="flex flex-wrap gap-2">
{directories.map((dir) => (
<div
key={dir}
className="flex items-center gap-2
rounded-lg border border-helios-solar/30
bg-helios-solar/5 pl-3 pr-2 py-1.5"
>
<span className="font-mono text-xs
text-helios-ink truncate max-w-[300px]"
title={dir}>
{dir.split("/").pop() || dir}
</span>
<button
type="button"
onClick={() =>
onDirectoriesChange(
directories.filter(
(d) => d !== dir
)
)
}
className="text-helios-slate/50
hover:text-status-error
transition-colors shrink-0"
>
<X size={13} />
</button>
</div>
))}
</div>
</div>
)}
{/* Browse button */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={dirInput}
onChange={(e) => onDirInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
addDirectory(dirInput);
}
}}
placeholder="/path/to/media"
className="flex-1 rounded-lg border border-helios-line/40 bg-helios-surface px-4 py-2.5 font-mono text-sm text-helios-ink outline-none transition-colors focus:border-helios-solar"
/>
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full flex items-center justify-center
gap-2 rounded-lg border border-helios-line/30
bg-helios-surface py-3 text-sm font-medium
text-helios-slate hover:border-helios-solar/40
hover:text-helios-ink transition-colors"
onClick={handleBrowseOpen}
className="rounded-lg border border-helios-line/30 bg-helios-surface px-4 py-2.5 text-sm font-medium text-helios-slate transition-colors hover:border-helios-solar/40 hover:text-helios-ink"
>
<FolderSearch size={15} />
Browse server folders
Browse
</button>
<button
type="button"
onClick={() => addDirectory(dirInput)}
className="rounded-lg bg-helios-solar px-4 py-2.5 text-sm font-semibold text-helios-main transition-opacity hover:opacity-90"
>
Add
</button>
</div>
{/* Manual path input */}
<div className="space-y-2">
<label className="text-xs font-medium
text-helios-slate">
Or paste a path directly
</label>
<div className="flex gap-2">
<input
type="text"
value={dirInput}
onChange={(e) => onDirInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
addDirectory(dirInput);
}
}}
placeholder="/path/to/media"
className="flex-1 rounded-lg border
border-helios-line/40 bg-helios-surface
px-4 py-2.5 font-mono text-sm
text-helios-ink focus:border-helios-solar
outline-none"
/>
{pickerOpen ? (
<div className="flex h-[min(28rem,calc(100dvh-20rem))] min-h-0 flex-col gap-4 overflow-hidden rounded-lg border border-helios-line/30 bg-helios-surface p-4">
<div className="shrink-0 flex items-start justify-between gap-4">
<div className="min-w-0 space-y-3">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-[0.12em] text-helios-slate/70">
Server Filesystem
</p>
<div className="flex items-center gap-2">
<Folder size={16} className="shrink-0 text-helios-solar" />
<p className="truncate text-sm font-medium text-helios-ink">
{currentBrowseName}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() =>
parentBreadcrumb
? void loadBrowse(parentBreadcrumb.path)
: void loadBrowse()
}
disabled={browseLoading || !browse || !parentBreadcrumb}
className="inline-flex items-center gap-1.5 rounded-lg border border-helios-line/30 px-3 py-1.5 text-sm text-helios-slate transition-colors hover:border-helios-solar/40 hover:text-helios-ink disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft size={15} />
Up
</button>
<div className="min-w-0 flex-1 overflow-x-auto">
<div className="flex min-w-max items-center gap-1.5 text-sm text-helios-slate">
{breadcrumbs.length > 0 ? (
breadcrumbs.map((crumb, index) => {
const isCurrent = crumb.path === currentBrowsePath;
return (
<div
key={crumb.path}
className="flex items-center gap-1.5"
>
{index > 0 && (
<span className="text-helios-slate/50">/</span>
)}
<button
type="button"
onClick={() => void loadBrowse(crumb.path)}
className={
isCurrent
? "rounded-lg bg-helios-solar/10 px-2 py-1 font-medium text-helios-ink"
: "rounded-lg px-2 py-1 transition-colors hover:bg-helios-surface-soft hover:text-helios-ink"
}
>
{crumb.name}
</button>
</div>
);
})
) : (
<span className="rounded-lg bg-helios-solar/10 px-2 py-1 font-medium text-helios-ink">
/
</span>
)}
</div>
</div>
</div>
</div>
<button
type="button"
onClick={() => addDirectory(dirInput)}
className="rounded-lg bg-helios-solar px-4
py-2.5 text-sm font-semibold
text-helios-main hover:opacity-90
transition-opacity"
onClick={handleBrowseClose}
className="shrink-0 rounded-lg border border-helios-line/30 px-3 py-1.5 text-sm text-helios-slate transition-colors hover:border-helios-solar/40 hover:text-helios-ink"
aria-label="Close folder browser"
>
Add
<X size={16} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-lg border border-helios-line/20 bg-helios-surface-soft/30">
{browse?.warnings.length ? (
<div className="border-b border-helios-line/10 px-4 py-3">
{browse.warnings.map((warning) => (
<p
key={warning}
className="text-xs text-helios-slate"
>
{warning}
</p>
))}
</div>
) : null}
{browseLoading ? (
<div className="animate-pulse space-y-3 p-4">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="flex items-center gap-3 rounded-lg border border-helios-line/10 bg-helios-surface px-4 py-3"
>
<div className="h-4 w-4 rounded bg-helios-line/20" />
<div className="h-3 flex-1 rounded bg-helios-line/20" />
<div className="h-3 w-3 rounded bg-helios-line/20" />
</div>
))}
</div>
) : browseError ? (
<div className="px-4 py-6 text-sm text-status-error">{browseError}</div>
) : visibleEntries.length === 0 ? (
<div className="px-4 py-6 text-sm text-helios-slate">
No readable child folders were found here.
</div>
) : (
<div className="divide-y divide-helios-line/10">
{parentBreadcrumb ? (
<button
type="button"
onClick={() => void loadBrowse(parentBreadcrumb.path)}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-helios-surface/70"
>
<ChevronLeft size={16} className="shrink-0 text-helios-slate" />
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-helios-ink">
..
</span>
<span className="block truncate text-xs text-helios-slate">
Go up to {parentBreadcrumb.name}
</span>
</div>
</button>
) : null}
{visibleEntries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => void loadBrowse(entry.path)}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-helios-solar/5"
>
<Folder size={16} className="shrink-0 text-helios-slate" />
<div className="min-w-0 flex-1">
<span className="block truncate text-sm text-helios-ink">
{entry.name}
</span>
<span className="block truncate font-mono text-xs text-helios-slate/80">
{entry.path}
</span>
</div>
<ChevronRight
size={16}
className="shrink-0 text-helios-slate"
/>
</button>
))}
</div>
)}
</div>
<div className="shrink-0 flex flex-col gap-3 rounded-lg border border-helios-line/20 bg-helios-surface-soft/30 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-xs font-medium text-helios-slate/80">
Current folder
</p>
<p className="min-w-0 break-all font-mono text-xs text-helios-slate">
{currentBrowsePath || "/"}
</p>
</div>
<button
type="button"
onClick={() => {
if (!currentBrowsePath) {
return;
}
addDirectory(currentBrowsePath);
handleBrowseClose();
}}
disabled={!currentBrowsePath}
className="shrink-0 rounded-lg bg-helios-solar px-4 py-2 text-sm font-semibold text-helios-main transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
Add {currentBrowseName}
</button>
</div>
</div>
</motion.div>
<ServerDirectoryPicker
open={pickerOpen}
title="Browse Server Folders"
description="Navigate the server filesystem and choose
the folder Alchemist should treat as a media root."
onClose={() => setPickerOpen(false)}
onSelect={(path) => {
addDirectory(path);
setPickerOpen(false);
}}
/>
</>
) : directories.length > 0 ? (
<div className="overflow-hidden rounded-lg border border-helios-line/30 bg-helios-surface">
{directories.map((dir, index) => (
<div
key={dir}
className={`flex items-start gap-4 px-4 py-3 ${
index < directories.length - 1 ? "border-b border-helios-line/10" : ""
}`}
>
<p
className="min-w-0 flex-1 break-all font-mono text-sm text-helios-slate"
title={dir}
>
{dir}
</p>
<button
type="button"
onClick={() => removeDirectory(dir)}
className="shrink-0 rounded-lg p-1.5 text-helios-slate transition-colors hover:text-status-error"
aria-label={`Remove ${dir}`}
>
<X size={15} />
</button>
</div>
))}
</div>
) : (
<div className="py-8 text-center">
<p className="text-sm text-helios-slate/60">No folders added yet</p>
<p className="mt-1 text-sm text-helios-slate/60">
Add a folder above or browse the server filesystem
</p>
</div>
)}
</motion.div>
);
}

View File

@@ -49,7 +49,7 @@ export default function SetupFrame({ step, configMutable, error, submitting, onB
{/* Step content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-6 py-8">
<div className="max-w-6xl mx-auto px-6 py-8">
<AnimatePresence mode="wait">
{children}
</AnimatePresence>
@@ -60,7 +60,7 @@ export default function SetupFrame({ step, configMutable, error, submitting, onB
{step < 6 && (
<div className="shrink-0 border-t border-helios-line/20
bg-helios-surface/50 px-6 py-4">
<div className="max-w-4xl mx-auto flex items-center
<div className="max-w-6xl mx-auto flex items-center
justify-between gap-4">
<button
type="button"

View File

@@ -117,7 +117,7 @@ export default function ServerDirectoryPicker({
/>
<div className="absolute inset-0 flex items-center justify-center px-4 py-6">
<div className="w-full max-w-5xl rounded-xl border border-helios-line/30 bg-helios-surface shadow-2xl overflow-hidden">
<div className="w-full max-w-5xl rounded-xl border border-helios-line/30 bg-helios-surface shadow-2xl overflow-hidden flex flex-col max-h-[min(90vh,800px)]">
<div className="border-b border-helios-line/20 px-6 py-5 flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
@@ -142,7 +142,7 @@ export default function ServerDirectoryPicker({
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] min-h-[620px]">
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] flex-1 min-h-0 overflow-hidden">
<aside className="border-r border-helios-line/20 bg-helios-surface-soft/40 px-5 py-5 space-y-5">
<div className="space-y-2">
<label className="text-xs font-medium text-helios-slate">
@@ -195,7 +195,7 @@ export default function ServerDirectoryPicker({
</div>
</aside>
<section className="px-6 py-5 flex flex-col">
<section className="px-6 py-5 flex flex-col overflow-y-auto min-h-0">
{error && (
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">
{error}
@@ -259,7 +259,7 @@ export default function ServerDirectoryPicker({
<div className="flex-1 overflow-y-auto rounded-lg border border-helios-line/20 bg-helios-surface-soft/30">
{browse.entries.length === 0 ? (
<div className="flex h-full min-h-[260px] items-center justify-center px-6 text-sm text-helios-slate">
<div className="flex h-full min-h-[120px] items-center justify-center px-6 text-sm text-helios-slate">
No child directories were found here.
</div>
) : (