- 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.
331 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|