Initial commit: ACE-Step UI - Open source music generation interface
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
|
||||
// SQLite database
|
||||
database: {
|
||||
path: process.env.DATABASE_PATH || path.join(__dirname, '../../data/acestep.db'),
|
||||
},
|
||||
|
||||
// ACE-Step API (local)
|
||||
acestep: {
|
||||
apiUrl: process.env.ACESTEP_API_URL || 'http://localhost:8001',
|
||||
},
|
||||
|
||||
// Pexels (optional - for video backgrounds)
|
||||
pexels: {
|
||||
apiKey: process.env.PEXELS_API_KEY || '',
|
||||
},
|
||||
|
||||
// Frontend URL
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
|
||||
// Storage (local only)
|
||||
storage: {
|
||||
provider: 'local' as const,
|
||||
audioDir: process.env.AUDIO_DIR || path.join(__dirname, '../../public/audio'),
|
||||
},
|
||||
|
||||
// Simplified JWT (for local session, not critical security)
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'ace-step-ui-local-secret',
|
||||
expiresIn: '365d', // Long-lived for local app
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { db } from './pool.js';
|
||||
|
||||
const migrations = `
|
||||
-- Users table (simplified - no credits, no stripe, no tiers)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
bio TEXT,
|
||||
avatar_url TEXT,
|
||||
banner_url TEXT,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Songs table
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
lyrics TEXT,
|
||||
style TEXT,
|
||||
caption TEXT,
|
||||
cover_url TEXT,
|
||||
audio_url TEXT,
|
||||
duration INTEGER,
|
||||
bpm INTEGER,
|
||||
key_scale TEXT,
|
||||
time_signature TEXT,
|
||||
tags TEXT DEFAULT '[]',
|
||||
is_public INTEGER DEFAULT 0,
|
||||
is_featured INTEGER DEFAULT 0,
|
||||
like_count INTEGER DEFAULT 0,
|
||||
view_count INTEGER DEFAULT 0,
|
||||
has_video INTEGER DEFAULT 0,
|
||||
video_url TEXT,
|
||||
generation_params TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Generation jobs table (simplified - no credit_reserved)
|
||||
CREATE TABLE IF NOT EXISTS generation_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
acestep_task_id TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
params TEXT,
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Playlists table
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
cover_url TEXT,
|
||||
is_public INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Playlist songs junction table
|
||||
CREATE TABLE IF NOT EXISTS playlist_songs (
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (playlist_id, song_id)
|
||||
);
|
||||
|
||||
-- Liked songs table
|
||||
CREATE TABLE IF NOT EXISTS liked_songs (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||
liked_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, song_id)
|
||||
);
|
||||
|
||||
-- Comments table
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Followers table
|
||||
CREATE TABLE IF NOT EXISTS followers (
|
||||
follower_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
following_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (follower_id, following_id),
|
||||
CHECK (follower_id != following_id)
|
||||
);
|
||||
|
||||
-- Reference tracks (uploaded audio for use as references)
|
||||
CREATE TABLE IF NOT EXISTS reference_tracks (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
duration INTEGER,
|
||||
file_size_bytes INTEGER,
|
||||
tags TEXT DEFAULT '[]',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Contact submissions table
|
||||
CREATE TABLE IF NOT EXISTS contact_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
category TEXT DEFAULT 'general',
|
||||
is_read INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_user_id ON songs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_created_at ON songs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_is_public ON songs(is_public);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_is_featured ON songs(is_featured);
|
||||
CREATE INDEX IF NOT EXISTS idx_generation_jobs_user_id ON generation_jobs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_generation_jobs_status ON generation_jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_generation_jobs_created_at ON generation_jobs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_playlists_user_id ON playlists(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_song_id ON comments(song_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_created_at ON comments(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_followers_follower ON followers(follower_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_followers_following ON followers(following_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reference_tracks_user_id ON reference_tracks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reference_tracks_created_at ON reference_tracks(created_at);
|
||||
`;
|
||||
|
||||
function migrate(): void {
|
||||
console.log('Running SQLite database migrations...');
|
||||
|
||||
try {
|
||||
// Execute the entire migration script at once
|
||||
db.exec(migrations);
|
||||
console.log('Migrations completed successfully!');
|
||||
} catch (error) {
|
||||
// Check if it's just "already exists" errors
|
||||
const errorMsg = String(error);
|
||||
if (errorMsg.includes('already exists')) {
|
||||
console.log('Tables already exist, migrations completed!');
|
||||
} else {
|
||||
console.error('Migration failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
migrate();
|
||||
@@ -0,0 +1,171 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { config } from '../config/index.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.dirname(config.database.path);
|
||||
import { mkdirSync } from 'fs';
|
||||
try {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
} catch {
|
||||
// Directory already exists
|
||||
}
|
||||
|
||||
const dbInstance = new Database(config.database.path);
|
||||
dbInstance.pragma('journal_mode = WAL');
|
||||
dbInstance.pragma('foreign_keys = ON');
|
||||
|
||||
export { dbInstance as db };
|
||||
|
||||
// Convert parameters to SQLite-compatible types
|
||||
function sanitizeParams(params?: unknown[]): unknown[] | undefined {
|
||||
if (!params) return params;
|
||||
return params.map(p => {
|
||||
// Convert undefined to null
|
||||
if (p === undefined) return null;
|
||||
// Convert boolean to integer (SQLite stores booleans as 0/1)
|
||||
if (typeof p === 'boolean') return p ? 1 : 0;
|
||||
// Convert arrays and objects to JSON strings
|
||||
if (Array.isArray(p) || (typeof p === 'object' && p !== null)) {
|
||||
return JSON.stringify(p);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
}
|
||||
|
||||
// Query result type - use 'any' for rows to avoid strict typing issues
|
||||
interface QueryResult {
|
||||
rows: any[];
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
// Convert SQL and execute
|
||||
function executeQuery(sql: string, params?: unknown[], dbRef: Database.Database = dbInstance): QueryResult {
|
||||
// Sanitize parameters for SQLite
|
||||
const sanitizedParams = sanitizeParams(params);
|
||||
|
||||
// Convert PostgreSQL $1, $2 placeholders to SQLite ?
|
||||
let convertedSql = sql;
|
||||
if (sanitizedParams && sanitizedParams.length > 0) {
|
||||
// Replace $N with ?
|
||||
convertedSql = sql.replace(/\$(\d+)/g, '?');
|
||||
}
|
||||
|
||||
// Handle common PostgreSQL -> SQLite conversions
|
||||
convertedSql = convertedSql
|
||||
.replace(/ILIKE/gi, 'LIKE')
|
||||
.replace(/CURRENT_TIMESTAMP/gi, "datetime('now')")
|
||||
.replace(/COALESCE/gi, 'COALESCE')
|
||||
.replace(/::text/gi, '')
|
||||
.replace(/::integer/gi, '')
|
||||
.replace(/::boolean/gi, '')
|
||||
.replace(/GREATEST\(([^,]+),\s*(\d+)\)/gi, 'MAX($1, $2)');
|
||||
|
||||
// Auto-generate UUID for INSERT statements that need an id
|
||||
const insertMatch = convertedSql.match(/INSERT INTO (\w+)\s*\(([^)]+)\)/i);
|
||||
if (insertMatch) {
|
||||
const tableName = insertMatch[1];
|
||||
const columns = insertMatch[2].split(',').map(c => c.trim().toLowerCase());
|
||||
|
||||
// Tables that need auto-generated IDs
|
||||
const tablesNeedingId = ['users', 'songs', 'playlists', 'generation_jobs', 'comments', 'reference_tracks', 'contact_submissions'];
|
||||
|
||||
if (tablesNeedingId.includes(tableName.toLowerCase()) && !columns.includes('id')) {
|
||||
// Add id to the INSERT
|
||||
const newId = randomUUID();
|
||||
const updatedColumns = 'id, ' + insertMatch[2];
|
||||
const valuesMatch = convertedSql.match(/VALUES\s*\(([^)]+)\)/i);
|
||||
if (valuesMatch) {
|
||||
const updatedValues = `VALUES ('${newId}', ${valuesMatch[1]})`;
|
||||
convertedSql = convertedSql.replace(/\([^)]+\)\s*VALUES/i, `(${updatedColumns}) VALUES`);
|
||||
convertedSql = convertedSql.replace(/VALUES\s*\([^)]+\)/i, updatedValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine if it's a SELECT/returning query
|
||||
const isSelect = /^\s*(SELECT|RETURNING)/i.test(convertedSql) ||
|
||||
convertedSql.includes('RETURNING');
|
||||
|
||||
if (isSelect || convertedSql.includes('RETURNING')) {
|
||||
const stmt = dbRef.prepare(convertedSql);
|
||||
const rows = sanitizedParams ? stmt.all(...sanitizedParams) : stmt.all();
|
||||
return { rows, rowCount: rows.length };
|
||||
} else {
|
||||
const stmt = dbRef.prepare(convertedSql);
|
||||
const result = sanitizedParams ? stmt.run(...sanitizedParams) : stmt.run();
|
||||
return { rows: [], rowCount: result.changes };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SQLite query error:', error);
|
||||
console.error('SQL:', convertedSql);
|
||||
console.error('Params:', sanitizedParams);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Client-like interface for transaction support
|
||||
class SqliteClient {
|
||||
private inTransaction = false;
|
||||
|
||||
async query(sql: string, params?: unknown[]): Promise<QueryResult> {
|
||||
return executeQuery(sql, params, dbInstance);
|
||||
}
|
||||
|
||||
release() {
|
||||
// No-op for SQLite - connection doesn't need to be released
|
||||
if (this.inTransaction) {
|
||||
// If released while in transaction, rollback
|
||||
try {
|
||||
dbInstance.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Ignore if no transaction
|
||||
}
|
||||
this.inTransaction = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for compatibility with existing code that expects pool-like interface
|
||||
export const pool = {
|
||||
query: async (sql: string, params?: unknown[]): Promise<QueryResult> => {
|
||||
return executeQuery(sql, params);
|
||||
},
|
||||
|
||||
// For transaction support (used by like endpoint)
|
||||
connect: async () => {
|
||||
const client = new SqliteClient();
|
||||
// Override query to handle BEGIN/COMMIT/ROLLBACK
|
||||
const originalQuery = client.query.bind(client);
|
||||
client.query = async (sql: string, params?: unknown[]) => {
|
||||
const upperSql = sql.trim().toUpperCase();
|
||||
if (upperSql === 'BEGIN') {
|
||||
dbInstance.exec('BEGIN IMMEDIATE');
|
||||
(client as any).inTransaction = true;
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (upperSql === 'COMMIT') {
|
||||
dbInstance.exec('COMMIT');
|
||||
(client as any).inTransaction = false;
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (upperSql === 'ROLLBACK') {
|
||||
dbInstance.exec('ROLLBACK');
|
||||
(client as any).inTransaction = false;
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
return originalQuery(sql, params);
|
||||
};
|
||||
return client;
|
||||
},
|
||||
|
||||
end: async () => {
|
||||
dbInstance.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { db } from './pool.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// UUID generation helper (SQLite doesn't have gen_random_uuid())
|
||||
export function generateUUID(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
// JSON helper for SQLite (serialize objects to strings)
|
||||
export function toJSON(obj: unknown): string {
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
// JSON helper for SQLite (parse JSON strings)
|
||||
export function fromJSON<T>(str: string | null | undefined): T | null {
|
||||
if (!str) return null;
|
||||
try {
|
||||
return JSON.parse(str) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Array helper for SQLite (store arrays as JSON strings)
|
||||
export function toArray(arr: unknown[]): string {
|
||||
return JSON.stringify(arr || []);
|
||||
}
|
||||
|
||||
// Array helper for SQLite (parse array from JSON string)
|
||||
export function fromArray<T>(str: string | null | undefined): T[] {
|
||||
if (!str) return [];
|
||||
try {
|
||||
return JSON.parse(str) as T[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ISO date string helper
|
||||
export function toISODate(date?: Date | null): string | null {
|
||||
if (!date) return null;
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
// Parse ISO date string
|
||||
export function fromISODate(str: string | null | undefined): Date | null {
|
||||
if (!str) return null;
|
||||
return new Date(str);
|
||||
}
|
||||
|
||||
// Transaction helper
|
||||
export function transaction<T>(fn: () => T): T {
|
||||
return db.transaction(fn)();
|
||||
}
|
||||
|
||||
// Batch insert helper
|
||||
export function batchInsert(
|
||||
table: string,
|
||||
columns: string[],
|
||||
rows: unknown[][]
|
||||
): void {
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`
|
||||
);
|
||||
|
||||
const insertMany = db.transaction((items: unknown[][]) => {
|
||||
for (const row of items) {
|
||||
stmt.run(...row);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(rows);
|
||||
}
|
||||
|
||||
export { db };
|
||||
@@ -0,0 +1,440 @@
|
||||
import dotenv from 'dotenv';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Load .env from project root (parent of server directory)
|
||||
const __filename_init = fileURLToPath(import.meta.url);
|
||||
const __dirname_init = path.dirname(__filename_init);
|
||||
dotenv.config({ path: path.join(__dirname_init, '../../.env') });
|
||||
import cron from 'node-cron';
|
||||
import { config } from './config/index.js';
|
||||
import { runCleanupJob, cleanupDeletedSongs } from './services/cleanup.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
import authRoutes from './routes/auth.js';
|
||||
import songsRoutes from './routes/songs.js';
|
||||
import generateRoutes from './routes/generate.js';
|
||||
import usersRoutes from './routes/users.js';
|
||||
import playlistsRoutes from './routes/playlists.js';
|
||||
import contactRoutes from './routes/contact.js';
|
||||
import referenceTrackRoutes from './routes/referenceTrack.js';
|
||||
import { pool } from './db/pool.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Security headers
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
crossOriginEmbedderPolicy: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
baseUri: ["'self'"],
|
||||
fontSrc: ["'self'", 'https:', 'data:'],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'self'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
objectSrc: ["'none'"],
|
||||
scriptSrc: ["'self'"],
|
||||
scriptSrcAttr: ["'none'"],
|
||||
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
|
||||
upgradeInsecureRequests: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||
if (!origin) return callback(null, true);
|
||||
// Allow localhost on any port in development
|
||||
if (config.nodeEnv === 'development' && origin.includes('localhost')) {
|
||||
return callback(null, true);
|
||||
}
|
||||
// Allow LAN IPs in development (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
|
||||
if (config.nodeEnv === 'development') {
|
||||
const lanPattern = /^https?:\/\/(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/;
|
||||
if (lanPattern.test(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
}
|
||||
// Allow configured frontend URL
|
||||
if (origin === config.frontendUrl) {
|
||||
return callback(null, true);
|
||||
}
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Serve static audio files
|
||||
app.use('/audio', express.static(path.join(__dirname, '../public/audio')));
|
||||
|
||||
// Audio Editor (AudioMass) - needs relaxed CSP for inline scripts and external images
|
||||
app.use('/editor', (req, res, next) => {
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data: http://localhost:* https:; connect-src 'self' http://localhost:* https:; worker-src 'self' blob:");
|
||||
next();
|
||||
}, express.static(path.join(__dirname, '../audio-editor')));
|
||||
|
||||
// Demucs Web (Stem Extraction) - requires COOP/COEP headers for SharedArrayBuffer and relaxed CSP for ONNX runtime
|
||||
app.use('/demucs-web', (req, res, next) => {
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://cdn.jsdelivr.net",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"img-src 'self' data: https:",
|
||||
"media-src 'self' blob: data: http://localhost:* https:",
|
||||
"connect-src 'self' blob: http://localhost:* https://cdn.jsdelivr.net https://huggingface.co https://*.huggingface.co https://*.hf.co",
|
||||
"worker-src 'self' blob:",
|
||||
"child-src 'self' blob:"
|
||||
].join('; '));
|
||||
next();
|
||||
}, express.static(path.join(__dirname, '../public/demucs-web')));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', service: 'ACE-Step UI API' });
|
||||
});
|
||||
|
||||
// oEmbed endpoint for rich embeds
|
||||
app.get('/api/oembed', async (req, res) => {
|
||||
const url = req.query.url as string;
|
||||
if (!url) {
|
||||
res.status(400).json({ error: 'URL required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const match = url.match(/\/song\/([a-zA-Z0-9-]+)/);
|
||||
if (!match) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT s.id, s.title, s.style, s.cover_url, s.duration,
|
||||
COALESCE(u.username, 'Anonymous') as creator
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = ? AND s.is_public = 1`,
|
||||
[match[1]]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const song = result.rows[0];
|
||||
res.json({
|
||||
version: '1.0',
|
||||
type: 'rich',
|
||||
provider_name: 'ACE-Step UI',
|
||||
provider_url: config.frontendUrl,
|
||||
title: song.title,
|
||||
author_name: song.creator,
|
||||
thumbnail_url: song.cover_url,
|
||||
thumbnail_width: 400,
|
||||
thumbnail_height: 400,
|
||||
html: `<iframe src="${config.frontendUrl}/embed/${song.id}" width="100%" height="152" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>`,
|
||||
width: 400,
|
||||
height: 152
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('oEmbed error:', error);
|
||||
res.status(500).json({ error: 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Song share page handler
|
||||
app.get('/song/:id', async (req, res) => {
|
||||
const songId = req.params.id;
|
||||
const userAgent = req.get('User-Agent') || '';
|
||||
|
||||
// Check if request is from a social media bot
|
||||
const isSocialBot = /twitterbot|facebookexternalhit|linkedinbot|slackbot|redditbot|discordbot|telegrambot|whatsapp|pinterestbot|tumblr|embedly|quora|outbrain|vkshare|w3c_validator|baiduspider|bingbot/i.test(userAgent);
|
||||
|
||||
if (!isSocialBot) {
|
||||
res.redirect(`${config.frontendUrl}?song=${songId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT s.id, s.title, s.style, s.cover_url, s.audio_url, s.duration, s.like_count, s.view_count,
|
||||
COALESCE(u.username, 'Anonymous') as creator
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = ? AND s.is_public = 1`,
|
||||
[songId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.redirect(config.frontendUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const song = result.rows[0];
|
||||
const coverUrl = song.cover_url || `https://picsum.photos/seed/${song.id}/1200/630`;
|
||||
const title = `${song.title} by ${song.creator}`;
|
||||
const description = `🎵 ${song.style} • Create your own AI music free on ACE-Step UI`;
|
||||
const pageUrl = `${config.frontendUrl}/song/${song.id}`;
|
||||
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title} | ACE-Step UI</title>
|
||||
<meta name="title" content="${title}">
|
||||
<meta name="description" content="${description}">
|
||||
<meta property="og:type" content="music.song">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
<meta property="og:title" content="${title}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${coverUrl}">
|
||||
<meta property="og:site_name" content="ACE-Step UI">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${title}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${coverUrl}">
|
||||
<meta http-equiv="refresh" content="0;url=${config.frontendUrl}?song=${song.id}">
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to <a href="${config.frontendUrl}?song=${song.id}">ACE-Step UI</a>...</p>
|
||||
</body>
|
||||
</html>`);
|
||||
} catch (error) {
|
||||
console.error('Error serving song share page:', error);
|
||||
res.redirect(config.frontendUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Image proxy for CORS
|
||||
app.get('/api/proxy/image', async (req, res) => {
|
||||
const url = req.query.url as string;
|
||||
if (!url) {
|
||||
res.status(400).json({ error: 'URL required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
res.status(response.status).json({ error: 'Failed to fetch image' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error('Image proxy error:', error);
|
||||
res.status(500).json({ error: 'Failed to proxy image' });
|
||||
}
|
||||
});
|
||||
|
||||
// Pexels API proxy - accepts API key from header or uses server config
|
||||
app.get('/api/pexels/photos', async (req, res) => {
|
||||
const query = req.query.query as string;
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Query required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept API key from header (user-provided) or fall back to server config
|
||||
const apiKey = req.headers['x-pexels-api-key'] as string || config.pexels.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(400).json({ error: 'Pexels API key not configured. Please set your API key in the Video Generator settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=20&orientation=landscape`,
|
||||
{ headers: { Authorization: apiKey } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
res.status(401).json({ error: 'Invalid Pexels API key' });
|
||||
return;
|
||||
}
|
||||
res.status(response.status).json({ error: 'Pexels API error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('Pexels photos error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch from Pexels' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/pexels/videos', async (req, res) => {
|
||||
const query = req.query.query as string;
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Query required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept API key from header (user-provided) or fall back to server config
|
||||
const apiKey = req.headers['x-pexels-api-key'] as string || config.pexels.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(400).json({ error: 'Pexels API key not configured. Please set your API key in the Video Generator settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.pexels.com/videos/search?query=${encodeURIComponent(query)}&per_page=15&orientation=landscape`,
|
||||
{ headers: { Authorization: apiKey } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
res.status(401).json({ error: 'Invalid Pexels API key' });
|
||||
return;
|
||||
}
|
||||
res.status(response.status).json({ error: 'Pexels API error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('Pexels videos error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch from Pexels' });
|
||||
}
|
||||
});
|
||||
|
||||
// Search endpoint
|
||||
app.get('/api/search', async (req, res) => {
|
||||
const query = (req.query.q as string)?.trim();
|
||||
const type = req.query.type as string;
|
||||
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Search query required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const searchPattern = `%${query}%`;
|
||||
const results: { songs: unknown[]; creators: unknown[]; playlists: unknown[] } = {
|
||||
songs: [],
|
||||
creators: [],
|
||||
playlists: [],
|
||||
};
|
||||
|
||||
if (!type || type === 'all' || type === 'songs') {
|
||||
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.tags, s.like_count, s.view_count, s.is_public, s.created_at,
|
||||
u.username as creator, u.avatar_url as creator_avatar
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.is_public = 1
|
||||
AND (s.title LIKE ? COLLATE NOCASE OR s.style LIKE ? COLLATE NOCASE)
|
||||
ORDER BY s.like_count DESC
|
||||
LIMIT 20`,
|
||||
[searchPattern, searchPattern]
|
||||
);
|
||||
results.songs = songsResult.rows;
|
||||
}
|
||||
|
||||
if (!type || type === 'all' || type === 'creators') {
|
||||
const creatorsResult = 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
|
||||
FROM users u
|
||||
WHERE u.username LIKE ? COLLATE NOCASE
|
||||
ORDER BY (SELECT COUNT(*) FROM followers WHERE following_id = u.id) DESC
|
||||
LIMIT 20`,
|
||||
[searchPattern]
|
||||
);
|
||||
results.creators = creatorsResult.rows;
|
||||
}
|
||||
|
||||
if (!type || type === 'all' || type === 'playlists') {
|
||||
const playlistsResult = 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,
|
||||
(SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = p.id) as song_count
|
||||
FROM playlists p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
WHERE p.is_public = 1 AND p.name LIKE ? COLLATE NOCASE
|
||||
ORDER BY (SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = p.id) DESC
|
||||
LIMIT 20`,
|
||||
[searchPattern]
|
||||
);
|
||||
results.playlists = playlistsResult.rows;
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
res.status(500).json({ error: 'Search failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/songs', songsRoutes);
|
||||
app.use('/api/generate', generateRoutes);
|
||||
app.use('/api/users', usersRoutes);
|
||||
app.use('/api/playlists', playlistsRoutes);
|
||||
app.use('/api/contact', contactRoutes);
|
||||
app.use('/api/reference-tracks', referenceTrackRoutes);
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Schedule cleanup job to run daily at 3 AM
|
||||
cron.schedule('0 3 * * *', async () => {
|
||||
console.log('Running scheduled cleanup job...');
|
||||
try {
|
||||
await runCleanupJob();
|
||||
await cleanupDeletedSongs();
|
||||
} catch (error) {
|
||||
console.error('Cleanup job failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Start server on all interfaces for LAN access
|
||||
app.listen(config.port, '0.0.0.0', () => {
|
||||
console.log(`ACE-Step UI Server running on http://localhost:${config.port}`);
|
||||
console.log(`Environment: ${config.nodeEnv}`);
|
||||
console.log(`ACE-Step API: ${config.acestep.apiUrl}`);
|
||||
|
||||
// Show LAN access info
|
||||
import('os').then(os => {
|
||||
const nets = os.networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name] || []) {
|
||||
if (net.family === 'IPv4' && !net.internal) {
|
||||
console.log(`LAN access: http://${net.address}:${config.port}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config/index.js';
|
||||
import { pool } from '../db/pool.js';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
export function authMiddleware(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as AuthenticatedUser;
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function optionalAuthMiddleware(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as AuthenticatedUser;
|
||||
req.user = decoded;
|
||||
} catch {
|
||||
// Token invalid, but continue without user
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export async function adminMiddleware(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as AuthenticatedUser;
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT is_admin FROM users WHERE id = ?',
|
||||
[decoded.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0 || !result.rows[0].is_admin) {
|
||||
res.status(403).json({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = { ...decoded, isAdmin: true };
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { pool } from '../db/pool.js';
|
||||
|
||||
async function run() {
|
||||
const users = await pool.query(
|
||||
`SELECT id, email FROM users
|
||||
WHERE avatar_url IS NULL AND email IS NOT NULL`
|
||||
);
|
||||
|
||||
let updated = 0;
|
||||
for (const row of users.rows) {
|
||||
const email = String(row.email || '').toLowerCase();
|
||||
if (!email) continue;
|
||||
const hash = await hashEmail(email);
|
||||
const gravatar = `https://www.gravatar.com/avatar/${hash}?d=identicon&s=256`;
|
||||
await pool.query(
|
||||
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
||||
[gravatar, row.id]
|
||||
);
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
console.log(`[backfill] Updated ${updated} users with gravatar identicons.`);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
async function hashEmail(email: string): Promise<string> {
|
||||
const crypto = await import('crypto');
|
||||
return crypto.createHash('md5').update(email.trim().toLowerCase()).digest('hex');
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { generationQueue } from '../services/generationQueue.js';
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results: string[] = [];
|
||||
|
||||
generationQueue.setConfig({
|
||||
maxTotalWorkers: 2,
|
||||
maxFreeWorkers: 1,
|
||||
maxPerUser: 1,
|
||||
batchWindowMs: 200,
|
||||
batchSize: 4,
|
||||
});
|
||||
|
||||
const jobs = [
|
||||
{ id: 'job-free-1', userId: 'u1', tier: 'free', delay: 300 },
|
||||
{ id: 'job-pro-1', userId: 'u2', tier: 'pro', delay: 200 },
|
||||
{ id: 'job-pro-2', userId: 'u2', tier: 'pro', delay: 200 },
|
||||
{ id: 'job-unlimited-1', userId: 'u3', tier: 'unlimited', delay: 150 },
|
||||
];
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
let remaining = jobs.length;
|
||||
for (const job of jobs) {
|
||||
generationQueue.enqueue({
|
||||
id: job.id,
|
||||
userId: job.userId,
|
||||
tier: job.tier as 'free' | 'pro' | 'unlimited',
|
||||
createdAt: Date.now(),
|
||||
params: {
|
||||
lyrics: 'test',
|
||||
style: 'test',
|
||||
title: 'test',
|
||||
duration: 30,
|
||||
},
|
||||
run: async () => {
|
||||
results.push(`start:${job.id}`);
|
||||
await sleep(job.delay);
|
||||
results.push(`end:${job.id}`);
|
||||
generationQueue.markJobFinished(job.id);
|
||||
remaining -= 1;
|
||||
if (remaining === 0) {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await done;
|
||||
|
||||
console.log('[test-queue] Order:', results.join(' | '));
|
||||
console.log('[test-queue] OK');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[test-queue] Failed', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,668 @@
|
||||
import { writeFile, mkdir, copyFile, rm, stat } from 'fs/promises';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
// Get audio duration using ffprobe
|
||||
function getAudioDuration(filePath: string): number {
|
||||
try {
|
||||
const result = execSync(
|
||||
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
|
||||
{ encoding: 'utf-8', timeout: 10000 }
|
||||
);
|
||||
const duration = parseFloat(result.trim());
|
||||
return isNaN(duration) ? 0 : Math.round(duration);
|
||||
} catch (error) {
|
||||
console.warn('Failed to get audio duration:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
import { fileURLToPath } from 'url';
|
||||
import { config } from '../config/index.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const AUDIO_DIR = path.join(__dirname, '../../public/audio');
|
||||
|
||||
const ACESTEP_API = config.acestep.apiUrl;
|
||||
|
||||
// Resolve ACE-Step path (from env or default relative path)
|
||||
function resolveAceStepPath(): string {
|
||||
const envPath = process.env.ACESTEP_PATH;
|
||||
if (envPath) {
|
||||
return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath);
|
||||
}
|
||||
// Default: sibling directory
|
||||
return path.resolve(__dirname, '../../../../ACE-Step-1.5');
|
||||
}
|
||||
|
||||
const ACESTEP_DIR = resolveAceStepPath();
|
||||
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
|
||||
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
|
||||
|
||||
export interface GenerationParams {
|
||||
// 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;
|
||||
}
|
||||
|
||||
interface GenerationResult {
|
||||
audioUrls: string[];
|
||||
duration: number;
|
||||
bpm?: number;
|
||||
keyScale?: string;
|
||||
timeSignature?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface JobStatus {
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
queuePosition?: number;
|
||||
etaSeconds?: number;
|
||||
result?: GenerationResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ActiveJob {
|
||||
params: GenerationParams;
|
||||
startTime: number;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
taskId?: string;
|
||||
result?: GenerationResult;
|
||||
error?: string;
|
||||
processPromise?: Promise<void>;
|
||||
rawResponse?: unknown;
|
||||
queuePosition?: number;
|
||||
}
|
||||
|
||||
const activeJobs = new Map<string, ActiveJob>();
|
||||
|
||||
// Job queue for sequential processing (GPU can only handle one job at a time)
|
||||
const jobQueue: string[] = [];
|
||||
let isProcessingQueue = false;
|
||||
|
||||
// Health check - verify Python script exists
|
||||
export async function checkSpaceHealth(): Promise<boolean> {
|
||||
try {
|
||||
const { access } = await import('fs/promises');
|
||||
await access(PYTHON_SCRIPT);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Discover endpoints (for compatibility)
|
||||
export async function discoverEndpoints(): Promise<unknown> {
|
||||
return { provider: 'acestep-local', endpoint: ACESTEP_API };
|
||||
}
|
||||
|
||||
// Reset client (no-op for REST API)
|
||||
export function resetClient(): void {
|
||||
// No client to reset for REST API
|
||||
}
|
||||
|
||||
// Process the job queue sequentially
|
||||
async function processQueue(): Promise<void> {
|
||||
if (isProcessingQueue) return;
|
||||
isProcessingQueue = true;
|
||||
|
||||
while (jobQueue.length > 0) {
|
||||
const jobId = jobQueue[0];
|
||||
const job = activeJobs.get(jobId);
|
||||
|
||||
if (job && job.status === 'queued') {
|
||||
try {
|
||||
await processGeneration(jobId, job.params, job);
|
||||
} catch (error) {
|
||||
console.error(`Queue processing error for ${jobId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from queue after processing (whether success or failure)
|
||||
jobQueue.shift();
|
||||
|
||||
// Update queue positions for remaining jobs
|
||||
jobQueue.forEach((id, index) => {
|
||||
const queuedJob = activeJobs.get(id);
|
||||
if (queuedJob) {
|
||||
queuedJob.queuePosition = index + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isProcessingQueue = false;
|
||||
}
|
||||
|
||||
// Submit generation job to queue
|
||||
export async function generateMusicViaAPI(params: GenerationParams): Promise<{ jobId: string }> {
|
||||
const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
const job: ActiveJob = {
|
||||
params,
|
||||
startTime: Date.now(),
|
||||
status: 'queued',
|
||||
queuePosition: jobQueue.length + 1,
|
||||
};
|
||||
|
||||
activeJobs.set(jobId, job);
|
||||
jobQueue.push(jobId);
|
||||
|
||||
console.log(`Job ${jobId}: Queued at position ${job.queuePosition}`);
|
||||
|
||||
// Start processing the queue (will be a no-op if already processing)
|
||||
processQueue().catch(err => console.error('Queue processing error:', err));
|
||||
|
||||
return { jobId };
|
||||
}
|
||||
|
||||
async function processGeneration(
|
||||
jobId: string,
|
||||
params: GenerationParams,
|
||||
job: ActiveJob
|
||||
): Promise<void> {
|
||||
job.status = 'running';
|
||||
|
||||
// Build prompt for generation
|
||||
const caption = params.style || 'pop music';
|
||||
const prompt = params.customMode ? caption : (params.songDescription || caption);
|
||||
const lyrics = params.instrumental ? '' : (params.lyrics || '');
|
||||
|
||||
console.log(`Job ${jobId}: Starting generation via Python script`, {
|
||||
prompt: prompt.slice(0, 50),
|
||||
lyricsPreview: lyrics.slice(0, 50),
|
||||
duration: params.duration,
|
||||
batchSize: params.batchSize,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create unique output directory for this job to avoid conflicts with concurrent jobs
|
||||
const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId);
|
||||
await mkdir(jobOutputDir, { recursive: true });
|
||||
|
||||
// Build command arguments for the Python script
|
||||
const args = [
|
||||
'--prompt', prompt,
|
||||
'--duration', String(params.duration ?? 60),
|
||||
'--batch-size', String(params.batchSize ?? 1),
|
||||
'--infer-steps', String(params.inferenceSteps ?? 8),
|
||||
'--guidance-scale', String(params.guidanceScale ?? 10.0),
|
||||
'--audio-format', params.audioFormat ?? 'mp3',
|
||||
'--output-dir', jobOutputDir,
|
||||
'--json',
|
||||
];
|
||||
|
||||
// Basic parameters
|
||||
if (lyrics) {
|
||||
args.push('--lyrics', lyrics);
|
||||
}
|
||||
if (params.instrumental) {
|
||||
args.push('--instrumental');
|
||||
}
|
||||
if (params.bpm && params.bpm > 0) {
|
||||
args.push('--bpm', String(params.bpm));
|
||||
}
|
||||
if (params.keyScale) {
|
||||
args.push('--key-scale', params.keyScale);
|
||||
}
|
||||
if (params.timeSignature) {
|
||||
args.push('--time-signature', params.timeSignature);
|
||||
}
|
||||
if (params.vocalLanguage) {
|
||||
args.push('--vocal-language', params.vocalLanguage);
|
||||
}
|
||||
if (params.seed !== undefined && params.seed >= 0 && !params.randomSeed) {
|
||||
args.push('--seed', String(params.seed));
|
||||
}
|
||||
if (params.shift !== undefined) {
|
||||
args.push('--shift', String(params.shift));
|
||||
}
|
||||
|
||||
// Task type parameters
|
||||
if (params.taskType && params.taskType !== 'text2music') {
|
||||
args.push('--task-type', params.taskType);
|
||||
}
|
||||
if (params.referenceAudioUrl) {
|
||||
// Convert URL path to filesystem path
|
||||
let refAudioPath = params.referenceAudioUrl;
|
||||
if (refAudioPath.startsWith('/audio/')) {
|
||||
refAudioPath = path.join(AUDIO_DIR, refAudioPath.replace('/audio/', ''));
|
||||
}
|
||||
args.push('--reference-audio', refAudioPath);
|
||||
}
|
||||
if (params.sourceAudioUrl) {
|
||||
// Convert URL path to filesystem path
|
||||
let srcAudioPath = params.sourceAudioUrl;
|
||||
if (srcAudioPath.startsWith('/audio/')) {
|
||||
srcAudioPath = path.join(AUDIO_DIR, srcAudioPath.replace('/audio/', ''));
|
||||
}
|
||||
args.push('--src-audio', srcAudioPath);
|
||||
}
|
||||
if (params.audioCodes) {
|
||||
args.push('--audio-codes', params.audioCodes);
|
||||
}
|
||||
if (params.repaintingStart !== undefined && params.repaintingStart > 0) {
|
||||
args.push('--repainting-start', String(params.repaintingStart));
|
||||
}
|
||||
if (params.repaintingEnd !== undefined && params.repaintingEnd > 0) {
|
||||
args.push('--repainting-end', String(params.repaintingEnd));
|
||||
}
|
||||
if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) {
|
||||
args.push('--audio-cover-strength', String(params.audioCoverStrength));
|
||||
}
|
||||
if (params.instruction) {
|
||||
args.push('--instruction', params.instruction);
|
||||
}
|
||||
|
||||
// LM/CoT parameters
|
||||
if (params.thinking) {
|
||||
args.push('--thinking');
|
||||
}
|
||||
if (params.lmTemperature !== undefined) {
|
||||
args.push('--lm-temperature', String(params.lmTemperature));
|
||||
}
|
||||
if (params.lmCfgScale !== undefined) {
|
||||
args.push('--lm-cfg-scale', String(params.lmCfgScale));
|
||||
}
|
||||
if (params.lmTopK !== undefined && params.lmTopK > 0) {
|
||||
args.push('--lm-top-k', String(params.lmTopK));
|
||||
}
|
||||
if (params.lmTopP !== undefined) {
|
||||
args.push('--lm-top-p', String(params.lmTopP));
|
||||
}
|
||||
if (params.lmNegativePrompt) {
|
||||
args.push('--lm-negative-prompt', params.lmNegativePrompt);
|
||||
}
|
||||
|
||||
// CoT parameters (pass when disabled, since they default to true)
|
||||
if (params.useCotMetas === false) {
|
||||
args.push('--no-cot-metas');
|
||||
}
|
||||
if (params.useCotCaption === false) {
|
||||
args.push('--no-cot-caption');
|
||||
}
|
||||
if (params.useCotLanguage === false) {
|
||||
args.push('--no-cot-language');
|
||||
}
|
||||
|
||||
// Advanced parameters
|
||||
if (params.useAdg) {
|
||||
args.push('--use-adg');
|
||||
}
|
||||
if (params.cfgIntervalStart !== undefined && params.cfgIntervalStart > 0) {
|
||||
args.push('--cfg-interval-start', String(params.cfgIntervalStart));
|
||||
}
|
||||
if (params.cfgIntervalEnd !== undefined && params.cfgIntervalEnd < 1.0) {
|
||||
args.push('--cfg-interval-end', String(params.cfgIntervalEnd));
|
||||
}
|
||||
|
||||
// Run the Python script
|
||||
const result = await runPythonGeneration(args);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Generation failed');
|
||||
}
|
||||
|
||||
if (!result.audio_paths || result.audio_paths.length === 0) {
|
||||
throw new Error('No audio files generated');
|
||||
}
|
||||
|
||||
// Copy audio files to public directory and build URLs
|
||||
const audioUrls: string[] = [];
|
||||
let actualDuration = 0;
|
||||
for (const srcPath of result.audio_paths) {
|
||||
const ext = srcPath.includes('.flac') ? '.flac' : '.mp3';
|
||||
const filename = `${jobId}_${audioUrls.length}${ext}`;
|
||||
const destPath = path.join(AUDIO_DIR, filename);
|
||||
|
||||
await mkdir(AUDIO_DIR, { recursive: true });
|
||||
await copyFile(srcPath, destPath);
|
||||
|
||||
// Get actual audio duration from first file
|
||||
if (audioUrls.length === 0) {
|
||||
actualDuration = getAudioDuration(destPath);
|
||||
}
|
||||
|
||||
audioUrls.push(`/audio/${filename}`);
|
||||
}
|
||||
|
||||
// Clean up job-specific output directory
|
||||
try {
|
||||
await rm(jobOutputDir, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.warn(`Job ${jobId}: Failed to cleanup output dir`, cleanupError);
|
||||
}
|
||||
|
||||
// Use actual duration, or fall back to params if > 0, otherwise default to 60
|
||||
const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 60);
|
||||
|
||||
job.status = 'succeeded';
|
||||
job.result = {
|
||||
audioUrls,
|
||||
duration: finalDuration,
|
||||
bpm: params.bpm,
|
||||
keyScale: params.keyScale,
|
||||
timeSignature: params.timeSignature,
|
||||
status: 'succeeded',
|
||||
};
|
||||
job.rawResponse = result;
|
||||
console.log(`Job ${jobId}: Completed in ${result.elapsed_seconds?.toFixed(1)}s with ${audioUrls.length} audio files`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Job ${jobId}: Generation failed`, error);
|
||||
job.status = 'failed';
|
||||
job.error = error instanceof Error ? error.message : 'Generation failed';
|
||||
|
||||
// Try to clean up job output directory on failure too
|
||||
try {
|
||||
const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId);
|
||||
await rm(jobOutputDir, { recursive: true, force: true });
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
interface PythonResult {
|
||||
success: boolean;
|
||||
audio_paths?: string[];
|
||||
elapsed_seconds?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
|
||||
return new Promise((resolve) => {
|
||||
// Use the ACE-Step venv's Python directly
|
||||
const pythonPath = path.join(ACESTEP_DIR, '.venv', 'bin', 'python');
|
||||
const args = [PYTHON_SCRIPT, ...scriptArgs];
|
||||
|
||||
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();
|
||||
// Log progress to console
|
||||
const lines = data.toString().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
console.log(`[ACE-Step] ${line}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
resolve({ success: false, error: stderr || `Process exited with code ${code}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the JSON output (last line that starts with {)
|
||||
const lines = stdout.split('\n').filter(l => l.trim());
|
||||
const jsonLine = lines.find(l => l.startsWith('{'));
|
||||
|
||||
if (!jsonLine) {
|
||||
resolve({ success: false, error: 'No JSON output from generation script' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = JSON.parse(jsonLine);
|
||||
resolve(result);
|
||||
} catch {
|
||||
resolve({ success: false, error: 'Invalid JSON from generation script' });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
resolve({ success: false, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extractAudioFiles(result: unknown): string[] {
|
||||
const urls: string[] = [];
|
||||
|
||||
function processItem(item: unknown): void {
|
||||
if (!item) return;
|
||||
|
||||
if (typeof item === 'string') {
|
||||
if (item.includes('.mp3') || item.includes('.wav') || item.includes('.flac')) {
|
||||
urls.push(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(item)) {
|
||||
for (const subItem of item) {
|
||||
processItem(subItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof item === 'object') {
|
||||
const obj = item as Record<string, unknown>;
|
||||
|
||||
// Check common audio path fields
|
||||
if (obj.audio_path && typeof obj.audio_path === 'string') {
|
||||
urls.push(obj.audio_path);
|
||||
}
|
||||
if (obj.path && typeof obj.path === 'string') {
|
||||
urls.push(obj.path);
|
||||
}
|
||||
if (obj.url && typeof obj.url === 'string') {
|
||||
urls.push(obj.url);
|
||||
}
|
||||
if (obj.file && typeof obj.file === 'string') {
|
||||
urls.push(obj.file);
|
||||
}
|
||||
|
||||
// Recursively check arrays and objects
|
||||
for (const key of Object.keys(obj)) {
|
||||
const val = obj[key];
|
||||
if (Array.isArray(val) || (typeof val === 'object' && val !== null)) {
|
||||
processItem(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processItem(result);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
// Get job status
|
||||
export async function getJobStatus(jobId: string): Promise<JobStatus> {
|
||||
const job = activeJobs.get(jobId);
|
||||
|
||||
if (!job) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: 'Job not found',
|
||||
};
|
||||
}
|
||||
|
||||
if (job.status === 'succeeded' && job.result) {
|
||||
return {
|
||||
status: 'succeeded',
|
||||
result: job.result,
|
||||
};
|
||||
}
|
||||
|
||||
if (job.status === 'failed') {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: job.error || 'Generation failed',
|
||||
};
|
||||
}
|
||||
|
||||
const elapsed = Math.floor((Date.now() - job.startTime) / 1000);
|
||||
|
||||
// Include queue position if queued
|
||||
if (job.status === 'queued') {
|
||||
return {
|
||||
status: job.status,
|
||||
queuePosition: job.queuePosition,
|
||||
etaSeconds: (job.queuePosition || 1) * 180, // ~3 min per job estimate
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: job.status,
|
||||
etaSeconds: Math.max(0, 180 - elapsed), // 3 min estimate
|
||||
};
|
||||
}
|
||||
|
||||
// Get raw response for debugging
|
||||
export function getJobRawResponse(jobId: string): unknown | null {
|
||||
const job = activeJobs.get(jobId);
|
||||
return job?.rawResponse || null;
|
||||
}
|
||||
|
||||
// Get audio stream from local file or remote URL
|
||||
export async function getAudioStream(audioPath: string): Promise<Response> {
|
||||
// If it's already a full URL, fetch directly
|
||||
if (audioPath.startsWith('http')) {
|
||||
return fetch(audioPath);
|
||||
}
|
||||
|
||||
// If it's a local /audio/ path, read from filesystem
|
||||
if (audioPath.startsWith('/audio/')) {
|
||||
const localPath = path.join(AUDIO_DIR, audioPath.replace('/audio/', ''));
|
||||
try {
|
||||
const { readFile } = await import('fs/promises');
|
||||
const buffer = await readFile(localPath);
|
||||
const ext = localPath.endsWith('.flac') ? 'flac' : 'mpeg';
|
||||
return new Response(buffer, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': `audio/${ext}` }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to read local audio file:', localPath, err);
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, use the ACE-Step audio endpoint
|
||||
const url = `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`;
|
||||
console.log('Fetching audio from:', url);
|
||||
return fetch(url);
|
||||
}
|
||||
|
||||
// Download audio to local storage
|
||||
export async function downloadAudio(remoteUrl: string, songId: string): Promise<string> {
|
||||
await mkdir(AUDIO_DIR, { recursive: true });
|
||||
|
||||
const response = await getAudioStream(remoteUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download audio: ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const ext = remoteUrl.includes('.flac') ? '.flac' : '.mp3';
|
||||
const filename = `${songId}${ext}`;
|
||||
const filepath = path.join(AUDIO_DIR, filename);
|
||||
|
||||
await writeFile(filepath, Buffer.from(buffer));
|
||||
console.log(`Downloaded audio to ${filepath}`);
|
||||
|
||||
return `/audio/${filename}`;
|
||||
}
|
||||
|
||||
// Download audio to buffer
|
||||
export async function downloadAudioToBuffer(remoteUrl: string): Promise<{ buffer: Buffer; size: number }> {
|
||||
const response = await getAudioStream(remoteUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download audio: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
return { buffer, size: buffer.length };
|
||||
}
|
||||
|
||||
// Cleanup job from memory
|
||||
export function cleanupJob(jobId: string): void {
|
||||
activeJobs.delete(jobId);
|
||||
}
|
||||
|
||||
// Cleanup old jobs
|
||||
export function cleanupOldJobs(maxAgeMs: number = 3600000): void {
|
||||
const now = Date.now();
|
||||
for (const [jobId, job] of activeJobs) {
|
||||
if (now - job.startTime > maxAgeMs) {
|
||||
activeJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { pool } from '../db/pool.js';
|
||||
import { getStorageProvider } from './storage/factory.js';
|
||||
|
||||
export interface CleanupResult {
|
||||
deleted: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export async function runCleanupJob(): Promise<CleanupResult> {
|
||||
console.log('Starting audio file cleanup job...');
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT af.id, af.song_id, af.storage_key, af.storage_provider
|
||||
FROM audio_files af
|
||||
WHERE af.expires_at < NOW()
|
||||
AND af.deleted_at IS NULL`
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
console.log('No expired audio files to clean up');
|
||||
return { deleted: 0, errors: 0 };
|
||||
}
|
||||
|
||||
console.log(`Found ${result.rows.length} expired audio files`);
|
||||
|
||||
const storage = getStorageProvider();
|
||||
let deleted = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const audioFile of result.rows) {
|
||||
try {
|
||||
await storage.delete(audioFile.storage_key);
|
||||
|
||||
await pool.query(
|
||||
'UPDATE audio_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = $1',
|
||||
[audioFile.id]
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
'UPDATE songs SET audio_url = NULL WHERE id = $1',
|
||||
[audioFile.song_id]
|
||||
);
|
||||
|
||||
deleted++;
|
||||
console.log(`Deleted expired audio: ${audioFile.storage_key}`);
|
||||
} catch (err) {
|
||||
errors++;
|
||||
console.error(`Failed to delete audio ${audioFile.storage_key}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Cleanup complete: ${deleted} deleted, ${errors} errors`);
|
||||
return { deleted, errors };
|
||||
}
|
||||
|
||||
export async function cleanupDeletedSongs(): Promise<number> {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM songs
|
||||
WHERE audio_url IS NULL
|
||||
AND created_at < NOW() - INTERVAL '7 days'
|
||||
RETURNING id`
|
||||
);
|
||||
|
||||
const count = result.rowCount || 0;
|
||||
if (count > 0) {
|
||||
console.log(`Cleaned up ${count} orphaned songs`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { GenerationParams } from './acestep.js';
|
||||
|
||||
type Tier = 'free' | 'pro' | 'unlimited';
|
||||
|
||||
interface QueueJob {
|
||||
id: string;
|
||||
userId: string;
|
||||
tier: Tier;
|
||||
params: GenerationParams;
|
||||
createdAt: number;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface QueueConfig {
|
||||
maxTotalWorkers: number;
|
||||
maxFreeWorkers: number;
|
||||
maxPerUser: number;
|
||||
batchWindowMs: number;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: QueueConfig = {
|
||||
maxTotalWorkers: 3,
|
||||
maxFreeWorkers: 1,
|
||||
maxPerUser: 1,
|
||||
batchWindowMs: 3000,
|
||||
batchSize: 4,
|
||||
};
|
||||
|
||||
class GenerationQueue {
|
||||
private queue: QueueJob[] = [];
|
||||
private activeJobs = new Map<string, QueueJob>();
|
||||
private activePerUser = new Map<string, number>();
|
||||
private activeFree = 0;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private config: QueueConfig;
|
||||
private persistEnqueue?: (jobId: string) => Promise<void>;
|
||||
private persistDequeue?: (jobId: string) => Promise<void>;
|
||||
|
||||
constructor(config: QueueConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
setConfig(config: QueueConfig): void {
|
||||
this.config = config;
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
getConfig(): QueueConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
setPersistence(
|
||||
enqueue: (jobId: string) => Promise<void>,
|
||||
dequeue: (jobId: string) => Promise<void>
|
||||
): void {
|
||||
this.persistEnqueue = enqueue;
|
||||
this.persistDequeue = dequeue;
|
||||
}
|
||||
|
||||
enqueue(job: QueueJob, options?: { persist?: boolean }): { position: number } {
|
||||
this.queue.push(job);
|
||||
if (options?.persist !== false) {
|
||||
this.persistEnqueue?.(job.id).catch(() => {});
|
||||
}
|
||||
this.schedule();
|
||||
return { position: this.getQueuePosition(job.id) };
|
||||
}
|
||||
|
||||
getQueuePosition(jobId: string): number {
|
||||
const index = this.queue.findIndex((job) => job.id === jobId);
|
||||
return index === -1 ? 0 : index + 1;
|
||||
}
|
||||
|
||||
markJobFinished(jobId: string): void {
|
||||
const job = this.activeJobs.get(jobId);
|
||||
if (!job) return;
|
||||
this.activeJobs.delete(jobId);
|
||||
const userCount = (this.activePerUser.get(job.userId) || 1) - 1;
|
||||
if (userCount <= 0) this.activePerUser.delete(job.userId);
|
||||
else this.activePerUser.set(job.userId, userCount);
|
||||
if (job.tier === 'free') {
|
||||
this.activeFree = Math.max(0, this.activeFree - 1);
|
||||
}
|
||||
this.persistDequeue?.(jobId).catch(() => {});
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.timer) return;
|
||||
if (this.queue.length === 0) return;
|
||||
if (this.queue.length >= this.config.batchSize) {
|
||||
this.flush();
|
||||
return;
|
||||
}
|
||||
this.timer = setTimeout(() => this.flush(), this.config.batchWindowMs);
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
this.queue.sort((a, b) => this.calculatePriority(b) - this.calculatePriority(a));
|
||||
|
||||
while (this.queue.length > 0 && this.canDispatchAny()) {
|
||||
const index = this.queue.findIndex((job) => this.canDispatch(job));
|
||||
if (index === -1) break;
|
||||
const job = this.queue.splice(index, 1)[0];
|
||||
this.dispatch(job);
|
||||
}
|
||||
|
||||
if (this.queue.length > 0) {
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(job: QueueJob): void {
|
||||
this.activeJobs.set(job.id, job);
|
||||
this.activePerUser.set(job.userId, (this.activePerUser.get(job.userId) || 0) + 1);
|
||||
if (job.tier === 'free') {
|
||||
this.activeFree += 1;
|
||||
}
|
||||
|
||||
job.run().catch(() => {
|
||||
this.markJobFinished(job.id);
|
||||
});
|
||||
}
|
||||
|
||||
private calculatePriority(job: QueueJob): number {
|
||||
const tierWeight: Record<Tier, number> = { free: 1, pro: 5, unlimited: 10 };
|
||||
const waitMinutes = (Date.now() - job.createdAt) / 60000;
|
||||
return tierWeight[job.tier] + waitMinutes;
|
||||
}
|
||||
|
||||
private canDispatch(job: QueueJob): boolean {
|
||||
if (this.activeJobs.size >= this.config.maxTotalWorkers) return false;
|
||||
if (job.tier === 'free' && this.activeFree >= this.config.maxFreeWorkers) return false;
|
||||
const perUser = this.activePerUser.get(job.userId) || 0;
|
||||
if (perUser >= this.config.maxPerUser) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private canDispatchAny(): boolean {
|
||||
return this.activeJobs.size < this.config.maxTotalWorkers;
|
||||
}
|
||||
}
|
||||
|
||||
export const generationQueue = new GenerationQueue(DEFAULT_CONFIG);
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { StorageProvider } from './index.js';
|
||||
import { LocalStorageProvider } from './local.js';
|
||||
|
||||
let storageInstance: StorageProvider | null = null;
|
||||
|
||||
export function getStorageProvider(): StorageProvider {
|
||||
if (storageInstance) {
|
||||
return storageInstance;
|
||||
}
|
||||
|
||||
// Always use local storage for ACE-Step UI
|
||||
console.log('Initializing local storage provider');
|
||||
storageInstance = new LocalStorageProvider();
|
||||
|
||||
return storageInstance;
|
||||
}
|
||||
|
||||
export function resetStorageProvider(): void {
|
||||
storageInstance = null;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface StorageProvider {
|
||||
upload(key: string, data: Buffer, contentType: string): Promise<string>;
|
||||
getUrl(key: string, expiresIn?: number): Promise<string>;
|
||||
getPublicUrl(key: string): string;
|
||||
delete(key: string): Promise<void>;
|
||||
exists(key: string): Promise<boolean>;
|
||||
copy(sourceKey: string, destKey: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type { StorageProvider as default };
|
||||
@@ -0,0 +1,60 @@
|
||||
import { writeFile, unlink, stat, mkdir, copyFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { StorageProvider } from './index.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const AUDIO_DIR = path.join(__dirname, '../../../public/audio');
|
||||
|
||||
export class LocalStorageProvider implements StorageProvider {
|
||||
private audioDir: string;
|
||||
|
||||
constructor() {
|
||||
this.audioDir = AUDIO_DIR;
|
||||
}
|
||||
|
||||
async upload(key: string, data: Buffer, _contentType: string): Promise<string> {
|
||||
const filepath = path.join(this.audioDir, key);
|
||||
await mkdir(path.dirname(filepath), { recursive: true });
|
||||
await writeFile(filepath, data);
|
||||
return `/audio/${key}`;
|
||||
}
|
||||
|
||||
async getUrl(key: string, _expiresIn?: number): Promise<string> {
|
||||
return `/audio/${key}`;
|
||||
}
|
||||
|
||||
getPublicUrl(key: string): string {
|
||||
return `/audio/${key}`;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const filepath = path.join(this.audioDir, key);
|
||||
try {
|
||||
await unlink(filepath);
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const filepath = path.join(this.audioDir, key);
|
||||
try {
|
||||
await stat(filepath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async copy(sourceKey: string, destKey: string): Promise<void> {
|
||||
const sourcePath = path.join(this.audioDir, sourceKey);
|
||||
const destPath = path.join(this.audioDir, destKey);
|
||||
await mkdir(path.dirname(destPath), { recursive: true });
|
||||
await copyFile(sourcePath, destPath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user