412 lines
14 KiB
TypeScript

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();
// Get songs with pagination and search
router.get('/', async (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 100;
const search = req.query.search as string || '';
const skip = (page - 1) * limit;
console.log(`Fetching songs from database... Page: ${page}, Limit: ${limit}, Search: "${search}"`);
// Build query for search
let query = {};
if (search) {
query = {
$or: [
{ title: { $regex: search, $options: 'i' } },
{ artist: { $regex: search, $options: 'i' } },
{ album: { $regex: search, $options: 'i' } },
{ genre: { $regex: search, $options: 'i' } }
]
};
}
// Get total count for pagination
const totalSongs = await Song.countDocuments(query);
const totalPages = Math.ceil(totalSongs / limit);
// Get songs for this page in the exact playlist order
// Determine the slice of trackIds for the requested page
const pageStart = (page - 1) * limit;
const pageEnd = Math.min(pageStart + limit, trackIds.length);
const pageTrackIds = trackIds.slice(pageStart, pageEnd);
const pageSongs = await Song.find({ id: { $in: pageTrackIds } })
.populate('s3File.musicFileId')
.lean();
// Order them to match pageTrackIds
const idToSong: Record<string, any> = {};
for (const s of pageSongs) idToSong[s.id] = s;
const songs = pageTrackIds.map(id => idToSong[id]).filter(Boolean);
console.log(`Found ${songs.length} songs (${totalSongs} total), ${songs.filter((s: any) => s.s3File?.hasS3File).length} with S3 files`);
res.json({
songs,
pagination: {
page,
limit,
totalSongs,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1
}
});
} catch (error) {
console.error('Error fetching songs:', error);
res.status(500).json({ message: 'Error fetching songs', error });
}
});
// Get songs by playlist with pagination
router.get('/playlist/*', async (req: Request, res: Response) => {
try {
const playlistName = decodeURIComponent(req.params[0]);
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 100;
const search = req.query.search as string || '';
const skip = (page - 1) * limit;
console.log(`Fetching songs for playlist "${playlistName}"... Page: ${page}, Limit: ${limit}, Search: "${search}"`);
// Find the playlist recursively in the playlist structure
const findPlaylistRecursively = (nodes: any[], targetName: string): any => {
for (const node of nodes) {
if (node.name === targetName) {
return node;
}
if (node.children && node.children.length > 0) {
const found = findPlaylistRecursively(node.children, targetName);
if (found) return found;
}
}
return null;
};
// Get all playlists and search recursively
const allPlaylists = await Playlist.find({});
let playlist = null;
for (const playlistDoc of allPlaylists) {
playlist = findPlaylistRecursively([playlistDoc], playlistName);
if (playlist) break;
}
if (!playlist) {
console.log(`Playlist "${playlistName}" not found. Available playlists:`);
const allPlaylistNames = await Playlist.find({}, 'name');
console.log(allPlaylistNames.map(p => p.name));
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
}
// Get all track IDs from the playlist (including nested playlists)
const getAllTrackIds = (node: any): string[] => {
if (node.type === 'playlist') {
const base = Array.isArray(node.tracks) ? node.tracks : [];
const order = Array.isArray(node.order) ? node.order : [];
if (order.length === 0) return base;
const setBase = new Set(base);
const orderedKnown = order.filter((id: string) => setBase.has(id));
const missing = base.filter((id: string) => !order.includes(id));
return [...orderedKnown, ...missing];
}
if (node.type === 'folder' && node.children) {
return node.children.flatMap((child: any) => getAllTrackIds(child));
}
return [];
};
const trackIds = getAllTrackIds(playlist);
console.log(`[PLAYLIST_ORDER] ${playlistName} tracks count:`, trackIds.length);
console.log('[PLAYLIST_ORDER] first 10 ids:', trackIds.slice(0, 10));
if (trackIds.length === 0) {
return res.json({
songs: [],
pagination: {
page,
limit,
totalSongs: 0,
totalPages: 0,
hasNextPage: false,
hasPrevPage: false
}
});
}
// Build query for songs in playlist
let query: any = { id: { $in: trackIds } };
if (search) {
query = {
$and: [
{ id: { $in: trackIds } },
{
$or: [
{ title: { $regex: search, $options: 'i' } },
{ artist: { $regex: search, $options: 'i' } },
{ album: { $regex: search, $options: 'i' } },
{ genre: { $regex: search, $options: 'i' } }
]
}
]
};
}
// Get total count for pagination
const totalSongs = await Song.countDocuments(query);
const totalPages = Math.ceil(totalSongs / limit);
// Calculate total duration for the entire playlist
const totalDuration = (await Song.find({ id: { $in: trackIds } }, { totalTime: 1 }).lean()).reduce((total, song: any) => {
if (!song.totalTime) return total;
const totalTimeStr = String(song.totalTime);
const seconds = Math.floor(Number(totalTimeStr) / (totalTimeStr.length > 4 ? 1000 : 1));
return total + seconds;
}, 0);
// Get songs for this page in the exact playlist order
const pageStart = (page - 1) * limit;
const pageEnd = Math.min(pageStart + limit, trackIds.length);
const pageTrackIds = trackIds.slice(pageStart, pageEnd);
console.log('[PLAYLIST_ORDER] page', page, 'limit', limit, 'slice', pageStart, pageEnd, 'ids:', pageTrackIds);
const pageSongs = await Song.find({ id: { $in: pageTrackIds } })
.populate('s3File.musicFileId')
.lean();
const idToSong: Record<string, any> = {};
for (const s of pageSongs) idToSong[s.id] = s;
const songs = pageTrackIds.map(id => idToSong[id]).filter(Boolean);
console.log(`Found ${songs.length} songs for playlist "${playlistName}" (${totalSongs} total), ${songs.filter((s: any) => s.s3File?.hasS3File).length} with S3 files`);
res.json({
songs,
pagination: {
page,
limit,
totalSongs,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1
},
totalDuration: totalDuration
});
} catch (error) {
console.error('Error fetching playlist songs:', error);
res.status(500).json({ message: 'Error fetching playlist songs', error });
}
});
// Get total song count
router.get('/count', async (req: Request, res: Response) => {
try {
const count = await Song.countDocuments();
res.json({ count });
} catch (error) {
console.error('Error fetching song count:', error);
res.status(500).json({ message: 'Error fetching song count', error });
}
});
// Export library to XML format with streaming
router.get('/export', async (req: Request, res: Response) => {
try {
console.log('Starting streaming XML export...');
// Set response headers for file download
res.setHeader('Content-Type', 'application/xml');
res.setHeader('Content-Disposition', `attachment; filename="rekordbox-library-${new Date().toISOString().split('T')[0]}.xml"`);
res.setHeader('Transfer-Encoding', 'chunked');
// Import the streaming XML generation function
const { streamToXml } = await import('../services/xmlService.js');
// Stream XML generation to response
await streamToXml(res);
console.log('Streaming XML export completed successfully');
} catch (error) {
console.error('Error exporting library:', error);
if (!res.headersSent) {
res.status(500).json({ message: 'Error exporting library', error });
}
}
});
// Create multiple songs
router.post('/batch', async (req: Request, res: Response) => {
try {
console.log('Received batch upload request');
const songs = req.body;
console.log(`Attempting to save ${songs.length} songs`);
// Delete all existing songs first
await Song.deleteMany({});
console.log('Cleared existing songs');
// Insert new songs
const result = await Song.insertMany(songs);
console.log(`Successfully saved ${result.length} songs`);
res.status(201).json(result);
} catch (error) {
console.error('Error creating songs:', error);
res.status(500).json({ message: 'Error creating songs', error });
}
});
export const songsRouter = router;
// Identify possible duplicate songs by normalized title+artist
router.get('/duplicates', async (req: Request, res: Response) => {
try {
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, averageBpm: 1, bitRate: 1 }).lean();
// Normalize helper
const normalize = (str?: string) => {
if (!str) return '';
return String(str)
.toLowerCase()
.replace(/\s+/g, ' ') // collapse whitespace
.replace(/\([^)]*\)|\[[^\]]*\]|\{[^}]*\}/g, '') // remove bracketed qualifiers
.replace(/[^a-z0-9\s]/g, '') // remove punctuation
.trim();
};
// Group songs by normalized key (title+artist)
const groupsMap: Record<string, any[]> = {};
for (const s of songs as any[]) {
const key = `${normalize(s.title as string)}|${normalize(s.artist as string)}`;
if (!groupsMap[key]) groupsMap[key] = [];
groupsMap[key].push({
id: s.id,
title: s.title,
artist: s.artist,
location: s.location,
totalTime: s.totalTime,
averageBpm: s.averageBpm,
bitRate: s.bitRate,
});
}
// Build songId -> playlists mapping (names)
const songIdToPlaylists: Record<string, string[]> = {};
const playlistDocs = await Playlist.find({}).lean();
const collect = (node: any) => {
if (!node) return;
if (node.type === 'playlist' && Array.isArray(node.tracks)) {
for (const songId of node.tracks) {
if (!songIdToPlaylists[songId]) songIdToPlaylists[songId] = [];
if (!songIdToPlaylists[songId].includes(node.name)) {
songIdToPlaylists[songId].push(node.name);
}
}
}
if (Array.isArray(node.children)) {
for (const child of node.children) collect(child);
}
};
for (const doc of playlistDocs) collect(doc);
// Build duplicate groups response
const duplicateGroups = Object.entries(groupsMap)
.filter(([, items]) => items.length >= minGroupSize)
.map(([key, items]) => {
const [normTitle, normArtist] = key.split('|');
return {
key,
normalizedTitle: normTitle,
normalizedArtist: normArtist,
count: items.length,
items: items.map((it: any) => ({
songId: it.id,
title: it.title,
artist: it.artist,
location: it.location,
totalTime: it.totalTime,
averageBpm: it.averageBpm,
bitRate: it.bitRate,
playlists: songIdToPlaylists[it.id] || [],
}))
};
})
.sort((a, b) => b.count - a.count);
res.json({ groups: duplicateGroups });
} catch (error) {
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 });
}
});