438 lines
14 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Heading,
Button,
Card,
CardBody,
CardHeader,
Badge,
Progress,
useToast,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure,
SimpleGrid,
Stat,
StatLabel,
StatNumber,
StatHelpText,
IconButton,
Tooltip,
Spinner,
} from '@chakra-ui/react';
import { FiPlay, FiLink, FiSearch, FiZap } from 'react-icons/fi';
interface MatchResult {
song: any;
musicFile: any;
confidence: number;
matchType: 'exact' | 'fuzzy' | 'partial' | 'none';
matchReason: string;
}
interface MatchingStats {
totalSongs: number;
totalMusicFiles: number;
matchedMusicFiles: number;
unmatchedMusicFiles: number;
songsWithoutMusicFiles: number;
songsWithMusicFiles: number;
matchRate: string;
}
export const SongMatching: React.FC = () => {
const [stats, setStats] = useState<MatchingStats | null>(null);
const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState<any[]>([]);
// Removed matched and songs-with-files lists to reduce load
const [isLoading, setIsLoading] = useState(false);
const [autoLinking, setAutoLinking] = useState(false);
const [selectedMusicFile, setSelectedMusicFile] = useState<any>(null);
const [suggestions, setSuggestions] = useState<MatchResult[]>([]);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
const [statsRes, unmatchedRes] = await Promise.all([
fetch('/api/matching/stats'),
fetch('/api/matching/unmatched-music-files?limit=200')
]);
if (statsRes.ok) {
const statsData = await statsRes.json();
setStats(statsData.stats);
}
if (unmatchedRes.ok) {
const unmatchedData = await unmatchedRes.json();
setUnmatchedMusicFiles(unmatchedData.musicFiles);
}
// Matched and songs-with-files lists are omitted
} catch (error) {
console.error('Error loading data:', error);
toast({
title: 'Error',
description: 'Failed to load matching data',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
const handleAutoLink = async () => {
setAutoLinking(true);
try {
const response = await fetch('/api/matching/auto-link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
minConfidence: 0.7,
enableFuzzyMatching: true,
enablePartialMatching: false
}),
});
if (!response.ok) {
const errText = await response.text();
throw new Error(errText || 'Auto-linking failed');
}
const result = await response.json();
toast({
title: 'Auto-linking Started',
description: `Job ${result.jobId} started.`,
status: 'info',
duration: 4000,
isClosable: true,
});
// Do not call loadData immediately; background job UI will refresh stats as it completes
} catch (error) {
console.error('Error during auto-linking:', error);
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to auto-link music files',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setAutoLinking(false);
}
};
const handleGetSuggestions = async (musicFile: any) => {
setSelectedMusicFile(musicFile);
setLoadingSuggestions(true);
try {
const response = await fetch(`/api/matching/music-file/${musicFile._id}/suggestions`);
if (response.ok) {
const data = await response.json();
setSuggestions(data.matches);
onOpen();
} else {
throw new Error('Failed to get suggestions');
}
} catch (error) {
console.error('Error getting suggestions:', error);
toast({
title: 'Error',
description: 'Failed to get matching suggestions',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoadingSuggestions(false);
}
};
const handleLinkMusicFile = async (musicFileId: string, songId: string) => {
try {
const response = await fetch(`/api/matching/link/${musicFileId}/${songId}`, {
method: 'POST',
});
if (response.ok) {
toast({
title: 'Success',
description: 'Music file linked to song successfully',
status: 'success',
duration: 3000,
isClosable: true,
});
onClose();
loadData(); // Refresh data
} else {
throw new Error('Failed to link music file');
}
} catch (error) {
console.error('Error linking music file:', error);
toast({
title: 'Error',
description: 'Failed to link music file to song',
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
// Unlink handler removed with matched/songs-with-files lists
const getConfidenceColor = (confidence: number) => {
if (confidence >= 0.9) return 'green';
if (confidence >= 0.7) return 'blue';
if (confidence >= 0.5) return 'yellow';
return 'red';
};
const canAutoLink = !!stats && stats.unmatchedMusicFiles > 0;
if (isLoading) {
return (
<Box p={6}>
<Progress size="xs" isIndeterminate />
<Text mt={4} textAlign="center">Loading matching data...</Text>
</Box>
);
}
return (
<VStack spacing={6} align="stretch">
{/* Statistics */}
{stats && (
<Card bg="gray.800" borderColor="gray.700" borderWidth="1px">
<CardHeader>
<Heading size="md" color="white">Matching Statistics</Heading>
</CardHeader>
<CardBody>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
<Stat>
<StatLabel color="gray.400">Total Songs</StatLabel>
<StatNumber color="white">{stats.totalSongs}</StatNumber>
</Stat>
<Stat>
<StatLabel color="gray.400">Music Files</StatLabel>
<StatNumber color="white">{stats.totalMusicFiles}</StatNumber>
</Stat>
<Stat>
<StatLabel color="gray.400">Match Rate</StatLabel>
<StatNumber color="green.400">{stats.matchRate}</StatNumber>
<StatHelpText color="gray.500">
{stats.matchedMusicFiles} of {stats.totalMusicFiles} files matched
</StatHelpText>
</Stat>
<Stat>
<StatLabel color="gray.400">Unmatched</StatLabel>
<StatNumber color="orange.400">{stats.unmatchedMusicFiles}</StatNumber>
<StatHelpText color="gray.500">
{stats.songsWithoutMusicFiles} songs without files
</StatHelpText>
</Stat>
</SimpleGrid>
</CardBody>
</Card>
)}
{/* Auto-Link Button */}
<Card bg="gray.800" borderColor="gray.700" borderWidth="1px">
<CardBody>
<VStack spacing={4}>
<Text color="gray.300" textAlign="center">
Try linking any remaining unmatched files. The main S3 sync already performs matching; use this for leftovers.
</Text>
<Button
leftIcon={<FiZap />}
colorScheme="blue"
size="lg"
onClick={handleAutoLink}
isLoading={autoLinking}
loadingText="Auto-Linking..."
_hover={{ bg: "blue.700" }}
isDisabled={autoLinking || !canAutoLink}
>
Auto-Link Remaining Files
</Button>
</VStack>
</CardBody>
</Card>
{/* Unmatched Music Files */}
<Card bg="gray.800" borderColor="gray.700" borderWidth="1px">
<CardHeader>
<Heading size="md" color="white">Unmatched Music Files ({unmatchedMusicFiles.length})</Heading>
</CardHeader>
<CardBody>
{unmatchedMusicFiles.length === 0 ? (
<Text color="gray.500" textAlign="center">
All music files are matched! 🎉
</Text>
) : (
<VStack spacing={3} align="stretch">
{unmatchedMusicFiles.slice(0, 10).map((file) => (
<Box
key={file._id}
p={3}
border="1px"
borderColor="gray.700"
borderRadius="md"
bg="gray.900"
>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<Text fontWeight="bold" fontSize="sm" color="white">
{file.title || file.originalName}
</Text>
<Text fontSize="xs" color="gray.400">
{file.artist}
</Text>
<Text fontSize="xs" color="gray.500">
{file.album}
</Text>
</VStack>
<HStack spacing={2}>
<Tooltip label="Get matching suggestions">
<IconButton
aria-label="Get suggestions"
icon={<FiSearch />}
size="sm"
variant="ghost"
colorScheme="blue"
onClick={() => handleGetSuggestions(file)}
_hover={{ bg: "blue.900" }}
/>
</Tooltip>
<Tooltip label="Play music file">
<IconButton
aria-label="Play"
icon={<FiPlay />}
size="sm"
variant="ghost"
colorScheme="green"
_hover={{ bg: "green.900" }}
/>
</Tooltip>
</HStack>
</HStack>
</Box>
))}
{unmatchedMusicFiles.length > 10 && (
<Text fontSize="sm" color="gray.500" textAlign="center">
Showing first 10 of {unmatchedMusicFiles.length} unmatched files
</Text>
)}
</VStack>
)}
</CardBody>
</Card>
{/* Removed Matched Music Files and Songs with Music Files lists to streamline UI */}
{/* Suggestions Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent bg="gray.800" borderColor="gray.700" borderWidth="1px">
<ModalHeader color="white">
Matching Suggestions for "{selectedMusicFile?.title || selectedMusicFile?.originalName}"
</ModalHeader>
<ModalCloseButton color="gray.400" />
<ModalBody>
{loadingSuggestions ? (
<VStack spacing={4}>
<Spinner size="lg" color="blue.400" />
<Text color="gray.400">Finding matching songs...</Text>
</VStack>
) : suggestions.length === 0 ? (
<Text color="gray.500" textAlign="center">
No matching songs found. You can manually link this file later.
</Text>
) : (
<VStack spacing={3} align="stretch">
{suggestions.map((suggestion, index) => (
<Box
key={index}
p={3}
border="1px"
borderColor="gray.700"
borderRadius="md"
bg="gray.900"
>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack spacing={2}>
<Text fontWeight="bold" fontSize="sm" color="white">
{suggestion.song.title}
</Text>
<Badge
colorScheme={getConfidenceColor(suggestion.confidence)}
size="sm"
bg={`${getConfidenceColor(suggestion.confidence)}.900`}
color={`${getConfidenceColor(suggestion.confidence)}.200`}
>
{Math.round(suggestion.confidence * 100)}%
</Badge>
</HStack>
<Text fontSize="xs" color="gray.400">
{suggestion.song.artist}
</Text>
{suggestion.song.location && (
<Text fontSize="xs" color="gray.500">
📁 {suggestion.song.location}
</Text>
)}
<Text fontSize="xs" color="blue.400">
{suggestion.matchReason}
</Text>
</VStack>
<Tooltip label="Link this song">
<IconButton
aria-label="Link"
icon={<FiLink />}
size="sm"
variant="ghost"
colorScheme="blue"
onClick={() => {
handleLinkMusicFile(selectedMusicFile._id, suggestion.song._id);
onClose();
}}
_hover={{ bg: "blue.900" }}
/>
</Tooltip>
</HStack>
</Box>
))}
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onClose} color="gray.400" _hover={{ bg: "gray.700" }}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
);
};