358 lines
14 KiB
JavaScript
358 lines
14 KiB
JavaScript
const path = require('path');
|
||
const fs = require('fs');
|
||
const crypto = require('crypto');
|
||
const express = require('express');
|
||
const multer = require('multer');
|
||
|
||
const { requireUser } = require('../middleware/auth');
|
||
const settings = require('../services/settings');
|
||
const generation = require('../services/generation');
|
||
const songs = require('../services/songs');
|
||
const imageService = require('../services/image');
|
||
|
||
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
||
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
||
|
||
const coverStorage = multer.diskStorage({
|
||
destination: (req, file, cb) => cb(null, path.join(mediaDir, 'covers')),
|
||
filename: (req, file, cb) => {
|
||
const ext = path.extname(file.originalname).toLowerCase();
|
||
cb(null, `${crypto.randomUUID()}${ext}`);
|
||
},
|
||
});
|
||
const uploadCover = multer({
|
||
storage: coverStorage,
|
||
limits: { fileSize: 10 * 1024 * 1024 },
|
||
fileFilter: (req, file, cb) => {
|
||
const ext = path.extname(file.originalname).toLowerCase();
|
||
if (!IMAGE_EXT.has(ext)) return cb(new Error('Unsupported image format.'));
|
||
cb(null, true);
|
||
},
|
||
}).single('cover');
|
||
|
||
const MIME = {
|
||
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac',
|
||
'.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.opus': 'audio/ogg',
|
||
'.flac': 'audio/flac', '.wav': 'audio/wav',
|
||
};
|
||
|
||
const KEYSCALES = [
|
||
'C major','C minor','C# major','C# minor',
|
||
'D major','D minor','D# major','D# minor',
|
||
'E major','E minor',
|
||
'F major','F minor','F# major','F# minor',
|
||
'G major','G minor','G# major','G# minor',
|
||
'A major','A minor','A# major','A# minor',
|
||
'B major','B minor',
|
||
];
|
||
|
||
// Guard: generation must be enabled and user must be verified
|
||
function requireGeneration(req, res, next) {
|
||
if (!settings.isGenerationEnabled()) {
|
||
const err = new Error('Song generation is not enabled.');
|
||
err.status = 403; err.expose = true; return next(err);
|
||
}
|
||
if (!req.session.user.isVip && req.session.user.role !== 'admin') {
|
||
const err = new Error('VIP access is required to generate songs.');
|
||
err.status = 403; err.expose = true; return next(err);
|
||
}
|
||
if (!req.session.user.emailVerified) {
|
||
const err = new Error('Please verify your email to use song generation.');
|
||
err.status = 403; err.expose = true; return next(err);
|
||
}
|
||
next();
|
||
}
|
||
|
||
module.exports = function generateRoutes(csrfProtection) {
|
||
const router = express.Router();
|
||
router.use(requireUser);
|
||
router.use(requireGeneration);
|
||
|
||
// ── GET / — generate page ──────────────────────────────────────────────
|
||
router.get('/', (req, res) => {
|
||
const userId = req.session.user.id;
|
||
const genList = generation.listByUser(userId);
|
||
const count = genList.length;
|
||
const rl = generation.getRateLimitInfo(userId);
|
||
const s = settings.get();
|
||
res.render('generate/index', {
|
||
title: 'Generate',
|
||
genList,
|
||
count,
|
||
max: generation.MAX_GENERATED,
|
||
canGenerate: count < generation.MAX_GENERATED && rl.rateLimitOk,
|
||
keyscales: KEYSCALES,
|
||
rateLimit: rl,
|
||
cooldownSeconds: s.generation_cooldown_seconds != null ? s.generation_cooldown_seconds : 180,
|
||
});
|
||
});
|
||
|
||
// ── GET /rate-limit — current rate-limit state (JSON) ─────────────────
|
||
router.get('/rate-limit', (req, res) => {
|
||
const s = settings.get();
|
||
const rl = generation.getRateLimitInfo(req.session.user.id);
|
||
res.json({ ...rl, cooldownSeconds: s.generation_cooldown_seconds != null ? s.generation_cooldown_seconds : 180 });
|
||
});
|
||
|
||
// ── POST /submit — kick off a generation job ───────────────────────────
|
||
router.post('/submit', csrfProtection, async (req, res) => {
|
||
const userId = req.session.user.id;
|
||
if (!generation.canGenerate(userId)) {
|
||
return res.status(400).json({ error: 'You have reached the 20-song limit. Delete or publish songs to make room.' });
|
||
}
|
||
const s = settings.get();
|
||
if (!s.comfyui_workflow_json) {
|
||
return res.status(503).json({ error: 'No workflow configured. Contact the administrator.' });
|
||
}
|
||
|
||
// Rate limit checks
|
||
const rl = generation.getRateLimitInfo(userId);
|
||
if (rl.cooldownSecsRemaining > 0) {
|
||
const mins = Math.ceil(rl.cooldownSecsRemaining / 60);
|
||
return res.status(429).json({ error: `Please wait ${mins} minute${mins !== 1 ? 's' : ''} before generating again.` });
|
||
}
|
||
if (rl.hourlyCount >= rl.hourlyLimit) {
|
||
const mins = Math.ceil(rl.hourlyResetSecsRemaining / 60);
|
||
return res.status(429).json({ error: `Hourly limit reached. Try again in ${mins} minute${mins !== 1 ? 's' : ''}.` });
|
||
}
|
||
|
||
const params = {
|
||
user_id: userId,
|
||
style_prompt: (req.body.style_prompt || '').trim(),
|
||
lyrics_prompt: (req.body.lyrics_prompt || '').trim(),
|
||
bpm: parseInt(req.body.bpm, 10) || null,
|
||
keyscale: req.body.keyscale || null,
|
||
duration_seconds: parseInt(req.body.duration, 10) || null,
|
||
creativity: parseFloat(req.body.creativity) || 3.5,
|
||
seed: parseInt(req.body.seed, 10) === -1 ? -1 : (parseInt(req.body.seed, 10) || -1),
|
||
};
|
||
|
||
const generationId = generation.create(params);
|
||
|
||
const nodeIds = {
|
||
style_prompt: s.comfyui_node_style_prompt,
|
||
lyrics_prompt: s.comfyui_node_lyrics_prompt,
|
||
bpm: s.comfyui_node_bpm,
|
||
keyscale: s.comfyui_node_keyscale,
|
||
duration: s.comfyui_node_duration,
|
||
creativity: s.comfyui_node_creativity,
|
||
seed: s.comfyui_node_seed,
|
||
};
|
||
|
||
try {
|
||
const promptId = await generation.submitToComfyUI(
|
||
s.comfyui_base_url, s.comfyui_workflow_json, params, nodeIds
|
||
);
|
||
generation.updateJobStmt.run(promptId, 'processing', generationId);
|
||
} catch (err) {
|
||
generation.updateFailStmt.run('failed', err.message, generationId);
|
||
// Still return generationId so client can display the failed row
|
||
}
|
||
|
||
res.json({ ok: true, generationId });
|
||
});
|
||
|
||
// ── GET /status/:id — poll status ─────────────────────────────────────
|
||
router.get('/status/:id', async (req, res) => {
|
||
const id = Number(req.params.id);
|
||
const row = generation.findById(id);
|
||
if (!row || row.user_id !== req.session.user.id) {
|
||
return res.status(404).json({ error: 'Not found' });
|
||
}
|
||
|
||
if (row.status === 'done') {
|
||
return res.json({ status: 'done' });
|
||
}
|
||
if (row.status === 'failed') {
|
||
return res.json({ status: 'failed', error: row.error_message });
|
||
}
|
||
|
||
// Still pending/processing — poll ComfyUI
|
||
if (!row.job_id) {
|
||
return res.json({ status: row.status });
|
||
}
|
||
|
||
const s = settings.get();
|
||
try {
|
||
const { done, relPath } = await generation.pollComfyUI(s.comfyui_base_url, row.job_id, row.id);
|
||
if (done) {
|
||
generation.updateDoneStmt.run('done', relPath, id);
|
||
return res.json({ status: 'done' });
|
||
}
|
||
} catch (err) {
|
||
// ComfyUI not reachable or job not finished yet — keep polling
|
||
}
|
||
res.json({ status: 'processing' });
|
||
});
|
||
|
||
// ── GET /:id/stream — stream generated audio ───────────────────────────
|
||
router.get('/:id/stream', (req, res, next) => {
|
||
const id = Number(req.params.id);
|
||
const row = generation.findById(id);
|
||
if (!row || row.user_id !== req.session.user.id || !row.audio_path) {
|
||
return res.status(404).end();
|
||
}
|
||
|
||
const absPath = path.join(generation.MEDIA_DIR, row.audio_path);
|
||
fs.stat(absPath, (err, stat) => {
|
||
if (err) return res.status(404).end();
|
||
const size = stat.size;
|
||
const ext = path.extname(absPath).toLowerCase();
|
||
const contentType = MIME[ext] || 'application/octet-stream';
|
||
const range = req.headers.range;
|
||
|
||
if (!range) {
|
||
res.writeHead(200, {
|
||
'Content-Type': contentType, 'Content-Length': size,
|
||
'Accept-Ranges': 'bytes', 'Cache-Control': 'no-cache',
|
||
});
|
||
return fs.createReadStream(absPath).pipe(res);
|
||
}
|
||
|
||
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
||
if (!match) {
|
||
res.writeHead(416, { 'Content-Range': `bytes */${size}` });
|
||
return res.end();
|
||
}
|
||
const start = match[1] === '' ? 0 : parseInt(match[1], 10);
|
||
const end = match[2] === '' ? size - 1 : parseInt(match[2], 10);
|
||
if (isNaN(start) || isNaN(end) || start > end || end >= size) {
|
||
res.writeHead(416, { 'Content-Range': `bytes */${size}` });
|
||
return res.end();
|
||
}
|
||
res.writeHead(206, {
|
||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||
'Accept-Ranges': 'bytes',
|
||
'Content-Length': end - start + 1,
|
||
'Content-Type': contentType,
|
||
'Cache-Control': 'no-cache',
|
||
});
|
||
fs.createReadStream(absPath, { start, end }).pipe(res);
|
||
});
|
||
});
|
||
|
||
// ── GET /:id/publish — show publish metadata form ──────────────────────
|
||
router.get('/:id/publish', (req, res, next) => {
|
||
const id = Number(req.params.id);
|
||
const row = generation.findById(id);
|
||
if (!row || row.user_id !== req.session.user.id) return next();
|
||
if (row.status !== 'done') {
|
||
req.flash('error', 'Song is not ready yet.');
|
||
return res.redirect('/generate');
|
||
}
|
||
res.render('generate/publish', {
|
||
title: 'Publish Song',
|
||
row,
|
||
genres: songs.listGenres(),
|
||
values: {
|
||
title: '',
|
||
artist: req.session.user.displayName || '',
|
||
visibility: 'private',
|
||
},
|
||
errors: {},
|
||
});
|
||
});
|
||
|
||
// ── POST /:id/publish — create song record + move file ─────────────────
|
||
// uploadCover (multer) must run before csrfProtection so req.body._csrf is
|
||
// available when the CSRF middleware checks it (multipart bodies aren't parsed
|
||
// by express.urlencoded).
|
||
router.post('/:id/publish', uploadCover, csrfProtection, async (req, res, next) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
const row = generation.findById(id);
|
||
if (!row || row.user_id !== req.session.user.id) return next();
|
||
if (row.status !== 'done' || !row.audio_path) {
|
||
if (req.file) fs.unlink(req.file.path, () => {});
|
||
req.flash('error', 'Song is not ready to publish.');
|
||
return res.redirect('/generate');
|
||
}
|
||
|
||
const title = (req.body.title || '').trim().slice(0, 200);
|
||
const artist = (req.body.artist || '').trim().slice(0, 200);
|
||
const errors = {};
|
||
if (!title) errors.title = 'Title is required.';
|
||
if (!artist) errors.artist = 'Artist is required.';
|
||
|
||
if (Object.keys(errors).length) {
|
||
if (req.file) fs.unlink(req.file.path, () => {});
|
||
return res.render('generate/publish', {
|
||
title: 'Publish Song',
|
||
row,
|
||
genres: songs.listGenres(),
|
||
values: { ...req.body },
|
||
errors,
|
||
});
|
||
}
|
||
|
||
const album = (req.body.album || '').trim().slice(0, 200) || null;
|
||
const genre = (req.body.genre !== '__new__' ? req.body.genre : '') || (req.body.genre_new || '').trim() || null;
|
||
const year = parseInt(req.body.year, 10) || null;
|
||
const visibility = ['public','logged_in','private'].includes(req.body.visibility)
|
||
? req.body.visibility : 'private';
|
||
|
||
// Cover art — process through sharp (1:1 crop, max 1000×1000, JPEG)
|
||
let coverPath = null;
|
||
if (req.file) {
|
||
const finalName = `${crypto.randomUUID()}.jpg`;
|
||
const finalAbs = path.join(mediaDir, 'covers', finalName);
|
||
try {
|
||
await imageService.processCoverImage(req.file.path, finalAbs);
|
||
coverPath = `covers/${finalName}`;
|
||
} catch (err) {
|
||
console.warn('[generate] failed to process cover image:', err.message);
|
||
} finally {
|
||
fs.unlink(req.file.path, () => {});
|
||
}
|
||
}
|
||
|
||
// Move audio from media/generated/ to media/audio/
|
||
const oldAbs = path.join(generation.MEDIA_DIR, row.audio_path);
|
||
const audioExt = path.extname(row.audio_path);
|
||
const newName = `${Date.now()}${audioExt}`;
|
||
const newRel = `audio/${newName}`;
|
||
const newAbs = path.join(generation.MEDIA_DIR, newRel);
|
||
|
||
try {
|
||
fs.renameSync(oldAbs, newAbs);
|
||
} catch {
|
||
fs.copyFileSync(oldAbs, newAbs);
|
||
fs.unlinkSync(oldAbs);
|
||
}
|
||
|
||
songs.create({
|
||
title, artist, album, genre, year,
|
||
durationSeconds: row.duration_seconds || null,
|
||
audioPath: newRel,
|
||
coverPath,
|
||
visibility,
|
||
uploadedBy: req.session.user.id,
|
||
});
|
||
|
||
generation.remove(id);
|
||
req.flash('success', 'Song published to your library!');
|
||
res.redirect('/mymusic/songs');
|
||
} catch (err) {
|
||
if (req.file) fs.unlink(req.file.path, () => {});
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
// ── POST /:id/delete ───────────────────────────────────────────────────
|
||
router.post('/:id/delete', csrfProtection, (req, res) => {
|
||
const id = Number(req.params.id);
|
||
const row = generation.findById(id);
|
||
if (!row || row.user_id !== req.session.user.id) {
|
||
return res.status(404).json({ error: 'Not found' });
|
||
}
|
||
if (row.audio_path) {
|
||
const abs = path.join(generation.MEDIA_DIR, row.audio_path);
|
||
fs.unlink(abs, () => {});
|
||
}
|
||
generation.remove(id);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
return router;
|
||
};
|