From 549fd8d525d9ab2d9bacae4193422f33e1a19645 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 08:59:54 +0200 Subject: [PATCH] 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 --- packages/backend/src/routes/songs.ts | 6 +- .../src/components/DuplicatesViewer.tsx | 108 ++++++++++++++---- packages/frontend/src/services/api.ts | 2 + 3 files changed, 95 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/routes/songs.ts b/packages/backend/src/routes/songs.ts index 6ea8d9f..5e12776 100644 --- a/packages/backend/src/routes/songs.ts +++ b/packages/backend/src/routes/songs.ts @@ -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] || [], })) }; diff --git a/packages/frontend/src/components/DuplicatesViewer.tsx b/packages/frontend/src/components/DuplicatesViewer.tsx index a61c246..983a309 100644 --- a/packages/frontend/src/components/DuplicatesViewer.tsx +++ b/packages/frontend/src/components/DuplicatesViewer.tsx @@ -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([]); const [loading, setLoading] = useState(false); const [minGroupSize, setMinGroupSize] = useState(2); + const [processingGroupKey, setProcessingGroupKey] = useState(null); const loadDuplicates = async (minSize: number) => { setLoading(true); @@ -77,26 +81,90 @@ export const DuplicatesViewer: React.FC = () => { {group.normalizedArtist} — {group.normalizedTitle} - - - - - - - - - - - {group.items.map((it) => ( - - - - - + +
TitleArtistPlaylistsRekordbox Path
{it.title}{it.artist}{(it.playlists || []).join(', ')}{it.location || '-'}
+ + + + + + + + + + - ))} - -
KeepTitleArtistLengthBPMBitratePlaylistsRekordbox Path
+ + + {group.items.map((it) => ( + + + + } + 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); + } + }} + /> + + + {it.title} + {it.artist} + {it.totalTime || '-'} + {it.averageBpm || '-'} + {it.bitRate || '-'} + {(it.playlists || []).join(', ')} + {it.location || '-'} + + ))} + + + ))} diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index c6a5968..d193e93 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -156,6 +156,8 @@ class Api { artist: string; location?: string; totalTime?: string; + averageBpm?: string; + bitRate?: string; playlists: string[]; }>; }>;