Geert Rademakes 4a7d9c178a feat: Add intelligent song matching system
- Add SongMatchingService with multi-criteria matching algorithms
- Add matching API routes for auto-linking and manual matching
- Add SongMatching component with statistics and suggestion modal
- Update SongList to show music file availability and play buttons
- Update MusicStorage page with song matching tab
- Enhance Song interface with music file integration
- Add comprehensive matching statistics and reporting

Features:
- Filename, title, artist, album, and duration matching
- Fuzzy matching with Levenshtein distance
- Confidence scoring and match type classification
- Auto-linking with configurable thresholds
- Manual matching with detailed suggestions
- Visual indicators for music file availability
- Integration with existing playlist functionality

Matching algorithms prioritize:
1. Exact filename matches
2. Artist-Title pattern matching
3. Metadata-based fuzzy matching
4. Duration-based validation

The system provides a complete workflow from upload to playback,
automatically linking music files to Rekordbox songs with manual
override capabilities for unmatched files.
2025-08-06 13:55:18 +02:00

331 lines
11 KiB
TypeScript

import React, { ChangeEvent } from 'react';
import {
Box,
Flex,
Text,
Button,
IconButton,
HStack,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
Checkbox,
Badge,
Tooltip
} from '@chakra-ui/react';
import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
import { FiPlay, FiMusic } from 'react-icons/fi';
import type { Song, PlaylistNode } from "../types/interfaces";
import { useState, useCallback, useMemo } from "react";
import { formatDuration, formatTotalDuration } from '../utils/formatters';
interface SongListProps {
songs: Song[];
onAddToPlaylist: (songIds: string[], playlistName: string) => void;
onRemoveFromPlaylist?: (songIds: string[]) => void;
playlists: PlaylistNode[];
onSongSelect: (song: Song) => void;
selectedSongId: string | null;
currentPlaylist: string | null;
depth?: number;
onPlaySong?: (song: Song) => void;
}
export const SongList: React.FC<SongListProps> = ({
songs,
onAddToPlaylist,
onRemoveFromPlaylist,
playlists,
onSongSelect,
selectedSongId,
currentPlaylist,
depth = 0,
onPlaySong
}) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
// Helper function to get all playlists (excluding folders) from the playlist tree
const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => {
let result: PlaylistNode[] = [];
for (const node of nodes) {
if (node.type === 'playlist') {
result.push(node);
} else if (node.type === 'folder' && node.children) {
result = result.concat(getAllPlaylists(node.children));
}
}
return result;
}, []);
// Get flattened list of all playlists
const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]);
const filteredSongs = useMemo(() => {
if (!searchQuery) return songs;
const query = searchQuery.toLowerCase();
return songs.filter(
song =>
song.title.toLowerCase().includes(query) ||
song.artist.toLowerCase().includes(query)
);
}, [songs, searchQuery]);
const toggleSelection = useCallback((songId: string) => {
setSelectedSongs(prev => {
const newSelection = new Set(prev);
if (newSelection.has(songId)) {
newSelection.delete(songId);
} else {
newSelection.add(songId);
}
return newSelection;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedSongs(prev =>
prev.size === songs.length ? new Set() : new Set(songs.map(s => s.id))
);
}, [songs]);
const handleBulkAddToPlaylist = (playlistName: string) => {
if (selectedSongs.size > 0) {
onAddToPlaylist(Array.from(selectedSongs), playlistName);
setSelectedSongs(new Set()); // Clear selection after action
}
};
const handlePlaySong = (song: Song, e: React.MouseEvent) => {
e.stopPropagation();
if (onPlaySong && song.hasMusicFile) {
onPlaySong(song);
}
};
// Calculate total duration
const totalDuration = useMemo(() => {
return filteredSongs.reduce((total, song) => {
if (!song.totalTime) return total;
// Convert to seconds, handling milliseconds if present
const seconds = Math.floor(Number(song.totalTime) / (song.totalTime.length > 4 ? 1000 : 1));
return total + seconds;
}, 0);
}, [filteredSongs]);
// Count songs with music files
const songsWithMusicFiles = useMemo(() => {
return filteredSongs.filter(song => song.hasMusicFile).length;
}, [filteredSongs]);
return (
<Flex direction="column" height="100%">
{/* Sticky Header */}
<Box
position="sticky"
top={0}
bg="gray.900"
zIndex={1}
pb={4}
>
{/* Search Bar */}
<InputGroup mb={4}>
<InputLeftElement pointerEvents="none">
<Search2Icon color="gray.300" />
</InputLeftElement>
<Input
placeholder="Search songs by title or artist..."
value={searchQuery}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
bg="gray.800"
borderColor="gray.600"
_hover={{ borderColor: "gray.500" }}
_focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }}
/>
</InputGroup>
{/* Bulk Actions Toolbar */}
<Flex justify="space-between" align="center" p={2} bg="gray.800" borderRadius="md">
<HStack spacing={4}>
<Checkbox
isChecked={selectedSongs.size === filteredSongs.length}
isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < filteredSongs.length}
onChange={toggleSelectAll}
colorScheme="blue"
sx={{
'& > span:first-of-type': {
opacity: 1,
border: '2px solid',
borderColor: 'gray.500'
}
}}
>
{selectedSongs.size === 0
? "Select All"
: `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`}
</Checkbox>
<Text color="gray.400" fontSize="sm">
{filteredSongs.length} song{filteredSongs.length === 1 ? '' : 's'} {formatTotalDuration(totalDuration)}
{songsWithMusicFiles > 0 && (
<Badge ml={2} colorScheme="green" variant="subtle">
{songsWithMusicFiles} with music files
</Badge>
)}
</Text>
</HStack>
{selectedSongs.size > 0 && (
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
size="sm"
colorScheme="blue"
>
Actions
</MenuButton>
<MenuList>
{allPlaylists.map((playlist) => (
<MenuItem
key={playlist.id}
onClick={() => {
handleBulkAddToPlaylist(playlist.name);
}}
>
Add to {playlist.name}
</MenuItem>
))}
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
<MenuItem
color="red.300"
onClick={() => {
onRemoveFromPlaylist(Array.from(selectedSongs));
setSelectedSongs(new Set());
}}
>
Remove from {currentPlaylist}
</MenuItem>
</>
)}
</MenuList>
</Menu>
)}
</Flex>
</Box>
{/* Scrollable Song List */}
<Box flex={1} overflowY="auto" mt={2}>
<Flex direction="column" gap={2}>
{filteredSongs.map((song) => (
<Flex
key={song.id}
alignItems="center"
p={2}
pl={depth > 0 ? 4 + (depth * 4) : 2}
borderBottom="1px"
borderColor="gray.700"
bg={selectedSongId === song.id ? "gray.700" : "transparent"}
_hover={{ bg: "gray.800", cursor: "pointer" }}
onClick={() => onSongSelect(song)}
>
<Checkbox
isChecked={selectedSongs.has(song.id)}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
toggleSelection(song.id);
}}
mr={4}
onClick={(e) => e.stopPropagation()}
size={depth > 0 ? "sm" : "md"}
/>
<Box flex="1">
<HStack spacing={2} align="center">
<Text
fontWeight="bold"
color={selectedSongId === song.id ? "white" : "gray.100"}
fontSize={depth > 0 ? "sm" : "md"}
>
{song.title}
</Text>
{song.hasMusicFile && (
<Tooltip label="Has music file available for playback">
<Badge colorScheme="green" size="sm" variant="subtle">
<FiMusic size={10} />
</Badge>
</Tooltip>
)}
</HStack>
<Text
fontSize={depth > 0 ? "xs" : "sm"}
color={selectedSongId === song.id ? "gray.300" : "gray.500"}
>
{song.artist} {formatDuration(song.totalTime)}
</Text>
</Box>
{/* Play Button */}
{song.hasMusicFile && onPlaySong && (
<Tooltip label="Play music file">
<IconButton
aria-label="Play song"
icon={<FiPlay />}
size={depth > 0 ? "xs" : "sm"}
variant="ghost"
colorScheme="blue"
onClick={(e) => handlePlaySong(song, e)}
mr={2}
/>
</Tooltip>
)}
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<ChevronDownIcon />}
variant="ghost"
onClick={(e) => e.stopPropagation()}
size={depth > 0 ? "xs" : "sm"}
ml="auto"
/>
<MenuList>
{allPlaylists.map((playlist) => (
<MenuItem
key={playlist.id}
onClick={(e) => {
e.stopPropagation();
onAddToPlaylist([song.id], playlist.name);
}}
>
Add to {playlist.name}
</MenuItem>
))}
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
<MenuItem
color="red.300"
onClick={(e) => {
e.stopPropagation();
onRemoveFromPlaylist([song.id]);
}}
>
Remove from {currentPlaylist}
</MenuItem>
</>
)}
</MenuList>
</Menu>
</Flex>
))}
</Flex>
</Box>
</Flex>
);
};