feat(frontend): start S3 sync via background job API and rely on progress widget instead of direct endpoint; remove unused MusicStorage page
This commit is contained in:
parent
31a420cf5c
commit
4f440267bd
@ -35,7 +35,7 @@ import { S3Configuration } from "./S3Configuration";
|
|||||||
import { MusicUpload } from "../components/MusicUpload";
|
import { MusicUpload } from "../components/MusicUpload";
|
||||||
import { SongMatching } from "../components/SongMatching";
|
import { SongMatching } from "../components/SongMatching";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import { DuplicatesViewer } from "../components/DuplicatesViewer";
|
import { DuplicatesViewer } from "../components/DuplicatesViewer.tsx";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
interface MusicFile {
|
interface MusicFile {
|
||||||
@ -136,35 +136,23 @@ export function Configuration() {
|
|||||||
const handleSyncS3 = async () => {
|
const handleSyncS3 = async () => {
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/music/sync-s3', {
|
const { jobId } = await api.startBackgroundJob('s3-sync');
|
||||||
method: 'POST',
|
toast({
|
||||||
|
title: 'S3 Sync Started',
|
||||||
|
description: `Job ${jobId} started. Progress will appear shortly.`,
|
||||||
|
status: 'info',
|
||||||
|
duration: 4000,
|
||||||
|
isClosable: true,
|
||||||
});
|
});
|
||||||
|
// Defer reloading; background job widget will show progress and we fetch on demand
|
||||||
if (response.ok) {
|
if (tabIndex !== TAB_INDEX.MUSIC_LIBRARY) {
|
||||||
const data = await response.json();
|
setMusicLoaded(false);
|
||||||
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 if user is on Music Library tab
|
|
||||||
if (tabIndex === TAB_INDEX.MUSIC_LIBRARY) {
|
|
||||||
await loadMusicFiles();
|
|
||||||
} else {
|
|
||||||
// Mark as not loaded so that when user opens the tab, it fetches fresh
|
|
||||||
setMusicLoaded(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to sync S3');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error syncing S3:', error);
|
console.error('Error starting S3 sync:', error);
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'Failed to sync S3 files',
|
description: 'Failed to start S3 sync',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
isClosable: true,
|
isClosable: true,
|
||||||
|
|||||||
@ -1,388 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Text,
|
|
||||||
Heading,
|
|
||||||
Tabs,
|
|
||||||
TabList,
|
|
||||||
TabPanels,
|
|
||||||
Tab,
|
|
||||||
TabPanel,
|
|
||||||
SimpleGrid,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardHeader,
|
|
||||||
Badge,
|
|
||||||
IconButton,
|
|
||||||
useToast,
|
|
||||||
Alert,
|
|
||||||
AlertIcon,
|
|
||||||
Button,
|
|
||||||
Spinner,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { FiPlay, FiTrash2, FiMusic, FiRefreshCw } from 'react-icons/fi';
|
|
||||||
import { MusicUpload } from '../components/MusicUpload';
|
|
||||||
import { SongMatching } from '../components/SongMatching';
|
|
||||||
import { useMusicPlayer } from '../contexts/MusicPlayerContext';
|
|
||||||
import type { Song } from '../types/interfaces';
|
|
||||||
|
|
||||||
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 const MusicStorage: React.FC = () => {
|
|
||||||
const [musicFiles, setMusicFiles] = useState<MusicFile[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
|
||||||
const { playSong } = useMusicPlayer();
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
// 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: `Successfully uploaded ${files.length} file(s)`,
|
|
||||||
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));
|
|
||||||
// The persistent player will handle removing the song if it was playing this file
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle playing a music file from the Music Storage page
|
|
||||||
const handlePlayMusicFile = async (musicFile: MusicFile) => {
|
|
||||||
try {
|
|
||||||
// Create a Song object from the music file for the persistent player
|
|
||||||
const song: Song = {
|
|
||||||
id: musicFile._id,
|
|
||||||
title: musicFile.title || musicFile.originalName,
|
|
||||||
artist: musicFile.artist || 'Unknown Artist',
|
|
||||||
album: musicFile.album || '',
|
|
||||||
totalTime: musicFile.duration?.toString() || '0',
|
|
||||||
location: '',
|
|
||||||
s3File: {
|
|
||||||
musicFileId: musicFile._id,
|
|
||||||
s3Key: '', // This will be fetched by the persistent player
|
|
||||||
s3Url: '',
|
|
||||||
streamingUrl: '',
|
|
||||||
hasS3File: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
playSong(song);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error playing music file:', error);
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'Failed to play 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 mins = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
p={6}
|
|
||||||
maxW="1200px"
|
|
||||||
mx="auto"
|
|
||||||
minH="100vh"
|
|
||||||
bg="gray.900"
|
|
||||||
color="gray.100"
|
|
||||||
overflowY="auto"
|
|
||||||
height="100vh"
|
|
||||||
>
|
|
||||||
<VStack spacing={6} align="stretch">
|
|
||||||
<Heading size="lg" textAlign="center" color="white">
|
|
||||||
🎵 Music Storage & Playback
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<Alert status="info" bg="blue.900" borderColor="blue.700" color="blue.100">
|
|
||||||
<AlertIcon color="blue.300" />
|
|
||||||
<Box>
|
|
||||||
<Text fontWeight="bold" color="blue.100">S3 Storage Feature</Text>
|
|
||||||
<Text fontSize="sm" color="blue.200">
|
|
||||||
Upload your music files to S3-compatible storage (MinIO) and stream them directly in the browser.
|
|
||||||
Supports MP3, WAV, FLAC, AAC, OGG, WMA, and Opus formats.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Tabs variant="enclosed" colorScheme="blue" height="calc(100vh - 200px)" display="flex" flexDirection="column">
|
|
||||||
<TabList bg="gray.800" borderColor="gray.700" flexShrink={0}>
|
|
||||||
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
|
||||||
Upload Music
|
|
||||||
</Tab>
|
|
||||||
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
|
||||||
Music Library
|
|
||||||
</Tab>
|
|
||||||
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
|
||||||
Song Matching
|
|
||||||
</Tab>
|
|
||||||
</TabList>
|
|
||||||
|
|
||||||
<TabPanels flex={1} overflow="hidden">
|
|
||||||
{/* Upload Tab */}
|
|
||||||
<TabPanel bg="gray.900" height="100%" overflowY="auto">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Library Tab */}
|
|
||||||
<TabPanel bg="gray.900" height="100%" overflowY="auto">
|
|
||||||
<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="Play file"
|
|
||||||
icon={<FiPlay />}
|
|
||||||
size="sm"
|
|
||||||
colorScheme="blue"
|
|
||||||
onClick={() => handlePlayMusicFile(file)}
|
|
||||||
_hover={{ bg: "blue.700" }}
|
|
||||||
/>
|
|
||||||
<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.900" height="100%" overflowY="auto">
|
|
||||||
<SongMatching />
|
|
||||||
</TabPanel>
|
|
||||||
</TabPanels>
|
|
||||||
</Tabs>
|
|
||||||
</VStack>
|
|
||||||
{/* Persistent Music Player */}
|
|
||||||
{/* The PersistentMusicPlayer component is now managed by the global context */}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Loading…
x
Reference in New Issue
Block a user