Geert Rademakes 1bb1f7d0d5 fix: Resolve scrolling and audio playback issues
- 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.
2025-08-06 15:05:33 +02:00

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