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