Folders working!

This commit is contained in:
Geert Rademakes 2025-04-24 23:45:14 +02:00
parent f82cb84397
commit 3a13c24301
12 changed files with 467 additions and 274 deletions

19
package-lock.json generated
View File

@ -2233,6 +2233,11 @@
"@types/send": "*"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="
},
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@ -5751,6 +5756,18 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -6038,12 +6055,14 @@
"@chakra-ui/transition": "^2.1.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@types/uuid": "^10.0.0",
"events": "^3.3.0",
"framer-motion": "^12.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.2",
"sax": "^1.4.1",
"uuid": "^11.1.0",
"xml2js": "^0.6.2",
"xmlbuilder": "^15.1.1"
},

View File

@ -1,8 +1,22 @@
import mongoose from 'mongoose';
const playlistSchema = new mongoose.Schema({
name: { type: String, required: true, unique: true },
tracks: [{ type: String, ref: 'Song' }],
const playlistNodeSchema = new mongoose.Schema({
name: { type: String, required: true },
type: { type: String, enum: ['folder', 'playlist'], default: 'playlist' },
tracks: [{ type: String, ref: 'Song', default: [] }],
children: [{ type: mongoose.Schema.Types.Mixed }], // This allows recursive structure
}, {
_id: true,
id: true,
timestamps: true,
toJSON: {
transform: function(doc, ret) {
ret.id = ret._id.toString();
delete ret._id;
delete ret.__v;
return ret;
}
}
});
export const Playlist = mongoose.model('Playlist', playlistSchema);
export const PlaylistNode = mongoose.model('PlaylistNode', playlistNodeSchema);

View File

@ -1,12 +1,12 @@
import express from 'express';
import { Playlist } from '../models/Playlist.js';
import { PlaylistNode } from '../models/Playlist.js';
const router = express.Router();
// Get all playlists
router.get('/', async (req, res) => {
try {
const playlists = await Playlist.find();
const playlists = await PlaylistNode.find();
res.json(playlists);
} catch (error) {
res.status(500).json({ message: 'Error fetching playlists', error });
@ -18,9 +18,9 @@ router.post('/batch', async (req, res) => {
try {
const playlists = req.body;
// Delete all existing playlists first
await Playlist.deleteMany({});
await PlaylistNode.deleteMany({});
// Insert new playlists
const result = await Playlist.insertMany(playlists);
const result = await PlaylistNode.insertMany(playlists);
res.status(201).json(result);
} catch (error) {
res.status(500).json({ message: 'Error creating playlists', error });

View File

@ -19,12 +19,14 @@
"@chakra-ui/transition": "^2.1.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@types/uuid": "^10.0.0",
"events": "^3.3.0",
"framer-motion": "^12.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.2",
"sax": "^1.4.1",
"uuid": "^11.1.0",
"xml2js": "^0.6.2",
"xmlbuilder": "^15.1.1"
},

View File

@ -8,7 +8,8 @@ import { SongDetails } from "./components/SongDetails";
import { useXmlParser } from "./hooks/useXmlParser";
import { exportToXml } from "./services/xmlService";
import { api } from "./services/api";
import { Song } from "./types/interfaces";
import type { Song, PlaylistNode } from "./types/interfaces";
import { v4 as uuidv4 } from "uuid";
import "./App.css";
const StyledFileInput = ({ isMobile = false }) => {
@ -92,6 +93,27 @@ const ResizeHandle = ({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => v
</Box>
);
const findPlaylistByName = (playlists: PlaylistNode[], name: string): PlaylistNode | null => {
for (const playlist of playlists) {
if (playlist.name === name) return playlist;
if (playlist.type === 'folder' && playlist.children) {
const found = findPlaylistByName(playlist.children, name);
if (found) return found;
}
}
return null;
};
const getAllPlaylistTracks = (node: PlaylistNode): string[] => {
if (!node.type || node.type === 'playlist') { // Handle both old and new playlist formats
return node.tracks || [];
}
if (node.type === 'folder' && node.children) {
return node.children.flatMap(child => getAllPlaylistTracks(child));
}
return [];
};
export default function RekordboxReader() {
const { songs, playlists, setPlaylists, loading } = useXmlParser();
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
@ -120,7 +142,7 @@ export default function RekordboxReader() {
// If we've loaded the data and the playlist doesn't exist
if (initialLoadDone.current &&
currentPlaylist !== "All Songs" &&
!playlists.some(p => p.name === currentPlaylist)) {
!findPlaylistByName(playlists, currentPlaylist)) {
navigate("/", { replace: true });
}
}, [currentPlaylist, playlists, navigate, loading]);
@ -137,7 +159,13 @@ export default function RekordboxReader() {
};
const handleCreatePlaylist = async (name: string) => {
const newPlaylist = { name, tracks: [] };
const newPlaylist: PlaylistNode = {
id: uuidv4(),
name,
type: 'playlist',
tracks: [],
children: undefined
};
const updatedPlaylists = [...playlists, newPlaylist];
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
@ -145,11 +173,29 @@ export default function RekordboxReader() {
};
const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => {
const updatedPlaylists = playlists.map((playlist) =>
playlist.name === playlistName
? { ...playlist, tracks: [...new Set([...playlist.tracks, ...songIds])] }
: playlist
);
const updatedPlaylists = playlists.map(node => {
if (node.name === playlistName && node.type === 'playlist') {
return {
...node,
tracks: [...new Set([...(node.tracks || []), ...songIds])]
};
}
if (node.type === 'folder' && node.children) {
return {
...node,
children: node.children.map(child => {
if (child.name === playlistName && child.type === 'playlist') {
return {
...child,
tracks: [...new Set([...(child.tracks || []), ...songIds])]
};
}
return child;
})
};
}
return node;
});
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
};
@ -157,11 +203,29 @@ export default function RekordboxReader() {
const handleRemoveFromPlaylist = async (songIds: string[]) => {
if (currentPlaylist === "All Songs") return;
const updatedPlaylists = playlists.map((playlist) =>
playlist.name === currentPlaylist
? { ...playlist, tracks: playlist.tracks.filter(id => !songIds.includes(id)) }
: playlist
);
const updatedPlaylists = playlists.map(node => {
if (node.name === currentPlaylist && node.type === 'playlist') {
return {
...node,
tracks: (node.tracks || []).filter(id => !songIds.includes(id))
};
}
if (node.type === 'folder' && node.children) {
return {
...node,
children: node.children.map(child => {
if (child.name === currentPlaylist && child.type === 'playlist') {
return {
...child,
tracks: (child.tracks || []).filter(id => !songIds.includes(id))
};
}
return child;
})
};
}
return node;
});
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
};
@ -189,9 +253,17 @@ export default function RekordboxReader() {
const displayedSongs = currentPlaylist === "All Songs"
? songs
: songs.filter((song) =>
playlists.find((p) => p.name === currentPlaylist)?.tracks.includes(song.id)
);
: songs.filter((song: Song) => {
// Try to find the playlist either in nested structure or at root level
const playlist = findPlaylistByName(playlists, currentPlaylist) ||
playlists.find(p => p.name === currentPlaylist);
if (!playlist) return false;
// Use getAllPlaylistTracks for both nested and root playlists
const playlistTracks = getAllPlaylistTracks(playlist);
return playlistTracks.includes(song.id);
});
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault();

View File

@ -18,13 +18,14 @@ import {
MenuList,
MenuItem,
IconButton,
Collapse,
} from "@chakra-ui/react";
import { ChevronDownIcon, DeleteIcon } from "@chakra-ui/icons";
import { ChevronDownIcon, DeleteIcon, ChevronRightIcon } from "@chakra-ui/icons";
import React, { useState } from "react";
import { Playlist } from "../types/Playlist";
import { PlaylistNode } from "../types/interfaces";
interface PlaylistManagerProps {
playlists: Playlist[];
playlists: PlaylistNode[];
selectedItem: string | null;
onPlaylistCreate: (name: string) => void;
onPlaylistSelect: (name: string | null) => void;
@ -50,6 +51,97 @@ const getButtonStyles = (isSelected: boolean) => ({
},
});
interface PlaylistItemProps {
node: PlaylistNode;
level: number;
selectedItem: string | null;
onPlaylistSelect: (name: string | null) => void;
onPlaylistDelete: (name: string) => void;
}
const PlaylistItem: React.FC<PlaylistItemProps> = ({
node,
level,
selectedItem,
onPlaylistSelect,
onPlaylistDelete,
}) => {
const [isOpen, setIsOpen] = useState(true);
const indent = level * 16; // 16px indent per level
if (node.type === 'folder') {
return (
<Box>
<Flex align="center" gap={1}>
<Button
flex={1}
{...getButtonStyles(false)}
onClick={() => setIsOpen(!isOpen)}
pl={indent}
leftIcon={
<ChevronRightIcon
transform={isOpen ? 'rotate(90deg)' : 'none'}
transition="transform 0.2s"
/>
}
>
{node.name}
</Button>
</Flex>
<Collapse in={isOpen}>
<VStack spacing={1} align="stretch">
{node.children?.map((child, index) => (
<PlaylistItem
key={child.id || index}
node={child}
level={level + 1}
selectedItem={selectedItem}
onPlaylistSelect={onPlaylistSelect}
onPlaylistDelete={onPlaylistDelete}
/>
))}
</VStack>
</Collapse>
</Box>
);
}
return (
<Flex align="center" gap={1}>
<Button
flex={1}
{...getButtonStyles(selectedItem === node.name)}
onClick={() => onPlaylistSelect(node.name)}
pl={indent + 24} // Extra indent for playlists inside folders
>
{node.name}
</Button>
<Menu>
<MenuButton
as={IconButton}
aria-label="Playlist options"
icon={<ChevronDownIcon />}
variant="ghost"
size="sm"
color="gray.400"
_hover={{ color: "white", bg: "whiteAlpha.200" }}
/>
<MenuList bg="gray.800" borderColor="gray.700">
<MenuItem
bg="gray.800"
color="red.300"
_hover={{ bg: "gray.700" }}
icon={<DeleteIcon />}
onClick={() => onPlaylistDelete(node.name)}
>
Delete Playlist
</MenuItem>
</MenuList>
</Menu>
</Flex>
);
};
export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
playlists,
selectedItem,
@ -77,38 +169,15 @@ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
>
All Songs
</Button>
{playlists.map((playlist) => (
<Flex key={playlist._id} align="center" gap={1}>
<Button
flex={1}
{...getButtonStyles(selectedItem === playlist.name)}
onClick={() => onPlaylistSelect(playlist.name)}
>
{playlist.name}
</Button>
<Menu>
<MenuButton
as={IconButton}
aria-label="Playlist options"
icon={<ChevronDownIcon />}
variant="ghost"
size="sm"
color="gray.400"
_hover={{ color: "white", bg: "whiteAlpha.200" }}
{playlists.map((node, index) => (
<PlaylistItem
key={node.id || index}
node={node}
level={0}
selectedItem={selectedItem}
onPlaylistSelect={onPlaylistSelect}
onPlaylistDelete={onPlaylistDelete}
/>
<MenuList bg="gray.800" borderColor="gray.700">
<MenuItem
bg="gray.800"
color="red.300"
_hover={{ bg: "gray.700" }}
icon={<DeleteIcon />}
onClick={() => onPlaylistDelete(playlist.name)}
>
Delete Playlist
</MenuItem>
</MenuList>
</Menu>
</Flex>
))}
</VStack>

View File

@ -1,3 +1,4 @@
import React, { ChangeEvent } from 'react';
import {
Box,
Flex,
@ -5,25 +6,25 @@ import {
Button,
IconButton,
HStack,
Menu as ChakraMenu,
MenuButton as ChakraMenuButton,
MenuList as ChakraMenuList,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
MenuGroup,
Checkbox as ChakraCheckbox
} from "@chakra-ui/react";
Checkbox
} from '@chakra-ui/react';
import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
import { Song } from "../types/interfaces";
import { useState, useCallback, useMemo, forwardRef } from "react";
import { ChangeEvent, MouseEvent } from "react";
import type { Song, PlaylistNode } from "../types/interfaces";
import { useState, useCallback, useMemo } from "react";
import type { MouseEvent } from 'react';
import { v4 as uuidv4 } from 'uuid';
interface SongListProps {
songs: Song[];
onAddToPlaylist: (songIds: string[], playlistName: string) => void;
onRemoveFromPlaylist?: (songIds: string[]) => void;
playlists: { name: string }[];
playlists: PlaylistNode[];
onSongSelect: (song: Song) => void;
selectedSongId: string | null;
currentPlaylist: string | null;
@ -41,6 +42,22 @@ export const SongList: React.FC<SongListProps> = ({
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
// Helper function to get all playlists (excluding folders) from the playlist tree
const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => {
let result: PlaylistNode[] = [];
for (const node of nodes) {
if (node.type === 'playlist') {
result.push(node);
} else if (node.type === 'folder' && node.children) {
result = result.concat(getAllPlaylists(node.children));
}
}
return result;
}, []);
// Get flattened list of all playlists
const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]);
const filteredSongs = useMemo(() => {
if (!searchQuery) return songs;
const query = searchQuery.toLowerCase();
@ -76,7 +93,7 @@ export const SongList: React.FC<SongListProps> = ({
}
};
const handleSongClick = (e: MouseEvent, song: Song) => {
const handleSongClick = (e: MouseEvent<HTMLButtonElement>, song: Song) => {
e.stopPropagation();
onSongSelect(song);
};
@ -102,7 +119,7 @@ export const SongList: React.FC<SongListProps> = ({
{/* Bulk Actions Toolbar */}
<Flex justify="space-between" align="center" mb={4} p={2} bg="gray.800" borderRadius="md">
<HStack>
<ChakraCheckbox
<Checkbox
isChecked={selectedSongs.size === filteredSongs.length}
isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < filteredSongs.length}
onChange={toggleSelectAll}
@ -118,35 +135,30 @@ export const SongList: React.FC<SongListProps> = ({
{selectedSongs.size === 0
? "Select All"
: `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`}
</ChakraCheckbox>
</Checkbox>
</HStack>
{selectedSongs.size > 0 && (
<ChakraMenu>
<ChakraMenuButton
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
size="sm"
colorScheme="blue"
>
Actions
</ChakraMenuButton>
<ChakraMenuList>
<MenuGroup title="Add to Playlist">
{playlists
.filter(p => p.name !== currentPlaylist)
.map((playlist) => (
</MenuButton>
<MenuList>
{allPlaylists.map((playlist) => (
<MenuItem
key={playlist.name}
key={playlist.id}
onClick={() => {
handleBulkAddToPlaylist(playlist.name);
setSelectedSongs(new Set());
}}
>
{playlist.name}
Add to {playlist.name}
</MenuItem>
))}
</MenuGroup>
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
@ -161,76 +173,36 @@ export const SongList: React.FC<SongListProps> = ({
</MenuItem>
</>
)}
</ChakraMenuList>
</ChakraMenu>
</MenuList>
</Menu>
)}
</Flex>
{/* Song List */}
<Flex direction="column" gap={2}>
{filteredSongs.map((song) => (
<Box
key={song.id}
borderWidth="1px"
borderRadius="md"
p={2}
shadow="sm"
bg={selectedSongId === song.id ? "blue.800" : selectedSongs.has(song.id) ? "gray.700" : "transparent"}
_hover={{ bg: selectedSongId === song.id ? "blue.700" : selectedSongs.has(song.id) ? "gray.700" : "gray.900" }}
transition="background-color 0.2s"
cursor="pointer"
onClick={(e: MouseEvent) => handleSongClick(e, song)}
>
<Flex justify="space-between" align="center">
<HStack>
<ChakraCheckbox
<Flex key={uuidv4()} alignItems="center" p={2} borderBottom="1px" borderColor="gray.200">
<Checkbox
isChecked={selectedSongs.has(song.id)}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
toggleSelection(song.id);
}}
colorScheme="blue"
onClick={(e: MouseEvent) => e.stopPropagation()}
sx={{
'& > span:first-of-type': {
opacity: 1,
border: '2px solid',
borderColor: 'gray.500'
}
}}
mr={4}
/>
<Box>
<Text fontWeight="bold" fontSize="sm">{song.title}</Text>
<Text color="gray.500" fontSize="xs">{song.artist}</Text>
<Box flex="1">
<Text fontWeight="bold">{song.title}</Text>
<Text fontSize="sm" color="gray.600">{song.artist}</Text>
</Box>
</HStack>
{!selectedSongs.has(song.id) && (
<ChakraMenu>
<ChakraMenuButton
as={IconButton}
aria-label="Add to playlist"
size="sm"
variant="ghost"
onClick={(e: MouseEvent) => e.stopPropagation()}
>
</ChakraMenuButton>
<ChakraMenuList onClick={(e: MouseEvent) => e.stopPropagation()}>
{playlists.map((playlist) => (
<MenuItem
key={playlist.name}
value={playlist.name}
onClick={() => onAddToPlaylist([song.id], playlist.name)}
>
{playlist.name}
</MenuItem>
))}
</ChakraMenuList>
</ChakraMenu>
)}
<Menu>
<MenuButton as={IconButton} aria-label="Options" icon={<span></span>} variant="ghost" />
<MenuList>
<MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Select</MenuItem>
<MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Add to Playlist</MenuItem>
<MenuItem onClick={(e: MouseEvent<HTMLButtonElement>) => handleSongClick(e, song)}>Remove from Playlist</MenuItem>
</MenuList>
</Menu>
</Flex>
</Box>
))}
</Flex>
</Box>

View File

@ -1,11 +1,11 @@
import { useState, useEffect } from "react";
import { Song, Playlist } from "../types/interfaces";
import type { Song, PlaylistNode } from "../types/interfaces";
import { parseXmlFile } from "../services/xmlService";
import { api } from "../services/api";
export const useXmlParser = () => {
const [songs, setSongs] = useState<Song[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [playlists, setPlaylists] = useState<PlaylistNode[]>([]);
const [loading, setLoading] = useState(true);
// Load data from the backend on mount

View File

@ -1,70 +1,43 @@
import { Song, Playlist } from '../types/interfaces';
import type { Song, PlaylistNode } from '../types/interfaces';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
throw new Error(error.message || 'An error occurred');
}
return response.json();
}
export const api = {
class Api {
async getSongs(): Promise<Song[]> {
console.log('Fetching songs from API...');
const response = await fetch(`${API_URL}/songs`);
const data = await handleResponse<Song[]>(response);
console.log(`Received ${data.length} songs from API`);
return data;
},
const response = await fetch(`${API_BASE_URL}/songs`);
if (!response.ok) throw new Error('Failed to fetch songs');
return response.json();
}
async saveSongs(songs: Song[]): Promise<Song[]> {
console.log(`Saving ${songs.length} songs to API...`);
const response = await fetch(`${API_URL}/songs/batch`, {
const response = await fetch(`${API_BASE_URL}/songs/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(songs),
});
const data = await handleResponse<Song[]>(response);
console.log(`Successfully saved ${data.length} songs`);
return data;
},
if (!response.ok) throw new Error('Failed to save songs');
return response.json();
}
async deleteSongs(songIds: string[]): Promise<void> {
console.log(`Deleting ${songIds.length} songs from API...`);
const response = await fetch(`${API_URL}/songs/batch`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids: songIds }),
});
await handleResponse<void>(response);
console.log(`Successfully deleted ${songIds.length} songs`);
},
async getPlaylists(): Promise<PlaylistNode[]> {
const response = await fetch(`${API_BASE_URL}/playlists`);
if (!response.ok) throw new Error('Failed to fetch playlists');
return response.json();
}
async getPlaylists(): Promise<Playlist[]> {
console.log('Fetching playlists from API...');
const response = await fetch(`${API_URL}/playlists`);
const data = await handleResponse<Playlist[]>(response);
console.log(`Received ${data.length} playlists from API`);
return data;
},
async savePlaylists(playlists: Playlist[]): Promise<Playlist[]> {
console.log(`Saving ${playlists.length} playlists to API...`);
const response = await fetch(`${API_URL}/playlists/batch`, {
async savePlaylists(playlists: PlaylistNode[]): Promise<PlaylistNode[]> {
const response = await fetch(`${API_BASE_URL}/playlists/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(playlists),
});
const data = await handleResponse<Playlist[]>(response);
console.log(`Successfully saved ${data.length} playlists`);
return data;
},
};
if (!response.ok) throw new Error('Failed to save playlists');
return response.json();
}
}
export const api = new Api();

View File

@ -1,8 +1,29 @@
import { parseStringPromise } from "xml2js";
import { create } from "xmlbuilder";
import { Song, Playlist } from "../types/interfaces";
import { Song, PlaylistNode } from "../types/interfaces";
import { v4 as uuidv4 } from 'uuid';
export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], playlists: Playlist[] }> => {
const parseNode = (node: any): PlaylistNode => {
const type = node.$.Type === "0" ? "folder" : "playlist";
const result: PlaylistNode = {
id: uuidv4(),
name: node.$.Name,
type,
children: [],
tracks: [],
};
if (type === "folder" && node.NODE) {
result.children = node.NODE.map((childNode: any) => parseNode(childNode));
} else if (type === "playlist") {
// Always initialize tracks array, even if TRACK is not present
result.tracks = node.TRACK ? node.TRACK.map((track: any) => track.$.Key) : [];
}
return result;
};
export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], playlists: PlaylistNode[] }> => {
try {
const result = await parseStringPromise(xmlText);
@ -40,10 +61,24 @@ export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], pl
} : undefined,
}));
const playlists: Playlist[] = result.DJ_PLAYLISTS.PLAYLISTS[0].NODE[0].NODE.map((playlist: any) => ({
name: playlist.$.Name,
tracks: playlist.TRACK ? playlist.TRACK.map((t: any) => t.$.Key) : [],
}));
// Parse the playlist structure
const playlistsRoot = result.DJ_PLAYLISTS.PLAYLISTS[0];
let playlists: PlaylistNode[] = [];
// Handle root level playlists (directly under PLAYLISTS)
if (playlistsRoot.NODE) {
for (const node of playlistsRoot.NODE) {
// If this is a root playlist (not the ROOT folder)
if (node.$.Name !== 'ROOT') {
playlists.push(parseNode(node));
} else {
// This is the ROOT folder, parse its children
if (node.NODE) {
playlists.push(...node.NODE.map((childNode: any) => parseNode(childNode)));
}
}
}
}
return { songs: tracks, playlists };
} catch (err) {
@ -52,69 +87,85 @@ export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], pl
}
};
export const exportToXml = (songs: Song[], playlists: Playlist[]): string => {
const doc = create("DJ_PLAYLISTS", { Version: "1.0.0" });
const collection = doc.ele("COLLECTION", {
Entries: songs.length.toString(),
});
const buildXmlNode = (node: PlaylistNode): any => {
const xmlNode: any = {
'@Type': node.type === 'folder' ? '0' : '1',
'@Name': node.name,
};
songs.forEach((song) => {
const trackElement = collection.ele("TRACK", {
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 || "",
});
if (node.type === 'folder') {
xmlNode['@Count'] = (node.children || []).length;
if (node.children && node.children.length > 0) {
xmlNode.NODE = node.children.map(child => 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 => ({
'@Key': trackId
}));
}
}
if (song.tempo) {
trackElement.ele("TEMPO", {
Inizio: song.tempo.inizio || "",
Bpm: song.tempo.bpm || "",
Metro: song.tempo.metro || "",
Battito: song.tempo.battito || "",
});
return xmlNode;
};
export const exportToXml = (songs: Song[], playlists: PlaylistNode[]): 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))
}
}
}
});
const playlistsNode = doc.ele("PLAYLISTS");
const playlistsFolder = playlistsNode.ele("NODE", {
Name: "ROOT",
Type: "0",
Count: playlists.length.toString(),
});
playlists.forEach((playlist) => {
const playlistNode = playlistsFolder.ele("NODE", {
Name: playlist.name,
KeyType: "0",
Type: "1",
Entries: playlist.tracks.length.toString(),
});
playlist.tracks.forEach((trackId) => {
playlistNode.ele("TRACK", { Key: trackId });
});
});
return doc.end({ pretty: true });
return xml.end({ pretty: true });
};

View File

@ -0,0 +1,12 @@
export interface Song {
_id: string;
title: string;
artist: string;
genre: string;
bpm: number;
key: string;
rating: number;
comments: string;
createdAt: string;
updatedAt: string;
}

View File

@ -32,6 +32,15 @@ export interface Song {
};
}
export interface PlaylistNode {
id: string;
name: string;
type: 'folder' | 'playlist';
children?: PlaylistNode[];
tracks?: string[];
}
// Keep the old Playlist interface for backward compatibility during transition
export interface Playlist {
name: string;
tracks: string[];