Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

232 lines
11 KiB
JavaScript

/**
* `opencli suno generate` — submit a Suno music-generation request, wait for
* both clips to finish, and download selected formats locally.
*
* Targets the /api/generate/v2-web/ endpoint (cookie auth). Each generation
* returns 2 candidate clips by design — both are downloaded so the caller
* can A/B them.
*
* Modes:
* - Custom (when --lyrics is provided): API receives prompt(lyrics)+tags
* +title+negative_tags. Use this for professional control over lyrics,
* structure metatags, style, and exclusions.
* - Simple (default): API receives a description in `prompt`; Suno picks
* the lyrics, tags, and title.
*
* Creative knobs (--weirdness / --style-weight) map directly to the
* `metadata.control_sliders` the web UI exposes.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import {
DEFAULT_SUNO_MODEL,
SUNO_DOMAIN,
SUNO_MODELS,
SUNO_URL,
checkSunoCaptcha,
clampSlider,
downloadSunoClip,
ensureSunoSession,
normalizeBooleanFlag,
parseFormats,
pollSunoClips,
requirePositiveInt,
resolveSunoOutputDir,
submitSunoGeneration,
} from './utils.js';
import * as crypto from 'node:crypto';
import * as os from 'node:os';
function displayPath(filePath) {
if (!filePath) return '-';
const home = os.homedir();
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
}
export const generateCommand = cli({
site: 'suno',
name: 'generate',
access: 'write',
description: 'Generate music with Suno (V5.5 chirp-fenix by default) and download clips locally',
domain: SUNO_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
siteSession: 'persistent',
navigateBefore: false,
defaultFormat: 'plain',
args: [
{ name: 'prompt', positional: true, required: false, help: 'Simple-mode description (ignored when --lyrics is provided)' },
{ name: 'lyrics', help: 'Custom-mode lyrics (with [Verse]/[Chorus] metatags). Triggers Custom mode.' },
{ name: 'tags', help: 'Custom-mode style tags (genre, BPM, instruments...). Used with --lyrics.' },
{ name: 'negative-tags', help: 'Custom-mode style exclusions (e.g. "no vocals, no autotune"). Used with --lyrics.' },
{ name: 'title', help: 'Song title (default: auto-derived from prompt)' },
{ name: 'instrumental', type: 'boolean', default: false, help: 'No vocals' },
{ name: 'model', help: `Model id: ${SUNO_MODELS.join(', ')}. Default: ${DEFAULT_SUNO_MODEL}` },
{ name: 'weirdness', help: 'Creative weirdness slider (0..1). Default: 0.5' },
{ name: 'style-weight', help: 'Style adherence slider (0..1). Default: 0.5' },
{ name: 'formats', help: 'Comma-separated download formats: mp3, m4a, wav, video, cover, metadata. Default: mp3,metadata' },
{ name: 'op', help: 'Output directory (default: ~/Music/suno)' },
{ name: 'timeout', type: 'int', default: 300, help: 'Max seconds to wait for clips to finish (default: 300)' },
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download; only print clip ids and Suno URLs' },
{ name: 'confirm-paid', type: 'boolean', default: false, help: 'Required to allow paid downloads (wav). Without it, paid formats are skipped with a warning.' },
],
columns: ['status', 'clip', 'title', 'files', 'link'],
func: async (page, kwargs) => {
const lyrics = kwargs.lyrics ? String(kwargs.lyrics) : '';
const tags = kwargs.tags ? String(kwargs.tags) : '';
const negativeTags = kwargs['negative-tags'] ? String(kwargs['negative-tags']) : '';
const description = kwargs.prompt ? String(kwargs.prompt) : '';
const titleArg = kwargs.title ? String(kwargs.title) : '';
const model = kwargs.model ? String(kwargs.model).trim() : DEFAULT_SUNO_MODEL;
if (!SUNO_MODELS.includes(model)) {
throw new ArgumentError(`Unsupported --model "${model}"`, `Choices: ${SUNO_MODELS.join(', ')}`);
}
const isCustom = lyrics.trim() !== '';
if (!isCustom && !description.trim()) {
throw new ArgumentError(
'Either provide a Simple-mode prompt as the positional argument, or pass --lyrics for Custom mode.',
'Examples:\n opencli suno generate "lo-fi study beat, 80 bpm"\n opencli suno generate --lyrics "[Verse]\\n..." --tags "synthwave, 120 bpm"',
);
}
if (!isCustom && (tags || negativeTags)) {
throw new ArgumentError('--tags and --negative-tags only apply in Custom mode (alongside --lyrics).');
}
const requestedFormats = parseFormats(kwargs.formats);
const confirmPaid = normalizeBooleanFlag(kwargs['confirm-paid']);
const skipDownload = normalizeBooleanFlag(kwargs.sd);
const PAID_FORMATS = new Set(['wav']);
const skippedPaid = [];
const formats = requestedFormats.filter(f => {
if (PAID_FORMATS.has(f) && !confirmPaid) {
skippedPaid.push(f);
return false;
}
return true;
});
if (!skipDownload && !formats.length) {
throw new ArgumentError('All requested formats require --confirm-paid true', 'Add --confirm-paid true or include a free format such as mp3 or metadata.');
}
const outputDir = resolveSunoOutputDir(kwargs.op);
const timeout = requirePositiveInt(kwargs.timeout, '--timeout');
const makeInstrumental = normalizeBooleanFlag(kwargs.instrumental);
const weirdness = clampSlider(kwargs.weirdness, '--weirdness', 0.5);
const styleWeight = clampSlider(kwargs['style-weight'], '--style-weight', 0.5);
// Title: required by API. Auto-derive from first 60 chars of source prompt if not provided.
const titleSource = titleArg || (isCustom ? (tags || lyrics.split('\n')[0]) : description);
const title = titleSource.replace(/\s+/g, ' ').trim().slice(0, 60) || 'Untitled';
const session = await ensureSunoSession(page);
if (!session.planId) {
throw new CommandExecutionError(
`Suno generation needs a resolved plan id for the user_tier field, but billing/info did not surface one for this account (subscription_type=${session.planKey}). Verify the account is active at ${SUNO_URL}/account, then retry.`,
);
}
const deviceId = session.deviceId;
const captcha = await checkSunoCaptcha(page, deviceId);
if (!captcha?.ok) {
throw new CommandExecutionError(
`Suno captcha pre-flight failed${captcha?.status ? ` (HTTP ${captcha.status})` : ''}.`,
`Open ${SUNO_URL}/create in Chrome and verify the account is ready, then retry.`,
);
}
if (captcha?.required) {
throw new CommandExecutionError(
'Suno requires a CAPTCHA challenge for this account/IP right now.',
`Open ${SUNO_URL}/create in Chrome, solve a Create challenge once, then retry.`,
);
}
if (session.totalCreditsAvailable < 10) {
const b = session.breakdown;
throw new CommandExecutionError(
`Suno generation needs ~10 credits; you have ${session.totalCreditsAvailable} (monthly ${b.monthlyRemaining}/${b.monthlyLimit} + packs ${b.purchasedPacks} + leftover ${b.pack}). Top up at ${SUNO_URL}/account.`,
);
}
const transactionUuid = crypto.randomUUID();
const createSessionToken = crypto.randomUUID();
const submission = await submitSunoGeneration(page, {
mode: isCustom ? 'custom' : 'simple',
model,
title,
lyrics,
tags,
negativeTags,
description,
makeInstrumental,
weirdness,
styleWeight,
userTier: session.planId,
createSessionToken,
transactionUuid,
deviceId,
});
if (!Array.isArray(submission.clips)) {
throw new CommandExecutionError('Suno generation returned malformed clips payload.');
}
const clipIds = submission.clips.map(c => c?.id);
if (clipIds.some(id => !id)) {
throw new CommandExecutionError('Suno generation returned malformed clip identity.');
}
if (!clipIds.length) {
throw new CommandExecutionError('Suno accepted the request but returned no clip ids.');
}
const clips = await pollSunoClips(page, clipIds, timeout, deviceId);
const completed = clips.filter(c => c.status === 'complete');
if (!completed.length) {
const errors = clips.map(c => `${c.id.slice(0, 8)}:${c.status}`).join(', ');
throw new CommandExecutionError(`All Suno clips failed (${errors}). Open ${SUNO_URL}/song/${clipIds[0]} to inspect.`);
}
const rows = [];
for (const clip of clips) {
const link = `${SUNO_URL}/song/${clip.id}`;
if (clip.status !== 'complete') {
rows.push({
status: `❌ ${clip.status}`,
clip: clip.id.slice(0, 8),
title: clip.title || '(untitled)',
files: '-',
link: `🔗 ${link}`,
});
continue;
}
if (skipDownload) {
rows.push({
status: '🎵 generated',
clip: clip.id.slice(0, 8),
title: clip.title || '(untitled)',
files: '📁 -',
link: `🔗 ${link}`,
});
continue;
}
const result = await downloadSunoClip(page, clip, outputDir, formats, deviceId);
if (!result.written.some(w => w.ok)) {
throw new CommandExecutionError(`Suno download wrote no files for clip ${clip.id}`);
}
const writtenSummary = result.written
.map(w => w.ok ? `${w.format}:${displayPath(w.file)}` : `${w.format}:✗(${w.reason})`)
.join(' | ');
const skippedSummary = skippedPaid.length
? ` | skipped(needs --confirm-paid):${skippedPaid.join(',')}`
: '';
const anyFailed = result.written.some(w => !w.ok);
rows.push({
status: anyFailed ? '⚠ partial' : '✅ saved',
clip: clip.id.slice(0, 8),
title: clip.title || '(untitled)',
files: `📁 ${writtenSummary}${skippedSummary}`,
link: `🔗 ${link}`,
});
}
return rows;
},
});