import axios, { AxiosInstance } from 'axios'; import UserAgent from 'user-agents'; import pino from 'pino'; import yn from 'yn'; import { sleep, isPage } from '@/lib/utils'; import * as cookie from 'cookie'; import { randomUUID } from 'node:crypto'; import { Solver } from '@2captcha/captcha-solver'; import { BrowserContext, Page, Locator, chromium, firefox } from 'rebrowser-playwright-core'; import { createCursor, Cursor } from 'ghost-cursor-playwright'; import { paramsCoordinates } from '@2captcha/captcha-solver/dist/structs/2captcha'; import { promises as fs } from 'fs'; import path from 'node:path'; // sunoApi instance caching const globalForSunoApi = global as unknown as { sunoApiCache?: Map }; const cache = globalForSunoApi.sunoApiCache || new Map(); globalForSunoApi.sunoApiCache = cache; const logger = pino(); export const DEFAULT_MODEL = 'chirp-v3-5'; export interface AudioInfo { id: string; // Unique identifier for the audio title?: string; // Title of the audio image_url?: string; // URL of the image associated with the audio lyric?: string; // Lyrics of the audio audio_url?: string; // URL of the audio file video_url?: string; // URL of the video associated with the audio created_at: string; // Date and time when the audio was created model_name: string; // Name of the model used for audio generation gpt_description_prompt?: string; // Prompt for GPT description prompt?: string; // Prompt for audio generation status: string; // Status type?: string; tags?: string; // Genre of music. negative_tags?: string; // Negative tags of music. duration?: string; // Duration of the audio error_message?: string; // Error message if any } class SunoApi { private static BASE_URL: string = 'https://studio-api.prod.suno.com'; private static CLERK_BASE_URL: string = 'https://clerk.suno.com'; private static CLERK_VERSION = '5.15.0'; private readonly client: AxiosInstance; private sid?: string; private currentToken?: string; private deviceId?: string; private userAgent?: string; private cookies: Record; private solver = new Solver(process.env.TWOCAPTCHA_KEY + ''); private ghostCursorEnabled = yn(process.env.BROWSER_GHOST_CURSOR, { default: false }); private cursor?: Cursor; constructor(cookies: string) { this.userAgent = new UserAgent(/Macintosh/).random().toString(); // Usually Mac systems get less amount of CAPTCHAs this.cookies = cookie.parse(cookies); this.deviceId = this.cookies.ajs_anonymous_id || randomUUID(); this.client = axios.create({ withCredentials: true, headers: { 'Affiliate-Id': 'undefined', 'Device-Id': `"${this.deviceId}"`, 'x-suno-client': 'Android prerelease-4nt180t 1.0.42', 'X-Requested-With': 'com.suno.android', 'sec-ch-ua': '"Chromium";v="130", "Android WebView";v="130", "Not?A_Brand";v="99"', 'sec-ch-ua-mobile': '?1', 'sec-ch-ua-platform': '"Android"', 'User-Agent': this.userAgent } }); this.client.interceptors.request.use(config => { if (this.currentToken && !config.headers.Authorization) config.headers.Authorization = `Bearer ${this.currentToken}`; const cookiesArray = Object.entries(this.cookies).map(([key, value]) => cookie.serialize(key, value as string) ); config.headers.Cookie = cookiesArray.join('; '); return config; }); this.client.interceptors.response.use(resp => { const setCookieHeader = resp.headers['set-cookie']; if (Array.isArray(setCookieHeader)) { const newCookies = cookie.parse(setCookieHeader.join('; ')); for (const [key, value] of Object.entries(newCookies)) { this.cookies[key] = value; } } return resp; }) } public async init(): Promise { //await this.getClerkLatestVersion(); await this.getAuthToken(); await this.keepAlive(); return this; } /** * Get the clerk package latest version id. * This method is commented because we are now using a hard-coded Clerk version, hence this method is not needed. private async getClerkLatestVersion() { // URL to get clerk version ID const getClerkVersionUrl = `${SunoApi.JSDELIVR_BASE_URL}/v1/package/npm/@clerk/clerk-js`; // Get clerk version ID const versionListResponse = await this.client.get(getClerkVersionUrl); if (!versionListResponse?.data?.['tags']['latest']) { throw new Error( 'Failed to get clerk version info, Please try again later' ); } // Save clerk version ID for auth SunoApi.clerkVersion = versionListResponse?.data?.['tags']['latest']; } */ /** * Get the session ID and save it for later use. */ private async getAuthToken() { logger.info('Getting the session ID'); // URL to get session ID const getSessionUrl = `${SunoApi.CLERK_BASE_URL}/v1/client?_is_native=true&_clerk_js_version=${SunoApi.CLERK_VERSION}`; // Get session ID const sessionResponse = await this.client.get(getSessionUrl, { headers: { Authorization: this.cookies.__client } }); if (!sessionResponse?.data?.response?.last_active_session_id) { throw new Error( 'Failed to get session id, you may need to update the SUNO_COOKIE' ); } // Save session ID for later use this.sid = sessionResponse.data.response.last_active_session_id; } /** * Keep the session alive. * @param isWait Indicates if the method should wait for the session to be fully renewed before returning. */ public async keepAlive(isWait?: boolean): Promise { if (!this.sid) { throw new Error('Session ID is not set. Cannot renew token.'); } // URL to renew session token const renewUrl = `${SunoApi.CLERK_BASE_URL}/v1/client/sessions/${this.sid}/tokens?_is_native=true&_clerk_js_version=${SunoApi.CLERK_VERSION}`; // Renew session token logger.info('KeepAlive...\n'); const renewResponse = await this.client.post(renewUrl, {}, { headers: { Authorization: this.cookies.__client } }); if (isWait) { await sleep(1, 2); } const newToken = renewResponse.data.jwt; // Update Authorization field in request header with the new JWT token this.currentToken = newToken; } /** * Get the session token (not to be confused with session ID) and save it for later use. */ private async getSessionToken() { const tokenResponse = await this.client.post( `${SunoApi.BASE_URL}/api/user/create_session_id/`, { session_properties: JSON.stringify({ deviceId: this.deviceId }), session_type: 1 } ); return tokenResponse.data.session_id; } private async captchaRequired(): Promise { const resp = await this.client.post(`${SunoApi.BASE_URL}/api/c/check`, { ctype: 'generation' }); logger.info(resp.data); // await sleep(10); return resp.data.required; } /** * Clicks on a locator or XY vector. This method is made because of the difference between ghost-cursor-playwright and Playwright methods */ private async click(target: Locator|Page, position?: { x: number, y: number }): Promise { if (this.ghostCursorEnabled) { let pos: any = isPage(target) ? { x: 0, y: 0 } : await target.boundingBox(); if (position) pos = { ...pos, x: pos.x + position.x, y: pos.y + position.y, width: null, height: null, }; return this.cursor?.actions.click({ target: pos }); } else { if (isPage(target)) return target.mouse.click(position?.x ?? 0, position?.y ?? 0); else return target.click({ force: true, position }); } } /** * Get the BrowserType from the `BROWSER` environment variable. * @returns {BrowserType} chromium, firefox or webkit. Default is chromium */ private getBrowserType() { const browser = process.env.BROWSER?.toLowerCase(); switch (browser) { case 'firefox': return firefox; /*case 'webkit': ** doesn't work with rebrowser-patches case 'safari': return webkit;*/ default: return chromium; } } /** * Launches a browser with the necessary cookies * @returns {BrowserContext} */ private async launchBrowser(): Promise { const browser = await this.getBrowserType().launch({ args: [ '--disable-blink-features=AutomationControlled', '--disable-web-security', '--no-sandbox', '--disable-dev-shm-usage', '--disable-features=site-per-process', '--disable-features=IsolateOrigins', '--disable-extensions', '--disable-infobars' ], headless: yn(process.env.BROWSER_HEADLESS, { default: true }) }); const context = await browser.newContext({ userAgent: this.userAgent, locale: process.env.BROWSER_LOCALE, viewport: null }); const cookies = []; const lax: 'Lax' | 'Strict' | 'None' = 'Lax'; cookies.push({ 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; } /** * Checks for CAPTCHA verification and solves the CAPTCHA if needed * @returns {string|null} hCaptcha token. If no verification is required, returns null */ public async getCaptcha(): Promise { if (!await this.captchaRequired()) return null; logger.info('CAPTCHA required. Launching browser...') const browser = await this.launchBrowser(); const page = await browser.newPage(); await page.goto('https://suno.com/create', { referer: 'https://www.google.com/', waitUntil: 'domcontentloaded', timeout: 0 }); logger.info('Waiting for Suno interface to load'); //await page.locator('.react-aria-GridList').waitFor({ timeout: 60000 }); await page.waitForResponse('**/api/feed/v2**', { timeout: 60000 }); // wait for song list API call if (this.ghostCursorEnabled) this.cursor = await createCursor(page); logger.info('Triggering the CAPTCHA'); await this.click(page, { x: 318, y: 13 }); // close all popups const textarea = page.locator('.custom-textarea'); await this.click(textarea); await textarea.pressSequentially('Lorem ipsum', { delay: 80 }); const button = page.locator('button[aria-label="Create"]').locator('div.flex'); await this.click(button); new Promise(async (resolve, reject) => { const frame = page.frameLocator('iframe[title*="hCaptcha"]'); const challenge = frame.locator('.challenge-container'); try { while (true) { await page.waitForResponse('https://img**.hcaptcha.com/**', { timeout: 60000 }); // wait for hCaptcha image to load while (true) { // wait for all requests to finish try { await page.waitForResponse('https://img**.hcaptcha.com/**', { timeout: 1000 }); } catch(e) { break } } const drag = (await challenge.locator('.prompt-text').first().innerText()).toLowerCase().includes('drag'); let captcha: any; for (let j = 0; j < 3; j++) { // try several times because sometimes 2Captcha could send an error try { logger.info('Sending the CAPTCHA to 2Captcha'); const payload: paramsCoordinates = { body: (await challenge.screenshot()).toString('base64'), lang: process.env.BROWSER_LOCALE }; if (drag) { // Say to the worker that he needs to click payload.textinstructions = '! Instead of dragging, CLICK on the shapes as shown in the image above !'; payload.imginstructions = (await fs.readFile(path.join(process.cwd(), 'public', 'drag-instructions.jpg'))).toString('base64'); } captcha = await this.solver.coordinates(payload); break; } catch(err: any) { logger.info(err.message); if (j != 2) logger.info('Retrying...'); else throw err; } } if (drag) { const challengeBox = await challenge.boundingBox(); if (challengeBox == null) throw new Error('.challenge-container boundingBox is null!'); for (let i = 0; i < captcha.data.length; i += 2) { const data1 = captcha.data[i]; const data2 = captcha.data[i+1]; logger.info(JSON.stringify(data1) + JSON.stringify(data2)); await page.mouse.move(challengeBox.x + +data1.x, challengeBox.y + +data1.y); await page.mouse.down(); await sleep(1.1); // wait for the piece to be 'unlocked' await page.mouse.move(challengeBox.x + +data2.x, challengeBox.y + +data2.y, { steps: 30 }); await page.mouse.up(); } } else { for (const data of captcha.data) { logger.info(data); await this.click(challenge, { x: +data.x, y: +data.y }); }; } this.click(frame.locator('.button-submit')).catch(e => { if (e.message.includes('viewport')) // when hCaptcha window has been closed due to inactivity, this.click(button); // click the Create button again to trigger the CAPTCHA else throw e; }); } } catch(e: any) { if (e.message.includes('been closed')) // catch error when closing the browser resolve(); else reject(e); } }).catch(e => { browser.browser()?.close(); throw e; }); return (new Promise((resolve, reject) => { page.route('**/api/generate/v2/**', async (route: any) => { try { logger.info('hCaptcha token received. Closing browser'); route.abort(); browser.browser()?.close(); const request = route.request(); this.currentToken = request.headers().authorization.split('Bearer ').pop(); resolve(request.postDataJSON().token); } catch(err) { reject(err); } }); })); } /** * Imitates Cloudflare Turnstile loading error. Unused right now, left for future */ private async getTurnstile() { return this.client.post( `https://clerk.suno.com/v1/client?__clerk_api_version=2021-02-05&_clerk_js_version=${SunoApi.CLERK_VERSION}&_method=PATCH`, { captcha_error: '300030,300030,300030' }, { headers: { 'content-type': 'application/x-www-form-urlencoded' } }); } /** * Generate a song based on the prompt. * @param prompt The text prompt to generate audio from. * @param make_instrumental Indicates if the generated audio should be instrumental. * @param wait_audio Indicates if the method should wait for the audio file to be fully generated before returning. * @returns */ public async generate( prompt: string, make_instrumental: boolean = false, model?: string, wait_audio: boolean = false ): Promise { await this.keepAlive(false); const startTime = Date.now(); const audios = await this.generateSongs( prompt, false, undefined, undefined, make_instrumental, model, wait_audio ); const costTime = Date.now() - startTime; logger.info('Generate Response:\n' + JSON.stringify(audios, null, 2)); logger.info('Cost time: ' + costTime); return audios; } /** * Calls the concatenate endpoint for a clip to generate the whole song. * @param clip_id The ID of the audio clip to concatenate. * @returns A promise that resolves to an AudioInfo object representing the concatenated audio. * @throws Error if the response status is not 200. */ public async concatenate(clip_id: string): Promise { await this.keepAlive(false); const payload: any = { clip_id: clip_id }; const response = await this.client.post( `${SunoApi.BASE_URL}/api/generate/concat/v2/`, payload, { timeout: 10000 // 10 seconds timeout } ); if (response.status !== 200) { throw new Error('Error response:' + response.statusText); } return response.data; } /** * Generates custom audio based on provided parameters. * * @param prompt The text prompt to generate audio from. * @param tags Tags to categorize the generated audio. * @param title The title for the generated audio. * @param make_instrumental Indicates if the generated audio should be instrumental. * @param wait_audio Indicates if the method should wait for the audio file to be fully generated before returning. * @param negative_tags Negative tags that should not be included in the generated audio. * @returns A promise that resolves to an array of AudioInfo objects representing the generated audios. */ public async custom_generate( prompt: string, tags: string, title: string, make_instrumental: boolean = false, model?: string, wait_audio: boolean = false, negative_tags?: string ): Promise { const startTime = Date.now(); const audios = await this.generateSongs( prompt, true, tags, title, make_instrumental, model, wait_audio, negative_tags ); const costTime = Date.now() - startTime; logger.info( 'Custom Generate Response:\n' + JSON.stringify(audios, null, 2) ); logger.info('Cost time: ' + costTime); return audios; } /** * Generates songs based on the provided parameters. * * @param prompt The text prompt to generate songs from. * @param isCustom Indicates if the generation should consider custom parameters like tags and title. * @param tags Optional tags to categorize the song, used only if isCustom is true. * @param title Optional title for the song, used only if isCustom is true. * @param make_instrumental Indicates if the generated song should be instrumental. * @param wait_audio Indicates if the method should wait for the audio file to be fully generated before returning. * @param negative_tags Negative tags that should not be included in the generated audio. * @param task Optional indication of what to do. Enter 'extend' if extending an audio, otherwise specify null. * @param continue_clip_id * @returns A promise that resolves to an array of AudioInfo objects representing the generated songs. */ private async generateSongs( prompt: string, isCustom: boolean, tags?: string, title?: string, make_instrumental?: boolean, model?: string, wait_audio: boolean = false, negative_tags?: string, task?: string, continue_clip_id?: string, continue_at?: number ): Promise { await this.keepAlive(); const payload: any = { make_instrumental: make_instrumental, mv: model || DEFAULT_MODEL, prompt: '', generation_type: 'TEXT', continue_at: continue_at, continue_clip_id: continue_clip_id, task: task, token: await this.getCaptcha() }; if (isCustom) { payload.tags = tags; payload.title = title; payload.negative_tags = negative_tags; payload.prompt = prompt; } else { payload.gpt_description_prompt = prompt; } logger.info( 'generateSongs payload:\n' + JSON.stringify( { prompt: prompt, isCustom: isCustom, tags: tags, title: title, make_instrumental: make_instrumental, wait_audio: wait_audio, negative_tags: negative_tags, payload: payload }, null, 2 ) ); const response = await this.client.post( `${SunoApi.BASE_URL}/api/generate/v2/`, payload, { timeout: 10000 // 10 seconds timeout } ); if (response.status !== 200) { throw new Error('Error response:' + response.statusText); } const songIds = response.data.clips.map((audio: any) => audio.id); //Want to wait for music file generation if (wait_audio) { const startTime = Date.now(); let lastResponse: AudioInfo[] = []; await sleep(5, 5); while (Date.now() - startTime < 100000) { const response = await this.get(songIds); const allCompleted = response.every( (audio) => audio.status === 'streaming' || audio.status === 'complete' ); const allError = response.every((audio) => audio.status === 'error'); if (allCompleted || allError) { return response; } lastResponse = response; await sleep(3, 6); await this.keepAlive(true); } return lastResponse; } else { return response.data.clips.map((audio: any) => ({ id: audio.id, title: audio.title, image_url: audio.image_url, lyric: audio.metadata.prompt, audio_url: audio.audio_url, video_url: audio.video_url, created_at: audio.created_at, model_name: audio.model_name, status: audio.status, gpt_description_prompt: audio.metadata.gpt_description_prompt, prompt: audio.metadata.prompt, type: audio.metadata.type, tags: audio.metadata.tags, negative_tags: audio.metadata.negative_tags, duration: audio.metadata.duration })); } } /** * Generates lyrics based on a given prompt. * @param prompt The prompt for generating lyrics. * @returns The generated lyrics text. */ public async generateLyrics(prompt: string): Promise { await this.keepAlive(false); // Initiate lyrics generation const generateResponse = await this.client.post( `${SunoApi.BASE_URL}/api/generate/lyrics/`, { prompt } ); const generateId = generateResponse.data.id; // Poll for lyrics completion let lyricsResponse = await this.client.get( `${SunoApi.BASE_URL}/api/generate/lyrics/${generateId}` ); while (lyricsResponse?.data?.status !== 'complete') { await sleep(2); // Wait for 2 seconds before polling again lyricsResponse = await this.client.get( `${SunoApi.BASE_URL}/api/generate/lyrics/${generateId}` ); } // Return the generated lyrics text return lyricsResponse.data; } /** * Extends an existing audio clip by generating additional content based on the provided prompt. * * @param audioId The ID of the audio clip to extend. * @param prompt The prompt for generating additional content. * @param continueAt Extend a new clip from a song at mm:ss(e.g. 00:30). Default extends from the end of the song. * @param tags Style of Music. * @param title Title of the song. * @returns A promise that resolves to an AudioInfo object representing the extended audio clip. */ public async extendAudio( audioId: string, prompt: string = '', continueAt: number, tags: string = '', negative_tags: string = '', title: string = '', model?: string, wait_audio?: boolean ): Promise { return this.generateSongs(prompt, true, tags, title, false, model, wait_audio, negative_tags, 'extend', audioId, continueAt); } /** * Generate stems for a song. * @param song_id The ID of the song to generate stems for. * @returns A promise that resolves to an AudioInfo object representing the generated stems. */ public async generateStems(song_id: string): Promise { await this.keepAlive(false); const response = await this.client.post( `${SunoApi.BASE_URL}/api/edit/stems/${song_id}`, {} ); console.log('generateStems response:\n', response?.data); return response.data.clips.map((clip: any) => ({ id: clip.id, status: clip.status, created_at: clip.created_at, title: clip.title, stem_from_id: clip.metadata.stem_from_id, duration: clip.metadata.duration })); } /** * Get the lyric alignment for a song. * @param song_id The ID of the song to get the lyric alignment for. * @returns A promise that resolves to an object containing the lyric alignment. */ public async getLyricAlignment(song_id: string): Promise { await this.keepAlive(false); const response = await this.client.get(`${SunoApi.BASE_URL}/api/gen/${song_id}/aligned_lyrics/v2/`); console.log(`getLyricAlignment ~ response:`, response.data); return response.data?.aligned_words.map((transcribedWord: any) => ({ word: transcribedWord.word, start_s: transcribedWord.start_s, end_s: transcribedWord.end_s, success: transcribedWord.success, p_align: transcribedWord.p_align })); } /** * Processes the lyrics (prompt) from the audio metadata into a more readable format. * @param prompt The original lyrics text. * @returns The processed lyrics text. */ private parseLyrics(prompt: string): string { // Assuming the original lyrics are separated by a specific delimiter (e.g., newline), we can convert it into a more readable format. // The implementation here can be adjusted according to the actual lyrics format. // For example, if the lyrics exist as continuous text, it might be necessary to split them based on specific markers (such as periods, commas, etc.). // The following implementation assumes that the lyrics are already separated by newlines. // Split the lyrics using newline and ensure to remove empty lines. const lines = prompt.split('\n').filter((line) => line.trim() !== ''); // Reassemble the processed lyrics lines into a single string, separated by newlines between each line. // Additional formatting logic can be added here, such as adding specific markers or handling special lines. return lines.join('\n'); } /** * Retrieves audio information for the given song IDs. * @param songIds An optional array of song IDs to retrieve information for. * @param page An optional page number to retrieve audio information from. * @returns A promise that resolves to an array of AudioInfo objects. */ public async get( songIds?: string[], page?: string | null ): Promise { await this.keepAlive(false); let url = new URL(`${SunoApi.BASE_URL}/api/feed/v2`); if (songIds) { url.searchParams.append('ids', songIds.join(',')); } if (page) { url.searchParams.append('page', page); } logger.info('Get audio status: ' + url.href); const response = await this.client.get(url.href, { // 10 seconds timeout timeout: 10000 }); const audios = response.data.clips; return audios.map((audio: any) => ({ id: audio.id, title: audio.title, image_url: audio.image_url, lyric: audio.metadata.prompt ? this.parseLyrics(audio.metadata.prompt) : '', audio_url: audio.audio_url, video_url: audio.video_url, created_at: audio.created_at, model_name: audio.model_name, status: audio.status, gpt_description_prompt: audio.metadata.gpt_description_prompt, prompt: audio.metadata.prompt, type: audio.metadata.type, tags: audio.metadata.tags, duration: audio.metadata.duration, error_message: audio.metadata.error_message })); } /** * Retrieves information for a specific audio clip. * @param clipId The ID of the audio clip to retrieve information for. * @returns A promise that resolves to an object containing the audio clip information. */ public async getClip(clipId: string): Promise { await this.keepAlive(false); const response = await this.client.get( `${SunoApi.BASE_URL}/api/clip/${clipId}` ); return response.data; } public async get_credits(): Promise { await this.keepAlive(false); const response = await this.client.get( `${SunoApi.BASE_URL}/api/billing/info/` ); return { credits_left: response.data.total_credits_left, period: response.data.period, monthly_limit: response.data.monthly_limit, monthly_usage: response.data.monthly_usage }; } } export const sunoApi = async (cookie?: string) => { const resolvedCookie = cookie || process.env.SUNO_COOKIE; if (!resolvedCookie) { logger.info('No cookie provided! Aborting...\nPlease provide a cookie either in the .env file or in the Cookie header of your request.') throw new Error('Please provide a cookie either in the .env file or in the Cookie header of your request.'); } // Check if the instance for this cookie already exists in the cache const cachedInstance = cache.get(resolvedCookie); if (cachedInstance) return cachedInstance; // If not, create a new instance and initialize it const instance = await new SunoApi(resolvedCookie).init(); // Cache the initialized instance cache.set(resolvedCookie, instance); return instance; };