feat: Integrate all Music Storage functionality into unified Configuration page
- Move Upload Music, Music Library, and Song Matching tabs to Configuration page - Add all music storage state management and functionality to Configuration component - Remove standalone Music Storage page and route - Update header to use single Configuration button instead of separate Music Storage button - Create comprehensive 5-tab Configuration page: 1. Library Management (XML import, database reset) 2. Upload Music (drag & drop file uploads) 3. Music Library (file management and S3 sync) 4. Song Matching (link files to Rekordbox songs) 5. S3 Configuration (external bucket setup) - Clean up unused imports and routes - Provide single, comprehensive configuration experience All configuration and music storage functionality is now centralized in one unified page with a clean tabbed interface.
This commit is contained in:
parent
d16217eac1
commit
9eb4587537
8
packages/backend/s3-config.json
Normal file
8
packages/backend/s3-config.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"endpoint": "http://localhost:9000",
|
||||||
|
"region": "us-east-1",
|
||||||
|
"accessKeyId": "minioadmin",
|
||||||
|
"secretAccessKey": "minioadmin",
|
||||||
|
"bucketName": "music-files",
|
||||||
|
"useSSL": true
|
||||||
|
}
|
||||||
@ -6,7 +6,6 @@ import { PaginatedSongList } from "./components/PaginatedSongList";
|
|||||||
import { PlaylistManager } from "./components/PlaylistManager";
|
import { PlaylistManager } from "./components/PlaylistManager";
|
||||||
import { SongDetails } from "./components/SongDetails";
|
import { SongDetails } from "./components/SongDetails";
|
||||||
import { Configuration } from "./pages/Configuration";
|
import { Configuration } from "./pages/Configuration";
|
||||||
import { MusicStorage } from "./pages/MusicStorage";
|
|
||||||
import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer";
|
import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer";
|
||||||
import { MusicPlayerProvider, useMusicPlayer } from "./contexts/MusicPlayerContext";
|
import { MusicPlayerProvider, useMusicPlayer } from "./contexts/MusicPlayerContext";
|
||||||
import { useXmlParser } from "./hooks/useXmlParser";
|
import { useXmlParser } from "./hooks/useXmlParser";
|
||||||
@ -503,18 +502,17 @@ const RekordboxReader: React.FC = () => {
|
|||||||
Rekordbox Reader
|
Rekordbox Reader
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
{/* Music Storage Button */}
|
{/* Configuration Button */}
|
||||||
<Button
|
<IconButton
|
||||||
size="sm"
|
icon={<SettingsIcon />}
|
||||||
|
aria-label="Configuration"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="gray.300"
|
color="gray.300"
|
||||||
_hover={{ color: "white", bg: "whiteAlpha.200" }}
|
_hover={{ color: "white", bg: "whiteAlpha.200" }}
|
||||||
onClick={() => navigate('/music-storage')}
|
onClick={() => navigate('/config')}
|
||||||
ml="auto"
|
ml="auto"
|
||||||
mr={2}
|
mr={2}
|
||||||
>
|
/>
|
||||||
🎵 Music Storage
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Export Library Button */}
|
{/* Export Library Button */}
|
||||||
<IconButton
|
<IconButton
|
||||||
@ -527,23 +525,12 @@ const RekordboxReader: React.FC = () => {
|
|||||||
onClick={handleExportLibrary}
|
onClick={handleExportLibrary}
|
||||||
isDisabled={songs.length === 0}
|
isDisabled={songs.length === 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Configuration Button */}
|
|
||||||
<IconButton
|
|
||||||
icon={<SettingsIcon />}
|
|
||||||
aria-label="Configuration"
|
|
||||||
variant="ghost"
|
|
||||||
color="gray.300"
|
|
||||||
_hover={{ color: "white", bg: "whiteAlpha.200" }}
|
|
||||||
onClick={() => navigate('/config')}
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<Box flex={1} overflow="hidden">
|
<Box flex={1} overflow="hidden">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/config" element={<Configuration />} />
|
<Route path="/config" element={<Configuration />} />
|
||||||
<Route path="/music-storage" element={<MusicStorage />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -23,14 +23,32 @@ import {
|
|||||||
TabPanel,
|
TabPanel,
|
||||||
Icon,
|
Icon,
|
||||||
HStack,
|
HStack,
|
||||||
|
Badge,
|
||||||
|
Spinner,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { ChevronLeftIcon } from "@chakra-ui/icons";
|
import { ChevronLeftIcon } from "@chakra-ui/icons";
|
||||||
import { FiDatabase, FiSettings } from 'react-icons/fi';
|
import { FiDatabase, FiSettings, FiUpload, FiMusic, FiLink, FiRefreshCw, FiTrash2 } from 'react-icons/fi';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useXmlParser } from "../hooks/useXmlParser";
|
import { useXmlParser } from "../hooks/useXmlParser";
|
||||||
import { StyledFileInput } from "../components/StyledFileInput";
|
import { StyledFileInput } from "../components/StyledFileInput";
|
||||||
import { S3Configuration } from "./S3Configuration";
|
import { S3Configuration } from "./S3Configuration";
|
||||||
|
import { MusicUpload } from "../components/MusicUpload";
|
||||||
|
import { SongMatching } from "../components/SongMatching";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
interface MusicFile {
|
||||||
|
_id: string;
|
||||||
|
originalName: string;
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
duration?: number;
|
||||||
|
size: number;
|
||||||
|
format?: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
songId?: any; // Reference to linked song
|
||||||
|
}
|
||||||
|
|
||||||
export function Configuration() {
|
export function Configuration() {
|
||||||
const { resetLibrary } = useXmlParser();
|
const { resetLibrary } = useXmlParser();
|
||||||
@ -38,6 +56,133 @@ export function Configuration() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Music storage state
|
||||||
|
const [musicFiles, setMusicFiles] = useState<MusicFile[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
|
||||||
|
// Load music files on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadMusicFiles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMusicFiles = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/music/files');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setMusicFiles(data.musicFiles || []);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to load music files');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading music files:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to load music files',
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncS3 = async () => {
|
||||||
|
setIsSyncing(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/music/sync-s3', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'S3 Sync Complete',
|
||||||
|
description: `Found ${data.results.total} files, synced ${data.results.newFiles} new files`,
|
||||||
|
status: 'success',
|
||||||
|
duration: 5000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload music files to show the new ones
|
||||||
|
await loadMusicFiles();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to sync S3');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing S3:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to sync S3 files',
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadComplete = (files: MusicFile[]) => {
|
||||||
|
setMusicFiles(prev => [...files, ...prev]);
|
||||||
|
toast({
|
||||||
|
title: 'Upload Complete',
|
||||||
|
description: `${files.length} file${files.length !== 1 ? 's' : ''} uploaded successfully`,
|
||||||
|
status: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFile = async (fileId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/music/${fileId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setMusicFiles(prev => prev.filter(file => file._id !== fileId));
|
||||||
|
toast({
|
||||||
|
title: 'File Deleted',
|
||||||
|
description: 'Music file deleted successfully',
|
||||||
|
status: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to delete file');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting file:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to delete music file',
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
if (!seconds || isNaN(seconds)) return '00:00';
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleResetDatabase = async () => {
|
const handleResetDatabase = async () => {
|
||||||
@ -104,6 +249,24 @@ export function Configuration() {
|
|||||||
<Text>Library Management</Text>
|
<Text>Library Management</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Icon as={FiUpload} />
|
||||||
|
<Text>Upload Music</Text>
|
||||||
|
</HStack>
|
||||||
|
</Tab>
|
||||||
|
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Icon as={FiMusic} />
|
||||||
|
<Text>Music Library</Text>
|
||||||
|
</HStack>
|
||||||
|
</Tab>
|
||||||
|
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Icon as={FiLink} />
|
||||||
|
<Text>Song Matching</Text>
|
||||||
|
</HStack>
|
||||||
|
</Tab>
|
||||||
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Icon as={FiSettings} />
|
<Icon as={FiSettings} />
|
||||||
@ -156,6 +319,134 @@ export function Configuration() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Upload Music Tab */}
|
||||||
|
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
|
||||||
|
<VStack spacing={6} align="stretch">
|
||||||
|
<Box>
|
||||||
|
<Heading size="md" mb={4} color="white">
|
||||||
|
Upload Music Files
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.400" mb={4}>
|
||||||
|
Drag and drop your music files here or click to select. Files will be uploaded to S3 storage
|
||||||
|
and metadata will be automatically extracted.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<MusicUpload onUploadComplete={handleUploadComplete} />
|
||||||
|
</VStack>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Music Library Tab */}
|
||||||
|
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Heading size="md" color="white">Music Library</Heading>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text color="gray.400">
|
||||||
|
{musicFiles.length} file{musicFiles.length !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
leftIcon={isSyncing ? <Spinner size="sm" /> : <FiRefreshCw />}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
colorScheme="blue"
|
||||||
|
onClick={handleSyncS3}
|
||||||
|
isLoading={isSyncing}
|
||||||
|
loadingText="Syncing..."
|
||||||
|
_hover={{ bg: "blue.900", borderColor: "blue.400" }}
|
||||||
|
>
|
||||||
|
Sync S3
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Text textAlign="center" color="gray.500">
|
||||||
|
Loading music files...
|
||||||
|
</Text>
|
||||||
|
) : musicFiles.length === 0 ? (
|
||||||
|
<VStack spacing={4} textAlign="center" color="gray.500">
|
||||||
|
<Text>No music files found in the database.</Text>
|
||||||
|
<Text fontSize="sm">
|
||||||
|
Try uploading files in the Upload tab, or click "Sync S3" to find files already in your S3 bucket.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
leftIcon={<FiRefreshCw />}
|
||||||
|
onClick={handleSyncS3}
|
||||||
|
isLoading={isSyncing}
|
||||||
|
loadingText="Syncing..."
|
||||||
|
colorScheme="blue"
|
||||||
|
_hover={{ bg: "blue.700" }}
|
||||||
|
>
|
||||||
|
Sync S3 Bucket
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<VStack spacing={2} align="stretch">
|
||||||
|
{musicFiles.map((file) => (
|
||||||
|
<Box
|
||||||
|
key={file._id}
|
||||||
|
p={4}
|
||||||
|
border="1px"
|
||||||
|
borderColor="gray.700"
|
||||||
|
borderRadius="md"
|
||||||
|
bg="gray.800"
|
||||||
|
_hover={{ bg: "gray.750" }}
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" align="start">
|
||||||
|
<VStack align="start" spacing={1} flex={1}>
|
||||||
|
<HStack spacing={2} align="center">
|
||||||
|
<Text fontWeight="bold" fontSize="md" color="white" noOfLines={1}>
|
||||||
|
{file.title || file.originalName}
|
||||||
|
</Text>
|
||||||
|
<Badge colorScheme="blue" variant="subtle" bg="blue.900" color="blue.200">
|
||||||
|
{file.format?.toUpperCase() || 'AUDIO'}
|
||||||
|
</Badge>
|
||||||
|
{file.songId && (
|
||||||
|
<Badge colorScheme="green" size="sm" bg="green.900" color="green.200">
|
||||||
|
Linked to Rekordbox
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
{file.artist && (
|
||||||
|
<Text fontSize="sm" color="gray.400" noOfLines={1}>
|
||||||
|
{file.artist}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{file.album && (
|
||||||
|
<Text fontSize="sm" color="gray.500" noOfLines={1}>
|
||||||
|
{file.album}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<HStack spacing={4} fontSize="xs" color="gray.500">
|
||||||
|
<Text>{formatDuration(file.duration || 0)}</Text>
|
||||||
|
<Text>{formatFileSize(file.size)}</Text>
|
||||||
|
<Text>{file.format?.toUpperCase()}</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Delete file"
|
||||||
|
icon={<FiTrash2 />}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorScheme="red"
|
||||||
|
onClick={() => handleDeleteFile(file._id)}
|
||||||
|
_hover={{ bg: "red.900" }}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Song Matching Tab */}
|
||||||
|
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
|
||||||
|
<SongMatching />
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
{/* S3 Configuration Tab */}
|
{/* S3 Configuration Tab */}
|
||||||
<TabPanel bg="gray.800" p={0}>
|
<TabPanel bg="gray.800" p={0}>
|
||||||
<Box p={6}>
|
<Box p={6}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user