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