Compare commits
No commits in common. "083eca58cf8b22a45374b4bee040b434d308f57e" and "f6ecd07d986c12d5831eb6898b27526ab5c3122e" have entirely different histories.
083eca58cf
...
f6ecd07d98
@ -209,7 +209,6 @@ router.get('/:id/stream', async (req, res) => {
|
||||
res.json({
|
||||
streamingUrl: presignedUrl,
|
||||
musicFile,
|
||||
contentType: musicFile.contentType || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Streaming error:', error);
|
||||
@ -416,42 +415,4 @@ 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 };
|
||||
@ -153,7 +153,8 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
|
||||
const totalPages = Math.ceil(totalSongs / limit);
|
||||
|
||||
// Calculate total duration for the entire playlist
|
||||
const totalDuration = (await Song.find({ id: { $in: trackIds } }, { totalTime: 1 }).lean()).reduce((total, song: any) => {
|
||||
const allPlaylistSongs = await Song.find({ id: { $in: trackIds } }).lean();
|
||||
const totalDuration = allPlaylistSongs.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));
|
||||
|
||||
@ -171,22 +171,6 @@ 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...',
|
||||
@ -238,16 +222,13 @@ 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 stripDiacritics = (s: string) => s.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
||||
const normalizedS3Filename = stripDiacritics(safeDecode(filename)).replace(/\.[^/.]+$/, '').toLowerCase();
|
||||
const normalizedS3Filename = filename.replace(/\.[^/.]+$/, '').toLowerCase();
|
||||
let matchedSong = null;
|
||||
|
||||
for (const song of allSongs) {
|
||||
if (song.location) {
|
||||
const rekordboxFilename = song.location.split(/[/\\]/).pop() || song.location;
|
||||
const normalizedRekordboxFilename = stripDiacritics(safeDecode(rekordboxFilename)).replace(/\.[^/.]+$/, '').toLowerCase();
|
||||
const normalizedRekordboxFilename = rekordboxFilename.replace(/\.[^/.]+$/, '').toLowerCase();
|
||||
|
||||
if (normalizedS3Filename === normalizedRekordboxFilename) {
|
||||
matchedSong = song;
|
||||
@ -262,7 +243,7 @@ class BackgroundJobService {
|
||||
originalName: filename,
|
||||
s3Key: s3File.key,
|
||||
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||
contentType: guessContentType(filename),
|
||||
contentType: 'audio/mpeg',
|
||||
size: s3File.size,
|
||||
...basicMetadata,
|
||||
songId: matchedSong._id,
|
||||
@ -339,7 +320,7 @@ class BackgroundJobService {
|
||||
originalName: filename,
|
||||
s3Key: s3File.key,
|
||||
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||
contentType: guessContentType(filename),
|
||||
contentType: 'audio/mpeg',
|
||||
size: s3File.size,
|
||||
...metadata,
|
||||
});
|
||||
@ -414,7 +395,7 @@ class BackgroundJobService {
|
||||
errors: 0
|
||||
},
|
||||
total: {
|
||||
processed: newAudioFiles.length,
|
||||
processed: allMusicFiles.length,
|
||||
matched: quickMatches.length + complexMatches,
|
||||
unmatched: stillUnmatched,
|
||||
errors: 0
|
||||
|
||||
@ -487,10 +487,8 @@ export class SongMatchingService {
|
||||
private matchLocation(filename: string, location: string): { score: number; reason: string } {
|
||||
if (!filename || !location) return { score: 0, reason: '' };
|
||||
|
||||
// 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));
|
||||
const cleanFilename = this.cleanString(filename);
|
||||
const cleanLocation = this.cleanString(location);
|
||||
|
||||
// Extract filename from location path (handle different path separators)
|
||||
const pathParts = cleanLocation.split(/[\/\\]/);
|
||||
@ -643,12 +641,7 @@ export class SongMatchingService {
|
||||
* Clean string for comparison
|
||||
*/
|
||||
private cleanString(str: string): string {
|
||||
// Normalize unicode and strip diacritics so "é" -> "e"
|
||||
const normalized = str
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
|
||||
return normalized
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
|
||||
275
packages/backend/src/services/twoPhaseSyncService.ts
Normal file
275
packages/backend/src/services/twoPhaseSyncService.ts
Normal file
@ -0,0 +1,275 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -12,9 +12,8 @@ html, body, #root {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ensure full-viewport centering for loading state */
|
||||
#root {
|
||||
max-width: none;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
|
||||
@ -405,7 +405,7 @@ const RekordboxReader: React.FC = () => {
|
||||
|
||||
if (xmlLoading) {
|
||||
return (
|
||||
<Flex height="100vh" width="100vw" align="center" justify="center" direction="column" gap={4}>
|
||||
<Flex height="100vh" align="center" justify="center" direction="column" gap={4}>
|
||||
<Spinner size="xl" />
|
||||
<Text>Loading your library...</Text>
|
||||
</Flex>
|
||||
|
||||
@ -92,31 +92,25 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling for jobs and update progress
|
||||
// Start polling for all active jobs
|
||||
const startPolling = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
await updateJobProgress(jobId);
|
||||
updateJobProgress(jobId);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore transient polling errors
|
||||
}
|
||||
}, 2000);
|
||||
}, 2000); // Poll every 2 seconds for less frequent updates
|
||||
};
|
||||
|
||||
// Stop polling
|
||||
@ -127,13 +121,23 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling on mount and stop on unmount
|
||||
// Load jobs on mount
|
||||
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 () => {
|
||||
|
||||
@ -6,14 +6,20 @@ import {
|
||||
Button,
|
||||
IconButton,
|
||||
HStack,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
MenuDivider,
|
||||
Checkbox,
|
||||
Tooltip,
|
||||
Spinner,
|
||||
useDisclosure,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
} from '@chakra-ui/react';
|
||||
import { Search2Icon } from '@chakra-ui/icons';
|
||||
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { FiPlay } from 'react-icons/fi';
|
||||
import type { Song, PlaylistNode } from '../types/interfaces';
|
||||
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
||||
@ -185,7 +191,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 => {
|
||||
@ -225,7 +231,10 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
||||
}, [onSongSelect]);
|
||||
|
||||
// Memoized search handler with debouncing
|
||||
// Search handled inline via localSearchQuery effect
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
setLocalSearchQuery(query);
|
||||
onSearch(query);
|
||||
}, [onSearch]);
|
||||
|
||||
// Memoized song items to prevent unnecessary re-renders
|
||||
const songItems = useMemo(() => {
|
||||
@ -261,7 +270,13 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
||||
}, [songs, totalPlaylistDuration]);
|
||||
|
||||
// Memoized playlist options for bulk actions
|
||||
// Playlist options built directly in the modal
|
||||
const playlistOptions = useMemo(() => {
|
||||
return allPlaylists.map(playlist => (
|
||||
<MenuItem key={playlist.id} onClick={() => handleBulkAddToPlaylist(playlist.name)}>
|
||||
{playlist.name}
|
||||
</MenuItem>
|
||||
));
|
||||
}, [allPlaylists, handleBulkAddToPlaylist]);
|
||||
|
||||
// Handle debounced search
|
||||
useEffect(() => {
|
||||
@ -330,7 +345,6 @@ 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>
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
FiX,
|
||||
} from 'react-icons/fi';
|
||||
import type { Song } from '../types/interfaces';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
|
||||
interface PersistentMusicPlayerProps {
|
||||
currentSong: Song | null;
|
||||
@ -82,8 +83,6 @@ 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);
|
||||
});
|
||||
@ -210,7 +209,6 @@ export const PersistentMusicPlayer: React.FC<PersistentMusicPlayerProps> = ({
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={handleEnded}
|
||||
tabIndex={-1}
|
||||
onError={(e) => {
|
||||
console.error('Audio error:', e);
|
||||
toast({
|
||||
@ -222,20 +220,7 @@ export const PersistentMusicPlayer: React.FC<PersistentMusicPlayerProps> = ({
|
||||
});
|
||||
}}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
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();
|
||||
}
|
||||
}}
|
||||
onCanPlay={() => setIsLoading(false)}
|
||||
/>
|
||||
|
||||
<HStack spacing={4} align="center">
|
||||
|
||||
@ -37,7 +37,6 @@ 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 },
|
||||
|
||||
@ -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.tsx";
|
||||
import { DuplicatesViewer } from "../components/DuplicatesViewer";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
|
||||
interface MusicFile {
|
||||
@ -136,23 +136,35 @@ export function Configuration() {
|
||||
const handleSyncS3 = async () => {
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const { jobId } = await api.startBackgroundJob('s3-sync');
|
||||
const response = await fetch('/api/music/sync-s3', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
toast({
|
||||
title: 'S3 Sync Started',
|
||||
description: `Job ${jobId} started. Progress will appear shortly.`,
|
||||
status: 'info',
|
||||
duration: 4000,
|
||||
title: 'S3 Sync Complete',
|
||||
description: `Found ${data.results.total} files, synced ${data.results.newFiles} new files`,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
// Defer reloading; background job widget will show progress and we fetch on demand
|
||||
if (tabIndex !== TAB_INDEX.MUSIC_LIBRARY) {
|
||||
|
||||
// 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');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting S3 sync:', error);
|
||||
console.error('Error syncing S3:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to start S3 sync',
|
||||
description: 'Failed to sync S3 files',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
|
||||
388
packages/frontend/src/pages/MusicStorage.tsx
Normal file
388
packages/frontend/src/pages/MusicStorage.tsx
Normal file
@ -0,0 +1,388 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
7
packages/frontend/src/types/Playlist.ts
Normal file
7
packages/frontend/src/types/Playlist.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface Playlist {
|
||||
_id: string;
|
||||
name: string;
|
||||
songs: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
12
packages/frontend/src/types/Song.ts
Normal file
12
packages/frontend/src/types/Song.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface Song {
|
||||
_id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
genre: string;
|
||||
bpm: number;
|
||||
key: string;
|
||||
rating: number;
|
||||
comments: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user