Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions scripts/selftest_browser_cookies.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Self-test for toBrowserCookies (SUNO_COOKIE -> Playwright addCookies shape).
* Run: npx tsx scripts/selftest_browser_cookies.mts
* Does NOT trigger any Suno generation — only parses .env SUNO_COOKIE and
* validates the resulting cookie objects against a live CDP context.
*/
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import * as cookie from 'cookie';
import { chromium } from 'rebrowser-playwright-core';
import { toBrowserCookies } from '../src/lib/SunoApi';

const raw = fs.readFileSync('.env', 'utf8').split('\n')
.find(l => l.startsWith('SUNO_COOKIE='))?.slice('SUNO_COOKIE='.length);
assert.ok(raw, 'SUNO_COOKIE missing from .env');
const parsed = cookie.parse(raw);

// 1. Junk Set-Cookie attribute keys are dropped, domain defaults to .suno.com
const withJunk = toBrowserCookies({ ...parsed, Domain: 'evil.com', 'Max-Age': '5', Path: '/x' });
assert.ok(!withJunk.some(c => ['Domain', 'Max-Age', 'Path'].includes(c.name)), 'attribute junk not dropped');

// 2. Every cookie is a valid Playwright shape
for (const c of withJunk) {
assert.ok(c.name && typeof c.value === 'string' && c.value.length > 0, `bad cookie ${c.name}`);
assert.strictEqual(c.domain, '.suno.com');
assert.strictEqual(c.path, '/');
assert.strictEqual(typeof c.httpOnly, 'boolean');
assert.strictEqual(typeof c.secure, 'boolean');
assert.ok(['Lax', 'Strict', 'None'].includes(c.sameSite));
assert.ok(!/[\x00-\x20\x7f",;\\]/.test(c.value), `illegal char left in ${c.name}`);
}

// 3. The original offender (OptanonConsent contains ';') survives sanitization
const optanon = withJunk.find(c => c.name === 'OptanonConsent');
assert.ok(optanon, 'OptanonConsent lost');
assert.ok(!optanon.value.includes(';'), 'raw ";" still in OptanonConsent');

// 4. Clean auth cookies pass through unmodified
for (const name of ['__client', 'sessionid', '__client_uat']) {
if (parsed[name]) {
assert.strictEqual(withJunk.find(c => c.name === name)?.value, parsed[name], `${name} value mutated`);
}
}

// 5. Live session cookie wins over the stale env __session
const withSession = toBrowserCookies(parsed, { name: '__session', value: 'LIVE_TOKEN', domain: '.suno.com', path: '/', sameSite: 'Lax' });
assert.strictEqual(withSession.filter(c => c.name === '__session').length, 1);
assert.strictEqual(withSession.find(c => c.name === '__session')?.value, 'LIVE_TOKEN');

// 6. Real CDP acceptance — this is the exact call that used to throw
// "Storage.setCookies: Invalid cookie fields"
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox'],
executablePath: os.homedir() + '/.local/opt/cloakbrowser/chrome'
});
const ctx = await browser.newContext();
await ctx.addCookies(withSession); // throws on any invalid field
const stored = await ctx.cookies('https://suno.com');
assert.ok(stored.some(c => c.name === 'OptanonConsent'), 'OptanonConsent not stored by CDP');
await browser.close();

console.log(`OK: ${withSession.length} cookies parsed, sanitized and accepted by CDP Storage.setCookies`);
5 changes: 4 additions & 1 deletion src/app/api/custom_generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
if (req.method === 'POST') {
try {
const body = await req.json();
const { prompt, tags, title, make_instrumental, model, wait_audio, negative_tags } = body;
const { prompt, tags, title, make_instrumental, model, wait_audio, negative_tags, upload_id, cover_clip_id, task } = body;
const audioInfo = await withGenerationConcurrency(async () => runSunoRequest(
(await cookies()).toString(),
accountTier(body.pool || req.headers.get('x-suno-pool')),
Expand All @@ -23,6 +23,9 @@ export async function POST(req: NextRequest) {
model || DEFAULT_MODEL,
Boolean(wait_audio),
negative_tags,
upload_id,
cover_clip_id,
task,
),
));
return new NextResponse(JSON.stringify(audioInfo), {
Expand Down
125 changes: 125 additions & 0 deletions src/app/api/download/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { NextResponse, NextRequest } from "next/server";
import { cookies } from 'next/headers';
import { sunoApi } from "@/lib/SunoApi";
import { corsHeaders } from "@/lib/utils";

export const maxDuration = 120; // allow server-side polling of export jobs
export const dynamic = "force-dynamic";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

/**
* Download / export router.
*
* Paths:
* path=auto -> try Studio export first (UNLIMITED); on no_permission/not
* enabled fall back to the regular download. Default.
* path=studio -> GET Suno Studio per-clip export (UNLIMITED, quota-free).
* path=sample-pack -> POST/GET stems/sample-pack export job (UNLIMITED, quota-free).
* Without ?job= starts a job; with ?job= polls it.
* path=normal -> GET the regular download endpoint (COUNTS toward the
* Premier 60/month quota - use only when a full mastered
* file is really needed).
*
* Query params:
* id clip id (required)
* format wav | mp3 | m4a (studio/normal; default wav for studio, mp3 for normal)
* job sample-pack job id (sample-pack polling)
*
* Response is the upstream payload plus our envelope:
* { path, clip_id, status, download_url?, files?, raw }
*/
export async function GET(req: NextRequest) {
try {
const url = new URL(req.url);
const clipId = url.searchParams.get('id');
const path = url.searchParams.get('path') || 'auto';
const format = url.searchParams.get('format') || (path === 'normal' ? 'mp3' : 'wav');
const jobId = url.searchParams.get('job');

if (!clipId) {
return NextResponse.json({ error: 'Missing parameter id' }, { status: 400, headers: corsHeaders });
}

const api = await sunoApi((await cookies()).toString());

if (path === 'sample-pack') {
if (!jobId) {
const started = await api.samplePackCreate(clipId);
return NextResponse.json({ path, clip_id: clipId, status: 'job_started', raw: started }, { headers: corsHeaders });
}
// Poll up to ~90s for the stems files.
let last: any = null;
for (let i = 0; i < 30; i++) {
last = await api.samplePackPoll(clipId, jobId);
if (last?.files?.length) {
return NextResponse.json({ path, clip_id: clipId, status: 'complete', files: last.files, raw: last }, { headers: corsHeaders });
}
await sleep(3000);
}
return NextResponse.json({ path, clip_id: clipId, status: 'processing', raw: last }, { headers: corsHeaders });
}

if (path === 'normal') {
// Quota-counted download: poll while upstream is preparing the file.
let last: any = null;
for (let i = 0; i < 30; i++) {
last = await api.downloadClip(clipId, format);
if (last?.download_url) {
return NextResponse.json({ path, clip_id: clipId, status: 'ready', download_url: last.download_url, raw: last }, { headers: corsHeaders });
}
if (last?.status && last.status !== 'processing') break;
await sleep(3000);
}
return NextResponse.json({ path, clip_id: clipId, status: last?.status || 'unknown', download_url: last?.download_url, raw: last }, { headers: corsHeaders });
}

if (path === 'studio' || path === 'auto') {
// Unlimited Suno Studio export (falls back to normal download on
// account-level no_permission when path=auto).
let last: any = null;
let gated = false;
for (let i = 0; i < 5; i++) {
last = await api.studioClipDownload(clipId, format);
if (last?.download_url) {
return NextResponse.json({ path, clip_id: clipId, status: 'ready', download_url: last.download_url, raw: last }, { headers: corsHeaders });
}
if (last?.reason === 'no_permission' || last?.reason === 'not_enabled' || last?.ok === false) {
gated = true;
break;
}
if (last?.status && last.status !== 'processing') break;
await sleep(3000);
}
if (!gated) {
return NextResponse.json({ path, clip_id: clipId, status: last?.status || 'unknown', download_url: last?.download_url, raw: last }, { headers: corsHeaders });
}
if (path === 'studio') {
return NextResponse.json({ path, clip_id: clipId, status: 'gated', raw: last }, { headers: corsHeaders });
}
// auto: fall back to the quota-counted normal download (mp3).
let norm: any = null;
for (let i = 0; i < 30; i++) {
norm = await api.downloadClip(clipId, 'mp3');
if (norm?.download_url) {
return NextResponse.json({ path: 'auto(fallback=normal)', clip_id: clipId, status: 'ready', download_url: norm.download_url, raw: norm, studio_gate: last }, { headers: corsHeaders });
}
if (norm?.status && norm.status !== 'processing') break;
await sleep(3000);
}
return NextResponse.json({ path: 'auto(fallback=normal)', clip_id: clipId, status: norm?.status || 'unknown', download_url: norm?.download_url, raw: norm, studio_gate: last }, { headers: corsHeaders });
}

return NextResponse.json({ error: `Unknown path: ${path}. Use auto | studio | sample-pack | normal` }, { status: 400, headers: corsHeaders });
} catch (error: any) {
console.error('Error in /api/download:', error);
return NextResponse.json(
{ error: error.response?.data?.detail || error.toString() },
{ status: error.response?.status || 500, headers: corsHeaders }
);
}
}

export async function OPTIONS() {
return new Response(null, { status: 200, headers: corsHeaders });
}
124 changes: 124 additions & 0 deletions src/app/api/uploads/audio/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { NextResponse, NextRequest } from "next/server";
import { cookies } from 'next/headers';
import { sunoApi } from "@/lib/SunoApi";
import { corsHeaders } from "@/lib/utils";

export const maxDuration = 300; // multipart upload + upstream chain
export const dynamic = "force-dynamic";

/**
* Audio upload proxy (cover generation source).
*
* Accepts multipart/form-data with the audio file and runs the native Suno
* web upload chain:
* POST /api/uploads/audio/ -> { id, url, fields (S3 POST policy) }
* POST <s3 url> (multipart: policy fields + file) (raw audio bytes)
* POST /api/uploads/audio/{id}/upload-finish/ { upload_type, upload_filename }
* GET /api/uploads/audio/{id}/ poll until status == 'complete'
* POST /api/uploads/audio/{id}/initialize-clip/ { user_reviewed_tags: true } -> clip_id
*
* Form fields:
* file the audio file (also accepts field name "audio")
* mime_type optional override of the file's content type
*
* Response envelope:
* { upload_id, clip_id, status, raw: { create, finish, poll, initialize } }
*
* The returned clip_id is the cover source for custom_generate
* (upload_id / cover_clip_id parameters).
*/
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get('file') || formData.get('audio');
if (!(file instanceof File)) {
return NextResponse.json(
{ error: 'Missing multipart field "file" (or "audio") with the audio to upload' },
{ status: 400, headers: corsHeaders }
);
}
// Suno signs the S3 policy for a declared mime type; the web client
// uploads audio/mpeg, so default to it unless explicitly overridden.
const mimeType = (formData.get('mime_type') as string) || 'audio/mpeg';
const data = Buffer.from(await file.arrayBuffer());
if (!data.length) {
return NextResponse.json({ error: 'Empty file' }, { status: 400, headers: corsHeaders });
}

const api = await sunoApi((await cookies()).toString());

// 1. Create the upload slot (returns id + S3 POST-policy url/fields).
const created = await api.createAudioUpload(mimeType);
const uploadId = created?.upload_id || created?.id;
const uploadUrl = created?.upload_url || created?.url;
if (!uploadId || !uploadUrl || !created?.fields) {
return NextResponse.json(
{ status: 'create_failed', raw: created },
{ status: 502, headers: corsHeaders }
);
}

// 2. POST the bytes per the S3 policy (no Suno auth headers). The
// policy is signed for the Content-Type Suno picked, so use it verbatim.
const s3ContentType = created.fields['Content-Type'] || mimeType;
try {
await api.postUploadFile(uploadUrl, created.fields, data, file.name || 'upload.mp3', s3ContentType);
} catch (putErr: any) {
return NextResponse.json({
status: 'put_failed',
upload_id: uploadId,
upload_url: uploadUrl,
create_raw: created,
error: putErr.response?.data || putErr.toString()
}, { status: 502, headers: corsHeaders });
}

// 3. Finish the upload, wait for processing, then initialize the clip
// (native web order, recovered from the suno.com bundles).
const finished = await api.finishAudioUpload(uploadId, 'file_upload', file.name || 'upload.mp3');

let polled: any = null;
for (let i = 0; i < 45; i++) {
polled = await api.getAudioUpload(uploadId);
if (polled?.status === 'complete') break;
if (polled?.status === 'error') {
return NextResponse.json({
status: 'upload_error',
upload_id: uploadId,
error: polled?.error_message || polled,
raw: { create: created, finish: finished, poll: polled }
}, { status: 502, headers: corsHeaders });
}
await new Promise((r) => setTimeout(r, 3000));
}
if (polled?.status !== 'complete') {
return NextResponse.json({
status: 'processing_timeout',
upload_id: uploadId,
raw: { create: created, finish: finished, poll: polled }
}, { status: 504, headers: corsHeaders });
}

const initialized = await api.initializeUploadClip(uploadId);
const clipId = initialized?.clip_id || initialized?.clip?.id;

return NextResponse.json({
upload_id: uploadId,
clip_id: clipId,
mime_type: mimeType,
size: data.length,
status: 'ready',
raw: { create: created, finish: finished, poll: polled, initialize: initialized }
}, { headers: corsHeaders });
} catch (error: any) {
console.error('Error in /api/uploads/audio:', error);
return NextResponse.json(
{ error: error.response?.data?.detail || error.response?.data || error.toString() },
{ status: error.response?.status || 500, headers: corsHeaders }
);
}
}

export async function OPTIONS() {
return new Response(null, { status: 200, headers: corsHeaders });
}
Loading