diff --git a/packages/backend/src/services/backgroundJobService.ts b/packages/backend/src/services/backgroundJobService.ts index 94f6672..79cdc2b 100644 --- a/packages/backend/src/services/backgroundJobService.ts +++ b/packages/backend/src/services/backgroundJobService.ts @@ -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, - }); - - // Save immediately for real-time availability + // 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; + 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); @@ -545,12 +572,13 @@ class BackgroundJobService { songUpdates.push({ updateOne: { filter: { _id: bestMatch.song._id }, - update: { - $addToSet: { - s3File: { - musicFileId: musicFile._id, - hasS3File: true - } + update: { + $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 } } } diff --git a/packages/frontend/src/components/SongMatching.tsx b/packages/frontend/src/components/SongMatching.tsx index 920e39a..d33e28d 100644 --- a/packages/frontend/src/components/SongMatching.tsx +++ b/packages/frontend/src/components/SongMatching.tsx @@ -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(null); const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState([]); - const [matchedMusicFiles, setMatchedMusicFiles] = useState([]); - const [songsWithMusicFiles, setSongsWithMusicFiles] = useState([]); + // Removed matched and songs-with-files lists to reduce load const [isLoading, setIsLoading] = useState(false); const [autoLinking, setAutoLinking] = useState(false); const [selectedMusicFile, setSelectedMusicFile] = useState(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 = () => { - {/* Matched Music Files */} - - - Matched Music Files ({matchedMusicFiles.length}) - - - {matchedMusicFiles.length === 0 ? ( - - No music files are matched yet. - - ) : ( - - {matchedMusicFiles.slice(0, 10).map((file) => ( - - - - - - {file.title || file.originalName} - - - - Matched - - - - {file.artist} - - - {file.album} - - - - - } - size="sm" - variant="ghost" - colorScheme="red" - onClick={() => handleUnlinkMusicFile(file.songId)} - _hover={{ bg: "red.900" }} - /> - - - } - size="sm" - variant="ghost" - colorScheme="blue" - _hover={{ bg: "blue.900" }} - /> - - - - - ))} - {matchedMusicFiles.length > 10 && ( - - Showing first 10 of {matchedMusicFiles.length} matched files - - )} - - )} - - - - {/* Songs with Music Files */} - - - Songs with Music Files ({songsWithMusicFiles.length}) - - - {songsWithMusicFiles.length === 0 ? ( - - No songs have music files linked yet. - - ) : ( - - {songsWithMusicFiles.slice(0, 10).map((song) => ( - - - - - - {song.title} - - - - Has S3 File - - - - {song.artist} - - {song.location && ( - - 📁 {song.location} - - )} - {song.s3File?.streamingUrl && ( - - 🎵 S3: {song.s3File.s3Key} - - )} - - - - } - size="sm" - variant="ghost" - colorScheme="red" - onClick={() => handleUnlinkMusicFile(song._id)} - _hover={{ bg: "red.900" }} - /> - - - } - size="sm" - variant="ghost" - colorScheme="blue" - _hover={{ bg: "blue.800" }} - /> - - - - - ))} - {songsWithMusicFiles.length > 10 && ( - - Showing first 10 of {songsWithMusicFiles.length} songs with music files - - )} - - )} - - + {/* Removed Matched Music Files and Songs with Music Files lists to streamline UI */} {/* Suggestions Modal */} diff --git a/packages/frontend/src/pages/Configuration.tsx b/packages/frontend/src/pages/Configuration.tsx index 717fb83..c878e6b 100644 --- a/packages/frontend/src/pages/Configuration.tsx +++ b/packages/frontend/src/pages/Configuration.tsx @@ -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([]); - const [isLoading, setIsLoading] = useState(false); - const [isSyncing, setIsSyncing] = useState(false); - const [hasS3Config, setHasS3Config] = useState(false); - const [musicLoaded, setMusicLoaded] = useState(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(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() { Upload Music - - - - Music Library - - + {/* Hide heavy Music Library tab from config to reduce load; may move to separate page later */} @@ -386,123 +244,40 @@ export function Configuration() { - {/* Music Library Tab */} - - - - Music Library - - - {musicFiles.length} file{musicFiles.length !== 1 ? 's' : ''} - - - - - - {tabIndex === TAB_INDEX.MUSIC_LIBRARY && isLoading ? ( - - Loading music files... - - ) : tabIndex === TAB_INDEX.MUSIC_LIBRARY && musicFiles.length === 0 ? ( - - No music files found in the database. - - Try uploading files in the Upload tab, or click "Sync S3" to find files already in your S3 bucket. - - - {!hasS3Config && ( - - Configure your S3 connection in the S3 Configuration tab first. - - )} - - ) : ( - - {musicFiles.map((file) => ( - - - - - - {file.title || file.originalName} - - - {file.format?.toUpperCase() || 'AUDIO'} - - {file.songId && ( - - Linked to Rekordbox - - )} - - {file.artist && ( - - {file.artist} - - )} - {file.album && ( - - {file.album} - - )} - - {formatDuration(file.duration || 0)} - {formatFileSize(file.size)} - {file.format?.toUpperCase()} - - - - } - size="sm" - variant="ghost" - colorScheme="red" - onClick={() => handleDeleteFile(file._id)} - _hover={{ bg: "red.900" }} - /> - - - - ))} - - )} - - + {/* Music Library tab removed from Config (heavy). Consider separate page if needed. */} {/* Song Matching Tab */} - + + Sync and Matching + + + + + + + {/* Duplicates Tab */} diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index cd94009..9967e5d 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -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 { const response = await fetch(`${API_BASE_URL}/background-jobs/progress/${jobId}`); if (!response.ok) throw new Error('Failed to get job progress');