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:
parent
7dc70c3bdf
commit
549fd8d525
@ -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] || [],
|
||||
}))
|
||||
};
|
||||
|
||||
@ -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,26 +81,90 @@ export const DuplicatesViewer: React.FC = () => {
|
||||
<Text color="gray.300">{group.normalizedArtist} — {group.normalizedTitle}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th color="gray.300">Title</Th>
|
||||
<Th color="gray.300">Artist</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 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>
|
||||
<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>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Thead>
|
||||
<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.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>
|
||||
|
||||
@ -156,6 +156,8 @@ class Api {
|
||||
artist: string;
|
||||
location?: string;
|
||||
totalTime?: string;
|
||||
averageBpm?: string;
|
||||
bitRate?: string;
|
||||
playlists: string[];
|
||||
}>;
|
||||
}>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user