import express from 'express'; import multer from 'multer'; import { S3Service } from '../services/s3Service.js'; import { AudioMetadataService } from '../services/audioMetadataService.js'; import { MusicFile } from '../models/MusicFile.js'; import { Song } from '../models/Song.js'; const router = express.Router(); // Configure multer for memory storage const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024, // 100MB limit }, fileFilter: (req, file, cb) => { const audioMetadataService = new AudioMetadataService(); if (audioMetadataService.isAudioFile(file.originalname)) { cb(null, true); } else { cb(new Error('Only audio files are allowed')); } }, }); // Initialize services let s3Service: S3Service; // Initialize S3 service with configuration from file async function initializeS3Service() { try { s3Service = await S3Service.createFromConfig(); console.log('✅ S3 service initialized with configuration from s3-config.json'); } catch (error) { console.error('❌ Failed to initialize S3 service:', error); // Fallback to environment variables s3Service = new S3Service({ endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000', accessKeyId: process.env.S3_ACCESS_KEY_ID || 'minioadmin', secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || 'minioadmin', bucketName: process.env.S3_BUCKET_NAME || 'music-files', region: process.env.S3_REGION || 'us-east-1', }); console.log('⚠️ S3 service initialized with environment variables as fallback'); } } // Initialize S3 service on startup initializeS3Service(); /** * Reload S3 service with updated configuration */ export async function reloadS3Service() { try { s3Service = await S3Service.createFromConfig(); console.log('✅ S3 service reloaded with updated configuration'); return true; } catch (error) { console.error('❌ Failed to reload S3 service:', error); return false; } } const audioMetadataService = new AudioMetadataService(); /** * Upload a single music file */ router.post('/upload', upload.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } const { buffer, originalname, mimetype } = req.file; const targetFolder = typeof req.body?.targetFolder === 'string' ? req.body.targetFolder : undefined; const markForScan = String(req.body?.markForScan || '').toLowerCase() === 'true'; // Upload to S3 const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype, targetFolder); // Extract audio metadata const metadata = await audioMetadataService.extractMetadata(buffer, originalname); // Save to database const musicFile = new MusicFile({ originalName: originalname, s3Key: uploadResult.key, s3Url: uploadResult.url, contentType: mimetype, size: uploadResult.size, ...metadata, }); await musicFile.save(); // Invalidate folder cache since we added a new file invalidateFolderCache(); // Optionally add to a special playlist for scanning if (markForScan) { try { const { Playlist } = await import('../models/Playlist.js'); const { Song } = await import('../models/Song.js'); // Find or create the "To Be Scanned" playlist let toScanPlaylist = await Playlist.findOne({ name: 'To Be Scanned' }); if (!toScanPlaylist) { toScanPlaylist = new Playlist({ id: 'to-be-scanned', name: 'To Be Scanned', type: 'playlist', tracks: [], order: 0 }); await toScanPlaylist.save(); console.log('✅ Created "To Be Scanned" playlist'); } // Create stub song with temporary id if needed const tempId = `stub-${musicFile._id.toString()}`; let existingStub = await Song.findOne({ id: tempId }); if (!existingStub) { const stub = new Song({ id: tempId, title: musicFile.title || musicFile.originalName, artist: musicFile.artist || '', album: musicFile.album || '', totalTime: musicFile.duration ? String(Math.round(musicFile.duration / 1000)) : '', location: '', s3File: { musicFileId: musicFile._id, s3Key: musicFile.s3Key, s3Url: musicFile.s3Url, hasS3File: true } }); await stub.save(); console.log('✅ Created stub song:', tempId); } // Add stub song to playlist if not already there if (!toScanPlaylist.tracks.includes(tempId)) { toScanPlaylist.tracks.push(tempId); await toScanPlaylist.save(); console.log('✅ Added song to "To Be Scanned" playlist'); } } catch (e) { console.error('❌ Failed to mark uploaded file for scanning:', e); } } res.json({ message: 'File uploaded successfully', musicFile, }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ error: 'Failed to upload file' }); } }); /** * Upload multiple music files */ router.post('/batch-upload', upload.array('files', 10), async (req, res) => { try { if (!req.files || req.files.length === 0) { return res.status(400).json({ error: 'No files uploaded' }); } const { markForScan = false } = req.body; const results = []; for (const file of req.files as Express.Multer.File[]) { try { const { buffer, originalname, mimetype } = file; // Upload to S3 const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype); // Extract audio metadata const metadata = await audioMetadataService.extractMetadata(buffer, originalname); // Save to database const musicFile = new MusicFile({ originalName: originalname, s3Key: uploadResult.key, s3Url: uploadResult.url, contentType: mimetype, size: uploadResult.size, ...metadata, }); await musicFile.save(); results.push({ success: true, musicFile }); // Optionally add to "To Be Scanned" playlist if (markForScan) { try { const { Playlist } = await import('../models/Playlist.js'); const { Song } = await import('../models/Song.js'); // Find or create the "To Be Scanned" playlist let toScanPlaylist = await Playlist.findOne({ name: 'To Be Scanned' }); if (!toScanPlaylist) { toScanPlaylist = new Playlist({ name: 'To Be Scanned', type: 'playlist', tracks: [], order: 0 }); await toScanPlaylist.save(); console.log('✅ Created "To Be Scanned" playlist (batch)'); } // Create stub song with temporary id if needed const tempId = `stub-${musicFile._id.toString()}`; let existingStub = await Song.findOne({ id: tempId }); if (!existingStub) { const stub = new Song({ id: tempId, title: musicFile.title || musicFile.originalName, artist: musicFile.artist || '', album: musicFile.album || '', totalTime: musicFile.duration ? String(Math.round(musicFile.duration / 1000)) : '', location: '', s3File: { musicFileId: musicFile._id, s3Key: musicFile.s3Key, s3Url: musicFile.s3Url, hasS3File: true } }); await stub.save(); console.log('✅ Created stub song (batch):', tempId); } // Add stub song to playlist if not already there if (!toScanPlaylist.tracks.includes(tempId)) { toScanPlaylist.tracks.push(tempId); await toScanPlaylist.save(); console.log('✅ Added song to "To Be Scanned" playlist (batch)'); } } catch (e) { console.error('❌ Failed to mark uploaded file for scanning (batch):', e); } } // Invalidate folder cache since we added a new file invalidateFolderCache(); } catch (error) { console.error(`Error uploading ${file.originalname}:`, error); results.push({ success: false, fileName: file.originalname, error: (error as Error).message }); } } res.json({ message: 'Batch upload completed', results, }); } catch (error) { console.error('Batch upload error:', error); res.status(500).json({ error: 'Failed to upload files' }); } }); /** * Get all music files (from database) */ router.get('/files', async (req, res) => { try { const musicFiles = await MusicFile.find({}).sort({ uploadedAt: -1 }); res.json({ musicFiles }); } catch (error) { console.error('Error fetching music files:', error); res.status(500).json({ error: 'Failed to fetch music files' }); } }); /** * List folders in the S3 bucket for folder selection */ // Cache for folder listing to improve performance let folderCache: { folders: string[]; timestamp: number } | null = null; const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes // Function to invalidate folder cache const invalidateFolderCache = () => { folderCache = null; }; router.get('/folders', async (req, res) => { try { // Check if we have a valid cached result if (folderCache && (Date.now() - folderCache.timestamp) < CACHE_DURATION) { return res.json({ folders: folderCache.folders }); } const folders = await s3Service.listAllFolders(''); const result = ['', ...folders]; // Cache the result folderCache = { folders: result, timestamp: Date.now() }; res.json({ folders: result }); } catch (error) { console.error('Error fetching S3 folders:', error); res.status(500).json({ error: 'Failed to fetch S3 folders' }); } }); /** * Force refresh folder cache */ router.post('/folders/refresh', async (req, res) => { try { invalidateFolderCache(); res.json({ message: 'Folder cache refreshed successfully' }); } catch (error) { console.error('Error refreshing folder cache:', error); res.status(500).json({ error: 'Failed to refresh folder cache' }); } }); /** * Sync S3 files with database - now uses background job system */ router.post('/sync-s3', async (req, res) => { try { console.log('🔄 Starting S3 sync background job...'); // Import background job service const { backgroundJobService } = await import('../services/backgroundJobService.js'); // Start the background job const jobId = await backgroundJobService.startJob({ type: 's3-sync', options: req.body }); res.json({ message: 'S3 sync started as background job', jobId, status: 'started' }); // Invalidate folder cache since sync might change folder structure invalidateFolderCache(); } catch (error) { console.error('❌ Error starting S3 sync job:', error); res.status(500).json({ error: 'Failed to start S3 sync job' }); } }); /** * Get streaming URL for a music file */ router.get('/:id/stream', async (req, res) => { try { const musicFile = await MusicFile.findById(req.params.id); if (!musicFile) { return res.status(404).json({ error: 'Music file not found' }); } // Use presigned URL for secure access instead of direct URL const presignedUrl = await s3Service.getPresignedUrl(musicFile.s3Key, 3600); // 1 hour expiry res.json({ streamingUrl: presignedUrl, musicFile, contentType: musicFile.contentType || undefined, }); } catch (error) { console.error('Streaming error:', error); res.status(500).json({ error: 'Failed to get streaming URL' }); } }); /** * Get presigned URL for secure access */ router.get('/:id/presigned', async (req, res) => { try { const musicFile = await MusicFile.findById(req.params.id); if (!musicFile) { return res.status(404).json({ error: 'Music file not found' }); } const expiresIn = parseInt(req.query.expiresIn as string) || 3600; const presignedUrl = await s3Service.getPresignedUrl(musicFile.s3Key, expiresIn); res.json({ presignedUrl, expiresIn, }); } catch (error) { console.error('Presigned URL error:', error); res.status(500).json({ error: 'Failed to generate presigned URL' }); } }); /** * Get music file metadata */ router.get('/:id/metadata', async (req, res) => { try { const musicFile = await MusicFile.findById(req.params.id); if (!musicFile) { return res.status(404).json({ error: 'Music file not found' }); } res.json(musicFile); } catch (error) { console.error('Metadata error:', error); res.status(500).json({ error: 'Failed to get metadata' }); } }); /** * List all music files with pagination and search */ router.get('/', async (req, res) => { try { const page = parseInt(req.query.page as string) || 1; const limit = parseInt(req.query.limit as string) || 20; const search = req.query.search as string; const artist = req.query.artist as string; const album = req.query.album as string; const genre = req.query.genre as string; const query: any = {}; // Build search query if (search) { query.$text = { $search: search }; } if (artist) { query.artist = { $regex: artist, $options: 'i' }; } if (album) { query.album = { $regex: album, $options: 'i' }; } if (genre) { query.genre = { $regex: genre, $options: 'i' }; } const skip = (page - 1) * limit; const [musicFiles, total] = await Promise.all([ MusicFile.find(query) .sort({ uploadedAt: -1 }) .skip(skip) .limit(limit) .populate('songId'), MusicFile.countDocuments(query), ]); res.json({ musicFiles, pagination: { page, limit, total, pages: Math.ceil(total / limit), }, }); } catch (error) { console.error('List error:', error); res.status(500).json({ error: 'Failed to list music files' }); } }); /** * Delete a music file */ router.delete('/:id', async (req, res) => { try { const musicFile = await MusicFile.findById(req.params.id); if (!musicFile) { return res.status(404).json({ error: 'Music file not found' }); } // Delete from S3 await s3Service.deleteFile(musicFile.s3Key); // Delete from database await MusicFile.findByIdAndDelete(req.params.id); // Invalidate folder cache since we removed a file invalidateFolderCache(); res.json({ message: 'Music file deleted successfully' }); } catch (error) { console.error('Delete error:', error); res.status(500).json({ error: 'Failed to delete music file' }); } }); /** * Link music file to existing song */ router.post('/:id/link-song/:songId', async (req, res) => { try { const { id, songId } = req.params; const [musicFile, song] = await Promise.all([ MusicFile.findById(id), Song.findById(songId), ]); if (!musicFile) { return res.status(404).json({ error: 'Music file not found' }); } if (!song) { return res.status(404).json({ error: 'Song not found' }); } musicFile.songId = song._id; await musicFile.save(); res.json({ message: 'Music file linked to song successfully', musicFile, }); } catch (error) { console.error('Link error:', error); res.status(500).json({ error: 'Failed to link music file to song' }); } }); /** * Fix orphaned music files (MusicFile exists but Song doesn't have hasS3File: true) */ router.post('/fix-orphaned', async (req, res) => { try { console.log('🔧 Starting orphaned music files fix...'); const orphanedMusicFiles = await MusicFile.find({ songId: { $exists: true }, s3Key: { $exists: true } }); let fixedCount = 0; const fixedFiles = []; for (const musicFile of orphanedMusicFiles) { // Check if the corresponding Song document needs to be updated const song = await Song.findById(musicFile.songId); if (song && !song.s3File?.hasS3File) { await Song.updateOne( { _id: musicFile.songId }, { $set: { 's3File.musicFileId': musicFile._id, 's3File.s3Key': musicFile.s3Key, 's3File.s3Url': musicFile.s3Url, 's3File.streamingUrl': musicFile.s3Url, 's3File.hasS3File': true } } ); fixedCount++; fixedFiles.push(musicFile.originalName); console.log(`🔧 Fixed orphaned music file: ${musicFile.originalName}`); } } console.log(`✅ Orphaned music files fix completed: Fixed ${fixedCount} files`); res.json({ message: `Fixed ${fixedCount} orphaned music files`, fixedCount, fixedFiles }); } catch (error) { console.error('Error fixing orphaned music files:', error); res.status(500).json({ message: 'Error fixing orphaned music files', error }); } }); /** * 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 };