2459 lines
87 KiB
TypeScript
2459 lines
87 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 discGroups = useMemo(() => {
|
|
if (!data?.tracks) return [];
|
|
const map = new Map<number, { track: AnalysisTrack; originalIndex: number }[]>();
|
|
|
|
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 (
|
|
<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="h-6 rounded bg-white/10 text-xs flex items-center justify-center font-mono text-white/50 px-2">
|
|
{trackNumStr}
|
|
</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, isMultiDisc]);
|
|
|
|
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;
|
|
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 (
|
|
<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-3">
|
|
{discGroups.map((group) => (
|
|
<div key={group.discNo} className="space-y-0.5">
|
|
{isMultiDisc && (
|
|
<div className="flex items-center gap-2 pt-2 pb-1.5 px-2 text-xs font-semibold uppercase tracking-wider text-primary/80 border-b border-white/5 mb-1 select-none">
|
|
<Disc className="w-3.5 h-3.5 shrink-0 text-primary" />
|
|
<span>Disc {group.discNo}</span>
|
|
<span className="text-[10px] font-normal text-white/40 lowercase">
|
|
({group.items.length} {group.items.length === 1 ? "track" : "tracks"})
|
|
</span>
|
|
</div>
|
|
)}
|
|
{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 (
|
|
<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 text-right shrink-0 ${isMultiDisc ? "w-10" : "w-6"}`}>
|
|
{trackNumDisplay}
|
|
</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>
|
|
</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...");
|
|
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 ? (
|
|
<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]}`;
|
|
}
|