diff --git a/packages/backend/src/models/Playlist.ts b/packages/backend/src/models/Playlist.ts index d9661d7..b88f11b 100644 --- a/packages/backend/src/models/Playlist.ts +++ b/packages/backend/src/models/Playlist.ts @@ -9,6 +9,8 @@ const playlistSchema = new mongoose.Schema({ required: true }, tracks: [String], + // Optional custom order of track IDs (display-only, not exported) + order: [String], children: [{ type: mongoose.Schema.Types.Mixed }] diff --git a/packages/backend/src/routes/playlists.ts b/packages/backend/src/routes/playlists.ts index 97f127d..448c121 100644 --- a/packages/backend/src/routes/playlists.ts +++ b/packages/backend/src/routes/playlists.ts @@ -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; \ No newline at end of file diff --git a/packages/backend/src/routes/songs.ts b/packages/backend/src/routes/songs.ts index d674ce4..184ebec 100644 --- a/packages/backend/src/routes/songs.ts +++ b/packages/backend/src/routes/songs.ts @@ -34,13 +34,19 @@ router.get('/', async (req: Request, res: Response) => { const totalSongs = await Song.countDocuments(query); const totalPages = Math.ceil(totalSongs / limit); - // Get songs with pagination - const songs = await Song.find(query) - .sort({ title: 1 }) - .skip(skip) - .limit(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 = {}; + 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`); @@ -70,7 +76,7 @@ router.get('/playlist/*', async (req: Request, res: Response) => { 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}"`); + // Fetch songs for playlist with pagination // Find the playlist recursively in the playlist structure const findPlaylistRecursively = (nodes: any[], targetName: string): any => { @@ -96,16 +102,20 @@ router.get('/playlist/*', async (req: Request, res: Response) => { } if (!playlist) { - console.log(`Playlist "${playlistName}" not found. Available playlists:`); - const allPlaylistNames = await Playlist.find({}, 'name'); - console.log(allPlaylistNames.map(p => p.name)); + // Playlist not found 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' && node.tracks) { - return node.tracks; + 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)); @@ -114,7 +124,7 @@ router.get('/playlist/*', async (req: Request, res: Response) => { }; const trackIds = getAllTrackIds(playlist); - console.log(`Found ${trackIds.length} tracks in playlist "${playlistName}"`); + // Compute effective ordered track IDs if (trackIds.length === 0) { return res.json({ @@ -160,15 +170,17 @@ router.get('/playlist/*', async (req: Request, res: Response) => { return total + seconds; }, 0); - // Get songs with pagination - const songs = await Song.find(query) - .sort({ title: 1 }) - .skip(skip) - .limit(limit) + // 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); + + const pageSongs = await Song.find({ id: { $in: pageTrackIds } }) .populate('s3File.musicFileId') .lean(); - - console.log(`Found ${songs.length} songs for playlist "${playlistName}" (${totalSongs} total), ${songs.filter((s: any) => s.s3File?.hasS3File).length} with S3 files`); + const idToSong: Record = {}; + for (const s of pageSongs) idToSong[s.id] = s; + const songs = pageTrackIds.map(id => idToSong[id]).filter(Boolean); res.json({ songs, diff --git a/packages/backend/src/services/xmlService.ts b/packages/backend/src/services/xmlService.ts index 5ce6e3d..2f2a92b 100644 --- a/packages/backend/src/services/xmlService.ts +++ b/packages/backend/src/services/xmlService.ts @@ -2,6 +2,17 @@ import { create } from "xmlbuilder"; import { Song } from "../models/Song.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 xmlNode: any = { '@Type': node.type === 'folder' ? '0' : '1', @@ -14,13 +25,12 @@ const buildXmlNode = (node: any): any => { xmlNode.NODE = node.children.map((child: any) => buildXmlNode(child)); } } 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'; - // Set Entries to the actual number of tracks - xmlNode['@Entries'] = (node.tracks || []).length; - // Include TRACK elements if there are tracks - if (node.tracks && node.tracks.length > 0) { - xmlNode.TRACK = node.tracks.map((trackId: string) => ({ + xmlNode['@Entries'] = effectiveIds.length; + if (effectiveIds.length > 0) { + xmlNode.TRACK = effectiveIds.map((trackId: string) => ({ '@Key': trackId })); } @@ -30,7 +40,7 @@ const buildXmlNode = (node: any): 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) res.write(''); @@ -40,9 +50,7 @@ export const streamToXml = async (res: any) => { res.write('\n '); // Start COLLECTION section - console.log('Counting songs in database...'); const songCount = await Song.countDocuments(); - console.log(`Found ${songCount} songs in database`); res.write(`\n `); // Stream songs in batches to avoid memory issues @@ -68,7 +76,6 @@ export const streamToXml = async (res: any) => { } processedSongs += songs.length; - console.log(`Streamed ${processedSongs}/${songCount} songs...`); } res.write('\n '); @@ -77,9 +84,7 @@ export const streamToXml = async (res: any) => { res.write('\n '); // Stream playlists - console.log('Fetching playlists from database...'); const playlists = await Playlist.find({}).lean(); - console.log(`Found ${playlists.length} playlists in database`); // Write ROOT node with correct Count res.write(`\n `); @@ -93,6 +98,7 @@ export const streamToXml = async (res: any) => { res.write('\n'); res.end(); + console.log('XML export completed.'); }; const streamPlaylistNodeFull = async (res: any, node: any) => { @@ -110,15 +116,13 @@ const streamPlaylistNodeFull = async (res: any, node: any) => { res.write('\n '); } else { - const trackCount = node.tracks ? node.tracks.length : 0; - res.write(`\n `); - - if (node.tracks && node.tracks.length > 0) { - for (const trackId of node.tracks) { + const effectiveIds = getEffectiveTrackIds(node); + res.write(`\n `); + if (effectiveIds.length > 0) { + for (const trackId of effectiveIds) { res.write(`\n `); } } - res.write('\n '); } }; @@ -138,15 +142,13 @@ const streamPlaylistNodeCompact = async (res: any, node: any) => { res.write(''); } else { - const trackCount = node.tracks ? node.tracks.length : 0; - res.write(``); - - if (node.tracks && node.tracks.length > 0) { - for (const trackId of node.tracks) { + const effectiveIds = getEffectiveTrackIds(node); + res.write(``); + if (effectiveIds.length > 0) { + for (const trackId of effectiveIds) { res.write(``); } } - res.write(''); } }; @@ -167,15 +169,13 @@ const streamPlaylistNode = async (res: any, node: any, indent: number) => { res.write(`${spaces}\n`); } else { - const trackCount = node.tracks ? node.tracks.length : 0; - res.write(`${spaces}\n`); - - if (node.tracks && node.tracks.length > 0) { - for (const trackId of node.tracks) { + const effectiveIds = getEffectiveTrackIds(node); + res.write(`${spaces}\n`); + if (effectiveIds.length > 0) { + for (const trackId of effectiveIds) { res.write(`${spaces} \n`); } } - res.write(`${spaces}\n`); } }; diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 3b54475..fd6cffa 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -129,7 +129,8 @@ const RekordboxReader: React.FC = () => { totalDuration, loadNextPage, searchSongs, - searchQuery + searchQuery, + refresh } = usePaginatedSongs({ pageSize: 100, playlistName: currentPlaylist }); // Export library to XML @@ -651,6 +652,11 @@ const RekordboxReader: React.FC = () => { searchQuery={searchQuery} isSwitchingPlaylist={isSwitchingPlaylist} 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(); + }} /> diff --git a/packages/frontend/src/components/PaginatedSongList.tsx b/packages/frontend/src/components/PaginatedSongList.tsx index e610cde..cb81877 100644 --- a/packages/frontend/src/components/PaginatedSongList.tsx +++ b/packages/frontend/src/components/PaginatedSongList.tsx @@ -16,6 +16,7 @@ import { import { Search2Icon } from '@chakra-ui/icons'; import { FiPlay } from 'react-icons/fi'; import type { Song, PlaylistNode } from '../types/interfaces'; +import { api } from '../services/api'; import { formatDuration, formatTotalDuration } from '../utils/formatters'; import { useDebounce } from '../hooks/useDebounce'; import { PlaylistSelectionModal } from './PlaylistSelectionModal'; @@ -38,6 +39,7 @@ interface PaginatedSongListProps { depth?: number; isSwitchingPlaylist?: boolean; // New prop to indicate playlist switching onPlaySong?: (song: Song) => void; // New prop for playing songs + onReorder?: (orderedIds: string[]) => Promise; } // Memoized song item component to prevent unnecessary re-renders @@ -50,7 +52,10 @@ const SongItem = memo<{ showCheckbox: boolean; onPlaySong?: (song: Song) => 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 const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]); const handleClick = useCallback(() => { @@ -88,6 +93,9 @@ const SongItem = memo<{ onDragStart={(e) => { onDragStart(e, [song.id]); }} + onDragOver={onRowDragOver} + onDrop={onRowDrop} + onDragStartCapture={onRowDragStartCapture} > {showCheckbox && ( = memo(({ searchQuery, depth = 0, isSwitchingPlaylist = false, - onPlaySong + onPlaySong, + onReorder }) => { const [selectedSongs, setSelectedSongs] = useState>(new Set()); const dragSelectionRef = useRef(null); @@ -160,6 +169,9 @@ export const PaginatedSongList: React.FC = memo(({ const scrollContainerRef = useRef(null); const isTriggeringRef = useRef(false); const timeoutRef = useRef(null); + const [dragHoverIndex, setDragHoverIndex] = useState(null); + const [endDropHover, setEndDropHover] = useState(false); + const [isReorderDragging, setIsReorderDragging] = useState(false); // Store current values in refs to avoid stale closures const hasMoreRef = useRef(hasMore); @@ -247,12 +259,15 @@ export const PaginatedSongList: React.FC = memo(({ const handleDragEnd = useCallback(() => { setIsDragging(false); + setIsReorderDragging(false); + setEndDropHover(false); + setDragHoverIndex(null); dragSelectionRef.current = null; }, []); // Memoized song items to prevent unnecessary re-renders const songItems = useMemo(() => { - return songs.map(song => ( + return songs.map((song, index) => ( = memo(({ showCheckbox={selectedSongs.size > 0 || depth === 0} onPlaySong={onPlaySong} 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 @@ -440,7 +500,61 @@ export const PaginatedSongList: React.FC = memo(({ id="song-list-container" > - {songItems} + + {songs.map((song, index) => ( + + {dragHoverIndex === index && ( + + )} + {songItems[index]} + + ))} + + + {/* Drop zone to move item to end of playlist */} + {onReorder && currentPlaylist && selectedSongs.size === 0 && isReorderDragging && ( + { + 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 && ( + + )} + + )} {/* Loading indicator for infinite scroll or playlist switching */} {(loading || isSwitchingPlaylist) && ( diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index 55f0a82..cd94009 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -97,6 +97,33 @@ class Api { return response.json(); } + async reorderPlaylist(playlistName: string, orderedIds: string[]): Promise { + 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 { + 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 { + 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 { try { const response = await fetch(`${API_BASE_URL}/reset`, {