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(null); const [error, setError] = useState(null); const [copied, setCopied] = useState(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(null); const [spectrograms, setSpectrograms] = useState< Record >({}); const [previewUrl, setPreviewUrl] = useState(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>({}); const [injecting, setInjecting] = useState(false); const [injectionStatus, setInjectionStatus] = useState< "idle" | "injecting" | "checking" | "success" | "error" | "resumed" >("idle"); const [injectionHash, setInjectionHash] = useState(null); const [recheckProgress, setRecheckProgress] = useState(0); const [resuming, setResuming] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [releaseType, setReleaseType] = useState(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(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(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(null); const preservedReleaseType = useRef(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([]); 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 ? ( <>
{album.name}
) : null; }, [album.coverPath, album.name]); const discGroups = useMemo(() => { if (!data?.tracks) return []; const map = new Map(); data.tracks.forEach((track: AnalysisTrack, i: number) => { let discNo = 1; if (!("error" in track)) { if (track.common?.disk?.no) { discNo = track.common.disk.no; } else { const match = track.filename.match(/^(\d+)-(\d+)\b/); if (match) discNo = parseInt(match[1], 10); } } if (!map.has(discNo)) { map.set(discNo, []); } map.get(discNo)!.push({ track, originalIndex: i }); }); const sortedDiscs = Array.from(map.keys()).sort((a, b) => a - b); return sortedDiscs.map((discNo) => ({ discNo, items: map.get(discNo)!, })); }, [data?.tracks]); const isMultiDisc = useMemo(() => { return ( discGroups.length > 1 || (discGroups.length === 1 && discGroups[0].discNo > 1) ); }, [discGroups]); 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; let discNo = 1; let trackNo = 0; if (!("error" in track)) { discNo = track.common?.disk?.no || 1; trackNo = track.common?.track?.no || 0; if (!track.common?.disk?.no) { const match = track.filename.match(/^(\d+)-(\d+)\b/); if (match) { discNo = parseInt(match[1], 10); if (!track.common?.track?.no) trackNo = parseInt(match[2], 10); } } } const trackNumStr = isMultiDisc ? `${discNo}-${trackNo.toString().padStart(2, "0")}` : (trackNo || "").toString().padStart(2, "0"); return (
{trackNumStr} {(!("error" in track) && track.common?.title) || track.filename}
{!("error" in track) && (
{track.format?.bitsPerSample}bit /{" "} {track.format?.sampleRate ? track.format.sampleRate / 1000 : "?"} kHz
)}
{`Spectrogram
); }); }, [data?.tracks, spectrograms, isMultiDisc]); if (loading) { return (
); } if (error) { return (

{error}

); } if (!data) return null; const { metadata, tracks, quality } = data; let currentDiscText: number | null = null; const tracklistTextLines: string[] = []; tracks.forEach((t) => { if ("error" in t) { tracklistTextLines.push(`${t.filename} (Error)`); return; } let discNo = t.common?.disk?.no || 1; let trackNo = (t.common?.track?.no || 0).toString().padStart(2, "0"); if (!t.common?.disk?.no) { const match = t.filename.match(/^(\d+)-(\d+)\b/); if (match) { discNo = parseInt(match[1], 10); if (!t.common?.track?.no) trackNo = match[2].padStart(2, "0"); } } if (isMultiDisc && discNo !== currentDiscText) { if (currentDiscText !== null) tracklistTextLines.push(""); tracklistTextLines.push(`Disc ${discNo}`); currentDiscText = discNo; } const trackNumStr = isMultiDisc ? `${discNo}-${trackNo}` : trackNo; tracklistTextLines.push(`${trackNumStr} - ${t.common?.title || t.filename}`); }); const tracklistText = tracklistTextLines.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 (
{/* Header Section */}
{backgroundCoverMemo}
{/* Cover Art */}
{album.coverPath ? ( {album.name} ) : (
)}
{/* Glow effect behind cover */}
{/* Info */}
{metadata.album} {metadata.artist}
{/* Metadata Grid */} {quality.status} {quality.mixed && ( MIXED FORMATS )} {metadata.year &&
} {metadata.year && ( copyToClipboard(String(metadata.year), "Year")} title="Copy Year" > {metadata.year} )} {metadata.totalSize && ( <>
copyToClipboard( formatBytes(metadata.totalSize), "Total Size", ) } title="Copy Total Size" > {formatBytes(metadata.totalSize)} )} {metadata.label && ( <>
copyToClipboard(metadata.label, "Label")} title="Copy Label" > {metadata.label} )} {metadata.upc && ( <>
copyToClipboard(metadata.upc || "", "UPC")} title="Copy UPC" > UPC: {metadata.upc} )} {/* Additional Artists */} {metadata.additionalArtists && ( Feat {metadata.additionalArtists.map((artist: string, i: number) => ( copyToClipboard(artist, "Artist Name")} > {artist} ))} )} {/* Release Type & Links */}
{isGuessedType && ( Auto-detected )} {metadata.tidalUrl && ( )}
{/* Tracklist Column */}
{quality.mixed && ( Quality Mismatch Different bit depths/sample rates detected. )} {hasLongFilenames && ( Filename Too Long Some filenames exceed 179 characters. Torrent creation is disabled. )}

Tracklist

{discGroups.map((group) => (
{isMultiDisc && (
Disc {group.discNo} ({group.items.length} {group.items.length === 1 ? "track" : "tracks"})
)} {group.items.map(({ track, originalIndex: i }) => { const isError = "error" in track; let discNo = group.discNo; let trackNo = 0; if (!isError) { discNo = track.common?.disk?.no || group.discNo; trackNo = track.common?.track?.no || 0; if (!track.common?.disk?.no) { const match = track.filename.match(/^(\d+)-(\d+)\b/); if (match) discNo = parseInt(match[1], 10); } } const trackNumDisplay = isError ? "!!" : isMultiDisc ? `${discNo}-${(trackNo > 0 ? trackNo : i + 1) .toString() .padStart(2, "0")}` : (trackNo > 0 ? trackNo : i + 1) .toString() .padStart(2, "0"); return ( handleTrackMouseDown(e, track.filename) } onMouseMove={(e) => handleTrackMouseMove(e, track.filename) } onMouseUp={(e) => handleTrackMouseUp(e, track.filename)} onMouseLeave={handleTrackMouseLeave} > {/* Progress Bar */} {playingTrack === track.filename && (
)}
{trackNumDisplay}
{track.filename}
{!isError && (
{track.format?.bitsPerSample}bit/ {track.format?.sampleRate ? track.format.sampleRate / 1000 : "?"} kHz {process.env.NEXT_PUBLIC_SHOW_TRACK_BITRATE === "true" && ( {Math.round( (track.format?.bitrate || 0) / 1000, )} kbps )}
)} {/* Spectrogram Button */}
); })}
))}
{/* Actions Column */}
{/* Quality Checks - Now Pre-flight */}
{!allChecksPassed && (
)}

Pre-flight Checks

{!allChecksPassed && ( )}
{mqaStatus === "success" && (
)} {upconvStatus === "success" && (
)} {specsPassed && specsExist && (
)}
{has24Bit && ( )}
{/* Release Workflow Stepper */}

Release Workflow

{(!( sanitizeStatus !== "idle" || torrentStatus !== "idle" || copyStatus !== "idle" || uploadingCover || !!coverUrl || injectionStatus !== "idle" || Object.keys(skippedSteps).length > 0 ) || autoModeStarted) && ( )}
{/* Vertical Line */}
{/* Step 1: Sanitize */} setSkippedSteps((prev) => ({ ...prev, 1: true }))} icon={} actionLabel="Sanitize" disabled={!allChecksPassed} allChecksPassed={allChecksPassed} /> {/* Step 2: Create Torrent */} setSkippedSteps((prev) => ({ ...prev, 2: true }))} icon={} actionLabel="Create Torrent" disabled={hasLongFilenames} allChecksPassed={allChecksPassed} /> {/* Step 3: Copy/Finalize */} setSkippedSteps((prev) => ({ ...prev, 3: true }))} icon={} actionLabel="Copy to Output" allChecksPassed={allChecksPassed} /> {/* Step 4: Cover Art */} setSkippedSteps((prev) => ({ ...prev, 4: true }))} icon={} actionLabel="Upload Cover" disabled={!album.coverPath} allChecksPassed={allChecksPassed} /> {/* Step 5: Inject */} setSkippedSteps((prev) => ({ ...prev, 5: true }))} icon={} actionLabel={ injectionStatus === "checking" ? `Checking ${recheckProgress}%` : "Inject" } disabled={injectionStatus === "checking"} allChecksPassed={allChecksPassed} /> {/* Step 6: RED Template */} { 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={} actionLabel="Copy Template" allChecksPassed={allChecksPassed} allowCompletedClick={true} /> {/* Info Message */} {(generatedFiles.json || skippedSteps[6]) && injectionStatus !== "resumed" && (

Ready to Upload!

You can now finalise the upload on RED.

Return here and resume when ready.

)} {/* Step 7: Resume */} setSkippedSteps((prev) => ({ ...prev, 7: true }))} icon={} actionLabel="Resume Torrent" disabled={ resuming || (injectionStatus !== "success" && !skippedSteps[5]) } allChecksPassed={allChecksPassed} />
{ setDeleting(true); addLog("Deleting album..."); if (audioRef.current) { audioRef.current.pause(); audioRef.current.removeAttribute("src"); audioRef.current.load(); } setPlayingTrack(null); setIsPlaying(false); await new Promise((resolve) => setTimeout(resolve, 100)); 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 ? ( ) : ( )} {deleting ? "Deleting..." : "Hold to Delete"}
{previewUrl && createPortal(
setPreviewUrl(null)} >
e.stopPropagation()} > Spectrogram
, document.body, )} {showAllSpectrograms && createPortal(
setShowAllSpectrograms(false)} >
e.stopPropagation()} > {/* Header */}

Album Spectrograms

{tracks.length} tracks generated

{/* Content */}
{spectrogramsMemo} {Object.keys(spectrograms).length === 0 && (

No spectrograms generated yet.

)}
, document.body, )} { addLog("Renamed files successfully."); analyzeAlbum(); // Re-analyze to update UI }} />
); } 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 (
{/* Circle */}
{status === "completed" || status === "reloading" ? ( ) : status === "skipped" ? ( ) : status === "error" ? ( ) : status === "loading" ? ( ) : ( {number} )}
{/* Content */}

{title}

{description}

{isActive && onSkip && allChecksPassed && ( )}
{(isActive || status === "loading" || status === "reloading" || status === "error" || (status === "completed" && allowCompletedClick)) && ( )}
); } function ScrollableTrackName({ text, isError, }: { text: string; isError: boolean; }) { const [isHovered, setIsHovered] = useState(false); const containerRef = useRef(null); const textRef = useRef(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 (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > 0 ? { x: -scrollDistance } : { x: 0 } } transition={{ type: "spring", stiffness: 100, damping: 100, repeatType: "mirror", repeatDelay: 1, }} style={{ x: 0 }} > {text}
); } function HoldButton({ onComplete, disabled, className, children }: any) { const [holding, setHolding] = useState(false); const [progress, setProgress] = useState(0); const intervalRef = useRef(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 ( ); } 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]}`; }