Compare commits

..

13 Commits

Author SHA1 Message Date
Geert Rademakes
083eca58cf Merge branch 'chore/refactor-cleanup' 2025-08-08 12:53:18 +02:00
Geert Rademakes
3bd110884c feat(admin): add /api/music/fix-content-types to correct MIME types; ensure sync sets proper contentType for FLAC and others 2025-08-08 12:04:05 +02:00
Geert Rademakes
5144df2e93 fix(flac): set correct contentType for FLAC and other types in S3 sync; include contentType hint in stream endpoint response 2025-08-08 12:02:03 +02:00
Geert Rademakes
dbf9dbcb8c 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 2025-08-08 11:30:59 +02:00
Geert Rademakes
70485e8808 fix(player): prevent audio element from stealing focus while playing (blur and tabIndex=-1) so search input remains usable 2025-08-08 11:15:47 +02:00
Geert Rademakes
2e21c3b5f5 fix(matching): strip diacritics in matching and quick match so accented letters (e.g., é) match plain equivalents 2025-08-08 11:06:48 +02:00
Geert Rademakes
07044c7594 fix(matching): URL-decode filenames and Rekordbox locations during quick match and location matching (%20, %27 etc.) 2025-08-08 11:00:08 +02:00
Geert Rademakes
fe282bf34f feat(frontend): show Rekordbox path in SongDetails for selected song 2025-08-08 10:52:38 +02:00
Geert Rademakes
9c8bf11986 fix(background-jobs): poll job list continuously so newly started jobs appear immediately in the floating widget 2025-08-08 10:20:30 +02:00
Geert Rademakes
7618c40a77 fix(frontend): center loading spinner on large screens by using full-viewport container and removing #root max-width constraint 2025-08-08 09:49:02 +02:00
Geert Rademakes
4f440267bd feat(frontend): start S3 sync via background job API and rely on progress widget instead of direct endpoint; remove unused MusicStorage page 2025-08-08 09:37:41 +02:00
Geert Rademakes
31a420cf5c perf(backend): reduce projection for playlist total duration calculation; fewer fields fetched 2025-08-08 09:18:48 +02:00
Geert Rademakes
940469ba52 chore(refactor): remove unused files (TwoPhaseSyncService, unused frontend types) 2025-08-08 09:18:06 +02:00
15 changed files with 137 additions and 768 deletions

View File

@ -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);
@ -415,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 };

View File

@ -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));

View File

@ -171,6 +171,22 @@ class BackgroundJobService {
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, {
message: 'Phase 1: Fetching files from S3...',
@ -222,13 +238,16 @@ 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 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 = rekordboxFilename.replace(/\.[^/.]+$/, '').toLowerCase();
const normalizedRekordboxFilename = stripDiacritics(safeDecode(rekordboxFilename)).replace(/\.[^/.]+$/, '').toLowerCase();
if (normalizedS3Filename === normalizedRekordboxFilename) {
matchedSong = song;
@ -237,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,
@ -320,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,
});
@ -395,7 +414,7 @@ class BackgroundJobService {
errors: 0
},
total: {
processed: allMusicFiles.length,
processed: newAudioFiles.length,
matched: quickMatches.length + complexMatches,
unmatched: stillUnmatched,
errors: 0

View File

@ -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(/[\/\\]/);
@ -641,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

View File

@ -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<void> {
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<SyncResult> {
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;
}
}

View File

@ -12,8 +12,9 @@ html, body, #root {
overflow: hidden;
}
/* Ensure full-viewport centering for loading state */
#root {
max-width: 1280px;
max-width: none;
}
.logo {

View File

@ -405,7 +405,7 @@ const RekordboxReader: React.FC = () => {
if (xmlLoading) {
return (
<Flex height="100vh" align="center" justify="center" direction="column" gap={4}>
<Flex height="100vh" width="100vw" align="center" justify="center" direction="column" gap={4}>
<Spinner size="xl" />
<Text>Loading your library...</Text>
</Flex>

View File

@ -92,25 +92,31 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
}
};
// 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);
});
intervalRef.current = setInterval(async () => {
try {
// Always reload job list to detect newly started jobs
const jobsData = await api.getAllJobs();
setJobs(jobsData);
// Also update specific jobId if provided
if (jobId) {
updateJobProgress(jobId);
// 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<BackgroundJobProgressProps> = ({
}
};
// 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 () => {

View File

@ -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<PaginatedSongListProps> = 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<PaginatedSongListProps> = 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<PaginatedSongListProps> = memo(({
}, [songs, totalPlaylistDuration]);
// Memoized playlist options for bulk actions
const playlistOptions = useMemo(() => {
return allPlaylists.map(playlist => (
<MenuItem key={playlist.id} onClick={() => handleBulkAddToPlaylist(playlist.name)}>
{playlist.name}
</MenuItem>
));
}, [allPlaylists, handleBulkAddToPlaylist]);
// Playlist options built directly in the modal
// Handle debounced search
useEffect(() => {
@ -345,6 +330,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
borderColor="gray.600"
_hover={{ borderColor: "gray.500" }}
_focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }}
autoFocus
/>
</InputGroup>

View File

@ -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<PersistentMusicPlayerProps> = ({
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<PersistentMusicPlayerProps> = ({
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
tabIndex={-1}
onError={(e) => {
console.error('Audio error:', e);
toast({
@ -220,7 +222,20 @@ export const PersistentMusicPlayer: React.FC<PersistentMusicPlayerProps> = ({
});
}}
onLoadStart={() => setIsLoading(true)}
onCanPlay={() => setIsLoading(false)}
onCanPlay={() => {
setIsLoading(false);
// Ensure the audio element never grabs focus during playback events
audioRef.current?.blur();
}}
onPlay={() => {
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();
}
}}
/>
<HStack spacing={4} align="center">

View File

@ -37,6 +37,7 @@ export const SongDetails: React.FC<SongDetailsProps> = 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 },

View File

@ -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,

View File

@ -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<MusicFile[]>([]);
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 (
<Box
p={6}
maxW="1200px"
mx="auto"
minH="100vh"
bg="gray.900"
color="gray.100"
overflowY="auto"
height="100vh"
>
<VStack spacing={6} align="stretch">
<Heading size="lg" textAlign="center" color="white">
🎵 Music Storage & Playback
</Heading>
<Alert status="info" bg="blue.900" borderColor="blue.700" color="blue.100">
<AlertIcon color="blue.300" />
<Box>
<Text fontWeight="bold" color="blue.100">S3 Storage Feature</Text>
<Text fontSize="sm" color="blue.200">
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.
</Text>
</Box>
</Alert>
<Tabs variant="enclosed" colorScheme="blue" height="calc(100vh - 200px)" display="flex" flexDirection="column">
<TabList bg="gray.800" borderColor="gray.700" flexShrink={0}>
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
Upload Music
</Tab>
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
Music Library
</Tab>
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
Song Matching
</Tab>
</TabList>
<TabPanels flex={1} overflow="hidden">
{/* Upload Tab */}
<TabPanel bg="gray.900" height="100%" overflowY="auto">
<VStack spacing={6} align="stretch">
<Box>
<Heading size="md" mb={4} color="white">
Upload Music Files
</Heading>
<Text color="gray.400" mb={4}>
Drag and drop your music files here or click to select. Files will be uploaded to S3 storage
and metadata will be automatically extracted.
</Text>
</Box>
<MusicUpload onUploadComplete={handleUploadComplete} />
</VStack>
</TabPanel>
{/* Library Tab */}
<TabPanel bg="gray.900" height="100%" overflowY="auto">
<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}
loadingText="Syncing..."
_hover={{ bg: "blue.900", borderColor: "blue.400" }}
>
Sync S3
</Button>
</HStack>
</HStack>
{isLoading ? (
<Text textAlign="center" color="gray.500">
Loading music files...
</Text>
) : 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}
loadingText="Syncing..."
colorScheme="blue"
_hover={{ bg: "blue.700" }}
>
Sync S3 Bucket
</Button>
</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="Play file"
icon={<FiPlay />}
size="sm"
colorScheme="blue"
onClick={() => handlePlayMusicFile(file)}
_hover={{ bg: "blue.700" }}
/>
<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 */}
<TabPanel bg="gray.900" height="100%" overflowY="auto">
<SongMatching />
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
{/* Persistent Music Player */}
{/* The PersistentMusicPlayer component is now managed by the global context */}
</Box>
);
};

View File

@ -1,7 +0,0 @@
export interface Playlist {
_id: string;
name: string;
songs: string[];
createdAt: string;
updatedAt: string;
}

View File

@ -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;
}