feat(duplicates): horizontal scroll table, keep-one merge playlists action, add length/BPM/bitrate columns\n\n- Backend: include averageBpm, bitRate in duplicates payload\n- Frontend: scrollable table with Keep action to merge playlists to selected song; show length/BPM/bitrate

This commit is contained in:
Geert Rademakes 2025-08-08 08:59:54 +02:00
parent 7dc70c3bdf
commit 549fd8d525
3 changed files with 95 additions and 21 deletions

View File

@ -253,7 +253,7 @@ router.get('/duplicates', async (req: Request, res: Response) => {
const minGroupSize = parseInt((req.query.minGroupSize as string) || '2', 10);
// Load needed song fields
const songs = await Song.find({}, { id: 1, title: 1, artist: 1, location: 1, totalTime: 1 }).lean();
const songs = await Song.find({}, { id: 1, title: 1, artist: 1, location: 1, totalTime: 1, averageBpm: 1, bitRate: 1 }).lean();
// Normalize helper
const normalize = (str?: string) => {
@ -277,6 +277,8 @@ router.get('/duplicates', async (req: Request, res: Response) => {
artist: s.artist,
location: s.location,
totalTime: s.totalTime,
averageBpm: s.averageBpm,
bitRate: s.bitRate,
});
}
@ -316,6 +318,8 @@ router.get('/duplicates', async (req: Request, res: Response) => {
artist: it.artist,
location: it.location,
totalTime: it.totalTime,
averageBpm: it.averageBpm,
bitRate: it.bitRate,
playlists: songIdToPlaylists[it.id] || [],
}))
};

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Box, VStack, Heading, Text, HStack, Badge, Select, Spinner, Table, Thead, Tr, Th, Tbody, Td } from '@chakra-ui/react';
import { Box, VStack, Heading, Text, HStack, Badge, Select, Spinner, Table, Thead, Tr, Th, Tbody, Td, IconButton, Tooltip } from '@chakra-ui/react';
import { FiCheck } from 'react-icons/fi';
import { api } from '../services/api';
interface DuplicateItem {
@ -8,6 +9,8 @@ interface DuplicateItem {
artist: string;
location?: string;
totalTime?: string;
averageBpm?: string;
bitRate?: string;
playlists: string[];
}
@ -23,6 +26,7 @@ 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 loadDuplicates = async (minSize: number) => {
setLoading(true);
@ -77,11 +81,16 @@ export const DuplicatesViewer: React.FC = () => {
<Text color="gray.300">{group.normalizedArtist} {group.normalizedTitle}</Text>
</HStack>
</HStack>
<Table size="sm" variant="simple">
<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>
@ -89,15 +98,74 @@ export const DuplicatesViewer: React.FC = () => {
<Tbody>
{group.items.map((it) => (
<Tr key={it.songId}>
<Td>
<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 {
// Merge playlists across the group into the chosen song
const merged = Array.from(new Set(group.items.flatMap(x => x.playlists || [])));
// Save playlists by replacing occurrences of group item songIds with the target one
// Fetch playlists, update locally, then save via api.savePlaylists
// We re-use existing client method
// 1) Get current 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 this playlist should contain the merged target, ensure it
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); // Refresh view
} finally {
setProcessingGroupKey(null);
}
}}
/>
</Tooltip>
</Td>
<Td color="gray.200">{it.title}</Td>
<Td color="gray.200">{it.artist}</Td>
<Td color="gray.300">{(it.playlists || []).join(', ')}</Td>
<Td color="gray.400" maxW="500px" whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">{it.location || '-'}</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>
)}

View File

@ -156,6 +156,8 @@ class Api {
artist: string;
location?: string;
totalTime?: string;
averageBpm?: string;
bitRate?: string;
playlists: string[];
}>;
}>;