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:
parent
4046bc20e0
commit
4602087400
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE site_settings ADD COLUMN generation_cooldown_seconds INTEGER NOT NULL DEFAULT 180;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users ADD COLUMN is_vip INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 [Verse 1] ..."></textarea>
|
placeholder="[inst] for instrumentals -- For lyrics, enter in a structured format: [verse] lyrics go here and here [pre-chorus] blah blah [chorus] 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
|
|
||||||
|
|
@ -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') { %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue