AlexMia commited on
Commit
bb11990
·
verified ·
1 Parent(s): 297f924

Upload 49 files

Browse files
Files changed (50) hide show
  1. .dockerignore +43 -0
  2. .env.example +7 -0
  3. .eslintrc.json +3 -0
  4. .gitattributes +2 -0
  5. .gitignore +39 -0
  6. .prettierrc +4 -0
  7. Dockerfile +32 -0
  8. README.md +11 -11
  9. docker-compose.yml +13 -0
  10. icon.png +0 -0
  11. next.config.mjs +15 -0
  12. package-lock.json +0 -0
  13. package.json +58 -0
  14. pnpm-lock.yaml +0 -0
  15. postcss.config.js +6 -0
  16. public/drag-instructions.jpg +0 -0
  17. public/get-cookie-demo.gif +3 -0
  18. public/get-cookie-demo.mp4 +3 -0
  19. public/github-logo.webp +0 -0
  20. public/github-mark.png +0 -0
  21. public/next.svg +1 -0
  22. public/swagger-suno-api.json +257 -0
  23. public/vercel.svg +1 -0
  24. src/app/api/clip/route.ts +58 -0
  25. src/app/api/concat/route.ts +65 -0
  26. src/app/api/custom_generate/route.ts +54 -0
  27. src/app/api/extend_audio/route.ts +70 -0
  28. src/app/api/generate/route.ts +64 -0
  29. src/app/api/generate_lyrics/route.ts +58 -0
  30. src/app/api/generate_stems/route.ts +70 -0
  31. src/app/api/get/route.ts +61 -0
  32. src/app/api/get_aligned_lyrics/route.ts +61 -0
  33. src/app/api/get_limit/route.ts +49 -0
  34. src/app/api/persona/route.ts +61 -0
  35. src/app/components/Footer.tsx +24 -0
  36. src/app/components/Header.tsx +50 -0
  37. src/app/components/Logo.tsx +13 -0
  38. src/app/components/Section.tsx +22 -0
  39. src/app/components/Swagger.tsx +20 -0
  40. src/app/docs/page.tsx +62 -0
  41. src/app/docs/swagger-suno-api.json +814 -0
  42. src/app/favicon.ico +0 -0
  43. src/app/globals.css +25 -0
  44. src/app/layout.tsx +34 -0
  45. src/app/page.tsx +156 -0
  46. src/app/v1/chat/completions/route.ts +61 -0
  47. src/lib/SunoApi.ts +870 -0
  48. src/lib/utils.ts +118 -0
  49. tailwind.config.ts +22 -0
  50. tsconfig.json +29 -0
.dockerignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+ .yarn/install-state.gz
8
+
9
+ # testing
10
+ /coverage
11
+
12
+ # next.js
13
+ /.next/
14
+ /out/
15
+
16
+ # production
17
+ /build
18
+
19
+ # misc
20
+ .DS_Store
21
+ *.pem
22
+
23
+ # debug
24
+ npm-debug.log*
25
+ yarn-debug.log*
26
+ yarn-error.log*
27
+
28
+ # local env files
29
+ .env*.local
30
+
31
+ # vercel
32
+ .vercel
33
+
34
+ # typescript
35
+ *.tsbuildinfo
36
+ next-env.d.ts
37
+
38
+ .idea
39
+
40
+ public/
41
+ Dockerfile
42
+ docker-compose.yml
43
+ README*.md
.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # For more information, please see the README.md
2
+ SUNO_COOKIE=
3
+ TWOCAPTCHA_KEY= # Obtain from 2captcha.com
4
+ BROWSER=chromium # `chromium` or `firefox`, although `chromium` is highly recommended
5
+ BROWSER_GHOST_CURSOR=false
6
+ BROWSER_LOCALE=en
7
+ BROWSER_HEADLESS=true
.eslintrc.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "extends": "next/core-web-vitals"
3
+ }
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ public/get-cookie-demo.gif filter=lfs diff=lfs merge=lfs -text
37
+ public/get-cookie-demo.mp4 filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+ .yarn/install-state.gz
8
+
9
+ # testing
10
+ /coverage
11
+
12
+ # next.js
13
+ /.next/
14
+ /out/
15
+
16
+ # production
17
+ /build
18
+
19
+ # misc
20
+ .DS_Store
21
+ *.pem
22
+
23
+ # debug
24
+ npm-debug.log*
25
+ yarn-debug.log*
26
+ yarn-error.log*
27
+
28
+ # local env files
29
+ .env*.local
30
+ .env
31
+
32
+ # vercel
33
+ .vercel
34
+
35
+ # typescript
36
+ *.tsbuildinfo
37
+ next-env.d.ts
38
+
39
+ .idea
.prettierrc ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "trailingComma": "none",
3
+ "singleQuote": true
4
+ }
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+
3
+ FROM node:lts-bookworm AS builder
4
+ WORKDIR /src
5
+ COPY package*.json ./
6
+ RUN npm install
7
+ COPY . .
8
+ RUN npm run build
9
+
10
+ FROM node:lts-bookworm
11
+ WORKDIR /app
12
+ COPY package*.json ./
13
+
14
+ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y libnss3 \
15
+ libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
16
+ libgbm1 libxkbcommon0 libasound2 libcups2 xvfb
17
+
18
+ ARG SUNO_COOKIE
19
+ RUN if [ -z "$SUNO_COOKIE" ]; then echo "Warning: SUNO_COOKIE is not set. You will have to set the cookies in the Cookie header of your requests."; fi
20
+ ENV SUNO_COOKIE=${SUNO_COOKIE}
21
+ # Disable GPU acceleration, as with it suno-api won't work in a Docker environment
22
+ ENV BROWSER_DISABLE_GPU=true
23
+
24
+ RUN npm install --only=production
25
+
26
+ # Install all supported browsers, else switching browsers requires an image rebuild
27
+ RUN npx playwright install chromium
28
+ # RUN npx playwright install firefox
29
+
30
+ COPY --from=builder /src/.next ./.next
31
+ EXPOSE 3000
32
+ CMD ["npm", "run", "start"]
README.md CHANGED
@@ -1,11 +1,11 @@
1
- ---
2
- title: N M Api
3
- emoji: 🏃
4
- colorFrom: gray
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ ---
2
+ title: N M Api
3
+ emoji: 🏃
4
+ colorFrom: gray
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ ---
10
+
11
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
docker-compose.yml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3'
2
+
3
+ services:
4
+ suno-api:
5
+ build:
6
+ context: .
7
+ args:
8
+ SUNO_COOKIE: ${SUNO_COOKIE}
9
+ volumes:
10
+ - ./public:/app/public
11
+ ports:
12
+ - "3000:3000"
13
+ env_file: ".env"
icon.png ADDED
next.config.mjs ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ webpack: (config) => {
4
+ config.module.rules.push({
5
+ test: /\.(ttf|html)$/i,
6
+ type: 'asset/resource'
7
+ });
8
+ return config;
9
+ },
10
+ experimental: {
11
+ serverMinification: false, // the server minification unfortunately breaks the selector class names
12
+ },
13
+ };
14
+
15
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "suno-api",
3
+ "description": "Use API to call the music generation service of suno.ai, and easily integrate it into agents like GPTs.",
4
+ "author": {
5
+ "name": "gcui.ai",
6
+ "url": "https://github.com/gcui-art/"
7
+ },
8
+ "license": "LGPL-3.0-or-later",
9
+ "version": "1.1.0",
10
+ "private": true,
11
+ "scripts": {
12
+ "dev": "next dev",
13
+ "build": "next build",
14
+ "start": "next start",
15
+ "lint": "next lint"
16
+ },
17
+ "dependencies": {
18
+ "@2captcha/captcha-solver": "^1.3.0",
19
+ "@playwright/browser-chromium": "^1.49.1",
20
+ "@vercel/analytics": "^1.2.2",
21
+ "axios": "^1.7.8",
22
+ "bufferutil": "^4.0.8",
23
+ "chromium-bidi": "^0.10.1",
24
+ "cookie": "^1.0.2",
25
+ "electron": "^33.2.1",
26
+ "ghost-cursor-playwright": "^2.1.0",
27
+ "js-cookie": "^3.0.5",
28
+ "next": "14.1.4",
29
+ "next-swagger-doc": "^0.4.0",
30
+ "pino": "^8.19.0",
31
+ "pino-pretty": "^11.0.0",
32
+ "react": "^18",
33
+ "react-dom": "^18",
34
+ "react-markdown": "^9.0.1",
35
+ "rebrowser-playwright-core": "^1.49.1",
36
+ "swagger-ui-react": "^5.18.2",
37
+ "tough-cookie": "^4.1.4",
38
+ "user-agents": "^1.1.156",
39
+ "utf-8-validate": "^6.0.5",
40
+ "yn": "^5.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@tailwindcss/typography": "^0.5.12",
44
+ "@types/js-cookie": "^3.0.6",
45
+ "@types/node": "^20",
46
+ "@types/react": "^18",
47
+ "@types/react-dom": "^18",
48
+ "@types/swagger-ui-react": "^4.18.3",
49
+ "@types/tough-cookie": "^4.0.5",
50
+ "@types/user-agents": "^1.0.4",
51
+ "autoprefixer": "^10.0.1",
52
+ "eslint": "^8.57.0",
53
+ "eslint-config-next": "14.1.4",
54
+ "postcss": "^8",
55
+ "tailwindcss": "^3.3.0",
56
+ "typescript": "^5"
57
+ }
58
+ }
pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
public/drag-instructions.jpg ADDED
public/get-cookie-demo.gif ADDED

Git LFS Details

  • SHA256: 388372f371032eda2433bbd204e0415477426f942f13479c7115c0b01d414046
  • Pointer size: 133 Bytes
  • Size of remote file: 44.6 MB
public/get-cookie-demo.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6ee1df7a41fdc5064253da0d13c69abb4af879a05b593f30157894e44ed84472
3
+ size 6762147
public/github-logo.webp ADDED
public/github-mark.png ADDED
public/next.svg ADDED
public/swagger-suno-api.json ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "suno-api",
5
+ "description": "",
6
+ "version": "",
7
+ "license": { "name": "gcui-art", "url": "https://github.com/gcui-art/" }
8
+ },
9
+ "tags": [{ "name": "\u9ed8\u8ba4\u5206\u7ec4" }],
10
+ "paths": {
11
+ "/api/custom_generate": {
12
+ "post": {
13
+ "summary": "Generate Audio - Custom Mode",
14
+ "description": "The custom mode enables users to provide additional details about the music, such as music genre, lyrics, and more.2 audio files will be generated for each request, consuming a total of 10 credits.wait_audio can be set to API mode:\u2022 By default, it is set to false, which indicates the background mode. It will only return audio task information, and you will need to call the get API to retrieve detailed audio information.\u2022 If set to true, it simulates synchronous mode. The API will wait for a maximum of 100s until the audio is generated, and will directly return the audio link and other information. Recommend using in GPTs and other agents.",
15
+ "tags": ["\u9ed8\u8ba4\u5206\u7ec4"],
16
+ "requestBody": {
17
+ "content": {
18
+ "application/json": {
19
+ "schema": {
20
+ "type": "object",
21
+ "required": ["prompt", "tags", "title"],
22
+ "properties": {
23
+ "prompt": {
24
+ "type": "string",
25
+ "description": "Detailed prompt, including information such as music lyrics.",
26
+ "example": "[Verse 1]\nCruel flames of war engulf this land\nBattlefields filled with death and dread\nInnocent souls in darkness, they rest\nMy heart trembles in this silent test\n\n[Verse 2]\nPeople weep for loved ones lost\nBattered bodies bear the cost\nSeeking peace and hope once known\nOur grief transforms to hearts of stone\n\n[Chorus]\nSilent battlegrounds, no birds' song\nShadows of war, where we don't belong\nMay flowers of peace bloom in this place\nLet's guard this precious dream with grace\n\n[Bridge]\nThrough the ashes, we will rise\nHand in hand, towards peaceful skies\nNo more sorrow, no more pain\nTogether, we'll break these chains\n\n[Chorus]\nSilent battlegrounds, no birds' song\nShadows of war, where we don't belong\nMay flowers of peace bloom in this place\nLet's guard this precious dream with grace\n\n[Outro]\nIn unity, our strength will grow\nA brighter future, we'll soon know\nFrom the ruins, hope will spring\nA new dawn, we'll together bring"
27
+ },
28
+ "tags": {
29
+ "type": "string",
30
+ "description": "Music genre",
31
+ "example": "pop metal male melancholic"
32
+ },
33
+ "title": {
34
+ "type": "string",
35
+ "description": "Music title",
36
+ "example": "Silent Battlefield"
37
+ },
38
+ "make_instrumental": {
39
+ "type": "boolean",
40
+ "description": "Whether to generate instrumental music",
41
+ "example": "false"
42
+ },
43
+ "model": {
44
+ "type": "string",
45
+ "description": "Model name ,default is chirp-v3-5",
46
+ "example": "chirp-v3-5|chirp-v3-0"
47
+ },
48
+ "wait_audio": {
49
+ "type": "boolean",
50
+ "description": "Whether to wait for music generation, default is false, directly return audio task information; set to true, will wait for up to 100s until the audio is generated.",
51
+ "example": "false"
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ },
58
+ "responses": {
59
+ "200": {
60
+ "description": "\u6210\u529f",
61
+ "content": {
62
+ "application/json": {
63
+ "schema": {
64
+ "type": "array",
65
+ "items": {
66
+ "type": "object",
67
+ "required": ["0", "1"],
68
+ "properties": [
69
+ { "$ref": "#/components/schemas/audio info" },
70
+ { "$ref": "#/components/schemas/audio info" }
71
+ ]
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ },
80
+ "/api/generate": {
81
+ "post": {
82
+ "summary": "Generate audio based on Prompt.",
83
+ "description": "It will automatically fill in the lyrics.2 audio files will be generated for each request, consuming a total of 10 credits.wait_audio can be set to API mode:\u2022 By default, it is set to false, which indicates the background mode. It will only return audio task information, and you will need to call the get API to retrieve detailed audio information.\u2022 If set to true, it simulates synchronous mode. The API will wait for a maximum of 100s until the audio is generated, and will directly return the audio link and other information. Recommend using in GPTs and other agents.",
84
+ "tags": ["\u9ed8\u8ba4\u5206\u7ec4"],
85
+ "requestBody": {
86
+ "content": {
87
+ "application/json": {
88
+ "schema": {
89
+ "type": "object",
90
+ "required": ["prompt", "make_instrumental", "wait_audio"],
91
+ "properties": {
92
+ "prompt": {
93
+ "type": "string",
94
+ "description": "Prompt",
95
+ "example": "A popular heavy metal song about war, sung by a deep-voiced male singer, slowly and melodiously. The lyrics depict the sorrow of people after the war."
96
+ },
97
+ "make_instrumental": {
98
+ "type": "boolean",
99
+ "description": "Whether to generate instrumental music",
100
+ "example": "false"
101
+ },
102
+ "model": {
103
+ "type": "string",
104
+ "description": "Model name ,default is chirp-v3-5",
105
+ "example": "chirp-v3-5|chirp-v3-0"
106
+ },
107
+ "wait_audio": {
108
+ "type": "boolean",
109
+ "description": "Whether to wait for music generation, default is false, directly return audio task information; set to true, will wait for up to 100s until the audio is generated.",
110
+ "example": "false"
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ },
117
+ "responses": {
118
+ "200": {
119
+ "description": "\u6210\u529f",
120
+ "content": {
121
+ "application/json": {
122
+ "schema": {
123
+ "type": "array",
124
+ "items": {
125
+ "type": "object",
126
+ "required": ["0", "1"],
127
+ "properties": [
128
+ { "$ref": "#/components/schemas/audio info" },
129
+ { "$ref": "#/components/schemas/audio info" }
130
+ ]
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ },
139
+ "/api/get": {
140
+ "get": {
141
+ "summary": "Get audio information",
142
+ "description": "",
143
+ "tags": ["\u9ed8\u8ba4\u5206\u7ec4"],
144
+ "parameters": [
145
+ {
146
+ "in": "query",
147
+ "name": "ids",
148
+ "description": "Audio IDs, separated by commas.",
149
+ "required": true,
150
+ "schema": { "type": "string" }
151
+ }
152
+ ],
153
+ "responses": { "200": { "description": "\u6210\u529f" } }
154
+ }
155
+ },
156
+ "/api/get_limit": {
157
+ "get": {
158
+ "summary": "Get quota information.",
159
+ "description": "",
160
+ "tags": ["\u9ed8\u8ba4\u5206\u7ec4"],
161
+ "responses": {
162
+ "200": {
163
+ "description": "\u6210\u529f",
164
+ "content": {
165
+ "application/json": {
166
+ "schema": {
167
+ "type": "object",
168
+ "required": [
169
+ "credits_left",
170
+ "period",
171
+ "monthly_limit",
172
+ "monthly_usage"
173
+ ],
174
+ "properties": {
175
+ "credits_left": {
176
+ "type": "number",
177
+ "description": "Remaining credits,Each generated audio consumes 5 credits."
178
+ },
179
+ "period": { "type": "string", "description": "Period" },
180
+ "monthly_limit": {
181
+ "type": "number",
182
+ "description": "Monthly limit"
183
+ },
184
+ "monthly_usage": {
185
+ "type": "number",
186
+ "description": "Monthly usage"
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ },
197
+ "components": {
198
+ "schemas": {
199
+ "audio info": {
200
+ "type": "object",
201
+ "required": [
202
+ "id",
203
+ "title",
204
+ "image_url",
205
+ "lyric",
206
+ "audio_url",
207
+ "video_url",
208
+ "created_at",
209
+ "model_name",
210
+ "status",
211
+ "gpt_description_prompt",
212
+ "prompt",
213
+ "type",
214
+ "tags"
215
+ ],
216
+ "properties": {
217
+ "id": { "type": "string", "description": "audio id" },
218
+ "title": { "type": "string", "description": "music title" },
219
+ "image_url": { "type": "string", "description": "music cover image" },
220
+ "lyric": { "type": "string", "description": "music lyric" },
221
+ "audio_url": {
222
+ "type": "string",
223
+ "description": "music download url"
224
+ },
225
+ "video_url": {
226
+ "type": "string",
227
+ "description": "Music video download link, can be used to share"
228
+ },
229
+ "created_at": { "type": "string", "description": "Create time" },
230
+ "model_name": {
231
+ "type": "string",
232
+ "description": "suno model name, chirp-v3"
233
+ },
234
+ "status": {
235
+ "type": "string",
236
+ "description": "The generated states include submitted, queue, streaming, complete."
237
+ },
238
+ "gpt_description_prompt": {
239
+ "type": "string",
240
+ "description": "Simple mode on user input prompt, Suno will generate formal prompts, lyrics, etc."
241
+ },
242
+ "prompt": {
243
+ "type": "string",
244
+ "description": "The final prompt for executing the generation task, customized by the user in custom mode, automatically generated by Suno in simple mode."
245
+ },
246
+ "type": { "type": "string", "description": "Type" },
247
+ "tags": {
248
+ "type": "string",
249
+ "description": "Music genre. User-provided in custom mode, automatically generated by Suno in simple mode."
250
+ }
251
+ },
252
+ "title": "audio info",
253
+ "description": "audio info"
254
+ }
255
+ }
256
+ }
257
+ }
public/vercel.svg ADDED
src/app/api/clip/route.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { sunoApi } from "@/lib/SunoApi";
3
+ import { corsHeaders } from "@/lib/utils";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET(req: NextRequest) {
8
+ if (req.method === 'GET') {
9
+ try {
10
+ const url = new URL(req.url);
11
+ const clipId = url.searchParams.get('id');
12
+ if (clipId == null) {
13
+ return new NextResponse(JSON.stringify({ error: 'Missing parameter id' }), {
14
+ status: 400,
15
+ headers: {
16
+ 'Content-Type': 'application/json',
17
+ ...corsHeaders
18
+ }
19
+ });
20
+ }
21
+
22
+ const audioInfo = await (await sunoApi()).getClip(clipId);
23
+
24
+ return new NextResponse(JSON.stringify(audioInfo), {
25
+ status: 200,
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ ...corsHeaders
29
+ }
30
+ });
31
+ } catch (error) {
32
+ console.error('Error fetching audio:', error);
33
+
34
+ return new NextResponse(JSON.stringify({ error: 'Internal server error' }), {
35
+ status: 500,
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ ...corsHeaders
39
+ }
40
+ });
41
+ }
42
+ } else {
43
+ return new NextResponse('Method Not Allowed', {
44
+ headers: {
45
+ Allow: 'GET',
46
+ ...corsHeaders
47
+ },
48
+ status: 405
49
+ });
50
+ }
51
+ }
52
+
53
+ export async function OPTIONS(request: Request) {
54
+ return new Response(null, {
55
+ status: 200,
56
+ headers: corsHeaders
57
+ });
58
+ }
src/app/api/concat/route.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { cookies } from 'next/headers'
3
+ import { sunoApi } from "@/lib/SunoApi";
4
+ import { corsHeaders } from "@/lib/utils";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export async function POST(req: NextRequest) {
9
+ if (req.method === 'POST') {
10
+ try {
11
+ const body = await req.json();
12
+ const { clip_id } = body;
13
+ if (!clip_id) {
14
+ return new NextResponse(JSON.stringify({ error: 'Clip id is required' }), {
15
+ status: 400,
16
+ headers: {
17
+ 'Content-Type': 'application/json',
18
+ ...corsHeaders
19
+ }
20
+ });
21
+ }
22
+ const audioInfo = await (await sunoApi((await cookies()).toString())).concatenate(clip_id);
23
+ return new NextResponse(JSON.stringify(audioInfo), {
24
+ status: 200,
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ ...corsHeaders
28
+ }
29
+ });
30
+ } catch (error: any) {
31
+ console.error('Error generating concatenating audio:', error.response.data);
32
+ if (error.response.status === 402) {
33
+ return new NextResponse(JSON.stringify({ error: error.response.data.detail }), {
34
+ status: 402,
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ ...corsHeaders
38
+ }
39
+ });
40
+ }
41
+ return new NextResponse(JSON.stringify({ error: 'Internal server error' }), {
42
+ status: 500,
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ ...corsHeaders
46
+ }
47
+ });
48
+ }
49
+ } else {
50
+ return new NextResponse('Method Not Allowed', {
51
+ headers: {
52
+ Allow: 'POST',
53
+ ...corsHeaders
54
+ },
55
+ status: 405
56
+ });
57
+ }
58
+ }
59
+
60
+ export async function OPTIONS(request: Request) {
61
+ return new Response(null, {
62
+ status: 200,
63
+ headers: corsHeaders
64
+ });
65
+ }
src/app/api/custom_generate/route.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { cookies } from 'next/headers';
3
+ import { DEFAULT_MODEL, sunoApi } from "@/lib/SunoApi";
4
+ import { corsHeaders } from "@/lib/utils";
5
+
6
+ export const maxDuration = 60; // allow longer timeout for wait_audio == true
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export async function POST(req: NextRequest) {
10
+ if (req.method === 'POST') {
11
+ try {
12
+ const body = await req.json();
13
+ const { prompt, tags, title, make_instrumental, model, wait_audio, negative_tags } = body;
14
+ const audioInfo = await (await sunoApi((await cookies()).toString())).custom_generate(
15
+ prompt, tags, title,
16
+ Boolean(make_instrumental),
17
+ model || DEFAULT_MODEL,
18
+ Boolean(wait_audio),
19
+ negative_tags
20
+ );
21
+ return new NextResponse(JSON.stringify(audioInfo), {
22
+ status: 200,
23
+ headers: {
24
+ 'Content-Type': 'application/json',
25
+ ...corsHeaders
26
+ }
27
+ });
28
+ } catch (error: any) {
29
+ console.error('Error generating custom audio:', error);
30
+ return new NextResponse(JSON.stringify({ error: error.response?.data?.detail || error.toString() }), {
31
+ status: error.response?.status || 500,
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ ...corsHeaders
35
+ }
36
+ });
37
+ }
38
+ } else {
39
+ return new NextResponse('Method Not Allowed', {
40
+ headers: {
41
+ Allow: 'POST',
42
+ ...corsHeaders
43
+ },
44
+ status: 405
45
+ });
46
+ }
47
+ }
48
+
49
+ export async function OPTIONS(request: Request) {
50
+ return new Response(null, {
51
+ status: 200,
52
+ headers: corsHeaders
53
+ });
54
+ }
src/app/api/extend_audio/route.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { cookies } from 'next/headers'
3
+ import { DEFAULT_MODEL, sunoApi } from "@/lib/SunoApi";
4
+ import { corsHeaders } from "@/lib/utils";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export async function POST(req: NextRequest) {
9
+ if (req.method === 'POST') {
10
+ try {
11
+ const body = await req.json();
12
+ const { audio_id, prompt, continue_at, tags, negative_tags, title, model, wait_audio } = body;
13
+
14
+ if (!audio_id) {
15
+ return new NextResponse(JSON.stringify({ error: 'Audio ID is required' }), {
16
+ status: 400,
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ ...corsHeaders
20
+ }
21
+ });
22
+ }
23
+
24
+ const audioInfo = await (await sunoApi((await cookies()).toString()))
25
+ .extendAudio(audio_id, prompt, continue_at, tags || '', negative_tags || '', title, model || DEFAULT_MODEL, wait_audio || false);
26
+
27
+ return new NextResponse(JSON.stringify(audioInfo), {
28
+ status: 200,
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ ...corsHeaders
32
+ }
33
+ });
34
+ } catch (error: any) {
35
+ console.error('Error extend audio:', JSON.stringify(error.response.data));
36
+ if (error.response.status === 402) {
37
+ return new NextResponse(JSON.stringify({ error: error.response.data.detail }), {
38
+ status: 402,
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ ...corsHeaders
42
+ }
43
+ });
44
+ }
45
+ return new NextResponse(JSON.stringify({ error: 'Internal server error: ' + JSON.stringify(error.response.data.detail) }), {
46
+ status: 500,
47
+ headers: {
48
+ 'Content-Type': 'application/json',
49
+ ...corsHeaders
50
+ }
51
+ });
52
+ }
53
+ } else {
54
+ return new NextResponse('Method Not Allowed', {
55
+ headers: {
56
+ Allow: 'POST',
57
+ ...corsHeaders
58
+ },
59
+ status: 405
60
+ });
61
+ }
62
+ }
63
+
64
+
65
+ export async function OPTIONS(request: Request) {
66
+ return new Response(null, {
67
+ status: 200,
68
+ headers: corsHeaders
69
+ });
70
+ }
src/app/api/generate/route.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { cookies } from 'next/headers'
3
+ import { DEFAULT_MODEL, sunoApi } from "@/lib/SunoApi";
4
+ import { corsHeaders } from "@/lib/utils";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export async function POST(req: NextRequest) {
9
+ if (req.method === 'POST') {
10
+ try {
11
+ const body = await req.json();
12
+ const { prompt, make_instrumental, model, wait_audio } = body;
13
+
14
+ const audioInfo = await (await sunoApi((await cookies()).toString())).generate(
15
+ prompt,
16
+ Boolean(make_instrumental),
17
+ model || DEFAULT_MODEL,
18
+ Boolean(wait_audio)
19
+ );
20
+
21
+ return new NextResponse(JSON.stringify(audioInfo), {
22
+ status: 200,
23
+ headers: {
24
+ 'Content-Type': 'application/json',
25
+ ...corsHeaders
26
+ }
27
+ });
28
+ } catch (error: any) {
29
+ console.error('Error generating custom audio:', JSON.stringify(error.response.data));
30
+ if (error.response.status === 402) {
31
+ return new NextResponse(JSON.stringify({ error: error.response.data.detail }), {
32
+ status: 402,
33
+ headers: {
34
+ 'Content-Type': 'application/json',
35
+ ...corsHeaders
36
+ }
37
+ });
38
+ }
39
+ return new NextResponse(JSON.stringify({ error: 'Internal server error: ' + JSON.stringify(error.response.data.detail) }), {
40
+ status: 500,
41
+ headers: {
42
+ 'Content-Type': 'application/json',
43
+ ...corsHeaders
44
+ }
45
+ });
46
+ }
47
+ } else {
48
+ return new NextResponse('Method Not Allowed', {
49
+ headers: {
50
+ Allow: 'POST',
51
+ ...corsHeaders
52
+ },
53
+ status: 405
54
+ });
55
+ }
56
+ }
57
+
58
+
59
+ export async function OPTIONS(request: Request) {
60
+ return new Response(null, {
61
+ status: 200,
62
+ headers: corsHeaders
63
+ });
64
+ }
src/app/api/generate_lyrics/route.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { cookies } from 'next/headers'
3
+ import { sunoApi } from "@/lib/SunoApi";
4
+ import { corsHeaders } from "@/lib/utils";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export async function POST(req: NextRequest) {
9
+ if (req.method === 'POST') {
10
+ try {
11
+ const body = await req.json();
12
+ const { prompt } = body;
13
+
14
+ const lyrics = await (await sunoApi((await cookies()).toString())).generateLyrics(prompt);
15
+
16
+ return new NextResponse(JSON.stringify(lyrics), {
17
+ status: 200,
18
+ headers: {
19
+ 'Content-Type': 'application/json',
20
+ ...corsHeaders
21
+ }
22
+ });
23
+ } catch (error: any) {
24
+ console.error('Error generating lyrics:', JSON.stringify(error.response.data));
25
+ if (error.response.status === 402) {
26
+ return new NextResponse(JSON.stringify({ error: error.response.data.detail }), {
27
+ status: 402,
28
+ headers: {
29
+ 'Content-Type': 'application/json',
30
+ ...corsHeaders
31
+ }
32
+ });
33
+ }
34
+ return new NextResponse(JSON.stringify({ error: 'Internal server error: ' + JSON.stringify(error.response.data.detail) }), {
35
+ status: 500,
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ ...corsHeaders
39
+ }
40
+ });
41
+ }
42
+ } else {
43
+ return new NextResponse('Method Not Allowed', {
44
+ headers: {
45
+ Allow: 'POST',
46
+ ...corsHeaders
47
+ },
48
+ status: 405
49
+ });
50
+ }
51
+ }
52
+
53
+ export async function OPTIONS(request: Request) {
54
+ return new Response(null, {
55
+ status: 200,
56
+ headers: corsHeaders
57
+ });
58
+ }
src/app/api/generate_stems/route.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { cookies } from 'next/headers';
3
+ import { DEFAULT_MODEL, sunoApi } from "@/lib/SunoApi";
4
+ import { corsHeaders } from "@/lib/utils";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export async function POST(req: NextRequest) {
9
+ if (req.method === 'POST') {
10
+ try {
11
+ const body = await req.json();
12
+ const { audio_id } = body;
13
+
14
+ if (!audio_id) {
15
+ return new NextResponse(JSON.stringify({ error: 'Audio ID is required' }), {
16
+ status: 400,
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ ...corsHeaders
20
+ }
21
+ });
22
+ }
23
+
24
+ const audioInfo = await (await sunoApi((await cookies()).toString()))
25
+ .generateStems(audio_id);
26
+
27
+ return new NextResponse(JSON.stringify(audioInfo), {
28
+ status: 200,
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ ...corsHeaders
32
+ }
33
+ });
34
+ } catch (error: any) {
35
+ console.error('Error generating stems:', JSON.stringify(error.response.data));
36
+ if (error.response.status === 402) {
37
+ return new NextResponse(JSON.stringify({ error: error.response.data.detail }), {
38
+ status: 402,
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ ...corsHeaders
42
+ }
43
+ });
44
+ }
45
+ return new NextResponse(JSON.stringify({ error: 'Internal server error: ' + JSON.stringify(error.response.data.detail) }), {
46
+ status: 500,
47
+ headers: {
48
+ 'Content-Type': 'application/json',
49
+ ...corsHeaders
50
+ }
51
+ });
52
+ }
53
+ } else {
54
+ return new NextResponse('Method Not Allowed', {
55
+ headers: {
56
+ Allow: 'POST',
57
+ ...corsHeaders
58
+ },
59
+ status: 405
60
+ });
61
+ }
62
+ }
63
+
64
+
65
+ export async function OPTIONS(request: Request) {
66
+ return new Response(null, {
67
+ status: 200,
68
+ headers: corsHeaders
69
+ });
70
+ }
src/app/api/get/route.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from 'next/server';
2
+ import { cookies } from 'next/headers';
3
+ import { sunoApi } from '@/lib/SunoApi';
4
+ import { corsHeaders } from '@/lib/utils';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export async function GET(req: NextRequest) {
9
+ if (req.method === 'GET') {
10
+ try {
11
+ const url = new URL(req.url);
12
+ const songIds = url.searchParams.get('ids');
13
+ const page = url.searchParams.get('page');
14
+ const cookie = (await cookies()).toString();
15
+
16
+ let audioInfo = [];
17
+ if (songIds && songIds.length > 0) {
18
+ const idsArray = songIds.split(',');
19
+ audioInfo = await (await sunoApi(cookie)).get(idsArray, page);
20
+ } else {
21
+ audioInfo = await (await sunoApi(cookie)).get(undefined, page);
22
+ }
23
+
24
+ return new NextResponse(JSON.stringify(audioInfo), {
25
+ status: 200,
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ ...corsHeaders
29
+ }
30
+ });
31
+ } catch (error) {
32
+ console.error('Error fetching audio:', error);
33
+
34
+ return new NextResponse(
35
+ JSON.stringify({ error: 'Internal server error' }),
36
+ {
37
+ status: 500,
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ ...corsHeaders
41
+ }
42
+ }
43
+ );
44
+ }
45
+ } else {
46
+ return new NextResponse('Method Not Allowed', {
47
+ headers: {
48
+ Allow: 'GET',
49
+ ...corsHeaders
50
+ },
51
+ status: 405
52
+ });
53
+ }
54
+ }
55
+
56
+ export async function OPTIONS(request: Request) {
57
+ return new Response(null, {
58
+ status: 200,
59
+ headers: corsHeaders
60
+ });
61
+ }
src/app/api/get_aligned_lyrics/route.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { cookies } from 'next/headers'
3
+ import { sunoApi } from "@/lib/SunoApi";
4
+ import { corsHeaders } from "@/lib/utils";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export async function GET(req: NextRequest) {
9
+ if (req.method === 'GET') {
10
+ try {
11
+ const url = new URL(req.url);
12
+ const song_id = url.searchParams.get('song_id');
13
+
14
+ if (!song_id) {
15
+ return new NextResponse(JSON.stringify({ error: 'Song ID is required' }), {
16
+ status: 400,
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ ...corsHeaders
20
+ }
21
+ });
22
+ }
23
+
24
+ const lyricAlignment = await (await sunoApi((await cookies()).toString())).getLyricAlignment(song_id);
25
+
26
+
27
+ return new NextResponse(JSON.stringify(lyricAlignment), {
28
+ status: 200,
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ ...corsHeaders
32
+ }
33
+ });
34
+ } catch (error) {
35
+ console.error('Error fetching lyric alignment:', error);
36
+
37
+ return new NextResponse(JSON.stringify({ error: 'Internal server error. ' + error }), {
38
+ status: 500,
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ ...corsHeaders
42
+ }
43
+ });
44
+ }
45
+ } else {
46
+ return new NextResponse('Method Not Allowed', {
47
+ headers: {
48
+ Allow: 'GET',
49
+ ...corsHeaders
50
+ },
51
+ status: 405
52
+ });
53
+ }
54
+ }
55
+
56
+ export async function OPTIONS(request: Request) {
57
+ return new Response(null, {
58
+ status: 200,
59
+ headers: corsHeaders
60
+ });
61
+ }
src/app/api/get_limit/route.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { cookies } from 'next/headers'
3
+ import { sunoApi } from "@/lib/SunoApi";
4
+ import { corsHeaders } from "@/lib/utils";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export async function GET(req: NextRequest) {
9
+ if (req.method === 'GET') {
10
+ try {
11
+
12
+ const limit = await (await sunoApi((await cookies()).toString())).get_credits();
13
+
14
+
15
+ return new NextResponse(JSON.stringify(limit), {
16
+ status: 200,
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ ...corsHeaders
20
+ }
21
+ });
22
+ } catch (error) {
23
+ console.error('Error fetching limit:', error);
24
+
25
+ return new NextResponse(JSON.stringify({ error: 'Internal server error. ' + error }), {
26
+ status: 500,
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ ...corsHeaders
30
+ }
31
+ });
32
+ }
33
+ } else {
34
+ return new NextResponse('Method Not Allowed', {
35
+ headers: {
36
+ Allow: 'GET',
37
+ ...corsHeaders
38
+ },
39
+ status: 405
40
+ });
41
+ }
42
+ }
43
+
44
+ export async function OPTIONS(request: Request) {
45
+ return new Response(null, {
46
+ status: 200,
47
+ headers: corsHeaders
48
+ });
49
+ }
src/app/api/persona/route.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { sunoApi } from "@/lib/SunoApi";
3
+ import { corsHeaders } from "@/lib/utils";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET(req: NextRequest) {
8
+ if (req.method === 'GET') {
9
+ try {
10
+ const url = new URL(req.url);
11
+ const personaId = url.searchParams.get('id');
12
+ const page = url.searchParams.get('page');
13
+
14
+ if (personaId == null) {
15
+ return new NextResponse(JSON.stringify({ error: 'Missing parameter id' }), {
16
+ status: 400,
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ ...corsHeaders
20
+ }
21
+ });
22
+ }
23
+
24
+ const pageNumber = page ? parseInt(page) : 1;
25
+ const personaInfo = await (await sunoApi()).getPersonaPaginated(personaId, pageNumber);
26
+
27
+ return new NextResponse(JSON.stringify(personaInfo), {
28
+ status: 200,
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ ...corsHeaders
32
+ }
33
+ });
34
+ } catch (error) {
35
+ console.error('Error fetching persona:', error);
36
+
37
+ return new NextResponse(JSON.stringify({ error: 'Internal server error' }), {
38
+ status: 500,
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ ...corsHeaders
42
+ }
43
+ });
44
+ }
45
+ } else {
46
+ return new NextResponse('Method Not Allowed', {
47
+ headers: {
48
+ Allow: 'GET',
49
+ ...corsHeaders
50
+ },
51
+ status: 405
52
+ });
53
+ }
54
+ }
55
+
56
+ export async function OPTIONS(request: Request) {
57
+ return new Response(null, {
58
+ status: 200,
59
+ headers: corsHeaders
60
+ });
61
+ }
src/app/components/Footer.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from "next/link";
2
+ import Image from "next/image";
3
+
4
+ export default function Footer() {
5
+ return (
6
+ <footer className=" flex w-full justify-center py-4 items-center
7
+ bg-indigo-900 text-white/60 backdrop-blur-2xl font-mono text-sm px-4 lg:px-0
8
+ ">
9
+ <p className="px-6 py-3 rounded-full flex justify-center items-center gap-2
10
+ hover:text-white duration-200
11
+ ">
12
+
13
+ </p>
14
+ <p className="px-6 py-3 rounded-full flex justify-center items-center gap-2
15
+ hover:text-white duration-200
16
+ ">
17
+ <span>© 2024</span>
18
+ <Link href="https://github.com/gcui-art/suno-api/">
19
+ gcui-art/suno-api
20
+ </Link>
21
+ </p>
22
+ </footer>
23
+ );
24
+ }
src/app/components/Header.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from "next/link";
2
+ import Image from "next/image";
3
+ import Logo from "./Logo";
4
+
5
+ export default function Header() {
6
+ return (
7
+ <nav className=" flex w-full justify-center py-4 items-center
8
+ border-b border-gray-300 backdrop-blur-2xl font-mono text-sm px-4 lg:px-0">
9
+ <div className="max-w-3xl flex w-full items-center justify-between">
10
+ <div className="font-medium text-xl text-indigo-900 flex items-center gap-2">
11
+ <Logo className="w-4 h-4" />
12
+ <Link href='/'>
13
+ Suno API
14
+ </Link>
15
+ </div>
16
+ <div className="flex items-center justify-center gap-1 text-sm font-light text-indigo-900/90">
17
+ <p className="p-2 lg:px-6 lg:py-3 rounded-full flex justify-center items-center
18
+ lg:hover:bg-indigo-300 duration-200
19
+ ">
20
+ <Link href="/">
21
+ Get Started
22
+ </Link>
23
+ </p>
24
+ <p className="p-2 lg:px-6 lg:py-3 rounded-full flex justify-center items-center
25
+ lg:hover:bg-indigo-300 duration-200
26
+ ">
27
+ <Link href="/docs">
28
+ API Docs
29
+ </Link>
30
+ </p>
31
+ <p className="p-2 lg:px-6 lg:py-3 rounded-full flex justify-center items-center
32
+ lg:hover:bg-indigo-300 duration-200
33
+ ">
34
+ <a href="https://github.com/gcui-art/suno-api/"
35
+ target="_blank"
36
+ className="flex items-center justify-center gap-1">
37
+ <span className="">
38
+ <Image src="/github-mark.png" alt="GitHub Logo" width={20} height={20} />
39
+ </span>
40
+ <span>Github</span>
41
+ </a>
42
+ </p>
43
+ </div>
44
+
45
+
46
+
47
+ </div>
48
+ </nav>
49
+ );
50
+ }
src/app/components/Logo.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ export default function Logo({ className = '', ...props }) {
4
+ return (
5
+ <span className=" bg-indigo-900 rounded-full p-2">
6
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className={className}
7
+ fill="none" stroke="#ffffff" strokeWidth="1"
8
+ strokeLinecap="round" strokeLinejoin="round">
9
+ <path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3" />
10
+ </svg>
11
+ </span>
12
+ );
13
+ }
src/app/components/Section.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ /**
3
+ *
4
+ * @param param0
5
+ * @returns
6
+ */
7
+ export default function Section({
8
+ children,
9
+ className
10
+ }: {
11
+ children?: React.ReactNode | string,
12
+ className?: string
13
+ }) {
14
+
15
+ return (
16
+ <section className={`mx-auto w-full px-4 lg:px-0 ${className}`} >
17
+ <div className=" max-w-3xl mx-auto">
18
+ {children}
19
+ </div>
20
+ </section>
21
+ );
22
+ };
src/app/components/Swagger.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import 'swagger-ui-react/swagger-ui.css';
3
+ import dynamic from "next/dynamic";
4
+
5
+ type Props = {
6
+ spec: Record<string, any>,
7
+ };
8
+
9
+ const SwaggerUI = dynamic(() => import('swagger-ui-react'), { ssr: false });
10
+
11
+ function Swagger({ spec }: Props) {
12
+ return <SwaggerUI spec={spec} requestInterceptor={(req) => {
13
+ // Remove cookies before sending requests
14
+ req.credentials = 'omit';
15
+ console.log(req);
16
+ return req;
17
+ }} />;
18
+ }
19
+
20
+ export default Swagger;
src/app/docs/page.tsx ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import Swagger from '../components/Swagger';
3
+ import spec from './swagger-suno-api.json'; // 直接导入JSON文件
4
+ import Section from '../components/Section';
5
+ import Markdown from 'react-markdown';
6
+
7
+
8
+ export default function Docs() {
9
+ return (
10
+ <>
11
+ <Section className="my-10">
12
+ <article className="prose lg:prose-lg max-w-3xl pt-10">
13
+ <h1 className=' text-center text-indigo-900'>
14
+ API Docs
15
+ </h1>
16
+ <Markdown>
17
+ {`
18
+ ---
19
+ \`gcui-art/suno-api\` currently mainly implements the following APIs:
20
+
21
+ \`\`\`bash
22
+ - \`/api/generate\`: Generate music
23
+ - \`/v1/chat/completions\`: Generate music - Call the generate API in a format
24
+ that works with OpenAI’s API.
25
+ - \`/api/custom_generate\`: Generate music (Custom Mode, support setting lyrics,
26
+ music style, title, etc.)
27
+ - \`/api/generate_lyrics\`: Generate lyrics based on prompt
28
+ - \`/api/get\`: Get music information based on the id. Use “,” to separate multiple
29
+ ids. If no IDs are provided, all music will be returned.
30
+ - \`/api/get_limit\`: Get quota Info
31
+ - \`/api/extend_audio\`: Extend audio length
32
+ - \`/api/generate_stems\`: Make stem tracks (separate audio and music track)
33
+ - \`/api/get_aligned_lyrics\`: Get list of timestamps for each word in the lyrics
34
+ - \`/api/clip\`: Get clip information based on ID passed as query parameter \`id\`
35
+ - \`/api/concat\`: Generate the whole song from extensions
36
+ - \`/api/persona\`: Get persona information and clips based on ID and page number
37
+ \`\`\`
38
+
39
+ Feel free to explore the detailed API parameters and conduct tests on this page.
40
+ `}
41
+ </Markdown>
42
+ </article>
43
+ </Section>
44
+ <Section className="my-10">
45
+ <article className='prose lg:prose-lg max-w-3xl py-10'>
46
+ <h2 className='text-center'>
47
+ Details of the API and testing it online
48
+ </h2>
49
+ <p className='text-red-800 italic'>
50
+ This is just a demo, bound to a test account. Please do not use it frequently, so that more people can test online.
51
+ </p>
52
+ </article>
53
+
54
+ <div className=' border p-4 rounded-2xl shadow-xl hover:shadow-none duration-200'>
55
+ <Swagger spec={spec} />
56
+ </div>
57
+
58
+ </Section>
59
+ </>
60
+
61
+ );
62
+ }
src/app/docs/swagger-suno-api.json ADDED
@@ -0,0 +1,814 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "suno-api",
5
+ "description": "Use API to call the music generation service of Suno.ai and easily integrate it into agents like GPTs.",
6
+ "version": "1.1.0"
7
+ },
8
+ "tags": [
9
+ {
10
+ "name": "default"
11
+ }
12
+ ],
13
+ "paths": {
14
+ "/api/generate": {
15
+ "post": {
16
+ "summary": "Generate audio based on Prompt.",
17
+ "description": "It will automatically fill in the lyrics.\n\n2 audio files will be generated for each request, consuming a total of 10 credits.\n\n`wait_audio` can be set to API mode:\n\n\u2022 By default, it is set to `false`, which indicates the background mode. It will only return audio task information, and you will need to call the get API to retrieve detailed audio information.\n\n\u2022 If set to `true`, it simulates synchronous mode. The API will wait for a maximum of 100s until the audio is generated, and will directly return the audio link and other information. Recommend using in GPTs and other agents.",
18
+ "tags": ["default"],
19
+ "requestBody": {
20
+ "content": {
21
+ "application/json": {
22
+ "schema": {
23
+ "type": "object",
24
+ "required": ["prompt", "make_instrumental", "wait_audio"],
25
+ "properties": {
26
+ "prompt": {
27
+ "type": "string",
28
+ "description": "Prompt",
29
+ "example": "A popular heavy metal song about war, sung by a deep-voiced male singer, slowly and melodiously. The lyrics depict the sorrow of people after the war."
30
+ },
31
+ "make_instrumental": {
32
+ "type": "boolean",
33
+ "description": "Whether to generate instrumental music",
34
+ "example": "false"
35
+ },
36
+ "model": {
37
+ "type": "string",
38
+ "description": "Model name ,default is chirp-v3-5",
39
+ "example": "chirp-v3-5|chirp-v3-0"
40
+ },
41
+ "wait_audio": {
42
+ "type": "boolean",
43
+ "description": "Whether to wait for music generation, default is false, directly return audio task information; set to true, will wait for up to 100s until the audio is generated.",
44
+ "example": "false"
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ },
51
+ "responses": {
52
+ "200": {
53
+ "description": "success",
54
+ "content": {
55
+ "application/json": {
56
+ "schema": {
57
+ "type": "array",
58
+ "items": {
59
+ "type": "object",
60
+ "required": ["0", "1"],
61
+ "properties": [
62
+ {
63
+ "$ref": "#/components/schemas/audio_info"
64
+ },
65
+ {
66
+ "$ref": "#/components/schemas/audio_info"
67
+ }
68
+ ]
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ },
77
+ "/v1/chat/completions": {
78
+ "post": {
79
+ "summary": "Generate audio based on Prompt - OpenAI API format compatibility.",
80
+ "description": "Convert the `/api/generate` API to be compatible with the OpenAI `/v1/chat/completions` API format. \n\nGenerally used in OpenAI compatible clients.",
81
+ "tags": ["default"],
82
+ "requestBody": {
83
+ "content": {
84
+ "application/json": {
85
+ "schema": {
86
+ "type": "object",
87
+ "required": ["prompt"],
88
+ "properties": {
89
+ "prompt": {
90
+ "type": "string",
91
+ "description": "Prompt",
92
+ "example": "A popular heavy metal song about war, sung by a deep-voiced male singer, slowly and melodiously. The lyrics depict the sorrow of people after the war."
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ },
99
+ "responses": {
100
+ "200": {
101
+ "description": "success",
102
+ "content": {
103
+ "application/json": {
104
+ "schema": {
105
+ "type": "object",
106
+ "properties": {
107
+ "data": {
108
+ "type": "string",
109
+ "description": "Text description for music, with details like title, album cover, lyrics, and more."
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ },
119
+ "/api/custom_generate": {
120
+ "post": {
121
+ "summary": "Generate Audio - Custom Mode",
122
+ "description": "The custom mode enables users to provide additional details about the music, such as music genre, lyrics, and more.\n\n 2 audio files will be generated for each request, consuming a total of 10 credits. \n\n `wait_audio` can be set to API mode:\n\n\u2022 By default, it is set to false, which indicates the background mode. It will only return audio task information, and you will need to call the get API to retrieve detailed audio information.\n\n\u2022 If set to true, it simulates synchronous mode. The API will wait for a maximum of 100s until the audio is generated, and will directly return the audio link and other information. Recommend using in GPTs and other agents.",
123
+ "tags": ["default"],
124
+ "requestBody": {
125
+ "content": {
126
+ "application/json": {
127
+ "schema": {
128
+ "type": "object",
129
+ "required": ["prompt", "tags", "title"],
130
+ "properties": {
131
+ "prompt": {
132
+ "type": "string",
133
+ "description": "Detailed prompt, including information such as music lyrics.",
134
+ "example": "[Verse 1]\nCruel flames of war engulf this land\nBattlefields filled with death and dread\nInnocent souls in darkness, they rest\nMy heart trembles in this silent test\n\n[Verse 2]\nPeople weep for loved ones lost\nBattered bodies bear the cost\nSeeking peace and hope once known\nOur grief transforms to hearts of stone\n\n[Chorus]\nSilent battlegrounds, no birds' song\nShadows of war, where we don't belong\nMay flowers of peace bloom in this place\nLet's guard this precious dream with grace\n\n[Bridge]\nThrough the ashes, we will rise\nHand in hand, towards peaceful skies\nNo more sorrow, no more pain\nTogether, we'll break these chains\n\n[Chorus]\nSilent battlegrounds, no birds' song\nShadows of war, where we don't belong\nMay flowers of peace bloom in this place\nLet's guard this precious dream with grace\n\n[Outro]\nIn unity, our strength will grow\nA brighter future, we'll soon know\nFrom the ruins, hope will spring\nA new dawn, we'll together bring"
135
+ },
136
+ "tags": {
137
+ "type": "string",
138
+ "description": "Music genre",
139
+ "example": "pop metal male melancholic"
140
+ },
141
+ "negative_tags": {
142
+ "type": "string",
143
+ "description": "Negative Music genre",
144
+ "example": "female edm techno"
145
+ },
146
+ "title": {
147
+ "type": "string",
148
+ "description": "Music title",
149
+ "example": "Silent Battlefield"
150
+ },
151
+ "make_instrumental": {
152
+ "type": "boolean",
153
+ "description": "Whether to generate instrumental music",
154
+ "example": "false"
155
+ },
156
+ "model": {
157
+ "type": "string",
158
+ "description": "Model name ,default is chirp-v3-5",
159
+ "example": "chirp-v3-5|chirp-v3-0"
160
+ },
161
+ "wait_audio": {
162
+ "type": "boolean",
163
+ "description": "Whether to wait for music generation, default is false, directly return audio task information; set to true, will wait for up to 100s until the audio is generated.",
164
+ "example": "false"
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+ },
171
+ "responses": {
172
+ "200": {
173
+ "description": "success",
174
+ "content": {
175
+ "application/json": {
176
+ "schema": {
177
+ "type": "array",
178
+ "items": {
179
+ "type": "object",
180
+ "required": ["0", "1"],
181
+ "properties": [
182
+ {
183
+ "$ref": "#/components/schemas/audio_info"
184
+ },
185
+ {
186
+ "$ref": "#/components/schemas/audio_info"
187
+ }
188
+ ]
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ },
197
+ "/api/extend_audio": {
198
+ "post": {
199
+ "summary": "Extend audio length.",
200
+ "description": "Extend audio length.",
201
+ "tags": ["default"],
202
+ "requestBody": {
203
+ "content": {
204
+ "application/json": {
205
+ "schema": {
206
+ "type": "object",
207
+ "required": ["audio_id"],
208
+ "properties": {
209
+ "audio_id": {
210
+ "type": "string",
211
+ "description": "The ID of the audio clip to extend.",
212
+ "example": "e76498dc-6ab4-4a10-a19f-8a095790e28d"
213
+ },
214
+ "prompt": {
215
+ "type": "string",
216
+ "description": "Detailed prompt, including information such as music lyrics.",
217
+ "example": "[lrc]Silent battlegrounds, no birds' song\nShadows of war, where we don't belong\nMay flowers of peace bloom in this place\nLet's guard this precious dream with grace\n[endlrc]"
218
+ },
219
+ "continue_at": {
220
+ "type": "string",
221
+ "description": "Extend a new clip from a song at mm:ss(e.g. 00:30). Default extends from the end of the song.",
222
+ "example": "109.96"
223
+ },
224
+ "title": {
225
+ "type": "string",
226
+ "description": "Music title",
227
+ "example": ""
228
+ },
229
+ "tags": {
230
+ "type": "string",
231
+ "description": "Music genre",
232
+ "example": ""
233
+ },
234
+ "negative_tags": {
235
+ "type": "string",
236
+ "description": "Negative Music genre",
237
+ "example":""
238
+ },
239
+ "model": {
240
+ "type": "string",
241
+ "description": "Model name ,default is chirp-v3-5",
242
+ "example": "chirp-v3-5|chirp-v3-0"
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+ },
251
+ "/api/generate_stems": {
252
+ "post": {
253
+ "summary": "Make stem tracks (separate audio and music track).",
254
+ "description": "Make stem tracks (separate audio and music track).",
255
+ "tags": ["default"],
256
+ "requestBody": {
257
+ "content": {
258
+ "application/json": {
259
+ "schema": {
260
+ "type": "object",
261
+ "required": ["audio_id"],
262
+ "properties": {
263
+ "audio_id": {
264
+ "type": "string",
265
+ "description": "The ID of the song to generate stems for.",
266
+ "example": "e76498dc-6ab4-4a10-a19f-8a095790e28d"
267
+ }
268
+ }
269
+ }
270
+ }
271
+ }
272
+ },
273
+ "responses": {
274
+ "200": {
275
+ "$ref": "#/components/schemas/audio_info"
276
+ }
277
+ }
278
+ }
279
+ },
280
+ "/api/generate_lyrics": {
281
+ "post": {
282
+ "summary": "Generate lyrics based on Prompt.",
283
+ "description": "Generate lyrics based on Prompt.",
284
+ "tags": ["default"],
285
+ "requestBody": {
286
+ "content": {
287
+ "application/json": {
288
+ "schema": {
289
+ "type": "object",
290
+ "required": ["prompt"],
291
+ "properties": {
292
+ "prompt": {
293
+ "type": "string",
294
+ "description": "Prompt",
295
+ "example": "A soothing lullaby"
296
+ }
297
+ }
298
+ }
299
+ }
300
+ }
301
+ },
302
+ "responses": {
303
+ "200": {
304
+ "description": "success",
305
+ "content": {
306
+ "application/json": {
307
+ "schema": {
308
+ "type": "object",
309
+ "properties": {
310
+ "text": {
311
+ "type": "string",
312
+ "description": "Lyrics"
313
+ },
314
+ "title": {
315
+ "type": "string",
316
+ "description": "music title"
317
+ },
318
+ "status": {
319
+ "type": "string",
320
+ "description": "Status"
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ },
330
+ "/api/get": {
331
+ "get": {
332
+ "summary": "Get audio information",
333
+ "description": "",
334
+ "tags": ["default"],
335
+ "parameters": [
336
+ {
337
+ "in": "query",
338
+ "name": "ids",
339
+ "description": "Audio IDs, separated by commas. Leave blank to return a list of all music.",
340
+ "required": false,
341
+ "schema": {
342
+ "type": "string"
343
+ }
344
+ },
345
+ {
346
+ "in": "query",
347
+ "name": "page",
348
+ "description": "Page number",
349
+ "required": false,
350
+ "schema": {
351
+ "type": "number"
352
+ }
353
+ }
354
+ ],
355
+ "responses": {
356
+ "200": {
357
+ "description": "success",
358
+ "content": {
359
+ "application/json": {
360
+ "schema": {
361
+ "type": "array",
362
+ "items": {
363
+ "type": "object",
364
+ "required": ["0", "1"],
365
+ "properties": [
366
+ {
367
+ "$ref": "#/components/schemas/audio_info"
368
+ },
369
+ {
370
+ "$ref": "#/components/schemas/audio_info"
371
+ }
372
+ ]
373
+ }
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+ },
381
+ "/api/get_limit": {
382
+ "get": {
383
+ "summary": "Get quota information.",
384
+ "description": "",
385
+ "tags": ["default"],
386
+ "responses": {
387
+ "200": {
388
+ "description": "success",
389
+ "content": {
390
+ "application/json": {
391
+ "schema": {
392
+ "type": "object",
393
+ "required": [
394
+ "credits_left",
395
+ "period",
396
+ "monthly_limit",
397
+ "monthly_usage"
398
+ ],
399
+ "properties": {
400
+ "credits_left": {
401
+ "type": "number",
402
+ "description": "Remaining credits,Each generated audio consumes 5 credits."
403
+ },
404
+ "period": {
405
+ "type": "string",
406
+ "description": "Period"
407
+ },
408
+ "monthly_limit": {
409
+ "type": "number",
410
+ "description": "Monthly limit"
411
+ },
412
+ "monthly_usage": {
413
+ "type": "number",
414
+ "description": "Monthly usage"
415
+ }
416
+ }
417
+ }
418
+ }
419
+ }
420
+ }
421
+ }
422
+ }
423
+ },
424
+ "/api/get_aligned_lyrics": {
425
+ "get": {
426
+ "summary": "Get lyric alignment.",
427
+ "description": "Get lyric alignment.",
428
+ "tags": ["default"],
429
+ "parameters": [
430
+ {
431
+ "name": "song_id",
432
+ "in": "query",
433
+ "required": true,
434
+ "description": "Song ID",
435
+ "schema": {
436
+ "type": "string"
437
+ }
438
+ }
439
+ ],
440
+ "responses": {
441
+ "200": {
442
+ "$ref": "#/components/schemas/audio_info"
443
+ }
444
+ }
445
+ }
446
+ },
447
+ "/api/clip": {
448
+ "get": {
449
+ "summary": "Get clip information based on ID.",
450
+ "description": "Retrieve specific clip information using the provided clip ID as a query parameter.",
451
+ "tags": ["default"],
452
+ "parameters": [
453
+ {
454
+ "name": "id",
455
+ "in": "query",
456
+ "required": true,
457
+ "description": "Clip ID",
458
+ "schema": {
459
+ "type": "string"
460
+ }
461
+ }
462
+ ],
463
+ "responses": {
464
+ "200": {
465
+ "description": "success",
466
+ "content": {
467
+ "application/json": {
468
+ "schema": {
469
+ "$ref": "#/components/schemas/audio_info"
470
+ }
471
+ }
472
+ }
473
+ },
474
+ "400": {
475
+ "description": "Missing parameter id",
476
+ "content": {
477
+ "application/json": {
478
+ "schema": {
479
+ "type": "object",
480
+ "properties": {
481
+ "error": {
482
+ "type": "string",
483
+ "example": "Missing parameter id"
484
+ }
485
+ }
486
+ }
487
+ }
488
+ }
489
+ },
490
+ "500": {
491
+ "description": "Internal server error",
492
+ "content": {
493
+ "application/json": {
494
+ "schema": {
495
+ "type": "object",
496
+ "properties": {
497
+ "error": {
498
+ "type": "string",
499
+ "example": "Internal server error"
500
+ }
501
+ }
502
+ }
503
+ }
504
+ }
505
+ }
506
+ }
507
+ }
508
+ },
509
+ "/api/concat": {
510
+ "post": {
511
+ "summary": "Generate the whole song from extensions.",
512
+ "description": "Concatenate audio clips to generate a complete song using the provided clip ID.",
513
+ "tags": ["default"],
514
+ "requestBody": {
515
+ "content": {
516
+ "application/json": {
517
+ "schema": {
518
+ "type": "object",
519
+ "required": ["clip_id"],
520
+ "properties": {
521
+ "clip_id": {
522
+ "type": "string",
523
+ "description": "Clip ID"
524
+ }
525
+ }
526
+ }
527
+ }
528
+ }
529
+ },
530
+ "responses": {
531
+ "200": {
532
+ "description": "success",
533
+ "content": {
534
+ "application/json": {
535
+ "schema": {
536
+ "$ref": "#/components/schemas/audio_info"
537
+ }
538
+ }
539
+ }
540
+ },
541
+ "400": {
542
+ "description": "Clip id is required",
543
+ "content": {
544
+ "application/json": {
545
+ "schema": {
546
+ "type": "object",
547
+ "properties": {
548
+ "error": {
549
+ "type": "string",
550
+ "example": "Clip id is required"
551
+ }
552
+ }
553
+ }
554
+ }
555
+ }
556
+ },
557
+ "402": {
558
+ "description": "Payment required",
559
+ "content": {
560
+ "application/json": {
561
+ "schema": {
562
+ "type": "object",
563
+ "properties": {
564
+ "error": {
565
+ "type": "string",
566
+ "example": "Payment required"
567
+ }
568
+ }
569
+ }
570
+ }
571
+ }
572
+ },
573
+ "500": {
574
+ "description": "Internal server error",
575
+ "content": {
576
+ "application/json": {
577
+ "schema": {
578
+ "type": "object",
579
+ "properties": {
580
+ "error": {
581
+ "type": "string",
582
+ "example": "Internal server error"
583
+ }
584
+ }
585
+ }
586
+ }
587
+ }
588
+ }
589
+ }
590
+ }
591
+ },
592
+ "/api/persona": {
593
+ "get": {
594
+ "summary": "Get persona information and clips.",
595
+ "description": "Retrieve persona information, including associated clips and pagination data.",
596
+ "tags": ["default"],
597
+ "parameters": [
598
+ {
599
+ "name": "id",
600
+ "in": "query",
601
+ "required": true,
602
+ "description": "Persona ID",
603
+ "schema": {
604
+ "type": "string"
605
+ }
606
+ },
607
+ {
608
+ "name": "page",
609
+ "in": "query",
610
+ "required": false,
611
+ "description": "Page number (defaults to 1)",
612
+ "schema": {
613
+ "type": "integer",
614
+ "default": 1
615
+ }
616
+ }
617
+ ],
618
+ "responses": {
619
+ "200": {
620
+ "description": "success",
621
+ "content": {
622
+ "application/json": {
623
+ "schema": {
624
+ "type": "object",
625
+ "properties": {
626
+ "persona": {
627
+ "type": "object",
628
+ "properties": {
629
+ "id": {
630
+ "type": "string",
631
+ "description": "Persona ID"
632
+ },
633
+ "name": {
634
+ "type": "string",
635
+ "description": "Persona name"
636
+ },
637
+ "description": {
638
+ "type": "string",
639
+ "description": "Persona description"
640
+ },
641
+ "image_s3_id": {
642
+ "type": "string",
643
+ "description": "Persona image URL"
644
+ },
645
+ "root_clip_id": {
646
+ "type": "string",
647
+ "description": "Root clip ID"
648
+ },
649
+ "clip": {
650
+ "type": "object",
651
+ "description": "Root clip information"
652
+ },
653
+ "persona_clips": {
654
+ "type": "array",
655
+ "items": {
656
+ "type": "object",
657
+ "properties": {
658
+ "clip": {
659
+ "type": "object",
660
+ "description": "Clip information"
661
+ }
662
+ }
663
+ }
664
+ },
665
+ "is_suno_persona": {
666
+ "type": "boolean",
667
+ "description": "Whether this is a Suno official persona"
668
+ },
669
+ "is_public": {
670
+ "type": "boolean",
671
+ "description": "Whether this persona is public"
672
+ },
673
+ "upvote_count": {
674
+ "type": "integer",
675
+ "description": "Number of upvotes"
676
+ },
677
+ "clip_count": {
678
+ "type": "integer",
679
+ "description": "Number of clips"
680
+ }
681
+ }
682
+ },
683
+ "total_results": {
684
+ "type": "integer",
685
+ "description": "Total number of results"
686
+ },
687
+ "current_page": {
688
+ "type": "integer",
689
+ "description": "Current page number"
690
+ },
691
+ "is_following": {
692
+ "type": "boolean",
693
+ "description": "Whether the current user is following this persona"
694
+ }
695
+ }
696
+ }
697
+ }
698
+ }
699
+ },
700
+ "400": {
701
+ "description": "Missing parameter id",
702
+ "content": {
703
+ "application/json": {
704
+ "schema": {
705
+ "type": "object",
706
+ "properties": {
707
+ "error": {
708
+ "type": "string",
709
+ "example": "Missing parameter id"
710
+ }
711
+ }
712
+ }
713
+ }
714
+ }
715
+ },
716
+ "500": {
717
+ "description": "Internal server error",
718
+ "content": {
719
+ "application/json": {
720
+ "schema": {
721
+ "type": "object",
722
+ "properties": {
723
+ "error": {
724
+ "type": "string",
725
+ "example": "Internal server error"
726
+ }
727
+ }
728
+ }
729
+ }
730
+ }
731
+ }
732
+ }
733
+ }
734
+ }
735
+ },
736
+ "components": {
737
+ "schemas": {
738
+ "audio_info": {
739
+ "type": "object",
740
+ "required": [
741
+ "id",
742
+ "title",
743
+ "image_url",
744
+ "lyric",
745
+ "audio_url",
746
+ "video_url",
747
+ "created_at",
748
+ "model_name",
749
+ "status",
750
+ "gpt_description_prompt",
751
+ "prompt",
752
+ "type",
753
+ "tags"
754
+ ],
755
+ "properties": {
756
+ "id": {
757
+ "type": "string",
758
+ "description": "audio id"
759
+ },
760
+ "title": {
761
+ "type": "string",
762
+ "description": "music title"
763
+ },
764
+ "image_url": {
765
+ "type": "string",
766
+ "description": "music cover image"
767
+ },
768
+ "lyric": {
769
+ "type": "string",
770
+ "description": "music lyric"
771
+ },
772
+ "audio_url": {
773
+ "type": "string",
774
+ "description": "music download url"
775
+ },
776
+ "video_url": {
777
+ "type": "string",
778
+ "description": "Music video download link, can be used to share"
779
+ },
780
+ "created_at": {
781
+ "type": "string",
782
+ "description": "Create time"
783
+ },
784
+ "model_name": {
785
+ "type": "string",
786
+ "description": "suno model name, chirp-v3"
787
+ },
788
+ "status": {
789
+ "type": "string",
790
+ "description": "The generated states include submitted, queue, streaming, complete."
791
+ },
792
+ "gpt_description_prompt": {
793
+ "type": "string",
794
+ "description": "Simple mode on user input prompt, Suno will generate formal prompts, lyrics, etc."
795
+ },
796
+ "prompt": {
797
+ "type": "string",
798
+ "description": "The final prompt for executing the generation task, customized by the user in custom mode, automatically generated by Suno in simple mode."
799
+ },
800
+ "type": {
801
+ "type": "string",
802
+ "description": "Type"
803
+ },
804
+ "tags": {
805
+ "type": "string",
806
+ "description": "Music genre. User-provided in custom mode, automatically generated by Suno in simple mode."
807
+ }
808
+ },
809
+ "title": "audio_info",
810
+ "description": "Audio Info"
811
+ }
812
+ }
813
+ }
814
+ }
src/app/favicon.ico ADDED
src/app/globals.css ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --foreground-rgb: 0, 0, 0;
7
+ --background-start-rgb: 255, 255, 255;
8
+ --background-end-rgb: 255, 255, 255;
9
+ }
10
+
11
+ body {
12
+ color: rgb(var(--foreground-rgb));
13
+ background: linear-gradient(
14
+ to bottom,
15
+ transparent,
16
+ rgb(var(--background-end-rgb))
17
+ )
18
+ rgb(var(--background-start-rgb));
19
+ }
20
+
21
+ @layer utilities {
22
+ .text-balance {
23
+ text-wrap: balance;
24
+ }
25
+ }
src/app/layout.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+ import Header from "./components/Header";
5
+ import Footer from "./components/Footer";
6
+ import { Analytics } from "@vercel/analytics/react"
7
+
8
+ const inter = Inter({ subsets: ["latin"] });
9
+
10
+ export const metadata: Metadata = {
11
+ title: "suno api",
12
+ description: "Use API to call the music generation ai of suno.ai",
13
+ keywords: ["suno", "suno api", "suno.ai", "api", "music", "generation", "ai"],
14
+ creator: "@gcui.ai",
15
+ };
16
+
17
+ export default function RootLayout({
18
+ children,
19
+ }: Readonly<{
20
+ children: React.ReactNode;
21
+ }>) {
22
+ return (
23
+ <html lang="en">
24
+ <body className={`${inter.className} overflow-y-scroll`} >
25
+ <Header />
26
+ <main className="flex flex-col items-center m-auto w-full">
27
+ {children}
28
+ </main>
29
+ <Footer />
30
+ <Analytics />
31
+ </body>
32
+ </html>
33
+ );
34
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Section from "./components/Section";
2
+ import Markdown from 'react-markdown';
3
+
4
+
5
+ export default function Home() {
6
+
7
+ const markdown = `
8
+
9
+ ---
10
+ ## 👋 Introduction
11
+
12
+ Suno.ai v3 is an amazing AI music service. Although the official API is not yet available, we couldn't wait to integrate its capabilities somewhere.
13
+
14
+ We discovered that some users have similar needs, so we decided to open-source this project, hoping you'll like it.
15
+
16
+ We update quickly, please star us on Github: [github.com/gcui-art/suno-api](https://github.com/gcui-art/suno-api) ⭐
17
+
18
+ ## 🌟 Features
19
+
20
+ - Perfectly implements the creation API from \`app.suno.ai\`
21
+ - Compatible with the format of OpenAI’s \`/v1/chat/completions\` API.
22
+ - Automatically keep the account active.
23
+ - Supports \`Custom Mode\`
24
+ - One-click deployment to Vercel
25
+ - In addition to the standard API, it also adapts to the API Schema of Agent platforms like GPTs and Coze, so you can use it as a tool/plugin/Action for LLMs and integrate it into any AI Agent.
26
+ - Permissive open-source license, allowing you to freely integrate and modify.
27
+
28
+ ## 🚀 Getting Started
29
+
30
+ ### 1. Obtain the cookie of your app.suno.ai account
31
+
32
+ 1. Head over to [app.suno.ai](https://app.suno.ai) using your browser.
33
+ 2. Open up the browser console: hit \`F12\` or access the \`Developer Tools\`.
34
+ 3. Navigate to the \`Network tab\`.
35
+ 4. Give the page a quick refresh.
36
+ 5. Identify the request that includes the keyword \`client?_clerk_js_version\`.
37
+ 6. Click on it and switch over to the \`Header\` tab.
38
+ 7. Locate the \`Cookie\` section, hover your mouse over it, and copy the value of the Cookie.
39
+ `;
40
+
41
+
42
+ const markdown_part2 = `
43
+ ### 2. Clone and deploy this project
44
+
45
+ You can choose your preferred deployment method:
46
+
47
+ #### Deploy to Vercel
48
+
49
+ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fgcui-art%2Fsuno-api&env=SUNO_COOKIE&project-name=suno-api&repository-name=suno-api)
50
+
51
+ #### Run locally
52
+
53
+ \`\`\`bash
54
+ git clone https://github.com/gcui-art/suno-api.git
55
+ cd suno-api
56
+ npm install
57
+ \`\`\`
58
+
59
+ ### 3. Configure suno-api
60
+
61
+ - If deployed to Vercel, please add an environment variable \`SUNO_COOKIE\` in the Vercel dashboard, with the value of the cookie obtained in the first step.
62
+
63
+ - If you’re running this locally, be sure to add the following to your \`.env\` file:
64
+
65
+ \`\`\`bash
66
+ SUNO_COOKIE=<your-cookie>
67
+ \`\`\`
68
+
69
+ ### 4. Run suno-api
70
+
71
+ - If you’ve deployed to Vercel:
72
+ - Please click on Deploy in the Vercel dashboard and wait for the deployment to be successful.
73
+ - Visit the \`https://<vercel-assigned-domain>/api/get_limit\` API for testing.
74
+ - If running locally:
75
+ - Run \`npm run dev\`.
76
+ - Visit the \`http://localhost:3000/api/get_limit\` API for testing.
77
+ - If the following result is returned:
78
+
79
+ \`\`\`json
80
+ {
81
+ "credits_left": 50,
82
+ "period": "day",
83
+ "monthly_limit": 50,
84
+ "monthly_usage": 50
85
+ }
86
+ \`\`\`
87
+
88
+ it means the program is running normally.
89
+
90
+ ### 5. Use Suno API
91
+
92
+ You can check out the detailed API documentation at [suno.gcui.ai/docs](https://suno.gcui.ai/docs).
93
+
94
+ ## 📚 API Reference
95
+
96
+ Suno API currently mainly implements the following APIs:
97
+
98
+ \`\`\`bash
99
+ - \`/api/generate\`: Generate music
100
+ - \`/v1/chat/completions\`: Generate music - Call the generate API in a format
101
+ that works with OpenAI’s API.
102
+ - \`/api/custom_generate\`: Generate music (Custom Mode, support setting lyrics,
103
+ music style, title, etc.)
104
+ - \`/api/generate_lyrics\`: Generate lyrics based on prompt
105
+ - \`/api/get\`: Get music list
106
+ - \`/api/get?ids=\`: Get music Info by id, separate multiple id with ",".
107
+ - \`/api/get_limit\`: Get quota Info
108
+ - \`/api/extend_audio\`: Extend audio length
109
+ - \`/api/generate_stems\`: Make stem tracks (separate audio and music track)
110
+ - \`/api/get_aligned_lyrics\`: Get list of timestamps for each word in the lyrics
111
+ - \`/api/concat\`: Generate the whole song from extensions
112
+ \`\`\`
113
+
114
+ For more detailed documentation, please check out the demo site:
115
+
116
+ 👉 [suno.gcui.ai/docs](https://suno.gcui.ai/docs)
117
+
118
+ `;
119
+ return (
120
+ <>
121
+ <Section className="">
122
+ <div className="flex flex-col m-auto py-20 text-center items-center justify-center gap-4 my-8
123
+ lg:px-20 px-4
124
+ bg-indigo-900/90 rounded-2xl border shadow-2xl hover:shadow-none duration-200">
125
+ <span className=" px-5 py-1 text-xs font-light border rounded-full
126
+ border-white/20 uppercase text-white/50">
127
+ Unofficial
128
+ </span>
129
+ <h1 className="font-bold text-7xl flex text-white/90">
130
+ Suno AI API
131
+ </h1>
132
+ <p className="text-white/80 text-lg">
133
+ `Suno-api` is an open-source project that enables you to set up your own Suno AI API.
134
+ </p>
135
+ </div>
136
+
137
+ </Section>
138
+ <Section className="my-10">
139
+ <article className="prose lg:prose-lg max-w-3xl">
140
+ <Markdown>
141
+ {markdown}
142
+ </Markdown>
143
+ <video controls width="1024" className="w-full border rounded-lg shadow-xl">
144
+ <source src="/get-cookie-demo.mp4" type="video/mp4" />
145
+ Your browser does not support frames.
146
+ </video>
147
+ <Markdown>
148
+ {markdown_part2}
149
+ </Markdown>
150
+ </article>
151
+ </Section>
152
+
153
+
154
+ </>
155
+ );
156
+ }
src/app/v1/chat/completions/route.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import { DEFAULT_MODEL, sunoApi } from "@/lib/SunoApi";
3
+ import { corsHeaders } from "@/lib/utils";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ /**
8
+ * desc
9
+ *
10
+ */
11
+ export async function POST(req: NextRequest) {
12
+ try {
13
+
14
+ const body = await req.json();
15
+
16
+ let userMessage = null;
17
+ const { messages } = body;
18
+ for (let message of messages) {
19
+ if (message.role == 'user') {
20
+ userMessage = message;
21
+ }
22
+ }
23
+
24
+ if (!userMessage) {
25
+ return new NextResponse(JSON.stringify({ error: 'Prompt message is required' }), {
26
+ status: 400,
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ ...corsHeaders
30
+ }
31
+ });
32
+ }
33
+
34
+
35
+ const audioInfo = await (await sunoApi()).generate(userMessage.content, true, DEFAULT_MODEL, true);
36
+
37
+ const audio = audioInfo[0]
38
+ const data = `## Song Title: ${audio.title}\n![Song Cover](${audio.image_url})\n### Lyrics:\n${audio.lyric}\n### Listen to the song: ${audio.audio_url}`
39
+
40
+ return new NextResponse(data, {
41
+ status: 200,
42
+ headers: corsHeaders
43
+ });
44
+ } catch (error: any) {
45
+ console.error('Error generating audio:', JSON.stringify(error.response.data));
46
+ return new NextResponse(JSON.stringify({ error: 'Internal server error: ' + JSON.stringify(error.response.data.detail) }), {
47
+ status: 500,
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ ...corsHeaders
51
+ }
52
+ });
53
+ }
54
+ }
55
+
56
+ export async function OPTIONS(request: Request) {
57
+ return new Response(null, {
58
+ status: 200,
59
+ headers: corsHeaders
60
+ });
61
+ }
src/lib/SunoApi.ts ADDED
@@ -0,0 +1,870 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios, { AxiosInstance } from 'axios';
2
+ import UserAgent from 'user-agents';
3
+ import pino from 'pino';
4
+ import yn from 'yn';
5
+ import { isPage, sleep, waitForRequests } from '@/lib/utils';
6
+ import * as cookie from 'cookie';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { Solver } from '@2captcha/captcha-solver';
9
+ import { paramsCoordinates } from '@2captcha/captcha-solver/dist/structs/2captcha';
10
+ import { BrowserContext, Page, Locator, chromium, firefox } from 'rebrowser-playwright-core';
11
+ import { createCursor, Cursor } from 'ghost-cursor-playwright';
12
+ import { promises as fs } from 'fs';
13
+ import path from 'node:path';
14
+
15
+ // sunoApi instance caching
16
+ const globalForSunoApi = global as unknown as { sunoApiCache?: Map<string, SunoApi> };
17
+ const cache = globalForSunoApi.sunoApiCache || new Map<string, SunoApi>();
18
+ globalForSunoApi.sunoApiCache = cache;
19
+
20
+ const logger = pino();
21
+ export const DEFAULT_MODEL = 'chirp-v3-5';
22
+
23
+ export interface AudioInfo {
24
+ id: string; // Unique identifier for the audio
25
+ title?: string; // Title of the audio
26
+ image_url?: string; // URL of the image associated with the audio
27
+ lyric?: string; // Lyrics of the audio
28
+ audio_url?: string; // URL of the audio file
29
+ video_url?: string; // URL of the video associated with the audio
30
+ created_at: string; // Date and time when the audio was created
31
+ model_name: string; // Name of the model used for audio generation
32
+ gpt_description_prompt?: string; // Prompt for GPT description
33
+ prompt?: string; // Prompt for audio generation
34
+ status: string; // Status
35
+ type?: string;
36
+ tags?: string; // Genre of music.
37
+ negative_tags?: string; // Negative tags of music.
38
+ duration?: string; // Duration of the audio
39
+ error_message?: string; // Error message if any
40
+ }
41
+
42
+ interface PersonaResponse {
43
+ persona: {
44
+ id: string;
45
+ name: string;
46
+ description: string;
47
+ image_s3_id: string;
48
+ root_clip_id: string;
49
+ clip: any; // You can define a more specific type if needed
50
+ user_display_name: string;
51
+ user_handle: string;
52
+ user_image_url: string;
53
+ persona_clips: Array<{
54
+ clip: any; // You can define a more specific type if needed
55
+ }>;
56
+ is_suno_persona: boolean;
57
+ is_trashed: boolean;
58
+ is_owned: boolean;
59
+ is_public: boolean;
60
+ is_public_approved: boolean;
61
+ is_loved: boolean;
62
+ upvote_count: number;
63
+ clip_count: number;
64
+ };
65
+ total_results: number;
66
+ current_page: number;
67
+ is_following: boolean;
68
+ }
69
+
70
+ class SunoApi {
71
+ private static BASE_URL: string = 'https://studio-api.prod.suno.com';
72
+ private static CLERK_BASE_URL: string = 'https://clerk.suno.com';
73
+ private static CLERK_VERSION = '5.15.0';
74
+
75
+ private readonly client: AxiosInstance;
76
+ private sid?: string;
77
+ private currentToken?: string;
78
+ private deviceId?: string;
79
+ private userAgent?: string;
80
+ private cookies: Record<string, string | undefined>;
81
+ private solver = new Solver(process.env.TWOCAPTCHA_KEY + '');
82
+ private ghostCursorEnabled = yn(process.env.BROWSER_GHOST_CURSOR, { default: false });
83
+ private cursor?: Cursor;
84
+
85
+ constructor(cookies: string) {
86
+ this.userAgent = new UserAgent(/Macintosh/).random().toString(); // Usually Mac systems get less amount of CAPTCHAs
87
+ this.cookies = cookie.parse(cookies);
88
+ this.deviceId = this.cookies.ajs_anonymous_id || randomUUID();
89
+ this.client = axios.create({
90
+ withCredentials: true,
91
+ headers: {
92
+ 'Affiliate-Id': 'undefined',
93
+ 'Device-Id': `"${this.deviceId}"`,
94
+ 'x-suno-client': 'Android prerelease-4nt180t 1.0.42',
95
+ 'X-Requested-With': 'com.suno.android',
96
+ 'sec-ch-ua': '"Chromium";v="130", "Android WebView";v="130", "Not?A_Brand";v="99"',
97
+ 'sec-ch-ua-mobile': '?1',
98
+ 'sec-ch-ua-platform': '"Android"',
99
+ 'User-Agent': this.userAgent
100
+ }
101
+ });
102
+ this.client.interceptors.request.use(config => {
103
+ if (this.currentToken && !config.headers.Authorization)
104
+ config.headers.Authorization = `Bearer ${this.currentToken}`;
105
+ const cookiesArray = Object.entries(this.cookies).map(([key, value]) =>
106
+ cookie.serialize(key, value as string)
107
+ );
108
+ config.headers.Cookie = cookiesArray.join('; ');
109
+ return config;
110
+ });
111
+ this.client.interceptors.response.use(resp => {
112
+ const setCookieHeader = resp.headers['set-cookie'];
113
+ if (Array.isArray(setCookieHeader)) {
114
+ const newCookies = cookie.parse(setCookieHeader.join('; '));
115
+ for (const [key, value] of Object.entries(newCookies)) {
116
+ this.cookies[key] = value;
117
+ }
118
+ }
119
+ return resp;
120
+ })
121
+ }
122
+
123
+ public async init(): Promise<SunoApi> {
124
+ //await this.getClerkLatestVersion();
125
+ await this.getAuthToken();
126
+ await this.keepAlive();
127
+ return this;
128
+ }
129
+
130
+ /**
131
+ * Get the clerk package latest version id.
132
+ * This method is commented because we are now using a hard-coded Clerk version, hence this method is not needed.
133
+
134
+ private async getClerkLatestVersion() {
135
+ // URL to get clerk version ID
136
+ const getClerkVersionUrl = `${SunoApi.JSDELIVR_BASE_URL}/v1/package/npm/@clerk/clerk-js`;
137
+ // Get clerk version ID
138
+ const versionListResponse = await this.client.get(getClerkVersionUrl);
139
+ if (!versionListResponse?.data?.['tags']['latest']) {
140
+ throw new Error(
141
+ 'Failed to get clerk version info, Please try again later'
142
+ );
143
+ }
144
+ // Save clerk version ID for auth
145
+ SunoApi.clerkVersion = versionListResponse?.data?.['tags']['latest'];
146
+ }
147
+ */
148
+
149
+ /**
150
+ * Get the session ID and save it for later use.
151
+ */
152
+ private async getAuthToken() {
153
+ logger.info('Getting the session ID');
154
+ // URL to get session ID
155
+ const getSessionUrl = `${SunoApi.CLERK_BASE_URL}/v1/client?_is_native=true&_clerk_js_version=${SunoApi.CLERK_VERSION}`;
156
+ // Get session ID
157
+ const sessionResponse = await this.client.get(getSessionUrl, {
158
+ headers: { Authorization: this.cookies.__client }
159
+ });
160
+ if (!sessionResponse?.data?.response?.last_active_session_id) {
161
+ throw new Error(
162
+ 'Failed to get session id, you may need to update the SUNO_COOKIE'
163
+ );
164
+ }
165
+ // Save session ID for later use
166
+ this.sid = sessionResponse.data.response.last_active_session_id;
167
+ }
168
+
169
+ /**
170
+ * Keep the session alive.
171
+ * @param isWait Indicates if the method should wait for the session to be fully renewed before returning.
172
+ */
173
+ public async keepAlive(isWait?: boolean): Promise<void> {
174
+ if (!this.sid) {
175
+ throw new Error('Session ID is not set. Cannot renew token.');
176
+ }
177
+ // URL to renew session token
178
+ const renewUrl = `${SunoApi.CLERK_BASE_URL}/v1/client/sessions/${this.sid}/tokens?_is_native=true&_clerk_js_version=${SunoApi.CLERK_VERSION}`;
179
+ // Renew session token
180
+ logger.info('KeepAlive...\n');
181
+ const renewResponse = await this.client.post(renewUrl, {}, {
182
+ headers: { Authorization: this.cookies.__client }
183
+ });
184
+ if (isWait) {
185
+ await sleep(1, 2);
186
+ }
187
+ const newToken = renewResponse.data.jwt;
188
+ // Update Authorization field in request header with the new JWT token
189
+ this.currentToken = newToken;
190
+ }
191
+
192
+ /**
193
+ * Get the session token (not to be confused with session ID) and save it for later use.
194
+ */
195
+ private async getSessionToken() {
196
+ const tokenResponse = await this.client.post(
197
+ `${SunoApi.BASE_URL}/api/user/create_session_id/`,
198
+ {
199
+ session_properties: JSON.stringify({ deviceId: this.deviceId }),
200
+ session_type: 1
201
+ }
202
+ );
203
+ return tokenResponse.data.session_id;
204
+ }
205
+
206
+ private async captchaRequired(): Promise<boolean> {
207
+ const resp = await this.client.post(`${SunoApi.BASE_URL}/api/c/check`, {
208
+ ctype: 'generation'
209
+ });
210
+ logger.info(resp.data);
211
+ return resp.data.required;
212
+ }
213
+
214
+ /**
215
+ * Clicks on a locator or XY vector. This method is made because of the difference between ghost-cursor-playwright and Playwright methods
216
+ */
217
+ private async click(target: Locator|Page, position?: { x: number, y: number }): Promise<void> {
218
+ if (this.ghostCursorEnabled) {
219
+ let pos: any = isPage(target) ? { x: 0, y: 0 } : await target.boundingBox();
220
+ if (position)
221
+ pos = {
222
+ ...pos,
223
+ x: pos.x + position.x,
224
+ y: pos.y + position.y,
225
+ width: null,
226
+ height: null,
227
+ };
228
+ return this.cursor?.actions.click({
229
+ target: pos
230
+ });
231
+ } else {
232
+ if (isPage(target))
233
+ return target.mouse.click(position?.x ?? 0, position?.y ?? 0);
234
+ else
235
+ return target.click({ force: true, position });
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Get the BrowserType from the `BROWSER` environment variable.
241
+ * @returns {BrowserType} chromium, firefox or webkit. Default is chromium
242
+ */
243
+ private getBrowserType() {
244
+ const browser = process.env.BROWSER?.toLowerCase();
245
+ switch (browser) {
246
+ case 'firefox':
247
+ return firefox;
248
+ /*case 'webkit': ** doesn't work with rebrowser-patches
249
+ case 'safari':
250
+ return webkit;*/
251
+ default:
252
+ return chromium;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Launches a browser with the necessary cookies
258
+ * @returns {BrowserContext}
259
+ */
260
+ private async launchBrowser(): Promise<BrowserContext> {
261
+ const args = [
262
+ '--disable-blink-features=AutomationControlled',
263
+ '--disable-web-security',
264
+ '--no-sandbox',
265
+ '--disable-dev-shm-usage',
266
+ '--disable-features=site-per-process',
267
+ '--disable-features=IsolateOrigins',
268
+ '--disable-extensions',
269
+ '--disable-infobars'
270
+ ];
271
+ // Check for GPU acceleration, as it is recommended to turn it off for Docker
272
+ if (yn(process.env.BROWSER_DISABLE_GPU, { default: false }))
273
+ args.push('--enable-unsafe-swiftshader',
274
+ '--disable-gpu',
275
+ '--disable-setuid-sandbox');
276
+ const browser = await this.getBrowserType().launch({
277
+ args,
278
+ headless: yn(process.env.BROWSER_HEADLESS, { default: true })
279
+ });
280
+ const context = await browser.newContext({ userAgent: this.userAgent, locale: process.env.BROWSER_LOCALE, viewport: null });
281
+ const cookies = [];
282
+ const lax: 'Lax' | 'Strict' | 'None' = 'Lax';
283
+ cookies.push({
284
+ name: '__session',
285
+ value: this.currentToken+'',
286
+ domain: '.suno.com',
287
+ path: '/',
288
+ sameSite: lax
289
+ });
290
+ for (const key in this.cookies) {
291
+ cookies.push({
292
+ name: key,
293
+ value: this.cookies[key]+'',
294
+ domain: '.suno.com',
295
+ path: '/',
296
+ sameSite: lax
297
+ })
298
+ }
299
+ await context.addCookies(cookies);
300
+ return context;
301
+ }
302
+
303
+ /**
304
+ * Checks for CAPTCHA verification and solves the CAPTCHA if needed
305
+ * @returns {string|null} hCaptcha token. If no verification is required, returns null
306
+ */
307
+ public async getCaptcha(): Promise<string|null> {
308
+ if (!await this.captchaRequired())
309
+ return null;
310
+
311
+ logger.info('CAPTCHA required. Launching browser...')
312
+ const browser = await this.launchBrowser();
313
+ const page = await browser.newPage();
314
+ await page.goto('https://suno.com/create', { referer: 'https://www.google.com/', waitUntil: 'domcontentloaded', timeout: 0 });
315
+
316
+ logger.info('Waiting for Suno interface to load');
317
+ // await page.locator('.react-aria-GridList').waitFor({ timeout: 60000 });
318
+ await page.waitForResponse('**/api/project/**\\?**', { timeout: 60000 }); // wait for song list API call
319
+
320
+ if (this.ghostCursorEnabled)
321
+ this.cursor = await createCursor(page);
322
+
323
+ logger.info('Triggering the CAPTCHA');
324
+ try {
325
+ await page.getByLabel('Close').click({ timeout: 2000 }); // close all popups
326
+ // await this.click(page, { x: 318, y: 13 });
327
+ } catch(e) {}
328
+
329
+ const textarea = page.locator('.custom-textarea');
330
+ await this.click(textarea);
331
+ await textarea.pressSequentially('Lorem ipsum', { delay: 80 });
332
+
333
+ const button = page.locator('button[aria-label="Create"]').locator('div.flex');
334
+ this.click(button);
335
+
336
+ const controller = new AbortController();
337
+ new Promise<void>(async (resolve, reject) => {
338
+ const frame = page.frameLocator('iframe[title*="hCaptcha"]');
339
+ const challenge = frame.locator('.challenge-container');
340
+ try {
341
+ let wait = true;
342
+ while (true) {
343
+ if (wait)
344
+ await waitForRequests(page, controller.signal);
345
+ const drag = (await challenge.locator('.prompt-text').first().innerText()).toLowerCase().includes('drag');
346
+ let captcha: any;
347
+ for (let j = 0; j < 3; j++) { // try several times because sometimes 2Captcha could return an error
348
+ try {
349
+ logger.info('Sending the CAPTCHA to 2Captcha');
350
+ const payload: paramsCoordinates = {
351
+ body: (await challenge.screenshot({ timeout: 5000 })).toString('base64'),
352
+ lang: process.env.BROWSER_LOCALE
353
+ };
354
+ if (drag) {
355
+ // Say to the worker that he needs to click
356
+ payload.textinstructions = 'CLICK on the shapes at their edge or center as shown above—please be precise!';
357
+ payload.imginstructions = (await fs.readFile(path.join(process.cwd(), 'public', 'drag-instructions.jpg'))).toString('base64');
358
+ }
359
+ captcha = await this.solver.coordinates(payload);
360
+ break;
361
+ } catch(err: any) {
362
+ logger.info(err.message);
363
+ if (j != 2)
364
+ logger.info('Retrying...');
365
+ else
366
+ throw err;
367
+ }
368
+ }
369
+ if (drag) {
370
+ const challengeBox = await challenge.boundingBox();
371
+ if (challengeBox == null)
372
+ throw new Error('.challenge-container boundingBox is null!');
373
+ if (captcha.data.length % 2) {
374
+ logger.info('Solution does not have even amount of points required for dragging. Requesting new solution...');
375
+ this.solver.badReport(captcha.id);
376
+ wait = false;
377
+ continue;
378
+ }
379
+ for (let i = 0; i < captcha.data.length; i += 2) {
380
+ const data1 = captcha.data[i];
381
+ const data2 = captcha.data[i+1];
382
+ logger.info(JSON.stringify(data1) + JSON.stringify(data2));
383
+ await page.mouse.move(challengeBox.x + +data1.x, challengeBox.y + +data1.y);
384
+ await page.mouse.down();
385
+ await sleep(1.1); // wait for the piece to be 'unlocked'
386
+ await page.mouse.move(challengeBox.x + +data2.x, challengeBox.y + +data2.y, { steps: 30 });
387
+ await page.mouse.up();
388
+ }
389
+ wait = true;
390
+ } else {
391
+ for (const data of captcha.data) {
392
+ logger.info(data);
393
+ await this.click(challenge, { x: +data.x, y: +data.y });
394
+ };
395
+ }
396
+ this.click(frame.locator('.button-submit')).catch(e => {
397
+ if (e.message.includes('viewport')) // when hCaptcha window has been closed due to inactivity,
398
+ this.click(button); // click the Create button again to trigger the CAPTCHA
399
+ else
400
+ throw e;
401
+ });
402
+ }
403
+ } catch(e: any) {
404
+ if (e.message.includes('been closed') // catch error when closing the browser
405
+ || e.message == 'AbortError') // catch error when waitForRequests is aborted
406
+ resolve();
407
+ else
408
+ reject(e);
409
+ }
410
+ }).catch(e => {
411
+ browser.browser()?.close();
412
+ throw e;
413
+ });
414
+ return (new Promise((resolve, reject) => {
415
+ page.route('**/api/generate/v2/**', async (route: any) => {
416
+ try {
417
+ logger.info('hCaptcha token received. Closing browser');
418
+ route.abort();
419
+ browser.browser()?.close();
420
+ controller.abort();
421
+ const request = route.request();
422
+ this.currentToken = request.headers().authorization.split('Bearer ').pop();
423
+ resolve(request.postDataJSON().token);
424
+ } catch(err) {
425
+ reject(err);
426
+ }
427
+ });
428
+ }));
429
+ }
430
+
431
+ /**
432
+ * Imitates Cloudflare Turnstile loading error. Unused right now, left for future
433
+ */
434
+ private async getTurnstile() {
435
+ return this.client.post(
436
+ `https://clerk.suno.com/v1/client?__clerk_api_version=2021-02-05&_clerk_js_version=${SunoApi.CLERK_VERSION}&_method=PATCH`,
437
+ { captcha_error: '300030,300030,300030' },
438
+ { headers: { 'content-type': 'application/x-www-form-urlencoded' } });
439
+ }
440
+
441
+ /**
442
+ * Generate a song based on the prompt.
443
+ * @param prompt The text prompt to generate audio from.
444
+ * @param make_instrumental Indicates if the generated audio should be instrumental.
445
+ * @param wait_audio Indicates if the method should wait for the audio file to be fully generated before returning.
446
+ * @returns
447
+ */
448
+ public async generate(
449
+ prompt: string,
450
+ make_instrumental: boolean = false,
451
+ model?: string,
452
+ wait_audio: boolean = false
453
+ ): Promise<AudioInfo[]> {
454
+ await this.keepAlive(false);
455
+ const startTime = Date.now();
456
+ const audios = await this.generateSongs(
457
+ prompt,
458
+ false,
459
+ undefined,
460
+ undefined,
461
+ make_instrumental,
462
+ model,
463
+ wait_audio
464
+ );
465
+ const costTime = Date.now() - startTime;
466
+ logger.info('Generate Response:\n' + JSON.stringify(audios, null, 2));
467
+ logger.info('Cost time: ' + costTime);
468
+ return audios;
469
+ }
470
+
471
+ /**
472
+ * Calls the concatenate endpoint for a clip to generate the whole song.
473
+ * @param clip_id The ID of the audio clip to concatenate.
474
+ * @returns A promise that resolves to an AudioInfo object representing the concatenated audio.
475
+ * @throws Error if the response status is not 200.
476
+ */
477
+ public async concatenate(clip_id: string): Promise<AudioInfo> {
478
+ await this.keepAlive(false);
479
+ const payload: any = { clip_id: clip_id };
480
+
481
+ const response = await this.client.post(
482
+ `${SunoApi.BASE_URL}/api/generate/concat/v2/`,
483
+ payload,
484
+ {
485
+ timeout: 10000 // 10 seconds timeout
486
+ }
487
+ );
488
+ if (response.status !== 200) {
489
+ throw new Error('Error response:' + response.statusText);
490
+ }
491
+ return response.data;
492
+ }
493
+
494
+ /**
495
+ * Generates custom audio based on provided parameters.
496
+ *
497
+ * @param prompt The text prompt to generate audio from.
498
+ * @param tags Tags to categorize the generated audio.
499
+ * @param title The title for the generated audio.
500
+ * @param make_instrumental Indicates if the generated audio should be instrumental.
501
+ * @param wait_audio Indicates if the method should wait for the audio file to be fully generated before returning.
502
+ * @param negative_tags Negative tags that should not be included in the generated audio.
503
+ * @returns A promise that resolves to an array of AudioInfo objects representing the generated audios.
504
+ */
505
+ public async custom_generate(
506
+ prompt: string,
507
+ tags: string,
508
+ title: string,
509
+ make_instrumental: boolean = false,
510
+ model?: string,
511
+ wait_audio: boolean = false,
512
+ negative_tags?: string
513
+ ): Promise<AudioInfo[]> {
514
+ const startTime = Date.now();
515
+ const audios = await this.generateSongs(
516
+ prompt,
517
+ true,
518
+ tags,
519
+ title,
520
+ make_instrumental,
521
+ model,
522
+ wait_audio,
523
+ negative_tags
524
+ );
525
+ const costTime = Date.now() - startTime;
526
+ logger.info(
527
+ 'Custom Generate Response:\n' + JSON.stringify(audios, null, 2)
528
+ );
529
+ logger.info('Cost time: ' + costTime);
530
+ return audios;
531
+ }
532
+
533
+ /**
534
+ * Generates songs based on the provided parameters.
535
+ *
536
+ * @param prompt The text prompt to generate songs from.
537
+ * @param isCustom Indicates if the generation should consider custom parameters like tags and title.
538
+ * @param tags Optional tags to categorize the song, used only if isCustom is true.
539
+ * @param title Optional title for the song, used only if isCustom is true.
540
+ * @param make_instrumental Indicates if the generated song should be instrumental.
541
+ * @param wait_audio Indicates if the method should wait for the audio file to be fully generated before returning.
542
+ * @param negative_tags Negative tags that should not be included in the generated audio.
543
+ * @param task Optional indication of what to do. Enter 'extend' if extending an audio, otherwise specify null.
544
+ * @param continue_clip_id
545
+ * @returns A promise that resolves to an array of AudioInfo objects representing the generated songs.
546
+ */
547
+ private async generateSongs(
548
+ prompt: string,
549
+ isCustom: boolean,
550
+ tags?: string,
551
+ title?: string,
552
+ make_instrumental?: boolean,
553
+ model?: string,
554
+ wait_audio: boolean = false,
555
+ negative_tags?: string,
556
+ task?: string,
557
+ continue_clip_id?: string,
558
+ continue_at?: number
559
+ ): Promise<AudioInfo[]> {
560
+ await this.keepAlive();
561
+ const payload: any = {
562
+ make_instrumental: make_instrumental,
563
+ mv: model || DEFAULT_MODEL,
564
+ prompt: '',
565
+ generation_type: 'TEXT',
566
+ continue_at: continue_at,
567
+ continue_clip_id: continue_clip_id,
568
+ task: task,
569
+ token: await this.getCaptcha()
570
+ };
571
+ if (isCustom) {
572
+ payload.tags = tags;
573
+ payload.title = title;
574
+ payload.negative_tags = negative_tags;
575
+ payload.prompt = prompt;
576
+ } else {
577
+ payload.gpt_description_prompt = prompt;
578
+ }
579
+ logger.info(
580
+ 'generateSongs payload:\n' +
581
+ JSON.stringify(
582
+ {
583
+ prompt: prompt,
584
+ isCustom: isCustom,
585
+ tags: tags,
586
+ title: title,
587
+ make_instrumental: make_instrumental,
588
+ wait_audio: wait_audio,
589
+ negative_tags: negative_tags,
590
+ payload: payload
591
+ },
592
+ null,
593
+ 2
594
+ )
595
+ );
596
+ const response = await this.client.post(
597
+ `${SunoApi.BASE_URL}/api/generate/v2/`,
598
+ payload,
599
+ {
600
+ timeout: 10000 // 10 seconds timeout
601
+ }
602
+ );
603
+ if (response.status !== 200) {
604
+ throw new Error('Error response:' + response.statusText);
605
+ }
606
+ const songIds = response.data.clips.map((audio: any) => audio.id);
607
+ //Want to wait for music file generation
608
+ if (wait_audio) {
609
+ const startTime = Date.now();
610
+ let lastResponse: AudioInfo[] = [];
611
+ await sleep(5, 5);
612
+ while (Date.now() - startTime < 100000) {
613
+ const response = await this.get(songIds);
614
+ const allCompleted = response.every(
615
+ (audio) => audio.status === 'streaming' || audio.status === 'complete'
616
+ );
617
+ const allError = response.every((audio) => audio.status === 'error');
618
+ if (allCompleted || allError) {
619
+ return response;
620
+ }
621
+ lastResponse = response;
622
+ await sleep(3, 6);
623
+ await this.keepAlive(true);
624
+ }
625
+ return lastResponse;
626
+ } else {
627
+ return response.data.clips.map((audio: any) => ({
628
+ id: audio.id,
629
+ title: audio.title,
630
+ image_url: audio.image_url,
631
+ lyric: audio.metadata.prompt,
632
+ audio_url: audio.audio_url,
633
+ video_url: audio.video_url,
634
+ created_at: audio.created_at,
635
+ model_name: audio.model_name,
636
+ status: audio.status,
637
+ gpt_description_prompt: audio.metadata.gpt_description_prompt,
638
+ prompt: audio.metadata.prompt,
639
+ type: audio.metadata.type,
640
+ tags: audio.metadata.tags,
641
+ negative_tags: audio.metadata.negative_tags,
642
+ duration: audio.metadata.duration
643
+ }));
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Generates lyrics based on a given prompt.
649
+ * @param prompt The prompt for generating lyrics.
650
+ * @returns The generated lyrics text.
651
+ */
652
+ public async generateLyrics(prompt: string): Promise<string> {
653
+ await this.keepAlive(false);
654
+ // Initiate lyrics generation
655
+ const generateResponse = await this.client.post(
656
+ `${SunoApi.BASE_URL}/api/generate/lyrics/`,
657
+ { prompt }
658
+ );
659
+ const generateId = generateResponse.data.id;
660
+
661
+ // Poll for lyrics completion
662
+ let lyricsResponse = await this.client.get(
663
+ `${SunoApi.BASE_URL}/api/generate/lyrics/${generateId}`
664
+ );
665
+ while (lyricsResponse?.data?.status !== 'complete') {
666
+ await sleep(2); // Wait for 2 seconds before polling again
667
+ lyricsResponse = await this.client.get(
668
+ `${SunoApi.BASE_URL}/api/generate/lyrics/${generateId}`
669
+ );
670
+ }
671
+
672
+ // Return the generated lyrics text
673
+ return lyricsResponse.data;
674
+ }
675
+
676
+ /**
677
+ * Extends an existing audio clip by generating additional content based on the provided prompt.
678
+ *
679
+ * @param audioId The ID of the audio clip to extend.
680
+ * @param prompt The prompt for generating additional content.
681
+ * @param continueAt Extend a new clip from a song at mm:ss(e.g. 00:30). Default extends from the end of the song.
682
+ * @param tags Style of Music.
683
+ * @param title Title of the song.
684
+ * @returns A promise that resolves to an AudioInfo object representing the extended audio clip.
685
+ */
686
+ public async extendAudio(
687
+ audioId: string,
688
+ prompt: string = '',
689
+ continueAt: number,
690
+ tags: string = '',
691
+ negative_tags: string = '',
692
+ title: string = '',
693
+ model?: string,
694
+ wait_audio?: boolean
695
+ ): Promise<AudioInfo[]> {
696
+ return this.generateSongs(prompt, true, tags, title, false, model, wait_audio, negative_tags, 'extend', audioId, continueAt);
697
+ }
698
+
699
+ /**
700
+ * Generate stems for a song.
701
+ * @param song_id The ID of the song to generate stems for.
702
+ * @returns A promise that resolves to an AudioInfo object representing the generated stems.
703
+ */
704
+ public async generateStems(song_id: string): Promise<AudioInfo[]> {
705
+ await this.keepAlive(false);
706
+ const response = await this.client.post(
707
+ `${SunoApi.BASE_URL}/api/edit/stems/${song_id}`, {}
708
+ );
709
+
710
+ console.log('generateStems response:\n', response?.data);
711
+ return response.data.clips.map((clip: any) => ({
712
+ id: clip.id,
713
+ status: clip.status,
714
+ created_at: clip.created_at,
715
+ title: clip.title,
716
+ stem_from_id: clip.metadata.stem_from_id,
717
+ duration: clip.metadata.duration
718
+ }));
719
+ }
720
+
721
+
722
+ /**
723
+ * Get the lyric alignment for a song.
724
+ * @param song_id The ID of the song to get the lyric alignment for.
725
+ * @returns A promise that resolves to an object containing the lyric alignment.
726
+ */
727
+ public async getLyricAlignment(song_id: string): Promise<object> {
728
+ await this.keepAlive(false);
729
+ const response = await this.client.get(`${SunoApi.BASE_URL}/api/gen/${song_id}/aligned_lyrics/v2/`);
730
+
731
+ console.log(`getLyricAlignment ~ response:`, response.data);
732
+ return response.data?.aligned_words.map((transcribedWord: any) => ({
733
+ word: transcribedWord.word,
734
+ start_s: transcribedWord.start_s,
735
+ end_s: transcribedWord.end_s,
736
+ success: transcribedWord.success,
737
+ p_align: transcribedWord.p_align
738
+ }));
739
+ }
740
+
741
+ /**
742
+ * Processes the lyrics (prompt) from the audio metadata into a more readable format.
743
+ * @param prompt The original lyrics text.
744
+ * @returns The processed lyrics text.
745
+ */
746
+ private parseLyrics(prompt: string): string {
747
+ // Assuming the original lyrics are separated by a specific delimiter (e.g., newline), we can convert it into a more readable format.
748
+ // The implementation here can be adjusted according to the actual lyrics format.
749
+ // 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.).
750
+ // The following implementation assumes that the lyrics are already separated by newlines.
751
+
752
+ // Split the lyrics using newline and ensure to remove empty lines.
753
+ const lines = prompt.split('\n').filter((line) => line.trim() !== '');
754
+
755
+ // Reassemble the processed lyrics lines into a single string, separated by newlines between each line.
756
+ // Additional formatting logic can be added here, such as adding specific markers or handling special lines.
757
+ return lines.join('\n');
758
+ }
759
+
760
+ /**
761
+ * Retrieves audio information for the given song IDs.
762
+ * @param songIds An optional array of song IDs to retrieve information for.
763
+ * @param page An optional page number to retrieve audio information from.
764
+ * @returns A promise that resolves to an array of AudioInfo objects.
765
+ */
766
+ public async get(
767
+ songIds?: string[],
768
+ page?: string | null
769
+ ): Promise<AudioInfo[]> {
770
+ await this.keepAlive(false);
771
+ let url = new URL(`${SunoApi.BASE_URL}/api/feed/v2`);
772
+ if (songIds) {
773
+ url.searchParams.append('ids', songIds.join(','));
774
+ }
775
+ if (page) {
776
+ url.searchParams.append('page', page);
777
+ }
778
+ logger.info('Get audio status: ' + url.href);
779
+ const response = await this.client.get(url.href, {
780
+ // 10 seconds timeout
781
+ timeout: 10000
782
+ });
783
+
784
+ const audios = response.data.clips;
785
+
786
+ return audios.map((audio: any) => ({
787
+ id: audio.id,
788
+ title: audio.title,
789
+ image_url: audio.image_url,
790
+ lyric: audio.metadata.prompt
791
+ ? this.parseLyrics(audio.metadata.prompt)
792
+ : '',
793
+ audio_url: audio.audio_url,
794
+ video_url: audio.video_url,
795
+ created_at: audio.created_at,
796
+ model_name: audio.model_name,
797
+ status: audio.status,
798
+ gpt_description_prompt: audio.metadata.gpt_description_prompt,
799
+ prompt: audio.metadata.prompt,
800
+ type: audio.metadata.type,
801
+ tags: audio.metadata.tags,
802
+ duration: audio.metadata.duration,
803
+ error_message: audio.metadata.error_message
804
+ }));
805
+ }
806
+
807
+ /**
808
+ * Retrieves information for a specific audio clip.
809
+ * @param clipId The ID of the audio clip to retrieve information for.
810
+ * @returns A promise that resolves to an object containing the audio clip information.
811
+ */
812
+ public async getClip(clipId: string): Promise<object> {
813
+ await this.keepAlive(false);
814
+ const response = await this.client.get(
815
+ `${SunoApi.BASE_URL}/api/clip/${clipId}`
816
+ );
817
+ return response.data;
818
+ }
819
+
820
+ public async get_credits(): Promise<object> {
821
+ await this.keepAlive(false);
822
+ const response = await this.client.get(
823
+ `${SunoApi.BASE_URL}/api/billing/info/`
824
+ );
825
+ return {
826
+ credits_left: response.data.total_credits_left,
827
+ period: response.data.period,
828
+ monthly_limit: response.data.monthly_limit,
829
+ monthly_usage: response.data.monthly_usage
830
+ };
831
+ }
832
+
833
+ public async getPersonaPaginated(personaId: string, page: number = 1): Promise<PersonaResponse> {
834
+ await this.keepAlive(false);
835
+
836
+ const url = `${SunoApi.BASE_URL}/api/persona/get-persona-paginated/${personaId}/?page=${page}`;
837
+
838
+ logger.info(`Fetching persona data: ${url}`);
839
+
840
+ const response = await this.client.get(url, {
841
+ timeout: 10000 // 10 seconds timeout
842
+ });
843
+
844
+ if (response.status !== 200) {
845
+ throw new Error('Error response: ' + response.statusText);
846
+ }
847
+
848
+ return response.data;
849
+ }
850
+ }
851
+
852
+ export const sunoApi = async (cookie?: string) => {
853
+ const resolvedCookie = cookie && cookie.includes('__client') ? cookie : process.env.SUNO_COOKIE; // Check for bad `Cookie` header (It's too expensive to actually parse the cookies *here*)
854
+ if (!resolvedCookie) {
855
+ logger.info('No cookie provided! Aborting...\nPlease provide a cookie either in the .env file or in the Cookie header of your request.')
856
+ throw new Error('Please provide a cookie either in the .env file or in the Cookie header of your request.');
857
+ }
858
+
859
+ // Check if the instance for this cookie already exists in the cache
860
+ const cachedInstance = cache.get(resolvedCookie);
861
+ if (cachedInstance)
862
+ return cachedInstance;
863
+
864
+ // If not, create a new instance and initialize it
865
+ const instance = await new SunoApi(resolvedCookie).init();
866
+ // Cache the initialized instance
867
+ cache.set(resolvedCookie, instance);
868
+
869
+ return instance;
870
+ };
src/lib/utils.ts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pino from "pino";
2
+ import { Page } from "rebrowser-playwright-core";
3
+
4
+ const logger = pino();
5
+
6
+ /**
7
+ * Pause for a specified number of seconds.
8
+ * @param x Minimum number of seconds.
9
+ * @param y Maximum number of seconds (optional).
10
+ */
11
+ export const sleep = (x: number, y?: number): Promise<void> => {
12
+ let timeout = x * 1000;
13
+ if (y !== undefined && y !== x) {
14
+ const min = Math.min(x, y);
15
+ const max = Math.max(x, y);
16
+ timeout = Math.floor(Math.random() * (max - min + 1) + min) * 1000;
17
+ }
18
+ // console.log(`Sleeping for ${timeout / 1000} seconds`);
19
+ logger.info(`Sleeping for ${timeout / 1000} seconds`);
20
+
21
+ return new Promise(resolve => setTimeout(resolve, timeout));
22
+ }
23
+
24
+ /**
25
+ * @param target A Locator or a page
26
+ * @returns {boolean}
27
+ */
28
+ export const isPage = (target: any): target is Page => {
29
+ return target.constructor.name === 'Page';
30
+ }
31
+
32
+ /**
33
+ * Waits for an hCaptcha image requests and then waits for all of them to end
34
+ * @param page
35
+ * @param signal `const controller = new AbortController(); controller.status`
36
+ * @returns {Promise<void>}
37
+ */
38
+ export const waitForRequests = (page: Page, signal: AbortSignal): Promise<void> => {
39
+ return new Promise((resolve, reject) => {
40
+ const urlPattern = /^https:\/\/img[a-zA-Z0-9]*\.hcaptcha\.com\/.*$/;
41
+ let timeoutHandle: NodeJS.Timeout | null = null;
42
+ let activeRequestCount = 0;
43
+ let requestOccurred = false;
44
+
45
+ const cleanupListeners = () => {
46
+ page.off('request', onRequest);
47
+ page.off('requestfinished', onRequestFinished);
48
+ page.off('requestfailed', onRequestFinished);
49
+ };
50
+
51
+ const resetTimeout = () => {
52
+ if (timeoutHandle)
53
+ clearTimeout(timeoutHandle);
54
+ if (activeRequestCount === 0) {
55
+ timeoutHandle = setTimeout(() => {
56
+ cleanupListeners();
57
+ resolve();
58
+ }, 1000); // 1 second of no requests
59
+ }
60
+ };
61
+
62
+ const onRequest = (request: { url: () => string }) => {
63
+ if (urlPattern.test(request.url())) {
64
+ requestOccurred = true;
65
+ activeRequestCount++;
66
+ if (timeoutHandle)
67
+ clearTimeout(timeoutHandle);
68
+ }
69
+ };
70
+
71
+ const onRequestFinished = (request: { url: () => string }) => {
72
+ if (urlPattern.test(request.url())) {
73
+ activeRequestCount--;
74
+ resetTimeout();
75
+ }
76
+ };
77
+
78
+ // Wait for an hCaptcha request for up to 1 minute
79
+ const initialTimeout = setTimeout(() => {
80
+ if (!requestOccurred) {
81
+ page.off('request', onRequest);
82
+ cleanupListeners();
83
+ reject(new Error('No hCaptcha request occurred within 1 minute.'));
84
+ } else {
85
+ // Start waiting for no hCaptcha requests
86
+ resetTimeout();
87
+ }
88
+ }, 60000); // 1 minute timeout
89
+
90
+ page.on('request', onRequest);
91
+ page.on('requestfinished', onRequestFinished);
92
+ page.on('requestfailed', onRequestFinished);
93
+
94
+ // Cleanup the initial timeout if an hCaptcha request occurs
95
+ page.on('request', (request: { url: () => string }) => {
96
+ if (urlPattern.test(request.url())) {
97
+ clearTimeout(initialTimeout);
98
+ }
99
+ });
100
+
101
+ const onAbort = () => {
102
+ cleanupListeners();
103
+ clearTimeout(initialTimeout);
104
+ if (timeoutHandle)
105
+ clearTimeout(timeoutHandle);
106
+ signal.removeEventListener('abort', onAbort);
107
+ reject(new Error('AbortError'));
108
+ };
109
+
110
+ signal.addEventListener('abort', onAbort, { once: true });
111
+ });
112
+ }
113
+
114
+ export const corsHeaders = {
115
+ 'Access-Control-Allow-Origin': '*',
116
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
117
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
118
+ }
tailwind.config.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ content: [
5
+ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6
+ "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7
+ "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8
+ ],
9
+ theme: {
10
+ extend: {
11
+ backgroundImage: {
12
+ "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
13
+ "gradient-conic":
14
+ "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
15
+ },
16
+ },
17
+ },
18
+ plugins: [
19
+ require('@tailwindcss/typography'),
20
+ ],
21
+ };
22
+ export default config;
tsconfig.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["dom", "dom.iterable", "esnext"],
4
+ "allowJs": true,
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "esModuleInterop": true,
10
+ "target": "ESNext",
11
+ "module": "ESNext",
12
+ "moduleResolution": "bundler",
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "jsx": "preserve",
16
+ "incremental": true,
17
+ "plugins": [
18
+ {
19
+ "name": "next"
20
+ }
21
+ ],
22
+ "paths": {
23
+ "@/*": ["./src/*"],
24
+ "playwright-core": ["./node_modules/rebrowser-playwright-core"]
25
+ }
26
+ },
27
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28
+ "exclude": ["node_modules"]
29
+ }