Merge branch 'chore/refactor-cleanup'
This commit is contained in:
commit
083eca58cf
@ -208,7 +208,8 @@ router.get('/:id/stream', async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
streamingUrl: presignedUrl,
|
streamingUrl: presignedUrl,
|
||||||
musicFile,
|
musicFile,
|
||||||
|
contentType: musicFile.contentType || undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Streaming error:', 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 };
|
export { router as musicRouter };
|
||||||
@ -153,8 +153,7 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
|
|||||||
const totalPages = Math.ceil(totalSongs / limit);
|
const totalPages = Math.ceil(totalSongs / limit);
|
||||||
|
|
||||||
// Calculate total duration for the entire playlist
|
// Calculate total duration for the entire playlist
|
||||||
const allPlaylistSongs = await Song.find({ id: { $in: trackIds } }).lean();
|
const totalDuration = (await Song.find({ id: { $in: trackIds } }, { totalTime: 1 }).lean()).reduce((total, song: any) => {
|
||||||
const totalDuration = allPlaylistSongs.reduce((total, song: any) => {
|
|
||||||
if (!song.totalTime) return total;
|
if (!song.totalTime) return total;
|
||||||
const totalTimeStr = String(song.totalTime);
|
const totalTimeStr = String(song.totalTime);
|
||||||
const seconds = Math.floor(Number(totalTimeStr) / (totalTimeStr.length > 4 ? 1000 : 1));
|
const seconds = Math.floor(Number(totalTimeStr) / (totalTimeStr.length > 4 ? 1000 : 1));
|
||||||
|
|||||||
@ -170,6 +170,22 @@ class BackgroundJobService {
|
|||||||
const s3Service = await S3Service.createFromConfig();
|
const s3Service = await S3Service.createFromConfig();
|
||||||
const audioMetadataService = new AudioMetadataService();
|
const audioMetadataService = new AudioMetadataService();
|
||||||
const songMatchingService = new SongMatchingService();
|
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
|
// Phase 1: Quick filename matching
|
||||||
this.updateProgress(jobId, {
|
this.updateProgress(jobId, {
|
||||||
@ -222,13 +238,16 @@ class BackgroundJobService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Quick filename matching logic
|
// 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;
|
let matchedSong = null;
|
||||||
|
|
||||||
for (const song of allSongs) {
|
for (const song of allSongs) {
|
||||||
if (song.location) {
|
if (song.location) {
|
||||||
const rekordboxFilename = song.location.split(/[/\\]/).pop() || 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) {
|
if (normalizedS3Filename === normalizedRekordboxFilename) {
|
||||||
matchedSong = song;
|
matchedSong = song;
|
||||||
@ -237,13 +256,13 @@ class BackgroundJobService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedSong) {
|
if (matchedSong) {
|
||||||
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
|
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
|
||||||
const musicFile = new MusicFile({
|
const musicFile = new MusicFile({
|
||||||
originalName: filename,
|
originalName: filename,
|
||||||
s3Key: s3File.key,
|
s3Key: s3File.key,
|
||||||
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||||
contentType: 'audio/mpeg',
|
contentType: guessContentType(filename),
|
||||||
size: s3File.size,
|
size: s3File.size,
|
||||||
...basicMetadata,
|
...basicMetadata,
|
||||||
songId: matchedSong._id,
|
songId: matchedSong._id,
|
||||||
@ -320,7 +339,7 @@ class BackgroundJobService {
|
|||||||
originalName: filename,
|
originalName: filename,
|
||||||
s3Key: s3File.key,
|
s3Key: s3File.key,
|
||||||
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||||
contentType: 'audio/mpeg',
|
contentType: guessContentType(filename),
|
||||||
size: s3File.size,
|
size: s3File.size,
|
||||||
...metadata,
|
...metadata,
|
||||||
});
|
});
|
||||||
@ -395,7 +414,7 @@ class BackgroundJobService {
|
|||||||
errors: 0
|
errors: 0
|
||||||
},
|
},
|
||||||
total: {
|
total: {
|
||||||
processed: allMusicFiles.length,
|
processed: newAudioFiles.length,
|
||||||
matched: quickMatches.length + complexMatches,
|
matched: quickMatches.length + complexMatches,
|
||||||
unmatched: stillUnmatched,
|
unmatched: stillUnmatched,
|
||||||
errors: 0
|
errors: 0
|
||||||
|
|||||||
@ -487,8 +487,10 @@ export class SongMatchingService {
|
|||||||
private matchLocation(filename: string, location: string): { score: number; reason: string } {
|
private matchLocation(filename: string, location: string): { score: number; reason: string } {
|
||||||
if (!filename || !location) return { score: 0, reason: '' };
|
if (!filename || !location) return { score: 0, reason: '' };
|
||||||
|
|
||||||
const cleanFilename = this.cleanString(filename);
|
// Decode URL-encoded sequences so Rekordbox paths with %20 etc. match S3 keys correctly
|
||||||
const cleanLocation = this.cleanString(location);
|
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)
|
// Extract filename from location path (handle different path separators)
|
||||||
const pathParts = cleanLocation.split(/[\/\\]/);
|
const pathParts = cleanLocation.split(/[\/\\]/);
|
||||||
@ -641,7 +643,12 @@ export class SongMatchingService {
|
|||||||
* Clean string for comparison
|
* Clean string for comparison
|
||||||
*/
|
*/
|
||||||
private cleanString(str: string): string {
|
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()
|
.toLowerCase()
|
||||||
.replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
|
.replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
|
||||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,8 +12,9 @@ html, body, #root {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure full-viewport centering for loading state */
|
||||||
#root {
|
#root {
|
||||||
max-width: 1280px;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
|
|||||||
@ -405,7 +405,7 @@ const RekordboxReader: React.FC = () => {
|
|||||||
|
|
||||||
if (xmlLoading) {
|
if (xmlLoading) {
|
||||||
return (
|
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" />
|
<Spinner size="xl" />
|
||||||
<Text>Loading your library...</Text>
|
<Text>Loading your library...</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@ -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 = () => {
|
const startPolling = () => {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(async () => {
|
||||||
// Update all active jobs
|
try {
|
||||||
const activeJobs = jobs.filter(job => job.status === 'running');
|
// Always reload job list to detect newly started jobs
|
||||||
const activeJobIds = activeJobs.map(job => job.jobId);
|
const jobsData = await api.getAllJobs();
|
||||||
activeJobIds.forEach(jobId => {
|
setJobs(jobsData);
|
||||||
updateJobProgress(jobId);
|
|
||||||
});
|
// Update progress for active jobs
|
||||||
|
const activeJobIds = jobsData.filter((j: JobProgress) => j.status === 'running').map((j: JobProgress) => j.jobId);
|
||||||
// Also update specific jobId if provided
|
for (const id of activeJobIds) {
|
||||||
if (jobId) {
|
await updateJobProgress(id);
|
||||||
updateJobProgress(jobId);
|
}
|
||||||
|
|
||||||
|
if (jobId) {
|
||||||
|
await updateJobProgress(jobId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore transient polling errors
|
||||||
}
|
}
|
||||||
}, 2000); // Poll every 2 seconds for less frequent updates
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stop polling
|
// 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(() => {
|
useEffect(() => {
|
||||||
loadJobs();
|
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
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@ -6,20 +6,14 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
HStack,
|
HStack,
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuList,
|
|
||||||
MenuItem,
|
|
||||||
MenuDivider,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Tooltip,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
Input,
|
Input,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputLeftElement,
|
InputLeftElement,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
|
import { Search2Icon } from '@chakra-ui/icons';
|
||||||
import { FiPlay } from 'react-icons/fi';
|
import { FiPlay } from 'react-icons/fi';
|
||||||
import type { Song, PlaylistNode } from '../types/interfaces';
|
import type { Song, PlaylistNode } from '../types/interfaces';
|
||||||
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
||||||
@ -191,7 +185,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Memoized flattened list of all playlists
|
// 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) => {
|
const toggleSelection = useCallback((songId: string) => {
|
||||||
setSelectedSongs(prev => {
|
setSelectedSongs(prev => {
|
||||||
@ -231,10 +225,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
}, [onSongSelect]);
|
}, [onSongSelect]);
|
||||||
|
|
||||||
// Memoized search handler with debouncing
|
// Memoized search handler with debouncing
|
||||||
const handleSearch = useCallback((query: string) => {
|
// Search handled inline via localSearchQuery effect
|
||||||
setLocalSearchQuery(query);
|
|
||||||
onSearch(query);
|
|
||||||
}, [onSearch]);
|
|
||||||
|
|
||||||
// Memoized song items to prevent unnecessary re-renders
|
// Memoized song items to prevent unnecessary re-renders
|
||||||
const songItems = useMemo(() => {
|
const songItems = useMemo(() => {
|
||||||
@ -270,13 +261,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
}, [songs, totalPlaylistDuration]);
|
}, [songs, totalPlaylistDuration]);
|
||||||
|
|
||||||
// Memoized playlist options for bulk actions
|
// Memoized playlist options for bulk actions
|
||||||
const playlistOptions = useMemo(() => {
|
// Playlist options built directly in the modal
|
||||||
return allPlaylists.map(playlist => (
|
|
||||||
<MenuItem key={playlist.id} onClick={() => handleBulkAddToPlaylist(playlist.name)}>
|
|
||||||
{playlist.name}
|
|
||||||
</MenuItem>
|
|
||||||
));
|
|
||||||
}, [allPlaylists, handleBulkAddToPlaylist]);
|
|
||||||
|
|
||||||
// Handle debounced search
|
// Handle debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -345,6 +330,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
borderColor="gray.600"
|
borderColor="gray.600"
|
||||||
_hover={{ borderColor: "gray.500" }}
|
_hover={{ borderColor: "gray.500" }}
|
||||||
_focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }}
|
_focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import {
|
|||||||
FiX,
|
FiX,
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
import type { Song } from '../types/interfaces';
|
import type { Song } from '../types/interfaces';
|
||||||
import { formatDuration } from '../utils/formatters';
|
|
||||||
|
|
||||||
interface PersistentMusicPlayerProps {
|
interface PersistentMusicPlayerProps {
|
||||||
currentSong: Song | null;
|
currentSong: Song | null;
|
||||||
@ -83,6 +82,8 @@ export const PersistentMusicPlayer: React.FC<PersistentMusicPlayerProps> = ({
|
|||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.play().then(() => {
|
audioRef.current.play().then(() => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
// Prevent the audio element from stealing focus
|
||||||
|
audioRef.current?.blur();
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error('Error auto-playing:', error);
|
console.error('Error auto-playing:', error);
|
||||||
});
|
});
|
||||||
@ -209,6 +210,7 @@ export const PersistentMusicPlayer: React.FC<PersistentMusicPlayerProps> = ({
|
|||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
onEnded={handleEnded}
|
onEnded={handleEnded}
|
||||||
|
tabIndex={-1}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.error('Audio error:', e);
|
console.error('Audio error:', e);
|
||||||
toast({
|
toast({
|
||||||
@ -220,7 +222,20 @@ export const PersistentMusicPlayer: React.FC<PersistentMusicPlayerProps> = ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onLoadStart={() => setIsLoading(true)}
|
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">
|
<HStack spacing={4} align="center">
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export const SongDetails: React.FC<SongDetailsProps> = memo(({ song }) => {
|
|||||||
{ label: "Title", value: song.title },
|
{ label: "Title", value: song.title },
|
||||||
{ label: "Artist", value: song.artist },
|
{ label: "Artist", value: song.artist },
|
||||||
{ label: "Duration", value: formatDuration(song.totalTime || '') },
|
{ label: "Duration", value: formatDuration(song.totalTime || '') },
|
||||||
|
{ label: "Rekordbox Path", value: song.location },
|
||||||
{ label: "Album", value: song.album },
|
{ label: "Album", value: song.album },
|
||||||
{ label: "Genre", value: song.genre },
|
{ label: "Genre", value: song.genre },
|
||||||
{ label: "BPM", value: song.averageBpm },
|
{ label: "BPM", value: song.averageBpm },
|
||||||
|
|||||||
@ -35,7 +35,7 @@ import { S3Configuration } from "./S3Configuration";
|
|||||||
import { MusicUpload } from "../components/MusicUpload";
|
import { MusicUpload } from "../components/MusicUpload";
|
||||||
import { SongMatching } from "../components/SongMatching";
|
import { SongMatching } from "../components/SongMatching";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import { DuplicatesViewer } from "../components/DuplicatesViewer";
|
import { DuplicatesViewer } from "../components/DuplicatesViewer.tsx";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
interface MusicFile {
|
interface MusicFile {
|
||||||
@ -136,35 +136,23 @@ export function Configuration() {
|
|||||||
const handleSyncS3 = async () => {
|
const handleSyncS3 = async () => {
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/music/sync-s3', {
|
const { jobId } = await api.startBackgroundJob('s3-sync');
|
||||||
method: 'POST',
|
toast({
|
||||||
|
title: 'S3 Sync Started',
|
||||||
|
description: `Job ${jobId} started. Progress will appear shortly.`,
|
||||||
|
status: 'info',
|
||||||
|
duration: 4000,
|
||||||
|
isClosable: true,
|
||||||
});
|
});
|
||||||
|
// Defer reloading; background job widget will show progress and we fetch on demand
|
||||||
if (response.ok) {
|
if (tabIndex !== TAB_INDEX.MUSIC_LIBRARY) {
|
||||||
const data = await response.json();
|
setMusicLoaded(false);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error syncing S3:', error);
|
console.error('Error starting S3 sync:', error);
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'Failed to sync S3 files',
|
description: 'Failed to start S3 sync',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
isClosable: true,
|
isClosable: true,
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
export interface Playlist {
|
|
||||||
_id: string;
|
|
||||||
name: string;
|
|
||||||
songs: string[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user