Compare commits
5 Commits
862c482565
...
6c879987bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c879987bf | ||
|
|
d87d83eaf6 | ||
|
|
fe3a7abf32 | ||
|
|
1560e614dc | ||
|
|
58eaa50bd2 |
@ -187,6 +187,30 @@ class BackgroundJobService {
|
||||
}
|
||||
};
|
||||
|
||||
// Optional: clear existing links for a force re-sync
|
||||
if (options?.clearLinks) {
|
||||
this.updateProgress(jobId, {
|
||||
message: 'Clearing existing song-file links...',
|
||||
current: 0,
|
||||
total: 0
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
// Reset song s3File metadata
|
||||
Song.updateMany({}, {
|
||||
$set: {
|
||||
's3File.musicFileId': null,
|
||||
's3File.s3Key': null,
|
||||
's3File.s3Url': null,
|
||||
's3File.streamingUrl': null,
|
||||
's3File.hasS3File': false
|
||||
}
|
||||
}),
|
||||
// Unlink music files
|
||||
MusicFile.updateMany({}, { $unset: { songId: '' } })
|
||||
]);
|
||||
}
|
||||
|
||||
// Phase 1: Quick filename matching
|
||||
this.updateProgress(jobId, {
|
||||
message: 'Phase 1: Fetching files from S3...',
|
||||
@ -209,7 +233,7 @@ class BackgroundJobService {
|
||||
// Get existing files
|
||||
const existingFiles = await MusicFile.find({}, { s3Key: 1 });
|
||||
const existingS3Keys = new Set(existingFiles.map(f => f.s3Key));
|
||||
const newAudioFiles = audioFiles.filter(s3File => !existingS3Keys.has(s3File.key));
|
||||
const newAudioFiles = options?.force ? audioFiles : audioFiles.filter(s3File => !existingS3Keys.has(s3File.key));
|
||||
|
||||
this.updateProgress(jobId, {
|
||||
message: `Phase 1: Processing ${newAudioFiles.length} new audio files...`,
|
||||
@ -258,17 +282,19 @@ class BackgroundJobService {
|
||||
|
||||
if (matchedSong) {
|
||||
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
|
||||
const musicFile = new MusicFile({
|
||||
originalName: filename,
|
||||
s3Key: s3File.key,
|
||||
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||
contentType: guessContentType(filename),
|
||||
size: s3File.size,
|
||||
...basicMetadata,
|
||||
songId: matchedSong._id,
|
||||
});
|
||||
// Reuse existing MusicFile if present (force mode), otherwise create new
|
||||
let musicFile = await MusicFile.findOne({ s3Key: s3File.key });
|
||||
if (!musicFile) {
|
||||
musicFile = new MusicFile({ s3Key: s3File.key });
|
||||
}
|
||||
musicFile.originalName = filename;
|
||||
musicFile.s3Key = s3File.key;
|
||||
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
|
||||
musicFile.contentType = guessContentType(filename);
|
||||
musicFile.size = s3File.size;
|
||||
Object.assign(musicFile, basicMetadata);
|
||||
musicFile.songId = matchedSong._id;
|
||||
|
||||
// Save immediately for real-time availability
|
||||
await musicFile.save();
|
||||
quickMatches.push(musicFile);
|
||||
|
||||
@ -335,16 +361,17 @@ class BackgroundJobService {
|
||||
const fileBuffer = await s3Service.getFileContent(s3File.key);
|
||||
const metadata = await audioMetadataService.extractMetadata(fileBuffer, filename);
|
||||
|
||||
const musicFile = new MusicFile({
|
||||
originalName: filename,
|
||||
s3Key: s3File.key,
|
||||
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||
contentType: guessContentType(filename),
|
||||
size: s3File.size,
|
||||
...metadata,
|
||||
});
|
||||
|
||||
processedFiles.push(musicFile);
|
||||
// Reuse existing MusicFile document if present to avoid duplicate key errors
|
||||
let musicFile = await MusicFile.findOne({ s3Key: s3File.key });
|
||||
if (!musicFile) {
|
||||
musicFile = new MusicFile({ s3Key: s3File.key });
|
||||
}
|
||||
musicFile.originalName = filename;
|
||||
musicFile.s3Key = s3File.key;
|
||||
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
|
||||
musicFile.contentType = guessContentType(filename);
|
||||
musicFile.size = s3File.size;
|
||||
Object.assign(musicFile, metadata);
|
||||
|
||||
// Try complex matching
|
||||
const matchResult = await songMatchingService.matchMusicFileToSongs(musicFile, {
|
||||
@ -376,7 +403,7 @@ class BackgroundJobService {
|
||||
stillUnmatched++;
|
||||
}
|
||||
|
||||
// Save immediately for real-time availability
|
||||
// Save immediately for real-time availability (create or update)
|
||||
await musicFile.save();
|
||||
processedFiles.push(musicFile);
|
||||
|
||||
@ -546,11 +573,12 @@ class BackgroundJobService {
|
||||
updateOne: {
|
||||
filter: { _id: bestMatch.song._id },
|
||||
update: {
|
||||
$addToSet: {
|
||||
s3File: {
|
||||
musicFileId: musicFile._id,
|
||||
hasS3File: true
|
||||
}
|
||||
$set: {
|
||||
's3File.musicFileId': musicFile._id,
|
||||
's3File.s3Key': musicFile.s3Key,
|
||||
's3File.s3Url': musicFile.s3Url,
|
||||
's3File.streamingUrl': musicFile.s3Key ? `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${musicFile.s3Key}` : undefined,
|
||||
's3File.hasS3File': true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ import {
|
||||
Tooltip,
|
||||
Spinner,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiPlay, FiLink, FiSearch, FiZap, FiMusic, FiCheck, FiX } from 'react-icons/fi';
|
||||
import { FiPlay, FiLink, FiSearch, FiZap } from 'react-icons/fi';
|
||||
|
||||
interface MatchResult {
|
||||
song: any;
|
||||
@ -52,8 +52,7 @@ interface MatchingStats {
|
||||
export const SongMatching: React.FC = () => {
|
||||
const [stats, setStats] = useState<MatchingStats | null>(null);
|
||||
const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState<any[]>([]);
|
||||
const [matchedMusicFiles, setMatchedMusicFiles] = useState<any[]>([]);
|
||||
const [songsWithMusicFiles, setSongsWithMusicFiles] = useState<any[]>([]);
|
||||
// Removed matched and songs-with-files lists to reduce load
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [autoLinking, setAutoLinking] = useState(false);
|
||||
const [selectedMusicFile, setSelectedMusicFile] = useState<any>(null);
|
||||
@ -70,11 +69,9 @@ export const SongMatching: React.FC = () => {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [statsRes, unmatchedRes, matchedRes, songsWithRes] = await Promise.all([
|
||||
const [statsRes, unmatchedRes] = await Promise.all([
|
||||
fetch('/api/matching/stats'),
|
||||
fetch('/api/matching/unmatched-music-files?limit=200'),
|
||||
fetch('/api/matching/matched-music-files?limit=200'),
|
||||
fetch('/api/matching/songs-with-music-files?limit=200')
|
||||
fetch('/api/matching/unmatched-music-files?limit=200')
|
||||
]);
|
||||
|
||||
if (statsRes.ok) {
|
||||
@ -87,15 +84,7 @@ export const SongMatching: React.FC = () => {
|
||||
setUnmatchedMusicFiles(unmatchedData.musicFiles);
|
||||
}
|
||||
|
||||
if (matchedRes.ok) {
|
||||
const matchedData = await matchedRes.json();
|
||||
setMatchedMusicFiles(matchedData.musicFiles);
|
||||
}
|
||||
|
||||
if (songsWithRes.ok) {
|
||||
const songsData = await songsWithRes.json();
|
||||
setSongsWithMusicFiles(songsData.songs);
|
||||
}
|
||||
// Matched and songs-with-files lists are omitted
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
toast({
|
||||
@ -125,24 +114,25 @@ export const SongMatching: React.FC = () => {
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
toast({
|
||||
title: 'Auto-linking Complete',
|
||||
description: `Linked ${result.result.linked} files, ${result.result.unmatched} unmatched`,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
loadData(); // Refresh data
|
||||
} else {
|
||||
throw new Error('Auto-linking failed');
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(errText || 'Auto-linking failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
toast({
|
||||
title: 'Auto-linking Started',
|
||||
description: `Job ${result.jobId} started.`,
|
||||
status: 'info',
|
||||
duration: 4000,
|
||||
isClosable: true,
|
||||
});
|
||||
// Do not call loadData immediately; background job UI will refresh stats as it completes
|
||||
} catch (error) {
|
||||
console.error('Error during auto-linking:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to auto-link music files',
|
||||
description: error instanceof Error ? error.message : 'Failed to auto-link music files',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
@ -209,35 +199,7 @@ export const SongMatching: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlinkMusicFile = async (songId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/matching/unlink/${songId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Music file unlinked from song successfully',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
loadData(); // Refresh data
|
||||
} else {
|
||||
throw new Error('Failed to unlink music file');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error unlinking music file:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to unlink music file from song',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
// Unlink handler removed with matched/songs-with-files lists
|
||||
|
||||
const getConfidenceColor = (confidence: number) => {
|
||||
if (confidence >= 0.9) return 'green';
|
||||
@ -386,162 +348,7 @@ export const SongMatching: React.FC = () => {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Matched Music Files */}
|
||||
<Card bg="gray.800" borderColor="gray.700" borderWidth="1px">
|
||||
<CardHeader>
|
||||
<Heading size="md" color="white">Matched Music Files ({matchedMusicFiles.length})</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{matchedMusicFiles.length === 0 ? (
|
||||
<Text color="gray.500" textAlign="center">
|
||||
No music files are matched yet.
|
||||
</Text>
|
||||
) : (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{matchedMusicFiles.slice(0, 10).map((file) => (
|
||||
<Box
|
||||
key={file._id}
|
||||
p={3}
|
||||
border="1px"
|
||||
borderColor="green.700"
|
||||
borderRadius="md"
|
||||
bg="green.900"
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack spacing={2}>
|
||||
<Text fontWeight="bold" fontSize="sm" color="white">
|
||||
{file.title || file.originalName}
|
||||
</Text>
|
||||
<Badge colorScheme="green" size="sm" bg="green.800" color="green.200">
|
||||
<FiCheck style={{ marginRight: '4px' }} />
|
||||
Matched
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.300">
|
||||
{file.artist}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
{file.album}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="Unlink music file">
|
||||
<IconButton
|
||||
aria-label="Unlink"
|
||||
icon={<FiX />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleUnlinkMusicFile(file.songId)}
|
||||
_hover={{ bg: "red.900" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Play music file">
|
||||
<IconButton
|
||||
aria-label="Play"
|
||||
icon={<FiPlay />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
_hover={{ bg: "blue.900" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
{matchedMusicFiles.length > 10 && (
|
||||
<Text fontSize="sm" color="gray.500" textAlign="center">
|
||||
Showing first 10 of {matchedMusicFiles.length} matched files
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Songs with Music Files */}
|
||||
<Card bg="gray.800" borderColor="gray.700" borderWidth="1px">
|
||||
<CardHeader>
|
||||
<Heading size="md" color="white">Songs with Music Files ({songsWithMusicFiles.length})</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{songsWithMusicFiles.length === 0 ? (
|
||||
<Text color="gray.500" textAlign="center">
|
||||
No songs have music files linked yet.
|
||||
</Text>
|
||||
) : (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{songsWithMusicFiles.slice(0, 10).map((song) => (
|
||||
<Box
|
||||
key={song._id}
|
||||
p={3}
|
||||
border="1px"
|
||||
borderColor="blue.700"
|
||||
borderRadius="md"
|
||||
bg="blue.900"
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack spacing={2}>
|
||||
<Text fontWeight="bold" fontSize="sm" color="white">
|
||||
{song.title}
|
||||
</Text>
|
||||
<Badge colorScheme="blue" size="sm" bg="blue.800" color="blue.200">
|
||||
<FiMusic style={{ marginRight: '4px' }} />
|
||||
Has S3 File
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.300">
|
||||
{song.artist}
|
||||
</Text>
|
||||
{song.location && (
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
📁 {song.location}
|
||||
</Text>
|
||||
)}
|
||||
{song.s3File?.streamingUrl && (
|
||||
<Text fontSize="xs" color="green.400">
|
||||
🎵 S3: {song.s3File.s3Key}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="Unlink music file">
|
||||
<IconButton
|
||||
aria-label="Unlink"
|
||||
icon={<FiX />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleUnlinkMusicFile(song._id)}
|
||||
_hover={{ bg: "red.900" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Play music file">
|
||||
<IconButton
|
||||
aria-label="Play"
|
||||
icon={<FiPlay />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
_hover={{ bg: "blue.800" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
{songsWithMusicFiles.length > 10 && (
|
||||
<Text fontSize="sm" color="gray.500" textAlign="center">
|
||||
Showing first 10 of {songsWithMusicFiles.length} songs with music files
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/* Removed Matched Music Files and Songs with Music Files lists to streamline UI */}
|
||||
|
||||
{/* Suggestions Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
|
||||
@ -23,11 +23,9 @@ import {
|
||||
TabPanel,
|
||||
Icon,
|
||||
HStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChevronLeftIcon } from "@chakra-ui/icons";
|
||||
import { FiDatabase, FiSettings, FiUpload, FiMusic, FiLink, FiRefreshCw, FiTrash2 } from 'react-icons/fi';
|
||||
import { FiDatabase, FiSettings, FiUpload, FiLink, FiRefreshCw, FiTrash2 } from 'react-icons/fi';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useXmlParser } from "../hooks/useXmlParser";
|
||||
import { StyledFileInput } from "../components/StyledFileInput";
|
||||
@ -36,7 +34,7 @@ import { MusicUpload } from "../components/MusicUpload";
|
||||
import { SongMatching } from "../components/SongMatching";
|
||||
import { api } from "../services/api";
|
||||
import { DuplicatesViewer } from "../components/DuplicatesViewer.tsx";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
interface MusicFile {
|
||||
_id: string;
|
||||
@ -57,12 +55,7 @@ export function Configuration() {
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Music storage state
|
||||
const [musicFiles, setMusicFiles] = useState<MusicFile[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [hasS3Config, setHasS3Config] = useState<boolean>(false);
|
||||
const [musicLoaded, setMusicLoaded] = useState<boolean>(false);
|
||||
// Music storage state removed from Config; see Sync & Matching section
|
||||
|
||||
// Tabs: remember active tab across refreshes
|
||||
const initialTabIndex = useMemo(() => {
|
||||
@ -71,99 +64,13 @@ export function Configuration() {
|
||||
}, []);
|
||||
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
|
||||
|
||||
// Tab indices (keep in sync with Tab order below)
|
||||
const TAB_INDEX = {
|
||||
LIBRARY: 0,
|
||||
UPLOAD: 1,
|
||||
MUSIC_LIBRARY: 2,
|
||||
MATCHING: 3,
|
||||
DUPLICATES: 4,
|
||||
S3_CONFIG: 5,
|
||||
} as const;
|
||||
// No explicit tab index enum needed
|
||||
|
||||
// Fetch S3 config (small and safe to do on mount)
|
||||
useEffect(() => {
|
||||
const fetchS3Config = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/config/s3');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const cfg = data?.config || {};
|
||||
const required = ['endpoint', 'region', 'accessKeyId', 'secretAccessKey', 'bucketName'];
|
||||
const present = required.every((k) => typeof cfg[k] === 'string' && cfg[k]);
|
||||
setHasS3Config(present);
|
||||
} else {
|
||||
setHasS3Config(false);
|
||||
}
|
||||
} catch {
|
||||
setHasS3Config(false);
|
||||
}
|
||||
};
|
||||
fetchS3Config();
|
||||
}, []);
|
||||
// S3 config fetch removed; Sync buttons remain available in the panel
|
||||
|
||||
// Lazy-load: only load music files when Music Library tab becomes active
|
||||
useEffect(() => {
|
||||
if (tabIndex === TAB_INDEX.MUSIC_LIBRARY && !musicLoaded) {
|
||||
loadMusicFiles().then(() => setMusicLoaded(true));
|
||||
}
|
||||
}, [tabIndex]);
|
||||
|
||||
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 { jobId } = await api.startBackgroundJob('s3-sync');
|
||||
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 (tabIndex !== TAB_INDEX.MUSIC_LIBRARY) {
|
||||
setMusicLoaded(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting S3 sync:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to start S3 sync',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
// Removed Music Library sync handlers from Config (moved to Sync & Matching panel)
|
||||
|
||||
const handleUploadComplete = (files: MusicFile[]) => {
|
||||
setMusicFiles(prev => [...files, ...prev]);
|
||||
toast({
|
||||
title: 'Upload Complete',
|
||||
description: `${files.length} file${files.length !== 1 ? 's' : ''} uploaded successfully`,
|
||||
@ -172,53 +79,9 @@ export function Configuration() {
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
// Deletion handler not needed in Config anymore
|
||||
|
||||
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')}`;
|
||||
};
|
||||
// Utilities for file list removed with Music Library tab
|
||||
|
||||
|
||||
const handleResetDatabase = async () => {
|
||||
@ -300,12 +163,7 @@ export function Configuration() {
|
||||
<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>
|
||||
{/* Hide heavy Music Library tab from config to reduce load; may move to separate page later */}
|
||||
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiLink} />
|
||||
@ -386,123 +244,40 @@ export function Configuration() {
|
||||
</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}
|
||||
isDisabled={!hasS3Config || isSyncing}
|
||||
loadingText="Syncing..."
|
||||
_hover={{ bg: "blue.900", borderColor: "blue.400" }}
|
||||
>
|
||||
Sync S3
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{tabIndex === TAB_INDEX.MUSIC_LIBRARY && isLoading ? (
|
||||
<Text textAlign="center" color="gray.500">
|
||||
Loading music files...
|
||||
</Text>
|
||||
) : tabIndex === TAB_INDEX.MUSIC_LIBRARY && 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}
|
||||
isDisabled={!hasS3Config || isSyncing}
|
||||
loadingText="Syncing..."
|
||||
colorScheme="blue"
|
||||
_hover={{ bg: "blue.700" }}
|
||||
>
|
||||
Sync S3 Bucket
|
||||
</Button>
|
||||
{!hasS3Config && (
|
||||
<Text fontSize="xs" color="orange.300">
|
||||
Configure your S3 connection in the S3 Configuration tab first.
|
||||
</Text>
|
||||
)}
|
||||
</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>
|
||||
{/* Music Library tab removed from Config (heavy). Consider separate page if needed. */}
|
||||
|
||||
{/* Song Matching Tab */}
|
||||
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
|
||||
<SongMatching />
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Heading size="md" color="white">Sync and Matching</Heading>
|
||||
<HStack spacing={3}>
|
||||
<Button
|
||||
leftIcon={<FiRefreshCw />}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
onClick={() => api.startS3Sync()}
|
||||
>
|
||||
Sync S3 (incremental)
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FiRefreshCw />}
|
||||
colorScheme="orange"
|
||||
variant="outline"
|
||||
onClick={() => api.startS3Sync({ force: true })}
|
||||
>
|
||||
Force Sync (rescan all)
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FiTrash2 />}
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
onClick={() => api.startS3Sync({ clearLinks: true, force: true })}
|
||||
>
|
||||
Clear Links + Force Sync
|
||||
</Button>
|
||||
</HStack>
|
||||
<SongMatching />
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Duplicates Tab */}
|
||||
|
||||
@ -155,6 +155,16 @@ class Api {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async startS3Sync(options?: { force?: boolean; clearLinks?: boolean }): Promise<{ jobId: string; type: string }> {
|
||||
const response = await fetch(`${API_BASE_URL}/music/sync-s3`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options || {})
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to start S3 sync');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getJobProgress(jobId: string): Promise<any> {
|
||||
const response = await fetch(`${API_BASE_URL}/background-jobs/progress/${jobId}`);
|
||||
if (!response.ok) throw new Error('Failed to get job progress');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user