438 lines
14 KiB
TypeScript
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 storage 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>
|
|
);
|
|
};
|