625 lines
18 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
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 };