diff --git a/scripts/selftest_browser_cookies.mts b/scripts/selftest_browser_cookies.mts new file mode 100644 index 0000000..b7701f0 --- /dev/null +++ b/scripts/selftest_browser_cookies.mts @@ -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`); diff --git a/src/app/api/custom_generate/route.ts b/src/app/api/custom_generate/route.ts index b499de3..f7f3957 100644 --- a/src/app/api/custom_generate/route.ts +++ b/src/app/api/custom_generate/route.ts @@ -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')), @@ -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), { diff --git a/src/app/api/download/route.ts b/src/app/api/download/route.ts new file mode 100644 index 0000000..eb8e14d --- /dev/null +++ b/src/app/api/download/route.ts @@ -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 }); +} diff --git a/src/app/api/uploads/audio/route.ts b/src/app/api/uploads/audio/route.ts new file mode 100644 index 0000000..a0a032c --- /dev/null +++ b/src/app/api/uploads/audio/route.ts @@ -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 (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 }); +} diff --git a/src/lib/SunoApi.ts b/src/lib/SunoApi.ts index 00f8ab2..7e54240 100644 --- a/src/lib/SunoApi.ts +++ b/src/lib/SunoApi.ts @@ -29,6 +29,39 @@ globalForSunoApi.sunoApiCache = cache; const logger = pino(); export const DEFAULT_MODEL = DEFAULT_SUNO_MODEL; +// Set-Cookie attribute names that leak into SUNO_COOKIE dumps — not real cookies. +const COOKIE_ATTR_KEYS = new Set(['domain', 'path', 'expires', 'max-age', 'httponly', 'secure', 'samesite']); +// Chromium rejects these characters in cookie values (CDP Storage.setCookies "Invalid cookie fields"). +const BAD_COOKIE_CHARS = /[\x00-\x20\x7f",;\\]/g; + +/** + * Converts a name->value cookie map (parsed from SUNO_COOKIE) into objects valid for + * Playwright's context.addCookies: drops attribute junk, percent-encodes illegal value + * chars (e.g. ';' inside OneTrust OptanonConsent), defaults domain to .suno.com. + */ +export function toBrowserCookies( + cookies: Record, + session?: { name: string; value: string; domain: string; path: string; sameSite: 'Lax' | 'Strict' | 'None' } +) { + const out: Array<{ name: string; value: string; domain: string; path: string; httpOnly: boolean; secure: boolean; sameSite: 'Lax' | 'Strict' | 'None' }> = []; + if (session) out.push({ ...session, httpOnly: false, secure: true }); + for (const [name, raw] of Object.entries(cookies)) { + if (!name || raw === undefined || raw === '') continue; + if (COOKIE_ATTR_KEYS.has(name.toLowerCase())) continue; + if (session && name === session.name) continue; // live session token wins over stale env value + out.push({ + name, + value: String(raw).replace(BAD_COOKIE_CHARS, encodeURIComponent), + domain: '.suno.com', + path: '/', + httpOnly: false, + secure: true, + sameSite: 'Lax' + }); + } + return out; +} + export interface AudioInfo { id: string; // Unique identifier for the audio title?: string; // Title of the audio @@ -291,24 +324,14 @@ export class SunoApi { try { await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: 'https://suno.com' }); } catch {} - const cookies = []; const lax: 'Lax' | 'Strict' | 'None' = 'Lax'; - cookies.push({ + const cookies = toBrowserCookies(this.cookies, { name: '__session', value: this.currentToken+'', domain: '.suno.com', path: '/', sameSite: lax }); - for (const key in this.cookies) { - cookies.push({ - name: key, - value: this.cookies[key]+'', - domain: '.suno.com', - path: '/', - sameSite: lax - }) - } await context.addCookies(cookies); return context; } @@ -877,9 +900,15 @@ export class SunoApi { make_instrumental: boolean = false, model?: string, wait_audio: boolean = false, - negative_tags?: string + negative_tags?: string, + upload_id?: string, + cover_clip_id?: string, + task?: string ): Promise { const startTime = Date.now(); + // Cover source: an existing/uploaded clip id. upload_id is accepted as an + // alias for API compatibility (the upload route returns the clip id). + const coverSource = cover_clip_id || upload_id; const audios = await this.generateSongs( prompt, true, @@ -888,7 +917,11 @@ export class SunoApi { make_instrumental, model, wait_audio, - negative_tags + negative_tags, + coverSource ? (task || 'cover') : task, + undefined, + undefined, + coverSource ); const costTime = Date.now() - startTime; logger.info( @@ -923,7 +956,8 @@ export class SunoApi { negative_tags?: string, task?: string, continue_clip_id?: string, - continue_at?: number + continue_at?: number, + cover_clip_id?: string ): Promise { await this.keepAlive(); const payload: any = { @@ -936,6 +970,14 @@ export class SunoApi { task: task, token: await this.getCaptcha() }; + // Cover generation (native web payload): the source clip + // (e.g. a user-uploaded audio clip) goes into cover_clip_id, task 'cover'. + if (cover_clip_id) { + payload.cover_clip_id = cover_clip_id; + payload.cover_start_s = null; + payload.cover_end_s = null; + if (!payload.task) payload.task = 'cover'; + } if (isCustom) { payload.tags = tags; payload.title = title; @@ -1215,6 +1257,153 @@ export class SunoApi { return response.data; } + + /** + * Quota-free download paths. + * Premier plan counts only "normal" downloads against the monthly quota; + * Suno Studio exports (per-clip download, sample packs / stems) are unlimited. + */ + + /** + * Normal (quota-counted) download. Returns upstream payload + * ({ status: 'processing' | ..., download_url? }). + */ + public async downloadClip(clipId: string, format: string = 'mp3'): Promise { + await this.keepAlive(false); + const response = await this.client.get( + `${SunoApi.BASE_URL}/api/download/clip/${clipId}`, + { params: { format }, timeout: 30000 } + ); + return response.data; + } + + /** + * Studio per-clip download (unlimited). Returns upstream payload with download_url. + */ + public async studioClipDownload(clipId: string, format: string = 'wav'): Promise { + await this.keepAlive(false); + const response = await this.client.get( + `${SunoApi.BASE_URL}/api/studio/clip/${clipId}/download`, + { params: { format }, timeout: 30000 } + ); + return response.data; + } + + /** + * Start a sample-pack (high-quality stems) export job. Returns { job_id?, ... }. + */ + public async samplePackCreate(clipId: string): Promise { + await this.keepAlive(false); + const response = await this.client.post( + `${SunoApi.BASE_URL}/api/download/sample-pack/${clipId}`, + {}, + { timeout: 30000 } + ); + return response.data; + } + + /** + * Poll a sample-pack export job. Done when files[] is present in the response. + */ + public async samplePackPoll(clipId: string, jobId: string): Promise { + await this.keepAlive(false); + const response = await this.client.get( + `${SunoApi.BASE_URL}/api/download/sample-pack/${clipId}`, + { params: { job_id: jobId }, timeout: 30000 } + ); + return response.data; + } + + /** + * Audio upload chain (cover generation source). + * Native web flow: POST /api/uploads/audio/ -> PUT presigned url -> + * POST /api/uploads/audio/{id}/initialize-clip/ -> POST .../upload-finish/ + */ + + /** + * Create an upload slot. Returns upstream payload: { id, url, fields: + * { key, policy, signature, AWSAccessKeyId, Content-Type, ... } }. Suno + * hands out an S3 POST-policy upload (multipart form), NOT a presigned PUT. + * Native web body: { extension, is_stem_mix, upload_type }. + */ + public async createAudioUpload(mimeType: string, extension?: string): Promise { + await this.keepAlive(false); + const body: any = { + is_stem_mix: false, + upload_type: 'file_upload', + mime_type: mimeType + }; + if (extension) body.extension = extension; + const response = await this.client.post( + `${SunoApi.BASE_URL}/api/uploads/audio/`, + body, + { timeout: 30000 } + ); + return response.data; + }; + + /** + * POST the audio bytes to the S3 policy upload. Uses a plain axios + * instance on purpose: the S3 endpoint must NOT receive our Suno auth + * headers, and the multipart form must contain exactly the policy fields. + */ + public async postUploadFile(uploadUrl: string, fields: Record, data: Buffer, fileName: string, contentType: string): Promise { + const form = new FormData(); + for (const [key, value] of Object.entries(fields || {})) { + if (key === 'Content-Type') continue; // set from the declared mime type below + form.append(key, value); + } + form.append('Content-Type', contentType); + form.append('file', new Blob([data]), fileName); + await axios.post(uploadUrl, form, { + maxContentLength: Infinity, + maxBodyLength: Infinity, + timeout: 120000 + }); + }; + + /** + * Initialize a clip from the uploaded audio (call AFTER upload-finish and + * after the upload status reaches 'complete'). Returns payload with clip_id. + * Native web body: {} or { user_reviewed_tags: true }. + */ + public async initializeUploadClip(uploadId: string): Promise { + await this.keepAlive(false); + const response = await this.client.post( + `${SunoApi.BASE_URL}/api/uploads/audio/${uploadId}/initialize-clip/`, + { user_reviewed_tags: true }, + { timeout: 60000 } + ); + return response.data; + }; + + /** + * Mark the upload as finished (native web body: upload_type + filename). + * Triggers server-side processing; poll getAudioUpload until 'complete'. + */ + public async finishAudioUpload(uploadId: string, uploadType: string = 'file_upload', fileName?: string): Promise { + await this.keepAlive(false); + const body: any = { upload_type: uploadType }; + if (fileName) body.upload_filename = fileName; + const response = await this.client.post( + `${SunoApi.BASE_URL}/api/uploads/audio/${uploadId}/upload-finish/`, + body, + { timeout: 30000 } + ); + return response.data; + }; + + /** + * Poll the upload processing status. status: 'complete' | 'error' | other. + */ + public async getAudioUpload(uploadId: string): Promise { + await this.keepAlive(false); + const response = await this.client.get( + `${SunoApi.BASE_URL}/api/uploads/audio/${uploadId}/`, + { timeout: 30000 } + ); + return response.data; + }; } async function directSunoApi(resolvedCookie: string) {