frontend AND backend bro!!
This commit is contained in:
parent
32823fd40d
commit
901c78990b
2113
package-lock.json
generated
2113
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
49
package.json
49
package.json
@ -1,43 +1,18 @@
|
||||
{
|
||||
"name": "rekordbox-reader",
|
||||
"name": "rekordbox-reader-monorepo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/modal": "^2.3.1",
|
||||
"@chakra-ui/react": "^3.12.0",
|
||||
"@chakra-ui/transition": "^2.1.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"events": "^3.3.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sax": "^1.4.1",
|
||||
"xml2js": "^0.6.2",
|
||||
"xmlbuilder": "^15.1.1"
|
||||
"dev:frontend": "cd packages/frontend && npm run dev",
|
||||
"dev:backend": "cd packages/backend && npm run dev",
|
||||
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
|
||||
"build": "npm run build --workspaces",
|
||||
"install:all": "npm install && npm install --workspaces"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.13",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"vite": "^6.2.0"
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/backend/.env
Normal file
2
packages/backend/.env
Normal file
@ -0,0 +1,2 @@
|
||||
PORT=3000
|
||||
MONGODB_URI=mongodb://localhost:27017/rekordbox
|
||||
24
packages/backend/package.json
Normal file
24
packages/backend/package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@rekordbox-reader/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.3",
|
||||
"mongoose": "^8.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.24",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
27
packages/backend/src/index.ts
Normal file
27
packages/backend/src/index.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import mongoose from 'mongoose';
|
||||
import dotenv from 'dotenv';
|
||||
import { songsRouter } from './routes/songs.js';
|
||||
import { playlistsRouter } from './routes/playlists.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Connect to MongoDB
|
||||
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/rekordbox')
|
||||
.then(() => console.log('Connected to MongoDB'))
|
||||
.catch((err) => console.error('MongoDB connection error:', err));
|
||||
|
||||
// Routes
|
||||
app.use('/api/songs', songsRouter);
|
||||
app.use('/api/playlists', playlistsRouter);
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
});
|
||||
8
packages/backend/src/models/Playlist.ts
Normal file
8
packages/backend/src/models/Playlist.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const playlistSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true, unique: true },
|
||||
tracks: [{ type: String, ref: 'Song' }],
|
||||
});
|
||||
|
||||
export const Playlist = mongoose.model('Playlist', playlistSchema);
|
||||
53
packages/backend/src/models/Song.ts
Normal file
53
packages/backend/src/models/Song.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const tempoSchema = new mongoose.Schema({
|
||||
inizio: String,
|
||||
bpm: String,
|
||||
metro: String,
|
||||
battito: String,
|
||||
}, { _id: false });
|
||||
|
||||
const songSchema = new mongoose.Schema({
|
||||
id: { type: String, required: true, unique: true },
|
||||
title: { type: String, required: true },
|
||||
artist: String,
|
||||
composer: String,
|
||||
album: String,
|
||||
grouping: String,
|
||||
genre: String,
|
||||
kind: String,
|
||||
size: String,
|
||||
totalTime: String,
|
||||
discNumber: String,
|
||||
trackNumber: String,
|
||||
year: String,
|
||||
averageBpm: String,
|
||||
dateAdded: String,
|
||||
bitRate: String,
|
||||
sampleRate: String,
|
||||
comments: String,
|
||||
playCount: String,
|
||||
rating: String,
|
||||
location: String,
|
||||
remixer: String,
|
||||
tonality: String,
|
||||
label: String,
|
||||
mix: String,
|
||||
tempo: tempoSchema,
|
||||
}, {
|
||||
timestamps: true,
|
||||
versionKey: false,
|
||||
toJSON: {
|
||||
transform: function(doc, ret) {
|
||||
ret.id = ret.id || ret._id;
|
||||
delete ret._id;
|
||||
delete ret.__v;
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure index on id field
|
||||
songSchema.index({ id: 1 }, { unique: true });
|
||||
|
||||
export const Song = mongoose.model('Song', songSchema);
|
||||
30
packages/backend/src/routes/playlists.ts
Normal file
30
packages/backend/src/routes/playlists.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import express from 'express';
|
||||
import { Playlist } from '../models/Playlist.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all playlists
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const playlists = await Playlist.find();
|
||||
res.json(playlists);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error fetching playlists', error });
|
||||
}
|
||||
});
|
||||
|
||||
// Create or update playlists
|
||||
router.post('/batch', async (req, res) => {
|
||||
try {
|
||||
const playlists = req.body;
|
||||
// Delete all existing playlists first
|
||||
await Playlist.deleteMany({});
|
||||
// Insert new playlists
|
||||
const result = await Playlist.insertMany(playlists);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error creating playlists', error });
|
||||
}
|
||||
});
|
||||
|
||||
export const playlistsRouter = router;
|
||||
40
packages/backend/src/routes/songs.ts
Normal file
40
packages/backend/src/routes/songs.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { Song } from '../models/Song.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all songs
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
console.log('Fetching songs from database...');
|
||||
const songs = await Song.find().lean();
|
||||
console.log(`Found ${songs.length} songs`);
|
||||
res.json(songs);
|
||||
} catch (error) {
|
||||
console.error('Error fetching songs:', error);
|
||||
res.status(500).json({ message: 'Error fetching songs', error });
|
||||
}
|
||||
});
|
||||
|
||||
// Create multiple songs
|
||||
router.post('/batch', async (req: Request, res: Response) => {
|
||||
try {
|
||||
console.log('Received batch upload request');
|
||||
const songs = req.body;
|
||||
console.log(`Attempting to save ${songs.length} songs`);
|
||||
|
||||
// Delete all existing songs first
|
||||
await Song.deleteMany({});
|
||||
console.log('Cleared existing songs');
|
||||
|
||||
// Insert new songs
|
||||
const result = await Song.insertMany(songs);
|
||||
console.log(`Successfully saved ${result.length} songs`);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
console.error('Error creating songs:', error);
|
||||
res.status(500).json({ message: 'Error creating songs', error });
|
||||
}
|
||||
});
|
||||
|
||||
export const songsRouter = router;
|
||||
14
packages/backend/tsconfig.json
Normal file
14
packages/backend/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
5249
packages/frontend/package-lock.json
generated
Normal file
5249
packages/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
packages/frontend/package.json
Normal file
43
packages/frontend/package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "rekordbox-reader",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/modal": "^2.3.1",
|
||||
"@chakra-ui/react": "^3.12.0",
|
||||
"@chakra-ui/transition": "^2.1.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"events": "^3.3.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sax": "^1.4.1",
|
||||
"xml2js": "^0.6.2",
|
||||
"xmlbuilder": "^15.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.13",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
91
packages/frontend/src/App.tsx
Normal file
91
packages/frontend/src/App.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { Box, Button, Flex, Heading, Input, Spinner, Text } from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { SongList } from "./components/SongList";
|
||||
import { PlaylistManager } from "./components/PlaylistManager";
|
||||
import { useXmlParser } from "./hooks/useXmlParser";
|
||||
import { exportToXml } from "./services/xmlService";
|
||||
import { api } from "./services/api";
|
||||
import "./App.css";
|
||||
|
||||
export default function RekordboxReader() {
|
||||
const { songs, playlists, setPlaylists, handleFileUpload, loading } = useXmlParser();
|
||||
const [selectedItem, setSelectedItem] = useState<string | null>("All Songs");
|
||||
|
||||
const handleCreatePlaylist = async (name: string) => {
|
||||
const newPlaylist = { name, tracks: [] };
|
||||
const updatedPlaylists = [...playlists, newPlaylist];
|
||||
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
|
||||
setPlaylists(savedPlaylists);
|
||||
};
|
||||
|
||||
const handleAddSongToPlaylist = async (songId: string, playlistName: string) => {
|
||||
const updatedPlaylists = playlists.map((playlist) =>
|
||||
playlist.name === playlistName
|
||||
? { ...playlist, tracks: [...playlist.tracks, songId] }
|
||||
: playlist
|
||||
);
|
||||
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
|
||||
setPlaylists(savedPlaylists);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const xmlContent = exportToXml(songs, playlists);
|
||||
const blob = new Blob([xmlContent], { type: "application/xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "rekordbox_playlists.xml";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
const displayedSongs = selectedItem === "All Songs"
|
||||
? songs
|
||||
: songs.filter((song) =>
|
||||
playlists.find((p) => p.name === selectedItem)?.tracks.includes(song.id)
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex height="100vh" align="center" justify="center" direction="column" gap={4}>
|
||||
<Spinner size="xl" />
|
||||
<Text>Loading your library...</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={5}>
|
||||
<Heading mb={4}>Rekordbox Reader</Heading>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".xml"
|
||||
onChange={handleFileUpload}
|
||||
mb={4}
|
||||
/>
|
||||
<Flex gap={4}>
|
||||
<Box w="250px">
|
||||
<PlaylistManager
|
||||
playlists={playlists}
|
||||
selectedItem={selectedItem}
|
||||
onPlaylistCreate={handleCreatePlaylist}
|
||||
onPlaylistSelect={setSelectedItem}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<SongList
|
||||
songs={displayedSongs}
|
||||
onAddToPlaylist={handleAddSongToPlaylist}
|
||||
playlists={playlists}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
{songs.length > 0 && (
|
||||
<Button onClick={handleExport} mt={4} colorScheme="blue">
|
||||
Export XML
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
89
packages/frontend/src/components/PlaylistManager.tsx
Normal file
89
packages/frontend/src/components/PlaylistManager.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Flex,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
} from "@chakra-ui/modal";
|
||||
import { useState } from "react";
|
||||
import { Playlist } from "../types/interfaces";
|
||||
|
||||
interface PlaylistManagerProps {
|
||||
playlists: Playlist[];
|
||||
selectedItem: string | null;
|
||||
onPlaylistCreate: (name: string) => void;
|
||||
onPlaylistSelect: (name: string) => void;
|
||||
}
|
||||
|
||||
export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
|
||||
playlists,
|
||||
selectedItem,
|
||||
onPlaylistCreate,
|
||||
onPlaylistSelect,
|
||||
}) => {
|
||||
const disclosure = useDisclosure();
|
||||
const [newPlaylistName, setNewPlaylistName] = useState("");
|
||||
|
||||
const handleCreatePlaylist = () => {
|
||||
if (newPlaylistName.trim()) {
|
||||
onPlaylistCreate(newPlaylistName);
|
||||
setNewPlaylistName("");
|
||||
disclosure.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex direction="column" gap={2}>
|
||||
<Button
|
||||
onClick={() => onPlaylistSelect("All Songs")}
|
||||
colorScheme={selectedItem === "All Songs" ? "blue" : "gray"}
|
||||
>
|
||||
All Songs
|
||||
</Button>
|
||||
{playlists.map((playlist) => (
|
||||
<Button
|
||||
key={playlist.name}
|
||||
onClick={() => onPlaylistSelect(playlist.name)}
|
||||
colorScheme={selectedItem === playlist.name ? "blue" : "gray"}
|
||||
>
|
||||
{playlist.name}
|
||||
</Button>
|
||||
))}
|
||||
<Button onClick={disclosure.onOpen} colorScheme="green">
|
||||
Create New Playlist
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Modal isOpen={disclosure.open} onClose={disclosure.onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Create New Playlist</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Input
|
||||
value={newPlaylistName}
|
||||
onChange={(e) => setNewPlaylistName(e.target.value)}
|
||||
placeholder="Playlist name"
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="blue" mr={3} onClick={handleCreatePlaylist}>
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={disclosure.onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
32
packages/frontend/src/components/SongList.tsx
Normal file
32
packages/frontend/src/components/SongList.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Box, Flex, Text } from "@chakra-ui/react";
|
||||
import { Song } from "../types/interfaces";
|
||||
|
||||
interface SongListProps {
|
||||
songs: Song[];
|
||||
onAddToPlaylist: (songId: string, playlistName: string) => void;
|
||||
playlists: { name: string }[];
|
||||
}
|
||||
|
||||
export const SongList: React.FC<SongListProps> = ({ songs, onAddToPlaylist, playlists }) => {
|
||||
return (
|
||||
<Flex direction="column" gap={4}>
|
||||
{songs.map((song) => (
|
||||
<Box key={song.id} borderWidth="1px" borderRadius="lg" p={4} shadow="md">
|
||||
<Text fontWeight="bold">{song.title}</Text>
|
||||
<Text color="gray.500">{song.artist}</Text>
|
||||
<select
|
||||
onChange={(e) => onAddToPlaylist(song.id, e.target.value)}
|
||||
value=""
|
||||
>
|
||||
<option value="" disabled>Add to playlist</option>
|
||||
{playlists.map((playlist) => (
|
||||
<option key={playlist.name} value={playlist.name}>
|
||||
{playlist.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
64
packages/frontend/src/hooks/useXmlParser.ts
Normal file
64
packages/frontend/src/hooks/useXmlParser.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Song, Playlist } 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 [loading, setLoading] = useState(true);
|
||||
|
||||
// Load data from the backend on mount
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [loadedSongs, loadedPlaylists] = await Promise.all([
|
||||
api.getSongs(),
|
||||
api.getPlaylists()
|
||||
]);
|
||||
setSongs(loadedSongs);
|
||||
setPlaylists(loadedPlaylists);
|
||||
} catch (err) {
|
||||
console.error("Error loading data from backend:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const xmlText = e.target?.result as string;
|
||||
try {
|
||||
const { songs: parsedSongs, playlists: parsedPlaylists } = await parseXmlFile(xmlText);
|
||||
|
||||
// Save to backend
|
||||
const [savedSongs, savedPlaylists] = await Promise.all([
|
||||
api.saveSongs(parsedSongs),
|
||||
api.savePlaylists(parsedPlaylists)
|
||||
]);
|
||||
|
||||
setSongs(savedSongs);
|
||||
setPlaylists(savedPlaylists);
|
||||
} catch (err) {
|
||||
console.error("Error processing XML:", err);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return {
|
||||
songs,
|
||||
playlists,
|
||||
setSongs,
|
||||
setPlaylists,
|
||||
handleFileUpload,
|
||||
loading
|
||||
};
|
||||
};
|
||||
57
packages/frontend/src/services/api.ts
Normal file
57
packages/frontend/src/services/api.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Song, Playlist } from '../types/interfaces';
|
||||
|
||||
const 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 = {
|
||||
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;
|
||||
},
|
||||
|
||||
async saveSongs(songs: Song[]): Promise<Song[]> {
|
||||
console.log(`Saving ${songs.length} songs to API...`);
|
||||
const response = await fetch(`${API_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;
|
||||
},
|
||||
|
||||
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`, {
|
||||
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;
|
||||
},
|
||||
};
|
||||
120
packages/frontend/src/services/xmlService.ts
Normal file
120
packages/frontend/src/services/xmlService.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { parseStringPromise } from "xml2js";
|
||||
import { create } from "xmlbuilder";
|
||||
import { Song, Playlist } from "../types/interfaces";
|
||||
|
||||
export const parseXmlFile = async (xmlText: string): Promise<{ songs: Song[], playlists: Playlist[] }> => {
|
||||
try {
|
||||
const result = await parseStringPromise(xmlText);
|
||||
|
||||
const tracks: Song[] = result.DJ_PLAYLISTS.COLLECTION[0].TRACK.map((track: any) => ({
|
||||
id: track.$.TrackID,
|
||||
title: track.$.Name,
|
||||
artist: track.$.Artist,
|
||||
composer: track.$.Composer,
|
||||
album: track.$.Album,
|
||||
grouping: track.$.Grouping,
|
||||
genre: track.$.Genre,
|
||||
kind: track.$.Kind,
|
||||
size: track.$.Size,
|
||||
totalTime: track.$.TotalTime,
|
||||
discNumber: track.$.DiscNumber,
|
||||
trackNumber: track.$.TrackNumber,
|
||||
year: track.$.Year,
|
||||
averageBpm: track.$.AverageBpm,
|
||||
dateAdded: track.$.DateAdded,
|
||||
bitRate: track.$.BitRate,
|
||||
sampleRate: track.$.SampleRate,
|
||||
comments: track.$.Comments,
|
||||
playCount: track.$.PlayCount,
|
||||
rating: track.$.Rating,
|
||||
location: track.$.Location,
|
||||
remixer: track.$.Remixer,
|
||||
tonality: track.$.Tonality,
|
||||
label: track.$.Label,
|
||||
mix: track.$.Mix,
|
||||
tempo: track.TEMPO ? {
|
||||
inizio: track.TEMPO[0].$.Inizio,
|
||||
bpm: track.TEMPO[0].$.Bpm,
|
||||
metro: track.TEMPO[0].$.Metro,
|
||||
battito: track.TEMPO[0].$.Battito,
|
||||
} : 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) : [],
|
||||
}));
|
||||
|
||||
return { songs: tracks, playlists };
|
||||
} catch (err) {
|
||||
console.error("Error parsing XML", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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 (song.tempo) {
|
||||
trackElement.ele("TEMPO", {
|
||||
Inizio: song.tempo.inizio || "",
|
||||
Bpm: song.tempo.bpm || "",
|
||||
Metro: song.tempo.metro || "",
|
||||
Battito: song.tempo.battito || "",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
};
|
||||
38
packages/frontend/src/types/interfaces.ts
Normal file
38
packages/frontend/src/types/interfaces.ts
Normal file
@ -0,0 +1,38 @@
|
||||
export interface Song {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
composer?: string;
|
||||
album?: string;
|
||||
grouping?: string;
|
||||
genre?: string;
|
||||
kind?: string;
|
||||
size?: string;
|
||||
totalTime?: string;
|
||||
discNumber?: string;
|
||||
trackNumber?: string;
|
||||
year?: string;
|
||||
averageBpm?: string;
|
||||
dateAdded?: string;
|
||||
bitRate?: string;
|
||||
sampleRate?: string;
|
||||
comments?: string;
|
||||
playCount?: string;
|
||||
rating?: string;
|
||||
location?: string;
|
||||
remixer?: string;
|
||||
tonality?: string;
|
||||
label?: string;
|
||||
mix?: string;
|
||||
tempo?: {
|
||||
inizio?: string;
|
||||
bpm?: string;
|
||||
metro?: string;
|
||||
battito?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
name: string;
|
||||
tracks: string[];
|
||||
}
|
||||
332
src/App.tsx
332
src/App.tsx
@ -1,332 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { parseStringPromise } from "xml2js";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
Input,
|
||||
Stack,
|
||||
Text,
|
||||
VStack,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
} from "@chakra-ui/modal";
|
||||
import { create } from "xmlbuilder";
|
||||
|
||||
interface Song {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
composer?: string;
|
||||
album?: string;
|
||||
grouping?: string;
|
||||
genre?: string;
|
||||
kind?: string;
|
||||
size?: string;
|
||||
totalTime?: string;
|
||||
discNumber?: string;
|
||||
trackNumber?: string;
|
||||
year?: string;
|
||||
averageBpm?: string;
|
||||
dateAdded?: string;
|
||||
bitRate?: string;
|
||||
sampleRate?: string;
|
||||
comments?: string;
|
||||
playCount?: string;
|
||||
rating?: string;
|
||||
location?: string;
|
||||
remixer?: string;
|
||||
tonality?: string;
|
||||
label?: string;
|
||||
mix?: string;
|
||||
tempo?: {
|
||||
inizio?: string;
|
||||
bpm?: string;
|
||||
metro?: string;
|
||||
battito?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Playlist {
|
||||
name: string;
|
||||
tracks: string[];
|
||||
}
|
||||
|
||||
export default function RekordboxReader() {
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [selectedItem, setSelectedItem] = useState<string | null>("All Songs");
|
||||
const { open, onOpen, onClose } = useDisclosure();
|
||||
const [newPlaylistName, setNewPlaylistName] = useState("");
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const xmlText = e.target?.result as string;
|
||||
try {
|
||||
const result = await parseStringPromise(xmlText);
|
||||
console.log("Parsed XML:", result);
|
||||
|
||||
const tracks: Song[] = result.DJ_PLAYLISTS.COLLECTION[0].TRACK.map((track: any) => ({
|
||||
id: track.$.TrackID,
|
||||
title: track.$.Name,
|
||||
artist: track.$.Artist,
|
||||
composer: track.$.Composer,
|
||||
album: track.$.Album,
|
||||
grouping: track.$.Grouping,
|
||||
genre: track.$.Genre,
|
||||
kind: track.$.Kind,
|
||||
size: track.$.Size,
|
||||
totalTime: track.$.TotalTime,
|
||||
discNumber: track.$.DiscNumber,
|
||||
trackNumber: track.$.TrackNumber,
|
||||
year: track.$.Year,
|
||||
averageBpm: track.$.AverageBpm,
|
||||
dateAdded: track.$.DateAdded,
|
||||
bitRate: track.$.BitRate,
|
||||
sampleRate: track.$.SampleRate,
|
||||
comments: track.$.Comments,
|
||||
playCount: track.$.PlayCount,
|
||||
rating: track.$.Rating,
|
||||
location: track.$.Location,
|
||||
remixer: track.$.Remixer,
|
||||
tonality: track.$.Tonality,
|
||||
label: track.$.Label,
|
||||
mix: track.$.Mix,
|
||||
tempo: track.TEMPO ? {
|
||||
inizio: track.TEMPO[0].$.Inizio,
|
||||
bpm: track.TEMPO[0].$.Bpm,
|
||||
metro: track.TEMPO[0].$.Metro,
|
||||
battito: track.TEMPO[0].$.Battito,
|
||||
} : undefined,
|
||||
}));
|
||||
|
||||
const lists: 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) : [],
|
||||
}));
|
||||
|
||||
console.log("Tracks:", tracks);
|
||||
console.log("Playlists:", lists);
|
||||
|
||||
setSongs(tracks);
|
||||
setPlaylists(lists);
|
||||
} catch (err) {
|
||||
console.error("Error parsing XML", err);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (item: string) => {
|
||||
setSelectedItem(item);
|
||||
};
|
||||
|
||||
const handleCreatePlaylist = () => {
|
||||
if (newPlaylistName.trim()) {
|
||||
setPlaylists([...playlists, { name: newPlaylistName, tracks: [] }]);
|
||||
setNewPlaylistName("");
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSongToPlaylist = (songId: string, playlistName: string) => {
|
||||
const updatedPlaylists = playlists.map((playlist) =>
|
||||
playlist.name === playlistName
|
||||
? { ...playlist, tracks: [...playlist.tracks, songId] }
|
||||
: playlist
|
||||
);
|
||||
setPlaylists(updatedPlaylists);
|
||||
};
|
||||
|
||||
const exportToXML = () => {
|
||||
const doc = create("DJ_PLAYLISTS", { Version: "1.0.0" });
|
||||
const collection = doc.ele("COLLECTION", {
|
||||
Entries: songs.length.toString(),
|
||||
});
|
||||
|
||||
// Add tracks to the collection with all metadata
|
||||
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 || "",
|
||||
});
|
||||
|
||||
// Add the TEMPO nested element if available
|
||||
if (song.tempo) {
|
||||
trackElement.ele("TEMPO", {
|
||||
Inizio: song.tempo.inizio || "",
|
||||
Bpm: song.tempo.bpm || "",
|
||||
Metro: song.tempo.metro || "",
|
||||
Battito: song.tempo.battito || "",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Create the playlists structure
|
||||
const playlistsNode = doc.ele("PLAYLISTS");
|
||||
const playlistsFolder = playlistsNode.ele("NODE", {
|
||||
Name: "ROOT",
|
||||
Type: "0",
|
||||
Count: playlists.length.toString(),
|
||||
});
|
||||
|
||||
// Add each playlist and its tracks
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
// Generate the XML content as a string
|
||||
const xmlContent = doc.end({ pretty: true });
|
||||
|
||||
// Create a Blob from the XML content
|
||||
const blob = new Blob([xmlContent], { type: "application/xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Create a download link and trigger the download
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "rekordbox_playlists.xml";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (selectedItem === "All Songs") {
|
||||
return (
|
||||
<Stack spacing={4} align="stretch">
|
||||
{songs.map((song) => (
|
||||
<Box key={song.id} borderWidth="1px" borderRadius="lg" p={4} shadow="md">
|
||||
<Text fontWeight="bold">{song.title}</Text>
|
||||
<Text color="gray.500">{song.artist}</Text>
|
||||
<select
|
||||
onChange={(e) => handleAddSongToPlaylist(song.id, e.target.value)}
|
||||
value=""
|
||||
>
|
||||
<option value="" disabled>Add to playlist</option>
|
||||
{playlists.map((playlist) => (
|
||||
<option key={playlist.name} value={playlist.name}>
|
||||
{playlist.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
} else {
|
||||
const selectedPlaylist = playlists.find((playlist) => playlist.name === selectedItem);
|
||||
return (
|
||||
<Stack spacing={4} align="stretch">
|
||||
{selectedPlaylist?.tracks.map((trackId) => {
|
||||
const song = songs.find((s) => s.id === trackId);
|
||||
return song ? (
|
||||
<Box key={trackId} borderWidth="1px" borderRadius="lg" p={4} shadow="md">
|
||||
<Text fontWeight="bold">{song.title}</Text>
|
||||
<Text color="gray.500">{song.artist}</Text>
|
||||
</Box>
|
||||
) : null;
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex h="100vh">
|
||||
<VStack w="200px" borderRight="1px" p={4}>
|
||||
<Button onClick={() => handleMenuItemClick("All Songs")} w="100%">
|
||||
All Songs
|
||||
</Button>
|
||||
{playlists.map((playlist) => (
|
||||
<Button
|
||||
key={playlist.name}
|
||||
onClick={() => handleMenuItemClick(playlist.name)}
|
||||
w="100%"
|
||||
>
|
||||
{playlist.name}
|
||||
</Button>
|
||||
))}
|
||||
<Button onClick={onOpen} w="100%">
|
||||
Create New Playlist
|
||||
</Button>
|
||||
</VStack>
|
||||
<Box p={4} flex="1">
|
||||
<Input type="file" accept=".xml" onChange={handleFileUpload} mb={4} />
|
||||
<Button onClick={exportToXML} mb={4}>
|
||||
Export to XML
|
||||
</Button>
|
||||
<Heading size="lg" mb={4}>
|
||||
{selectedItem}
|
||||
</Heading>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
<Modal isOpen={open} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Create New Playlist</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Input
|
||||
placeholder="Playlist Name"
|
||||
value={newPlaylistName}
|
||||
onChange={(e) => setNewPlaylistName(e.target.value)}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="blue" mr={3} onClick={handleCreatePlaylist}>
|
||||
Create
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user