From f7f44f2c480f69dd8fde77fdf020519c115297e0 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 09:07:45 +0200 Subject: [PATCH] feat(duplicates): add backend delete-duplicates and UI action to delete redundant songs after merging playlists --- packages/backend/src/routes/songs.ts | 61 ++++++++ .../src/components/DuplicatesViewer.tsx | 144 +++++++++++------- packages/frontend/src/services/api.ts | 10 ++ 3 files changed, 162 insertions(+), 53 deletions(-) diff --git a/packages/backend/src/routes/songs.ts b/packages/backend/src/routes/songs.ts index 5e12776..d4c0e96 100644 --- a/packages/backend/src/routes/songs.ts +++ b/packages/backend/src/routes/songs.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from 'express'; import { Song } from '../models/Song.js'; import { Playlist } from '../models/Playlist.js'; +import { MusicFile } from '../models/MusicFile.js'; const router = express.Router(); @@ -331,4 +332,64 @@ router.get('/duplicates', async (req: Request, res: Response) => { console.error('Error finding duplicate songs:', error); res.status(500).json({ message: 'Error finding duplicate songs', error }); } +}); + +// Delete redundant duplicate songs (optionally remove associated music files) +router.post('/delete-duplicates', async (req: Request, res: Response) => { + try { + const { targetSongId, redundantSongIds, deleteMusicFiles } = req.body as { + targetSongId: string; + redundantSongIds: string[]; + deleteMusicFiles?: boolean; + }; + + if (!targetSongId || !Array.isArray(redundantSongIds) || redundantSongIds.length === 0) { + return res.status(400).json({ message: 'targetSongId and redundantSongIds are required' }); + } + + // 1) Remove redundant IDs from playlists (ensure they don't remain anywhere) + const playlists = await Playlist.find({}); + + const cleanNode = (node: any): any => { + if (!node) return node; + if (node.type === 'playlist') { + const setTarget = new Set([targetSongId]); + const cleaned = Array.from(new Set((node.tracks || []).map((id: string) => (redundantSongIds.includes(id) ? targetSongId : id)))); + // Ensure uniqueness and avoid duplicate target insertions + node.tracks = Array.from(new Set(cleaned.filter(Boolean))); + return node; + } + if (Array.isArray(node.children)) { + node.children = node.children.map(cleanNode); + } + return node; + }; + + for (const p of playlists) { + cleanNode(p as any); + await p.save(); + } + + // 2) Optionally delete associated music files for redundant songs + let musicFilesDeleted = 0; + if (deleteMusicFiles) { + const mfResult = await MusicFile.deleteMany({ songId: { $in: redundantSongIds } }); + musicFilesDeleted = mfResult.deletedCount || 0; + } else { + // Otherwise, unlink their music files to avoid orphaned references to deleted songs + await MusicFile.updateMany({ songId: { $in: redundantSongIds } }, { $unset: { songId: 1 } }); + } + + // 3) Delete redundant Song documents + const result = await Song.deleteMany({ id: { $in: redundantSongIds } }); + + res.json({ + message: 'Duplicates deleted', + deletedSongs: result.deletedCount || 0, + unlinkedOrDeletedMusicFiles: musicFilesDeleted, + }); + } catch (error) { + console.error('Error deleting duplicate songs:', error); + res.status(500).json({ message: 'Error deleting duplicate songs', error }); + } }); \ No newline at end of file diff --git a/packages/frontend/src/components/DuplicatesViewer.tsx b/packages/frontend/src/components/DuplicatesViewer.tsx index 983a309..ab64b85 100644 --- a/packages/frontend/src/components/DuplicatesViewer.tsx +++ b/packages/frontend/src/components/DuplicatesViewer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from '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 { FiCheck, FiTrash2 } from 'react-icons/fi'; import { api } from '../services/api'; interface DuplicateItem { @@ -99,59 +99,97 @@ export const DuplicatesViewer: React.FC = () => { {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 }; + + + } + 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); } - 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); - } - }} - /> - + 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); + } + }} + /> + + + } + 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); + } + }} + /> + + {it.title} {it.artist} diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index d193e93..55f0a82 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -167,6 +167,16 @@ class Api { if (!response.ok) throw new Error('Failed to fetch duplicates'); return response.json(); } + + async deleteDuplicateSongs(targetSongId: string, redundantSongIds: string[], deleteMusicFiles: boolean): Promise<{ deletedSongs: number; unlinkedOrDeletedMusicFiles: number; }>{ + const response = await fetch(`${API_BASE_URL}/songs/delete-duplicates`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetSongId, redundantSongIds, deleteMusicFiles }) + }); + if (!response.ok) throw new Error('Failed to delete duplicates'); + return response.json(); + } } export const api = new Api(); \ No newline at end of file