|
import axios, { AxiosInstance } from 'axios'; |
|
import UserAgent from 'user-agents'; |
|
import pino from 'pino'; |
|
import { wrapper } from "axios-cookiejar-support"; |
|
import { CookieJar } from "tough-cookie"; |
|
import { sleep } from "@/lib/utils"; |
|
|
|
const logger = pino(); |
|
export const DEFAULT_MODEL = "chirp-v3-5"; |
|
|
|
|
|
export interface AudioInfo { |
|
id: string; |
|
title?: string; |
|
image_url?: string; |
|
lyric?: string; |
|
audio_url?: string; |
|
video_url?: string; |
|
created_at: string; |
|
model_name: string; |
|
gpt_description_prompt?: string; |
|
prompt?: string; |
|
status: string; |
|
type?: string; |
|
tags?: string; |
|
duration?: string; |
|
error_message?: string; |
|
} |
|
|
|
class SunoApi { |
|
private static BASE_URL: string = 'https://studio-api.suno.ai'; |
|
private static CLERK_BASE_URL: string = 'https://clerk.suno.com'; |
|
|
|
private readonly client: AxiosInstance; |
|
private sid?: string; |
|
private currentToken?: string; |
|
|
|
constructor(cookie: string) { |
|
const cookieJar = new CookieJar(); |
|
const randomUserAgent = new UserAgent(/Chrome/).random().toString(); |
|
this.client = wrapper(axios.create({ |
|
jar: cookieJar, |
|
withCredentials: true, |
|
headers: { |
|
'User-Agent': randomUserAgent, |
|
'Cookie': cookie |
|
} |
|
})) |
|
this.client.interceptors.request.use((config) => { |
|
if (this.currentToken) { |
|
config.headers['Authorization'] = `Bearer ${this.currentToken}`; |
|
} |
|
return config; |
|
}); |
|
} |
|
|
|
public async init(): Promise<SunoApi> { |
|
await this.getAuthToken(); |
|
await this.keepAlive(); |
|
return this; |
|
} |
|
|
|
|
|
|
|
|
|
private async getAuthToken() { |
|
|
|
const getSessionUrl = `${SunoApi.CLERK_BASE_URL}/v1/client?_clerk_js_version=4.73.4`; |
|
|
|
const sessionResponse = await this.client.get(getSessionUrl); |
|
if (!sessionResponse?.data?.response?.['last_active_session_id']) { |
|
throw new Error("Failed to get session id, you may need to update the SUNO_COOKIE"); |
|
} |
|
|
|
this.sid = sessionResponse.data.response['last_active_session_id']; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
public async keepAlive(isWait?: boolean): Promise<void> { |
|
if (!this.sid) { |
|
throw new Error("Session ID is not set. Cannot renew token."); |
|
} |
|
|
|
const renewUrl = `${SunoApi.CLERK_BASE_URL}/v1/client/sessions/${this.sid}/tokens?_clerk_js_version==4.73.4`; |
|
|
|
const renewResponse = await this.client.post(renewUrl); |
|
logger.info("KeepAlive...\n"); |
|
if (isWait) { |
|
await sleep(1, 2); |
|
} |
|
const newToken = renewResponse.data['jwt']; |
|
|
|
this.currentToken = newToken; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public async generate( |
|
prompt: string, |
|
make_instrumental: boolean = false, |
|
model?: string, |
|
wait_audio: boolean = false, |
|
|
|
): Promise<AudioInfo[]> { |
|
await this.keepAlive(false); |
|
const startTime = Date.now(); |
|
const audios = 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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public async concatenate(clip_id: string): Promise<AudioInfo> { |
|
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, |
|
}, |
|
); |
|
if (response.status !== 200) { |
|
throw new Error("Error response:" + response.statusText); |
|
} |
|
return response.data; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public async custom_generate( |
|
prompt: string, |
|
tags: string, |
|
title: string, |
|
make_instrumental: boolean = false, |
|
model?: string, |
|
wait_audio: boolean = false, |
|
): Promise<AudioInfo[]> { |
|
const startTime = Date.now(); |
|
const audios = await this.generateSongs(prompt, true, tags, title, make_instrumental, model, wait_audio); |
|
const costTime = Date.now() - startTime; |
|
logger.info("Custom Generate Response:\n" + JSON.stringify(audios, null, 2)); |
|
logger.info("Cost time: " + costTime); |
|
return audios; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async generateSongs( |
|
prompt: string, |
|
isCustom: boolean, |
|
tags?: string, |
|
title?: string, |
|
make_instrumental?: boolean, |
|
model?: string, |
|
wait_audio: boolean = false |
|
): Promise<AudioInfo[]> { |
|
await this.keepAlive(false); |
|
const payload: any = { |
|
make_instrumental: make_instrumental == true, |
|
mv: model || DEFAULT_MODEL, |
|
prompt: "", |
|
}; |
|
if (isCustom) { |
|
payload.tags = tags; |
|
payload.title = title; |
|
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, |
|
payload: payload, |
|
}, null, 2)); |
|
const response = await this.client.post( |
|
`${SunoApi.BASE_URL}/api/generate/v2/`, |
|
payload, |
|
{ |
|
timeout: 10000, |
|
}, |
|
); |
|
logger.info("generateSongs Response:\n" + JSON.stringify(response.data, null, 2)); |
|
if (response.status !== 200) { |
|
throw new Error("Error response:" + response.statusText); |
|
} |
|
const songIds = response.data['clips'].map((audio: any) => audio.id); |
|
|
|
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 { |
|
await this.keepAlive(true); |
|
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, |
|
duration: audio.metadata.duration, |
|
})); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
public async generateLyrics(prompt: string): Promise<string> { |
|
await this.keepAlive(false); |
|
|
|
const generateResponse = await this.client.post(`${SunoApi.BASE_URL}/api/generate/lyrics/`, { prompt }); |
|
const generateId = generateResponse.data.id; |
|
|
|
|
|
let lyricsResponse = await this.client.get(`${SunoApi.BASE_URL}/api/generate/lyrics/${generateId}`); |
|
while (lyricsResponse?.data?.status !== 'complete') { |
|
await sleep(2); |
|
lyricsResponse = await this.client.get(`${SunoApi.BASE_URL}/api/generate/lyrics/${generateId}`); |
|
} |
|
|
|
|
|
return lyricsResponse.data; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public async extendAudio( |
|
audioId: string, |
|
prompt: string = "", |
|
continueAt: string = "0", |
|
tags: string = "", |
|
title: string = "", |
|
model?: string, |
|
): Promise<AudioInfo> { |
|
const response = await this.client.post(`${SunoApi.BASE_URL}/api/generate/v2/`, { |
|
continue_clip_id: audioId, |
|
continue_at: continueAt, |
|
mv: model || DEFAULT_MODEL, |
|
prompt: prompt, |
|
tags: tags, |
|
title: title |
|
}); |
|
console.log("response:\n", response); |
|
return response.data; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
private parseLyrics(prompt: string): string { |
|
|
|
|
|
|
|
|
|
|
|
|
|
const lines = prompt.split('\n').filter(line => line.trim() !== ''); |
|
|
|
|
|
|
|
return lines.join('\n'); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
public async get(songIds?: string[]): Promise<AudioInfo[]> { |
|
await this.keepAlive(false); |
|
let url = `${SunoApi.BASE_URL}/api/feed/`; |
|
if (songIds) { |
|
url = `${url}?ids=${songIds.join(',')}`; |
|
} |
|
logger.info("Get audio status: " + url); |
|
const response = await this.client.get(url, { |
|
|
|
timeout: 3000 |
|
}); |
|
|
|
const audios = response.data; |
|
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, |
|
})); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
public async getClip(clipId: string): Promise<object> { |
|
await this.keepAlive(false); |
|
const response = await this.client.get(`${SunoApi.BASE_URL}/api/clip/${clipId}`); |
|
return response.data; |
|
} |
|
|
|
public async get_credits(): Promise<object> { |
|
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, |
|
}; |
|
} |
|
} |
|
|
|
const newSunoApi = async (cookie: string) => { |
|
const sunoApi = new SunoApi(cookie); |
|
return await sunoApi.init(); |
|
} |
|
|
|
if (!process.env.SUNO_COOKIE) { |
|
console.log("Environment does not contain SUNO_COOKIE.", process.env) |
|
} |
|
|
|
export const sunoApi = newSunoApi(process.env.SUNO_COOKIE || ''); |
|
|