From 940469ba52095f2e7d0b719cff9d47a012ebba0d Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 09:18:06 +0200 Subject: [PATCH 01/12] chore(refactor): remove unused files (TwoPhaseSyncService, unused frontend types) --- .../src/services/twoPhaseSyncService.ts | 275 ------------------ packages/frontend/src/types/Playlist.ts | 7 - packages/frontend/src/types/Song.ts | 12 - 3 files changed, 294 deletions(-) delete mode 100644 packages/backend/src/services/twoPhaseSyncService.ts delete mode 100644 packages/frontend/src/types/Playlist.ts delete mode 100644 packages/frontend/src/types/Song.ts diff --git a/packages/backend/src/services/twoPhaseSyncService.ts b/packages/backend/src/services/twoPhaseSyncService.ts deleted file mode 100644 index f23458e..0000000 --- a/packages/backend/src/services/twoPhaseSyncService.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { S3Service } from './s3Service.js'; -import { AudioMetadataService } from './audioMetadataService.js'; -import { SongMatchingService } from './songMatchingService.js'; -import { MusicFile } from '../models/MusicFile.js'; -import { Song } from '../models/Song.js'; - -export interface SyncResult { - phase1: { - totalFiles: number; - quickMatches: number; - unmatchedFiles: number; - errors: number; - }; - phase2: { - processedFiles: number; - complexMatches: number; - stillUnmatched: number; - errors: number; - }; - total: { - processed: number; - matched: number; - unmatched: number; - errors: number; - }; -} - -export class TwoPhaseSyncService { - private s3Service: S3Service; - private audioMetadataService: AudioMetadataService; - private songMatchingService: SongMatchingService; - - constructor() { - this.audioMetadataService = new AudioMetadataService(); - this.songMatchingService = new SongMatchingService(); - } - - /** - * Initialize S3 service - */ - async initialize(): Promise { - this.s3Service = await S3Service.createFromConfig(); - } - - /** - * Extract filename from S3 key - */ - private getFilenameFromS3Key(s3Key: string): string { - return s3Key.split('/').pop() || s3Key; - } - - /** - * Extract filename from Rekordbox location path - */ - private getFilenameFromLocation(location: string): string { - // Handle both Windows and Unix paths - const normalizedPath = location.replace(/\\/g, '/'); - return normalizedPath.split('/').pop() || location; - } - - /** - * Normalize filename for comparison (remove extension, lowercase) - */ - private normalizeFilename(filename: string): string { - return filename.replace(/\.[^/.]+$/, '').toLowerCase(); - } - - /** - * Phase 1: Quick filename-based matching - */ - async phase1QuickMatch(): Promise<{ - quickMatches: MusicFile[]; - unmatchedFiles: any[]; - errors: any[]; - }> { - console.log('🚀 Starting Phase 1: Quick filename-based matching...'); - - // Get all S3 files - const s3Files = await this.s3Service.listAllFiles(); - const audioFiles = s3Files.filter(s3File => { - const filename = this.getFilenameFromS3Key(s3File.key); - return this.audioMetadataService.isAudioFile(filename); - }); - - console.log(`📁 Found ${audioFiles.length} audio files in S3`); - - // Get existing music files to avoid duplicates - 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)); - - console.log(`🆕 Found ${newAudioFiles.length} new audio files to process`); - - // Get all songs from database for filename matching - const allSongs = await Song.find({}, { id: 1, title: 1, artist: 1, location: 1 }); - console.log(`🎵 Found ${allSongs.length} songs in database for matching`); - - const quickMatches: MusicFile[] = []; - const unmatchedFiles: any[] = []; - const errors: any[] = []; - - for (const s3File of newAudioFiles) { - try { - const s3Filename = this.getFilenameFromS3Key(s3File.key); - const normalizedS3Filename = this.normalizeFilename(s3Filename); - - // Try to find exact filename match in Rekordbox songs - let matchedSong = null; - - for (const song of allSongs) { - if (song.location) { - const rekordboxFilename = this.getFilenameFromLocation(song.location); - const normalizedRekordboxFilename = this.normalizeFilename(rekordboxFilename); - - if (normalizedS3Filename === normalizedRekordboxFilename) { - matchedSong = song; - break; - } - } - } - - if (matchedSong) { - console.log(`✅ Quick match found: ${s3Filename} -> ${matchedSong.title}`); - - // Extract basic metadata from filename (no need to download file) - const basicMetadata = this.audioMetadataService.extractBasicMetadataFromFilename(s3Filename); - - const musicFile = new MusicFile({ - originalName: s3Filename, - s3Key: s3File.key, - s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`, - contentType: 'audio/mpeg', - size: s3File.size, - ...basicMetadata, - songId: matchedSong._id, // Link to the matched song - }); - - quickMatches.push(musicFile); - } else { - console.log(`❓ No quick match for: ${s3Filename}`); - unmatchedFiles.push(s3File); - } - - } catch (error) { - console.error(`❌ Error processing ${s3File.key}:`, error); - errors.push({ file: s3File, error: error.message }); - } - } - - console.log(`✅ Phase 1 completed: ${quickMatches.length} quick matches, ${unmatchedFiles.length} unmatched files`); - - return { quickMatches, unmatchedFiles, errors }; - } - - /** - * Phase 2: Complex matching for unmatched files - */ - async phase2ComplexMatch(unmatchedFiles: any[]): Promise<{ - processedFiles: MusicFile[]; - complexMatches: number; - stillUnmatched: number; - errors: any[]; - }> { - console.log('🔍 Starting Phase 2: Complex matching for unmatched files...'); - - const processedFiles: MusicFile[] = []; - let complexMatches = 0; - let stillUnmatched = 0; - const errors: any[] = []; - - for (const s3File of unmatchedFiles) { - try { - const filename = this.getFilenameFromS3Key(s3File.key); - console.log(`🔍 Processing unmatched file: ${filename}`); - - // Download file and extract metadata - const fileBuffer = await this.s3Service.getFileContent(s3File.key); - const metadata = await this.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: 'audio/mpeg', - size: s3File.size, - ...metadata, - }); - - processedFiles.push(musicFile); - - // Try complex matching - const matchResult = await this.songMatchingService.matchMusicFileToSongs(musicFile, { - minConfidence: 0.7, - enableFuzzyMatching: true, - enablePartialMatching: true, - maxResults: 1 - }); - - if (matchResult.length > 0 && matchResult[0].confidence >= 0.7) { - const bestMatch = matchResult[0]; - musicFile.songId = bestMatch.song._id; - complexMatches++; - console.log(`✅ Complex match found: ${filename} -> ${bestMatch.song.title} (${bestMatch.confidence})`); - } else { - stillUnmatched++; - console.log(`❓ No complex match for: ${filename}`); - } - - } catch (error) { - console.error(`❌ Error processing ${s3File.key}:`, error); - errors.push({ file: s3File, error: error.message }); - } - } - - console.log(`✅ Phase 2 completed: ${complexMatches} complex matches, ${stillUnmatched} still unmatched`); - - return { processedFiles, complexMatches, stillUnmatched, errors }; - } - - /** - * Run complete two-phase sync - */ - async runTwoPhaseSync(): Promise { - console.log('🎯 Starting Two-Phase S3 Sync...'); - const startTime = Date.now(); - - await this.initialize(); - - // Phase 1: Quick filename matching - const phase1Result = await this.phase1QuickMatch(); - - // Phase 2: Complex matching for unmatched files - const phase2Result = await this.phase2ComplexMatch(phase1Result.unmatchedFiles); - - // Combine all music files - const allMusicFiles = [...phase1Result.quickMatches, ...phase2Result.processedFiles]; - - // Batch save all music files - if (allMusicFiles.length > 0) { - console.log(`💾 Saving ${allMusicFiles.length} music files to database...`); - await MusicFile.insertMany(allMusicFiles); - } - - const endTime = Date.now(); - const duration = ((endTime - startTime) / 1000).toFixed(2); - - const result: SyncResult = { - phase1: { - totalFiles: phase1Result.quickMatches.length + phase1Result.unmatchedFiles.length, - quickMatches: phase1Result.quickMatches.length, - unmatchedFiles: phase1Result.unmatchedFiles.length, - errors: phase1Result.errors.length, - }, - phase2: { - processedFiles: phase2Result.processedFiles.length, - complexMatches: phase2Result.complexMatches, - stillUnmatched: phase2Result.stillUnmatched, - errors: phase2Result.errors.length, - }, - total: { - processed: allMusicFiles.length, - matched: phase1Result.quickMatches.length + phase2Result.complexMatches, - unmatched: phase2Result.stillUnmatched, - errors: phase1Result.errors.length + phase2Result.errors.length, - }, - }; - - console.log(`🎉 Two-Phase Sync completed in ${duration}s:`); - console.log(` Phase 1: ${result.phase1.quickMatches} quick matches, ${result.phase1.unmatchedFiles} unmatched`); - console.log(` Phase 2: ${result.phase2.complexMatches} complex matches, ${result.phase2.stillUnmatched} still unmatched`); - console.log(` Total: ${result.total.processed} processed, ${result.total.matched} matched, ${result.total.unmatched} unmatched`); - - return result; - } -} \ No newline at end of file diff --git a/packages/frontend/src/types/Playlist.ts b/packages/frontend/src/types/Playlist.ts deleted file mode 100644 index 2c64f00..0000000 --- a/packages/frontend/src/types/Playlist.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface Playlist { - _id: string; - name: string; - songs: string[]; - createdAt: string; - updatedAt: string; -} \ No newline at end of file diff --git a/packages/frontend/src/types/Song.ts b/packages/frontend/src/types/Song.ts deleted file mode 100644 index 2c49c51..0000000 --- a/packages/frontend/src/types/Song.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface Song { - _id: string; - title: string; - artist: string; - genre: string; - bpm: number; - key: string; - rating: number; - comments: string; - createdAt: string; - updatedAt: string; -} \ No newline at end of file From 31a420cf5c446249ba52e0ddcb2937e99f6e3e84 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 09:18:48 +0200 Subject: [PATCH 02/12] perf(backend): reduce projection for playlist total duration calculation; fewer fields fetched --- packages/backend/src/routes/songs.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/backend/src/routes/songs.ts b/packages/backend/src/routes/songs.ts index d4c0e96..d674ce4 100644 --- a/packages/backend/src/routes/songs.ts +++ b/packages/backend/src/routes/songs.ts @@ -153,8 +153,7 @@ router.get('/playlist/*', async (req: Request, res: Response) => { const totalPages = Math.ceil(totalSongs / limit); // Calculate total duration for the entire playlist - const allPlaylistSongs = await Song.find({ id: { $in: trackIds } }).lean(); - const totalDuration = allPlaylistSongs.reduce((total, song: any) => { + const totalDuration = (await Song.find({ id: { $in: trackIds } }, { totalTime: 1 }).lean()).reduce((total, song: any) => { if (!song.totalTime) return total; const totalTimeStr = String(song.totalTime); const seconds = Math.floor(Number(totalTimeStr) / (totalTimeStr.length > 4 ? 1000 : 1)); From 4f440267bd0d797e37d09dd3a2b9810daeb98b09 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 09:37:41 +0200 Subject: [PATCH 03/12] feat(frontend): start S3 sync via background job API and rely on progress widget instead of direct endpoint; remove unused MusicStorage page --- packages/frontend/src/pages/Configuration.tsx | 38 +- packages/frontend/src/pages/MusicStorage.tsx | 388 ------------------ 2 files changed, 13 insertions(+), 413 deletions(-) delete mode 100644 packages/frontend/src/pages/MusicStorage.tsx diff --git a/packages/frontend/src/pages/Configuration.tsx b/packages/frontend/src/pages/Configuration.tsx index 51dea05..717fb83 100644 --- a/packages/frontend/src/pages/Configuration.tsx +++ b/packages/frontend/src/pages/Configuration.tsx @@ -35,7 +35,7 @@ import { S3Configuration } from "./S3Configuration"; import { MusicUpload } from "../components/MusicUpload"; import { SongMatching } from "../components/SongMatching"; import { api } from "../services/api"; -import { DuplicatesViewer } from "../components/DuplicatesViewer"; +import { DuplicatesViewer } from "../components/DuplicatesViewer.tsx"; import { useState, useEffect, useMemo } from "react"; interface MusicFile { @@ -136,35 +136,23 @@ export function Configuration() { const handleSyncS3 = async () => { setIsSyncing(true); try { - const response = await fetch('/api/music/sync-s3', { - method: 'POST', + 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, }); - - if (response.ok) { - const data = await response.json(); - toast({ - title: 'S3 Sync Complete', - description: `Found ${data.results.total} files, synced ${data.results.newFiles} new files`, - status: 'success', - duration: 5000, - isClosable: true, - }); - - // Reload music files to show the new ones if user is on Music Library tab - if (tabIndex === TAB_INDEX.MUSIC_LIBRARY) { - await loadMusicFiles(); - } else { - // Mark as not loaded so that when user opens the tab, it fetches fresh - setMusicLoaded(false); - } - } else { - throw new Error('Failed to sync S3'); + // 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 syncing S3:', error); + console.error('Error starting S3 sync:', error); toast({ title: 'Error', - description: 'Failed to sync S3 files', + description: 'Failed to start S3 sync', status: 'error', duration: 3000, isClosable: true, diff --git a/packages/frontend/src/pages/MusicStorage.tsx b/packages/frontend/src/pages/MusicStorage.tsx deleted file mode 100644 index 32c22b3..0000000 --- a/packages/frontend/src/pages/MusicStorage.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Box, - VStack, - HStack, - Text, - Heading, - Tabs, - TabList, - TabPanels, - Tab, - TabPanel, - SimpleGrid, - Card, - CardBody, - CardHeader, - Badge, - IconButton, - useToast, - Alert, - AlertIcon, - Button, - Spinner, -} from '@chakra-ui/react'; -import { FiPlay, FiTrash2, FiMusic, FiRefreshCw } from 'react-icons/fi'; -import { MusicUpload } from '../components/MusicUpload'; -import { SongMatching } from '../components/SongMatching'; -import { useMusicPlayer } from '../contexts/MusicPlayerContext'; -import type { Song } from '../types/interfaces'; - -interface MusicFile { - _id: string; - originalName: string; - title?: string; - artist?: string; - album?: string; - duration?: number; - size: number; - format?: string; - uploadedAt: string; - songId?: any; // Reference to linked song -} - -export const MusicStorage: React.FC = () => { - const [musicFiles, setMusicFiles] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isSyncing, setIsSyncing] = useState(false); - const { playSong } = useMusicPlayer(); - const toast = useToast(); - - // Load music files on component mount - useEffect(() => { - loadMusicFiles(); - }, []); - - const loadMusicFiles = async () => { - setIsLoading(true); - try { - const response = await fetch('/api/music/files'); - if (response.ok) { - const data = await response.json(); - setMusicFiles(data.musicFiles || []); - } else { - throw new Error('Failed to load music files'); - } - } catch (error) { - console.error('Error loading music files:', error); - toast({ - title: 'Error', - description: 'Failed to load music files', - status: 'error', - duration: 3000, - isClosable: true, - }); - } finally { - setIsLoading(false); - } - }; - - const handleSyncS3 = async () => { - setIsSyncing(true); - try { - const response = await fetch('/api/music/sync-s3', { - method: 'POST', - }); - - if (response.ok) { - const data = await response.json(); - toast({ - title: 'S3 Sync Complete', - description: `Found ${data.results.total} files, synced ${data.results.newFiles} new files`, - status: 'success', - duration: 5000, - isClosable: true, - }); - - // Reload music files to show the new ones - await loadMusicFiles(); - } else { - throw new Error('Failed to sync S3'); - } - } catch (error) { - console.error('Error syncing S3:', error); - toast({ - title: 'Error', - description: 'Failed to sync S3 files', - status: 'error', - duration: 3000, - isClosable: true, - }); - } finally { - setIsSyncing(false); - } - }; - - const handleUploadComplete = (files: MusicFile[]) => { - setMusicFiles(prev => [...files, ...prev]); - toast({ - title: 'Upload Complete', - description: `Successfully uploaded ${files.length} file(s)`, - status: 'success', - duration: 3000, - isClosable: true, - }); - }; - - const handleDeleteFile = async (fileId: string) => { - try { - const response = await fetch(`/api/music/${fileId}`, { - method: 'DELETE', - }); - - if (response.ok) { - setMusicFiles(prev => prev.filter(file => file._id !== fileId)); - // The persistent player will handle removing the song if it was playing this file - toast({ - title: 'File Deleted', - description: 'Music file deleted successfully', - status: 'success', - duration: 3000, - isClosable: true, - }); - } else { - throw new Error('Failed to delete file'); - } - } catch (error) { - console.error('Error deleting file:', error); - toast({ - title: 'Error', - description: 'Failed to delete music file', - status: 'error', - duration: 3000, - isClosable: true, - }); - } - }; - - // Handle playing a music file from the Music Storage page - const handlePlayMusicFile = async (musicFile: MusicFile) => { - try { - // Create a Song object from the music file for the persistent player - const song: Song = { - id: musicFile._id, - title: musicFile.title || musicFile.originalName, - artist: musicFile.artist || 'Unknown Artist', - album: musicFile.album || '', - totalTime: musicFile.duration?.toString() || '0', - location: '', - s3File: { - musicFileId: musicFile._id, - s3Key: '', // This will be fetched by the persistent player - s3Url: '', - streamingUrl: '', - hasS3File: true, - }, - }; - - playSong(song); - } catch (error) { - console.error('Error playing music file:', error); - toast({ - title: 'Error', - description: 'Failed to play music file', - status: 'error', - duration: 3000, - isClosable: true, - }); - } - }; - - const formatFileSize = (bytes: number): string => { - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - if (bytes === 0) return '0 Bytes'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; - }; - - const formatDuration = (seconds: number): string => { - if (!seconds || isNaN(seconds)) return '00:00'; - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - }; - - return ( - - - - 🎵 Music Storage & Playback - - - - - - S3 Storage Feature - - Upload your music files to S3-compatible storage (MinIO) and stream them directly in the browser. - Supports MP3, WAV, FLAC, AAC, OGG, WMA, and Opus formats. - - - - - - - - Upload Music - - - Music Library - - - Song Matching - - - - - {/* Upload Tab */} - - - - - Upload Music Files - - - Drag and drop your music files here or click to select. Files will be uploaded to S3 storage - and metadata will be automatically extracted. - - - - - - - {/* Library Tab */} - - - - Music Library - - - {musicFiles.length} file{musicFiles.length !== 1 ? 's' : ''} - - - - - - {isLoading ? ( - - Loading music files... - - ) : 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. - - - - ) : ( - - {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" - colorScheme="blue" - onClick={() => handlePlayMusicFile(file)} - _hover={{ bg: "blue.700" }} - /> - } - size="sm" - variant="ghost" - colorScheme="red" - onClick={() => handleDeleteFile(file._id)} - _hover={{ bg: "red.900" }} - /> - - - - ))} - - )} - - - - {/* Song Matching Tab */} - - - - - - - {/* Persistent Music Player */} - {/* The PersistentMusicPlayer component is now managed by the global context */} - - ); -}; \ No newline at end of file From 7618c40a777d723f783c8feda05ce43f959d475e Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 09:49:02 +0200 Subject: [PATCH 04/12] fix(frontend): center loading spinner on large screens by using full-viewport container and removing #root max-width constraint --- packages/frontend/src/App.css | 3 ++- packages/frontend/src/App.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/App.css b/packages/frontend/src/App.css index 2abd334..be89341 100644 --- a/packages/frontend/src/App.css +++ b/packages/frontend/src/App.css @@ -12,8 +12,9 @@ html, body, #root { overflow: hidden; } +/* Ensure full-viewport centering for loading state */ #root { - max-width: 1280px; + max-width: none; } .logo { diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 4d5c0e0..4f159c9 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -405,7 +405,7 @@ const RekordboxReader: React.FC = () => { if (xmlLoading) { return ( - + Loading your library... From 9c8bf1198650941b2098d135c6ff1784ff3ec350 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 10:20:30 +0200 Subject: [PATCH 05/12] fix(background-jobs): poll job list continuously so newly started jobs appear immediately in the floating widget --- .../src/components/BackgroundJobProgress.tsx | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/frontend/src/components/BackgroundJobProgress.tsx b/packages/frontend/src/components/BackgroundJobProgress.tsx index d7b9787..0d08576 100644 --- a/packages/frontend/src/components/BackgroundJobProgress.tsx +++ b/packages/frontend/src/components/BackgroundJobProgress.tsx @@ -92,25 +92,31 @@ export const BackgroundJobProgress: React.FC = ({ } }; - // Start polling for all active jobs + // Start polling for jobs and update progress const startPolling = () => { if (intervalRef.current) { clearInterval(intervalRef.current); } - intervalRef.current = setInterval(() => { - // Update all active jobs - const activeJobs = jobs.filter(job => job.status === 'running'); - const activeJobIds = activeJobs.map(job => job.jobId); - activeJobIds.forEach(jobId => { - updateJobProgress(jobId); - }); - - // Also update specific jobId if provided - if (jobId) { - updateJobProgress(jobId); + intervalRef.current = setInterval(async () => { + try { + // Always reload job list to detect newly started jobs + const jobsData = await api.getAllJobs(); + setJobs(jobsData); + + // Update progress for active jobs + const activeJobIds = jobsData.filter((j: JobProgress) => j.status === 'running').map((j: JobProgress) => j.jobId); + for (const id of activeJobIds) { + await updateJobProgress(id); + } + + if (jobId) { + await updateJobProgress(jobId); + } + } catch (err) { + // ignore transient polling errors } - }, 2000); // Poll every 2 seconds for less frequent updates + }, 2000); }; // Stop polling @@ -121,23 +127,13 @@ export const BackgroundJobProgress: React.FC = ({ } }; - // Load jobs on mount + // Start polling on mount and stop on unmount useEffect(() => { loadJobs(); + startPolling(); + return () => stopPolling(); }, []); - // Start polling for active jobs - useEffect(() => { - const activeJobs = jobs.filter(job => job.status === 'running'); - if (activeJobs.length > 0 || jobId) { - startPolling(); - } - - return () => { - stopPolling(); - }; - }, [jobs, jobId]); - // Cleanup on unmount useEffect(() => { return () => { From fe282bf34ffa8ba95ec4a6e9bf02c15b1ff0f389 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 10:52:38 +0200 Subject: [PATCH 06/12] feat(frontend): show Rekordbox path in SongDetails for selected song --- packages/frontend/src/components/SongDetails.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend/src/components/SongDetails.tsx b/packages/frontend/src/components/SongDetails.tsx index d507d5a..14aa670 100644 --- a/packages/frontend/src/components/SongDetails.tsx +++ b/packages/frontend/src/components/SongDetails.tsx @@ -37,6 +37,7 @@ export const SongDetails: React.FC = memo(({ song }) => { { label: "Title", value: song.title }, { label: "Artist", value: song.artist }, { label: "Duration", value: formatDuration(song.totalTime || '') }, + { label: "Rekordbox Path", value: song.location }, { label: "Album", value: song.album }, { label: "Genre", value: song.genre }, { label: "BPM", value: song.averageBpm }, From 07044c7594a1b023defc0adce0eafc608a172d92 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 11:00:08 +0200 Subject: [PATCH 07/12] fix(matching): URL-decode filenames and Rekordbox locations during quick match and location matching (%20, %27 etc.) --- packages/backend/src/services/backgroundJobService.ts | 6 ++++-- packages/backend/src/services/songMatchingService.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/services/backgroundJobService.ts b/packages/backend/src/services/backgroundJobService.ts index 35023f2..4bc8ccd 100644 --- a/packages/backend/src/services/backgroundJobService.ts +++ b/packages/backend/src/services/backgroundJobService.ts @@ -222,13 +222,15 @@ class BackgroundJobService { }); // Quick filename matching logic - const normalizedS3Filename = filename.replace(/\.[^/.]+$/, '').toLowerCase(); + // Decode URL-encoded sequences so %20, %27 etc. are compared correctly + const safeDecode = (s: string): string => { try { return decodeURIComponent(s); } catch { return s; } }; + const normalizedS3Filename = safeDecode(filename).replace(/\.[^/.]+$/, '').toLowerCase(); let matchedSong = null; for (const song of allSongs) { if (song.location) { const rekordboxFilename = song.location.split(/[/\\]/).pop() || song.location; - const normalizedRekordboxFilename = rekordboxFilename.replace(/\.[^/.]+$/, '').toLowerCase(); + const normalizedRekordboxFilename = safeDecode(rekordboxFilename).replace(/\.[^/.]+$/, '').toLowerCase(); if (normalizedS3Filename === normalizedRekordboxFilename) { matchedSong = song; diff --git a/packages/backend/src/services/songMatchingService.ts b/packages/backend/src/services/songMatchingService.ts index 5e581d3..d072a16 100644 --- a/packages/backend/src/services/songMatchingService.ts +++ b/packages/backend/src/services/songMatchingService.ts @@ -487,8 +487,10 @@ export class SongMatchingService { private matchLocation(filename: string, location: string): { score: number; reason: string } { if (!filename || !location) return { score: 0, reason: '' }; - const cleanFilename = this.cleanString(filename); - const cleanLocation = this.cleanString(location); + // Decode URL-encoded sequences so Rekordbox paths with %20 etc. match S3 keys correctly + const safeDecode = (s: string): string => { try { return decodeURIComponent(s); } catch { return s; } }; + const cleanFilename = this.cleanString(safeDecode(filename)); + const cleanLocation = this.cleanString(safeDecode(location)); // Extract filename from location path (handle different path separators) const pathParts = cleanLocation.split(/[\/\\]/); From 2e21c3b5f519017b4600a44fdd5e884dd4a9003a Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 11:06:48 +0200 Subject: [PATCH 08/12] =?UTF-8?q?fix(matching):=20strip=20diacritics=20in?= =?UTF-8?q?=20matching=20and=20quick=20match=20so=20accented=20letters=20(?= =?UTF-8?q?e.g.,=20=C3=A9)=20match=20plain=20equivalents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/services/backgroundJobService.ts | 5 +++-- packages/backend/src/services/songMatchingService.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/services/backgroundJobService.ts b/packages/backend/src/services/backgroundJobService.ts index 4bc8ccd..2e1f2d3 100644 --- a/packages/backend/src/services/backgroundJobService.ts +++ b/packages/backend/src/services/backgroundJobService.ts @@ -224,13 +224,14 @@ class BackgroundJobService { // Quick filename matching logic // Decode URL-encoded sequences so %20, %27 etc. are compared correctly const safeDecode = (s: string): string => { try { return decodeURIComponent(s); } catch { return s; } }; - const normalizedS3Filename = safeDecode(filename).replace(/\.[^/.]+$/, '').toLowerCase(); + const stripDiacritics = (s: string) => s.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); + const normalizedS3Filename = stripDiacritics(safeDecode(filename)).replace(/\.[^/.]+$/, '').toLowerCase(); let matchedSong = null; for (const song of allSongs) { if (song.location) { const rekordboxFilename = song.location.split(/[/\\]/).pop() || song.location; - const normalizedRekordboxFilename = safeDecode(rekordboxFilename).replace(/\.[^/.]+$/, '').toLowerCase(); + const normalizedRekordboxFilename = stripDiacritics(safeDecode(rekordboxFilename)).replace(/\.[^/.]+$/, '').toLowerCase(); if (normalizedS3Filename === normalizedRekordboxFilename) { matchedSong = song; diff --git a/packages/backend/src/services/songMatchingService.ts b/packages/backend/src/services/songMatchingService.ts index d072a16..8519df0 100644 --- a/packages/backend/src/services/songMatchingService.ts +++ b/packages/backend/src/services/songMatchingService.ts @@ -643,7 +643,12 @@ export class SongMatchingService { * Clean string for comparison */ private cleanString(str: string): string { - return str + // Normalize unicode and strip diacritics so "é" -> "e" + const normalized = str + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, ''); + + return normalized .toLowerCase() .replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens .replace(/\s+/g, ' ') // Normalize whitespace From 70485e88087cf8d1893e9caa39d97ca1baef138b Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 11:15:47 +0200 Subject: [PATCH 09/12] fix(player): prevent audio element from stealing focus while playing (blur and tabIndex=-1) so search input remains usable --- .../src/components/PersistentMusicPlayer.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/PersistentMusicPlayer.tsx b/packages/frontend/src/components/PersistentMusicPlayer.tsx index d21fe2f..c8da66a 100644 --- a/packages/frontend/src/components/PersistentMusicPlayer.tsx +++ b/packages/frontend/src/components/PersistentMusicPlayer.tsx @@ -22,7 +22,6 @@ import { FiX, } from 'react-icons/fi'; import type { Song } from '../types/interfaces'; -import { formatDuration } from '../utils/formatters'; interface PersistentMusicPlayerProps { currentSong: Song | null; @@ -83,6 +82,8 @@ export const PersistentMusicPlayer: React.FC = ({ if (audioRef.current) { audioRef.current.play().then(() => { setIsPlaying(true); + // Prevent the audio element from stealing focus + audioRef.current?.blur(); }).catch(error => { console.error('Error auto-playing:', error); }); @@ -209,6 +210,7 @@ export const PersistentMusicPlayer: React.FC = ({ onTimeUpdate={handleTimeUpdate} onLoadedMetadata={handleLoadedMetadata} onEnded={handleEnded} + tabIndex={-1} onError={(e) => { console.error('Audio error:', e); toast({ @@ -220,7 +222,15 @@ export const PersistentMusicPlayer: React.FC = ({ }); }} onLoadStart={() => setIsLoading(true)} - onCanPlay={() => setIsLoading(false)} + onCanPlay={() => { + setIsLoading(false); + // Ensure the audio element never grabs focus during playback events + audioRef.current?.blur(); + }} + onPlay={() => { + // Extra safeguard to avoid focus jump while playing + audioRef.current?.blur(); + }} /> From dbf9dbcb8c9fafce4d76c387e729401e4684d6bf Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 11:30:59 +0200 Subject: [PATCH 10/12] fix(sync): reference correct processed count in job result; fix(search): keep focus in search while playing by blurring audio and focusing search; cleanup lints --- .../src/services/backgroundJobService.ts | 2 +- .../src/components/PaginatedSongList.tsx | 24 ++++--------------- .../src/components/PersistentMusicPlayer.tsx | 7 +++++- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/services/backgroundJobService.ts b/packages/backend/src/services/backgroundJobService.ts index 2e1f2d3..75190f0 100644 --- a/packages/backend/src/services/backgroundJobService.ts +++ b/packages/backend/src/services/backgroundJobService.ts @@ -398,7 +398,7 @@ class BackgroundJobService { errors: 0 }, total: { - processed: allMusicFiles.length, + processed: newAudioFiles.length, matched: quickMatches.length + complexMatches, unmatched: stillUnmatched, errors: 0 diff --git a/packages/frontend/src/components/PaginatedSongList.tsx b/packages/frontend/src/components/PaginatedSongList.tsx index 826cdd6..6a7a3ac 100644 --- a/packages/frontend/src/components/PaginatedSongList.tsx +++ b/packages/frontend/src/components/PaginatedSongList.tsx @@ -6,20 +6,14 @@ import { Button, IconButton, HStack, - Menu, - MenuButton, - MenuList, - MenuItem, - MenuDivider, Checkbox, - Tooltip, Spinner, useDisclosure, Input, InputGroup, InputLeftElement, } from '@chakra-ui/react'; -import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons'; +import { Search2Icon } from '@chakra-ui/icons'; import { FiPlay } from 'react-icons/fi'; import type { Song, PlaylistNode } from '../types/interfaces'; import { formatDuration, formatTotalDuration } from '../utils/formatters'; @@ -191,7 +185,7 @@ export const PaginatedSongList: React.FC = memo(({ }, []); // Memoized flattened list of all playlists - const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]); + // const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]); const toggleSelection = useCallback((songId: string) => { setSelectedSongs(prev => { @@ -231,10 +225,7 @@ export const PaginatedSongList: React.FC = memo(({ }, [onSongSelect]); // Memoized search handler with debouncing - const handleSearch = useCallback((query: string) => { - setLocalSearchQuery(query); - onSearch(query); - }, [onSearch]); + // Search handled inline via localSearchQuery effect // Memoized song items to prevent unnecessary re-renders const songItems = useMemo(() => { @@ -270,13 +261,7 @@ export const PaginatedSongList: React.FC = memo(({ }, [songs, totalPlaylistDuration]); // Memoized playlist options for bulk actions - const playlistOptions = useMemo(() => { - return allPlaylists.map(playlist => ( - handleBulkAddToPlaylist(playlist.name)}> - {playlist.name} - - )); - }, [allPlaylists, handleBulkAddToPlaylist]); + // Playlist options built directly in the modal // Handle debounced search useEffect(() => { @@ -345,6 +330,7 @@ export const PaginatedSongList: React.FC = memo(({ borderColor="gray.600" _hover={{ borderColor: "gray.500" }} _focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }} + autoFocus /> diff --git a/packages/frontend/src/components/PersistentMusicPlayer.tsx b/packages/frontend/src/components/PersistentMusicPlayer.tsx index c8da66a..ae59712 100644 --- a/packages/frontend/src/components/PersistentMusicPlayer.tsx +++ b/packages/frontend/src/components/PersistentMusicPlayer.tsx @@ -228,8 +228,13 @@ export const PersistentMusicPlayer: React.FC = ({ audioRef.current?.blur(); }} onPlay={() => { - // Extra safeguard to avoid focus jump while playing audioRef.current?.blur(); + // Return focus to active text input if present + const active = document.activeElement as HTMLElement | null; + if (!active || active.tagName !== 'INPUT') { + const search = document.querySelector('#song-list-container input[type="text"]') as HTMLInputElement | null; + search?.focus(); + } }} /> From 5144df2e9366f441140d94a4a2a578c9867551e3 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 12:02:03 +0200 Subject: [PATCH 11/12] fix(flac): set correct contentType for FLAC and other types in S3 sync; include contentType hint in stream endpoint response --- packages/backend/src/routes/music.ts | 3 ++- .../src/services/backgroundJobService.ts | 22 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/routes/music.ts b/packages/backend/src/routes/music.ts index c6909b8..c7627c8 100644 --- a/packages/backend/src/routes/music.ts +++ b/packages/backend/src/routes/music.ts @@ -208,7 +208,8 @@ router.get('/:id/stream', async (req, res) => { res.json({ streamingUrl: presignedUrl, - musicFile, + musicFile, + contentType: musicFile.contentType || undefined, }); } catch (error) { console.error('Streaming error:', error); diff --git a/packages/backend/src/services/backgroundJobService.ts b/packages/backend/src/services/backgroundJobService.ts index 75190f0..94f6672 100644 --- a/packages/backend/src/services/backgroundJobService.ts +++ b/packages/backend/src/services/backgroundJobService.ts @@ -170,6 +170,22 @@ class BackgroundJobService { const s3Service = await S3Service.createFromConfig(); const audioMetadataService = new AudioMetadataService(); const songMatchingService = new SongMatchingService(); + + // Helper to set correct MIME type based on file extension + const guessContentType = (fileName: string): string => { + const ext = (fileName.split('.').pop() || '').toLowerCase(); + switch (ext) { + case 'mp3': return 'audio/mpeg'; + case 'wav': return 'audio/wav'; + case 'flac': return 'audio/flac'; + case 'm4a': return 'audio/mp4'; + case 'aac': return 'audio/aac'; + case 'ogg': return 'audio/ogg'; + case 'opus': return 'audio/opus'; + case 'wma': return 'audio/x-ms-wma'; + default: return 'application/octet-stream'; + } + }; // Phase 1: Quick filename matching this.updateProgress(jobId, { @@ -240,13 +256,13 @@ class BackgroundJobService { } } - if (matchedSong) { + 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: 'audio/mpeg', + contentType: guessContentType(filename), size: s3File.size, ...basicMetadata, songId: matchedSong._id, @@ -323,7 +339,7 @@ class BackgroundJobService { originalName: filename, s3Key: s3File.key, s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`, - contentType: 'audio/mpeg', + contentType: guessContentType(filename), size: s3File.size, ...metadata, }); From 3bd110884c29f145641ada85759b60d7570cdea5 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Fri, 8 Aug 2025 12:04:05 +0200 Subject: [PATCH 12/12] feat(admin): add /api/music/fix-content-types to correct MIME types; ensure sync sets proper contentType for FLAC and others --- packages/backend/src/routes/music.ts | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/backend/src/routes/music.ts b/packages/backend/src/routes/music.ts index c7627c8..118452d 100644 --- a/packages/backend/src/routes/music.ts +++ b/packages/backend/src/routes/music.ts @@ -416,4 +416,42 @@ router.post('/fix-orphaned', async (req, res) => { } }); +/** + * Fix incorrect or missing content types for existing MusicFile documents + */ +router.post('/fix-content-types', async (req, res) => { + try { + const guessContentType = (fileName: string): string => { + const ext = (fileName.split('.').pop() || '').toLowerCase(); + switch (ext) { + case 'mp3': return 'audio/mpeg'; + case 'wav': return 'audio/wav'; + case 'flac': return 'audio/flac'; + case 'm4a': return 'audio/mp4'; + case 'aac': return 'audio/aac'; + case 'ogg': return 'audio/ogg'; + case 'opus': return 'audio/opus'; + case 'wma': return 'audio/x-ms-wma'; + default: return 'application/octet-stream'; + } + }; + + const files = await MusicFile.find({}); + let updated = 0; + for (const mf of files) { + const expected = guessContentType(mf.originalName || mf.s3Key); + if (!mf.contentType || mf.contentType !== expected) { + mf.contentType = expected; + await mf.save(); + updated++; + } + } + + res.json({ message: 'Content types fixed', updated }); + } catch (error) { + console.error('Error fixing content types:', error); + res.status(500).json({ message: 'Error fixing content types', error }); + } +}); + export { router as musicRouter }; \ No newline at end of file