Initial commit: ACE-Step UI - Open source music generation interface

This commit is contained in:
fspecii
2026-02-04 03:09:38 +02:00
commit 44f7563014
187 changed files with 99985 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
import { Router, Request, Response } from 'express';
import jwt, { SignOptions } from 'jsonwebtoken';
import { pool } from '../db/pool.js';
import { generateUUID } from '../db/sqlite.js';
import { config } from '../config/index.js';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
const jwtOptions = { expiresIn: config.jwt.expiresIn } as SignOptions;
const router = Router();
interface SetupBody {
username: string;
}
function issueAccessToken(payload: { id: string; username: string }): string {
return jwt.sign(payload, config.jwt.secret, jwtOptions);
}
// Auto-login: Get the default user from database (for local single-user app)
router.get('/auto', async (_req: Request, res: Response) => {
try {
// Get the first user from the database (local app typically has one user)
const result = await pool.query(
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users ORDER BY created_at ASC LIMIT 1'
);
if (result.rows.length === 0) {
// No user exists yet - frontend should show username setup
res.status(404).json({ error: 'No user found' });
return;
}
const user = result.rows[0];
// Generate token for the user
const token = issueAccessToken({
id: user.id,
username: user.username,
});
res.json({
user: {
id: user.id,
username: user.username,
bio: user.bio,
avatar_url: user.avatar_url,
banner_url: user.banner_url,
isAdmin: Boolean(user.is_admin),
createdAt: user.created_at,
},
token,
});
} catch (error) {
console.error('Auto-login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Setup or get user by username (simplified auth for local app)
router.post('/setup', async (req: Request<object, object, SetupBody>, res: Response) => {
try {
const { username } = req.body;
if (!username || typeof username !== 'string') {
res.status(400).json({ error: 'Username is required' });
return;
}
// Sanitize username
const sanitizedUsername = username
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '')
.slice(0, 50);
if (sanitizedUsername.length < 2) {
res.status(400).json({ error: 'Username must be at least 2 characters' });
return;
}
// Check if user exists
const existingUser = await pool.query(
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users WHERE username = ?',
[sanitizedUsername]
);
let user;
if (existingUser.rows.length > 0) {
// User exists, return it
user = existingUser.rows[0];
} else {
// Create new user
const userId = generateUUID();
await pool.query(
`INSERT INTO users (id, username, is_admin, created_at, updated_at)
VALUES (?, ?, 0, datetime('now'), datetime('now'))`,
[userId, sanitizedUsername]
);
const newUser = await pool.query(
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users WHERE id = ?',
[userId]
);
user = newUser.rows[0];
}
// Generate token
const token = issueAccessToken({
id: user.id,
username: user.username,
});
res.status(200).json({
user: {
id: user.id,
username: user.username,
bio: user.bio,
avatar_url: user.avatar_url,
banner_url: user.banner_url,
isAdmin: Boolean(user.is_admin),
createdAt: user.created_at,
},
token,
});
} catch (error) {
console.error('Auth setup error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get current user
router.get('/me', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users WHERE id = ?',
[req.user!.id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
const user = result.rows[0];
res.json({
user: {
id: user.id,
username: user.username,
bio: user.bio,
avatar_url: user.avatar_url,
banner_url: user.banner_url,
isAdmin: Boolean(user.is_admin),
createdAt: user.created_at,
},
});
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update username
router.patch('/username', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { username } = req.body;
if (!username || typeof username !== 'string') {
res.status(400).json({ error: 'Username is required' });
return;
}
// Sanitize username
const sanitizedUsername = username
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '')
.slice(0, 50);
if (sanitizedUsername.length < 2) {
res.status(400).json({ error: 'Username must be at least 2 characters' });
return;
}
// Check if username is taken by another user
const existingUser = await pool.query(
'SELECT id FROM users WHERE username = ? AND id != ?',
[sanitizedUsername, req.user!.id]
);
if (existingUser.rows.length > 0) {
res.status(409).json({ error: 'Username is already taken' });
return;
}
// Update username
await pool.query(
`UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?`,
[sanitizedUsername, req.user!.id]
);
// Get updated user
const result = await pool.query(
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users WHERE id = ?',
[req.user!.id]
);
const user = result.rows[0];
// Issue new token with updated username
const token = issueAccessToken({
id: user.id,
username: user.username,
});
res.json({
user: {
id: user.id,
username: user.username,
bio: user.bio,
avatar_url: user.avatar_url,
banner_url: user.banner_url,
isAdmin: Boolean(user.is_admin),
createdAt: user.created_at,
},
token,
});
} catch (error) {
console.error('Update username error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Logout (no-op for local app, just for API compatibility)
router.post('/logout', async (_req: Request, res: Response) => {
res.json({ success: true });
});
// Refresh token (for API compatibility - just returns current user if token valid)
router.post('/refresh', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users WHERE id = ?',
[req.user!.id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
const user = result.rows[0];
const token = issueAccessToken({
id: user.id,
username: user.username,
});
res.json({
user: {
id: user.id,
username: user.username,
bio: user.bio,
avatar_url: user.avatar_url,
banner_url: user.banner_url,
isAdmin: Boolean(user.is_admin),
createdAt: user.created_at,
},
token,
});
} catch (error) {
console.error('Refresh token error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+148
View File
@@ -0,0 +1,148 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool.js';
import { adminMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
const router = Router();
interface ContactSubmission {
name: string;
email: string;
subject: string;
message: string;
category: 'general' | 'support' | 'business' | 'press' | 'legal';
}
// Public endpoint - submit contact form
router.post('/', async (req: Request, res: Response) => {
try {
const { name, email, subject, message, category } = req.body as ContactSubmission;
// Validate required fields
if (!name || !email || !subject || !message) {
res.status(400).json({ error: 'All fields are required' });
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
res.status(400).json({ error: 'Invalid email address' });
return;
}
// Validate message length
if (message.length > 5000) {
res.status(400).json({ error: 'Message too long (max 5000 characters)' });
return;
}
// Create table if not exists
await pool.query(`
CREATE TABLE IF NOT EXISTS contact_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
message TEXT NOT NULL,
category VARCHAR(50) DEFAULT 'general',
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Insert submission
const result = await pool.query(
`INSERT INTO contact_submissions (name, email, subject, message, category)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at`,
[name, email, subject, message, category || 'general']
);
res.status(201).json({
success: true,
message: 'Your message has been sent. We\'ll get back to you soon!',
id: result.rows[0].id,
});
} catch (error) {
console.error('Contact submission error:', error);
res.status(500).json({ error: 'Failed to send message. Please try again.' });
}
});
// Admin endpoint - get all contact submissions
router.get('/admin', adminMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(`
SELECT id, name, email, subject, message, category, is_read, created_at
FROM contact_submissions
ORDER BY created_at DESC
LIMIT 100
`);
res.json({ submissions: result.rows });
} catch (error) {
console.error('Get contacts error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Admin endpoint - mark as read/unread
router.patch('/admin/:id/read', adminMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const { isRead } = req.body;
const result = await pool.query(
`UPDATE contact_submissions SET is_read = $1 WHERE id = $2 RETURNING is_read`,
[isRead, id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Submission not found' });
return;
}
res.json({ success: true, isRead: result.rows[0].is_read });
} catch (error) {
console.error('Update contact error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Admin endpoint - delete submission
router.delete('/admin/:id', adminMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const result = await pool.query(
`DELETE FROM contact_submissions WHERE id = $1 RETURNING id`,
[id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Submission not found' });
return;
}
res.json({ success: true });
} catch (error) {
console.error('Delete contact error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Admin endpoint - get unread count
router.get('/admin/unread-count', adminMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(`
SELECT COUNT(*) as count FROM contact_submissions WHERE is_read = FALSE
`);
res.json({ count: parseInt(result.rows[0].count, 10) });
} catch (error) {
console.error('Get unread count error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+627
View File
@@ -0,0 +1,627 @@
import { Router, Response } from 'express';
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import { pool } from '../db/pool.js';
import { generateUUID } from '../db/sqlite.js';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import {
generateMusicViaAPI,
getJobStatus,
getAudioStream,
discoverEndpoints,
checkSpaceHealth,
cleanupJob,
getJobRawResponse,
downloadAudioToBuffer,
} from '../services/acestep.js';
import { getStorageProvider } from '../services/storage/factory.js';
const router = Router();
const audioUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max
fileFilter: (_req, file, cb) => {
const allowedTypes = [
'audio/mpeg',
'audio/wav',
'audio/x-wav',
'audio/flac',
'audio/x-flac',
'audio/mp4',
'audio/aac',
'audio/ogg',
'audio/webm',
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only common audio formats are allowed.'));
}
}
});
interface GenerateBody {
// Mode
customMode: boolean;
// Simple Mode
songDescription?: string;
// Custom Mode
lyrics: string;
style: string;
title: string;
// Common
instrumental: boolean;
vocalLanguage?: string;
// Music Parameters
duration?: number;
bpm?: number;
keyScale?: string;
timeSignature?: string;
// Generation Settings
inferenceSteps?: number;
guidanceScale?: number;
batchSize?: number;
randomSeed?: boolean;
seed?: number;
thinking?: boolean;
audioFormat?: 'mp3' | 'flac';
inferMethod?: 'ode' | 'sde';
shift?: number;
// LM Parameters
lmTemperature?: number;
lmCfgScale?: number;
lmTopK?: number;
lmTopP?: number;
lmNegativePrompt?: string;
// Expert Parameters
referenceAudioUrl?: string;
sourceAudioUrl?: string;
audioCodes?: string;
repaintingStart?: number;
repaintingEnd?: number;
instruction?: string;
audioCoverStrength?: number;
taskType?: string;
useAdg?: boolean;
cfgIntervalStart?: number;
cfgIntervalEnd?: number;
customTimesteps?: string;
useCotMetas?: boolean;
useCotCaption?: boolean;
useCotLanguage?: boolean;
autogen?: boolean;
constrainedDecodingDebug?: boolean;
allowLmBatch?: boolean;
getScores?: boolean;
getLrc?: boolean;
scoreScale?: number;
lmBatchChunkSize?: number;
trackName?: string;
completeTrackClasses?: string[];
isFormatCaption?: boolean;
}
router.post('/upload-audio', authMiddleware, audioUpload.single('audio'), async (req: AuthenticatedRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'Audio file is required' });
return;
}
const storage = getStorageProvider();
const extFromName = path.extname(req.file.originalname || '').toLowerCase();
const extFromType = (() => {
switch (req.file.mimetype) {
case 'audio/mpeg':
return '.mp3';
case 'audio/wav':
case 'audio/x-wav':
return '.wav';
case 'audio/flac':
case 'audio/x-flac':
return '.flac';
case 'audio/ogg':
return '.ogg';
case 'audio/mp4':
case 'audio/aac':
return '.m4a';
case 'audio/webm':
return '.webm';
default:
return '';
}
})();
const ext = extFromName || extFromType || '.audio';
const key = `references/${req.user!.id}/${Date.now()}-${generateUUID()}${ext}`;
const storedKey = await storage.upload(key, req.file.buffer, req.file.mimetype);
const publicUrl = storage.getPublicUrl(storedKey);
res.json({ url: publicUrl, key: storedKey });
} catch (error) {
console.error('Upload reference audio error:', error);
res.status(500).json({ error: 'Failed to upload audio' });
}
});
router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
customMode,
songDescription,
lyrics,
style,
title,
instrumental,
vocalLanguage,
duration,
bpm,
keyScale,
timeSignature,
inferenceSteps,
guidanceScale,
batchSize,
randomSeed,
seed,
thinking,
audioFormat,
inferMethod,
shift,
lmTemperature,
lmCfgScale,
lmTopK,
lmTopP,
lmNegativePrompt,
referenceAudioUrl,
sourceAudioUrl,
audioCodes,
repaintingStart,
repaintingEnd,
instruction,
audioCoverStrength,
taskType,
useAdg,
cfgIntervalStart,
cfgIntervalEnd,
customTimesteps,
useCotMetas,
useCotCaption,
useCotLanguage,
autogen,
constrainedDecodingDebug,
allowLmBatch,
getScores,
getLrc,
scoreScale,
lmBatchChunkSize,
trackName,
completeTrackClasses,
isFormatCaption,
} = req.body as GenerateBody;
if (!customMode && !songDescription) {
res.status(400).json({ error: 'Song description required for simple mode' });
return;
}
if (customMode && !style && !lyrics) {
res.status(400).json({ error: 'Style or lyrics required for custom mode' });
return;
}
const params = {
customMode,
songDescription,
lyrics,
style,
title,
instrumental,
vocalLanguage,
duration,
bpm,
keyScale,
timeSignature,
inferenceSteps,
guidanceScale,
batchSize,
randomSeed,
seed,
thinking,
audioFormat,
inferMethod,
shift,
lmTemperature,
lmCfgScale,
lmTopK,
lmTopP,
lmNegativePrompt,
referenceAudioUrl,
sourceAudioUrl,
audioCodes,
repaintingStart,
repaintingEnd,
instruction,
audioCoverStrength,
taskType,
useAdg,
cfgIntervalStart,
cfgIntervalEnd,
customTimesteps,
useCotMetas,
useCotCaption,
useCotLanguage,
autogen,
constrainedDecodingDebug,
allowLmBatch,
getScores,
getLrc,
scoreScale,
lmBatchChunkSize,
trackName,
completeTrackClasses,
isFormatCaption,
};
// Create job record in database
const localJobId = generateUUID();
await pool.query(
`INSERT INTO generation_jobs (id, user_id, status, params, created_at, updated_at)
VALUES (?, ?, 'queued', ?, datetime('now'), datetime('now'))`,
[localJobId, req.user!.id, JSON.stringify(params)]
);
// Start generation
const { jobId: hfJobId } = await generateMusicViaAPI(params);
// Update job with ACE-Step task ID
await pool.query(
`UPDATE generation_jobs SET acestep_task_id = ?, status = 'running', updated_at = datetime('now') WHERE id = ?`,
[hfJobId, localJobId]
);
res.json({
jobId: localJobId,
status: 'queued',
queuePosition: 1,
});
} catch (error) {
console.error('Generate error:', error);
res.status(500).json({ error: (error as Error).message || 'Generation failed' });
}
});
router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const jobResult = await pool.query(
`SELECT id, user_id, acestep_task_id, status, params, result, error, created_at
FROM generation_jobs
WHERE id = ?`,
[req.params.jobId]
);
if (jobResult.rows.length === 0) {
res.status(404).json({ error: 'Job not found' });
return;
}
const job = jobResult.rows[0];
if (job.user_id !== req.user!.id) {
res.status(403).json({ error: 'Access denied' });
return;
}
// If job is still running, check ACE-Step status
if (['pending', 'queued', 'running'].includes(job.status) && job.acestep_task_id) {
try {
const aceStatus = await getJobStatus(job.acestep_task_id);
if (aceStatus.status !== job.status) {
let updateQuery = `UPDATE generation_jobs SET status = ?, updated_at = datetime('now')`;
const updateParams: unknown[] = [aceStatus.status];
if (aceStatus.status === 'succeeded' && aceStatus.result) {
updateQuery += `, result = ?`;
updateParams.push(JSON.stringify(aceStatus.result));
} else if (aceStatus.status === 'failed' && aceStatus.error) {
updateQuery += `, error = ?`;
updateParams.push(aceStatus.error);
}
updateQuery += ` WHERE id = ?`;
updateParams.push(req.params.jobId);
await pool.query(updateQuery, updateParams);
// If succeeded, create song records
if (aceStatus.status === 'succeeded' && aceStatus.result) {
const params = typeof job.params === 'string' ? JSON.parse(job.params) : job.params;
const audioUrls = aceStatus.result.audioUrls.filter((url: string) =>
url.endsWith('.mp3') || url.endsWith('.flac')
);
const localPaths: string[] = [];
const storage = getStorageProvider();
for (let i = 0; i < audioUrls.length; i++) {
const audioUrl = audioUrls[i];
const variationSuffix = audioUrls.length > 1 ? ` (v${i + 1})` : '';
const songTitle = (params.title || 'Untitled') + variationSuffix;
const songId = generateUUID();
try {
const { buffer } = await downloadAudioToBuffer(audioUrl);
const ext = audioUrl.includes('.flac') ? '.flac' : '.mp3';
const storageKey = `${req.user!.id}/${songId}${ext}`;
const storedPath = await storage.upload(storageKey, buffer, `audio/${ext.slice(1)}`);
await pool.query(
`INSERT INTO songs (id, user_id, title, lyrics, style, caption, audio_url,
duration, bpm, key_scale, time_signature, tags, is_public, generation_params,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, datetime('now'), datetime('now'))`,
[
songId,
req.user!.id,
songTitle,
params.instrumental ? '[Instrumental]' : params.lyrics,
params.style,
params.style,
storedPath,
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120),
aceStatus.result.bpm || params.bpm,
aceStatus.result.keyScale || params.keyScale,
aceStatus.result.timeSignature || params.timeSignature,
JSON.stringify([]),
JSON.stringify(params),
]
);
localPaths.push(storedPath);
} catch (downloadError) {
console.error(`Failed to download audio ${i + 1}:`, downloadError);
// Still create song record with remote URL
await pool.query(
`INSERT INTO songs (id, user_id, title, lyrics, style, caption, audio_url,
duration, bpm, key_scale, time_signature, tags, is_public, generation_params,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, datetime('now'), datetime('now'))`,
[
songId,
req.user!.id,
songTitle,
params.instrumental ? '[Instrumental]' : params.lyrics,
params.style,
params.style,
audioUrl,
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120),
aceStatus.result.bpm || params.bpm,
aceStatus.result.keyScale || params.keyScale,
aceStatus.result.timeSignature || params.timeSignature,
JSON.stringify([]),
JSON.stringify(params),
]
);
localPaths.push(audioUrl);
}
}
aceStatus.result.audioUrls = localPaths;
cleanupJob(job.acestep_task_id);
}
}
res.json({
jobId: req.params.jobId,
status: aceStatus.status,
queuePosition: aceStatus.queuePosition,
etaSeconds: aceStatus.etaSeconds,
result: aceStatus.result,
error: aceStatus.error,
});
return;
} catch (aceError) {
console.error('ACE-Step status check error:', aceError);
}
}
// Return stored status
res.json({
jobId: req.params.jobId,
status: job.status,
result: job.result && typeof job.result === 'string' ? JSON.parse(job.result) : job.result,
error: job.error,
});
} catch (error) {
console.error('Status check error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Audio proxy endpoint
router.get('/audio', async (req, res: Response) => {
try {
const audioPath = req.query.path as string;
if (!audioPath) {
res.status(400).json({ error: 'Path required' });
return;
}
const audioResponse = await getAudioStream(audioPath);
if (!audioResponse.ok) {
res.status(audioResponse.status).json({ error: 'Failed to fetch audio' });
return;
}
const contentType = audioResponse.headers.get('content-type');
if (contentType) {
res.setHeader('Content-Type', contentType);
}
const contentLength = audioResponse.headers.get('content-length');
if (contentLength) {
res.setHeader('Content-Length', contentLength);
}
const reader = audioResponse.body?.getReader();
if (!reader) {
res.status(500).json({ error: 'Failed to read audio stream' });
return;
}
const pump = async (): Promise<void> => {
const { done, value } = await reader.read();
if (done) {
res.end();
return;
}
res.write(value);
return pump();
};
await pump();
} catch (error) {
console.error('Audio proxy error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/history', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`SELECT id, acestep_task_id, status, params, result, error, created_at
FROM generation_jobs
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 50`,
[req.user!.id]
);
res.json({ jobs: result.rows });
} catch (error) {
console.error('Get history error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/endpoints', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
const endpoints = await discoverEndpoints();
res.json({ endpoints });
} catch (error) {
console.error('Discover endpoints error:', error);
res.status(500).json({ error: 'Failed to discover endpoints' });
}
});
router.get('/health', async (_req, res: Response) => {
try {
const healthy = await checkSpaceHealth();
res.json({ healthy });
} catch (error) {
res.json({ healthy: false, error: (error as Error).message });
}
});
router.get('/debug/:taskId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const rawResponse = getJobRawResponse(req.params.taskId);
if (!rawResponse) {
res.status(404).json({ error: 'Job not found or no raw response available' });
return;
}
res.json({ rawResponse });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Format endpoint - uses LLM to enhance style/lyrics
router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { caption, lyrics, bpm, duration, keyScale, timeSignature, temperature, topK, topP } = req.body;
if (!caption) {
res.status(400).json({ error: 'Caption/style is required' });
return;
}
const { spawn } = await import('child_process');
const ACESTEP_DIR = process.env.ACESTEP_PATH || '/home/ambsd/Desktop/aceui/ACE-Step-1.5';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
const FORMAT_SCRIPT = path.join(SCRIPTS_DIR, 'format_sample.py');
const pythonPath = path.join(ACESTEP_DIR, '.venv', 'bin', 'python');
const args = [
FORMAT_SCRIPT,
'--caption', caption,
'--json',
];
if (lyrics) args.push('--lyrics', lyrics);
if (bpm && bpm > 0) args.push('--bpm', String(bpm));
if (duration && duration > 0) args.push('--duration', String(duration));
if (keyScale) args.push('--key-scale', keyScale);
if (timeSignature) args.push('--time-signature', timeSignature);
if (temperature !== undefined) args.push('--temperature', String(temperature));
if (topK && topK > 0) args.push('--top-k', String(topK));
if (topP !== undefined) args.push('--top-p', String(topP));
const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => {
const proc = spawn(pythonPath, args, {
cwd: ACESTEP_DIR,
env: {
...process.env,
CUDA_VISIBLE_DEVICES: '0',
ACESTEP_PATH: ACESTEP_DIR,
},
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0 && stdout) {
try {
const parsed = JSON.parse(stdout);
resolve({ success: true, data: parsed });
} catch {
resolve({ success: false, error: 'Failed to parse format result' });
}
} else {
resolve({ success: false, error: stderr || 'Format failed' });
}
});
proc.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
if (result.success && result.data) {
res.json(result.data);
} else {
res.status(500).json({ success: false, error: result.error });
}
} catch (error) {
console.error('Format error:', error);
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
+257
View File
@@ -0,0 +1,257 @@
import { Router, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { pool } from '../db/pool.js';
import { authMiddleware, optionalAuthMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import { getStorageProvider } from '../services/storage/factory.js';
const router = Router();
async function resolveAccessibleAudioUrl(audioUrl: string | null, isPublic: boolean): Promise<string | null> {
if (!audioUrl) return null;
if (audioUrl.startsWith('s3://')) {
const storageKey = audioUrl.replace('s3://', '');
const storage = getStorageProvider();
return isPublic ? storage.getPublicUrl(storageKey) : storage.getUrl(storageKey, 3600);
}
return audioUrl;
}
// Create playlist
router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { name, description, isPublic, coverUrl } = req.body;
if (!name) {
res.status(400).json({ error: 'Name is required' });
return;
}
const result = await pool.query(
`INSERT INTO playlists (user_id, name, description, is_public, cover_url)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[req.user!.id, name, description, isPublic || false, coverUrl]
);
res.status(201).json({ playlist: result.rows[0] });
} catch (error) {
console.error('Create playlist error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get my playlists
router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`SELECT p.*, COUNT(ps.song_id) as song_count
FROM playlists p
LEFT JOIN playlist_songs ps ON p.id = ps.playlist_id
WHERE p.user_id = $1
GROUP BY p.id
ORDER BY p.created_at DESC`,
[req.user!.id]
);
res.json({ playlists: result.rows });
} catch (error) {
console.error('Get playlists error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get featured public playlists (for search/explore page)
router.get('/public/featured', async (_req, res: Response) => {
try {
// First try to get playlists with songs
let result = await pool.query(
`SELECT p.id, p.name, p.description, p.cover_url, p.created_at,
u.username as creator, u.avatar_url as creator_avatar,
COUNT(ps.song_id) as song_count
FROM playlists p
JOIN users u ON p.user_id = u.id
LEFT JOIN playlist_songs ps ON p.id = ps.playlist_id
WHERE p.is_public = true
GROUP BY p.id, u.username, u.avatar_url
HAVING COUNT(ps.song_id) > 0
ORDER BY COUNT(ps.song_id) DESC
LIMIT 20`
);
// Fallback: if no playlists with songs, get any public playlists
if (result.rows.length === 0) {
result = await pool.query(
`SELECT p.id, p.name, p.description, p.cover_url, p.created_at,
u.username as creator, u.avatar_url as creator_avatar,
0 as song_count
FROM playlists p
JOIN users u ON p.user_id = u.id
WHERE p.is_public = true
ORDER BY p.created_at DESC
LIMIT 20`
);
}
res.json({ playlists: result.rows });
} catch (error) {
console.error('Get featured playlists error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get playlist by ID
router.get('/:id', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const playlistResult = await pool.query(
`SELECT p.*, u.username as creator, u.avatar_url as creator_avatar
FROM playlists p
JOIN users u ON p.user_id = u.id
WHERE p.id = $1`,
[req.params.id]
);
if (playlistResult.rows.length === 0) {
res.status(404).json({ error: 'Playlist not found' });
return;
}
const playlist = playlistResult.rows[0];
// Access control
if (!playlist.is_public && (!req.user || req.user.id !== playlist.user_id)) {
res.status(403).json({ error: 'Access denied' });
return;
}
// Get songs
const songsResult = await pool.query(
`SELECT s.id, s.title, s.lyrics, s.style, s.cover_url, s.audio_url, s.duration,
s.user_id, s.is_public, u.username as creator, ps.added_at, ps.position
FROM playlist_songs ps
JOIN songs s ON ps.song_id = s.id
JOIN users u ON s.user_id = u.id
WHERE ps.playlist_id = $1
ORDER BY ps.position ASC`,
[req.params.id]
);
const songs = await Promise.all(
songsResult.rows.map(async (row) => ({
...row,
audio_url: await resolveAccessibleAudioUrl(row.audio_url, row.is_public),
}))
);
res.json({
playlist,
songs
});
} catch (error) {
console.error('Get playlist details error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Add song to playlist
router.post('/:id/songs', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { songId } = req.body;
// Verify playlist ownership
const playlistCheck = await pool.query('SELECT user_id FROM playlists WHERE id = $1', [req.params.id]);
if (playlistCheck.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
if (playlistCheck.rows[0].user_id !== req.user!.id) return res.status(403).json({ error: 'Access denied' });
// Get max position
const positionResult = await pool.query(
'SELECT MAX(position) as max_pos FROM playlist_songs WHERE playlist_id = $1',
[req.params.id]
);
const position = (positionResult.rows[0].max_pos || 0) + 1;
await pool.query(
`INSERT INTO playlist_songs (playlist_id, song_id, position)
VALUES ($1, $2, $3)
ON CONFLICT (playlist_id, song_id) DO NOTHING`,
[req.params.id, songId, position]
);
res.json({ success: true });
} catch (error) {
console.error('Add song to playlist error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Remove song from playlist
router.delete('/:id/songs/:songId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
// Verify playlist ownership
const playlistCheck = await pool.query('SELECT user_id FROM playlists WHERE id = $1', [req.params.id]);
if (playlistCheck.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
if (playlistCheck.rows[0].user_id !== req.user!.id) return res.status(403).json({ error: 'Access denied' });
await pool.query(
'DELETE FROM playlist_songs WHERE playlist_id = $1 AND song_id = $2',
[req.params.id, req.params.songId]
);
res.json({ success: true });
} catch (error) {
console.error('Remove song from playlist error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update playlist
router.patch('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
// Verify playlist ownership
const playlistCheck = await pool.query('SELECT user_id FROM playlists WHERE id = $1', [req.params.id]);
if (playlistCheck.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
if (playlistCheck.rows[0].user_id !== req.user!.id) return res.status(403).json({ error: 'Access denied' });
const { name, description, isPublic, coverUrl } = req.body;
const updates: string[] = [];
const values: unknown[] = [];
let paramCount = 1;
if (name !== undefined) { updates.push(`name = $${paramCount}`); values.push(name); paramCount++; }
if (description !== undefined) { updates.push(`description = $${paramCount}`); values.push(description); paramCount++; }
if (isPublic !== undefined) { updates.push(`is_public = $${paramCount}`); values.push(isPublic); paramCount++; }
if (coverUrl !== undefined) { updates.push(`cover_url = $${paramCount}`); values.push(coverUrl); paramCount++; }
if (updates.length > 0) {
updates.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(req.params.id);
await pool.query(
`UPDATE playlists SET ${updates.join(', ')} WHERE id = $${paramCount}`,
values
);
}
const updated = await pool.query('SELECT * FROM playlists WHERE id = $1', [req.params.id]);
res.json({ playlist: updated.rows[0] });
} catch (error) {
console.error('Update playlist error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete playlist
router.delete('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
// Verify playlist ownership
const playlistCheck = await pool.query('SELECT user_id FROM playlists WHERE id = $1', [req.params.id]);
if (playlistCheck.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
if (playlistCheck.rows[0].user_id !== req.user!.id) return res.status(403).json({ error: 'Access denied' });
await pool.query('DELETE FROM playlists WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (error) {
console.error('Delete playlist error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+180
View File
@@ -0,0 +1,180 @@
import { Router, Response } from 'express';
import multer from 'multer';
import path from 'path';
import { pool } from '../db/pool.js';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import { getStorageProvider } from '../services/storage/factory.js';
const router = Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB max
fileFilter: (_req, file, cb) => {
const allowedTypes = ['audio/mpeg', 'audio/wav', 'audio/flac', 'audio/mp3', 'audio/x-wav', 'audio/x-flac'];
if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|flac)$/i)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only MP3, WAV, and FLAC are allowed.'));
}
}
});
// Get user's reference tracks
router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`SELECT id, filename, storage_key, duration, file_size_bytes, tags, created_at
FROM reference_tracks
WHERE user_id = $1
ORDER BY created_at DESC`,
[req.user!.id]
);
const storage = getStorageProvider();
const tracks = result.rows.map(row => ({
...row,
audio_url: storage.getPublicUrl(row.storage_key)
}));
res.json({ tracks });
} catch (error) {
console.error('Get reference tracks error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Upload a new reference track
router.post('/', authMiddleware, upload.single('audio'), async (req: AuthenticatedRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'No file uploaded' });
return;
}
const userId = req.user!.id;
const originalFilename = req.file.originalname;
const ext = path.extname(originalFilename) || '.mp3';
const timestamp = Date.now();
const key = `reference-tracks/${userId}/${timestamp}${ext}`;
const storage = getStorageProvider();
await storage.upload(key, req.file.buffer, req.file.mimetype);
const audioUrl = storage.getPublicUrl(key);
// Parse tags from request body if provided
const tags = req.body.tags ? JSON.parse(req.body.tags) : null;
const result = await pool.query(
`INSERT INTO reference_tracks (user_id, filename, storage_key, file_size_bytes, tags)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[userId, originalFilename, key, req.file.size, tags]
);
res.status(201).json({
track: {
...result.rows[0],
audio_url: audioUrl
}
});
} catch (error) {
console.error('Upload reference track error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: 'Failed to upload reference track', details: errorMessage });
}
});
// Update reference track (duration, tags)
router.patch('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
// Verify ownership
const check = await pool.query(
'SELECT user_id FROM reference_tracks WHERE id = $1',
[req.params.id]
);
if (check.rows.length === 0) {
res.status(404).json({ error: 'Track not found' });
return;
}
if (check.rows[0].user_id !== req.user!.id) {
res.status(403).json({ error: 'Access denied' });
return;
}
const { duration, tags } = req.body;
const updates: string[] = [];
const values: unknown[] = [];
let paramCount = 1;
if (duration !== undefined) {
updates.push(`duration = $${paramCount}`);
values.push(duration);
paramCount++;
}
if (tags !== undefined) {
updates.push(`tags = $${paramCount}`);
values.push(tags);
paramCount++;
}
if (updates.length === 0) {
res.status(400).json({ error: 'No fields to update' });
return;
}
values.push(req.params.id);
const result = await pool.query(
`UPDATE reference_tracks SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
values
);
const storage = getStorageProvider();
res.json({
track: {
...result.rows[0],
audio_url: storage.getPublicUrl(result.rows[0].storage_key)
}
});
} catch (error) {
console.error('Update reference track error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete a reference track
router.delete('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
// Verify ownership
const check = await pool.query(
'SELECT user_id, storage_key FROM reference_tracks WHERE id = $1',
[req.params.id]
);
if (check.rows.length === 0) {
res.status(404).json({ error: 'Track not found' });
return;
}
if (check.rows[0].user_id !== req.user!.id) {
res.status(403).json({ error: 'Access denied' });
return;
}
// Delete from storage
const storage = getStorageProvider();
try {
await storage.delete(check.rows[0].storage_key);
} catch (storageError) {
console.error('Failed to delete from storage:', storageError);
}
// Delete from database
await pool.query('DELETE FROM reference_tracks WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (error) {
console.error('Delete reference track error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+658
View File
@@ -0,0 +1,658 @@
import { Router, Response } from 'express';
import { Readable } from 'node:stream';
import { v4 as uuidv4 } from 'uuid';
import { pool } from '../db/pool.js';
import { authMiddleware, optionalAuthMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import { getStorageProvider } from '../services/storage/factory.js';
const router = Router();
// Helper: resolve audio URL (generates signed URL for S3)
async function resolveAudioUrl(audioUrl: string | null): Promise<string | null> {
if (!audioUrl) return null;
if (audioUrl.startsWith('s3://')) {
const storageKey = audioUrl.replace('s3://', '');
const storage = getStorageProvider();
return storage.getUrl(storageKey, 3600); // 1 hour expiry
}
return audioUrl;
}
// Helper: resolve audio URL for direct playback
async function resolveAccessibleAudioUrl(audioUrl: string | null, isPublic: boolean): Promise<string | null> {
if (!audioUrl) return null;
if (audioUrl.startsWith('s3://')) {
const storageKey = audioUrl.replace('s3://', '');
const storage = getStorageProvider();
return isPublic ? storage.getPublicUrl(storageKey) : storage.getUrl(storageKey, 3600);
}
return audioUrl;
}
// Get audio - proxies from S3 to avoid CORS issues
router.get('/:id/audio', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`SELECT s.audio_url, s.is_public, s.user_id FROM songs s WHERE s.id = $1`,
[req.params.id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Song not found' });
return;
}
const song = result.rows[0];
if (!song.is_public && (!req.user || req.user.id !== song.user_id)) {
res.status(403).json({ error: 'Access denied' });
return;
}
const audioUrl = await resolveAudioUrl(song.audio_url);
if (!audioUrl) {
res.status(404).json({ error: 'Audio not available' });
return;
}
// Local files - redirect
if (audioUrl.startsWith('/')) {
res.redirect(audioUrl);
return;
}
// S3/remote - proxy to avoid CORS
const range = req.headers.range;
const audioRes = await fetch(audioUrl, {
headers: range ? { Range: range } : undefined,
});
if (!audioRes.ok && audioRes.status !== 206) {
res.status(502).json({ error: 'Failed to fetch audio' });
return;
}
const contentType = audioRes.headers.get('content-type') || 'audio/mpeg';
res.setHeader('Content-Type', contentType);
res.setHeader('Accept-Ranges', 'bytes');
const contentLength = audioRes.headers.get('content-length');
if (contentLength) {
res.setHeader('Content-Length', contentLength);
}
const contentRange = audioRes.headers.get('content-range');
if (contentRange) {
res.status(206);
res.setHeader('Content-Range', contentRange);
}
if (audioRes.body) {
Readable.fromWeb(audioRes.body as any).pipe(res);
return;
}
const arrayBuffer = await audioRes.arrayBuffer();
res.send(Buffer.from(arrayBuffer));
} catch (error) {
console.error('Get audio error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get user's songs
router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public,
s.like_count, s.view_count, s.user_id, s.created_at,
COALESCE(u.username, 'Anonymous') as creator
FROM songs s
LEFT JOIN users u ON s.user_id = u.id
WHERE s.user_id = $1
ORDER BY s.created_at DESC`,
[req.user!.id]
);
const songs = await Promise.all(
result.rows.map(async (row) => ({
...row,
audio_url: await resolveAccessibleAudioUrl(row.audio_url, row.is_public),
}))
);
res.json({ songs });
} catch (error) {
console.error('Get songs error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get featured songs (random songs for discover page)
router.get('/public/featured', optionalAuthMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
try {
// Return random songs - for local app, show all songs randomly
const result = await pool.query(
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.like_count, s.view_count, s.created_at, s.user_id,
COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar
FROM songs s
LEFT JOIN users u ON s.user_id = u.id
ORDER BY RANDOM()
LIMIT 20`
);
const songs = await Promise.all(
result.rows.map(async (row) => ({
id: row.id,
title: row.title,
lyrics: row.lyrics,
style: row.style,
caption: row.caption,
cover_url: row.cover_url,
audio_url: await resolveAccessibleAudioUrl(row.audio_url, true),
duration: row.duration,
bpm: row.bpm,
key_scale: row.key_scale,
time_signature: row.time_signature,
tags: row.tags || [],
like_count: row.like_count || 0,
view_count: row.view_count || 0,
created_at: row.created_at,
creator: row.creator,
creator_avatar: row.creator_avatar,
user_id: row.user_id,
is_public: true
}))
);
res.json({ songs });
} catch (error) {
console.error('Get featured/random songs error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get public songs (for explore/home)
router.get('/public', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = parseInt(req.query.offset as string) || 0;
const result = await pool.query(
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.like_count, s.created_at,
COALESCE(u.username, 'Anonymous') as creator
FROM songs s
LEFT JOIN users u ON s.user_id = u.id
WHERE s.is_public = true
ORDER BY s.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
);
const songs = await Promise.all(
result.rows.map(async (row) => ({
...row,
audio_url: await resolveAccessibleAudioUrl(row.audio_url, true),
}))
);
res.json({ songs });
} catch (error) {
console.error('Get public songs error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get single song
router.get('/:id', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`SELECT s.id, s.user_id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public, s.like_count, s.view_count, s.created_at,
COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar
FROM songs s
LEFT JOIN users u ON s.user_id = u.id
WHERE s.id = $1`,
[req.params.id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Song not found' });
return;
}
const song = result.rows[0];
// Check access
if (!song.is_public && (!req.user || req.user.id !== song.user_id)) {
res.status(403).json({ error: 'Access denied' });
return;
}
const resolvedSong = {
...song,
audio_url: await resolveAccessibleAudioUrl(song.audio_url, song.is_public),
};
res.json({ song: resolvedSong });
} catch (error) {
console.error('Get song error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get full song details (including comments)
router.get('/:id/full', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const [songResult, commentsResult] = await Promise.all([
pool.query(
`SELECT s.id, s.user_id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public,
s.like_count, s.view_count, s.created_at,
COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar
FROM songs s
LEFT JOIN users u ON s.user_id = u.id
WHERE s.id = $1`,
[req.params.id]
),
pool.query(
`SELECT c.id, c.content, c.created_at, c.updated_at,
u.id as user_id, u.username, u.avatar_url
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.song_id = $1
ORDER BY c.created_at DESC`,
[req.params.id]
)
]);
if (songResult.rows.length === 0) {
res.status(404).json({ error: 'Song not found' });
return;
}
const song = songResult.rows[0];
// Check access
if (!song.is_public && (!req.user || req.user.id !== song.user_id)) {
res.status(403).json({ error: 'Access denied' });
return;
}
// Increment view count
await pool.query('UPDATE songs SET view_count = view_count + 1 WHERE id = $1', [req.params.id]);
const resolvedSong = {
...song,
audio_url: await resolveAccessibleAudioUrl(song.audio_url, song.is_public),
};
res.json({
song: resolvedSong,
comments: commentsResult.rows
});
} catch (error) {
console.error('Get full song error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Create song (manual, not from generation)
router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const {
title,
lyrics,
style,
caption,
coverUrl,
audioUrl,
duration,
bpm,
keyScale,
timeSignature,
tags,
isPublic,
} = req.body;
const result = await pool.query(
`INSERT INTO songs (user_id, title, lyrics, style, caption, cover_url, audio_url,
duration, bpm, key_scale, time_signature, tags, is_public)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
req.user!.id,
title,
lyrics,
style,
caption,
coverUrl,
audioUrl,
duration,
bpm,
keyScale,
timeSignature,
tags || [],
isPublic || false,
]
);
res.status(201).json({ song: result.rows[0] });
} catch (error) {
console.error('Create song error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update song
router.patch('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
// Verify ownership
const check = await pool.query('SELECT user_id FROM songs WHERE id = $1', [req.params.id]);
if (check.rows.length === 0) {
res.status(404).json({ error: 'Song not found' });
return;
}
if (check.rows[0].user_id !== req.user!.id) {
res.status(403).json({ error: 'Access denied' });
return;
}
const updates: string[] = [];
const values: unknown[] = [];
let paramCount = 1;
const allowedFields = ['title', 'lyrics', 'style', 'caption', 'cover_url', 'is_public', 'tags'];
for (const field of allowedFields) {
if (req.body[field] !== undefined) {
updates.push(`${field} = $${paramCount}`);
values.push(req.body[field]);
paramCount++;
}
}
if (updates.length === 0) {
res.status(400).json({ error: 'No fields to update' });
return;
}
updates.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(req.params.id);
const result = await pool.query(
`UPDATE songs SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
values
);
res.json({ song: result.rows[0] });
} catch (error) {
console.error('Update song error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete song
router.delete('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const check = await pool.query('SELECT user_id FROM songs WHERE id = $1', [req.params.id]);
if (check.rows.length === 0) {
res.status(404).json({ error: 'Song not found' });
return;
}
if (check.rows[0].user_id !== req.user!.id) {
res.status(403).json({ error: 'Access denied' });
return;
}
const audioFileResult = await pool.query(
'SELECT id, storage_key, storage_provider FROM audio_files WHERE song_id = $1 AND deleted_at IS NULL',
[req.params.id]
);
if (audioFileResult.rows.length > 0) {
const storage = getStorageProvider();
for (const audioFile of audioFileResult.rows) {
try {
await storage.delete(audioFile.storage_key);
} catch (err) {
console.error(`Failed to delete storage file ${audioFile.storage_key}:`, err);
}
}
await pool.query(
'UPDATE audio_files SET deleted_at = CURRENT_TIMESTAMP WHERE song_id = $1',
[req.params.id]
);
}
await pool.query('DELETE FROM songs WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (error) {
console.error('Delete song error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Like/unlike song
router.post('/:id/like', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Check if already liked
const existing = await client.query(
'SELECT 1 FROM liked_songs WHERE user_id = $1 AND song_id = $2',
[req.user!.id, req.params.id]
);
if (existing.rows.length > 0) {
// Unlike
await client.query('DELETE FROM liked_songs WHERE user_id = $1 AND song_id = $2', [
req.user!.id,
req.params.id,
]);
// Decrement like_count
await client.query(
'UPDATE songs SET like_count = GREATEST(like_count - 1, 0) WHERE id = $1',
[req.params.id]
);
await client.query('COMMIT');
res.json({ liked: false });
} else {
// Like
await client.query('INSERT INTO liked_songs (user_id, song_id) VALUES ($1, $2)', [
req.user!.id,
req.params.id,
]);
// Increment like_count
await client.query(
'UPDATE songs SET like_count = like_count + 1 WHERE id = $1',
[req.params.id]
);
await client.query('COMMIT');
res.json({ liked: true });
}
} catch (error) {
await client.query('ROLLBACK');
console.error('Like song error:', error);
res.status(500).json({ error: 'Internal server error' });
} finally {
client.release();
}
});
// Get liked songs
router.get('/liked/list', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`SELECT s.id, s.title, s.lyrics, s.style, s.cover_url, s.audio_url,
s.duration, s.tags, s.like_count, s.created_at, s.is_public,
COALESCE(u.username, 'Anonymous') as creator
FROM liked_songs ls
JOIN songs s ON ls.song_id = s.id
LEFT JOIN users u ON s.user_id = u.id
WHERE ls.user_id = $1
ORDER BY ls.liked_at DESC`,
[req.user!.id]
);
const songs = await Promise.all(
result.rows.map(async (row) => ({
...row,
audio_url: await resolveAccessibleAudioUrl(row.audio_url, row.is_public),
}))
);
res.json({ songs });
} catch (error) {
console.error('Get liked songs error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Toggle song privacy (paid users only can make songs private)
router.patch('/:id/privacy', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
// Get user's account tier
const userResult = await pool.query('SELECT account_tier FROM users WHERE id = $1', [req.user!.id]);
const accountTier = userResult.rows[0]?.account_tier || 'free';
const check = await pool.query('SELECT user_id, is_public FROM songs WHERE id = $1', [req.params.id]);
if (check.rows.length === 0) {
res.status(404).json({ error: 'Song not found' });
return;
}
if (check.rows[0].user_id !== req.user!.id) {
res.status(403).json({ error: 'Access denied' });
return;
}
const newPublicState = !check.rows[0].is_public;
// Free users cannot make songs private
if (accountTier === 'free' && !newPublicState) {
res.status(403).json({ error: 'Upgrade to Pro or Unlimited to make songs private' });
return;
}
await pool.query('UPDATE songs SET is_public = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [
newPublicState,
req.params.id,
]);
res.json({ isPublic: newPublicState });
} catch (error) {
console.error('Toggle privacy error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Track song play
router.post('/:id/play', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`UPDATE songs
SET view_count = COALESCE(view_count, 0) + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING view_count`,
[req.params.id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Song not found' });
return;
}
res.json({ viewCount: result.rows[0].view_count });
} catch (error) {
console.error('Track play error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get comments for a song
router.get('/:id/comments', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`SELECT c.id, c.content, c.created_at, u.username, u.id as user_id, u.avatar_url
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.song_id = $1
ORDER BY c.created_at DESC`,
[req.params.id]
);
res.json({ comments: result.rows });
} catch (error) {
console.error('Get comments error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Add comment to a song
router.post('/:id/comments', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { content } = req.body;
if (!content || content.trim().length === 0) {
res.status(400).json({ error: 'Comment content is required' });
return;
}
// Check if song exists and is public
const songCheck = await pool.query('SELECT is_public FROM songs WHERE id = $1', [req.params.id]);
if (songCheck.rows.length === 0) {
res.status(404).json({ error: 'Song not found' });
return;
}
if (!songCheck.rows[0].is_public) {
res.status(403).json({ error: 'Cannot comment on private songs' });
return;
}
const result = await pool.query(
`INSERT INTO comments (song_id, user_id, content)
VALUES ($1, $2, $3)
RETURNING id, content, created_at`,
[req.params.id, req.user!.id, content.trim()]
);
const comment = {
...result.rows[0],
username: req.user!.username,
user_id: req.user!.id,
};
res.status(201).json({ comment });
} catch (error) {
console.error('Add comment error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete comment
router.delete('/comments/:commentId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const check = await pool.query('SELECT user_id FROM comments WHERE id = $1', [req.params.commentId]);
if (check.rows.length === 0) {
res.status(404).json({ error: 'Comment not found' });
return;
}
if (check.rows[0].user_id !== req.user!.id) {
res.status(403).json({ error: 'Access denied' });
return;
}
await pool.query('DELETE FROM comments WHERE id = $1', [req.params.commentId]);
res.json({ success: true });
} catch (error) {
console.error('Delete comment error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+445
View File
@@ -0,0 +1,445 @@
import { Router, Response } from 'express';
import multer from 'multer';
import path from 'path';
import { pool } from '../db/pool.js';
import { authMiddleware, optionalAuthMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
import { getStorageProvider } from '../services/storage/factory.js';
const router = Router();
async function resolvePublicAudioUrl(audioUrl: string | null): Promise<string | null> {
if (!audioUrl) return null;
if (audioUrl.startsWith('s3://')) {
const storageKey = audioUrl.replace('s3://', '');
const storage = getStorageProvider();
return storage.getPublicUrl(storageKey);
}
return audioUrl;
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
fileFilter: (_req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, WebP, and GIF are allowed.'));
}
}
});
// Get featured creators (for search/explore page)
router.get('/public/featured', async (_req, res: Response) => {
try {
// First try to get users with public songs
let result = await pool.query(
`SELECT u.id, u.username, u.bio, u.avatar_url, u.created_at,
(SELECT COUNT(*) FROM followers WHERE following_id = u.id) as follower_count,
(SELECT COUNT(*) FROM songs WHERE user_id = u.id AND is_public = 1) as song_count
FROM users u
WHERE EXISTS (SELECT 1 FROM songs WHERE user_id = u.id AND is_public = 1)
ORDER BY (SELECT COUNT(*) FROM songs WHERE user_id = u.id AND is_public = 1) DESC,
(SELECT COUNT(*) FROM followers WHERE following_id = u.id) DESC
LIMIT 20`
);
// Fallback: if no users with public songs, get any recent users
if (result.rows.length === 0) {
result = await pool.query(
`SELECT u.id, u.username, u.bio, u.avatar_url, u.created_at,
(SELECT COUNT(*) FROM followers WHERE following_id = u.id) as follower_count,
0 as song_count
FROM users u
ORDER BY u.created_at DESC
LIMIT 20`
);
}
res.json({ creators: result.rows });
} catch (error) {
console.error('Get featured creators error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get user profile by username
router.get('/:username', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await pool.query(
`SELECT u.id, u.username, u.created_at, u.bio, u.avatar_url, u.banner_url
FROM users u
WHERE u.username = $1`,
[req.params.username]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
const user = result.rows[0];
res.json({ user });
} catch (error) {
console.error('Get user profile error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get user's public songs
router.get('/:username/songs', async (req, res: Response) => {
try {
const userResult = await pool.query(
'SELECT id FROM users WHERE username = $1',
[req.params.username]
);
if (userResult.rows.length === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
const userId = userResult.rows[0].id;
const songsResult = await pool.query(
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.like_count,
s.view_count, s.created_at, u.username as creator
FROM songs s
LEFT JOIN users u ON s.user_id = u.id
WHERE s.user_id = $1 AND s.is_public = 1
ORDER BY s.created_at DESC`,
[userId]
);
const songs = await Promise.all(
songsResult.rows.map(async (row) => ({
...row,
audio_url: await resolvePublicAudioUrl(row.audio_url),
}))
);
res.json({ songs });
} catch (error) {
console.error('Get user songs error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get user's public playlists
router.get('/:username/playlists', async (req, res: Response) => {
try {
const userResult = await pool.query(
'SELECT id FROM users WHERE username = $1',
[req.params.username]
);
if (userResult.rows.length === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
const userId = userResult.rows[0].id;
const playlistsResult = await pool.query(
`SELECT p.id, p.name, p.description, p.cover_url, p.created_at,
COUNT(ps.song_id) as song_count
FROM playlists p
LEFT JOIN playlist_songs ps ON p.id = ps.playlist_id
WHERE p.user_id = $1 AND p.is_public = 1
GROUP BY p.id
ORDER BY p.created_at DESC`,
[userId]
);
res.json({ playlists: playlistsResult.rows });
} catch (error) {
console.error('Get user playlists error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Upload avatar image
router.post('/me/avatar', authMiddleware, upload.single('avatar'), async (req: AuthenticatedRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'No file uploaded' });
return;
}
const userId = req.user!.id;
const ext = path.extname(req.file.originalname) || '.jpg';
const key = `users/${userId}/avatar${ext}`;
const storage = getStorageProvider();
await storage.upload(key, req.file.buffer, req.file.mimetype);
const url = storage.getPublicUrl(key);
// Update user record
const result = await pool.query(
`UPDATE users SET avatar_url = $1, updated_at = datetime('now') WHERE id = $2 RETURNING id, username, created_at, bio, avatar_url, banner_url`,
[url, userId]
);
res.json({ user: result.rows[0], url });
} catch (error) {
console.error('Avatar upload error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: 'Failed to upload avatar', details: errorMessage });
}
});
// Upload banner image
router.post('/me/banner', authMiddleware, upload.single('banner'), async (req: AuthenticatedRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'No file uploaded' });
return;
}
const userId = req.user!.id;
const ext = path.extname(req.file.originalname) || '.jpg';
const key = `users/${userId}/banner${ext}`;
const storage = getStorageProvider();
await storage.upload(key, req.file.buffer, req.file.mimetype);
const url = storage.getPublicUrl(key);
// Update user record
const result = await pool.query(
`UPDATE users SET banner_url = $1, updated_at = datetime('now') WHERE id = $2 RETURNING id, username, created_at, bio, avatar_url, banner_url`,
[url, userId]
);
res.json({ user: result.rows[0], url });
} catch (error) {
console.error('Banner upload error:', error);
res.status(500).json({ error: 'Failed to upload banner' });
}
});
// Update own profile
router.patch('/me', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const { username, bio, avatarUrl, bannerUrl } = req.body;
const updates: string[] = [];
const values: unknown[] = [];
let paramCount = 1;
if (username !== undefined) {
// Check if username is already taken
const existing = await pool.query(
'SELECT id FROM users WHERE username = $1 AND id != $2',
[username, req.user!.id]
);
if (existing.rows.length > 0) {
res.status(400).json({ error: 'Username already taken' });
return;
}
updates.push(`username = $${paramCount}`);
values.push(username);
paramCount++;
}
if (bio !== undefined) {
updates.push(`bio = $${paramCount}`);
values.push(bio);
paramCount++;
}
if (avatarUrl !== undefined) {
updates.push(`avatar_url = $${paramCount}`);
values.push(avatarUrl);
paramCount++;
}
if (bannerUrl !== undefined) {
updates.push(`banner_url = $${paramCount}`);
values.push(bannerUrl);
paramCount++;
}
if (updates.length === 0) {
res.status(400).json({ error: 'No fields to update' });
return;
}
updates.push(`updated_at = datetime('now')`);
values.push(req.user!.id);
const result = await pool.query(
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING id, username, created_at, bio, avatar_url, banner_url`,
values
);
res.json({ user: result.rows[0] });
} catch (error) {
console.error('Update profile error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Toggle follow/unfollow user
router.post('/:username/follow', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const userResult = await pool.query(
'SELECT id FROM users WHERE username = $1',
[req.params.username]
);
if (userResult.rows.length === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
const targetUserId = userResult.rows[0].id;
const currentUserId = req.user!.id;
if (targetUserId === currentUserId) {
res.status(400).json({ error: 'Cannot follow yourself' });
return;
}
// Check if already following
const existingFollow = await pool.query(
'SELECT 1 FROM followers WHERE follower_id = $1 AND following_id = $2',
[currentUserId, targetUserId]
);
let following = false;
if (existingFollow.rows.length > 0) {
// Unfollow
await pool.query(
'DELETE FROM followers WHERE follower_id = $1 AND following_id = $2',
[currentUserId, targetUserId]
);
following = false;
} else {
// Follow
await pool.query(
'INSERT INTO followers (follower_id, following_id) VALUES ($1, $2)',
[currentUserId, targetUserId]
);
following = true;
}
// Get updated follower count
const countResult = await pool.query(
'SELECT COUNT(*) as count FROM followers WHERE following_id = $1',
[targetUserId]
);
res.json({
following,
followerCount: parseInt(countResult.rows[0].count)
});
} catch (error) {
console.error('Toggle follow error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get user's followers
router.get('/:username/followers', async (req, res: Response) => {
try {
const userResult = await pool.query(
'SELECT id FROM users WHERE username = $1',
[req.params.username]
);
if (userResult.rows.length === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
const userId = userResult.rows[0].id;
const followersResult = await pool.query(
`SELECT u.id, u.username, u.created_at
FROM followers f
JOIN users u ON f.follower_id = u.id
WHERE f.following_id = $1
ORDER BY f.created_at DESC`,
[userId]
);
res.json({ followers: followersResult.rows });
} catch (error) {
console.error('Get followers error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get users that this user follows
router.get('/:username/following', async (req, res: Response) => {
try {
const userResult = await pool.query(
'SELECT id FROM users WHERE username = $1',
[req.params.username]
);
if (userResult.rows.length === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
const userId = userResult.rows[0].id;
const followingResult = await pool.query(
`SELECT u.id, u.username, u.created_at
FROM followers f
JOIN users u ON f.following_id = u.id
WHERE f.follower_id = $1
ORDER BY f.created_at DESC`,
[userId]
);
res.json({ following: followingResult.rows });
} catch (error) {
console.error('Get following error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get user stats (follower/following counts)
router.get('/:username/stats', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
try {
const userResult = await pool.query(
'SELECT id FROM users WHERE username = $1',
[req.params.username]
);
if (userResult.rows.length === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
const userId = userResult.rows[0].id;
const [followerCountResult, followingCountResult, isFollowingResult] = await Promise.all([
pool.query('SELECT COUNT(*) as count FROM followers WHERE following_id = $1', [userId]),
pool.query('SELECT COUNT(*) as count FROM followers WHERE follower_id = $1', [userId]),
req.user
? pool.query(
'SELECT 1 FROM followers WHERE follower_id = $1 AND following_id = $2',
[req.user.id, userId]
)
: Promise.resolve({ rows: [] })
]);
res.json({
followerCount: parseInt(followerCountResult.rows[0].count),
followingCount: parseInt(followingCountResult.rows[0].count),
isFollowing: isFollowingResult.rows.length > 0
});
} catch (error) {
console.error('Get user stats error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;