feat: add AI song generation via ComfyUI with rate limiting and admin configuration
- Add database schema for site_settings and generated_songs tables with migration 011_generation.sql - Implement backend services for settings management and generation workflow (submit/poll ComfyUI) - Create routes for admin panel (/admin) to configure generation parameters, upload workflow JSON, manage users - Create generate routes (/generate) with rate limiting (3min cooldown, 10/hour limit), form submission, polling, streaming - Add frontend UI: generate page with form and rate-limit panel, publish flow for completed generations - Integrate generation link into header navigation for verified users when enabled
This commit is contained in:
parent
dfeedb1acd
commit
8b4a28e062
|
|
@ -0,0 +1,33 @@
|
|||
CREATE TABLE IF NOT EXISTS site_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
generation_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
comfyui_base_url TEXT,
|
||||
comfyui_workflow_json TEXT,
|
||||
comfyui_node_style_prompt TEXT,
|
||||
comfyui_node_lyrics_prompt TEXT,
|
||||
comfyui_node_bpm TEXT,
|
||||
comfyui_node_keyscale TEXT,
|
||||
comfyui_node_duration TEXT,
|
||||
comfyui_node_creativity TEXT,
|
||||
comfyui_node_seed TEXT
|
||||
);
|
||||
INSERT OR IGNORE INTO site_settings (id) VALUES (1);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS generated_songs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
job_id TEXT UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
audio_path TEXT,
|
||||
style_prompt TEXT,
|
||||
lyrics_prompt TEXT,
|
||||
bpm INTEGER,
|
||||
keyscale TEXT,
|
||||
duration_seconds INTEGER,
|
||||
creativity INTEGER,
|
||||
seed INTEGER,
|
||||
error_message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS generated_songs_user ON generated_songs (user_id, created_at DESC);
|
||||
|
|
@ -517,6 +517,24 @@ a.detail-creator-card:hover {
|
|||
.stacked .row { flex-direction: row; gap: 0.75rem; align-items: center; }
|
||||
.stacked .checkbox { flex-direction: row; align-items: center; gap: 0.5rem; }
|
||||
.err { color: var(--danger); font-size: 0.75rem; text-shadow: 0 0 4px rgba(255, 77, 109, 0.6); }
|
||||
.field-required { color: var(--danger); font-size: 0.8em; }
|
||||
|
||||
/* Cover art upload widget */
|
||||
.cover-upload-wrap { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.25rem; }
|
||||
.cover-file-input { font-size: 0.85rem; }
|
||||
.cover-preview-wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.cover-preview-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.cover-clear-btn { align-self: flex-start; margin-top: 0.25rem; }
|
||||
|
||||
/* ---------- Search ---------- */
|
||||
.search-form { display: flex; gap: 0.5rem; align-items: center; margin: 1rem 0; }
|
||||
|
|
@ -1296,6 +1314,169 @@ a.detail-creator-card:hover {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- Admin panel — generation node grid ---------- */
|
||||
.gen-node-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.gen-node-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ---------- Generate page ---------- */
|
||||
.gen-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 240px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.gen-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Rate limit panel */
|
||||
.gen-rate-panel {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
.gen-rate-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.gen-rate-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.gen-rate-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.gen-rate-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.2rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.gen-rate-sep { color: var(--muted); font-weight: 400; }
|
||||
.gen-rate-ok { color: var(--success, #4caf82); }
|
||||
.gen-rate-warn { color: var(--neon-orange, #ff9d4d); font-weight: 600; font-size: 0.85rem; }
|
||||
.gen-rate-sub {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.gen-rate-bar-wrap {
|
||||
height: 4px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.gen-rate-bar {
|
||||
height: 100%;
|
||||
background: var(--neon-violet);
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.gen-limit-notice {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(255, 77, 109, 0.08);
|
||||
border: 1px solid rgba(255, 77, 109, 0.35);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
.gen-form-full {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.gen-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.gen-two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
.gen-row-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.gen-slider-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.gen-slider-wrap input[type="range"] {
|
||||
flex: 1;
|
||||
accent-color: var(--neon-violet);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gen-slider-val {
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
color: var(--neon-violet);
|
||||
font-weight: 600;
|
||||
}
|
||||
.gen-submit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.gen-status-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* Spinner */
|
||||
.gen-spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid rgba(180, 120, 255, 0.25);
|
||||
border-top-color: var(--neon-violet);
|
||||
border-radius: 50%;
|
||||
animation: gen-spin 0.75s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes gen-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.gen-prompt-cell {
|
||||
max-width: 240px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---------- Bulk song selection ---------- */
|
||||
.col-check {
|
||||
width: 2.2rem;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const pinoHttp = require('pino-http');
|
|||
const { db } = require('../db');
|
||||
const SqliteStore = SqliteStoreFactory(session);
|
||||
const notifications = require('./services/notifications');
|
||||
const settings = require('./services/settings');
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -63,7 +64,8 @@ app.use((req, res, next) => {
|
|||
delete req.session.flash;
|
||||
res.locals.csrfToken = generateToken(req);
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.unreadNotifCount = req.session.user ? notifications.countUnread(req.session.user.id) : 0;
|
||||
res.locals.unreadNotifCount = req.session.user ? notifications.countUnread(req.session.user.id) : 0;
|
||||
res.locals.generationEnabled = settings.isGenerationEnabled();
|
||||
next();
|
||||
});
|
||||
|
||||
|
|
@ -80,8 +82,9 @@ app.use('/api', require('./routes/social')(csrfSynchronisedProtection));
|
|||
app.use('/', require('./routes/public'));
|
||||
app.use('/', require('./routes/auth')(csrfSynchronisedProtection));
|
||||
app.use('/account', require('./routes/account')(csrfSynchronisedProtection));
|
||||
app.use('/mymusic/users', require('./routes/users-admin')(csrfSynchronisedProtection));
|
||||
app.use('/mymusic', require('./routes/admin')(csrfSynchronisedProtection));
|
||||
app.use('/admin', require('./routes/admin-panel')(csrfSynchronisedProtection));
|
||||
app.use('/generate', require('./routes/generate')(csrfSynchronisedProtection));
|
||||
app.use('/mymusic', require('./routes/admin')(csrfSynchronisedProtection));
|
||||
|
||||
// 404.
|
||||
app.use((req, res) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
|
||||
const { requireAdmin } = require('../middleware/auth');
|
||||
const users = require('../services/users');
|
||||
const settings = require('../services/settings');
|
||||
|
||||
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
||||
|
||||
function unlinkQuiet(absPath) {
|
||||
fs.unlink(absPath, () => {});
|
||||
}
|
||||
|
||||
// Workflow JSON upload — stored in memory, never written to disk
|
||||
const workflowUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB max
|
||||
}).single('workflow_json');
|
||||
|
||||
module.exports = function adminPanelRoutes(csrfProtection) {
|
||||
const router = express.Router();
|
||||
router.use(requireAdmin);
|
||||
|
||||
// ── GET / — render admin panel ─────────────────────────────────────────
|
||||
router.get('/', (req, res) => {
|
||||
const s = settings.get();
|
||||
res.render('admin/index', {
|
||||
title: 'Admin Panel',
|
||||
userList: users.listAllUsers(),
|
||||
genSettings: s,
|
||||
workflowLoaded: !!(s.comfyui_workflow_json),
|
||||
workflowSize: s.comfyui_workflow_json
|
||||
? Math.round(Buffer.byteLength(s.comfyui_workflow_json, 'utf8') / 1024)
|
||||
: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// ── User management (same as old users-admin.js, updated redirect URLs) ─
|
||||
router.post('/users/:id/disable', csrfProtection, (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (id === req.session.user.id) {
|
||||
req.flash('error', 'You cannot disable your own account.');
|
||||
return res.redirect('/admin');
|
||||
}
|
||||
users.setDisabled(id, true);
|
||||
req.flash('success', 'Account disabled.');
|
||||
res.redirect('/admin');
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/users/:id/enable', csrfProtection, (req, res, next) => {
|
||||
try {
|
||||
users.setDisabled(Number(req.params.id), false);
|
||||
req.flash('success', 'Account enabled.');
|
||||
res.redirect('/admin');
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/users/:id/verify', csrfProtection, (req, res, next) => {
|
||||
try {
|
||||
users.markVerifiedAdmin(Number(req.params.id));
|
||||
req.flash('success', 'Email marked as verified.');
|
||||
res.redirect('/admin');
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/users/:id/delete', csrfProtection, (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (id === req.session.user.id) {
|
||||
req.flash('error', 'You cannot delete your own account.');
|
||||
return res.redirect('/admin');
|
||||
}
|
||||
const { songs, playlists } = users.deleteUserWithContent(id);
|
||||
for (const s of songs) {
|
||||
if (s.audio_path) unlinkQuiet(path.join(mediaDir, s.audio_path));
|
||||
if (s.cover_path) unlinkQuiet(path.join(mediaDir, s.cover_path));
|
||||
}
|
||||
for (const p of playlists) {
|
||||
if (p.cover_path) unlinkQuiet(path.join(mediaDir, p.cover_path));
|
||||
}
|
||||
req.flash('success', 'User and all their content deleted.');
|
||||
res.redirect('/admin');
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Generation settings ────────────────────────────────────────────────
|
||||
router.post('/generation', csrfProtection, (req, res, next) => {
|
||||
try {
|
||||
const current = settings.get();
|
||||
settings.set({
|
||||
generation_enabled: req.body.generation_enabled === '1' ? 1 : 0,
|
||||
comfyui_base_url: (req.body.comfyui_base_url || '').trim() || current.comfyui_base_url || null,
|
||||
comfyui_workflow_json: current.comfyui_workflow_json || null,
|
||||
comfyui_node_style_prompt: (req.body.node_style_prompt || '').trim() || null,
|
||||
comfyui_node_lyrics_prompt: (req.body.node_lyrics_prompt || '').trim() || null,
|
||||
comfyui_node_bpm: (req.body.node_bpm || '').trim() || null,
|
||||
comfyui_node_keyscale: (req.body.node_keyscale || '').trim() || null,
|
||||
comfyui_node_duration: (req.body.node_duration || '').trim() || null,
|
||||
comfyui_node_creativity: (req.body.node_creativity || '').trim() || null,
|
||||
comfyui_node_seed: (req.body.node_seed || '').trim() || null,
|
||||
});
|
||||
req.flash('success', 'Generation settings saved.');
|
||||
res.redirect('/admin#generation');
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/generation/workflow', (req, res, next) => {
|
||||
workflowUpload(req, res, (uploadErr) => {
|
||||
if (uploadErr) {
|
||||
req.flash('error', uploadErr.message);
|
||||
return res.redirect('/admin#generation');
|
||||
}
|
||||
// Now run CSRF check (multer must run first so body is parsed)
|
||||
csrfProtection(req, res, (csrfErr) => {
|
||||
if (csrfErr) return next(csrfErr);
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
req.flash('error', 'No file uploaded.');
|
||||
return res.redirect('/admin#generation');
|
||||
}
|
||||
const json = file.buffer.toString('utf8');
|
||||
try { JSON.parse(json); } catch {
|
||||
req.flash('error', 'Invalid JSON file.');
|
||||
return res.redirect('/admin#generation');
|
||||
}
|
||||
const current = settings.get();
|
||||
settings.set({ ...current, comfyui_workflow_json: json });
|
||||
req.flash('success', 'Workflow JSON uploaded and saved.');
|
||||
res.redirect('/admin#generation');
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
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 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.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);
|
||||
res.render('generate/index', {
|
||||
title: 'Generate',
|
||||
genList,
|
||||
count,
|
||||
max: generation.MAX_GENERATED,
|
||||
canGenerate: count < generation.MAX_GENERATED && rl.rateLimitOk,
|
||||
keyscales: KEYSCALES,
|
||||
rateLimit: rl,
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /rate-limit — current rate-limit state (JSON) ─────────────────
|
||||
router.get('/rate-limit', (req, res) => {
|
||||
res.json(generation.getRateLimitInfo(req.session.user.id));
|
||||
});
|
||||
|
||||
// ── 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: parseInt(req.body.creativity, 10) ?? 50,
|
||||
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 ─────────────────
|
||||
router.post('/:id/publish', csrfProtection, (req, res, next) => {
|
||||
uploadCover(req, res, (uploadErr) => {
|
||||
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 — use uploaded file if provided
|
||||
let coverPath = null;
|
||||
if (req.file) {
|
||||
const ext = path.extname(req.file.originalname).toLowerCase();
|
||||
coverPath = `covers/${req.file.filename}`;
|
||||
}
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { db } = require('../../db');
|
||||
|
||||
const MEDIA_DIR = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
||||
const GEN_DIR = path.join(MEDIA_DIR, 'generated');
|
||||
fs.mkdirSync(GEN_DIR, { recursive: true });
|
||||
|
||||
const MAX_GENERATED = 20;
|
||||
const COOLDOWN_MS = 3 * 60 * 1000; // 3 minutes between generations
|
||||
const HOURLY_LIMIT = 10; // max generations per 1-hour rolling window
|
||||
const HOURLY_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
// ── Prepared statements ────────────────────────────────────────────────────
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO generated_songs
|
||||
(user_id, status, style_prompt, lyrics_prompt, bpm, keyscale, duration_seconds, creativity, seed)
|
||||
VALUES
|
||||
(@user_id, 'pending', @style_prompt, @lyrics_prompt, @bpm, @keyscale, @duration_seconds, @creativity, @seed)
|
||||
`);
|
||||
const countByUserStmt = db.prepare('SELECT COUNT(*) AS c FROM generated_songs WHERE user_id = ?');
|
||||
const listByUserStmt = db.prepare('SELECT * FROM generated_songs WHERE user_id = ? ORDER BY created_at DESC');
|
||||
const findByIdStmt = db.prepare('SELECT * FROM generated_songs WHERE id = ?');
|
||||
const updateJobStmt = db.prepare('UPDATE generated_songs SET job_id = ?, status = ? WHERE id = ?');
|
||||
const updateDoneStmt = db.prepare('UPDATE generated_songs SET status = ?, audio_path = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?');
|
||||
const updateFailStmt = db.prepare('UPDATE generated_songs SET status = ?, error_message = ? WHERE id = ?');
|
||||
const deleteStmt = db.prepare('DELETE FROM generated_songs WHERE id = ?');
|
||||
const lastGenStmt = db.prepare(
|
||||
'SELECT created_at FROM generated_songs WHERE user_id = ? ORDER BY created_at DESC LIMIT 1'
|
||||
);
|
||||
const countHourlyStmt = db.prepare(
|
||||
"SELECT COUNT(*) AS c FROM generated_songs WHERE user_id = ? AND created_at >= datetime('now', '-1 hour')"
|
||||
);
|
||||
const oldestHourlyStmt = db.prepare(
|
||||
"SELECT MIN(created_at) AS oldest FROM generated_songs WHERE user_id = ? AND created_at >= datetime('now', '-1 hour')"
|
||||
);
|
||||
|
||||
// Parse a SQLite CURRENT_TIMESTAMP string (UTC) into a JS timestamp.
|
||||
function parseSqliteTs(ts) {
|
||||
return new Date(ts.replace(' ', 'T') + 'Z').getTime();
|
||||
}
|
||||
|
||||
// ── CRUD ───────────────────────────────────────────────────────────────────
|
||||
function countByUser(userId) { return countByUserStmt.get(userId).c; }
|
||||
function listByUser(userId) { return listByUserStmt.all(userId); }
|
||||
function findById(id) { return findByIdStmt.get(id); }
|
||||
function canGenerate(userId) { return countByUser(userId) < MAX_GENERATED; }
|
||||
|
||||
// Returns rate-limit state for a user (all times in whole seconds from now).
|
||||
function getRateLimitInfo(userId) {
|
||||
const now = Date.now();
|
||||
|
||||
// Cooldown
|
||||
let cooldownSecsRemaining = 0;
|
||||
const lastRow = lastGenStmt.get(userId);
|
||||
if (lastRow) {
|
||||
const elapsed = now - parseSqliteTs(lastRow.created_at);
|
||||
if (elapsed < COOLDOWN_MS) {
|
||||
cooldownSecsRemaining = Math.ceil((COOLDOWN_MS - elapsed) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Hourly rolling window
|
||||
const hourlyCount = countHourlyStmt.get(userId).c;
|
||||
let hourlyResetSecsRemaining = 0;
|
||||
if (hourlyCount >= HOURLY_LIMIT) {
|
||||
const oldestRow = oldestHourlyStmt.get(userId);
|
||||
if (oldestRow && oldestRow.oldest) {
|
||||
const resetAt = parseSqliteTs(oldestRow.oldest) + HOURLY_WINDOW_MS;
|
||||
hourlyResetSecsRemaining = Math.max(0, Math.ceil((resetAt - now) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cooldownSecsRemaining,
|
||||
hourlyCount,
|
||||
hourlyLimit: HOURLY_LIMIT,
|
||||
hourlyResetSecsRemaining,
|
||||
rateLimitOk: cooldownSecsRemaining === 0 && hourlyCount < HOURLY_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
function create(data) {
|
||||
return insertStmt.run(data).lastInsertRowid;
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
deleteStmt.run(id);
|
||||
}
|
||||
|
||||
// ── ComfyUI proxy ──────────────────────────────────────────────────────────
|
||||
|
||||
// Inject user params into the workflow JSON at the configured node IDs,
|
||||
// then POST to ComfyUI. Returns the prompt_id string.
|
||||
async function submitToComfyUI(baseUrl, workflowJson, params, nodeIds) {
|
||||
const workflow = JSON.parse(workflowJson);
|
||||
|
||||
function setInput(nodeId, key, value) {
|
||||
if (nodeId && workflow[nodeId] && workflow[nodeId].inputs !== undefined) {
|
||||
workflow[nodeId].inputs[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve seed once — same value injected into all seed nodes
|
||||
const resolvedSeed = (params.seed === -1 || params.seed == null)
|
||||
? Math.floor(Math.random() * 2 ** 32)
|
||||
: params.seed;
|
||||
|
||||
// Scale creativity 0–10 (slider) → temperature 3.0–7.0
|
||||
const temperature = 3.0 + ((params.creativity ?? 5) / 10) * 4.0;
|
||||
|
||||
const paramValues = {
|
||||
style_prompt: params.style_prompt || '',
|
||||
lyrics_prompt: params.lyrics_prompt || '',
|
||||
bpm: params.bpm,
|
||||
keyscale: params.keyscale,
|
||||
duration: params.duration_seconds,
|
||||
creativity: temperature,
|
||||
seed: resolvedSeed,
|
||||
};
|
||||
|
||||
// Maps each param to the input key name(s) for its node(s).
|
||||
// For comma-separated node IDs (e.g. duration "98,94"), keys are applied positionally.
|
||||
const PARAM_KEYS = {
|
||||
style_prompt: ['tags'],
|
||||
lyrics_prompt: ['lyrics'],
|
||||
bpm: ['bpm'],
|
||||
keyscale: ['keyscale'],
|
||||
duration: ['seconds', 'duration'], // node 98 → seconds, node 94 → duration
|
||||
creativity: ['cfg'],
|
||||
seed: ['seed', 'seed'], // node 3 → seed, node 94 → seed
|
||||
};
|
||||
|
||||
for (const [param, keys] of Object.entries(PARAM_KEYS)) {
|
||||
const rawNodeId = nodeIds[param];
|
||||
if (!rawNodeId) continue;
|
||||
const nodeIdList = String(rawNodeId).split(',').map(s => s.trim()).filter(Boolean);
|
||||
nodeIdList.forEach((nid, i) => {
|
||||
const key = keys[i] !== undefined ? keys[i] : keys[keys.length - 1];
|
||||
setInput(nid, key, paramValues[param]);
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: workflow }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ComfyUI /prompt returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data.prompt_id) throw new Error('ComfyUI did not return a prompt_id');
|
||||
return data.prompt_id;
|
||||
}
|
||||
|
||||
// Poll ComfyUI /history for a completed job.
|
||||
// Returns { done: true, relPath } when finished, { done: false } when still running.
|
||||
async function pollComfyUI(baseUrl, promptId, generationId) {
|
||||
const res = await fetch(`${baseUrl}/history/${promptId}`);
|
||||
if (!res.ok) throw new Error(`ComfyUI /history returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data[promptId]) return { done: false };
|
||||
|
||||
const outputs = data[promptId].outputs || {};
|
||||
let fileInfo = null;
|
||||
for (const nodeOut of Object.values(outputs)) {
|
||||
if (nodeOut.audio && nodeOut.audio.length) { fileInfo = nodeOut.audio[0]; break; }
|
||||
if (nodeOut.gifs && nodeOut.gifs.length) { fileInfo = nodeOut.gifs[0]; break; }
|
||||
}
|
||||
if (!fileInfo) return { done: false };
|
||||
|
||||
// Download the output file from ComfyUI
|
||||
const viewUrl = `${baseUrl}/view?filename=${encodeURIComponent(fileInfo.filename)}`
|
||||
+ `&subfolder=${encodeURIComponent(fileInfo.subfolder || '')}`
|
||||
+ `&type=${fileInfo.type || 'output'}`;
|
||||
const fileRes = await fetch(viewUrl);
|
||||
if (!fileRes.ok) throw new Error(`ComfyUI /view returned ${fileRes.status}`);
|
||||
|
||||
const ext = path.extname(fileInfo.filename) || '.mp3';
|
||||
const saveName = `${generationId}-${Date.now()}${ext}`;
|
||||
const savePath = path.join(GEN_DIR, saveName);
|
||||
fs.writeFileSync(savePath, Buffer.from(await fileRes.arrayBuffer()));
|
||||
|
||||
return { done: true, relPath: `generated/${saveName}` };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_GENERATED, COOLDOWN_MS, HOURLY_LIMIT, GEN_DIR, MEDIA_DIR,
|
||||
canGenerate, countByUser, listByUser, findById, create, remove,
|
||||
getRateLimitInfo,
|
||||
updateJobStmt, updateDoneStmt, updateFailStmt,
|
||||
submitToComfyUI, pollComfyUI,
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
const { db } = require('../../db');
|
||||
|
||||
const getStmt = db.prepare('SELECT * FROM site_settings WHERE id = 1');
|
||||
const setStmt = db.prepare(`
|
||||
UPDATE site_settings SET
|
||||
generation_enabled = @generation_enabled,
|
||||
comfyui_base_url = @comfyui_base_url,
|
||||
comfyui_workflow_json = @comfyui_workflow_json,
|
||||
comfyui_node_style_prompt = @comfyui_node_style_prompt,
|
||||
comfyui_node_lyrics_prompt = @comfyui_node_lyrics_prompt,
|
||||
comfyui_node_bpm = @comfyui_node_bpm,
|
||||
comfyui_node_keyscale = @comfyui_node_keyscale,
|
||||
comfyui_node_duration = @comfyui_node_duration,
|
||||
comfyui_node_creativity = @comfyui_node_creativity,
|
||||
comfyui_node_seed = @comfyui_node_seed
|
||||
WHERE id = 1
|
||||
`);
|
||||
|
||||
function get() {
|
||||
return getStmt.get() || {};
|
||||
}
|
||||
|
||||
function set(data) {
|
||||
setStmt.run(data);
|
||||
}
|
||||
|
||||
function isGenerationEnabled() {
|
||||
return !!(get().generation_enabled);
|
||||
}
|
||||
|
||||
module.exports = { get, set, isGenerationEnabled };
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
<h1>Admin Panel</h1>
|
||||
|
||||
<!-- ── Users ──────────────────────────────────────────────────────────── -->
|
||||
<h2 id="users" style="margin-top:1.5rem">Users</h2>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Display Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Verified</th>
|
||||
<th>Status</th>
|
||||
<th class="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% userList.forEach((u) => { %>
|
||||
<tr class="<%= u.disabled ? 'row-disabled' : '' %>">
|
||||
<td><%= u.displayName %></td>
|
||||
<td class="muted small"><%= u.email %></td>
|
||||
<td><%= u.role %></td>
|
||||
<td><%= u.emailVerified ? '✓' : '✗' %></td>
|
||||
<td><%= u.disabled ? 'Disabled' : 'Active' %></td>
|
||||
<td class="actions">
|
||||
<% if (!u.emailVerified) { %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/verify" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button type="submit" class="btn-small">Verify</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (u.id !== user.id) { %>
|
||||
<% if (u.disabled) { %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/enable" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button type="submit" class="btn-small">Enable</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/disable" class="inline-form">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button type="submit" class="btn-small">Disable</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<form method="post" action="/admin/users/<%= u.id %>/delete" class="inline-form"
|
||||
onsubmit="return confirm('Delete <%= u.displayName.replace(/'/g, "\\'") %> and ALL their content? This cannot be undone.')">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button type="submit" class="btn-small danger">Delete</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ── Song Generation Details ────────────────────────────────────────── -->
|
||||
<h2 id="generation" style="margin-top:2.5rem">Song Generation Details</h2>
|
||||
|
||||
<form method="post" action="/admin/generation" class="stacked form-card" style="margin-bottom:1.5rem">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
|
||||
<label class="checkbox" style="flex-direction:row;align-items:center;gap:0.6rem">
|
||||
<input type="checkbox" name="generation_enabled" value="1"
|
||||
<%= genSettings.generation_enabled ? 'checked' : '' %>>
|
||||
<span>Enable Song Generation <span class="muted small">(shows "Generate" in the nav for verified users)</span></span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
ComfyUI Base URL
|
||||
<input type="text" name="comfyui_base_url"
|
||||
value="<%= genSettings.comfyui_base_url || '' %>"
|
||||
placeholder="http://127.0.0.1:8188">
|
||||
<span class="muted small">Internal URL of your ComfyUI instance — never exposed to users.</span>
|
||||
</label>
|
||||
|
||||
<fieldset style="border:1px solid var(--border);border-radius:var(--radius);padding:1rem">
|
||||
<legend style="padding:0 0.5rem;font-size:0.72rem;color:var(--muted);letter-spacing:0.1em">NODE ID MAPPINGS</legend>
|
||||
<p class="muted small" style="margin-top:0;margin-bottom:0.75rem">
|
||||
Enter the node IDs from your ComfyUI workflow JSON that correspond to each parameter.
|
||||
The code injects values into <code>inputs.text</code> for prompts, <code>inputs.value</code> for numbers,
|
||||
and <code>inputs.seed</code> for the seed node.
|
||||
</p>
|
||||
<div class="gen-node-grid">
|
||||
<label>Style Prompt node ID
|
||||
<input type="text" name="node_style_prompt" value="<%= genSettings.comfyui_node_style_prompt || '' %>" placeholder="e.g. 6">
|
||||
</label>
|
||||
<label>Lyrics Prompt node ID
|
||||
<input type="text" name="node_lyrics_prompt" value="<%= genSettings.comfyui_node_lyrics_prompt || '' %>" placeholder="e.g. 7">
|
||||
</label>
|
||||
<label>BPM node ID
|
||||
<input type="text" name="node_bpm" value="<%= genSettings.comfyui_node_bpm || '' %>" placeholder="e.g. 12">
|
||||
</label>
|
||||
<label>Keyscale node ID
|
||||
<input type="text" name="node_keyscale" value="<%= genSettings.comfyui_node_keyscale || '' %>" placeholder="e.g. 13">
|
||||
</label>
|
||||
<label>Song Duration node ID
|
||||
<input type="text" name="node_duration" value="<%= genSettings.comfyui_node_duration || '' %>" placeholder="e.g. 14">
|
||||
</label>
|
||||
<label>Creativity node ID
|
||||
<input type="text" name="node_creativity" value="<%= genSettings.comfyui_node_creativity || '' %>" placeholder="e.g. 15">
|
||||
</label>
|
||||
<label>Seed node ID
|
||||
<input type="text" name="node_seed" value="<%= genSettings.comfyui_node_seed || '' %>" placeholder="e.g. 16">
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn-primary">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Workflow JSON upload (separate form — uses multipart) -->
|
||||
<div class="form-card" style="padding:1.25rem 1.5rem">
|
||||
<h3 style="margin:0 0 0.75rem">Workflow JSON</h3>
|
||||
<% if (workflowLoaded) { %>
|
||||
<p class="muted small" style="margin-bottom:0.75rem">
|
||||
✓ Workflow loaded — <strong><%= workflowSize %> KB</strong>.
|
||||
Upload a new file below to replace it.
|
||||
</p>
|
||||
<% } else { %>
|
||||
<p class="muted small" style="margin-bottom:0.75rem">No workflow loaded yet.</p>
|
||||
<% } %>
|
||||
<form method="post" action="/admin/generation/workflow" enctype="multipart/form-data"
|
||||
class="stacked" style="gap:0.75rem">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<label>
|
||||
ComfyUI API JSON file
|
||||
<input type="file" name="workflow_json" accept=".json,application/json" required>
|
||||
</label>
|
||||
<div>
|
||||
<button type="submit" class="btn-primary">Upload Workflow</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
<div class="row-between">
|
||||
<h1>Generate a Song</h1>
|
||||
<span class="muted small"><%= count %> / <%= max %> generated songs</span>
|
||||
</div>
|
||||
|
||||
<% if (!canGenerate && count >= max) { %>
|
||||
<div class="gen-limit-notice">
|
||||
You've reached the <strong><%= max %>-song limit</strong>.
|
||||
Delete or publish songs from your history below to make room for new generations.
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="gen-layout">
|
||||
<!-- ── Form ── -->
|
||||
<div class="form-card stacked">
|
||||
<label>
|
||||
Style Prompt
|
||||
<textarea name="style_prompt" id="gen-style" rows="3"
|
||||
placeholder="e.g. Upbeat synthwave with driving bass and arpeggio synths"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Lyrics Prompt
|
||||
<textarea name="lyrics_prompt" id="gen-lyrics" rows="12"
|
||||
placeholder="e.g. A song about late-night drives and neon lights [Verse 1] ..."></textarea>
|
||||
</label>
|
||||
|
||||
<div class="gen-row-fields">
|
||||
<label>
|
||||
BPM
|
||||
<input type="number" id="gen-bpm" min="40" max="300" value="100" placeholder="100">
|
||||
</label>
|
||||
<label>
|
||||
Keyscale
|
||||
<select id="gen-keyscale">
|
||||
<% keyscales.forEach(function(k) { %>
|
||||
<option value="<%= k %>" <%= k === 'E minor' ? 'selected' : '' %>><%= k %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Duration <span class="muted small">(seconds)</span>
|
||||
<input type="number" id="gen-duration" min="10" max="600" value="150" placeholder="150">
|
||||
</label>
|
||||
<label>
|
||||
Seed <span class="muted small">(-1 = random)</span>
|
||||
<input type="number" id="gen-seed" value="-1">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Creativity
|
||||
<div class="gen-slider-wrap">
|
||||
<input type="range" id="gen-creativity" min="0" max="10" step="1" value="5"
|
||||
oninput="document.getElementById('gen-creativity-val').textContent = (3 + this.value * 0.4).toFixed(1)">
|
||||
<span id="gen-creativity-val" class="gen-slider-val">5.0</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="gen-submit-row">
|
||||
<button type="button" id="gen-submit-btn" class="btn-primary"
|
||||
<%= !canGenerate ? 'disabled' : '' %>>
|
||||
Generate
|
||||
</button>
|
||||
<span id="gen-status" class="gen-status-inline" style="display:none">
|
||||
<span class="gen-spinner"></span>
|
||||
<span id="gen-status-text">Generating…</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Rate limit panel ── -->
|
||||
<div class="gen-rate-panel" id="gen-rate-panel">
|
||||
<h3 class="gen-rate-title">Generation Limits</h3>
|
||||
|
||||
<!-- Cooldown -->
|
||||
<div class="gen-rate-item" id="rl-cooldown-item">
|
||||
<div class="gen-rate-label">Cooldown</div>
|
||||
<div class="gen-rate-value" id="rl-cooldown-value">
|
||||
<% if (rateLimit.cooldownSecsRemaining > 0) { %>
|
||||
<span class="gen-rate-warn" id="rl-cooldown-text">—</span>
|
||||
<% } else { %>
|
||||
<span class="gen-rate-ok">Ready</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="gen-rate-sub">1 generation per 3 minutes</div>
|
||||
</div>
|
||||
|
||||
<!-- Hourly -->
|
||||
<div class="gen-rate-item">
|
||||
<div class="gen-rate-label">Hourly Usage</div>
|
||||
<div class="gen-rate-value">
|
||||
<span id="rl-hourly-count"><%= rateLimit.hourlyCount %></span>
|
||||
<span class="gen-rate-sep">/</span>
|
||||
<span><%= rateLimit.hourlyLimit %></span>
|
||||
</div>
|
||||
<div class="gen-rate-bar-wrap">
|
||||
<div class="gen-rate-bar" id="rl-hourly-bar"
|
||||
style="width:<%= Math.min(100, (rateLimit.hourlyCount / rateLimit.hourlyLimit) * 100) %>%"></div>
|
||||
</div>
|
||||
<% if (rateLimit.hourlyResetSecsRemaining > 0) { %>
|
||||
<div class="gen-rate-sub gen-rate-warn" id="rl-hourly-reset-text">—</div>
|
||||
<% } else { %>
|
||||
<div class="gen-rate-sub">Resets on a rolling 1-hour window</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio player (hidden until a generated song is playing) -->
|
||||
<audio id="gen-audio" controls style="display:none;width:100%;margin-bottom:1rem"></audio>
|
||||
|
||||
<!-- ── History ─────────────────────────────────────────────────────────── -->
|
||||
<h2>Your Generated Songs</h2>
|
||||
|
||||
<div id="gen-history-wrap">
|
||||
<% if (genList.length === 0) { %>
|
||||
<p class="muted" id="gen-empty-msg">No generated songs yet. Fill in the form above and hit Generate!</p>
|
||||
<% } else { %>
|
||||
<table class="data-table" id="gen-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<th>Style Prompt</th>
|
||||
<th>BPM</th>
|
||||
<th>Key</th>
|
||||
<th>Status</th>
|
||||
<th class="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% genList.forEach(function(g) { %>
|
||||
<tr id="gen-row-<%= g.id %>" data-gen-id="<%= g.id %>" data-status="<%= g.status %>">
|
||||
<td class="muted small"><%= new Date(g.created_at).toLocaleDateString() %></td>
|
||||
<td class="gen-prompt-cell"><%= (g.style_prompt || '—').slice(0, 60) %><% if ((g.style_prompt || '').length > 60) { %>…<% } %></td>
|
||||
<td><%= g.bpm || '—' %></td>
|
||||
<td><%= g.keyscale || '—' %></td>
|
||||
<td class="gen-status-cell">
|
||||
<% if (g.status === 'done') { %>
|
||||
<span class="badge badge-public">Done</span>
|
||||
<% } else if (g.status === 'failed') { %>
|
||||
<span class="badge badge-private" title="<%= g.error_message || '' %>">Failed</span>
|
||||
<% } else { %>
|
||||
<span class="badge badge-logged-in">Working…</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="actions gen-actions-cell">
|
||||
<% if (g.status === 'done') { %>
|
||||
<button type="button" class="btn-small gen-listen-btn" data-gen-id="<%= g.id %>">▶ Listen</button>
|
||||
<a href="/generate/<%= g.id %>/publish" class="btn-small">Publish</a>
|
||||
<% } %>
|
||||
<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="<%= g.id %>">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
var submitBtn = document.getElementById('gen-submit-btn');
|
||||
var statusEl = document.getElementById('gen-status');
|
||||
var statusTxt = document.getElementById('gen-status-text');
|
||||
var audio = document.getElementById('gen-audio');
|
||||
|
||||
// ── Rate limit state (seeded from server) ──────────────────────────
|
||||
var rlState = {
|
||||
cooldownSecs: <%= rateLimit.cooldownSecsRemaining %>,
|
||||
hourlySecs: <%= rateLimit.hourlyResetSecsRemaining %>,
|
||||
hourlyCount: <%= rateLimit.hourlyCount %>,
|
||||
hourlyLimit: <%= rateLimit.hourlyLimit %>,
|
||||
};
|
||||
|
||||
var cooldownEl = document.getElementById('rl-cooldown-text');
|
||||
var hourlyResetEl= document.getElementById('rl-hourly-reset-text');
|
||||
var hourlyCountEl= document.getElementById('rl-hourly-count');
|
||||
var hourlyBarEl = document.getElementById('rl-hourly-bar');
|
||||
var cooldownItem = document.getElementById('rl-cooldown-item');
|
||||
|
||||
function fmtMmSs(secs) {
|
||||
var m = Math.floor(secs / 60);
|
||||
var s = secs % 60;
|
||||
return m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
function updateRateUI() {
|
||||
var blocked = false;
|
||||
|
||||
// Cooldown display
|
||||
if (rlState.cooldownSecs > 0) {
|
||||
blocked = true;
|
||||
if (cooldownEl) {
|
||||
cooldownEl.textContent = 'Next in ' + fmtMmSs(rlState.cooldownSecs);
|
||||
} else {
|
||||
// Replace "Ready" span with warn span
|
||||
var val = document.getElementById('rl-cooldown-value');
|
||||
if (val) {
|
||||
val.innerHTML = '<span class="gen-rate-warn" id="rl-cooldown-text">Next in ' + fmtMmSs(rlState.cooldownSecs) + '</span>';
|
||||
cooldownEl = document.getElementById('rl-cooldown-text');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (cooldownEl) {
|
||||
var val = document.getElementById('rl-cooldown-value');
|
||||
if (val) {
|
||||
val.innerHTML = '<span class="gen-rate-ok">Ready</span>';
|
||||
cooldownEl = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hourly display
|
||||
if (hourlyCountEl) hourlyCountEl.textContent = rlState.hourlyCount;
|
||||
if (hourlyBarEl) hourlyBarEl.style.width = Math.min(100, (rlState.hourlyCount / rlState.hourlyLimit) * 100) + '%';
|
||||
|
||||
if (rlState.hourlyCount >= rlState.hourlyLimit && rlState.hourlySecs > 0) {
|
||||
blocked = true;
|
||||
if (hourlyResetEl) {
|
||||
hourlyResetEl.textContent = 'Resets in ' + fmtMmSs(rlState.hourlySecs);
|
||||
}
|
||||
} else if (hourlyResetEl && rlState.hourlySecs <= 0) {
|
||||
hourlyResetEl.textContent = 'Resets on a rolling 1-hour window';
|
||||
hourlyResetEl.classList.remove('gen-rate-warn');
|
||||
}
|
||||
|
||||
// Enable/disable button based on rate limits (don't re-enable if generation in progress)
|
||||
if (!generationInProgress) {
|
||||
submitBtn.disabled = blocked;
|
||||
}
|
||||
}
|
||||
|
||||
// Tick down both counters every second
|
||||
var generationInProgress = false;
|
||||
setInterval(function () {
|
||||
if (rlState.cooldownSecs > 0) rlState.cooldownSecs--;
|
||||
if (rlState.hourlySecs > 0) rlState.hourlySecs--;
|
||||
updateRateUI();
|
||||
}, 1000);
|
||||
|
||||
updateRateUI();
|
||||
|
||||
// Refresh rate limit info from server
|
||||
function fetchRateLimit(cb) {
|
||||
fetch('/generate/rate-limit')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
rlState.cooldownSecs = data.cooldownSecsRemaining;
|
||||
rlState.hourlySecs = data.hourlyResetSecsRemaining;
|
||||
rlState.hourlyCount = data.hourlyCount;
|
||||
rlState.hourlyLimit = data.hourlyLimit;
|
||||
updateRateUI();
|
||||
if (cb) cb(data);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// ── Submit ──────────────────────────────────────────────────────────
|
||||
if (submitBtn) {
|
||||
submitBtn.addEventListener('click', function () {
|
||||
if (submitBtn.disabled) return;
|
||||
var params = new URLSearchParams({
|
||||
_csrf: csrfToken,
|
||||
style_prompt: document.getElementById('gen-style').value,
|
||||
lyrics_prompt: document.getElementById('gen-lyrics').value,
|
||||
bpm: document.getElementById('gen-bpm').value,
|
||||
keyscale: document.getElementById('gen-keyscale').value,
|
||||
duration: document.getElementById('gen-duration').value,
|
||||
creativity: document.getElementById('gen-creativity').value,
|
||||
seed: document.getElementById('gen-seed').value,
|
||||
});
|
||||
|
||||
generationInProgress = true;
|
||||
submitBtn.disabled = true;
|
||||
statusEl.style.display = '';
|
||||
statusTxt.textContent = 'Submitting…';
|
||||
|
||||
fetch('/generate/submit', { method: 'POST', body: params })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.ok) {
|
||||
statusTxt.textContent = data.error || 'Error submitting job.';
|
||||
setTimeout(function () { statusEl.style.display = 'none'; }, 5000);
|
||||
generationInProgress = false;
|
||||
fetchRateLimit();
|
||||
return;
|
||||
}
|
||||
statusTxt.textContent = 'Generating… (this may take a minute)';
|
||||
// Start cooldown from now
|
||||
rlState.cooldownSecs = 3 * 60;
|
||||
updateRateUI();
|
||||
addHistoryRow(data.generationId);
|
||||
pollStatus(data.generationId);
|
||||
})
|
||||
.catch(function () {
|
||||
statusTxt.textContent = 'Network error.';
|
||||
setTimeout(function () { statusEl.style.display = 'none'; }, 5000);
|
||||
generationInProgress = false;
|
||||
fetchRateLimit();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────
|
||||
function pollStatus(id) {
|
||||
setTimeout(function () {
|
||||
fetch('/generate/status/' + id)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.status === 'done') {
|
||||
updateRow(id, 'done');
|
||||
statusTxt.textContent = 'Done! Your song is ready.';
|
||||
setTimeout(function () { statusEl.style.display = 'none'; }, 3000);
|
||||
generationInProgress = false;
|
||||
fetchRateLimit();
|
||||
} else if (data.status === 'failed') {
|
||||
updateRow(id, 'failed');
|
||||
statusTxt.textContent = 'Generation failed: ' + (data.error || 'unknown error');
|
||||
setTimeout(function () { statusEl.style.display = 'none'; }, 5000);
|
||||
generationInProgress = false;
|
||||
fetchRateLimit();
|
||||
} else {
|
||||
pollStatus(id);
|
||||
}
|
||||
})
|
||||
.catch(function () { pollStatus(id); });
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ── Add a new pending row to the history table ───────────────────────
|
||||
function addHistoryRow(id) {
|
||||
var emptyMsg = document.getElementById('gen-empty-msg');
|
||||
if (emptyMsg) emptyMsg.remove();
|
||||
|
||||
var wrap = document.getElementById('gen-history-wrap');
|
||||
var table = document.getElementById('gen-history-table');
|
||||
|
||||
if (!table) {
|
||||
table = document.createElement('table');
|
||||
table.className = 'data-table';
|
||||
table.id = 'gen-history-table';
|
||||
table.innerHTML =
|
||||
'<thead><tr>' +
|
||||
'<th>Created</th><th>Style Prompt</th><th>BPM</th>' +
|
||||
'<th>Key</th><th>Status</th><th class="actions">Actions</th>' +
|
||||
'</tr></thead><tbody></tbody>';
|
||||
wrap.appendChild(table);
|
||||
}
|
||||
|
||||
var tbody = table.querySelector('tbody');
|
||||
var tr = document.createElement('tr');
|
||||
tr.id = 'gen-row-' + id;
|
||||
tr.dataset.genId = id;
|
||||
tr.dataset.status = 'processing';
|
||||
tr.innerHTML =
|
||||
'<td class="muted small">' + new Date().toLocaleDateString() + '</td>' +
|
||||
'<td class="gen-prompt-cell">' + escHtml(document.getElementById('gen-style').value.slice(0, 60)) + '</td>' +
|
||||
'<td>' + (document.getElementById('gen-bpm').value || '—') + '</td>' +
|
||||
'<td>' + (document.getElementById('gen-keyscale').value || '—') + '</td>' +
|
||||
'<td class="gen-status-cell"><span class="badge badge-logged-in">Working…</span></td>' +
|
||||
'<td class="actions gen-actions-cell">' +
|
||||
'<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="' + id + '">Delete</button>' +
|
||||
'</td>';
|
||||
tbody.insertBefore(tr, tbody.firstChild);
|
||||
}
|
||||
|
||||
function updateRow(id, status) {
|
||||
var row = document.getElementById('gen-row-' + id);
|
||||
if (!row) return;
|
||||
row.dataset.status = status;
|
||||
var statusCell = row.querySelector('.gen-status-cell');
|
||||
var actionsCell = row.querySelector('.gen-actions-cell');
|
||||
if (status === 'done') {
|
||||
statusCell.innerHTML = '<span class="badge badge-public">Done</span>';
|
||||
actionsCell.innerHTML =
|
||||
'<button type="button" class="btn-small gen-listen-btn" data-gen-id="' + id + '">▶ Listen</button>' +
|
||||
'<a href="/generate/' + id + '/publish" class="btn-small">Publish</a>' +
|
||||
'<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="' + id + '">Delete</button>';
|
||||
} else if (status === 'failed') {
|
||||
statusCell.innerHTML = '<span class="badge badge-private">Failed</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Listen / Delete (delegated) ─────────────────────────────────────
|
||||
document.addEventListener('click', function (e) {
|
||||
var listenBtn = e.target.closest('.gen-listen-btn');
|
||||
if (listenBtn) {
|
||||
var id = listenBtn.dataset.genId;
|
||||
audio.src = '/generate/' + id + '/stream';
|
||||
audio.style.display = '';
|
||||
audio.play().catch(function () {});
|
||||
return;
|
||||
}
|
||||
|
||||
var deleteBtn = e.target.closest('.gen-delete-btn');
|
||||
if (deleteBtn) {
|
||||
if (!confirm('Delete this generated song?')) return;
|
||||
var did = deleteBtn.dataset.genId;
|
||||
var params = new URLSearchParams({ _csrf: csrfToken });
|
||||
fetch('/generate/' + did + '/delete', { method: 'POST', body: params })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.ok) {
|
||||
var row = document.getElementById('gen-row-' + did);
|
||||
if (row) row.remove();
|
||||
var tbody = document.querySelector('#gen-history-table tbody');
|
||||
if (tbody && tbody.children.length === 0) {
|
||||
var tbl = document.getElementById('gen-history-table');
|
||||
var wrap = document.getElementById('gen-history-wrap');
|
||||
if (tbl) tbl.remove();
|
||||
var p = document.createElement('p');
|
||||
p.className = 'muted';
|
||||
p.id = 'gen-empty-msg';
|
||||
p.textContent = 'No generated songs yet. Fill in the form above and hit Generate!';
|
||||
wrap.appendChild(p);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function (err) { console.error(err); });
|
||||
}
|
||||
});
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<h1>Publish Generated Song</h1>
|
||||
<p class="muted small">Fill in the details below. The song will be added to your library and removed from your generated list.</p>
|
||||
|
||||
<% if (row.style_prompt) { %>
|
||||
<div class="preselect-notice" style="margin-bottom:1rem">
|
||||
<strong>Style prompt:</strong> <%= row.style_prompt %>
|
||||
<% if (row.lyrics_prompt) { %><br><strong>Lyrics prompt:</strong> <%= row.lyrics_prompt %><% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<form method="post" action="/generate/<%= row.id %>/publish" class="stacked form-card" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
|
||||
<label>
|
||||
Title <span class="field-required">*</span>
|
||||
<input type="text" name="title" value="<%= values.title || '' %>" required maxlength="200" placeholder="Song title">
|
||||
<% if (errors.title) { %><span class="err"><%= errors.title %></span><% } %>
|
||||
</label>
|
||||
<label>
|
||||
Artist <span class="field-required">*</span>
|
||||
<input type="text" name="artist" value="<%= values.artist || '' %>" required maxlength="200" placeholder="Artist name">
|
||||
<% if (errors.artist) { %><span class="err"><%= errors.artist %></span><% } %>
|
||||
</label>
|
||||
<label>
|
||||
Album
|
||||
<input type="text" name="album" value="<%= values.album || '' %>" maxlength="200">
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<div class="genre-field">
|
||||
<label>
|
||||
Genre
|
||||
<select name="genre" id="genre-select">
|
||||
<option value="">— No genre —</option>
|
||||
<% genres.forEach(function(g) { %>
|
||||
<option value="<%= g %>" <%= (values.genre === g) ? 'selected' : '' %>><%= g %></option>
|
||||
<% }) %>
|
||||
<option value="__new__">+ Add new genre…</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="genre-new-wrap">
|
||||
New genre
|
||||
<input type="text" name="genre_new" id="genre-new-input" maxlength="80" value="">
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Year
|
||||
<input type="number" name="year" min="1" max="9999" value="<%= values.year || new Date().getFullYear() %>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Cover Art <span class="muted small">(JPG, PNG, WebP — optional)</span>
|
||||
<div class="cover-upload-wrap">
|
||||
<input type="file" name="cover" id="cover-input" accept=".jpg,.jpeg,.png,.webp,.gif" class="cover-file-input">
|
||||
<div class="cover-preview-wrap" id="cover-preview-wrap" style="display:none">
|
||||
<img id="cover-preview" src="" alt="Cover preview" class="cover-preview-img">
|
||||
<button type="button" id="cover-clear-btn" class="btn-small danger cover-clear-btn">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<fieldset class="visibility-field">
|
||||
<legend>Visibility</legend>
|
||||
<label class="checkbox">
|
||||
<input type="radio" name="visibility" value="public" <%= values.visibility === 'public' ? 'checked' : '' %>>
|
||||
Public — visible to everyone
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="radio" name="visibility" value="logged_in" <%= values.visibility === 'logged_in' ? 'checked' : '' %>>
|
||||
Logged in only — members only
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="radio" name="visibility" value="private" <%= (!values.visibility || values.visibility === 'private') ? 'checked' : '' %>>
|
||||
Private — only you
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="row">
|
||||
<button type="submit" class="btn-primary">Publish to Library</button>
|
||||
<a href="/generate" class="btn-link">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Genre new-field toggle
|
||||
var sel = document.getElementById('genre-select');
|
||||
var wrap = document.getElementById('genre-new-wrap');
|
||||
if (sel && wrap) {
|
||||
function syncGenre() { wrap.style.display = sel.value === '__new__' ? '' : 'none'; }
|
||||
sel.addEventListener('change', syncGenre);
|
||||
syncGenre();
|
||||
}
|
||||
|
||||
// Cover art preview
|
||||
var coverInput = document.getElementById('cover-input');
|
||||
var previewWrap = document.getElementById('cover-preview-wrap');
|
||||
var previewImg = document.getElementById('cover-preview');
|
||||
var clearBtn = document.getElementById('cover-clear-btn');
|
||||
|
||||
if (coverInput) {
|
||||
coverInput.addEventListener('change', function () {
|
||||
var file = coverInput.files[0];
|
||||
if (!file) return;
|
||||
var url = URL.createObjectURL(file);
|
||||
previewImg.src = url;
|
||||
previewWrap.style.display = '';
|
||||
coverInput.style.display = 'none';
|
||||
});
|
||||
}
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function () {
|
||||
coverInput.value = '';
|
||||
previewImg.src = '';
|
||||
previewWrap.style.display = 'none';
|
||||
coverInput.style.display = '';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -6,9 +6,12 @@
|
|||
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
|
||||
<a href="/profiles" class="<%= currentPath && currentPath.startsWith('/profiles') ? 'active' : '' %>">Profiles</a>
|
||||
<% if (user) { %>
|
||||
<a href="/mymusic/songs" class="<%= currentPath && currentPath.startsWith('/mymusic') && !currentPath.startsWith('/mymusic/users') ? 'active' : '' %>">My Music</a>
|
||||
<a href="/mymusic/songs" class="<%= currentPath && currentPath.startsWith('/mymusic') ? 'active' : '' %>">My Music</a>
|
||||
<% if (generationEnabled && user.emailVerified) { %>
|
||||
<a href="/generate" class="<%= currentPath && currentPath.startsWith('/generate') ? 'active' : '' %>">Generate</a>
|
||||
<% } %>
|
||||
<% if (user.role === 'admin') { %>
|
||||
<a href="/mymusic/users" class="<%= currentPath && currentPath.startsWith('/mymusic/users') ? 'active' : '' %>">Users</a>
|
||||
<a href="/admin" class="<%= currentPath && currentPath.startsWith('/admin') ? 'active' : '' %>">Admin</a>
|
||||
<% } %>
|
||||
<a href="/account" class="<%= currentPath && currentPath.startsWith('/account') ? 'active' : '' %>">Account<% if (unreadNotifCount > 0) { %><span class="notif-badge"><%= unreadNotifCount %></span><% } %></a>
|
||||
<form method="post" action="/logout" class="inline-form">
|
||||
|
|
|
|||
Loading…
Reference in New Issue