From 7286140bd5f7f3f326c7107f2693de9971a1c72b Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Wed, 6 Aug 2025 11:26:51 +0200 Subject: [PATCH] feat: Move export functionality to backend for complete database export - Create backend XML service for generating Rekordbox-compatible XML - Add /api/songs/export endpoint that exports entire database - Frontend now calls backend API instead of local function - Exports ALL songs and playlists from database, not just loaded ones - Proper file download with correct headers and filename --- packages/backend/src/routes/songs.ts | 31 ++++++++ packages/backend/src/services/xmlService.ts | 86 +++++++++++++++++++++ packages/frontend/src/App.tsx | 13 ++-- 3 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/services/xmlService.ts diff --git a/packages/backend/src/routes/songs.ts b/packages/backend/src/routes/songs.ts index 4681cb0..464cc9a 100644 --- a/packages/backend/src/routes/songs.ts +++ b/packages/backend/src/routes/songs.ts @@ -195,6 +195,37 @@ router.get('/count', async (req: Request, res: Response) => { } }); +// Export library to XML format +router.get('/export', async (req: Request, res: Response) => { + try { + console.log('Exporting library to XML format...'); + + // Get all songs from database + const allSongs = await Song.find({}).lean(); + console.log(`Found ${allSongs.length} songs to export`); + + // Get all playlists from database + const allPlaylists = await Playlist.find({}).lean(); + console.log(`Found ${allPlaylists.length} playlists to export`); + + // Import the XML generation function + const { exportToXml } = await import('../services/xmlService.js'); + + // Generate XML content + const xmlContent = exportToXml(allSongs, allPlaylists); + + // 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"`); + + console.log('XML export completed successfully'); + res.send(xmlContent); + } catch (error) { + console.error('Error exporting library:', error); + res.status(500).json({ message: 'Error exporting library', error }); + } +}); + // Create multiple songs router.post('/batch', async (req: Request, res: Response) => { try { diff --git a/packages/backend/src/services/xmlService.ts b/packages/backend/src/services/xmlService.ts new file mode 100644 index 0000000..d985cc8 --- /dev/null +++ b/packages/backend/src/services/xmlService.ts @@ -0,0 +1,86 @@ +import { create } from "xmlbuilder"; +import { Song } from "../models/Song.js"; +import { Playlist } from "../models/Playlist.js"; + +const buildXmlNode = (node: any): any => { + const xmlNode: any = { + '@Type': node.type === 'folder' ? '0' : '1', + '@Name': node.name, + }; + + if (node.type === 'folder') { + xmlNode['@Count'] = (node.children || []).length; + if (node.children && node.children.length > 0) { + xmlNode.NODE = node.children.map((child: any) => buildXmlNode(child)); + } + } else { + // For playlists, always include KeyType and Entries + 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) => ({ + '@Key': trackId + })); + } + } + + return xmlNode; +}; + +export const exportToXml = (songs: any[], playlists: any[]): string => { + const xml = create({ + DJ_PLAYLISTS: { + '@Version': '1.0.0', + COLLECTION: { + '@Entries': songs.length, + TRACK: songs.map(song => ({ + '@TrackID': song.id, + '@Name': song.title, + '@Artist': song.artist, + '@Composer': song.composer, + '@Album': song.album, + '@Grouping': song.grouping, + '@Genre': song.genre, + '@Kind': song.kind, + '@Size': song.size, + '@TotalTime': song.totalTime, + '@DiscNumber': song.discNumber, + '@TrackNumber': song.trackNumber, + '@Year': song.year, + '@AverageBpm': song.averageBpm, + '@DateAdded': song.dateAdded, + '@BitRate': song.bitRate, + '@SampleRate': song.sampleRate, + '@Comments': song.comments, + '@PlayCount': song.playCount, + '@Rating': song.rating, + '@Location': song.location, + '@Remixer': song.remixer, + '@Tonality': song.tonality, + '@Label': song.label, + '@Mix': song.mix, + ...(song.tempo ? { + TEMPO: { + '@Inizio': song.tempo.inizio, + '@Bpm': song.tempo.bpm, + '@Metro': song.tempo.metro, + '@Battito': song.tempo.battito + } + } : {}) + })) + }, + PLAYLISTS: { + NODE: { + '@Type': '0', + '@Name': 'ROOT', + '@Count': playlists.length, + NODE: playlists.map(playlist => buildXmlNode(playlist)) + } + } + } + }); + + return xml.end({ pretty: true }); +}; \ No newline at end of file diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 0822b7d..dbdcc70 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -9,7 +9,6 @@ import { Configuration } from "./pages/Configuration"; import { useXmlParser } from "./hooks/useXmlParser"; import { usePaginatedSongs } from "./hooks/usePaginatedSongs"; import { formatTotalDuration } from "./utils/formatters"; -import { exportToXml } from "./services/xmlService"; import { api } from "./services/api"; import type { Song, PlaylistNode } from "./types/interfaces"; @@ -116,10 +115,14 @@ export default function RekordboxReader() { } = usePaginatedSongs({ pageSize: 50, playlistName: currentPlaylist }); // Export library to XML - const handleExportLibrary = useCallback(() => { + const handleExportLibrary = useCallback(async () => { try { - const xmlContent = exportToXml(songs, playlists); - const blob = new Blob([xmlContent], { type: 'application/xml' }); + const response = await fetch('/api/songs/export'); + if (!response.ok) { + throw new Error('Failed to export library'); + } + + const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -131,7 +134,7 @@ export default function RekordboxReader() { } catch (error) { console.error('Failed to export library:', error); } - }, [songs, playlists]); + }, []); // Check if database is initialized (has songs or playlists) - moved after useDisclosure useEffect(() => {