Files
crossfade/components/album-view.tsx
T

2345 lines
83 KiB
TypeScript

import { useState, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import {
Copy,
Check,
AlertTriangle,
FileAudio,
ShieldCheck,
ShieldAlert,
RefreshCw,
Trash2,
Download,
Activity,
FastForward,
Disc,
Upload,
X,
Waves,
HardDrive,
FileJson,
Magnet,
Play,
Link,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { motion } from "motion/react";
import { RenameDialog } from "@/components/rename-dialog";
import {
Album,
AnalysisResponse,
SpectrogramResponse,
TorrentCreationResponse,
CheckMQAResponse,
CheckUpconvResponse,
QbittorrentInjectResponse,
QbittorrentResumeResponse,
QbittorrentStatusResponse,
AnalysisTrack,
} from "@/types";
interface AlbumViewProps {
album: Album;
addLog: (msg: string) => void;
onReset: () => void;
onAlbumUpdate: (newPath?: string) => void;
}
export function AlbumView({
album,
addLog,
onReset,
onAlbumUpdate,
}: AlbumViewProps) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<AnalysisResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [sanitizing, setSanitizing] = useState(false);
const [sanitizeStatus, setSanitizeStatus] = useState<
"idle" | "success" | "error"
>("idle");
const [torrenting, setTorrenting] = useState(false);
const [torrentStatus, setTorrentStatus] = useState<
"idle" | "success" | "error"
>("idle");
const [checkingMQA, setCheckingMQA] = useState(false);
const [mqaStatus, setMqaStatus] = useState<
"idle" | "success" | "error" | "warning"
>("idle");
const [checkingUpconv, setCheckingUpconv] = useState(false);
const [upconvStatus, setUpconvStatus] = useState<
"idle" | "success" | "error"
>("idle");
const [uploadingCover, setUploadingCover] = useState(false);
const [coverUploadStatus, setCoverUploadStatus] = useState<
"idle" | "success" | "error"
>("idle");
const [coverUrl, setCoverUrl] = useState<string | null>(null);
const [spectrograms, setSpectrograms] = useState<
Record<string, { exists: boolean; url?: string }>
>({});
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [showAllSpectrograms, setShowAllSpectrograms] = useState(false);
const [spectrogramsViewed, setSpectrogramsViewed] = useState(false);
const [copying, setCopying] = useState(false);
const [copyStatus, setCopyStatus] = useState<"idle" | "success" | "error">(
"idle",
);
const [deleting, setDeleting] = useState(false);
const [generatingTemplate, setGeneratingTemplate] = useState(false);
// Mock state for stepper visualization since we removed the real file generation
const [generatedFiles, setGeneratedFiles] = useState<{
json: { name: string; url: string } | null;
torrent: { name: string; url: string } | null;
}>({ json: null, torrent: null });
const [skippedSteps, setSkippedSteps] = useState<Record<number, boolean>>({});
const [injecting, setInjecting] = useState(false);
const [injectionStatus, setInjectionStatus] = useState<
"idle" | "injecting" | "checking" | "success" | "error" | "resumed"
>("idle");
const [injectionHash, setInjectionHash] = useState<string | null>(null);
const [recheckProgress, setRecheckProgress] = useState(0);
const [resuming, setResuming] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [releaseType, setReleaseType] = useState<number>(1);
const [isGuessedType, setIsGuessedType] = useState(false);
const [preflightSkipped, setPreflightSkipped] = useState(false);
const [autoMode, setAutoMode] = useState(false);
const [autoModeStarted, setAutoModeStarted] = useState(false);
// Audio Preview State
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const isDraggingRef = useRef(false);
const lastSeekTimeRef = useRef(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
const dragStartedRef = useRef(false);
const shouldSkipClickRef = useRef(false);
useEffect(() => {
audioRef.current = new Audio();
audioRef.current.volume = 0.5;
const handleEnded = () => {
setIsPlaying(false);
setProgress(0);
setPlayingTrack(null);
};
const handleTimeUpdate = () => {
if (audioRef.current && !isDraggingRef.current) {
const duration = audioRef.current.duration;
const currentTime = audioRef.current.currentTime;
if (duration > 0) {
setProgress((currentTime / duration) * 100);
}
}
};
audioRef.current.addEventListener("ended", handleEnded);
audioRef.current.addEventListener("timeupdate", handleTimeUpdate);
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.removeEventListener("ended", handleEnded);
audioRef.current.removeEventListener("timeupdate", handleTimeUpdate);
}
};
}, []);
const togglePlay = (filename: string) => {
if (!audioRef.current) return;
if (playingTrack === filename) {
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => setIsPlaying(true))
.catch((e) => {
if (e.name === "AbortError") return;
console.error("Playback failed", e);
});
}
}
} else {
// Stop previous if any
audioRef.current.pause();
// Play new
audioRef.current.src = `/api/audio?path=${encodeURIComponent(`${album.path}/${filename}`)}`;
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => setIsPlaying(true))
.catch((e) => {
if (e.name === "AbortError") return;
console.error("Playback failed", e);
addLog(`Playback failed: ${e.message}`);
setPlayingTrack(null);
setIsPlaying(false);
});
}
setPlayingTrack(filename);
setProgress(0);
}
};
const handleSeek = (e: React.MouseEvent, filename: string) => {
if (playingTrack !== filename || !audioRef.current) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX - rect.left;
const width = rect.width;
const percentage = Math.max(0, Math.min(1, x / width));
// Update UI immediately
setProgress(percentage * 100);
// Throttle audio seek to every 50ms
const now = Date.now();
if (now - lastSeekTimeRef.current > 50) {
if (audioRef.current.duration) {
audioRef.current.currentTime = percentage * audioRef.current.duration;
}
lastSeekTimeRef.current = now;
}
};
const handleTrackMouseDown = (e: React.MouseEvent, filename: string) => {
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault(); // Prevent text selection
dragStartedRef.current = false;
shouldSkipClickRef.current = false;
if (playingTrack !== filename) {
togglePlay(filename);
shouldSkipClickRef.current = true; // We handled the action here, skip MouseUp toggle
}
setIsDragging(true);
isDraggingRef.current = true;
};
const handleTrackMouseMove = (e: React.MouseEvent, filename: string) => {
if (isDragging) {
dragStartedRef.current = true;
if (playingTrack === filename) {
handleSeek(e, filename);
}
}
};
const handleTrackMouseUp = (e: React.MouseEvent, filename: string) => {
if ((e.target as HTMLElement).closest("button")) return;
setIsDragging(false);
isDraggingRef.current = false;
if (!dragStartedRef.current) {
// It was a click
if (playingTrack === filename && !shouldSkipClickRef.current) {
togglePlay(filename);
}
} else {
// It was a drag, ensure final position is set
if (playingTrack === filename && audioRef.current) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX - rect.left;
const width = rect.width;
const percentage = Math.max(0, Math.min(1, x / width));
if (audioRef.current.duration) {
audioRef.current.currentTime = percentage * audioRef.current.duration;
}
}
}
};
const handleTrackMouseLeave = () => {
setIsDragging(false);
isDraggingRef.current = false;
};
const handleUploadCover = async (
isAutoMode: boolean | React.MouseEvent = false,
) => {
const auto = isAutoMode === true;
if (!album.coverPath) return;
if (coverUrl) {
if (!auto) copyToClipboard(coverUrl, "Cover URL");
return;
}
setUploadingCover(true);
addLog("Uploading cover art...");
try {
// 1. Fetch the image blob from our local API
const coverRes = await fetch(
`/api/cover?path=${encodeURIComponent(album.coverPath)}`,
);
const blob = await coverRes.blob();
// 2. Prepare form data
const formData = new FormData();
formData.append("file", blob, "cover.jpg");
// 3. Upload to our proxy API
const res = await fetch("/api/upload/image", {
method: "POST",
body: formData,
});
const result = (await res.json()) as {
error?: string;
url?: string;
};
if (result.url) {
setCoverUrl(result.url);
setCoverUploadStatus("success");
if (!auto) copyToClipboard(result.url, "Cover URL");
addLog(`Cover uploaded: ${result.url}`);
} else {
setCoverUploadStatus("error");
addLog(`Upload failed: ${result.error}`);
}
} catch (e) {
setCoverUploadStatus("error");
addLog("Upload request failed.");
} finally {
setUploadingCover(false);
}
};
// Track previous album path to handle resets correctly
const prevAlbumPathRef = useRef<string | null>(null);
const preservedReleaseType = useRef<number | null>(null);
const hasInitializedReleaseType = useRef(false);
const isSanitizingTransition = useRef(false);
useEffect(() => {
if (album) {
analyzeAlbum();
// Only reset checks if we switched to a different album
if (prevAlbumPathRef.current !== album.path) {
// If this is a sanitization transition, we want to keep the release type and checks
if (isSanitizingTransition.current) {
isSanitizingTransition.current = false;
// We preserve preflight checks and release type
} else {
preservedReleaseType.current = null;
hasInitializedReleaseType.current = false;
// Reset checks and state for a completely new album detection
setSpectrograms({});
setMqaStatus("idle");
setUpconvStatus("idle");
setSpectrogramsViewed(false);
setPreflightSkipped(false);
setAutoModeStarted(false);
setAutoMode(false);
// Reset workflow steps
setSkippedSteps({});
setSanitizeStatus("idle");
setTorrentStatus("idle");
setCopyStatus("idle");
setCoverUploadStatus("idle");
setGeneratedFiles({ json: null, torrent: null });
setInjectionStatus("idle");
setInjectionHash(null);
setCoverUrl(null);
}
}
prevAlbumPathRef.current = album.path;
}
}, [album]);
useEffect(() => {
if (data && data.tracks) {
fetchSpectrograms();
}
}, [data]);
useEffect(() => {
if (data && data.metadata) {
if (data.metadata.releaseType) {
if (preservedReleaseType.current !== null) {
setReleaseType(preservedReleaseType.current);
hasInitializedReleaseType.current = true;
preservedReleaseType.current = null;
} else if (!hasInitializedReleaseType.current) {
setReleaseType(data.metadata.releaseType);
hasInitializedReleaseType.current = true;
if (data.metadata.releaseType !== 1) {
setIsGuessedType(true);
}
}
}
}
}, [data]);
const fetchSpectrograms = async () => {
if (!data || !album) return;
try {
const res = await fetch("/api/spectrogram", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
albumPath: album.path,
tracks: data.tracks,
upc: data.metadata.upc,
}),
});
const result = (await res.json()) as SpectrogramResponse;
if (result.results) {
console.log("Spectrograms loaded:", result.results);
setSpectrograms(result.results);
}
} catch (e) {
console.error("Failed to fetch spectrograms", e);
}
};
// Auto-refresh album details every 5 seconds to catch new files
useEffect(() => {
if (!album) return;
const interval = setInterval(() => {
// Silent update
fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ albumPath: album.path }),
})
.then((res) => res.json())
.then((result: AnalysisResponse & { error?: string }) => {
if (!result.error) {
// Only update if track count changed or filenames changed
setData((prev) => {
if (!prev) return result;
if (prev.tracks.length !== result.tracks.length) return result;
const prevNames = prev.tracks
.map((t) => t.filename)
.sort()
.join("|");
const newNames = result.tracks
.map((t) => t.filename)
.sort()
.join("|");
if (prevNames !== newNames) return result;
return prev;
});
}
})
.catch(() => {});
}, 5000);
return () => clearInterval(interval);
}, [album]);
// Polling for qBittorrent status
useEffect(() => {
if (injectionStatus !== "checking" || !injectionHash) return;
const interval = setInterval(async () => {
try {
const res = await fetch(
`/api/qbittorrent/status?hash=${injectionHash}`,
);
const result = (await res.json()) as QbittorrentStatusResponse & {
error?: string;
};
if (result.success && result.info) {
const { state, progress } = result.info;
if (progress === 1) {
setInjectionStatus("success");
setRecheckProgress(100);
addLog("Torrent recheck complete: 100%");
} else if (
state === "checkingUP" ||
state === "checkingDL" ||
state === "metaDL" ||
state === "allocating" ||
state === "queuedUP" ||
state === "queuedDL"
) {
setRecheckProgress(Math.round(progress * 100));
} else if (
state === "stoppedDL" ||
state === "pausedDL" ||
state === "stoppedUP" ||
state === "pausedUP"
) {
// If stopped and not 100%, it means recheck finished (or didn't find anything)
setInjectionStatus("error");
addLog(
`Recheck finished with incomplete data (${Math.round(progress * 100)}%). Check path mapping.`,
);
} else {
// Unknown state or other error
setInjectionStatus("error");
addLog(`Injection failed. State: ${state}`);
}
}
} catch (e) {
console.error("Polling failed", e);
}
}, 1000);
return () => clearInterval(interval);
}, [injectionStatus, injectionHash]);
const analyzeAlbum = async () => {
if (!data) setLoading(true);
setError(null);
// setData(null); // Keep existing data to prevent flicker
addLog(`Analyzing album: ${album.name}...`);
try {
const res = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ albumPath: album.path }),
});
const result = (await res.json()) as AnalysisResponse & {
error?: string;
};
if (result.error) {
setError(result.error);
addLog(`Error: ${result.error}`);
} else {
setData(result);
addLog(`Analysis complete. Status: ${result.quality.status}`);
// Check for long filenames (Folder + / + File)
// We need to type guard tracks to ensure they are not error tracks for filename access
const hasLong = result.tracks.some((t) => {
if ("error" in t) return false;
return album.name.length + 1 + t.filename.length > 179;
});
if (hasLong) {
setShowRenameDialog(true);
}
}
} catch (e) {
setError("Failed to connect to server");
addLog("Failed to connect to server");
} finally {
setLoading(false);
}
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
setCopied(label);
setTimeout(() => setCopied(null), 2000);
addLog(`Copied ${label} to clipboard.`);
};
const handleSanitize = async () => {
setSanitizing(true);
setSanitizeStatus("idle");
addLog("Sanitizing album...");
try {
const res = await fetch("/api/sanitize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ albumPath: album.path }),
});
const result = await res.json();
if (result.success) {
setSanitizeStatus("success");
addLog("Album sanitized successfully.");
// Preserve release type across the reload
preservedReleaseType.current = releaseType;
if (result.newAlbumPath && result.newAlbumPath !== album.path) {
isSanitizingTransition.current = true;
onAlbumUpdate(result.newAlbumPath); // Update parent with new path
} else {
onAlbumUpdate(); // Refresh album data
}
} else {
setSanitizeStatus("error");
addLog(`Sanitize failed: ${result.error}`);
}
} catch (e) {
setSanitizeStatus("error");
addLog("Sanitize request failed.");
} finally {
setSanitizing(false);
}
};
const handleTorrent = async () => {
setTorrenting(true);
setTorrentStatus("idle");
addLog("Creating torrent...");
try {
const res = await fetch("/api/torrent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ albumPath: album.path }),
});
const result = (await res.json()) as TorrentCreationResponse;
if (result.success) {
setTorrentStatus("success");
addLog("Torrent created successfully.");
if (result.output) addLog(result.output);
} else {
setTorrentStatus("error");
addLog(`Torrent creation failed: ${result.error}`);
if (result.errorOutput) addLog(result.errorOutput);
}
} catch (e) {
setTorrentStatus("error");
addLog("Torrent request failed.");
} finally {
setTorrenting(false);
}
};
const handleCheckMQA = async () => {
if (!data?.tracks?.length) return;
setCheckingMQA(true);
setMqaStatus("idle");
addLog("Checking for MQA...");
try {
const res = await fetch("/api/check/mqa", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ albumPath: album.path }),
});
const result = (await res.json()) as CheckMQAResponse & {
error?: string;
};
if (result.success) {
if (result.isMQA) {
setMqaStatus("warning");
addLog("WARNING: MQA DETECTED!");
if (
confirm("MQA Detected! Do you want to delete this album and reset?")
) {
addLog(
"User requested delete (Not implemented in MVP, please delete manually).",
);
}
} else {
setMqaStatus("success");
addLog("MQA Check Passed: No MQA detected.");
}
} else {
setMqaStatus("error");
addLog(`MQA Check failed: ${result.error}`);
}
} catch (e) {
setMqaStatus("error");
addLog("MQA Check request failed.");
} finally {
setCheckingMQA(false);
// setTimeout(() => setMqaStatus("idle"), 5000); // Removed auto-reset
}
};
const handleCheckUpconv = async () => {
if (!data?.tracks?.length) return;
setCheckingUpconv(true);
setUpconvStatus("idle");
addLog("Checking for Upconversion...");
const has24Bit = data.tracks.some((t) => {
if ("error" in t) return false;
return (t.format?.bitsPerSample || 0) >= 24;
});
if (!has24Bit) {
addLog("No 24-bit files to check.");
setCheckingUpconv(false);
return;
}
try {
const res = await fetch("/api/check/upconv", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ albumPath: album.path }),
});
const result = (await res.json()) as CheckUpconvResponse & {
error?: string;
};
if (result.success) {
setUpconvStatus("success");
addLog(`Upconversion Check: ${result.wastedBits}`);
addLog(result.output);
} else {
setUpconvStatus("error");
addLog(`Upconversion Check failed: ${result.error}`);
}
} catch (e) {
setUpconvStatus("error");
addLog("Upconversion Check request failed.");
} finally {
setCheckingUpconv(false);
// setTimeout(() => setUpconvStatus("idle"), 5000); // Removed auto-reset
}
};
const handleInject = async (eOrRetryCount?: React.MouseEvent | number) => {
const retryCount = typeof eOrRetryCount === "number" ? eOrRetryCount : 0;
if (retryCount === 0) {
setInjecting(true);
setInjectionStatus("injecting");
addLog("Injecting torrent to qBittorrent...");
}
try {
const res = await fetch("/api/qbittorrent/inject", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ albumPath: album.path }),
});
const result = (await res.json()) as QbittorrentInjectResponse & {
error?: string;
};
if (result.success) {
setInjectionHash(result.hash);
addLog(
`Torrent injected${retryCount > 0 ? ` (after ${retryCount} retries)` : ""}. Waiting for client to register...`,
);
// Wait 2 seconds before starting recheck loop to allow qBit to register the torrent
setTimeout(() => {
setInjectionStatus("checking");
addLog("Starting recheck...");
setInjecting(false);
}, 4000);
} else {
if (retryCount < 3) {
addLog(`Injection failed. Retrying... (${retryCount + 1}/3)`);
setTimeout(() => handleInject(retryCount + 1), 2000);
} else {
setInjectionStatus("error");
addLog(`Injection failed after 3 retries: ${result.error}`);
setInjecting(false);
}
}
} catch (e) {
if (retryCount < 3) {
addLog(`Injection request failed. Retrying... (${retryCount + 1}/3)`);
setTimeout(() => handleInject(retryCount + 1), 2000);
} else {
setInjectionStatus("error");
addLog("Injection request failed after 3 retries.");
setInjecting(false);
}
}
};
const handleCopy = async () => {
setCopying(true);
addLog("Copying album to output directory...");
try {
const res = await fetch("/api/copy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ albumPath: album.path }),
});
const result = await res.json();
if (result.success) {
addLog(`Album copied to: ${result.destPath}`);
setCopyStatus("success");
} else {
addLog(`Copy failed: ${result.error}`);
setCopyStatus("error");
}
} catch (e) {
addLog("Copy request failed.");
setCopyStatus("error");
} finally {
setCopying(false);
}
};
// Auto Workflow effect
useEffect(() => {
if (!autoMode) return;
if (
loading ||
sanitizing ||
torrenting ||
copying ||
uploadingCover ||
injecting ||
injectionStatus === "checking" ||
injectionStatus === "injecting"
)
return;
if (isSanitizingTransition.current) return;
const timer = setTimeout(() => {
if (sanitizeStatus !== "success" && !skippedSteps[1]) {
handleSanitize();
} else if (torrentStatus !== "success" && !skippedSteps[2]) {
handleTorrent();
} else if (copyStatus !== "success" && !skippedSteps[3]) {
handleCopy();
} else if (!coverUrl && !skippedSteps[4] && album.coverPath) {
handleUploadCover(true);
} else if (
injectionStatus !== "success" &&
injectionStatus !== "resumed" &&
!skippedSteps[5]
) {
handleInject(0);
} else {
setAutoMode(false);
addLog("Auto workflow completed.");
}
}, 500);
return () => clearTimeout(timer);
}, [
autoMode,
sanitizeStatus,
torrentStatus,
copyStatus,
coverUrl,
injectionStatus,
skippedSteps,
sanitizing,
torrenting,
copying,
uploadingCover,
injecting,
loading,
album.path,
]);
const handleResume = async () => {
if (!injectionHash) return;
setResuming(true);
addLog("Resuming torrent...");
try {
const res = await fetch("/api/qbittorrent/resume", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hash: injectionHash }),
});
const result = (await res.json()) as QbittorrentResumeResponse & {
error?: string;
};
if (result.success) {
setInjectionStatus("resumed");
addLog("Torrent resumed successfully.");
} else {
addLog(`Resume failed: ${result.error}`);
}
} catch (e) {
addLog("Resume request failed.");
} finally {
setResuming(false);
}
};
const handleSkipPreflight = () => {
if (
confirm(
"WARNING: Skipping these checks is UNSAFE. You might upload bad files or break the workflow. Are you sure you want to skip?",
)
) {
setPreflightSkipped(true);
addLog("WARNING: Pre-flight checks skipped by user.");
}
};
const audioTriggers = useMemo(
() => [
{
sound: "/sfx/error.mp3",
conditions: [
!!error,
data?.quality?.status === "CORRUPT",
showRenameDialog,
sanitizeStatus === "error",
torrentStatus === "error",
copyStatus === "error",
coverUploadStatus === "error",
injectionStatus === "error",
mqaStatus === "error",
mqaStatus === "warning",
upconvStatus === "error",
],
},
{
sound: "/sfx/stage-complete.mp3",
conditions: [
sanitizeStatus === "success",
torrentStatus === "success",
copyStatus === "success",
coverUploadStatus === "success",
injectionStatus === "checking",
mqaStatus === "success",
upconvStatus === "success",
],
},
{
sound: "/sfx/success.mp3",
conditions: [injectionStatus === "success"],
},
],
[
error,
data?.quality?.status,
showRenameDialog,
sanitizeStatus,
torrentStatus,
copyStatus,
coverUploadStatus,
injectionStatus,
mqaStatus,
upconvStatus,
],
);
const prevTriggersRef = useRef<boolean[][]>([]);
useEffect(() => {
if (prevTriggersRef.current.length === 0) {
prevTriggersRef.current = audioTriggers.map((t) =>
t.conditions.map(() => false),
);
}
audioTriggers.forEach((trigger, i) => {
const prevConditions = prevTriggersRef.current[i] || [];
// Trigger if condition goes explicitly from falsy to truthy
const justFired = trigger.conditions.some(
(cond, j) => cond && !prevConditions[j],
);
if (justFired) {
new Audio(trigger.sound).play().catch(console.error);
}
prevTriggersRef.current[i] = [...trigger.conditions];
});
}, [audioTriggers]);
const backgroundCoverMemo = useMemo(() => {
return album.coverPath ? (
<>
<div className="absolute w-full h-full top-0 left-0 backdrop-blur-xs z-10 bg-background/70" />
<div className="absolute w-full h-full top-0 left-0 z-0">
<img
src={`/api/cover?path=${encodeURIComponent(album.coverPath)}`}
alt={album.name}
className="w-full h-full object-cover scale-110"
/>
</div>
</>
) : null;
}, [album.coverPath, album.name]);
const spectrogramsMemo = useMemo(() => {
if (!data?.tracks) return null;
return data.tracks.map((track: AnalysisTrack) => {
const spec = spectrograms[track.filename];
if (!spec?.exists || !spec.url) return null;
return (
<div
key={track.filename}
className="space-y-3 p-4 rounded-xl bg-black/20 border border-white/5"
>
<div className="flex items-center justify-between">
<div className="font-medium text-white/90 flex items-center gap-3">
<span className="w-6 h-6 rounded bg-white/10 text-xs flex items-center justify-center font-mono text-white/50">
{(!("error" in track) && track.common?.track?.no) || ""}
</span>
<span>
{(!("error" in track) && track.common?.title) || track.filename}
</span>
</div>
{!("error" in track) && (
<div className="text-xs font-mono text-white/30">
{track.format?.bitsPerSample}bit /{" "}
{track.format?.sampleRate
? track.format.sampleRate / 1000
: "?"}
kHz
</div>
)}
</div>
<div className="bg-black/50 rounded-lg overflow-hidden border border-white/10 relative group">
<div className="absolute inset-0 bg-white/0 group-hover:bg-white/5 transition-colors pointer-events-none" />
<img
key={spec.url}
src={spec.url}
alt={`Spectrogram for ${track.filename}`}
className="w-full h-auto object-contain max-h-[500px]"
/>
</div>
</div>
);
});
}, [data?.tracks, spectrograms]);
if (loading) {
return (
<div className="flex flex-col gap-4 p-6 h-full justify-center">
<div className="space-y-4 w-full max-w-2xl mx-auto">
<Skeleton className="h-12 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="space-y-2 mt-8">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full text-destructive p-6">
<AlertTriangle className="w-12 h-12 mb-4" />
<p className="text-lg font-medium">{error}</p>
<Button onClick={analyzeAlbum} variant="outline" className="mt-4">
Try Again
</Button>
</div>
);
}
if (!data) return null;
const { metadata, tracks, quality } = data;
const tracklistText = tracks
.map((t) => {
if ("error" in t) return `${t.filename} (Error)`;
return `${t.common?.track?.no || "00"} - ${t.common?.title || t.filename}`;
})
.join("\n");
// Dynamic offset deduction for Tracklist Max Height
const isLogEnabled = process.env.NEXT_PUBLIC_SHOW_SYSTEM_LOG === "true";
const tracklistOffsetRem = isLogEnabled ? 42 : 28;
// Check relative length: Folder Name + / + File Name
const hasLongFilenames = tracks.some((t) => {
if ("error" in t) return false;
return album.name.length + 1 + t.filename.length > 179;
});
const has24Bit = tracks.some((t) => {
if ("error" in t) return false;
return (t.format?.bitsPerSample || 0) >= 24;
});
// Pre-flight checks validation
const mqaPassed = mqaStatus === "success";
const upconvPassed = !has24Bit || upconvStatus === "success";
const specsExist = Object.keys(spectrograms).length > 0;
const specsPassed = !specsExist || spectrogramsViewed;
const allChecksPassed =
preflightSkipped || (mqaPassed && upconvPassed && specsPassed);
const releaseTypes = [
{ id: 1, name: "Album" },
{ id: 3, name: "Soundtrack" },
{ id: 5, name: "EP" },
{ id: 6, name: "Anthology" },
{ id: 7, name: "Compilation" },
{ id: 9, name: "Single" },
{ id: 11, name: "Live album" },
{ id: 13, name: "Remix" },
{ id: 14, name: "Bootleg" },
{ id: 15, name: "Interview" },
{ id: 16, name: "Mixtape" },
{ id: 17, name: "DJ Mix" },
{ id: 18, name: "Concert recording" },
{ id: 21, name: "Unknown" },
];
return (
<div className="flex flex-col gap-4">
{/* Header Section */}
<div className="relative overflow-hidden rounded-xl bg-linear-to-b from-white/5 to-transparent border border-white/10 p-3 shadow-2xl">
{backgroundCoverMemo}
<div className="flex flex-col md:flex-row gap-8 items-start relative z-10">
{/* Cover Art */}
<div className="shrink-0 group relative">
<div className="w-40 h-40 md:w-50 md:h-50 rounded-lg bg-black/50 shadow-2xl overflow-hidden border border-white/10 relative z-10 transition-transform duration-500 group-hover:scale-[1.02]">
{album.coverPath ? (
<img
src={`/api/cover?path=${encodeURIComponent(album.coverPath)}`}
alt={album.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
<Disc className="w-20 h-20 text-muted-foreground/50" />
</div>
)}
</div>
{/* Glow effect behind cover */}
<div className="absolute inset-0 bg-primary/20 blur-3xl -z-10 rounded-full opacity-0 group-hover:opacity-50 transition-opacity duration-700" />
</div>
{/* Info */}
<div className="flex-1 min-w-0 flex flex-col gap-4 pt-2">
<div>
<motion.h2
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-3xl md:text-4xl font-bold tracking-tight text-white leading-tight flex items-center gap-3"
>
<span className="truncate">{metadata.album}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity"
onClick={() => copyToClipboard(metadata.album, "Album Title")}
>
{copied === "Album Title" ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</motion.h2>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-xl md:text-2xl text-white/70 font-medium flex items-center gap-2 mt-1"
>
<span>{metadata.artist}</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity"
onClick={() =>
copyToClipboard(metadata.artist, "Artist Name")
}
>
{copied === "Artist Name" ? (
<Check className="w-3 h-3 text-green-500" />
) : (
<Copy className="w-3 h-3" />
)}
</Button>
</motion.div>
</div>
{/* Metadata Grid */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex flex-wrap gap-2 items-center mt-auto"
>
<Badge
variant="outline"
className={`text-xs px-2.5 py-1 border ${
quality.status === "LOSSLESS" || quality.status === "HI-RES"
? "bg-green-500/10 text-green-400 border-green-500/30"
: "bg-red-500/10 text-red-400 border-red-500/30"
}`}
>
{quality.status}
</Badge>
{quality.mixed && (
<Badge
variant="outline"
className="bg-yellow-500/10 text-yellow-400 border-yellow-500/30 text-xs px-2.5 py-1"
>
MIXED FORMATS
</Badge>
)}
{metadata.year && <div className="h-6 w-px bg-white/10 mx-1" />}
{metadata.year && (
<span
className="text-sm text-white/60 hover:text-white cursor-pointer transition-colors font-mono"
onClick={() => copyToClipboard(String(metadata.year), "Year")}
title="Copy Year"
>
{metadata.year}
</span>
)}
{metadata.totalSize && (
<>
<div className="h-6 w-px bg-white/10 mx-1" />
<span
className="text-sm text-white/60 hover:text-white cursor-pointer transition-colors font-mono"
onClick={() =>
copyToClipboard(
formatBytes(metadata.totalSize),
"Total Size",
)
}
title="Copy Total Size"
>
{formatBytes(metadata.totalSize)}
</span>
</>
)}
{metadata.label && (
<>
<div className="h-6 w-px bg-white/10 mx-1" />
<span
className="text-sm text-white/60 hover:text-white cursor-pointer transition-colors max-w-[200px] truncate"
onClick={() => copyToClipboard(metadata.label, "Label")}
title="Copy Label"
>
{metadata.label}
</span>
</>
)}
{metadata.upc && (
<>
<div className="h-6 w-px bg-white/10 mx-1" />
<span
className="text-sm text-white/60 hover:text-white cursor-pointer transition-colors font-mono"
onClick={() => copyToClipboard(metadata.upc || "", "UPC")}
title="Copy UPC"
>
UPC: {metadata.upc}
</span>
</>
)}
</motion.div>
{/* Additional Artists */}
{metadata.additionalArtists && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="flex flex-wrap gap-1.5 items-center"
>
<span className="text-xs text-white/40 uppercase tracking-wider font-semibold mr-1">
Feat
</span>
{metadata.additionalArtists.map((artist: string, i: number) => (
<Badge
key={i}
variant="secondary"
className="text-[10px] px-2 py-0.5 bg-white/5 hover:bg-white/10 text-white/80 cursor-pointer border-transparent"
onClick={() => copyToClipboard(artist, "Artist Name")}
>
{artist}
</Badge>
))}
</motion.div>
)}
{/* Release Type & Links */}
<div className="flex items-center gap-3 mt-2">
<div className="relative">
<select
className="h-8 w-[160px] rounded-md border border-white/10 bg-black/20 px-3 py-1 text-sm text-white/80 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary hover:bg-white/5 appearance-none cursor-pointer"
value={releaseType}
onChange={(e) => {
setReleaseType(parseInt(e.target.value));
setIsGuessedType(false);
}}
>
{releaseTypes.map((type) => (
<option
key={type.id}
value={type.id}
className="bg-zinc-900 text-white"
>
{type.name}
</option>
))}
</select>
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-white/40">
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L5 5L9 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
{isGuessedType && (
<Badge
variant="secondary"
className="text-[10px] bg-blue-500/40 text-blue-400 border border-blue-500/20"
>
Auto-detected
</Badge>
)}
{metadata.tidalUrl && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs text-cyan-400 hover:text-cyan-300 font-semibold cursor-pointer"
onClick={() => window.open(metadata.tidalUrl, "_blank")}
>
<Link />
TIDAL
</Button>
)}
</div>
</div>
</div>
</div>
<Separator />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Tracklist Column */}
<div className="lg:col-span-2 flex flex-col min-h-0 gap-4">
{quality.mixed && (
<Alert
variant="destructive"
className="py-2 bg-red-500/10 border-red-500/20 text-red-200"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="text-sm font-semibold">
Quality Mismatch
</AlertTitle>
<AlertDescription className="text-xs flex items-center gap-2 mt-1">
<span>Different bit depths/sample rates detected.</span>
<Button
variant="outline"
size="sm"
className="h-6 text-xs bg-red-500/10 border-red-500/30 hover:bg-red-500/20 text-red-200"
onClick={() =>
copyToClipboard(quality.details, "Quality Report")
}
>
Copy Report
</Button>
</AlertDescription>
</Alert>
)}
{hasLongFilenames && (
<Alert
variant="destructive"
className="py-2 bg-red-500/10 border-red-500/20 text-red-200"
>
<ShieldAlert className="h-4 w-4" />
<AlertTitle className="text-sm font-semibold">
Filename Too Long
</AlertTitle>
<AlertDescription className="text-xs mt-1">
Some filenames exceed 179 characters. Torrent creation is
disabled.
</AlertDescription>
</Alert>
)}
<div className="flex flex-col rounded-xl bg-black/20 border border-white/10 overflow-hidden shadow-inner">
<div className="py-3 px-4 border-b border-white/5 flex flex-row items-center justify-between shrink-0 bg-white/5">
<h3 className="text-sm font-medium flex items-center gap-2 text-white/80">
<FileAudio className="w-4 h-4 text-primary" /> Tracklist
</h3>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-white/50 hover:text-white hover:bg-white/10"
onClick={() => setShowRenameDialog(true)}
>
Rename Files
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-white/50 hover:text-white hover:bg-white/10"
onClick={() => copyToClipboard(tracklistText, "Tracklist")}
>
{copied === "Tracklist" ? (
<Check className="w-3 h-3 mr-1 text-green-500" />
) : (
<Copy className="w-3 h-3 mr-1" />
)}
Copy All
</Button>
</div>
</div>
<div
className="relative overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/10 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-white/20"
style={{ maxHeight: `calc(100vh - ${tracklistOffsetRem}rem)` }}
>
<div className="h-full">
<div className="p-2 space-y-0.5">
{tracks.map((track: AnalysisTrack, i: number) => {
const isError = "error" in track;
return (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.02 }}
className={`group relative flex items-center justify-between p-2 rounded-lg transition-colors text-sm overflow-hidden select-none cursor-pointer ${playingTrack === track.filename ? "bg-white/10" : "hover:bg-white/5"}`}
onMouseDown={(e) =>
handleTrackMouseDown(e, track.filename)
}
onMouseMove={(e) =>
handleTrackMouseMove(e, track.filename)
}
onMouseUp={(e) => handleTrackMouseUp(e, track.filename)}
onMouseLeave={handleTrackMouseLeave}
>
{/* Progress Bar */}
{playingTrack === track.filename && (
<div
className="absolute inset-y-0 left-0 bg-white/10 z-0 pointer-events-none transition-[width] duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
)}
<div className="relative z-10 flex items-center gap-3 min-w-0 pointer-events-none">
<span className="text-white/30 font-mono w-6 text-right shrink-0">
{isError
? "!!"
: (track.common?.track?.no || i + 1)
.toString()
.padStart(2, "0")}
</span>
<div className="flex flex-col min-w-0 pr-2 pointer-events-auto">
<ScrollableTrackName
text={
isError
? track.filename
: track.common?.title || track.filename
}
isError={isError}
/>
<span className="text-[10px] text-white/30 truncate font-mono">
{track.filename}
</span>
</div>
</div>
<div className="relative z-10 flex items-center gap-4 shrink-0">
{!isError && (
<div className="flex items-center gap-2 text-xs text-white/40 font-mono">
<span>
{track.format?.bitsPerSample}bit/
{track.format?.sampleRate
? track.format.sampleRate / 1000
: "?"}
kHz
</span>
{process.env.NEXT_PUBLIC_SHOW_TRACK_BITRATE ===
"true" && (
<span>
{Math.round(
(track.format?.bitrate || 0) / 1000,
)}
kbps
</span>
)}
</div>
)}
{/* Spectrogram Button */}
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 cursor-pointer relative z-20 ${spectrograms[track.filename]?.exists ? "text-primary hover:text-primary hover:bg-primary/10" : "text-white/20 hover:text-white hover:bg-white/10"}`}
onClick={(e) => {
e.stopPropagation();
const spec = spectrograms[track.filename];
if (spec?.exists && spec.url) {
setPreviewUrl(spec.url);
}
}}
disabled={!spectrograms[track.filename]?.exists}
title="View Spectrogram"
>
<Activity className="w-3 h-3" />
</Button>
</div>
</motion.div>
);
})}
</div>
</div>
</div>
</div>
</div>
{/* Actions Column */}
<div className="flex flex-col gap-6">
{/* Quality Checks - Now Pre-flight */}
<div
className={`rounded-xl border transition-all duration-500 overflow-hidden shadow-sm ${
!allChecksPassed
? "bg-amber-500/5 border-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.1)] relative"
: "bg-black/20 border-white/10"
}`}
>
{!allChecksPassed && (
<div className="absolute inset-0 bg-amber-500/5 animate-pulse pointer-events-none" />
)}
<div
className={`py-3 px-4 border-b flex items-center justify-between relative z-10 ${!allChecksPassed ? "border-amber-500/20 bg-amber-500/10" : "border-white/5 bg-white/5"}`}
>
<h3
className={`text-sm font-medium ${!allChecksPassed ? "text-amber-200" : "text-white/80"}`}
>
Pre-flight Checks
</h3>
<div className="flex gap-2 items-center">
{!allChecksPassed && (
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleSkipPreflight();
}}
className="h-5 w-5 text-amber-500/50 hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Skip Checks (Unsafe)"
>
<FastForward className="w-3.5 h-3.5" />
</Button>
)}
<div className="flex gap-1">
{mqaStatus === "success" && (
<div className="w-2 h-2 rounded-full bg-green-500" />
)}
{upconvStatus === "success" && (
<div className="w-2 h-2 rounded-full bg-green-500" />
)}
{specsPassed && specsExist && (
<div className="w-2 h-2 rounded-full bg-green-500" />
)}
</div>
</div>
</div>
<div className="p-4 flex flex-col gap-2.5">
<Button
onClick={handleCheckMQA}
disabled={checkingMQA}
className={`w-full justify-start h-9 text-xs transition-all border border-white/5 ${mqaStatus === "success" ? "bg-green-500/20 text-green-400 hover:bg-green-500/30" : mqaStatus === "warning" ? "bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30" : mqaStatus === "error" ? "bg-red-500/20 text-red-400 hover:bg-red-500/30" : "bg-white/5 hover:bg-white/10"}`}
variant="ghost"
>
{checkingMQA ? (
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
) : mqaStatus === "success" ? (
<Check className="w-3.5 h-3.5 mr-2" />
) : mqaStatus === "warning" ? (
<AlertTriangle className="w-3.5 h-3.5 mr-2" />
) : (
<Activity className="w-3.5 h-3.5 mr-2 text-white/70" />
)}
{checkingMQA
? "Checking..."
: mqaStatus === "success"
? "MQA Not Detected"
: mqaStatus === "warning"
? "MQA Detected"
: "Check for MQA"}
</Button>
{has24Bit && (
<Button
onClick={handleCheckUpconv}
disabled={checkingUpconv}
className={`w-full justify-start h-9 text-xs transition-all border border-white/5 ${upconvStatus === "success" ? "bg-green-500/20 text-green-400 hover:bg-green-500/30" : upconvStatus === "error" ? "bg-red-500/20 text-red-400 hover:bg-red-500/30" : "bg-white/5 hover:bg-white/10"}`}
variant="ghost"
>
{checkingUpconv ? (
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
) : upconvStatus === "success" ? (
<Check className="w-3.5 h-3.5 mr-2" />
) : (
<Activity className="w-3.5 h-3.5 mr-2 text-white/70" />
)}
{checkingUpconv
? "Checking..."
: upconvStatus === "success"
? "Zero Upconverts Detected"
: "Check for Upconverts"}
</Button>
)}
<Button
onClick={() => {
setShowAllSpectrograms(true);
setSpectrogramsViewed(true);
}}
disabled={Object.keys(spectrograms).length === 0}
className={`w-full justify-start h-9 text-xs transition-all border border-white/5 ${spectrogramsViewed ? "bg-green-500/20 text-green-400 hover:bg-green-500/30" : "bg-white/5 hover:bg-white/10"}`}
variant="ghost"
>
{spectrogramsViewed ? (
<Check className="w-3.5 h-3.5 mr-2" />
) : (
<Waves className="w-3.5 h-3.5 mr-2 text-blue-400" />
)}
{spectrogramsViewed
? "Spectrograms Viewed"
: "View Spectrograms"}
</Button>
</div>
</div>
{/* Release Workflow Stepper */}
<div className="rounded-xl bg-black/20 border border-white/10 overflow-hidden shadow-sm flex flex-col">
<div className="py-3 px-4 border-b border-white/5 bg-white/5 flex items-center justify-between">
<h3 className="text-sm font-medium text-white/80">
Release Workflow
</h3>
{(!(
sanitizeStatus !== "idle" ||
torrentStatus !== "idle" ||
copyStatus !== "idle" ||
uploadingCover ||
!!coverUrl ||
injectionStatus !== "idle" ||
Object.keys(skippedSteps).length > 0
) ||
autoModeStarted) && (
<Button
size="sm"
variant="secondary"
className="h-6 text-xs bg-white/10 hover:bg-white/20 text-white"
onClick={() => {
setAutoModeStarted(true);
setAutoMode(true);
}}
disabled={!allChecksPassed || autoModeStarted}
>
{autoMode ? (
<RefreshCw className="w-3 h-3 mr-1 animate-spin" />
) : (
<Play className="w-3 h-3 mr-1" />
)}
{autoMode ? "Running..." : "Auto 1-5"}
</Button>
)}
</div>
<div
className={`p-6 flex flex-col relative transition-opacity duration-300 ${!allChecksPassed ? "opacity-30 pointer-events-none" : ""}`}
>
{/* Vertical Line */}
<div className="absolute left-[39px] top-6 bottom-6 w-px bg-white/10" />
{/* Step 1: Sanitize */}
<WorkflowStep
number={1}
title="Sanitize Album"
description={
!allChecksPassed
? "Complete pre-flight checks first"
: "Normalise file names and remove special characters"
}
status={
skippedSteps[1]
? "skipped"
: sanitizeStatus === "success"
? "completed"
: sanitizeStatus === "error"
? "error"
: sanitizing
? "loading"
: "pending"
}
isActive={sanitizeStatus !== "success" && !skippedSteps[1]}
onClick={handleSanitize}
onSkip={() => setSkippedSteps((prev) => ({ ...prev, 1: true }))}
icon={<ShieldCheck className="w-4 h-4" />}
actionLabel="Sanitize"
disabled={!allChecksPassed}
allChecksPassed={allChecksPassed}
/>
{/* Step 2: Create Torrent */}
<WorkflowStep
number={2}
title="Create Torrent"
description="Generate .torrent file"
status={
skippedSteps[2]
? "skipped"
: torrentStatus === "success"
? "completed"
: torrentStatus === "error"
? "error"
: torrenting
? "loading"
: "pending"
}
isActive={
(sanitizeStatus === "success" || skippedSteps[1]) &&
torrentStatus !== "success" &&
!skippedSteps[2]
}
onClick={handleTorrent}
onSkip={() => setSkippedSteps((prev) => ({ ...prev, 2: true }))}
icon={<Download className="w-4 h-4" />}
actionLabel="Create Torrent"
disabled={hasLongFilenames}
allChecksPassed={allChecksPassed}
/>
{/* Step 3: Copy/Finalize */}
<WorkflowStep
number={3}
title="Copy to Output"
description="Copy album to the output directory"
status={
skippedSteps[3]
? "skipped"
: copyStatus === "success"
? "completed"
: copyStatus === "error"
? "error"
: copying
? "loading"
: "pending"
}
isActive={
(sanitizeStatus === "success" || skippedSteps[1]) &&
(torrentStatus === "success" || skippedSteps[2]) &&
copyStatus !== "success" &&
!skippedSteps[3]
}
onClick={handleCopy}
onSkip={() => setSkippedSteps((prev) => ({ ...prev, 3: true }))}
icon={<HardDrive className="w-4 h-4" />}
actionLabel="Copy to Output"
allChecksPassed={allChecksPassed}
/>
{/* Step 4: Cover Art */}
<WorkflowStep
number={4}
title="Cover Art"
description="Upload cover artwork"
status={
skippedSteps[4]
? "skipped"
: coverUrl
? "completed"
: uploadingCover
? "loading"
: "pending"
}
isActive={
(sanitizeStatus === "success" || skippedSteps[1]) &&
(torrentStatus === "success" || skippedSteps[2]) &&
(copyStatus === "success" || skippedSteps[3]) &&
!coverUrl &&
!skippedSteps[4]
}
onClick={handleUploadCover}
onSkip={() => setSkippedSteps((prev) => ({ ...prev, 4: true }))}
icon={<Upload className="w-4 h-4" />}
actionLabel="Upload Cover"
disabled={!album.coverPath}
allChecksPassed={allChecksPassed}
/>
{/* Step 5: Inject */}
<WorkflowStep
number={5}
title="Inject to Client"
description="Add to qBittorrent"
status={
skippedSteps[5]
? "skipped"
: injectionStatus === "success" ||
injectionStatus === "resumed"
? "completed"
: injectionStatus === "error"
? "error"
: injecting ||
injectionStatus === "injecting" ||
injectionStatus === "checking"
? "loading"
: "pending"
}
isActive={
(sanitizeStatus === "success" || skippedSteps[1]) &&
(torrentStatus === "success" || skippedSteps[2]) &&
(copyStatus === "success" || skippedSteps[3]) &&
(!!coverUrl || skippedSteps[4]) &&
injectionStatus !== "success" &&
injectionStatus !== "resumed" &&
!skippedSteps[5]
}
onClick={handleInject}
onSkip={() => setSkippedSteps((prev) => ({ ...prev, 5: true }))}
icon={<Magnet className="w-4 h-4" />}
actionLabel={
injectionStatus === "checking"
? `Checking ${recheckProgress}%`
: "Inject"
}
disabled={injectionStatus === "checking"}
allChecksPassed={allChecksPassed}
/>
{/* Step 6: RED Template */}
<WorkflowStep
number={6}
title="RED Template"
description="Generate upload metadata"
status={
skippedSteps[6]
? "skipped"
: generatingTemplate
? generatedFiles.json
? "reloading"
: "loading"
: generatedFiles.json
? "completed"
: "pending"
}
isActive={
(sanitizeStatus === "success" || skippedSteps[1]) &&
(torrentStatus === "success" || skippedSteps[2]) &&
(copyStatus === "success" || skippedSteps[3]) &&
(!!coverUrl || skippedSteps[4]) &&
(injectionStatus === "success" ||
injectionStatus === "resumed" ||
skippedSteps[5]) &&
!generatedFiles.json &&
!skippedSteps[6]
}
onClick={async () => {
setGeneratingTemplate(true);
addLog("Generating RED upload template...");
try {
const res = await fetch("/api/red-template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
albumPath: album.path,
releaseType: releaseType,
}),
});
const result = await res.json();
if (result.response) {
if (coverUrl) {
if (!result.response.group) result.response.group = {};
result.response.group.wikiImage = coverUrl;
}
const jsonString = JSON.stringify(result, null, 4);
await navigator.clipboard.writeText(jsonString);
// Mocking the generated file state for the stepper visual
setGeneratedFiles((prev) => ({
...prev,
json: { name: "Template", url: "" },
}));
addLog("Template copied to clipboard!");
} else {
addLog(`Template generation failed: ${result.error}`);
}
} catch (e) {
addLog("Template request failed.");
} finally {
setGeneratingTemplate(false);
}
}}
onSkip={() => setSkippedSteps((prev) => ({ ...prev, 6: true }))}
icon={<FileJson className="w-4 h-4" />}
actionLabel="Copy Template"
allChecksPassed={allChecksPassed}
allowCompletedClick={true}
/>
{/* Info Message */}
{(generatedFiles.json || skippedSteps[6]) &&
injectionStatus !== "resumed" && (
<div className="ml-0 p-3 rounded-md bg-blue-400/20 text-white text-xs mb-4 -mt-4 backdrop-blur-sm">
<p className="font-semibold mb-1">Ready to Upload!</p>
<p>You can now finalise the upload on RED.</p>
<p>Return here and resume when ready.</p>
</div>
)}
{/* Step 7: Resume */}
<WorkflowStep
number={7}
title="Resume Torrent"
description="Start seeding"
status={
skippedSteps[7]
? "skipped"
: injectionStatus === "resumed"
? "completed"
: resuming
? "loading"
: "pending"
}
isActive={
(sanitizeStatus === "success" || skippedSteps[1]) &&
(torrentStatus === "success" || skippedSteps[2]) &&
(copyStatus === "success" || skippedSteps[3]) &&
(!!coverUrl || skippedSteps[4]) &&
(injectionStatus === "success" ||
injectionStatus === "resumed" ||
skippedSteps[5]) &&
(!!generatedFiles.json || skippedSteps[6]) &&
injectionStatus !== "resumed" &&
!skippedSteps[7]
}
onClick={handleResume}
onSkip={() => setSkippedSteps((prev) => ({ ...prev, 7: true }))}
icon={<Play className="w-4 h-4" />}
actionLabel="Resume Torrent"
disabled={
resuming ||
(injectionStatus !== "success" && !skippedSteps[5])
}
allChecksPassed={allChecksPassed}
/>
</div>
</div>
<HoldButton
onComplete={async () => {
setDeleting(true);
addLog("Deleting album...");
try {
const res = await fetch("/api/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
albumPath: album.path,
upc: data.metadata.upc,
}),
});
const result = await res.json();
if (result.success) {
addLog("Album deleted successfully.");
// Trigger a rescan to update the list
await fetch("/api/scan", { method: "POST" });
// Redirect to home
window.location.href = "/";
} else {
addLog(`Delete failed: ${result.error}`);
setDeleting(false);
}
} catch (e) {
addLog("Delete request failed.");
setDeleting(false);
}
}}
disabled={deleting}
className="w-full justify-start h-10 text-sm transition-all bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 relative overflow-hidden"
>
{deleting ? (
<RefreshCw className="w-4 h-4 mr-3 animate-spin relative z-10" />
) : (
<Trash2 className="w-4 h-4 mr-3 relative z-10" />
)}
<span className="relative z-10">
{deleting ? "Deleting..." : "Hold to Delete"}
</span>
</HoldButton>
</div>
</div>
{previewUrl &&
createPortal(
<div
className="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center p-8 backdrop-blur-sm"
onClick={() => setPreviewUrl(null)}
>
<div
className="relative max-w-full max-h-full bg-background/10 rounded-lg overflow-hidden shadow-2xl border border-white/10"
onClick={(e) => e.stopPropagation()}
>
<img
src={previewUrl!}
alt="Spectrogram"
className="max-w-full max-h-[90vh] object-contain"
/>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 text-white/70 hover:text-white bg-black/40 hover:bg-black/60 rounded-full"
onClick={() => setPreviewUrl(null)}
>
<X className="w-5 h-5" />
</Button>
</div>
</div>,
document.body,
)}
{showAllSpectrograms &&
createPortal(
<div
className="fixed inset-0 z-[100] bg-black/95 flex flex-col p-4 md:p-8 backdrop-blur-md overflow-hidden animate-in fade-in duration-200"
onClick={() => setShowAllSpectrograms(false)}
>
<div
className="max-w-5xl w-full mx-auto flex flex-col h-full bg-zinc-900/50 rounded-2xl border border-white/10 shadow-2xl overflow-hidden ring-1 ring-white/5"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex justify-between items-center p-6 border-b border-white/5 bg-black/40 shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center border border-blue-500/20">
<Waves className="w-5 h-5 text-blue-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">
Album Spectrograms
</h2>
<p className="text-sm text-white/50">
{tracks.length} tracks generated
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-white/50 hover:text-white hover:bg-white/10 rounded-full transition-colors"
onClick={() => setShowAllSpectrograms(false)}
>
<X className="w-6 h-6" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-8 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/10 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-white/20">
{spectrogramsMemo}
{Object.keys(spectrograms).length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-white/30 gap-4">
<Waves className="w-12 h-12 opacity-50" />
<p>No spectrograms generated yet.</p>
</div>
)}
</div>
</div>
</div>,
document.body,
)}
<RenameDialog
open={showRenameDialog}
onOpenChange={setShowRenameDialog}
albumPath={album.path}
onRenameComplete={() => {
addLog("Renamed files successfully.");
analyzeAlbum(); // Re-analyze to update UI
}}
/>
</div>
);
}
interface WorkflowStepProps {
number: number;
title: string;
description: string;
status:
| "skipped"
| "completed"
| "error"
| "loading"
| "pending"
| "reloading";
isActive: boolean;
onClick: () => void;
onSkip?: () => void;
icon: React.ReactNode;
actionLabel: string;
disabled?: boolean;
allChecksPassed?: boolean;
allowCompletedClick?: boolean;
}
function WorkflowStep({
number,
title,
description,
status,
isActive,
onClick,
onSkip,
icon,
actionLabel,
disabled,
allChecksPassed,
allowCompletedClick,
}: WorkflowStepProps) {
return (
<div
className={`relative flex gap-4 pb-8 last:pb-0 ${isActive ? "opacity-100" : "opacity-50 transition-opacity"}`}
>
{/* Circle */}
<div
className={`relative z-10 flex items-center justify-center w-8 h-8 rounded-full border-2 transition-colors shrink-0 ${
status === "completed" || status === "reloading"
? "bg-green-500 border-green-500 text-black"
: status === "skipped"
? "bg-yellow-500 border-yellow-500 text-black"
: status === "error"
? "bg-red-500 border-red-500 text-white"
: isActive
? "bg-primary border-primary text-primary-foreground"
: "bg-background border-white/20 text-muted-foreground"
}`}
>
{status === "completed" || status === "reloading" ? (
<Check className="w-4 h-4" />
) : status === "skipped" ? (
<FastForward className="w-4 h-4" />
) : status === "error" ? (
<AlertTriangle className="w-4 h-4" />
) : status === "loading" ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<span className="text-xs font-bold">{number}</span>
)}
</div>
{/* Content */}
<div className="flex-1 pt-1">
<div className="flex justify-between items-start mb-1">
<div>
<h4
className={`text-sm font-medium leading-none ${status === "completed" || status === "reloading" ? "text-green-400" : status === "skipped" ? "text-yellow-400" : "text-white"}`}
>
{title}
</h4>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
{isActive && onSkip && allChecksPassed && (
<Button
variant="ghost"
size="icon"
onClick={onSkip}
className="h-6 w-6 -mt-1 text-muted-foreground hover:text-yellow-400 hover:bg-yellow-500/10"
title="Skip this step"
>
<FastForward className="w-3.5 h-3.5" />
</Button>
)}
</div>
{(isActive ||
status === "loading" ||
status === "reloading" ||
status === "error" ||
(status === "completed" && allowCompletedClick)) && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
className="mt-3"
>
<Button
size="sm"
onClick={onClick}
disabled={
disabled ||
status === "loading" ||
status === "reloading" ||
(!allowCompletedClick && status === "completed") ||
status === "skipped"
}
className={`w-full justify-start h-8 text-xs disabled:opacity-100 ${status === "completed" || status === "reloading" ? "!bg-green-500/20 !text-green-400 !border-green-500/30 hover:!bg-green-500/30" : status === "skipped" ? "!bg-yellow-500/20 !text-yellow-400 !border-yellow-500/30 hover:!bg-yellow-500/30" : ""}`}
variant={
status === "completed" ||
status === "reloading" ||
status === "skipped"
? "outline"
: "default"
}
>
{status === "loading" || status === "reloading" ? (
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
) : (
icon
)}
<span className="ml-2">{actionLabel}</span>
</Button>
</motion.div>
)}
</div>
</div>
);
}
function ScrollableTrackName({
text,
isError,
}: {
text: string;
isError: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollDistance, setScrollDistance] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
if (containerRef.current && textRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const textWidth = textRef.current.offsetWidth;
if (textWidth > containerWidth) {
const distance = textWidth - containerWidth + 20; // Add some padding
setScrollDistance(distance);
setDuration(distance * 0.05); // Adjust speed (seconds per pixel)
} else {
setScrollDistance(0);
}
}
}, [text]);
return (
<div
ref={containerRef}
className="overflow-hidden relative h-5 flex items-center"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<motion.span
ref={textRef}
className={`whitespace-nowrap ${isError ? "text-red-400" : "group-hover:text-primary transition-colors"}`}
animate={
isHovered && scrollDistance > 0 ? { x: -scrollDistance } : { x: 0 }
}
transition={{
type: "spring",
stiffness: 100,
damping: 100,
repeatType: "mirror",
repeatDelay: 1,
}}
style={{ x: 0 }}
>
{text}
</motion.span>
</div>
);
}
function HoldButton({ onComplete, disabled, className, children }: any) {
const [holding, setHolding] = useState(false);
const [progress, setProgress] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const startHold = () => {
if (disabled) return;
setHolding(true);
setProgress(0);
const startTime = Date.now();
const envDuration = process.env.NEXT_PUBLIC_DELETE_HOLD_TIME_MS
? parseInt(process.env.NEXT_PUBLIC_DELETE_HOLD_TIME_MS)
: 1500;
const duration = isNaN(envDuration) ? 1500 : envDuration;
intervalRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
const newProgress = Math.min((elapsed / duration) * 100, 100);
setProgress(newProgress);
if (newProgress >= 100) {
if (intervalRef.current) clearInterval(intervalRef.current);
setHolding(false);
setProgress(0);
onComplete();
}
}, 16);
};
const stopHold = () => {
if (intervalRef.current) clearInterval(intervalRef.current);
setHolding(false);
setProgress(0);
};
return (
<Button
onMouseDown={startHold}
onMouseUp={stopHold}
onMouseLeave={stopHold}
onTouchStart={startHold}
onTouchEnd={stopHold}
disabled={disabled}
className={className}
variant="ghost"
>
<div
className="absolute inset-0 bg-red-500/30 transition-all duration-75 ease-linear"
style={{ width: `${progress}%` }}
/>
{children}
</Button>
);
}
function formatBytes(bytes: number, decimals = 2) {
if (!+bytes) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
// Start from MB (index 1) as audio albums are rarely just bytes or KB in these contexts,
// but generic logic is fine. FLAC albums are usually MB or GB.
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}