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