feat(duplicates): add backend delete-duplicates and UI action to delete redundant songs after merging playlists
This commit is contained in:
parent
549fd8d525
commit
f7f44f2c48
@ -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 });
|
||||
}
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
Loading…
x
Reference in New Issue
Block a user