Bri-Tunes/src/routes/generate.js

358 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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