229 lines
11 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { Box, VStack, Heading, Text, HStack, Badge, Select, Spinner, Table, Thead, Tr, Th, Tbody, Td, IconButton, Tooltip, Switch } from '@chakra-ui/react';
import { FiCheck, FiTrash2 } from 'react-icons/fi';
import { api } from '../services/api';
interface DuplicateItem {
songId: string;
title: string;
artist: string;
location?: string;
totalTime?: string;
averageBpm?: string;
bitRate?: string;
playlists: string[];
}
interface DuplicateGroup {
key: string;
normalizedTitle: string;
normalizedArtist: string;
count: number;
items: DuplicateItem[];
}
export const DuplicatesViewer: React.FC = () => {
const [groups, setGroups] = useState<DuplicateGroup[]>([]);
const [loading, setLoading] = useState(false);
const [minGroupSize, setMinGroupSize] = useState(2);
const [processingGroupKey, setProcessingGroupKey] = useState<string | null>(null);
const [onlyWithPlaylists, setOnlyWithPlaylists] = useState(false);
const loadDuplicates = async (minSize: number) => {
setLoading(true);
try {
const res = await api.getDuplicateSongs(minSize);
setGroups(res.groups || []);
} catch (e) {
setGroups([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDuplicates(minGroupSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minGroupSize]);
const filteredGroups = useMemo(() => {
if (!onlyWithPlaylists) return groups;
return groups.filter(g => g.items.some(it => (it.playlists || []).length > 0));
}, [groups, onlyWithPlaylists]);
return (
<VStack spacing={4} align="stretch">
<HStack justify="space-between">
<Heading size="md" color="white">Possible Duplicates</Heading>
<HStack>
<Text color="gray.400">Minimum group size</Text>
<Select
size="sm"
value={minGroupSize}
onChange={(e) => setMinGroupSize(parseInt(e.target.value, 10))}
bg="gray.700"
borderColor="gray.600"
color="white"
w="120px"
>
<option value={2}>2+</option>
<option value={3}>3+</option>
<option value={4}>4+</option>
</Select>
<HStack pl={4} spacing={2}>
<Text color="gray.400" fontSize="sm">Only with playlists</Text>
<Switch
size="sm"
isChecked={onlyWithPlaylists}
onChange={(e) => setOnlyWithPlaylists(e.target.checked)}
colorScheme="blue"
/>
</HStack>
</HStack>
</HStack>
{loading ? (
<HStack><Spinner size="sm" /><Text color="gray.400">Scanning duplicates</Text></HStack>
) : filteredGroups.length === 0 ? (
<Text color="gray.500">No duplicate groups found.</Text>
) : (
<VStack spacing={4} align="stretch">
{filteredGroups.map((group) => (
<Box key={group.key} p={4} bg="gray.800" borderRadius="md" borderWidth="1px" borderColor="gray.700">
<HStack justify="space-between" mb={2}>
<HStack>
<Badge colorScheme="blue" variant="subtle">{group.count} items</Badge>
<Text color="gray.300">{group.normalizedArtist} {group.normalizedTitle}</Text>
</HStack>
</HStack>
<Box overflowX="auto">
<Table size="sm" variant="simple" minW="1000px">
<Thead>
<Tr>
<Th color="gray.300">Keep</Th>
<Th color="gray.300">Title</Th>
<Th color="gray.300">Artist</Th>
<Th color="gray.300">Length</Th>
<Th color="gray.300">BPM</Th>
<Th color="gray.300">Bitrate</Th>
<Th color="gray.300">Playlists</Th>
<Th color="gray.300">Rekordbox Path</Th>
</Tr>
</Thead>
<Tbody>
{group.items.map((it) => (
<Tr key={it.songId}>
<Td>
<HStack>
<Tooltip label="Keep this song and merge playlists">
<IconButton
aria-label="Keep this song"
icon={<FiCheck />}
size="sm"
colorScheme="green"
variant="outline"
isLoading={processingGroupKey === group.key}
onClick={async () => {
setProcessingGroupKey(group.key);
try {
const merged = Array.from(new Set(group.items.flatMap(x => x.playlists || [])));
const allPlaylists = await api.getPlaylists();
const targetId = it.songId;
const idsToRemove = new Set(group.items.map(x => x.songId).filter(id => id !== targetId));
const updated = allPlaylists.map(p => {
if (p.type === 'playlist') {
const tracks = Array.from(new Set([...(p.tracks || []).map(id => idsToRemove.has(id) ? targetId : id)]));
if (merged.includes(p.name) && !tracks.includes(targetId)) {
tracks.push(targetId);
}
return { ...p, tracks };
}
if (p.type === 'folder' && p.children) {
const updateChildren = (nodes: any[]): any[] => nodes.map(node => {
if (node.type === 'playlist') {
const tracks = Array.from(new Set([...(node.tracks || []).map((id: string) => idsToRemove.has(id) ? targetId : id)]));
if (merged.includes(node.name) && !tracks.includes(targetId)) {
tracks.push(targetId);
}
return { ...node, tracks };
}
if (node.children) return { ...node, children: updateChildren(node.children) };
return node;
});
return { ...p, children: updateChildren(p.children) };
}
return p;
});
await api.savePlaylists(updated as any);
await loadDuplicates(minGroupSize);
} finally {
setProcessingGroupKey(null);
}
}}
/>
</Tooltip>
<Tooltip label="Delete other duplicates (optionally remove music files)">
<IconButton
aria-label="Delete duplicates"
icon={<FiTrash2 />}
size="sm"
colorScheme="red"
variant="outline"
isLoading={processingGroupKey === group.key}
onClick={async () => {
setProcessingGroupKey(group.key);
try {
const targetId = it.songId;
const others = group.items.map(x => x.songId).filter(id => id !== targetId);
// First merge playlists (safe), then delete redundant songs and optionally their music files
const allPlaylists = await api.getPlaylists();
const updated = allPlaylists.map(p => {
if (p.type === 'playlist') {
const tracks = Array.from(new Set([...(p.tracks || []).map(id => others.includes(id) ? targetId : id)]));
return { ...p, tracks };
}
if (p.type === 'folder' && p.children) {
const updateChildren = (nodes: any[]): any[] => nodes.map(node => {
if (node.type === 'playlist') {
const tracks = Array.from(new Set([...(node.tracks || []).map((id: string) => others.includes(id) ? targetId : id)]));
return { ...node, tracks };
}
if (node.children) return { ...node, children: updateChildren(node.children) };
return node;
});
return { ...p, children: updateChildren(p.children) };
}
return p;
});
await api.savePlaylists(updated as any);
await api.deleteDuplicateSongs(targetId, others, true);
await loadDuplicates(minGroupSize);
} finally {
setProcessingGroupKey(null);
}
}}
/>
</Tooltip>
</HStack>
</Td>
<Td color="gray.200">{it.title}</Td>
<Td color="gray.200">{it.artist}</Td>
<Td color="gray.300">{it.totalTime || '-'}</Td>
<Td color="gray.300">{it.averageBpm || '-'}</Td>
<Td color="gray.300">{it.bitRate || '-'}</Td>
<Td color="gray.300" maxW="400px">{(it.playlists || []).join(', ')}</Td>
<Td color="gray.400" whiteSpace="nowrap">{it.location || '-'}</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
))}
</VStack>
)}
</VStack>
);
};