From b2f0c3aacccee1fb164040a020ab56709da8464d Mon Sep 17 00:00:00 2001 From: Wesley Seubring Date: Fri, 25 Oct 2024 13:51:08 +0200 Subject: [PATCH] feat(get): add page query param --- src/app/api/get/route.ts | 31 ++-- src/app/docs/swagger-suno-api.json | 9 + src/lib/SunoApi.ts | 255 ++++++++++++++++++----------- 3 files changed, 182 insertions(+), 113 deletions(-) diff --git a/src/app/api/get/route.ts b/src/app/api/get/route.ts index f3f34f2..d8e568b 100644 --- a/src/app/api/get/route.ts +++ b/src/app/api/get/route.ts @@ -1,20 +1,22 @@ -import { NextResponse, NextRequest } from "next/server"; -import { sunoApi } from "@/lib/SunoApi"; -import { corsHeaders } from "@/lib/utils"; +import { NextResponse, NextRequest } from 'next/server'; +import { sunoApi } from '@/lib/SunoApi'; +import { corsHeaders } from '@/lib/utils'; -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic'; export async function GET(req: NextRequest) { if (req.method === 'GET') { try { const url = new URL(req.url); const songIds = url.searchParams.get('ids'); + const page = url.searchParams.get('page'); + let audioInfo = []; if (songIds && songIds.length > 0) { const idsArray = songIds.split(','); - audioInfo = await (await sunoApi).get(idsArray); + audioInfo = await (await sunoApi).get(idsArray, page); } else { - audioInfo = await (await sunoApi).get(); + audioInfo = await (await sunoApi).get(undefined, page); } return new NextResponse(JSON.stringify(audioInfo), { @@ -27,13 +29,16 @@ export async function GET(req: NextRequest) { } catch (error) { console.error('Error fetching audio:', error); - return new NextResponse(JSON.stringify({ error: 'Internal server error' }), { - status: 500, - headers: { - 'Content-Type': 'application/json', - ...corsHeaders + return new NextResponse( + JSON.stringify({ error: 'Internal server error' }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders + } } - }); + ); } } else { return new NextResponse('Method Not Allowed', { @@ -51,4 +56,4 @@ export async function OPTIONS(request: Request) { status: 200, headers: corsHeaders }); -} \ No newline at end of file +} diff --git a/src/app/docs/swagger-suno-api.json b/src/app/docs/swagger-suno-api.json index d18dd54..d00b676 100644 --- a/src/app/docs/swagger-suno-api.json +++ b/src/app/docs/swagger-suno-api.json @@ -307,6 +307,15 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "page", + "description": "Page number", + "required": false, + "schema": { + "type": "number" + } } ], "responses": { diff --git a/src/lib/SunoApi.ts b/src/lib/SunoApi.ts index d610b01..a7018da 100644 --- a/src/lib/SunoApi.ts +++ b/src/lib/SunoApi.ts @@ -1,13 +1,12 @@ 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"; +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 const DEFAULT_MODEL = 'chirp-v3-5'; export interface AudioInfo { id: string; // Unique identifier for the audio @@ -19,7 +18,7 @@ export interface AudioInfo { 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  + prompt?: string; // Prompt for audio generation status: string; // Status type?: string; tags?: string; // Genre of music. @@ -41,16 +40,19 @@ class SunoApi { 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 = wrapper( + axios.create({ + jar: cookieJar, + withCredentials: true, + headers: { + 'User-Agent': randomUserAgent, + Cookie: cookie + } + }) + ); this.client.interceptors.request.use((config) => { - if (this.currentToken) { // Use the current token status + if (this.currentToken) { + // Use the current token status config.headers['Authorization'] = `Bearer ${this.currentToken}`; } return config; @@ -69,11 +71,13 @@ class SunoApi { */ private async getClerkLatestVersion() { // URL to get clerk version ID - const getClerkVersionUrl = `${SunoApi.JSDELIVR_BASE_URL}/v1/package/npm/@clerk/clerk-js`;  + 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"); + throw new Error( + 'Failed to get clerk version info, Please try again later' + ); } // Save clerk version ID for auth this.clerkVersion = versionListResponse?.data?.['tags']['latest']; @@ -84,11 +88,13 @@ class SunoApi { */ private async getAuthToken() { // URL to get session ID - const getSessionUrl = `${SunoApi.CLERK_BASE_URL}/v1/client?_clerk_js_version=${this.clerkVersion}`;  + const getSessionUrl = `${SunoApi.CLERK_BASE_URL}/v1/client?_clerk_js_version=${this.clerkVersion}`; // Get session ID 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"); + 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']; @@ -100,13 +106,13 @@ class SunoApi { */ public async keepAlive(isWait?: boolean): Promise { if (!this.sid) { - throw new Error("Session ID is not set. Cannot renew token."); + 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?_clerk_js_version==${this.clerkVersion}`;  + const renewUrl = `${SunoApi.CLERK_BASE_URL}/v1/client/sessions/${this.sid}/tokens?_clerk_js_version==${this.clerkVersion}`; // Renew session token const renewResponse = await this.client.post(renewUrl); - logger.info("KeepAlive...\n"); + logger.info('KeepAlive...\n'); if (isWait) { await sleep(1, 2); } @@ -120,21 +126,28 @@ class SunoApi { * @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 + * @returns */ public async generate( prompt: string, make_instrumental: boolean = false, model?: string, - wait_audio: boolean = false, - + wait_audio: boolean = false ): Promise { await this.keepAlive(false); const startTime = Date.now(); - const audios = this.generateSongs(prompt, false, undefined, undefined, make_instrumental, model, wait_audio); + 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); + logger.info('Generate Response:\n' + JSON.stringify(audios, null, 2)); + logger.info('Cost time: ' + costTime); return audios; } @@ -152,11 +165,11 @@ class SunoApi { `${SunoApi.BASE_URL}/api/generate/concat/v2/`, payload, { - timeout: 10000, // 10 seconds timeout - }, + timeout: 10000 // 10 seconds timeout + } ); if (response.status !== 200) { - throw new Error("Error response:" + response.statusText); + throw new Error('Error response:' + response.statusText); } return response.data; } @@ -172,22 +185,33 @@ class SunoApi { * @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; -} + 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. @@ -209,13 +233,13 @@ public async custom_generate( make_instrumental?: boolean, model?: string, wait_audio: boolean = false, - negative_tags?: string, + negative_tags?: string ): Promise { await this.keepAlive(false); const payload: any = { make_instrumental: make_instrumental == true, mv: model || DEFAULT_MODEL, - prompt: "", + prompt: '' }; if (isCustom) { payload.tags = tags; @@ -225,26 +249,35 @@ public async custom_generate( } 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)); + 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 - }, + timeout: 10000 // 10 seconds timeout + } + ); + logger.info( + 'generateSongs Response:\n' + JSON.stringify(response.data, null, 2) ); - logger.info("generateSongs Response:\n" + JSON.stringify(response.data, null, 2)); if (response.status !== 200) { - throw new Error("Error response:" + response.statusText); + throw new Error('Error response:' + response.statusText); } const songIds = response.data['clips'].map((audio: any) => audio.id); //Want to wait for music file generation @@ -255,11 +288,9 @@ public async custom_generate( 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' + (audio) => audio.status === 'streaming' || audio.status === 'complete' ); + const allError = response.every((audio) => audio.status === 'error'); if (allCompleted || allError) { return response; } @@ -285,7 +316,7 @@ public async custom_generate( type: audio.metadata.type, tags: audio.metadata.tags, negative_tags: audio.metadata.negative_tags, - duration: audio.metadata.duration, + duration: audio.metadata.duration })); } } @@ -298,14 +329,21 @@ public async custom_generate( 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 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}`); + 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}`); + lyricsResponse = await this.client.get( + `${SunoApi.BASE_URL}/api/generate/lyrics/${generateId}` + ); } // Return the generated lyrics text @@ -314,7 +352,7 @@ public async custom_generate( /** * 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. @@ -324,21 +362,24 @@ public async custom_generate( */ public async extendAudio( audioId: string, - prompt: string = "", - continueAt: string = "0", - tags: string = "", - title: string = "", - model?: string, + prompt: string = '', + continueAt: string = '0', + tags: string = '', + title: string = '', + model?: string ): Promise { - 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); + 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; } @@ -354,7 +395,7 @@ public async custom_generate( // 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() !== ''); + 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. @@ -364,26 +405,36 @@ public async custom_generate( /** * 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[]): Promise { + public async get( + songIds?: string[], + page?: string | null + ): Promise { await this.keepAlive(false); - let url = `${SunoApi.BASE_URL}/api/feed/`; + let url = new URL(`${SunoApi.BASE_URL}/api/feed/`); if (songIds) { - url = `${url}?ids=${songIds.join(',')}`; + url.searchParams.append('ids', songIds.join(',')); } - logger.info("Get audio status: " + url); - const response = await this.client.get(url, { + if (page) { + url.searchParams.append('page', page); + } + logger.info('Get audio status: ' + url.href); + const response = await this.client.get(url.href, { // 3 seconds timeout 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) : "", + lyric: audio.metadata.prompt + ? this.parseLyrics(audio.metadata.prompt) + : '', audio_url: audio.audio_url, video_url: audio.video_url, created_at: audio.created_at, @@ -393,8 +444,8 @@ public async custom_generate( prompt: audio.metadata.prompt, type: audio.metadata.type, tags: audio.metadata.tags, - duration: audio.metadata.duration, - error_message: audio.metadata.error_message, + duration: audio.metadata.duration, + error_message: audio.metadata.error_message })); } @@ -405,18 +456,22 @@ public async custom_generate( */ public async getClip(clipId: string): Promise { await this.keepAlive(false); - const response = await this.client.get(`${SunoApi.BASE_URL}/api/clip/${clipId}`); + 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/`); + 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, + monthly_usage: response.data.monthly_usage }; } } @@ -424,10 +479,10 @@ public async custom_generate( 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) + console.log('Environment does not contain SUNO_COOKIE.', process.env); } export const sunoApi = newSunoApi(process.env.SUNO_COOKIE || '');