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);
|
const minGroupSize = parseInt((req.query.minGroupSize as string) || '2', 10);
|
||||||
|
|
||||||
// Load needed song fields
|
// 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
|
// Normalize helper
|
||||||
const normalize = (str?: string) => {
|
const normalize = (str?: string) => {
|
||||||
@ -277,6 +277,8 @@ router.get('/duplicates', async (req: Request, res: Response) => {
|
|||||||
artist: s.artist,
|
artist: s.artist,
|
||||||
location: s.location,
|
location: s.location,
|
||||||
totalTime: s.totalTime,
|
totalTime: s.totalTime,
|
||||||
|
averageBpm: s.averageBpm,
|
||||||
|
bitRate: s.bitRate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,6 +318,8 @@ router.get('/duplicates', async (req: Request, res: Response) => {
|
|||||||
artist: it.artist,
|
artist: it.artist,
|
||||||
location: it.location,
|
location: it.location,
|
||||||
totalTime: it.totalTime,
|
totalTime: it.totalTime,
|
||||||
|
averageBpm: it.averageBpm,
|
||||||
|
bitRate: it.bitRate,
|
||||||
playlists: songIdToPlaylists[it.id] || [],
|
playlists: songIdToPlaylists[it.id] || [],
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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';
|
import { api } from '../services/api';
|
||||||
|
|
||||||
interface DuplicateItem {
|
interface DuplicateItem {
|
||||||
@ -8,6 +9,8 @@ interface DuplicateItem {
|
|||||||
artist: string;
|
artist: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
totalTime?: string;
|
totalTime?: string;
|
||||||
|
averageBpm?: string;
|
||||||
|
bitRate?: string;
|
||||||
playlists: string[];
|
playlists: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,6 +26,7 @@ export const DuplicatesViewer: React.FC = () => {
|
|||||||
const [groups, setGroups] = useState<DuplicateGroup[]>([]);
|
const [groups, setGroups] = useState<DuplicateGroup[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [minGroupSize, setMinGroupSize] = useState(2);
|
const [minGroupSize, setMinGroupSize] = useState(2);
|
||||||
|
const [processingGroupKey, setProcessingGroupKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadDuplicates = async (minSize: number) => {
|
const loadDuplicates = async (minSize: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -77,11 +81,16 @@ export const DuplicatesViewer: React.FC = () => {
|
|||||||
<Text color="gray.300">{group.normalizedArtist} — {group.normalizedTitle}</Text>
|
<Text color="gray.300">{group.normalizedArtist} — {group.normalizedTitle}</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<Table size="sm" variant="simple">
|
<Box overflowX="auto">
|
||||||
|
<Table size="sm" variant="simple" minW="1000px">
|
||||||
<Thead>
|
<Thead>
|
||||||
<Tr>
|
<Tr>
|
||||||
|
<Th color="gray.300">Keep</Th>
|
||||||
<Th color="gray.300">Title</Th>
|
<Th color="gray.300">Title</Th>
|
||||||
<Th color="gray.300">Artist</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">Playlists</Th>
|
||||||
<Th color="gray.300">Rekordbox Path</Th>
|
<Th color="gray.300">Rekordbox Path</Th>
|
||||||
</Tr>
|
</Tr>
|
||||||
@ -89,15 +98,74 @@ export const DuplicatesViewer: React.FC = () => {
|
|||||||
<Tbody>
|
<Tbody>
|
||||||
{group.items.map((it) => (
|
{group.items.map((it) => (
|
||||||
<Tr key={it.songId}>
|
<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.title}</Td>
|
||||||
<Td color="gray.200">{it.artist}</Td>
|
<Td color="gray.200">{it.artist}</Td>
|
||||||
<Td color="gray.300">{(it.playlists || []).join(', ')}</Td>
|
<Td color="gray.300">{it.totalTime || '-'}</Td>
|
||||||
<Td color="gray.400" maxW="500px" whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">{it.location || '-'}</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>
|
</Tr>
|
||||||
))}
|
))}
|
||||||
</Tbody>
|
</Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -156,6 +156,8 @@ class Api {
|
|||||||
artist: string;
|
artist: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
totalTime?: string;
|
totalTime?: string;
|
||||||
|
averageBpm?: string;
|
||||||
|
bitRate?: string;
|
||||||
playlists: string[];
|
playlists: string[];
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user