Compare commits
15 Commits
b3b2808508
...
d13fe81ade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d13fe81ade | ||
|
|
5f17380816 | ||
|
|
5659dde540 | ||
|
|
9249a5a4a7 | ||
|
|
ac52441dd1 | ||
|
|
d0a83a85f5 | ||
|
|
2d42c6da71 | ||
|
|
e2d6d55433 | ||
|
|
484d191201 | ||
|
|
8136bbb959 | ||
|
|
61d4ca16de | ||
|
|
5a396d774e | ||
|
|
5a21243f5b | ||
|
|
50a486f6d8 | ||
|
|
32d545959d |
@ -9,6 +9,8 @@ const playlistSchema = new mongoose.Schema({
|
|||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
tracks: [String],
|
tracks: [String],
|
||||||
|
// Optional custom order of track IDs (display-only, not exported)
|
||||||
|
order: [String],
|
||||||
children: [{
|
children: [{
|
||||||
type: mongoose.Schema.Types.Mixed
|
type: mongoose.Schema.Types.Mixed
|
||||||
}]
|
}]
|
||||||
|
|||||||
@ -80,4 +80,171 @@ router.post('/batch', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reorder tracks inside a playlist (by name). Stores order separately in `order` to avoid interfering with original `tracks`.
|
||||||
|
router.post('/reorder', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { playlistName, orderedIds } = req.body as { playlistName: string; orderedIds: string[] };
|
||||||
|
if (!playlistName || !Array.isArray(orderedIds)) {
|
||||||
|
return res.status(400).json({ message: 'playlistName and orderedIds are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlists = await Playlist.find({});
|
||||||
|
|
||||||
|
let updated = false;
|
||||||
|
const reorderNode = (node: any): any => {
|
||||||
|
if (!node) return node;
|
||||||
|
if (node.type === 'playlist' && node.name === playlistName) {
|
||||||
|
// Store order separately; do not mutate tracks
|
||||||
|
const unique = Array.from(new Set(orderedIds));
|
||||||
|
node.order = unique;
|
||||||
|
updated = true;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
node.children = node.children.map(reorderNode);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const p of playlists) {
|
||||||
|
reorderNode(p as any);
|
||||||
|
// Force Mongoose to see deep changes under Mixed children
|
||||||
|
p.markModified('children');
|
||||||
|
p.markModified('tracks');
|
||||||
|
await p.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Playlist order updated', playlistName, newLength: orderedIds.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reordering playlist:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to reorder playlist' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move a single track before another (stable, full-playlist context)
|
||||||
|
router.post('/reorder-move', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { playlistName, fromId, toId } = req.body as { playlistName: string; fromId: string; toId?: string };
|
||||||
|
if (!playlistName || !fromId) {
|
||||||
|
return res.status(400).json({ message: 'playlistName and fromId are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlists = await Playlist.find({});
|
||||||
|
let updated = false;
|
||||||
|
|
||||||
|
const applyMove = (node: any): any => {
|
||||||
|
if (!node) return node;
|
||||||
|
if (node.type === 'playlist' && node.name === playlistName) {
|
||||||
|
const base: string[] = Array.isArray(node.tracks) ? node.tracks : [];
|
||||||
|
const order: string[] = Array.isArray(node.order) ? node.order : [];
|
||||||
|
// Build effective order
|
||||||
|
const baseSet = new Set(base);
|
||||||
|
const effective = [
|
||||||
|
...order.filter((id: string) => baseSet.has(id)),
|
||||||
|
...base.filter((id: string) => !order.includes(id))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Remove fromId if present
|
||||||
|
const without = effective.filter(id => id !== fromId);
|
||||||
|
let insertIndex = typeof toId === 'string' ? without.indexOf(toId) : without.length;
|
||||||
|
if (insertIndex < 0) insertIndex = without.length;
|
||||||
|
without.splice(insertIndex, 0, fromId);
|
||||||
|
|
||||||
|
// Updated playlist order overlay
|
||||||
|
node.order = without; // store full order overlay
|
||||||
|
updated = true;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
node.children = node.children.map(applyMove);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const p of playlists) {
|
||||||
|
applyMove(p as any);
|
||||||
|
p.markModified('children');
|
||||||
|
p.markModified('order');
|
||||||
|
await p.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Track moved', playlistName });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error moving track in playlist:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to move track' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move multiple tracks before another (en bloc, preserves relative order)
|
||||||
|
router.post('/reorder-move-many', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { playlistName, fromIds, toId } = req.body as { playlistName: string; fromIds: string[]; toId?: string };
|
||||||
|
if (!playlistName || !Array.isArray(fromIds) || fromIds.length === 0) {
|
||||||
|
return res.status(400).json({ message: 'playlistName and fromIds are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlists = await Playlist.find({});
|
||||||
|
let updated = false;
|
||||||
|
|
||||||
|
const applyMoveMany = (node: any): any => {
|
||||||
|
if (!node) return node;
|
||||||
|
if (node.type === 'playlist' && node.name === playlistName) {
|
||||||
|
const base: string[] = Array.isArray(node.tracks) ? node.tracks : [];
|
||||||
|
const order: string[] = Array.isArray(node.order) ? node.order : [];
|
||||||
|
const baseSet = new Set(base);
|
||||||
|
const effective: string[] = [
|
||||||
|
...order.filter((id: string) => baseSet.has(id)),
|
||||||
|
...base.filter((id: string) => !order.includes(id))
|
||||||
|
];
|
||||||
|
|
||||||
|
const moveSet = new Set(fromIds);
|
||||||
|
const movingOrdered = effective.filter(id => moveSet.has(id));
|
||||||
|
if (movingOrdered.length === 0) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all moving ids
|
||||||
|
const without = effective.filter(id => !moveSet.has(id));
|
||||||
|
let insertIndex = typeof toId === 'string' ? without.indexOf(toId) : without.length;
|
||||||
|
if (insertIndex < 0) insertIndex = without.length;
|
||||||
|
// Insert block preserving relative order
|
||||||
|
without.splice(insertIndex, 0, ...movingOrdered);
|
||||||
|
|
||||||
|
// Updated playlist order overlay for batch move
|
||||||
|
node.order = without;
|
||||||
|
updated = true;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
node.children = node.children.map(applyMoveMany);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const p of playlists) {
|
||||||
|
applyMoveMany(p as any);
|
||||||
|
p.markModified('children');
|
||||||
|
p.markModified('order');
|
||||||
|
await p.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Tracks moved', playlistName });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error moving tracks in playlist:', error);
|
||||||
|
res.status(500).json({ message: 'Error moving tracks in playlist', error });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const playlistsRouter = router;
|
export const playlistsRouter = router;
|
||||||
@ -34,13 +34,19 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
const totalSongs = await Song.countDocuments(query);
|
const totalSongs = await Song.countDocuments(query);
|
||||||
const totalPages = Math.ceil(totalSongs / limit);
|
const totalPages = Math.ceil(totalSongs / limit);
|
||||||
|
|
||||||
// Get songs with pagination
|
// Get songs for this page in the exact playlist order
|
||||||
const songs = await Song.find(query)
|
// Determine the slice of trackIds for the requested page
|
||||||
.sort({ title: 1 })
|
const pageStart = (page - 1) * limit;
|
||||||
.skip(skip)
|
const pageEnd = Math.min(pageStart + limit, trackIds.length);
|
||||||
.limit(limit)
|
const pageTrackIds = trackIds.slice(pageStart, pageEnd);
|
||||||
|
|
||||||
|
const pageSongs = await Song.find({ id: { $in: pageTrackIds } })
|
||||||
.populate('s3File.musicFileId')
|
.populate('s3File.musicFileId')
|
||||||
.lean();
|
.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`);
|
console.log(`Found ${songs.length} songs (${totalSongs} total), ${songs.filter((s: any) => s.s3File?.hasS3File).length} with S3 files`);
|
||||||
|
|
||||||
@ -70,7 +76,7 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
|
|||||||
const search = req.query.search as string || '';
|
const search = req.query.search as string || '';
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
console.log(`Fetching songs for playlist "${playlistName}"... Page: ${page}, Limit: ${limit}, Search: "${search}"`);
|
// Fetch songs for playlist with pagination
|
||||||
|
|
||||||
// Find the playlist recursively in the playlist structure
|
// Find the playlist recursively in the playlist structure
|
||||||
const findPlaylistRecursively = (nodes: any[], targetName: string): any => {
|
const findPlaylistRecursively = (nodes: any[], targetName: string): any => {
|
||||||
@ -96,16 +102,20 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
console.log(`Playlist "${playlistName}" not found. Available playlists:`);
|
// Playlist not found
|
||||||
const allPlaylistNames = await Playlist.find({}, 'name');
|
|
||||||
console.log(allPlaylistNames.map(p => p.name));
|
|
||||||
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
|
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all track IDs from the playlist (including nested playlists)
|
// Get all track IDs from the playlist (including nested playlists)
|
||||||
const getAllTrackIds = (node: any): string[] => {
|
const getAllTrackIds = (node: any): string[] => {
|
||||||
if (node.type === 'playlist' && node.tracks) {
|
if (node.type === 'playlist') {
|
||||||
return node.tracks;
|
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) {
|
if (node.type === 'folder' && node.children) {
|
||||||
return node.children.flatMap((child: any) => getAllTrackIds(child));
|
return node.children.flatMap((child: any) => getAllTrackIds(child));
|
||||||
@ -114,7 +124,7 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const trackIds = getAllTrackIds(playlist);
|
const trackIds = getAllTrackIds(playlist);
|
||||||
console.log(`Found ${trackIds.length} tracks in playlist "${playlistName}"`);
|
// Compute effective ordered track IDs
|
||||||
|
|
||||||
if (trackIds.length === 0) {
|
if (trackIds.length === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
@ -160,15 +170,17 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
|
|||||||
return total + seconds;
|
return total + seconds;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Get songs with pagination
|
// Get songs for this page in the exact playlist order
|
||||||
const songs = await Song.find(query)
|
const pageStart = (page - 1) * limit;
|
||||||
.sort({ title: 1 })
|
const pageEnd = Math.min(pageStart + limit, trackIds.length);
|
||||||
.skip(skip)
|
const pageTrackIds = trackIds.slice(pageStart, pageEnd);
|
||||||
.limit(limit)
|
|
||||||
|
const pageSongs = await Song.find({ id: { $in: pageTrackIds } })
|
||||||
.populate('s3File.musicFileId')
|
.populate('s3File.musicFileId')
|
||||||
.lean();
|
.lean();
|
||||||
|
const idToSong: Record<string, any> = {};
|
||||||
console.log(`Found ${songs.length} songs for playlist "${playlistName}" (${totalSongs} total), ${songs.filter((s: any) => s.s3File?.hasS3File).length} with S3 files`);
|
for (const s of pageSongs) idToSong[s.id] = s;
|
||||||
|
const songs = pageTrackIds.map(id => idToSong[id]).filter(Boolean);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
songs,
|
songs,
|
||||||
|
|||||||
@ -2,6 +2,17 @@ import { create } from "xmlbuilder";
|
|||||||
import { Song } from "../models/Song.js";
|
import { Song } from "../models/Song.js";
|
||||||
import { Playlist } from "../models/Playlist.js";
|
import { Playlist } from "../models/Playlist.js";
|
||||||
|
|
||||||
|
// Compute effective playlist order: custom overlay (order) + remaining base tracks
|
||||||
|
const getEffectiveTrackIds = (node: any): string[] => {
|
||||||
|
const base: string[] = Array.isArray(node?.tracks) ? node.tracks : [];
|
||||||
|
const overlay: string[] = Array.isArray(node?.order) ? node.order : [];
|
||||||
|
if (overlay.length === 0) return base;
|
||||||
|
const setBase = new Set(base);
|
||||||
|
const orderedKnown = overlay.filter((id: string) => setBase.has(id));
|
||||||
|
const missing = base.filter((id: string) => !overlay.includes(id));
|
||||||
|
return [...orderedKnown, ...missing];
|
||||||
|
};
|
||||||
|
|
||||||
const buildXmlNode = (node: any): any => {
|
const buildXmlNode = (node: any): any => {
|
||||||
const xmlNode: any = {
|
const xmlNode: any = {
|
||||||
'@Type': node.type === 'folder' ? '0' : '1',
|
'@Type': node.type === 'folder' ? '0' : '1',
|
||||||
@ -14,13 +25,12 @@ const buildXmlNode = (node: any): any => {
|
|||||||
xmlNode.NODE = node.children.map((child: any) => buildXmlNode(child));
|
xmlNode.NODE = node.children.map((child: any) => buildXmlNode(child));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For playlists, always include KeyType and Entries
|
// For playlists, include KeyType and Entries based on effective order
|
||||||
|
const effectiveIds = getEffectiveTrackIds(node);
|
||||||
xmlNode['@KeyType'] = '0';
|
xmlNode['@KeyType'] = '0';
|
||||||
// Set Entries to the actual number of tracks
|
xmlNode['@Entries'] = effectiveIds.length;
|
||||||
xmlNode['@Entries'] = (node.tracks || []).length;
|
if (effectiveIds.length > 0) {
|
||||||
// Include TRACK elements if there are tracks
|
xmlNode.TRACK = effectiveIds.map((trackId: string) => ({
|
||||||
if (node.tracks && node.tracks.length > 0) {
|
|
||||||
xmlNode.TRACK = node.tracks.map((trackId: string) => ({
|
|
||||||
'@Key': trackId
|
'@Key': trackId
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -30,7 +40,7 @@ const buildXmlNode = (node: any): any => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const streamToXml = async (res: any) => {
|
export const streamToXml = async (res: any) => {
|
||||||
console.log('Starting streamToXml function...');
|
console.log('Starting XML export...');
|
||||||
|
|
||||||
// Write XML header with encoding (like master collection)
|
// Write XML header with encoding (like master collection)
|
||||||
res.write('<?xml version="1.0" encoding="UTF-8"?>');
|
res.write('<?xml version="1.0" encoding="UTF-8"?>');
|
||||||
@ -40,9 +50,7 @@ export const streamToXml = async (res: any) => {
|
|||||||
res.write('\n <PRODUCT Name="rekordbox" Version="7.1.3" Company="AlphaTheta"/>');
|
res.write('\n <PRODUCT Name="rekordbox" Version="7.1.3" Company="AlphaTheta"/>');
|
||||||
|
|
||||||
// Start COLLECTION section
|
// Start COLLECTION section
|
||||||
console.log('Counting songs in database...');
|
|
||||||
const songCount = await Song.countDocuments();
|
const songCount = await Song.countDocuments();
|
||||||
console.log(`Found ${songCount} songs in database`);
|
|
||||||
res.write(`\n <COLLECTION Entries="${songCount}">`);
|
res.write(`\n <COLLECTION Entries="${songCount}">`);
|
||||||
|
|
||||||
// Stream songs in batches to avoid memory issues
|
// Stream songs in batches to avoid memory issues
|
||||||
@ -68,7 +76,6 @@ export const streamToXml = async (res: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
processedSongs += songs.length;
|
processedSongs += songs.length;
|
||||||
console.log(`Streamed ${processedSongs}/${songCount} songs...`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.write('\n </COLLECTION>');
|
res.write('\n </COLLECTION>');
|
||||||
@ -77,9 +84,7 @@ export const streamToXml = async (res: any) => {
|
|||||||
res.write('\n <PLAYLISTS>');
|
res.write('\n <PLAYLISTS>');
|
||||||
|
|
||||||
// Stream playlists
|
// Stream playlists
|
||||||
console.log('Fetching playlists from database...');
|
|
||||||
const playlists = await Playlist.find({}).lean();
|
const playlists = await Playlist.find({}).lean();
|
||||||
console.log(`Found ${playlists.length} playlists in database`);
|
|
||||||
|
|
||||||
// Write ROOT node with correct Count
|
// Write ROOT node with correct Count
|
||||||
res.write(`\n <NODE Type="0" Name="ROOT" Count="${playlists.length}">`);
|
res.write(`\n <NODE Type="0" Name="ROOT" Count="${playlists.length}">`);
|
||||||
@ -93,6 +98,7 @@ export const streamToXml = async (res: any) => {
|
|||||||
res.write('\n</DJ_PLAYLISTS>');
|
res.write('\n</DJ_PLAYLISTS>');
|
||||||
|
|
||||||
res.end();
|
res.end();
|
||||||
|
console.log('XML export completed.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const streamPlaylistNodeFull = async (res: any, node: any) => {
|
const streamPlaylistNodeFull = async (res: any, node: any) => {
|
||||||
@ -110,15 +116,13 @@ const streamPlaylistNodeFull = async (res: any, node: any) => {
|
|||||||
|
|
||||||
res.write('\n </NODE>');
|
res.write('\n </NODE>');
|
||||||
} else {
|
} else {
|
||||||
const trackCount = node.tracks ? node.tracks.length : 0;
|
const effectiveIds = getEffectiveTrackIds(node);
|
||||||
res.write(`\n <NODE Name="${escapeXml(node.name)}" Type="${nodeType}" KeyType="0" Entries="${trackCount}">`);
|
res.write(`\n <NODE Name="${escapeXml(node.name)}" Type="${nodeType}" KeyType="0" Entries="${effectiveIds.length}">`);
|
||||||
|
if (effectiveIds.length > 0) {
|
||||||
if (node.tracks && node.tracks.length > 0) {
|
for (const trackId of effectiveIds) {
|
||||||
for (const trackId of node.tracks) {
|
|
||||||
res.write(`\n <TRACK Key="${trackId}"/>`);
|
res.write(`\n <TRACK Key="${trackId}"/>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.write('\n </NODE>');
|
res.write('\n </NODE>');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -138,15 +142,13 @@ const streamPlaylistNodeCompact = async (res: any, node: any) => {
|
|||||||
|
|
||||||
res.write('</NODE>');
|
res.write('</NODE>');
|
||||||
} else {
|
} else {
|
||||||
const trackCount = node.tracks ? node.tracks.length : 0;
|
const effectiveIds = getEffectiveTrackIds(node);
|
||||||
res.write(`<NODE Name="${escapeXml(node.name)}" Type="${nodeType}" Count="${trackCount}">`);
|
res.write(`<NODE Name="${escapeXml(node.name)}" Type="${nodeType}" Count="${effectiveIds.length}">`);
|
||||||
|
if (effectiveIds.length > 0) {
|
||||||
if (node.tracks && node.tracks.length > 0) {
|
for (const trackId of effectiveIds) {
|
||||||
for (const trackId of node.tracks) {
|
|
||||||
res.write(`<TRACK Key="${trackId}"/>`);
|
res.write(`<TRACK Key="${trackId}"/>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.write('</NODE>');
|
res.write('</NODE>');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -167,15 +169,13 @@ const streamPlaylistNode = async (res: any, node: any, indent: number) => {
|
|||||||
|
|
||||||
res.write(`${spaces}</NODE>\n`);
|
res.write(`${spaces}</NODE>\n`);
|
||||||
} else {
|
} else {
|
||||||
const trackCount = node.tracks ? node.tracks.length : 0;
|
const effectiveIds = getEffectiveTrackIds(node);
|
||||||
res.write(`${spaces}<NODE Type="${nodeType}" Name="${escapeXml(node.name)}" KeyType="0" Entries="${trackCount}">\n`);
|
res.write(`${spaces}<NODE Type="${nodeType}" Name="${escapeXml(node.name)}" KeyType="0" Entries="${effectiveIds.length}">\n`);
|
||||||
|
if (effectiveIds.length > 0) {
|
||||||
if (node.tracks && node.tracks.length > 0) {
|
for (const trackId of effectiveIds) {
|
||||||
for (const trackId of node.tracks) {
|
|
||||||
res.write(`${spaces} <TRACK Key="${trackId}"/>\n`);
|
res.write(`${spaces} <TRACK Key="${trackId}"/>\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.write(`${spaces}</NODE>\n`);
|
res.write(`${spaces}</NODE>\n`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -129,7 +129,8 @@ const RekordboxReader: React.FC = () => {
|
|||||||
totalDuration,
|
totalDuration,
|
||||||
loadNextPage,
|
loadNextPage,
|
||||||
searchSongs,
|
searchSongs,
|
||||||
searchQuery
|
searchQuery,
|
||||||
|
refresh
|
||||||
} = usePaginatedSongs({ pageSize: 100, playlistName: currentPlaylist });
|
} = usePaginatedSongs({ pageSize: 100, playlistName: currentPlaylist });
|
||||||
|
|
||||||
// Export library to XML
|
// Export library to XML
|
||||||
@ -651,6 +652,11 @@ const RekordboxReader: React.FC = () => {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
isSwitchingPlaylist={isSwitchingPlaylist}
|
isSwitchingPlaylist={isSwitchingPlaylist}
|
||||||
onPlaySong={handlePlaySong}
|
onPlaySong={handlePlaySong}
|
||||||
|
onReorder={async (_orderedIds: string[]) => {
|
||||||
|
if (!currentPlaylist || currentPlaylist === 'All Songs') return;
|
||||||
|
// Do not overwrite server's move result with stale order; just refresh
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
import { Search2Icon } from '@chakra-ui/icons';
|
import { Search2Icon } from '@chakra-ui/icons';
|
||||||
import { FiPlay } from 'react-icons/fi';
|
import { FiPlay } from 'react-icons/fi';
|
||||||
import type { Song, PlaylistNode } from '../types/interfaces';
|
import type { Song, PlaylistNode } from '../types/interfaces';
|
||||||
|
import { api } from '../services/api';
|
||||||
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
||||||
import { useDebounce } from '../hooks/useDebounce';
|
import { useDebounce } from '../hooks/useDebounce';
|
||||||
import { PlaylistSelectionModal } from './PlaylistSelectionModal';
|
import { PlaylistSelectionModal } from './PlaylistSelectionModal';
|
||||||
@ -38,6 +39,7 @@ interface PaginatedSongListProps {
|
|||||||
depth?: number;
|
depth?: number;
|
||||||
isSwitchingPlaylist?: boolean; // New prop to indicate playlist switching
|
isSwitchingPlaylist?: boolean; // New prop to indicate playlist switching
|
||||||
onPlaySong?: (song: Song) => void; // New prop for playing songs
|
onPlaySong?: (song: Song) => void; // New prop for playing songs
|
||||||
|
onReorder?: (orderedIds: string[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized song item component to prevent unnecessary re-renders
|
// Memoized song item component to prevent unnecessary re-renders
|
||||||
@ -50,7 +52,10 @@ const SongItem = memo<{
|
|||||||
showCheckbox: boolean;
|
showCheckbox: boolean;
|
||||||
onPlaySong?: (song: Song) => void;
|
onPlaySong?: (song: Song) => void;
|
||||||
onDragStart: (e: React.DragEvent, songIdsFallback: string[]) => void;
|
onDragStart: (e: React.DragEvent, songIdsFallback: string[]) => void;
|
||||||
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, onDragStart }) => {
|
onRowDragOver?: (e: React.DragEvent) => void;
|
||||||
|
onRowDrop?: (e: React.DragEvent) => void;
|
||||||
|
onRowDragStartCapture?: (e: React.DragEvent) => void;
|
||||||
|
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture }) => {
|
||||||
// Memoize the formatted duration to prevent recalculation
|
// Memoize the formatted duration to prevent recalculation
|
||||||
const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]);
|
const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]);
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
@ -88,6 +93,9 @@ const SongItem = memo<{
|
|||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
onDragStart(e, [song.id]);
|
onDragStart(e, [song.id]);
|
||||||
}}
|
}}
|
||||||
|
onDragOver={onRowDragOver}
|
||||||
|
onDrop={onRowDrop}
|
||||||
|
onDragStartCapture={onRowDragStartCapture}
|
||||||
>
|
>
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -148,7 +156,8 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
isSwitchingPlaylist = false,
|
isSwitchingPlaylist = false,
|
||||||
onPlaySong
|
onPlaySong,
|
||||||
|
onReorder
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
||||||
const dragSelectionRef = useRef<string[] | null>(null);
|
const dragSelectionRef = useRef<string[] | null>(null);
|
||||||
@ -160,6 +169,9 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const isTriggeringRef = useRef(false);
|
const isTriggeringRef = useRef(false);
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [dragHoverIndex, setDragHoverIndex] = useState<number | null>(null);
|
||||||
|
const [endDropHover, setEndDropHover] = useState<boolean>(false);
|
||||||
|
const [isReorderDragging, setIsReorderDragging] = useState<boolean>(false);
|
||||||
|
|
||||||
// Store current values in refs to avoid stale closures
|
// Store current values in refs to avoid stale closures
|
||||||
const hasMoreRef = useRef(hasMore);
|
const hasMoreRef = useRef(hasMore);
|
||||||
@ -247,12 +259,15 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
setIsReorderDragging(false);
|
||||||
|
setEndDropHover(false);
|
||||||
|
setDragHoverIndex(null);
|
||||||
dragSelectionRef.current = null;
|
dragSelectionRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Memoized song items to prevent unnecessary re-renders
|
// Memoized song items to prevent unnecessary re-renders
|
||||||
const songItems = useMemo(() => {
|
const songItems = useMemo(() => {
|
||||||
return songs.map(song => (
|
return songs.map((song, index) => (
|
||||||
<SongItem
|
<SongItem
|
||||||
key={song.id}
|
key={song.id}
|
||||||
song={song}
|
song={song}
|
||||||
@ -263,6 +278,51 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
showCheckbox={selectedSongs.size > 0 || depth === 0}
|
showCheckbox={selectedSongs.size > 0 || depth === 0}
|
||||||
onPlaySong={onPlaySong}
|
onPlaySong={onPlaySong}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
// Simple playlist reordering within same list by dragging rows
|
||||||
|
onRowDragOver={(e: React.DragEvent) => {
|
||||||
|
if (!onReorder || !currentPlaylist || selectedSongs.size > 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragHoverIndex(index);
|
||||||
|
}}
|
||||||
|
onRowDrop={async (e: React.DragEvent) => {
|
||||||
|
if (!onReorder || !currentPlaylist || selectedSongs.size > 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const fromId = e.dataTransfer.getData('text/song-id');
|
||||||
|
const multiJson = e.dataTransfer.getData('application/json');
|
||||||
|
let multiIds: string[] | null = null;
|
||||||
|
if (multiJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(multiJson);
|
||||||
|
if (parsed && parsed.type === 'songs' && Array.isArray(parsed.songIds)) {
|
||||||
|
multiIds = parsed.songIds as string[];
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!fromId && !multiIds) return;
|
||||||
|
const fromIndex = fromId ? songs.findIndex(s => s.id === fromId) : -1;
|
||||||
|
const toIndex = index;
|
||||||
|
if (fromIndex < 0 || toIndex < 0) return;
|
||||||
|
if (fromIndex === toIndex) return;
|
||||||
|
const toId = songs[index].id;
|
||||||
|
// If multiple, move block; else move single
|
||||||
|
if (multiIds && multiIds.length > 0) {
|
||||||
|
await api.moveTracksInPlaylist(currentPlaylist, multiIds, toId);
|
||||||
|
} else {
|
||||||
|
await api.moveTrackInPlaylist(currentPlaylist, fromId!, toId);
|
||||||
|
}
|
||||||
|
await onReorder(songs.map(s => s.id)); // trigger refresh via parent
|
||||||
|
setDragHoverIndex(null);
|
||||||
|
setIsReorderDragging(false);
|
||||||
|
}}
|
||||||
|
onRowDragStartCapture={(e: React.DragEvent) => {
|
||||||
|
// Provide a simple id for intra-list reorder
|
||||||
|
if (!currentPlaylist || selectedSongs.size > 0) return;
|
||||||
|
e.dataTransfer.setData('text/song-id', song.id);
|
||||||
|
// Explicitly set effect to move for better UX
|
||||||
|
try { e.dataTransfer.effectAllowed = 'move'; } catch {}
|
||||||
|
try { e.dataTransfer.dropEffect = 'move'; } catch {}
|
||||||
|
setIsReorderDragging(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
}, [songs, selectedSongs, selectedSongId, toggleSelection, depth, onPlaySong, handleDragStart]); // Removed handleSongSelect since it's already memoized
|
}, [songs, selectedSongs, selectedSongId, toggleSelection, depth, onPlaySong, handleDragStart]); // Removed handleSongSelect since it's already memoized
|
||||||
@ -440,7 +500,61 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
id="song-list-container"
|
id="song-list-container"
|
||||||
>
|
>
|
||||||
<Flex direction="column" gap={2}>
|
<Flex direction="column" gap={2}>
|
||||||
<Box onDragEnd={handleDragEnd}>{songItems}</Box>
|
<Box onDragEnd={handleDragEnd}>
|
||||||
|
{songs.map((song, index) => (
|
||||||
|
<Box key={`row-${song.id}`} position="relative">
|
||||||
|
{dragHoverIndex === index && (
|
||||||
|
<Box position="absolute" top={0} left={0} right={0} height="2px" bg="blue.400" zIndex={1} />
|
||||||
|
)}
|
||||||
|
{songItems[index]}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Drop zone to move item to end of playlist */}
|
||||||
|
{onReorder && currentPlaylist && selectedSongs.size === 0 && isReorderDragging && (
|
||||||
|
<Box
|
||||||
|
onDragOver={(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragHoverIndex(null);
|
||||||
|
setEndDropHover(true);
|
||||||
|
try { e.dataTransfer.dropEffect = 'move'; } catch {}
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setEndDropHover(false)}
|
||||||
|
onDrop={async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fromId = e.dataTransfer.getData('text/song-id');
|
||||||
|
const multiJson = e.dataTransfer.getData('application/json');
|
||||||
|
let multiIds: string[] | null = null;
|
||||||
|
if (multiJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(multiJson);
|
||||||
|
if (parsed && parsed.type === 'songs' && Array.isArray(parsed.songIds)) {
|
||||||
|
multiIds = parsed.songIds as string[];
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!fromId && !multiIds) return;
|
||||||
|
// Move to end: omit toId
|
||||||
|
if (multiIds && multiIds.length > 0) {
|
||||||
|
await api.moveTracksInPlaylist(currentPlaylist, multiIds);
|
||||||
|
} else {
|
||||||
|
await api.moveTrackInPlaylist(currentPlaylist, fromId!);
|
||||||
|
}
|
||||||
|
await onReorder(songs.map(s => s.id));
|
||||||
|
setEndDropHover(false);
|
||||||
|
setIsReorderDragging(false);
|
||||||
|
}}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
position="relative"
|
||||||
|
height="28px"
|
||||||
|
mt={1}
|
||||||
|
>
|
||||||
|
{endDropHover && (
|
||||||
|
<Box position="absolute" top="50%" left={0} right={0} height="2px" bg="blue.400" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Loading indicator for infinite scroll or playlist switching */}
|
{/* Loading indicator for infinite scroll or playlist switching */}
|
||||||
{(loading || isSwitchingPlaylist) && (
|
{(loading || isSwitchingPlaylist) && (
|
||||||
|
|||||||
@ -97,6 +97,33 @@ class Api {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorderPlaylist(playlistName: string, orderedIds: string[]): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/playlists/reorder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playlistName, orderedIds })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to reorder playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveTrackInPlaylist(playlistName: string, fromId: string, toId?: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/playlists/reorder-move`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playlistName, fromId, toId })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to move track in playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveTracksInPlaylist(playlistName: string, fromIds: string[], toId?: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/playlists/reorder-move-many`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playlistName, fromIds, toId })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to move tracks in playlist');
|
||||||
|
}
|
||||||
|
|
||||||
async resetDatabase(): Promise<boolean> {
|
async resetDatabase(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/reset`, {
|
const response = await fetch(`${API_BASE_URL}/reset`, {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user