- Fix scrolling on Music Storage page by adding proper overflow handling - Add height constraints and flex layout for better tab panel scrolling - Update streaming endpoint to use presigned URLs instead of direct URLs - Improve audio error handling with better error messages - Update MusicPlayer component with dark theme styling - Add loading indicators and error states for better UX - Fix audio playback for files synced from S3 subdirectories The Music Storage page now has proper scrolling behavior and audio playback should work correctly for all music files.
400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
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
|
|
const 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',
|
|
});
|
|
|
|
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;
|
|
|
|
// 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();
|
|
|
|
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 results = [];
|
|
|
|
for (const file of req.files) {
|
|
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 });
|
|
} catch (error) {
|
|
console.error(`Error uploading ${file.originalname}:`, error);
|
|
results.push({ success: false, fileName: file.originalname, error: 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' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Sync S3 files with database - recursively list all files in S3 and sync
|
|
*/
|
|
router.post('/sync-s3', async (req, res) => {
|
|
try {
|
|
console.log('Starting S3 sync...');
|
|
|
|
// Get all files from S3 recursively
|
|
const s3Files = await s3Service.listAllFiles();
|
|
console.log(`Found ${s3Files.length} files in S3 bucket`);
|
|
|
|
const results = {
|
|
total: s3Files.length,
|
|
synced: 0,
|
|
skipped: 0,
|
|
errors: 0,
|
|
newFiles: 0
|
|
};
|
|
|
|
for (const s3File of s3Files) {
|
|
try {
|
|
// Check if file already exists in database
|
|
const existingFile = await MusicFile.findOne({ s3Key: s3File.key });
|
|
|
|
if (existingFile) {
|
|
results.skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Extract filename from S3 key
|
|
const filename = s3File.key.split('/').pop() || s3File.key;
|
|
|
|
// Check if it's an audio file
|
|
if (!audioMetadataService.isAudioFile(filename)) {
|
|
results.skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Get file content to extract metadata
|
|
try {
|
|
const fileBuffer = await s3Service.getFileContent(s3File.key);
|
|
const metadata = await audioMetadataService.extractMetadata(fileBuffer, filename);
|
|
|
|
// Save to database
|
|
const musicFile = new MusicFile({
|
|
originalName: filename,
|
|
s3Key: s3File.key,
|
|
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
|
contentType: 'audio/mpeg', // Default, will be updated by metadata
|
|
size: s3File.size,
|
|
...metadata,
|
|
});
|
|
|
|
await musicFile.save();
|
|
results.synced++;
|
|
results.newFiles++;
|
|
|
|
} catch (metadataError) {
|
|
console.error(`Error extracting metadata for ${s3File.key}:`, metadataError);
|
|
// Still save the file without metadata
|
|
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,
|
|
});
|
|
await musicFile.save();
|
|
results.synced++;
|
|
results.newFiles++;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`Error processing ${s3File.key}:`, error);
|
|
results.errors++;
|
|
}
|
|
}
|
|
|
|
console.log('S3 sync completed:', results);
|
|
res.json({
|
|
message: 'S3 sync completed',
|
|
results
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('S3 sync error:', error);
|
|
res.status(500).json({ error: 'Failed to sync S3 files' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
} 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);
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
export { router as musicRouter };
|