Compare commits
No commits in common. "6c879987bf214427020b26b5b56d1dfed03ccfb3" and "862c4825652e371262256b903ee4dba71dd5081c" have entirely different histories.
6c879987bf
...
862c482565
@ -187,30 +187,6 @@ 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...',
|
||||||
@ -233,7 +209,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 = options?.force ? audioFiles : audioFiles.filter(s3File => !existingS3Keys.has(s3File.key));
|
const newAudioFiles = 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...`,
|
||||||
@ -282,19 +258,17 @@ class BackgroundJobService {
|
|||||||
|
|
||||||
if (matchedSong) {
|
if (matchedSong) {
|
||||||
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
|
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
|
||||||
// Reuse existing MusicFile if present (force mode), otherwise create new
|
const musicFile = new MusicFile({
|
||||||
let musicFile = await MusicFile.findOne({ s3Key: s3File.key });
|
originalName: filename,
|
||||||
if (!musicFile) {
|
s3Key: s3File.key,
|
||||||
musicFile = new MusicFile({ s3Key: s3File.key });
|
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||||
}
|
contentType: guessContentType(filename),
|
||||||
musicFile.originalName = filename;
|
size: s3File.size,
|
||||||
musicFile.s3Key = s3File.key;
|
...basicMetadata,
|
||||||
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
|
songId: matchedSong._id,
|
||||||
musicFile.contentType = guessContentType(filename);
|
});
|
||||||
musicFile.size = s3File.size;
|
|
||||||
Object.assign(musicFile, basicMetadata);
|
// Save immediately for real-time availability
|
||||||
musicFile.songId = matchedSong._id;
|
|
||||||
|
|
||||||
await musicFile.save();
|
await musicFile.save();
|
||||||
quickMatches.push(musicFile);
|
quickMatches.push(musicFile);
|
||||||
|
|
||||||
@ -361,17 +335,16 @@ 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);
|
||||||
|
|
||||||
// Reuse existing MusicFile document if present to avoid duplicate key errors
|
const musicFile = new MusicFile({
|
||||||
let musicFile = await MusicFile.findOne({ s3Key: s3File.key });
|
originalName: filename,
|
||||||
if (!musicFile) {
|
s3Key: s3File.key,
|
||||||
musicFile = new MusicFile({ s3Key: s3File.key });
|
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||||
}
|
contentType: guessContentType(filename),
|
||||||
musicFile.originalName = filename;
|
size: s3File.size,
|
||||||
musicFile.s3Key = s3File.key;
|
...metadata,
|
||||||
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
|
});
|
||||||
musicFile.contentType = guessContentType(filename);
|
|
||||||
musicFile.size = s3File.size;
|
processedFiles.push(musicFile);
|
||||||
Object.assign(musicFile, metadata);
|
|
||||||
|
|
||||||
// Try complex matching
|
// Try complex matching
|
||||||
const matchResult = await songMatchingService.matchMusicFileToSongs(musicFile, {
|
const matchResult = await songMatchingService.matchMusicFileToSongs(musicFile, {
|
||||||
@ -403,7 +376,7 @@ class BackgroundJobService {
|
|||||||
stillUnmatched++;
|
stillUnmatched++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save immediately for real-time availability (create or update)
|
// Save immediately for real-time availability
|
||||||
await musicFile.save();
|
await musicFile.save();
|
||||||
processedFiles.push(musicFile);
|
processedFiles.push(musicFile);
|
||||||
|
|
||||||
@ -572,13 +545,12 @@ class BackgroundJobService {
|
|||||||
songUpdates.push({
|
songUpdates.push({
|
||||||
updateOne: {
|
updateOne: {
|
||||||
filter: { _id: bestMatch.song._id },
|
filter: { _id: bestMatch.song._id },
|
||||||
update: {
|
update: {
|
||||||
$set: {
|
$addToSet: {
|
||||||
's3File.musicFileId': musicFile._id,
|
s3File: {
|
||||||
's3File.s3Key': musicFile.s3Key,
|
musicFileId: musicFile._id,
|
||||||
's3File.s3Url': musicFile.s3Url,
|
hasS3File: true
|
||||||
'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 } from 'react-icons/fi';
|
import { FiPlay, FiLink, FiSearch, FiZap, FiMusic, FiCheck, FiX } from 'react-icons/fi';
|
||||||
|
|
||||||
interface MatchResult {
|
interface MatchResult {
|
||||||
song: any;
|
song: any;
|
||||||
@ -52,7 +52,8 @@ 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[]>([]);
|
||||||
// Removed matched and songs-with-files lists to reduce load
|
const [matchedMusicFiles, setMatchedMusicFiles] = useState<any[]>([]);
|
||||||
|
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);
|
||||||
@ -69,9 +70,11 @@ export const SongMatching: React.FC = () => {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statsRes, unmatchedRes] = await Promise.all([
|
const [statsRes, unmatchedRes, matchedRes, songsWithRes] = 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) {
|
||||||
@ -84,7 +87,15 @@ export const SongMatching: React.FC = () => {
|
|||||||
setUnmatchedMusicFiles(unmatchedData.musicFiles);
|
setUnmatchedMusicFiles(unmatchedData.musicFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matched and songs-with-files lists are omitted
|
if (matchedRes.ok) {
|
||||||
|
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({
|
||||||
@ -114,25 +125,24 @@ export const SongMatching: React.FC = () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (response.ok) {
|
||||||
const errText = await response.text();
|
const result = await response.json();
|
||||||
throw new Error(errText || 'Auto-linking failed');
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Error during auto-linking:', error);
|
console.error('Error during auto-linking:', error);
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: error instanceof Error ? error.message : 'Failed to auto-link music files',
|
description: 'Failed to auto-link music files',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
isClosable: true,
|
isClosable: true,
|
||||||
@ -199,7 +209,35 @@ export const SongMatching: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unlink handler removed with matched/songs-with-files lists
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getConfidenceColor = (confidence: number) => {
|
const getConfidenceColor = (confidence: number) => {
|
||||||
if (confidence >= 0.9) return 'green';
|
if (confidence >= 0.9) return 'green';
|
||||||
@ -348,7 +386,162 @@ export const SongMatching: React.FC = () => {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Removed Matched Music Files and Songs with Music Files lists to streamline UI */}
|
{/* 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>
|
||||||
|
|
||||||
{/* Suggestions Modal */}
|
{/* Suggestions Modal */}
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||||
|
|||||||
@ -23,9 +23,11 @@ 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, FiLink, FiRefreshCw, FiTrash2 } 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";
|
||||||
@ -34,7 +36,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, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
interface MusicFile {
|
interface MusicFile {
|
||||||
_id: string;
|
_id: string;
|
||||||
@ -55,7 +57,12 @@ export function Configuration() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Music storage state removed from Config; see Sync & Matching section
|
// 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);
|
||||||
|
|
||||||
// Tabs: remember active tab across refreshes
|
// Tabs: remember active tab across refreshes
|
||||||
const initialTabIndex = useMemo(() => {
|
const initialTabIndex = useMemo(() => {
|
||||||
@ -64,13 +71,99 @@ export function Configuration() {
|
|||||||
}, []);
|
}, []);
|
||||||
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
|
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
|
||||||
|
|
||||||
// No explicit tab index enum needed
|
// 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;
|
||||||
|
|
||||||
// S3 config fetch removed; Sync buttons remain available in the panel
|
// 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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Removed Music Library sync handlers from Config (moved to Sync & Matching 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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`,
|
||||||
@ -79,9 +172,53 @@ export function Configuration() {
|
|||||||
isClosable: true,
|
isClosable: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// Deletion handler not needed in Config anymore
|
|
||||||
|
|
||||||
// Utilities for file list removed with Music Library tab
|
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 () => {
|
||||||
@ -163,7 +300,12 @@ export function Configuration() {
|
|||||||
<Text>Upload Music</Text>
|
<Text>Upload Music</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Tab>
|
</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={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} />
|
||||||
@ -244,40 +386,123 @@ export function Configuration() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Music Library tab removed from Config (heavy). Consider separate page if needed. */}
|
{/* 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>
|
||||||
|
|
||||||
{/* 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">
|
<SongMatching />
|
||||||
<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>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Duplicates Tab */}
|
{/* Duplicates Tab */}
|
||||||
|
|||||||
@ -155,16 +155,6 @@ 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