Merge branch 'feat/config-sync-refactor' into main
This commit is contained in:
commit
6c879987bf
@ -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
|
// Phase 1: Quick filename matching
|
||||||
this.updateProgress(jobId, {
|
this.updateProgress(jobId, {
|
||||||
message: 'Phase 1: Fetching files from S3...',
|
message: 'Phase 1: Fetching files from S3...',
|
||||||
@ -209,7 +233,7 @@ class BackgroundJobService {
|
|||||||
// Get existing files
|
// Get existing files
|
||||||
const existingFiles = await MusicFile.find({}, { s3Key: 1 });
|
const existingFiles = await MusicFile.find({}, { s3Key: 1 });
|
||||||
const existingS3Keys = new Set(existingFiles.map(f => f.s3Key));
|
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, {
|
this.updateProgress(jobId, {
|
||||||
message: `Phase 1: Processing ${newAudioFiles.length} new audio files...`,
|
message: `Phase 1: Processing ${newAudioFiles.length} new audio files...`,
|
||||||
@ -258,17 +282,19 @@ class BackgroundJobService {
|
|||||||
|
|
||||||
if (matchedSong) {
|
if (matchedSong) {
|
||||||
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
|
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
|
||||||
const musicFile = new MusicFile({
|
// Reuse existing MusicFile if present (force mode), otherwise create new
|
||||||
originalName: filename,
|
let musicFile = await MusicFile.findOne({ s3Key: s3File.key });
|
||||||
s3Key: s3File.key,
|
if (!musicFile) {
|
||||||
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
musicFile = new MusicFile({ s3Key: s3File.key });
|
||||||
contentType: guessContentType(filename),
|
}
|
||||||
size: s3File.size,
|
musicFile.originalName = filename;
|
||||||
...basicMetadata,
|
musicFile.s3Key = s3File.key;
|
||||||
songId: matchedSong._id,
|
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();
|
await musicFile.save();
|
||||||
quickMatches.push(musicFile);
|
quickMatches.push(musicFile);
|
||||||
|
|
||||||
@ -335,16 +361,17 @@ class BackgroundJobService {
|
|||||||
const fileBuffer = await s3Service.getFileContent(s3File.key);
|
const fileBuffer = await s3Service.getFileContent(s3File.key);
|
||||||
const metadata = await audioMetadataService.extractMetadata(fileBuffer, filename);
|
const metadata = await audioMetadataService.extractMetadata(fileBuffer, filename);
|
||||||
|
|
||||||
const musicFile = new MusicFile({
|
// Reuse existing MusicFile document if present to avoid duplicate key errors
|
||||||
originalName: filename,
|
let musicFile = await MusicFile.findOne({ s3Key: s3File.key });
|
||||||
s3Key: s3File.key,
|
if (!musicFile) {
|
||||||
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
musicFile = new MusicFile({ s3Key: s3File.key });
|
||||||
contentType: guessContentType(filename),
|
}
|
||||||
size: s3File.size,
|
musicFile.originalName = filename;
|
||||||
...metadata,
|
musicFile.s3Key = s3File.key;
|
||||||
});
|
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
|
||||||
|
musicFile.contentType = guessContentType(filename);
|
||||||
processedFiles.push(musicFile);
|
musicFile.size = s3File.size;
|
||||||
|
Object.assign(musicFile, metadata);
|
||||||
|
|
||||||
// Try complex matching
|
// Try complex matching
|
||||||
const matchResult = await songMatchingService.matchMusicFileToSongs(musicFile, {
|
const matchResult = await songMatchingService.matchMusicFileToSongs(musicFile, {
|
||||||
@ -376,7 +403,7 @@ class BackgroundJobService {
|
|||||||
stillUnmatched++;
|
stillUnmatched++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save immediately for real-time availability
|
// Save immediately for real-time availability (create or update)
|
||||||
await musicFile.save();
|
await musicFile.save();
|
||||||
processedFiles.push(musicFile);
|
processedFiles.push(musicFile);
|
||||||
|
|
||||||
@ -546,11 +573,12 @@ class BackgroundJobService {
|
|||||||
updateOne: {
|
updateOne: {
|
||||||
filter: { _id: bestMatch.song._id },
|
filter: { _id: bestMatch.song._id },
|
||||||
update: {
|
update: {
|
||||||
$addToSet: {
|
$set: {
|
||||||
s3File: {
|
's3File.musicFileId': musicFile._id,
|
||||||
musicFileId: musicFile._id,
|
's3File.s3Key': musicFile.s3Key,
|
||||||
hasS3File: true
|
'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,
|
Tooltip,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from '@chakra-ui/react';
|
} 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 {
|
interface MatchResult {
|
||||||
song: any;
|
song: any;
|
||||||
@ -52,8 +52,7 @@ interface MatchingStats {
|
|||||||
export const SongMatching: React.FC = () => {
|
export const SongMatching: React.FC = () => {
|
||||||
const [stats, setStats] = useState<MatchingStats | null>(null);
|
const [stats, setStats] = useState<MatchingStats | null>(null);
|
||||||
const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState<any[]>([]);
|
const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState<any[]>([]);
|
||||||
const [matchedMusicFiles, setMatchedMusicFiles] = useState<any[]>([]);
|
// Removed matched and songs-with-files lists to reduce load
|
||||||
const [songsWithMusicFiles, setSongsWithMusicFiles] = useState<any[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [autoLinking, setAutoLinking] = useState(false);
|
const [autoLinking, setAutoLinking] = useState(false);
|
||||||
const [selectedMusicFile, setSelectedMusicFile] = useState<any>(null);
|
const [selectedMusicFile, setSelectedMusicFile] = useState<any>(null);
|
||||||
@ -70,11 +69,9 @@ export const SongMatching: React.FC = () => {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statsRes, unmatchedRes, matchedRes, songsWithRes] = await Promise.all([
|
const [statsRes, unmatchedRes] = await Promise.all([
|
||||||
fetch('/api/matching/stats'),
|
fetch('/api/matching/stats'),
|
||||||
fetch('/api/matching/unmatched-music-files?limit=200'),
|
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')
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statsRes.ok) {
|
if (statsRes.ok) {
|
||||||
@ -87,15 +84,7 @@ export const SongMatching: React.FC = () => {
|
|||||||
setUnmatchedMusicFiles(unmatchedData.musicFiles);
|
setUnmatchedMusicFiles(unmatchedData.musicFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedRes.ok) {
|
// Matched and songs-with-files lists are omitted
|
||||||
const matchedData = await matchedRes.json();
|
|
||||||
setMatchedMusicFiles(matchedData.musicFiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (songsWithRes.ok) {
|
|
||||||
const songsData = await songsWithRes.json();
|
|
||||||
setSongsWithMusicFiles(songsData.songs);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading data:', error);
|
console.error('Error loading data:', error);
|
||||||
toast({
|
toast({
|
||||||
@ -125,24 +114,25 @@ export const SongMatching: React.FC = () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(errText || 'Auto-linking failed');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
toast({
|
toast({
|
||||||
title: 'Auto-linking Complete',
|
title: 'Auto-linking Started',
|
||||||
description: `Linked ${result.result.linked} files, ${result.result.unmatched} unmatched`,
|
description: `Job ${result.jobId} started.`,
|
||||||
status: 'success',
|
status: 'info',
|
||||||
duration: 5000,
|
duration: 4000,
|
||||||
isClosable: true,
|
isClosable: true,
|
||||||
});
|
});
|
||||||
loadData(); // Refresh data
|
// Do not call loadData immediately; background job UI will refresh stats as it completes
|
||||||
} else {
|
|
||||||
throw new Error('Auto-linking failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during auto-linking:', error);
|
console.error('Error during auto-linking:', error);
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'Failed to auto-link music files',
|
description: error instanceof Error ? error.message : 'Failed to auto-link music files',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
isClosable: true,
|
isClosable: true,
|
||||||
@ -209,35 +199,7 @@ export const SongMatching: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnlinkMusicFile = async (songId: string) => {
|
// Unlink handler removed with matched/songs-with-files lists
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConfidenceColor = (confidence: number) => {
|
const getConfidenceColor = (confidence: number) => {
|
||||||
if (confidence >= 0.9) return 'green';
|
if (confidence >= 0.9) return 'green';
|
||||||
@ -386,162 +348,7 @@ export const SongMatching: React.FC = () => {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Matched Music Files */}
|
{/* Removed Matched Music Files and Songs with Music Files lists to streamline UI */}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Suggestions Modal */}
|
{/* Suggestions Modal */}
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||||
|
|||||||
@ -23,11 +23,9 @@ 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, 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 { 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";
|
||||||
@ -36,7 +34,7 @@ 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.tsx";
|
import { DuplicatesViewer } from "../components/DuplicatesViewer.tsx";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
interface MusicFile {
|
interface MusicFile {
|
||||||
_id: string;
|
_id: string;
|
||||||
@ -57,12 +55,7 @@ export function Configuration() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Music storage state
|
// Music storage state removed from Config; see Sync & Matching section
|
||||||
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);
|
|
||||||
|
|
||||||
// Tabs: remember active tab across refreshes
|
// Tabs: remember active tab across refreshes
|
||||||
const initialTabIndex = useMemo(() => {
|
const initialTabIndex = useMemo(() => {
|
||||||
@ -71,99 +64,13 @@ export function Configuration() {
|
|||||||
}, []);
|
}, []);
|
||||||
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
|
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
|
||||||
|
|
||||||
// Tab indices (keep in sync with Tab order below)
|
// No explicit tab index enum needed
|
||||||
const TAB_INDEX = {
|
|
||||||
LIBRARY: 0,
|
|
||||||
UPLOAD: 1,
|
|
||||||
MUSIC_LIBRARY: 2,
|
|
||||||
MATCHING: 3,
|
|
||||||
DUPLICATES: 4,
|
|
||||||
S3_CONFIG: 5,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Fetch S3 config (small and safe to do on mount)
|
// S3 config fetch removed; Sync buttons remain available in the panel
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Lazy-load: only load music files when Music Library tab becomes active
|
// Removed Music Library sync handlers from Config (moved to Sync & Matching panel)
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadComplete = (files: MusicFile[]) => {
|
const handleUploadComplete = (files: MusicFile[]) => {
|
||||||
setMusicFiles(prev => [...files, ...prev]);
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Upload Complete',
|
title: 'Upload Complete',
|
||||||
description: `${files.length} file${files.length !== 1 ? 's' : ''} uploaded successfully`,
|
description: `${files.length} file${files.length !== 1 ? 's' : ''} uploaded successfully`,
|
||||||
@ -172,53 +79,9 @@ export function Configuration() {
|
|||||||
isClosable: true,
|
isClosable: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
// Deletion handler not needed in Config anymore
|
||||||
|
|
||||||
const handleDeleteFile = async (fileId: string) => {
|
// Utilities for file list removed with Music Library tab
|
||||||
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 () => {
|
||||||
@ -300,12 +163,7 @@ export function Configuration() {
|
|||||||
<Text>Upload Music</Text>
|
<Text>Upload Music</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
{/* Hide heavy Music Library tab from config to reduce load; may move to separate page later */}
|
||||||
<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" }}>
|
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Icon as={FiLink} />
|
<Icon as={FiLink} />
|
||||||
@ -386,123 +244,40 @@ export function Configuration() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Music Library Tab */}
|
{/* Music Library tab removed from Config (heavy). Consider separate page if needed. */}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Song Matching Tab */}
|
{/* Song Matching Tab */}
|
||||||
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
|
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
|
||||||
|
<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 />
|
<SongMatching />
|
||||||
|
</VStack>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Duplicates Tab */}
|
{/* Duplicates Tab */}
|
||||||
|
|||||||
@ -155,6 +155,16 @@ class Api {
|
|||||||
return response.json();
|
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> {
|
async getJobProgress(jobId: string): Promise<any> {
|
||||||
const response = await fetch(`${API_BASE_URL}/background-jobs/progress/${jobId}`);
|
const response = await fetch(`${API_BASE_URL}/background-jobs/progress/${jobId}`);
|
||||||
if (!response.ok) throw new Error('Failed to get job progress');
|
if (!response.ok) throw new Error('Failed to get job progress');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user