feat: implement VIP access control and configurable generation cooldown

- Add `generation_cooldown_seconds` column to `site_settings` (default 180s)
- Add `is_vip` column to `users` table (default 0/false)
- Introduce new `requireVip` middleware for enforcing VIP-only routes
- Restrict song generation access to VIP users or admins
- Add admin UI endpoints to grant/remove VIP status per user
- Allow admins to configure generation cooldown period via admin panel
- Update generation service to use dynamic cooldown (replacing hardcoded 3 minutes)
- Enhance generate page UI with Re-Gen button, improved placeholder text, and rate-limit state updates
- Update header navigation to show "My Music" and "Generate" only for VIP/admin users
This commit is contained in:
Brian Fertig 2026-04-12 19:26:31 -06:00
parent 4046bc20e0
commit 4602087400
12 changed files with 202 additions and 53 deletions

View File

@ -0,0 +1 @@
ALTER TABLE site_settings ADD COLUMN generation_cooldown_seconds INTEGER NOT NULL DEFAULT 180;

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN is_vip INTEGER NOT NULL DEFAULT 0;

View File

@ -20,4 +20,18 @@ function requireAdmin(req, res, next) {
next(); next();
} }
module.exports = { requireUser, requireAdmin }; function requireVip(req, res, next) {
if (!req.session.user) {
req.session.returnTo = req.originalUrl;
return res.redirect('/login');
}
if (!req.session.user.isVip && req.session.user.role !== 'admin') {
const err = new Error('VIP access required.');
err.status = 403;
err.expose = true;
return next(err);
}
next();
}
module.exports = { requireUser, requireAdmin, requireVip };

View File

@ -67,6 +67,22 @@ module.exports = function adminPanelRoutes(csrfProtection) {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
router.post('/users/:id/vip', csrfProtection, (req, res, next) => {
try {
users.setVip(Number(req.params.id), true);
req.flash('success', 'Account granted VIP access.');
res.redirect('/admin');
} catch (err) { next(err); }
});
router.post('/users/:id/unvip', csrfProtection, (req, res, next) => {
try {
users.setVip(Number(req.params.id), false);
req.flash('success', 'VIP access removed.');
res.redirect('/admin');
} catch (err) { next(err); }
});
router.post('/users/:id/delete', csrfProtection, (req, res, next) => { router.post('/users/:id/delete', csrfProtection, (req, res, next) => {
try { try {
const id = Number(req.params.id); const id = Number(req.params.id);
@ -91,17 +107,19 @@ module.exports = function adminPanelRoutes(csrfProtection) {
router.post('/generation', csrfProtection, (req, res, next) => { router.post('/generation', csrfProtection, (req, res, next) => {
try { try {
const current = settings.get(); const current = settings.get();
const cooldown = parseInt(req.body.generation_cooldown_seconds, 10);
settings.set({ settings.set({
generation_enabled: req.body.generation_enabled === '1' ? 1 : 0, generation_enabled: req.body.generation_enabled === '1' ? 1 : 0,
comfyui_base_url: (req.body.comfyui_base_url || '').trim() || current.comfyui_base_url || null, generation_cooldown_seconds: (!isNaN(cooldown) && cooldown >= 0) ? cooldown : 180,
comfyui_workflow_json: current.comfyui_workflow_json || null, comfyui_base_url: (req.body.comfyui_base_url || '').trim() || current.comfyui_base_url || null,
comfyui_node_style_prompt: (req.body.node_style_prompt || '').trim() || null, comfyui_workflow_json: current.comfyui_workflow_json || null,
comfyui_node_lyrics_prompt: (req.body.node_lyrics_prompt || '').trim() || null, comfyui_node_style_prompt: (req.body.node_style_prompt || '').trim() || null,
comfyui_node_bpm: (req.body.node_bpm || '').trim() || null, comfyui_node_lyrics_prompt: (req.body.node_lyrics_prompt || '').trim() || null,
comfyui_node_keyscale: (req.body.node_keyscale || '').trim() || null, comfyui_node_bpm: (req.body.node_bpm || '').trim() || null,
comfyui_node_duration: (req.body.node_duration || '').trim() || null, comfyui_node_keyscale: (req.body.node_keyscale || '').trim() || null,
comfyui_node_creativity: (req.body.node_creativity || '').trim() || null, comfyui_node_duration: (req.body.node_duration || '').trim() || null,
comfyui_node_seed: (req.body.node_seed || '').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.'); req.flash('success', 'Generation settings saved.');
res.redirect('/admin#generation'); res.redirect('/admin#generation');

View File

@ -5,7 +5,7 @@ const express = require('express');
const multer = require('multer'); const multer = require('multer');
const { z } = require('zod'); const { z } = require('zod');
const { requireUser, requireAdmin } = require('../middleware/auth'); const { requireUser, requireAdmin, requireVip } = require('../middleware/auth');
const songs = require('../services/songs'); const songs = require('../services/songs');
const playlists = require('../services/playlists'); const playlists = require('../services/playlists');
const imageService = require('../services/image'); const imageService = require('../services/image');
@ -177,7 +177,7 @@ function resolveGenre(body) {
module.exports = function adminRoutes(csrfProtection) { module.exports = function adminRoutes(csrfProtection) {
const router = express.Router(); const router = express.Router();
router.use(requireUser); router.use(requireVip);
// ---------- Songs ---------- // ---------- Songs ----------
router.get('/', (req, res) => res.redirect('/mymusic/songs')); router.get('/', (req, res) => res.redirect('/mymusic/songs'));

View File

@ -52,6 +52,10 @@ function requireGeneration(req, res, next) {
const err = new Error('Song generation is not enabled.'); const err = new Error('Song generation is not enabled.');
err.status = 403; err.expose = true; return next(err); 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) { if (!req.session.user.emailVerified) {
const err = new Error('Please verify your email to use song generation.'); const err = new Error('Please verify your email to use song generation.');
err.status = 403; err.expose = true; return next(err); err.status = 403; err.expose = true; return next(err);
@ -70,6 +74,7 @@ module.exports = function generateRoutes(csrfProtection) {
const genList = generation.listByUser(userId); const genList = generation.listByUser(userId);
const count = genList.length; const count = genList.length;
const rl = generation.getRateLimitInfo(userId); const rl = generation.getRateLimitInfo(userId);
const s = settings.get();
res.render('generate/index', { res.render('generate/index', {
title: 'Generate', title: 'Generate',
genList, genList,
@ -78,12 +83,15 @@ module.exports = function generateRoutes(csrfProtection) {
canGenerate: count < generation.MAX_GENERATED && rl.rateLimitOk, canGenerate: count < generation.MAX_GENERATED && rl.rateLimitOk,
keyscales: KEYSCALES, keyscales: KEYSCALES,
rateLimit: rl, rateLimit: rl,
cooldownSeconds: s.generation_cooldown_seconds != null ? s.generation_cooldown_seconds : 180,
}); });
}); });
// ── GET /rate-limit — current rate-limit state (JSON) ───────────────── // ── GET /rate-limit — current rate-limit state (JSON) ─────────────────
router.get('/rate-limit', (req, res) => { router.get('/rate-limit', (req, res) => {
res.json(generation.getRateLimitInfo(req.session.user.id)); 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 ─────────────────────────── // ── POST /submit — kick off a generation job ───────────────────────────

View File

@ -1,16 +1,21 @@
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { db } = require('../../db'); const { db } = require('../../db');
const settings = require('./settings');
const MEDIA_DIR = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media'); const MEDIA_DIR = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
const GEN_DIR = path.join(MEDIA_DIR, 'generated'); const GEN_DIR = path.join(MEDIA_DIR, 'generated');
fs.mkdirSync(GEN_DIR, { recursive: true }); fs.mkdirSync(GEN_DIR, { recursive: true });
const MAX_GENERATED = 20; const MAX_GENERATED = 20;
const COOLDOWN_MS = 3 * 60 * 1000; // 3 minutes between generations const HOURLY_LIMIT = 10;
const HOURLY_LIMIT = 10; // max generations per 1-hour rolling window
const HOURLY_WINDOW_MS = 60 * 60 * 1000; const HOURLY_WINDOW_MS = 60 * 60 * 1000;
function getCooldownMs() {
const secs = settings.get().generation_cooldown_seconds;
return (secs != null ? secs : 180) * 1000;
}
// ── Prepared statements ──────────────────────────────────────────────────── // ── Prepared statements ────────────────────────────────────────────────────
const insertStmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO generated_songs INSERT INTO generated_songs
@ -51,12 +56,13 @@ function getRateLimitInfo(userId) {
const now = Date.now(); const now = Date.now();
// Cooldown // Cooldown
const cooldownMs = getCooldownMs();
let cooldownSecsRemaining = 0; let cooldownSecsRemaining = 0;
const lastRow = lastGenStmt.get(userId); const lastRow = lastGenStmt.get(userId);
if (lastRow) { if (lastRow) {
const elapsed = now - parseSqliteTs(lastRow.created_at); const elapsed = now - parseSqliteTs(lastRow.created_at);
if (elapsed < COOLDOWN_MS) { if (elapsed < cooldownMs) {
cooldownSecsRemaining = Math.ceil((COOLDOWN_MS - elapsed) / 1000); cooldownSecsRemaining = Math.ceil((cooldownMs - elapsed) / 1000);
} }
} }
@ -184,7 +190,7 @@ async function pollComfyUI(baseUrl, promptId, generationId) {
} }
module.exports = { module.exports = {
MAX_GENERATED, COOLDOWN_MS, HOURLY_LIMIT, GEN_DIR, MEDIA_DIR, MAX_GENERATED, HOURLY_LIMIT, GEN_DIR, MEDIA_DIR,
canGenerate, countByUser, listByUser, findById, create, remove, canGenerate, countByUser, listByUser, findById, create, remove,
getRateLimitInfo, getRateLimitInfo,
updateJobStmt, updateDoneStmt, updateFailStmt, updateJobStmt, updateDoneStmt, updateFailStmt,

View File

@ -3,16 +3,17 @@ const { db } = require('../../db');
const getStmt = db.prepare('SELECT * FROM site_settings WHERE id = 1'); const getStmt = db.prepare('SELECT * FROM site_settings WHERE id = 1');
const setStmt = db.prepare(` const setStmt = db.prepare(`
UPDATE site_settings SET UPDATE site_settings SET
generation_enabled = @generation_enabled, generation_enabled = @generation_enabled,
comfyui_base_url = @comfyui_base_url, generation_cooldown_seconds = @generation_cooldown_seconds,
comfyui_workflow_json = @comfyui_workflow_json, comfyui_base_url = @comfyui_base_url,
comfyui_node_style_prompt = @comfyui_node_style_prompt, comfyui_workflow_json = @comfyui_workflow_json,
comfyui_node_lyrics_prompt = @comfyui_node_lyrics_prompt, comfyui_node_style_prompt = @comfyui_node_style_prompt,
comfyui_node_bpm = @comfyui_node_bpm, comfyui_node_lyrics_prompt = @comfyui_node_lyrics_prompt,
comfyui_node_keyscale = @comfyui_node_keyscale, comfyui_node_bpm = @comfyui_node_bpm,
comfyui_node_duration = @comfyui_node_duration, comfyui_node_keyscale = @comfyui_node_keyscale,
comfyui_node_creativity = @comfyui_node_creativity, comfyui_node_duration = @comfyui_node_duration,
comfyui_node_seed = @comfyui_node_seed comfyui_node_creativity = @comfyui_node_creativity,
comfyui_node_seed = @comfyui_node_seed
WHERE id = 1 WHERE id = 1
`); `);

View File

@ -23,6 +23,7 @@ const findVerifTokenStmt = db.prepare(
); );
const listAllUsersStmt = db.prepare('SELECT * FROM users ORDER BY created_at ASC'); const listAllUsersStmt = db.prepare('SELECT * FROM users ORDER BY created_at ASC');
const setDisabledStmt = db.prepare('UPDATE users SET disabled = ? WHERE id = ?'); const setDisabledStmt = db.prepare('UPDATE users SET disabled = ? WHERE id = ?');
const setVipStmt = db.prepare('UPDATE users SET is_vip = ? WHERE id = ?');
const deleteUserStmt = db.prepare('DELETE FROM users WHERE id = ?'); const deleteUserStmt = db.prepare('DELETE FROM users WHERE id = ?');
const songsByUserStmt = db.prepare('SELECT * FROM songs WHERE uploaded_by = ?'); const songsByUserStmt = db.prepare('SELECT * FROM songs WHERE uploaded_by = ?');
const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ?'); const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ?');
@ -80,6 +81,7 @@ function publicView(row) {
displayName: row.display_name, role: row.role, displayName: row.display_name, role: row.role,
emailVerified: row.email_verified === 1, emailVerified: row.email_verified === 1,
disabled: row.disabled === 1, disabled: row.disabled === 1,
isVip: row.is_vip === 1,
avatarPath: row.avatar_path || null, avatarPath: row.avatar_path || null,
slug: row.slug || null, slug: row.slug || null,
songCount: row.song_count != null ? row.song_count : undefined, songCount: row.song_count != null ? row.song_count : undefined,
@ -163,6 +165,10 @@ function setDisabled(id, disabled) {
setDisabledStmt.run(disabled ? 1 : 0, id); setDisabledStmt.run(disabled ? 1 : 0, id);
} }
function setVip(id, vip) {
setVipStmt.run(vip ? 1 : 0, id);
}
function markVerifiedAdmin(id) { function markVerifiedAdmin(id) {
db.transaction(() => { db.transaction(() => {
markVerifiedStmt.run(id); markVerifiedStmt.run(id);
@ -207,6 +213,7 @@ module.exports = {
listAllUsers, listAllUsers,
listVerified, listVerified,
setDisabled, setDisabled,
setVip,
markVerifiedAdmin, markVerifiedAdmin,
deleteUserWithContent, deleteUserWithContent,
updateAvatar, updateAvatar,

View File

@ -8,6 +8,7 @@
<th>Display Name</th> <th>Display Name</th>
<th>Email</th> <th>Email</th>
<th>Role</th> <th>Role</th>
<th>VIP</th>
<th>Verified</th> <th>Verified</th>
<th>Status</th> <th>Status</th>
<th class="actions">Actions</th> <th class="actions">Actions</th>
@ -19,9 +20,21 @@
<td><%= u.displayName %></td> <td><%= u.displayName %></td>
<td class="muted small"><%= u.email %></td> <td class="muted small"><%= u.email %></td>
<td><%= u.role %></td> <td><%= u.role %></td>
<td><%= u.isVip ? '★ VIP' : '—' %></td>
<td><%= u.emailVerified ? '✓' : '✗' %></td> <td><%= u.emailVerified ? '✓' : '✗' %></td>
<td><%= u.disabled ? 'Disabled' : 'Active' %></td> <td><%= u.disabled ? 'Disabled' : 'Active' %></td>
<td class="actions"> <td class="actions">
<% if (u.isVip) { %>
<form method="post" action="/admin/users/<%= u.id %>/unvip" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-small">Remove VIP</button>
</form>
<% } else { %>
<form method="post" action="/admin/users/<%= u.id %>/vip" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-small">Grant VIP</button>
</form>
<% } %>
<% if (!u.emailVerified) { %> <% if (!u.emailVerified) { %>
<form method="post" action="/admin/users/<%= u.id %>/verify" class="inline-form"> <form method="post" action="/admin/users/<%= u.id %>/verify" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>"> <input type="hidden" name="_csrf" value="<%= csrfToken %>">
@ -64,6 +77,13 @@
<span>Enable Song Generation <span class="muted small">(shows "Generate" in the nav for verified users)</span></span> <span>Enable Song Generation <span class="muted small">(shows "Generate" in the nav for verified users)</span></span>
</label> </label>
<label style="max-width:260px">
Cooldown Between Generations <span class="muted small">(seconds)</span>
<input type="number" name="generation_cooldown_seconds" min="0" max="86400"
value="<%= genSettings.generation_cooldown_seconds != null ? genSettings.generation_cooldown_seconds : 180 %>">
<span class="muted small">Default: 180 (3 minutes). Set to 0 to disable cooldown.</span>
</label>
<label> <label>
ComfyUI Base URL ComfyUI Base URL
<input type="text" name="comfyui_base_url" <input type="text" name="comfyui_base_url"

View File

@ -21,7 +21,7 @@
<label> <label>
Lyrics Prompt Lyrics Prompt
<textarea name="lyrics_prompt" id="gen-lyrics" rows="12" <textarea name="lyrics_prompt" id="gen-lyrics" rows="12"
placeholder="e.g. A song about late-night drives and neon lights&#10;&#10;[Verse 1]&#10;..."></textarea> placeholder="[inst] for instrumentals&#10;--&#10;For lyrics, enter in a structured format:&#10;&#10;[verse]&#10;lyrics go here&#10;and here&#10;&#10;[pre-chorus]&#10;blah blah&#10;&#10;[chorus]&#10;This is the chorus"></textarea>
</label> </label>
<div class="gen-row-fields"> <div class="gen-row-fields">
@ -129,7 +129,16 @@
</thead> </thead>
<tbody> <tbody>
<% genList.forEach(function(g) { %> <% genList.forEach(function(g) { %>
<tr id="gen-row-<%= g.id %>" data-gen-id="<%= g.id %>" data-status="<%= g.status %>"> <tr id="gen-row-<%= g.id %>"
data-gen-id="<%= g.id %>"
data-status="<%= g.status %>"
data-style="<%- encodeURIComponent(g.style_prompt || '') %>"
data-lyrics="<%- encodeURIComponent(g.lyrics_prompt || '') %>"
data-bpm="<%= g.bpm || '' %>"
data-keyscale="<%- encodeURIComponent(g.keyscale || '') %>"
data-duration="<%= g.duration_seconds || '' %>"
data-creativity="<%= g.creativity != null ? g.creativity : 5 %>"
data-seed="<%= g.seed != null ? g.seed : -1 %>">
<td class="muted small"><%= new Date(g.created_at).toLocaleDateString() %></td> <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 class="gen-prompt-cell"><%= (g.style_prompt || '—').slice(0, 60) %><% if ((g.style_prompt || '').length > 60) { %>…<% } %></td>
<td><%= g.bpm || '—' %></td> <td><%= g.bpm || '—' %></td>
@ -148,6 +157,7 @@
<button type="button" class="btn-small gen-listen-btn" data-gen-id="<%= g.id %>">▶ Listen</button> <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> <a href="/generate/<%= g.id %>/publish" class="btn-small">Publish</a>
<% } %> <% } %>
<button type="button" class="btn-small gen-regen-btn" data-gen-id="<%= g.id %>">Re-Gen</button>
<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="<%= g.id %>">Delete</button> <button type="button" class="btn-small danger gen-delete-btn" data-gen-id="<%= g.id %>">Delete</button>
</td> </td>
</tr> </tr>
@ -167,10 +177,11 @@
// ── Rate limit state (seeded from server) ────────────────────────── // ── Rate limit state (seeded from server) ──────────────────────────
var rlState = { var rlState = {
cooldownSecs: <%= rateLimit.cooldownSecsRemaining %>, cooldownSecs: <%= rateLimit.cooldownSecsRemaining %>,
hourlySecs: <%= rateLimit.hourlyResetSecsRemaining %>, hourlySecs: <%= rateLimit.hourlyResetSecsRemaining %>,
hourlyCount: <%= rateLimit.hourlyCount %>, hourlyCount: <%= rateLimit.hourlyCount %>,
hourlyLimit: <%= rateLimit.hourlyLimit %>, hourlyLimit: <%= rateLimit.hourlyLimit %>,
cooldownSeconds: <%= cooldownSeconds %>,
}; };
var cooldownEl = document.getElementById('rl-cooldown-text'); var cooldownEl = document.getElementById('rl-cooldown-text');
@ -246,10 +257,11 @@
fetch('/generate/rate-limit') fetch('/generate/rate-limit')
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
rlState.cooldownSecs = data.cooldownSecsRemaining; rlState.cooldownSecs = data.cooldownSecsRemaining;
rlState.hourlySecs = data.hourlyResetSecsRemaining; rlState.hourlySecs = data.hourlyResetSecsRemaining;
rlState.hourlyCount = data.hourlyCount; rlState.hourlyCount = data.hourlyCount;
rlState.hourlyLimit = data.hourlyLimit; rlState.hourlyLimit = data.hourlyLimit;
if (data.cooldownSeconds != null) rlState.cooldownSeconds = data.cooldownSeconds;
updateRateUI(); updateRateUI();
if (cb) cb(data); if (cb) cb(data);
}) })
@ -287,8 +299,8 @@
return; return;
} }
statusTxt.textContent = 'Generating… (this may take a minute)'; statusTxt.textContent = 'Generating… (this may take a minute)';
// Start cooldown from now // Start cooldown from now using the server-configured value
rlState.cooldownSecs = 3 * 60; rlState.cooldownSecs = rlState.cooldownSeconds;
updateRateUI(); updateRateUI();
addHistoryRow(data.generationId); addHistoryRow(data.generationId);
pollStatus(data.generationId); pollStatus(data.generationId);
@ -351,8 +363,15 @@
var tbody = table.querySelector('tbody'); var tbody = table.querySelector('tbody');
var tr = document.createElement('tr'); var tr = document.createElement('tr');
tr.id = 'gen-row-' + id; tr.id = 'gen-row-' + id;
tr.dataset.genId = id; tr.dataset.genId = id;
tr.dataset.status = 'processing'; tr.dataset.status = 'processing';
tr.dataset.style = encodeURIComponent(document.getElementById('gen-style').value);
tr.dataset.lyrics = encodeURIComponent(document.getElementById('gen-lyrics').value);
tr.dataset.bpm = document.getElementById('gen-bpm').value;
tr.dataset.keyscale = encodeURIComponent(document.getElementById('gen-keyscale').value);
tr.dataset.duration = document.getElementById('gen-duration').value;
tr.dataset.creativity = document.getElementById('gen-creativity').value;
tr.dataset.seed = document.getElementById('gen-seed').value;
tr.innerHTML = tr.innerHTML =
'<td class="muted small">' + new Date().toLocaleDateString() + '</td>' + '<td class="muted small">' + new Date().toLocaleDateString() + '</td>' +
'<td class="gen-prompt-cell">' + escHtml(document.getElementById('gen-style').value.slice(0, 60)) + '</td>' + '<td class="gen-prompt-cell">' + escHtml(document.getElementById('gen-style').value.slice(0, 60)) + '</td>' +
@ -360,6 +379,7 @@
'<td>' + (document.getElementById('gen-keyscale').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="gen-status-cell"><span class="badge badge-logged-in">Working…</span></td>' +
'<td class="actions gen-actions-cell">' + '<td class="actions gen-actions-cell">' +
'<button type="button" class="btn-small gen-regen-btn" data-gen-id="' + id + '">Re-Gen</button>' +
'<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="' + id + '">Delete</button>' + '<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="' + id + '">Delete</button>' +
'</td>'; '</td>';
tbody.insertBefore(tr, tbody.firstChild); tbody.insertBefore(tr, tbody.firstChild);
@ -376,14 +396,23 @@
actionsCell.innerHTML = actionsCell.innerHTML =
'<button type="button" class="btn-small gen-listen-btn" data-gen-id="' + id + '">▶ Listen</button>' + '<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>' + '<a href="/generate/' + id + '/publish" class="btn-small">Publish</a>' +
'<button type="button" class="btn-small gen-regen-btn" data-gen-id="' + id + '">Re-Gen</button>' +
'<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="' + id + '">Delete</button>'; '<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="' + id + '">Delete</button>';
} else if (status === 'failed') { } else if (status === 'failed') {
statusCell.innerHTML = '<span class="badge badge-private">Failed</span>'; statusCell.innerHTML = '<span class="badge badge-private">Failed</span>';
actionsCell.innerHTML =
'<button type="button" class="btn-small gen-regen-btn" data-gen-id="' + id + '">Re-Gen</button>' +
'<button type="button" class="btn-small danger gen-delete-btn" data-gen-id="' + id + '">Delete</button>';
} }
} }
// ── Listen / Delete (delegated) ───────────────────────────────────── // ── Listen / Re-Gen / Delete (delegated) ───────────────────────────
document.addEventListener('click', function (e) { // Remove any handler registered by a previous execution of this script
// (SPA navigation re-runs the IIFE each time, which would stack listeners).
if (document._genClickHandler) {
document.removeEventListener('click', document._genClickHandler);
}
document._genClickHandler = function (e) {
var listenBtn = e.target.closest('.gen-listen-btn'); var listenBtn = e.target.closest('.gen-listen-btn');
if (listenBtn) { if (listenBtn) {
var id = listenBtn.dataset.genId; var id = listenBtn.dataset.genId;
@ -393,6 +422,47 @@
return; return;
} }
var regenBtn = e.target.closest('.gen-regen-btn');
if (regenBtn) {
var row = document.getElementById('gen-row-' + regenBtn.dataset.genId);
if (!row) return;
var styleEl = document.getElementById('gen-style');
var lyricsEl = document.getElementById('gen-lyrics');
var bpmEl = document.getElementById('gen-bpm');
var keyscaleEl = document.getElementById('gen-keyscale');
var durationEl = document.getElementById('gen-duration');
var creativityEl = document.getElementById('gen-creativity');
var creativityValEl = document.getElementById('gen-creativity-val');
var seedEl = document.getElementById('gen-seed');
styleEl.value = decodeURIComponent(row.dataset.style || '');
lyricsEl.value = decodeURIComponent(row.dataset.lyrics || '');
bpmEl.value = row.dataset.bpm || '';
durationEl.value = row.dataset.duration || '';
seedEl.value = row.dataset.seed || '-1';
var creativity = row.dataset.creativity != null ? row.dataset.creativity : '5';
creativityEl.value = creativity;
if (creativityValEl) creativityValEl.textContent = (3 + parseFloat(creativity) * 0.4).toFixed(1);
// Set keyscale select
var keyscale = decodeURIComponent(row.dataset.keyscale || '');
if (keyscale) {
for (var i = 0; i < keyscaleEl.options.length; i++) {
if (keyscaleEl.options[i].value === keyscale) {
keyscaleEl.selectedIndex = i;
break;
}
}
}
// Scroll form into view
var formCard = document.querySelector('.gen-layout');
if (formCard) formCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
styleEl.focus();
return;
}
var deleteBtn = e.target.closest('.gen-delete-btn'); var deleteBtn = e.target.closest('.gen-delete-btn');
if (deleteBtn) { if (deleteBtn) {
if (!confirm('Delete this generated song?')) return; if (!confirm('Delete this generated song?')) return;
@ -419,7 +489,8 @@
}) })
.catch(function (err) { console.error(err); }); .catch(function (err) { console.error(err); });
} }
}); };
document.addEventListener('click', document._genClickHandler);
function escHtml(s) { function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');

View File

@ -6,8 +6,10 @@
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a> <a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
<a href="/profiles" class="<%= currentPath && currentPath.startsWith('/profiles') ? 'active' : '' %>">Profiles</a> <a href="/profiles" class="<%= currentPath && currentPath.startsWith('/profiles') ? 'active' : '' %>">Profiles</a>
<% if (user) { %> <% if (user) { %>
<a href="/mymusic/songs" class="<%= currentPath && currentPath.startsWith('/mymusic') ? 'active' : '' %>">My Music</a> <% if (user.isVip || user.role === 'admin') { %>
<% if (generationEnabled && user.emailVerified) { %> <a href="/mymusic/songs" class="<%= currentPath && currentPath.startsWith('/mymusic') ? 'active' : '' %>">My Music</a>
<% } %>
<% if (generationEnabled && user.emailVerified && (user.isVip || user.role === 'admin')) { %>
<a href="/generate" class="<%= currentPath && currentPath.startsWith('/generate') ? 'active' : '' %>">Generate</a> <a href="/generate" class="<%= currentPath && currentPath.startsWith('/generate') ? 'active' : '' %>">Generate</a>
<% } %> <% } %>
<% if (user.role === 'admin') { %> <% if (user.role === 'admin') { %>