feat(duplicates): add backend delete-duplicates and UI action to delete redundant songs after merging playlists

This commit is contained in:
Geert Rademakes 2025-08-08 09:07:45 +02:00
parent 549fd8d525
commit f7f44f2c48
3 changed files with 162 additions and 53 deletions

View File

@ -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 });
}
});

View File

@ -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) => (
<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 };
<HStack>
<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 {
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);
}
}}
/>
</Tooltip>
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);
}
}}
/>
</Tooltip>
<Tooltip label="Delete other duplicates (optionally remove music files)">
<IconButton
aria-label="Delete duplicates"
icon={<FiTrash2 />}
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);
}
}}
/>
</Tooltip>
</HStack>
</Td>
<Td color="gray.200">{it.title}</Td>
<Td color="gray.200">{it.artist}</Td>

View File

@ -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();