412 lines
14 KiB
TypeScript
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 });
|
|
}
|
|
}); |