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