- Disable 'Sync S3' buttons when no S3 config present; add hint - Remember active config tab via localStorage; enable isLazy Tabs - Lazy-load Music Library data only when its tab is opened - Clarify 'Auto-Link' as a cleanup action; disable when nothing to link - Limit matching tab API calls with ?limit to reduce payloads
631 lines
22 KiB
TypeScript
631 lines
22 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, FiMusic, FiCheck, FiX } 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[]>([]);
|
|
const [matchedMusicFiles, setMatchedMusicFiles] = useState<any[]>([]);
|
|
const [songsWithMusicFiles, setSongsWithMusicFiles] = useState<any[]>([]);
|
|
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, matchedRes, songsWithRes] = await Promise.all([
|
|
fetch('/api/matching/stats'),
|
|
fetch('/api/matching/unmatched-music-files?limit=200'),
|
|
fetch('/api/matching/matched-music-files?limit=200'),
|
|
fetch('/api/matching/songs-with-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);
|
|
}
|
|
|
|
if (matchedRes.ok) {
|
|
const matchedData = await matchedRes.json();
|
|
setMatchedMusicFiles(matchedData.musicFiles);
|
|
}
|
|
|
|
if (songsWithRes.ok) {
|
|
const songsData = await songsWithRes.json();
|
|
setSongsWithMusicFiles(songsData.songs);
|
|
}
|
|
} 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 result = await response.json();
|
|
toast({
|
|
title: 'Auto-linking Complete',
|
|
description: `Linked ${result.result.linked} files, ${result.result.unmatched} unmatched`,
|
|
status: 'success',
|
|
duration: 5000,
|
|
isClosable: true,
|
|
});
|
|
loadData(); // Refresh data
|
|
} else {
|
|
throw new Error('Auto-linking failed');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error during auto-linking:', error);
|
|
toast({
|
|
title: 'Error',
|
|
description: '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,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUnlinkMusicFile = async (songId: string) => {
|
|
try {
|
|
const response = await fetch(`/api/matching/unlink/${songId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast({
|
|
title: 'Success',
|
|
description: 'Music file unlinked from song successfully',
|
|
status: 'success',
|
|
duration: 3000,
|
|
isClosable: true,
|
|
});
|
|
loadData(); // Refresh data
|
|
} else {
|
|
throw new Error('Failed to unlink music file');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error unlinking music file:', error);
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to unlink music file from song',
|
|
status: 'error',
|
|
duration: 3000,
|
|
isClosable: true,
|
|
});
|
|
}
|
|
};
|
|
|
|
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>
|
|
|
|
{/* Matched Music Files */}
|
|
<Card bg="gray.800" borderColor="gray.700" borderWidth="1px">
|
|
<CardHeader>
|
|
<Heading size="md" color="white">Matched Music Files ({matchedMusicFiles.length})</Heading>
|
|
</CardHeader>
|
|
<CardBody>
|
|
{matchedMusicFiles.length === 0 ? (
|
|
<Text color="gray.500" textAlign="center">
|
|
No music files are matched yet.
|
|
</Text>
|
|
) : (
|
|
<VStack spacing={3} align="stretch">
|
|
{matchedMusicFiles.slice(0, 10).map((file) => (
|
|
<Box
|
|
key={file._id}
|
|
p={3}
|
|
border="1px"
|
|
borderColor="green.700"
|
|
borderRadius="md"
|
|
bg="green.900"
|
|
>
|
|
<HStack justify="space-between">
|
|
<VStack align="start" spacing={1} flex={1}>
|
|
<HStack spacing={2}>
|
|
<Text fontWeight="bold" fontSize="sm" color="white">
|
|
{file.title || file.originalName}
|
|
</Text>
|
|
<Badge colorScheme="green" size="sm" bg="green.800" color="green.200">
|
|
<FiCheck style={{ marginRight: '4px' }} />
|
|
Matched
|
|
</Badge>
|
|
</HStack>
|
|
<Text fontSize="xs" color="gray.300">
|
|
{file.artist}
|
|
</Text>
|
|
<Text fontSize="xs" color="gray.400">
|
|
{file.album}
|
|
</Text>
|
|
</VStack>
|
|
<HStack spacing={2}>
|
|
<Tooltip label="Unlink music file">
|
|
<IconButton
|
|
aria-label="Unlink"
|
|
icon={<FiX />}
|
|
size="sm"
|
|
variant="ghost"
|
|
colorScheme="red"
|
|
onClick={() => handleUnlinkMusicFile(file.songId)}
|
|
_hover={{ bg: "red.900" }}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip label="Play music file">
|
|
<IconButton
|
|
aria-label="Play"
|
|
icon={<FiPlay />}
|
|
size="sm"
|
|
variant="ghost"
|
|
colorScheme="blue"
|
|
_hover={{ bg: "blue.900" }}
|
|
/>
|
|
</Tooltip>
|
|
</HStack>
|
|
</HStack>
|
|
</Box>
|
|
))}
|
|
{matchedMusicFiles.length > 10 && (
|
|
<Text fontSize="sm" color="gray.500" textAlign="center">
|
|
Showing first 10 of {matchedMusicFiles.length} matched files
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* Songs with Music Files */}
|
|
<Card bg="gray.800" borderColor="gray.700" borderWidth="1px">
|
|
<CardHeader>
|
|
<Heading size="md" color="white">Songs with Music Files ({songsWithMusicFiles.length})</Heading>
|
|
</CardHeader>
|
|
<CardBody>
|
|
{songsWithMusicFiles.length === 0 ? (
|
|
<Text color="gray.500" textAlign="center">
|
|
No songs have music files linked yet.
|
|
</Text>
|
|
) : (
|
|
<VStack spacing={3} align="stretch">
|
|
{songsWithMusicFiles.slice(0, 10).map((song) => (
|
|
<Box
|
|
key={song._id}
|
|
p={3}
|
|
border="1px"
|
|
borderColor="blue.700"
|
|
borderRadius="md"
|
|
bg="blue.900"
|
|
>
|
|
<HStack justify="space-between">
|
|
<VStack align="start" spacing={1} flex={1}>
|
|
<HStack spacing={2}>
|
|
<Text fontWeight="bold" fontSize="sm" color="white">
|
|
{song.title}
|
|
</Text>
|
|
<Badge colorScheme="blue" size="sm" bg="blue.800" color="blue.200">
|
|
<FiMusic style={{ marginRight: '4px' }} />
|
|
Has S3 File
|
|
</Badge>
|
|
</HStack>
|
|
<Text fontSize="xs" color="gray.300">
|
|
{song.artist}
|
|
</Text>
|
|
{song.location && (
|
|
<Text fontSize="xs" color="gray.400">
|
|
📁 {song.location}
|
|
</Text>
|
|
)}
|
|
{song.s3File?.streamingUrl && (
|
|
<Text fontSize="xs" color="green.400">
|
|
🎵 S3: {song.s3File.s3Key}
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
<HStack spacing={2}>
|
|
<Tooltip label="Unlink music file">
|
|
<IconButton
|
|
aria-label="Unlink"
|
|
icon={<FiX />}
|
|
size="sm"
|
|
variant="ghost"
|
|
colorScheme="red"
|
|
onClick={() => handleUnlinkMusicFile(song._id)}
|
|
_hover={{ bg: "red.900" }}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip label="Play music file">
|
|
<IconButton
|
|
aria-label="Play"
|
|
icon={<FiPlay />}
|
|
size="sm"
|
|
variant="ghost"
|
|
colorScheme="blue"
|
|
_hover={{ bg: "blue.800" }}
|
|
/>
|
|
</Tooltip>
|
|
</HStack>
|
|
</HStack>
|
|
</Box>
|
|
))}
|
|
{songsWithMusicFiles.length > 10 && (
|
|
<Text fontSize="sm" color="gray.500" textAlign="center">
|
|
Showing first 10 of {songsWithMusicFiles.length} songs with music files
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 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>
|
|
);
|
|
};
|