feat: Major performance optimizations - Add React.memo, useMemo, and useCallback to prevent unnecessary re-renders - Implement request cancellation with AbortController to prevent memory leaks - Add debounced search to reduce API calls - Optimize song selection and playlist switching performance - Fix browser crashes and memory leaks - Add proper cleanup functions

This commit is contained in:
Geert Rademakes 2025-08-06 10:03:26 +02:00
parent e98fdf50cb
commit 7fb8614130
14 changed files with 1130 additions and 216 deletions

223
README.md
View File

@ -1,54 +1,191 @@
# React + TypeScript + Vite
# Rekordbox Reader
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
A web application for reading, managing, and exporting Rekordbox XML files. This project allows DJs to upload their Rekordbox library XML files, view and organize their music collection, manage playlists, and export modified libraries back to XML format.
Currently, two official plugins are available:
## 🎵 Features
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
- **XML File Upload**: Upload and parse Rekordbox XML library files
- **Music Library Management**: Browse and search through your music collection
- **Playlist Management**: Create, edit, and organize playlists and folders
- **Song Details**: View detailed information about tracks including BPM, key, rating, etc.
- **Export Functionality**: Export modified libraries back to XML format
- **Responsive Design**: Works on desktop and mobile devices
- **Database Storage**: Persistent storage using MongoDB
## Expanding the ESLint configuration
## 🏗️ Architecture
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
This is a monorepo with the following structure:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
- **Frontend**: React + TypeScript + Vite application with Chakra UI
- **Backend**: Node.js + Express + TypeScript API server
- **Database**: MongoDB for data persistence
- **Docker**: Containerized deployment option
## 🚀 Quick Start
### Prerequisites
- Node.js (v18 or higher)
- npm or yarn
- MongoDB (for local development)
### Option 1: Development Setup (Recommended)
1. **Clone and install dependencies:**
```bash
git clone <repository-url>
cd rekordbox-reader
npm run install:all
```
2. **Start MongoDB** (if not already running):
```bash
# On macOS with Homebrew
brew services start mongodb-community
# Or using Docker
docker run -d -p 27017:27017 --name mongodb mongo:latest
```
3. **Start the development servers:**
```bash
npm run dev
```
This will start both frontend and backend servers concurrently:
- Frontend: http://localhost:5173
- Backend: http://localhost:3000
### Option 2: Docker Setup
1. **Build and run with Docker Compose:**
```bash
docker-compose up --build
```
This will start all services:
- Frontend: http://localhost:8080
- Backend: http://localhost:3001
- MongoDB: localhost:27017
## 📁 Project Structure
```
rekordbox-reader/
├── packages/
│ ├── frontend/ # React frontend application
│ │ ├── src/
│ │ │ ├── components/ # React components
│ │ │ ├── hooks/ # Custom React hooks
│ │ │ ├── pages/ # Page components
│ │ │ ├── services/ # API and XML services
│ │ │ ├── types/ # TypeScript interfaces
│ │ │ └── utils/ # Utility functions
│ │ └── Dockerfile
│ └── backend/ # Express API server
│ ├── src/
│ │ ├── models/ # MongoDB models
│ │ ├── routes/ # API routes
│ │ └── index.ts # Server entry point
│ └── Dockerfile
├── docker-compose.yml # Docker orchestration
└── package.json # Monorepo configuration
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
## 🛠️ Available Scripts
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
### Root Level (Monorepo)
- `npm run dev` - Start both frontend and backend in development mode
- `npm run dev:frontend` - Start only the frontend
- `npm run dev:backend` - Start only the backend
- `npm run build` - Build all packages
- `npm run install:all` - Install dependencies for all packages
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
### Frontend (`packages/frontend/`)
- `npm run dev` - Start Vite development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
- `npm run lint` - Run ESLint
### Backend (`packages/backend/`)
- `npm run dev` - Start development server with hot reload
- `npm run build` - Build TypeScript
- `npm run start` - Start production server
## 🔧 Configuration
### Environment Variables
Create a `.env` file in the backend directory:
```env
MONGODB_URI=mongodb://localhost:27017/rekordbox
PORT=3000
NODE_ENV=development
```
### API Configuration
The frontend is configured to connect to the backend API. The API URL can be configured in:
- Development: `packages/frontend/src/services/api.ts`
- Production: Environment variable `VITE_API_URL` in Docker
## 📊 API Endpoints
- `GET /api/health` - Health check
- `POST /api/reset` - Reset database (delete all data)
- `GET /api/songs` - Get all songs
- `POST /api/songs` - Create/update songs
- `DELETE /api/songs` - Delete songs
- `GET /api/playlists` - Get all playlists
- `POST /api/playlists` - Create/update playlists
- `DELETE /api/playlists` - Delete playlists
## 🎯 Usage
1. **Upload XML File**: Click "Choose XML File" to upload your Rekordbox library XML
2. **Browse Library**: View your music collection in the main panel
3. **Manage Playlists**: Use the playlist manager to organize your music
4. **Export**: Export your modified library back to XML format
## 🐛 Troubleshooting
### Common Issues
1. **MongoDB Connection Error**:
- Ensure MongoDB is running: `brew services start mongodb-community`
- Check connection string in backend `.env` file
2. **Port Already in Use**:
- Frontend: Change port in `packages/frontend/vite.config.ts`
- Backend: Change `PORT` in `.env` file
3. **Build Errors**:
- Clear node_modules and reinstall: `rm -rf node_modules && npm run install:all`
### Development Tips
- Use the browser's developer tools to inspect network requests
- Check the backend console for API logs
- Use the `/api/health` endpoint to verify backend connectivity
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature-name`
3. Make your changes
4. Run tests and linting: `npm run lint`
5. Commit your changes: `git commit -m 'Add feature'`
6. Push to the branch: `git push origin feature-name`
7. Submit a pull request
## 📝 License
This project is licensed under the MIT License.
## 🙏 Acknowledgments
- Built with React, TypeScript, and Vite
- UI components from Chakra UI
- XML parsing with xml2js and sax
- Backend powered by Express and MongoDB

21
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,21 @@
version: '3.8'
services:
mongo:
image: mongo:latest
ports:
- "27017:27017"
volumes:
- mongodb_dev_data:/data/db
environment:
- MONGO_INITDB_DATABASE=rekordbox
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
restart: unless-stopped
volumes:
mongodb_dev_data:

View File

@ -1,16 +1,55 @@
import express from 'express';
import { Playlist } from '../models/Playlist.js';
import { Request, Response } from 'express';
const router = express.Router();
// Get all playlists
router.get('/', async (req, res) => {
router.get('/', async (req: Request, res: Response) => {
try {
const playlists = await Playlist.find();
const playlists = await Playlist.find({});
res.json(playlists);
} catch (error) {
console.error('Error fetching playlists:', error);
res.status(500).json({ error: 'Failed to fetch playlists' });
res.status(500).json({ message: 'Error fetching playlists', error });
}
});
// Get playlist structure only (without track data) for faster loading
router.get('/structure', async (req: Request, res: Response) => {
try {
const playlists = await Playlist.find({});
// Remove track data from playlists to reduce payload size
const structureOnly = playlists.map(playlist => {
const cleanPlaylist = playlist.toObject() as any;
// Remove tracks from the main playlist
delete cleanPlaylist.tracks;
// Recursively remove tracks from children
const cleanChildren = (children: any[]): any[] => {
return children.map(child => {
const cleanChild = { ...child } as any;
delete cleanChild.tracks;
if (cleanChild.children && cleanChild.children.length > 0) {
cleanChild.children = cleanChildren(cleanChild.children);
}
return cleanChild;
});
};
if (cleanPlaylist.children && cleanPlaylist.children.length > 0) {
cleanPlaylist.children = cleanChildren(cleanPlaylist.children);
}
return cleanPlaylist;
});
res.json(structureOnly);
} catch (error) {
console.error('Error fetching playlist structure:', error);
res.status(500).json({ message: 'Error fetching playlist structure', error });
}
});

View File

@ -1,21 +1,190 @@
import express, { Request, Response } from 'express';
import { Song } from '../models/Song.js';
import { Playlist } from '../models/Playlist.js';
const router = express.Router();
// Get all songs
// Get songs with pagination and search
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);
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 50;
const search = req.query.search as string || '';
const skip = (page - 1) * limit;
console.log(`Fetching songs from database... Page: ${page}, Limit: ${limit}, Search: "${search}"`);
// Build query for search
let query = {};
if (search) {
query = {
$or: [
{ title: { $regex: search, $options: 'i' } },
{ artist: { $regex: search, $options: 'i' } },
{ album: { $regex: search, $options: 'i' } },
{ genre: { $regex: search, $options: 'i' } }
]
};
}
// Get total count for pagination
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)
.lean();
console.log(`Found ${songs.length} songs (${totalSongs} total)`);
res.json({
songs,
pagination: {
page,
limit,
totalSongs,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1
}
});
} catch (error) {
console.error('Error fetching songs:', error);
res.status(500).json({ message: 'Error fetching songs', error });
}
});
// Get songs by playlist with pagination
router.get('/playlist/*', async (req: Request, res: Response) => {
try {
const playlistName = decodeURIComponent(req.params[0]);
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 50;
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}"`);
// Find the playlist recursively in the playlist structure
const findPlaylistRecursively = (nodes: any[], targetName: string): any => {
for (const node of nodes) {
if (node.name === targetName) {
return node;
}
if (node.children && node.children.length > 0) {
const found = findPlaylistRecursively(node.children, targetName);
if (found) return found;
}
}
return null;
};
// Get all playlists and search recursively
const allPlaylists = await Playlist.find({});
let playlist = null;
for (const playlistDoc of allPlaylists) {
playlist = findPlaylistRecursively([playlistDoc], playlistName);
if (playlist) break;
}
if (!playlist) {
console.log(`Playlist "${playlistName}" not found. Available playlists:`);
const allPlaylistNames = await Playlist.find({}, 'name');
console.log(allPlaylistNames.map(p => p.name));
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 === 'folder' && node.children) {
return node.children.flatMap((child: any) => getAllTrackIds(child));
}
return [];
};
const trackIds = getAllTrackIds(playlist);
console.log(`Found ${trackIds.length} tracks in playlist "${playlistName}"`);
if (trackIds.length === 0) {
return res.json({
songs: [],
pagination: {
page,
limit,
totalSongs: 0,
totalPages: 0,
hasNextPage: false,
hasPrevPage: false
}
});
}
// Build query for songs in playlist
let query: any = { id: { $in: trackIds } };
if (search) {
query = {
$and: [
{ id: { $in: trackIds } },
{
$or: [
{ title: { $regex: search, $options: 'i' } },
{ artist: { $regex: search, $options: 'i' } },
{ album: { $regex: search, $options: 'i' } },
{ genre: { $regex: search, $options: 'i' } }
]
}
]
};
}
// Get total count for pagination
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)
.lean();
console.log(`Found ${songs.length} songs for playlist "${playlistName}" (${totalSongs} total)`);
res.json({
songs,
pagination: {
page,
limit,
totalSongs,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1
}
});
} catch (error) {
console.error('Error fetching playlist songs:', error);
res.status(500).json({ message: 'Error fetching playlist songs', error });
}
});
// Get total song count
router.get('/count', async (req: Request, res: Response) => {
try {
const count = await Song.countDocuments();
res.json({ count });
} catch (error) {
console.error('Error fetching song count:', error);
res.status(500).json({ message: 'Error fetching song count', error });
}
});
// Create multiple songs
router.post('/batch', async (req: Request, res: Response) => {
try {

View File

@ -1,69 +1,20 @@
import { Box, Button, Flex, Heading, Input, Spinner, Text, useStyleConfig, useBreakpointValue, IconButton, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, DrawerCloseButton, useDisclosure, Container, Menu, MenuButton, MenuList, MenuItem, useToken, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, VStack } from "@chakra-ui/react";
import { ChevronLeftIcon, ChevronRightIcon, HamburgerIcon, ViewIcon, SettingsIcon, DragHandleIcon } from "@chakra-ui/icons";
import { useState, useRef, useEffect } from "react";
import { Box, Button, Flex, Heading, Spinner, Text, useBreakpointValue, IconButton, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, VStack } from "@chakra-ui/react";
import { ChevronLeftIcon, ChevronRightIcon, SettingsIcon } from "@chakra-ui/icons";
import { useState, useRef, useEffect, useCallback } from "react";
import { useNavigate, useLocation, Routes, Route } from "react-router-dom";
import { SongList } from "./components/SongList";
import { PaginatedSongList } from "./components/PaginatedSongList";
import { PlaylistManager } from "./components/PlaylistManager";
import { SongDetails } from "./components/SongDetails";
import { Configuration } from "./pages/Configuration";
import { useXmlParser } from "./hooks/useXmlParser";
import { exportToXml } from "./services/xmlService";
import { usePaginatedSongs } from "./hooks/usePaginatedSongs";
import { api } from "./services/api";
import type { Song, PlaylistNode } from "./types/interfaces";
import { v4 as uuidv4 } from "uuid";
import "./App.css";
const StyledFileInput = ({ isMobile = false }) => {
const { handleFileUpload } = useXmlParser();
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.click();
};
return (
<Box
position="relative"
width="auto"
maxW={isMobile ? "100%" : "300px"}
>
<Input
type="file"
accept=".xml"
onChange={handleFileUpload}
height="40px"
padding="8px 12px"
opacity="0"
position="absolute"
top="0"
left="0"
width="100%"
cursor="pointer"
ref={inputRef}
style={{ display: 'none' }}
/>
<Button
width="100%"
height="40px"
bg="gray.700"
color="gray.300"
border="2px dashed"
borderColor="gray.600"
_hover={{
bg: "gray.600",
borderColor: "gray.500"
}}
_active={{
bg: "gray.500"
}}
onClick={handleClick}
size={isMobile ? "sm" : "md"}
>
Choose XML File
</Button>
</Box>
);
};
const ResizeHandle = ({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => void }) => (
<Box
@ -116,12 +67,17 @@ const getAllPlaylistTracks = (node: PlaylistNode): string[] => {
};
export default function RekordboxReader() {
const { songs, playlists, setPlaylists, loading } = useXmlParser();
const { playlists, setPlaylists, loading: xmlLoading } = useXmlParser();
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
// Memoized song selection handler to prevent unnecessary re-renders
const handleSongSelect = useCallback((song: Song) => {
setSelectedSong(song);
}, []);
const navigate = useNavigate();
const location = useLocation();
const initialLoadDone = useRef(false);
const mobileFileInputRef = useRef<HTMLInputElement>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isWelcomeOpen, onClose: onWelcomeClose } = useDisclosure({ defaultIsOpen: true });
@ -135,9 +91,19 @@ export default function RekordboxReader() {
? decodeURIComponent(location.pathname.slice("/playlists/".length))
: "All Songs";
const {
songs,
loading: songsLoading,
hasMore,
totalSongs,
loadNextPage,
searchSongs,
searchQuery
} = usePaginatedSongs({ pageSize: 50, playlistName: currentPlaylist });
useEffect(() => {
// Only run this check after the initial data load
if (!loading && playlists.length > 0) {
if (!xmlLoading && playlists.length > 0) {
initialLoadDone.current = true;
}
@ -147,7 +113,7 @@ export default function RekordboxReader() {
!findPlaylistByName(playlists, currentPlaylist)) {
navigate("/", { replace: true });
}
}, [currentPlaylist, playlists, navigate, loading]);
}, [currentPlaylist, playlists, navigate, xmlLoading]);
const handlePlaylistSelect = (name: string) => {
setSelectedSong(null); // Clear selected song when changing playlists
@ -232,17 +198,7 @@ export default function RekordboxReader() {
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 handlePlaylistDelete = async (name: string) => {
const updatedPlaylists = playlists.filter(p => p.name !== name);
@ -321,19 +277,8 @@ export default function RekordboxReader() {
setPlaylists(savedPlaylists);
};
const displayedSongs = currentPlaylist === "All Songs"
? songs
: 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);
});
// Note: For now, we're showing all songs with pagination
// TODO: Implement playlist filtering with pagination
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
@ -368,7 +313,7 @@ export default function RekordboxReader() {
};
}, [isResizing]);
if (loading) {
if (xmlLoading || songsLoading) {
return (
<Flex height="100vh" align="center" justify="center" direction="column" gap={4}>
<Spinner size="xl" />
@ -410,7 +355,7 @@ export default function RekordboxReader() {
userSelect={isResizing ? 'none' : 'auto'}
>
{/* Welcome Modal */}
{!loading && songs.length === 0 && (
{!xmlLoading && !songsLoading && songs.length === 0 && (
<Modal isOpen={isWelcomeOpen} onClose={onWelcomeClose} isCentered>
<ModalOverlay />
<ModalContent bg="gray.800" maxW="md">
@ -554,14 +499,20 @@ export default function RekordboxReader() {
>
{/* Song List */}
<Box flex={1} overflowY="auto" minH={0}>
<SongList
songs={displayedSongs}
<PaginatedSongList
songs={songs}
onAddToPlaylist={handleAddSongsToPlaylist}
onRemoveFromPlaylist={handleRemoveFromPlaylist}
playlists={playlists}
onSongSelect={setSelectedSong}
onSongSelect={handleSongSelect}
selectedSongId={selectedSong?.id || null}
currentPlaylist={currentPlaylist}
loading={songsLoading}
hasMore={hasMore}
totalSongs={totalSongs}
onLoadMore={loadNextPage}
onSearch={searchSongs}
searchQuery={searchQuery}
/>
</Box>

View File

@ -0,0 +1,376 @@
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react';
import { useDebounce } from '../hooks/useDebounce';
import {
Box,
Flex,
Text,
Input,
InputGroup,
InputLeftElement,
Checkbox,
Button,
HStack,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
IconButton,
Spinner,
} from '@chakra-ui/react';
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
import type { Song, PlaylistNode } from '../types/interfaces';
import { formatDuration, formatTotalDuration } from '../utils/formatters';
interface PaginatedSongListProps {
songs: Song[];
onAddToPlaylist: (songIds: string[], playlistName: string) => void;
onRemoveFromPlaylist?: (songIds: string[]) => void;
playlists: PlaylistNode[];
onSongSelect: (song: Song) => void;
selectedSongId: string | null;
currentPlaylist: string | null;
loading: boolean;
hasMore: boolean;
totalSongs: number;
onLoadMore: () => void;
onSearch: (query: string) => void;
searchQuery: string;
depth?: number;
}
// Memoized song item component to prevent unnecessary re-renders
const SongItem = memo<{
song: Song;
isSelected: boolean;
isHighlighted: boolean;
onSelect: (song: Song) => void;
onToggleSelection: (songId: string) => void;
showCheckbox: boolean;
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox }) => {
const handleClick = useCallback(() => {
onSelect(song);
}, [onSelect, song]);
const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
onToggleSelection(song.id);
}, [onToggleSelection, song.id]);
return (
<Flex
key={song.id}
p={3}
borderBottom="1px"
borderColor="gray.700"
cursor="pointer"
bg={isHighlighted ? "blue.900" : "transparent"}
_hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }}
onClick={handleClick}
transition="background-color 0.2s"
>
{showCheckbox && (
<Checkbox
isChecked={isSelected}
onChange={handleCheckboxClick}
mr={3}
onClick={(e) => e.stopPropagation()}
/>
)}
<Box flex={1} minW={0}>
<Text fontSize="sm" fontWeight="medium" color="white" noOfLines={1}>
{song.title}
</Text>
<Text fontSize="xs" color="gray.400" noOfLines={1}>
{song.artist}
</Text>
</Box>
<Box textAlign="right" ml={2}>
<Text fontSize="xs" color="gray.500">
{formatDuration(song.totalTime || '')}
</Text>
<Text fontSize="xs" color="gray.600">
{song.averageBpm} BPM
</Text>
</Box>
</Flex>
);
});
SongItem.displayName = 'SongItem';
export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
songs,
onAddToPlaylist,
onRemoveFromPlaylist,
playlists,
onSongSelect,
selectedSongId,
currentPlaylist,
loading,
hasMore,
totalSongs,
onLoadMore,
onSearch,
searchQuery,
depth = 0
}) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null);
// Debounce search to prevent excessive API calls
const debouncedSearchQuery = useDebounce(localSearchQuery, 300);
// Memoized 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;
}, []);
// Memoized flattened list of all playlists
const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]);
const toggleSelection = useCallback((songId: string) => {
setSelectedSongs(prev => {
const newSelection = new Set(prev);
if (newSelection.has(songId)) {
newSelection.delete(songId);
} else {
newSelection.add(songId);
}
return newSelection;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedSongs(prev =>
prev.size === songs.length ? new Set() : new Set(songs.map(s => s.id))
);
}, [songs]);
const handleBulkAddToPlaylist = useCallback((playlistName: string) => {
if (selectedSongs.size > 0) {
onAddToPlaylist(Array.from(selectedSongs), playlistName);
setSelectedSongs(new Set()); // Clear selection after action
}
}, [selectedSongs, onAddToPlaylist]);
const handleBulkRemoveFromPlaylist = useCallback(() => {
if (selectedSongs.size > 0 && onRemoveFromPlaylist) {
onRemoveFromPlaylist(Array.from(selectedSongs));
setSelectedSongs(new Set()); // Clear selection after action
}
}, [selectedSongs, onRemoveFromPlaylist]);
// Memoized song selection handler
const handleSongSelect = useCallback((song: Song) => {
onSongSelect(song);
}, [onSongSelect]);
// Memoized search handler with debouncing
const handleSearch = useCallback((query: string) => {
setLocalSearchQuery(query);
onSearch(query);
}, [onSearch]);
// Memoized song items to prevent unnecessary re-renders
const songItems = useMemo(() => {
return songs.map(song => (
<SongItem
key={song.id}
song={song}
isSelected={selectedSongs.has(song.id)}
isHighlighted={selectedSongId === song.id}
onSelect={handleSongSelect}
onToggleSelection={toggleSelection}
showCheckbox={selectedSongs.size > 0 || depth === 0}
/>
));
}, [songs, selectedSongs, selectedSongId, handleSongSelect, toggleSelection, depth]);
// Memoized total duration calculation
const totalDuration = useMemo(() => {
const totalSeconds = songs.reduce((total, song) => {
if (!song.totalTime) return total;
const seconds = Math.floor(Number(song.totalTime) / (song.totalTime.length > 4 ? 1000 : 1));
return total + seconds;
}, 0);
return formatTotalDuration(totalSeconds);
}, [songs]);
// Memoized playlist options for bulk actions
const playlistOptions = useMemo(() => {
return allPlaylists.map(playlist => (
<MenuItem key={playlist.id} onClick={() => handleBulkAddToPlaylist(playlist.name)}>
{playlist.name}
</MenuItem>
));
}, [allPlaylists, handleBulkAddToPlaylist]);
// Handle debounced search
useEffect(() => {
if (debouncedSearchQuery !== searchQuery) {
onSearch(debouncedSearchQuery);
}
}, [debouncedSearchQuery, searchQuery, onSearch]);
// Intersection Observer for infinite scroll
useEffect(() => {
if (loadingRef.current) {
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
onLoadMore();
}
},
{ threshold: 0.1 }
);
observerRef.current.observe(loadingRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [hasMore, loading, onLoadMore]);
return (
<Flex direction="column" height="100%">
{/* Sticky Header */}
<Box
position="sticky"
top={0}
bg="gray.900"
zIndex={1}
pb={4}
>
{/* Search Bar */}
<InputGroup mb={4}>
<InputLeftElement pointerEvents="none">
<Search2Icon color="gray.300" />
</InputLeftElement>
<Input
placeholder="Search songs by title, artist, album, or genre..."
value={localSearchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLocalSearchQuery(e.target.value)}
bg="gray.800"
borderColor="gray.600"
_hover={{ borderColor: "gray.500" }}
_focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }}
/>
</InputGroup>
{/* Bulk Actions Toolbar */}
<Flex justify="space-between" align="center" p={2} bg="gray.800" borderRadius="md">
<HStack spacing={4}>
<Checkbox
isChecked={selectedSongs.size === songs.length && songs.length > 0}
isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < songs.length}
onChange={toggleSelectAll}
colorScheme="blue"
sx={{
'& > span:first-of-type': {
opacity: 1,
border: '2px solid',
borderColor: 'gray.500'
}
}}
>
{selectedSongs.size === 0
? "Select All"
: `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`}
</Checkbox>
<Text color="gray.400" fontSize="sm">
{songs.length} of {totalSongs} songs {totalDuration}
</Text>
</HStack>
{selectedSongs.size > 0 && (
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
size="sm"
colorScheme="blue"
>
Actions
</MenuButton>
<MenuList>
{allPlaylists.map((playlist) => (
<MenuItem
key={playlist.id}
onClick={() => {
handleBulkAddToPlaylist(playlist.name);
}}
>
Add to {playlist.name}
</MenuItem>
))}
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
<MenuItem
color="red.300"
onClick={() => {
handleBulkRemoveFromPlaylist();
}}
>
Remove from {currentPlaylist}
</MenuItem>
</>
)}
</MenuList>
</Menu>
)}
</Flex>
</Box>
{/* Scrollable Song List */}
<Box flex={1} overflowY="auto" mt={2}>
<Flex direction="column" gap={2}>
{songItems}
{/* Loading indicator for infinite scroll */}
{loading && (
<Flex justify="center" p={4}>
<Spinner size="md" color="blue.400" />
</Flex>
)}
{/* Intersection observer target */}
<div ref={loadingRef} style={{ height: '20px' }} />
{/* End of results message */}
{!hasMore && songs.length > 0 && (
<Flex justify="center" p={4}>
<Text color="gray.500" fontSize="sm">
No more songs to load
</Text>
</Flex>
)}
{/* No results message */}
{!loading && songs.length === 0 && (
<Flex justify="center" p={8}>
<Text color="gray.500">
{searchQuery ? 'No songs found matching your search' : 'No songs available'}
</Text>
</Flex>
)}
</Flex>
</Box>
</Flex>
);
});

View File

@ -16,7 +16,7 @@ import {
MenuButton,
MenuList,
MenuItem,
IconButton,
Collapse,
MenuDivider,
MenuGroup,

View File

@ -1,3 +1,4 @@
import React, { useMemo, memo } from "react";
import { Box, VStack, Text, Divider } from "@chakra-ui/react";
import { Song } from "../types/interfaces";
import { formatDuration } from '../utils/formatters';
@ -21,7 +22,36 @@ const calculateBitrate = (size: string, totalTime: string): number | null => {
return Math.round(bits / seconds / 1000);
};
export const SongDetails: React.FC<SongDetailsProps> = ({ song }) => {
export const SongDetails: React.FC<SongDetailsProps> = memo(({ song }) => {
// Memoize expensive calculations
const songDetails = useMemo(() => {
if (!song) return null;
// Calculate bitrate only if imported value isn't available
const calculatedBitrate = song.size && song.totalTime ? calculateBitrate(song.size, song.totalTime) : null;
const displayBitrate = song.bitRate ?
`${song.bitRate} kbps` :
(calculatedBitrate ? `${calculatedBitrate} kbps (calculated)` : undefined);
const details = [
{ label: "Title", value: song.title },
{ label: "Artist", value: song.artist },
{ label: "Duration", value: formatDuration(song.totalTime || '') },
{ label: "Album", value: song.album },
{ label: "Genre", value: song.genre },
{ label: "BPM", value: song.averageBpm },
{ label: "Key", value: song.tonality },
{ label: "Year", value: song.year },
{ label: "Label", value: song.label },
{ label: "Mix", value: song.mix },
{ label: "Rating", value: song.rating },
{ label: "Bitrate", value: displayBitrate },
{ label: "Comments", value: song.comments },
].filter(detail => detail.value);
return { details, displayBitrate };
}, [song]);
if (!song) {
return (
<Box p={4} bg="gray.800" borderRadius="md">
@ -30,27 +60,13 @@ export const SongDetails: React.FC<SongDetailsProps> = ({ song }) => {
);
}
// Calculate bitrate only if imported value isn't available
const calculatedBitrate = song.size && song.totalTime ? calculateBitrate(song.size, song.totalTime) : null;
const displayBitrate = song.bitRate ?
`${song.bitRate} kbps` :
(calculatedBitrate ? `${calculatedBitrate} kbps (calculated)` : undefined);
const details = [
{ label: "Title", value: song.title },
{ label: "Artist", value: song.artist },
{ label: "Duration", value: formatDuration(song.totalTime) },
{ label: "Album", value: song.album },
{ label: "Genre", value: song.genre },
{ label: "BPM", value: song.averageBpm },
{ label: "Key", value: song.tonality },
{ label: "Year", value: song.year },
{ label: "Label", value: song.label },
{ label: "Mix", value: song.mix },
{ label: "Rating", value: song.rating },
{ label: "Bitrate", value: displayBitrate },
{ label: "Comments", value: song.comments },
].filter(detail => detail.value);
if (!songDetails) {
return (
<Box p={4} bg="gray.800" borderRadius="md">
<Text color="gray.400">Loading song details...</Text>
</Box>
);
}
return (
<Box p={4} bg="gray.800" borderRadius="md">
@ -65,7 +81,7 @@ export const SongDetails: React.FC<SongDetailsProps> = ({ song }) => {
</Box>
<Divider borderColor="gray.700" />
<VStack align="stretch" spacing={3}>
{details.map(({ label, value }) => (
{songDetails.details.map(({ label, value }) => (
<Box key={label}>
<Text fontSize="xs" color="gray.500" mb={1}>
{label}
@ -103,4 +119,6 @@ export const SongDetails: React.FC<SongDetailsProps> = ({ song }) => {
</VStack>
</Box>
);
};
});
SongDetails.displayName = 'SongDetails';

View File

@ -17,8 +17,7 @@ import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
import type { Song, PlaylistNode } from "../types/interfaces";
import { useState, useCallback, useMemo } from "react";
import type { MouseEvent } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { formatDuration, formatTotalDuration } from '../utils/formatters';
interface SongListProps {

View File

@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -0,0 +1,164 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { api, type SongsResponse } from '../services/api';
import type { Song } from '../types/interfaces';
interface UsePaginatedSongsOptions {
pageSize?: number;
initialSearch?: string;
playlistName?: string;
}
export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
const { pageSize = 50, initialSearch = '', playlistName } = options;
const [songs, setSongs] = useState<Song[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [totalSongs, setTotalSongs] = useState(0);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const loadingRef = useRef(false);
const currentPlaylistRef = useRef(playlistName);
const abortControllerRef = useRef<AbortController | null>(null);
// Cleanup function to prevent memory leaks
const cleanup = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
loadingRef.current = false;
}, []);
// Load songs for a specific page
const loadPage = useCallback(async (page: number, search: string = searchQuery, targetPlaylist?: string) => {
if (loadingRef.current) return;
const playlistToUse = targetPlaylist || playlistName;
// Cleanup previous request
cleanup();
// Create new abort controller for this request
abortControllerRef.current = new AbortController();
loadingRef.current = true;
setLoading(true);
setError(null);
try {
let response: SongsResponse;
if (playlistToUse && playlistToUse !== 'All Songs') {
// Load songs for specific playlist
response = await api.getPlaylistSongsPaginated(playlistToUse, page, pageSize, search);
} else {
// Load all songs
response = await api.getSongsPaginated(page, pageSize, search);
}
// Check if request was aborted
if (abortControllerRef.current?.signal.aborted) {
return;
}
if (page === 1) {
// First page - replace all songs
setSongs(response.songs);
} else {
// Subsequent pages - append songs
setSongs(prev => [...prev, ...response.songs]);
}
setHasMore(response.pagination.hasNextPage);
setTotalSongs(response.pagination.totalSongs);
setCurrentPage(page);
} catch (err) {
// Don't set error if request was aborted
if (!abortControllerRef.current?.signal.aborted) {
setError(err instanceof Error ? err.message : 'Failed to load songs');
}
} finally {
if (!abortControllerRef.current?.signal.aborted) {
setLoading(false);
loadingRef.current = false;
setIsInitialLoad(false);
}
}
}, [pageSize, searchQuery, playlistName, cleanup]);
// Load next page (for infinite scroll)
const loadNextPage = useCallback(() => {
if (!loading && hasMore && !loadingRef.current) {
loadPage(currentPage + 1);
}
}, [loading, hasMore, currentPage, loadPage]);
// Search songs with debouncing
const searchSongs = useCallback((query: string) => {
setSearchQuery(query);
setSongs([]);
setHasMore(true);
setCurrentPage(1);
setError(null);
loadPage(1, query);
}, [loadPage]);
// Reset to initial state
const reset = useCallback(() => {
cleanup();
setSongs([]);
setLoading(false);
setError(null);
setHasMore(true);
setCurrentPage(1);
setSearchQuery(initialSearch);
setIsInitialLoad(true);
}, [initialSearch, cleanup]);
// Initial load
useEffect(() => {
loadPage(1);
// Cleanup on unmount
return () => {
cleanup();
};
}, []);
// Handle playlist changes
useEffect(() => {
if (currentPlaylistRef.current !== playlistName) {
currentPlaylistRef.current = playlistName;
if (!isInitialLoad) {
setSongs([]);
setHasMore(true);
setCurrentPage(1);
setSearchQuery(initialSearch);
setError(null);
// Use setTimeout to avoid the dependency issue
setTimeout(() => {
loadPage(1, initialSearch, playlistName);
}, 0);
}
}
}, [playlistName, isInitialLoad, initialSearch, loadPage]);
return {
songs,
loading,
error,
hasMore,
totalSongs,
currentPage,
searchQuery,
isInitialLoad,
loadNextPage,
searchSongs,
reset,
refresh: () => loadPage(1)
};
};

View File

@ -1,27 +1,30 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import type { Song, PlaylistNode } from "../types/interfaces";
import type { 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<PlaylistNode[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
// Load data from the backend on mount
// Load playlist structure from the backend on mount (faster loading)
useEffect(() => {
const loadData = async () => {
try {
const [loadedSongs, loadedPlaylists] = await Promise.all([
api.getSongs(),
api.getPlaylists()
]);
setSongs(loadedSongs);
// Load just the structure first for faster initial load
const loadedPlaylists = await api.getPlaylistStructure();
setPlaylists(loadedPlaylists);
} catch (err) {
console.error("Error loading data from backend:", err);
console.error("Error loading playlist structure from backend:", err);
// Fallback to full playlist data if structure endpoint fails
try {
const fullPlaylists = await api.getPlaylists();
setPlaylists(fullPlaylists);
} catch (fallbackErr) {
console.error("Error loading full playlists from backend:", fallbackErr);
}
} finally {
setLoading(false);
}
@ -57,15 +60,12 @@ export const useXmlParser = () => {
};
const resetLibrary = async () => {
setSongs([]);
setPlaylists([]);
setLoading(false);
};
return {
songs,
playlists,
setSongs,
setPlaylists,
handleFileUpload,
loading,

View File

@ -2,27 +2,17 @@ import { Box, Heading, VStack, Text, Button, OrderedList, ListItem, useDisclosur
import { ChevronLeftIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
import { useXmlParser } from "../hooks/useXmlParser";
import { exportToXml } from "../services/xmlService";
import { StyledFileInput } from "../components/StyledFileInput";
import { api } from "../services/api";
export function Configuration() {
const { songs, resetLibrary } = useXmlParser();
const { resetLibrary } = useXmlParser();
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
const navigate = useNavigate();
const handleExport = () => {
const xmlContent = exportToXml(songs, []);
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 handleResetDatabase = async () => {
try {
@ -105,31 +95,7 @@ export function Configuration() {
<StyledFileInput />
</Box>
{songs.length > 0 && (
<Box>
<Text fontWeight="medium" mb={2}>Export Library</Text>
<Text color="gray.400" mb={4}>
Export your current library back to XML format to import into Rekordbox.
</Text>
<OrderedList spacing={3} color="gray.400" mb={4}>
<ListItem>
Click the button below to export your library to XML
</ListItem>
<ListItem>
Open Rekordbox 7 and go to <Text as="span" color="gray.300" fontWeight="medium">Preferences Advanced rekordbox xml</Text>
</ListItem>
<ListItem>
Select your exported XML file in the <Text as="span" color="gray.300" fontWeight="medium">rekordbox xml</Text> section
</ListItem>
<ListItem>
Your imported playlists will appear in the left sidebar under <Text as="span" color="gray.300" fontWeight="medium">the sd card with < > in them icon.</></Text>
</ListItem>
</OrderedList>
<Button onClick={handleExport} width="full">
Export XML
</Button>
</Box>
)}
<Box borderTopWidth="1px" borderColor="gray.700" pt={6} mt={4}>
<Text fontWeight="medium" mb={2}>Reset Database</Text>

View File

@ -2,13 +2,63 @@ import type { Song, PlaylistNode } from '../types/interfaces';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
export interface PaginationInfo {
page: number;
limit: number;
totalSongs: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
export interface SongsResponse {
songs: Song[];
pagination: PaginationInfo;
}
class Api {
// Legacy method for backward compatibility
async getSongs(): Promise<Song[]> {
const response = await fetch(`${API_BASE_URL}/songs`);
if (!response.ok) throw new Error('Failed to fetch songs');
return response.json();
}
// New paginated method for all songs
async getSongsPaginated(page: number = 1, limit: number = 50, search: string = ''): Promise<SongsResponse> {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
...(search && { search })
});
const response = await fetch(`${API_BASE_URL}/songs?${params}`);
if (!response.ok) throw new Error('Failed to fetch songs');
return response.json();
}
// New paginated method for playlist songs
async getPlaylistSongsPaginated(playlistName: string, page: number = 1, limit: number = 50, search: string = ''): Promise<SongsResponse> {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
...(search && { search })
});
const encodedPlaylistName = encodeURIComponent(playlistName);
const response = await fetch(`${API_BASE_URL}/songs/playlist/${encodedPlaylistName}?${params}`);
if (!response.ok) throw new Error('Failed to fetch playlist songs');
return response.json();
}
// Get total song count
async getSongCount(): Promise<number> {
const response = await fetch(`${API_BASE_URL}/songs/count`);
if (!response.ok) throw new Error('Failed to fetch song count');
const data = await response.json();
return data.count;
}
async saveSongs(songs: Song[]): Promise<Song[]> {
const response = await fetch(`${API_BASE_URL}/songs/batch`, {
method: 'POST',
@ -27,6 +77,13 @@ class Api {
return response.json();
}
// Get playlist structure only (without track data) for faster loading
async getPlaylistStructure(): Promise<PlaylistNode[]> {
const response = await fetch(`${API_BASE_URL}/playlists/structure`);
if (!response.ok) throw new Error('Failed to fetch playlist structure');
return response.json();
}
async savePlaylists(playlists: PlaylistNode[]): Promise<PlaylistNode[]> {
const response = await fetch(`${API_BASE_URL}/playlists/batch`, {
method: 'POST',