├── .devcontainer └── devcontainer.json ├── .github └── workflows │ ├── build_docker.yml │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── README_ZH.md ├── README_en.md ├── app.py ├── data ├── PromptException.py ├── cookie.py ├── message.py └── schemas.py ├── docker-compose.yml ├── env.example ├── images ├── 后端操作截图.png ├── 数据库可视化截图.png └── 背景图.jpg ├── main.py ├── process └── process_cookies.py ├── public ├── favicon.png ├── logo_dark.png └── logo_light.png ├── setup.py ├── start.sh ├── suno ├── __init__.py ├── constants.py ├── http_client.py └── suno.py ├── suno2openai.egg-info └── PKG-INFO ├── suno_ai.egg-info └── PKG-INFO ├── tests └── test_suno.py └── util ├── config.py ├── logger.py ├── sql_uilts.py ├── tool.py └── utils.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 4 | "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": [ 8 | "README.md", 9 | "background/main.py" 10 | ] 11 | }, 12 | "vscode": { 13 | "settings": {}, 14 | "extensions": [ 15 | "ms-python.python", 16 | "ms-python.vscode-pylance" 17 | ] 18 | } 19 | }, 20 | "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y /etc/timezone && \ 10 | apt-get clean 11 | 12 | RUN pip install --no-cache-dir -r requirements.txt 13 | 14 | EXPOSE 8000 8501 15 | 16 | CMD bash -c "python app.py & streamlit run background/main.py" 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Suno API 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [中文](https://github.com/wlhtea/Suno2openai/blob/main/README_ZH.md) English 2 | 3 | ## Suno2openai 4 | 5 | > Integrated based on [SunoSongsCreator](https://github.com/yihong0618/SunoSongsCreator) and [Suno-API](https://github.com/SunoAI-API/Suno-API) projects, offering standardized service interfaces compliant with OpenAI formats. 6 | 7 | ## ✨ Project Highlights 8 | 9 | - **OpenAI Format Calls**: Supports streaming output. 10 | - **Front-end Compatibility**: Compatible with front-end projects like `chat-next-web`. 11 | - **Docker Deployment**: Simplifies deployment process, supports `docker-compose`, `docker`. 12 | - **Multiple Cookie Management**: Implements rotation of multiple cookies. 13 | 14 | ## 🚀 Future Plans 15 | 16 | - Introduce request queueing optimizations. 17 | - Support for custom parameters (such as `tags`, `prompt`, `style`, and song continuation). 18 | - Explore development of official-like frontend pages. 19 | - Welcome valuable suggestions! 📧 **Email**: 1544007699@qq.com 20 | 21 | --- 22 | 23 | ## 🐳 Docker Deployment 24 | 25 | This tutorial provides step-by-step guidance on running a Docker container with specific environment variables and port mappings. For the purpose of this guide, sensitive information such as SQL names, passwords, and IP addresses will be replaced with placeholders. 26 | 27 | ### Prerequisites 28 | 29 | - Docker is installed on your machine. 30 | - Basic knowledge of Docker CLI. 31 | - MySQL version >= 5.7 32 | 33 | ### How to get cookie 34 | ![image](https://github.com/wlhtea/Suno2openai/assets/115779315/51fec32d-0fe4-403d-8760-1e85f74a1fb6) 35 | copy all content about the cookie 36 | 37 | ### Steps 38 | 39 | 1. **Pull Docker Image** 40 | 41 | Ensure the Docker image `wlhtea/suno2openai:latest` is available on your machine. If not, you can pull it from the Docker repository using: 42 | 43 | ```bash 44 | docker pull wlhtea/suno2openai:latest 45 | ``` 46 | 47 | 2. **Run Docker Container** 48 | 49 | Run the Docker container using necessary environment variables and port mappings. Replace ``, ``, and `` with your actual SQL database connection values. These should be kept confidential and not shared publicly. 50 | 51 | ```bash 52 | docker run -d --name wsunoapi \ 53 | -p 8000:8000 \ 54 | -p 8501:8501 \ 55 | -e BASE_URL='' \ 56 | -e SESSION_ID='' \ 57 | -e USER_NAME='' \ 58 | -e SQL_NAME='' \ 59 | -e SQL_PASSWORD='' \ 60 | -e SQL_IP='127.0.0.1' \ 61 | -e SQL_DK=3306 \ 62 | -e COOKIES_PREFIX='your_cookies_prefix' \ 63 | -e AUTH_KEY='' \ 64 | -e RETRIES=5 \ 65 | -e BATCH_SIZE=10 \ 66 | -e MAX_TIME=5 \ 67 | --restart=always \ 68 | wlhtea/suno2openai:latest 69 | ``` 70 | 71 | ### Example 72 | 73 | ```bash 74 | docker run -d --name wsunoapi \ 75 | -p 8000:8000 \ 76 | -p 8501:8501 \ 77 | -e BASE_URL='https://studio-api.suno.ai' \ 78 | -e SESSION_ID='your-session-id' \ 79 | -e USER_NAME='suno2openaiUsername' \ 80 | -e SQL_NAME='suno2openaiSQLname' \ 81 | -e SQL_PASSWORD='12345678' \ 82 | -e SQL_IP='127.0.0.1' \ 83 | -e SQL_DK=3306 \ 84 | -e COOKIES_PREFIX='your_cookies_prefix' \ 85 | -e AUTH_KEY='your-auth-key' \ 86 | -e RETRIES=5 \ 87 | -e BATCH_SIZE=10 \ 88 | -e MAX_TIME=5 \ 89 | --restart=always \ 90 | wlhtea/suno2openai:latest 91 | ``` 92 | 93 | **Parameter Explanation:** 94 | 95 | - `-d`: Run the container in detached mode and log the container ID. 96 | - `--name wsunoapi`: Name your container `wsunoapi` for easy reference. 97 | - `-p 8000:8000`: Map the container's 8000 port to the host machine's 8000 port. 98 | - `-e`: Set environment variables for your container. 99 | - `--restart=always`: Ensure the container always restarts, unless manually stopped. 100 | 101 | 3. **Access the Application** 102 | 103 | Once the container is running, the application inside should be accessible via `http://localhost:8000` or the 8000 port of your Docker host machine's IP address. 104 | 105 | ## Note 106 | 107 | Before running the Docker container, make sure you replace placeholders like ``, ``, ``, and `` with actual values. 108 | 109 | ## 📦 Docker-Compose Deployment 110 | 111 | _Update Time: 2024/4/7 18:18_ 112 | 113 | ### Clone the Project to Your Server 114 | 115 | ```bash 116 | git clone https://github.com/wlhtea/Suno2openai.git 117 | ``` 118 | 119 | ### Create a Database 120 | 121 | Create a database (name it as you wish), remember to save the password, and ensure the database permissions are set correctly (allow connections from all IPs or only from Docker container IPs). 122 | 123 | ### Configure Environment Variables 124 | 125 | **Rename the `env.example` file to `.env` and fill in the corresponding details:** 126 | 127 | ```plaintext 128 | BASE_URL=https://studio-api.suno.ai 129 | SESSION_ID=your-session-id 130 | USER_NAME=your-username 131 | SQL_NAME=your-database-name 132 | SQL_PASSWORD=your-database-password 133 | SQL_IP=127.0.0.1 134 | SQL_DK=3306 135 | COOKIES_PREFIX=your_cookies_prefix 136 | AUTH_KEY=your-auth-key 137 | RETRIES=5 138 | BATCH_SIZE=10 139 | MAX_TIME=5 140 | ``` 141 | 142 | ### Enter the Project Directory 143 | 144 | ```bash 145 | cd Suno2openai 146 | ``` 147 | 148 | ### Start Docker 149 | 150 | ```bash 151 | docker compose build && docker compose up 152 | ``` 153 | 154 | **Notes:** 155 | 156 | - **Security Group Configuration**: Ensure the port 8000 is open. 157 | - **HTTPS Support**: If the frontend project uses HTTPS, the proxy URL of this project should also use HTTPS. 158 | 159 | --- 160 | 161 | ## 📋 API Requests 162 | 163 | ### API Overview 164 | 165 | 1. **Add Cookie**: Use the `/your_cookies_prefix/cookies` endpoint to add cookies. 166 | 2. **Get All Cookies**: Use the `/your_cookies_prefix/cookies` endpoint to retrieve all cookies. 167 | 3. **Delete Cookie**: Use the `/your_cookies_prefix/cookies` endpoint to delete specific cookies. 168 | 4. **Refresh Cookie**: Use the `/your_cookies_prefix/refresh/cookies` endpoint to refresh cookies. 169 | 5. **Generate Chat Completion**: Use the `/v1/chat/completions` endpoint to generate chat responses. 170 | 171 | ### Add Cookie Example 172 | 173 | You can add cookies using the `/your_cookies_prefix/cookies` endpoint. Here is an example request using `requests` library in Python: 174 | 175 | ```python 176 | import requests 177 | 178 | url = "http://localhost:8000/your_cookies_prefix/cookies" 179 | headers = { 180 | "Authorization": "Bearer your-auth-key", 181 | "Content-Type": "application/json" 182 | } 183 | data = { 184 | "cookies": ["cookie1", "cookie2"] 185 | } 186 | 187 | response = requests.put(url, headers=headers, json=data) 188 | print(response.text) 189 | ``` 190 | 191 | ### Get All Cookies Example 192 | 193 | You can retrieve all cookies using the `/your_cookies_prefix/cookies` endpoint. Here is an example request: 194 | 195 | ```python 196 | import requests 197 | 198 | url = "http://localhost:8000/your_cookies_prefix/cookies" 199 | headers = { 200 | "Authorization": "Bearer your-auth-key", 201 | "Content-Type": "application/json" 202 | } 203 | 204 | response = requests.post(url, headers=headers) 205 | print(response.text) 206 | ``` 207 | 208 | ### Delete Cookie Example 209 | 210 | You can delete specific cookies using the `/your_cookies_prefix/cookies` endpoint. Here is an example request: 211 | 212 | ```python 213 | import requests 214 | 215 | url = "http://localhost:8000/your_cookies_prefix/cookies" 216 | headers = { 217 | "Authorization": "Bearer your-auth-key", 218 | "Content-Type": "application/json" 219 | } 220 | data = { 221 | "cookies": ["cookie1", "cookie2"] 222 | } 223 | 224 | response = requests.delete(url, headers=headers, json=data) 225 | print(response.text) 226 | ``` 227 | 228 | ### Refresh Cookies Example 229 | 230 | You can refresh cookies using the `/your_cookies_prefix/refresh/cookies` endpoint. Here is an example request: 231 | 232 | ```python 233 | import requests 234 | 235 | url = "http://localhost:8000/your_cookies_prefix/refresh/cookies" 236 | headers = { 237 | "Authorization": "Bearer your-auth-key", 238 | "Content-Type": "application/json" 239 | } 240 | 241 | response = requests.get(url, headers=headers) 242 | print(response.text) 243 | ``` 244 | 245 | ### Generate Chat Completion Example 246 | 247 | You can use the `/v1/chat/completions` endpoint to generate chat responses. Here is an example request: 248 | 249 | ```python 250 | import requests 251 | 252 | url = "http://localhost:8000/v1/chat/completions" 253 | headers = { 254 | "Authorization": "Bearer your-auth-key", 255 | "Content-Type": "application/json" 256 | } 257 | data = { 258 | "model": "gpt-3.5-turbo", 259 | "messages": [ 260 | {"role": "system", "content": "You are a helpful assistant."}, 261 | {"role": "user", "content": "Tell me a joke."} 262 | ] 263 | # "stream": true # Uncomment to enable streaming output 264 | } 265 | 266 | response = requests.post(url, headers=headers, json=data) 267 | print(response.text) 268 | ``` 269 | 270 | ### Parameter Explanation 271 | 272 | - `BASE_URL`: Default API base URL, defaults to `https://studio-api.suno.ai` if not set. 273 | - `SESSION_ID`: The session ID. 274 | - `USER_NAME`: Database username. 275 | - `SQL_NAME`: Database name. 276 | - `SQL_PASSWORD`: Database password. 277 | - `SQL_IP`: Database IP address. 278 | - `SQL_DK`: Database port, default is 3306. 279 | - `COOKIES_PREFIX`: Prefix for cookies, remember `/` to begin with. 280 | - `AUTH_KEY`: Authorization key, default is the current timestamp. 281 | - `RETRIES`: Number of retries, default is 5. 282 | - `BATCH_SIZE`: Batch size, default is 10. 283 | - `MAX_TIME`: Maximum request time (min), default is 5. 284 | 285 | BATCH_SIZE`: Batch size, default is 10. 286 | 287 | --- 288 | 289 | ## 🎉 Effect Display 290 | 291 | ![Effect Display](https://github.com/wlhtea/Suno2openai/assets/115779315/6f289256-6ba5-4016-b9a3-20640d864302) 292 | 293 | ## 💌 Internship Opportunities 294 | 295 | If interested in welcoming a third-year student with experience in data analysis and front-end/back-end development for an internship, please contact: 296 | 297 | - 📧 **Email**: 1544007699@qq.com 298 | 299 | **Support Us**: If you find this project helpful, please do not hesitate to star it ⭐! We welcome any form of support and suggestions, let’s progress together! 300 | -------------------------------------------------------------------------------- /README_ZH.md: -------------------------------------------------------------------------------- 1 | 中文 | [English](https://github.com/wlhtea/Suno2openai/blob/main/README.md) 2 | 3 | ## Suno2openai 4 | 5 | > 基于 [SunoSongsCreator](https://github.com/yihong0618/SunoSongsCreator) 和 [Suno-API](https://github.com/SunoAI-API/Suno-API) 项目集成,提供符合 OpenAI 格式的标准化服务接口。 6 | 7 | ## ✨ 项目亮点 8 | 9 | - **OpenAI 格式调用**: 支持流输出。 10 | - **前端兼容性**: 兼容 `chat-next-web` 等前端项目。 11 | - **Docker 部署**: 简化部署流程,支持 `docker-compose`、`docker`。 12 | - **多 Cookie 管理**: 实现多个 Cookie 的轮换。 13 | - **后台管理系统**: 便于非程序员管理数据库和查看项目运行情况。 14 | 15 | ## 🎉 效果展示 16 | ### 💻 管理员后端 17 | ![后端](./images/后端操作截图.png) 18 | ![后端](./images/数据库可视化截图.png) 19 | ![效果图](https://github.com/wlhtea/Suno2openai/assets/115779315/6f289256-6ba5-4016-b9a3-20640d864302) 20 | 21 | 22 | ## 🚀 未来计划 23 | 24 | - 引入请求队列优化。 25 | - 支持自定义参数(如 `tags`、`prompt`、`style` 和歌曲续写)。 26 | - 探索开发类似官方的前端页面。 27 | - 欢迎宝贵建议!📧 **邮箱**: 1544007699@qq.com 28 | 29 | --- 30 | 31 | ## 🐳 Docker 部署 32 | 33 | 本教程提供了使用特定环境变量和端口映射运行 Docker 容器的分步指南。为了本指南的目的,SQL 名称、密码和 IP 地址等敏感信息将被替换为占位符。 34 | 35 | ### 前提条件 36 | 37 | - 您的机器上已安装 Docker。 38 | - 基本的 Docker CLI 知识。 39 | - MySQL 版本 >= 5.7 40 | 41 | ### 步骤 42 | 43 | 1. **拉取 Docker 镜像** 44 | 45 | 确保您的机器上已有 Docker 镜像 `wlhtea/suno2openai:latest`。如果没有,可以从 Docker 仓库拉取: 46 | 47 | ```bash 48 | docker pull wlhtea/suno2openai:latest 49 | ``` 50 | 51 | 2. **运行 Docker 容器** 52 | 53 | 使用必要的环境变量和端口映射运行 Docker 容器。将 ``、`` 和 `` 替换为实际的 SQL 数据库连接值。这些信息应保密,不要公开分享。 54 | 55 | ```bash 56 | docker run -d --name wsunoapi \ 57 | -p 8000:8000 \ 58 | -p 8501:8501 \ 59 | -e BASE_URL='' \ 60 | -e SESSION_ID='' \ 61 | -e USER_NAME='' \ 62 | -e SQL_NAME='' \ 63 | -e SQL_PASSWORD='' \ 64 | -e SQL_IP='127.0.0.1' \ 65 | -e SQL_DK=3306 \ 66 | -e COOKIES_PREFIX='your_cookies_prefix' \ 67 | -e AUTH_KEY='' \ 68 | -e RETRIES=5 \ 69 | -e BATCH_SIZE=10 \ 70 | -e MAX_TIME=5 \ 71 | --restart=always \ 72 | wlhtea/suno2openai:latest 73 | ``` 74 | 75 | ### 示例 76 | 77 | ```bash 78 | docker run -d --name wsunoapi \ 79 | -p 8000:8000 \ 80 | -p 8501:8501 \ 81 | -e BASE_URL='https://studio-api.suno.ai' \ 82 | -e SESSION_ID='your-session-id' \ 83 | -e USER_NAME='suno2openaiUsername' \ 84 | -e SQL_NAME='suno2openaiSQLname' \ 85 | -e SQL_PASSWORD='12345678' \ 86 | -e SQL_IP='127.0.0.1' \ 87 | -e SQL_DK=3306 \ 88 | -e COOKIES_PREFIX='your_cookies_prefix' \ 89 | -e AUTH_KEY='your-auth-key' \ 90 | -e RETRIES=5 \ 91 | -e BATCH_SIZE=10 \ 92 | -e MAX_TIME=5 \ 93 | --restart=always \ 94 | wlhtea/suno2openai:latest 95 | ``` 96 | 97 | **参数说明:** 98 | 99 | - `-d`: 在后台模式下运行容器并记录容器 ID。 100 | - `--name wsunoapi`: 将容器命名为 `wsunoapi` 以便于引用。 101 | - `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口(后端)。 102 | - `-p 8501:8000`: 将容器的 8501 端口映射到主机的 8000 端口(管理员前端)。 103 | - `-e`: 为容器设置环境变量。 104 | - `--restart=always`: 确保容器始终重启,除非手动停止。 105 | 106 | 3. **访问应用程序** 107 | - **接口地址:** 容器运行后,内部的应用程序应可通过 `http://localhost:8000` 或 Docker 主机的 IP 地址的 8000 端口请求接口. 108 | - **管理员控制台地址:** 访问`http://localhost:8501`访问管理员后端 或者 `http://ip:8501`。 109 | 110 | ## 注意 111 | 112 | 在运行 Docker 容器之前,请确保将 ``、``、`` 和 `` 等占位符替换为实际值。 113 | 114 | ## 📦 Docker-Compose 部署 115 | 116 | _更新时间:2024/6/30 13:18 117 | 118 | ### 克隆项目到您的服务器 119 | 120 | ```bash 121 | git clone https://github.com/wlhtea/Suno2openai.git 122 | ``` 123 | 124 | ### 创建数据库 125 | 126 | 创建数据库(名称自定义),记住保存密码,并确保数据库权限设置正确(允许来自所有 IP 或仅来自 Docker 容器 IP 的连接)。 127 | 128 | ### 配置环境变量 129 | 130 | **将 `env.example` 文件重命名为 `.env` 并填写相应的详细信息:** 131 | 132 | ```plaintext 133 | BASE_URL=https://studio-api.suno.ai 134 | SESSION_ID=your-session-id 135 | USER_NAME=your-username 136 | SQL_NAME=your-database-name 137 | SQL_PASSWORD=your-database-password 138 | SQL_IP=127.0.0.1 139 | SQL_DK=3306 140 | COOKIES_PREFIX=your_cookies_prefix 141 | AUTH_KEY=your-auth-key 142 | RETRIES=5 143 | BATCH_SIZE=10 144 | MAX_TIME=5 145 | ``` 146 | 147 | ### 进入项目目录 148 | 149 | ```bash 150 | cd Suno2openai 151 | ``` 152 | 153 | ### 启动 Docker 154 | 155 | ```bash 156 | docker compose build && docker compose up 157 | ``` 158 | 159 | **注意:** 160 | 161 | - **安全组配置**:确保端口 8000和8501 是开放的。 162 | - **HTTPS 支持**:如果前端项目使用 HTTPS,则本项目的代理 URL 也应使用 HTTPS。 163 | 164 | --- 165 | 166 | ## 📋 API 请求 167 | 168 | ### 接口总结 169 | 170 | 1. **添加 Cookie**: 使用 `/your_cookies_prefix/cookies` 端点添加 Cookie。 171 | 2. **获取所有 Cookie**: 使用 `/your_cookies_prefix/cookies` 端点检索所有 Cookie。 172 | 3. **删除 Cookie**: 使用 `/your_cookies_prefix/cookies` 端点删除特定 Cookie。 173 | 4. **刷新 Cookie**: 使用 `/your_cookies_prefix/refresh/cookies` 端点刷新 Cookie。 174 | 5. **生成 Chat Completion**: 使用 `/v1/chat/completions` 端点生成聊天回复。 175 | 6. **管理员后端**: 访问ip:8501即可 176 | ### 添加 Cookie 示例 177 | 178 | 您可以使用 `/your_cookies_prefix/cookies` 端点添加 Cookie。以下是使用 Python 的 `requests` 库的示例请求: 179 | 180 | ```python 181 | import requests 182 | 183 | url = "http://localhost:8000/your_cookies_prefix/cookies" 184 | headers = { 185 | "Authorization": "Bearer your-auth-key", 186 | "Content-Type": "application/json" 187 | } 188 | data = { 189 | "cookies": ["cookie1", "cookie2"] 190 | } 191 | 192 | response = requests.put(url, headers=headers, json=data) 193 | print(response.text) 194 | ``` 195 | 196 | ### 获取所有 Cookie 示例 197 | 198 | 您可以使用 `/your_cookies_prefix/cookies` 端点检索所有 Cookie。以下是示例请求: 199 | 200 | ```python 201 | import requests 202 | 203 | url = "http://localhost:8000/your_cookies_prefix/cookies" 204 | headers = { 205 | "Authorization": "Bearer your-auth-key", 206 | "Content-Type": "application/json" 207 | } 208 | 209 | response = requests.post(url, headers=headers) 210 | print(response.text) 211 | ``` 212 | 213 | ### 删除 Cookie 示例 214 | 215 | 您可以使用 `/your_cookies_prefix/cookies` 端点删除特定 Cookie。以下是示例请求: 216 | 217 | ```python 218 | import requests 219 | 220 | url = "http://localhost:8000/your_cookies_prefix/cookies" 221 | headers = { 222 | "Authorization": "Bearer your-auth-key", 223 | "Content-Type": "application/json" 224 | } 225 | data = { 226 | "cookies": ["cookie1", "cookie2"] 227 | } 228 | 229 | response = requests.delete(url, headers=headers, json=data) 230 | print(response.text) 231 | ``` 232 | 233 | ### 刷新 Cookie 示例 234 | 235 | 您可以使用 `/your_cookies_prefix/refresh/cookies` 端点刷新 Cookie。以下是示例请求: 236 | 237 | ```python 238 | import requests 239 | 240 | url = "http://localhost:8000/your_cookies_prefix/refresh/cookies" 241 | headers = { 242 | "Authorization": "Bearer your-auth-key", 243 | "Content-Type": "application/json" 244 | } 245 | 246 | response = requests.get(url, headers=headers) 247 | print(response.text) 248 | ``` 249 | 250 | ### 生成 Chat Completion 示例 251 | 252 | 您可以使用 `/v1/chat/completions` 端点生成聊天回复。以下是示例请求: 253 | 254 | ```python 255 | import requests 256 | 257 | url = "http://localhost:8000/v1/chat/completions" 258 | headers = { 259 | "Authorization": "Bearer your-auth-key", 260 | "Content-Type": "application/json" 261 | } 262 | data = { 263 | "model": "gpt-3.5-turbo", 264 | "messages": [ 265 | {"role": "system", "content": "You are a helpful assistant."}, 266 | {"role": "user", "content": "Tell me a joke."}, 267 | ] 268 | # "stream": True # 取消注释以启用流输出 269 | } 270 | 271 | response = requests.post(url, headers=headers, json=data) 272 | print(response.text) 273 | ``` 274 | 275 | ### 参数说明 276 | 277 | - `BASE_URL`: 默认 API 基础 URL,默认为 `https://studio-api.suno.ai`。 278 | - 279 | 280 | `SESSION_ID`: 会话 ID。 281 | - `USER_NAME`: 数据库用户名。 282 | - `SQL_NAME`: 数据库名称。 283 | - `SQL_PASSWORD`: 数据库密码。 284 | - `SQL_IP`: 数据库 IP 地址。 285 | - `SQL_DK`: 数据库端口,默认是 3306。 286 | - `COOKIES_PREFIX`: Cookie 前缀(记得要以/开头,例如/test) 287 | - `AUTH_KEY`: 授权密钥,默认为当前时间戳。 288 | - `RETRIES`: 重试次数,默认为 5。 289 | - `BATCH_SIZE`: 批处理大小,默认为 10。 290 | - `MAX_TIME`: 最大请求时间(min),默认为 5。 291 | 292 | --- 293 | 294 | 295 | ## 💌 实习机会 296 | 297 | 如果您有兴趣欢迎一名拥有数据分析和前后端开发经验的三年级学生进行实习,请联系: 298 | 299 | - 📧 **邮箱**: 1544007699@qq.com 300 | 301 | **支持我们**:如果您觉得这个项目对您有帮助,请不要犹豫,给它加星 ⭐!我们欢迎任何形式的支持和建议,让我们共同进步! 302 | -------------------------------------------------------------------------------- /README_en.md: -------------------------------------------------------------------------------- 1 | # Suno2openai 2 | 3 | > Based on the [SunoSongsCreator](https://github.com/yihong0618/SunoSongsCreator) 4 | > and [Suno-API](https://github.com/SunoAI-API/Suno-API) project integrations, provides interface standardization 5 | > services 6 | > conforming to the OpenAI format interface standardization service. 7 | 8 | Chinese | [English](https://github.com/wlhtea/Suno2openai/blob/main/README_en.md) 9 | 10 | ## 2024.4.10 Due to suno official update, some of the project features are not available, and have been re-changed. Projects that were pulled before 2024/4/10:15:04, please pull them again. docker will be updated later (already updated, please pay attention to the pulled version number when pulling them.) 11 | 12 | ## ✨ Project features 13 | 14 | - **OpenAI format call**: support for streaming output content. 15 | - **Front-end compatibility**: adapt `chat-next-web` and other front-end projects. 16 | - **Docker deployment**: simplify the deployment process, support `docker-compose`, `docker`. 17 | - **Multi-cookie management**: enables polling of multiple cookies for use. 18 | 19 | ## 🚀 Follow-up plans 20 | 21 | - Introduce request queue wait optimization. 22 | - Support for custom parameters (e.g. `tags`, `prompt`, `style` and continuation of songs). 23 | - Explore the development of a front-end page similar to the official website. 24 | - Welcome to make valuable suggestions! 📧 **email**: 1544007699@qq.com 25 | 26 | --- 27 | 28 | ## 🫙 Docker Deployment 29 | 30 | This tutorial provides step-by-step instructions on how to run a Docker container using specific environment variables 31 | and port mapping. For the purposes of this guide, sensitive information such as SQL names, passwords, and IP addresses 32 | will be replaced with placeholders. 33 | 34 | ## Prerequisites 35 | 36 | - You have Docker installed on your machine. 37 | - You have basic knowledge of the Docker command line interface. 38 | 39 | ## Procedure 40 | 41 | 1. **Pulling a Docker image** 42 | 43 | First, make sure you have the Docker image `wlhtea/suno2openai:latest` on your machine. If not, you can pull it from 44 | the Docker repository using the following command: 45 | 46 | ```bash 47 | docker pull wlhtea/suno2openai:0.0.2 48 | ``` 49 | 50 | 2. **Running a Docker container** 51 | 52 | Run the Docker container with the necessary environment variables and port mapping. 53 | Replace ``, `` and `` with the actual values of your SQL database connection. These 54 | values should be kept private and not shared publicly. 55 | 56 | ```bash 57 | docker run -d --name wsunoapi \ 58 | -p 8000:8000 \ 59 | -e BASE_URL='https://studio-api.suno.ai' \ 60 | -e SESSION_ID='' \ 61 | -e SQL_NAME='' \\ 62 | -e SQL_PASSWORD='' \\ 63 | -e SQL_IP='' \ 64 | -e SQL_DK=3306 \ 65 | --restart=always \ 66 | wlhtea/suno2openai:latest 67 | \ --restart=always 68 | ``` 69 | 70 | **Parameter description:** 71 | - `-d`: Run the container in background mode and logger.info the container ID. 72 | - `--name wsunoapi`: Name your container `wsunoapi` for easy referencing. 73 | - `-p 8000:8000`: Maps the container's port 8000 to the host's port 8000. 74 | - `-e`: Set environment variables for your container. 75 | - `--restart=always`: Ensure that the container is always restarted, unless stopped manually. 76 | 77 | 3. **Add a cookie to the database 78 | Just open the database and add a cookie count is the number of times remaining (an auto-import will be added later) 79 | ```mysql 80 | id = int 81 | cookie = Cookie 82 | count = int 83 | working = 0 84 | ``` 85 | 86 | Database may report error: 'NoneType' object has no attribute ' 87 | items', [check for correctness here](https://github.com/wlhtea/Suno2openai/issues/10) 88 | 89 | 5. **Access to applications** 90 | 91 | Once the container is running, the application inside it should be accessible on port 8000 92 | via `http://localhost:8000` or the IP address of your Docker host. 93 | 94 | ## Caution. 95 | 96 | Before running the Docker container, make sure you replace placeholders such 97 | as ``, ``, ``, and `` with their actual values. 98 | 99 | --- 100 | 101 | ## 📦 docker-compose deployment 102 | 103 | _Updated: 2024/4/7 18:18_ 104 | 105 | ### Clone the project to the server 106 | 107 | ```bash 108 | git clone https://github.com/wlhtea/Suno2openai.git 109 | ``` 110 | 111 | ### Create a database 112 | 113 | Create a database (with any name you want), remember to save the password and make sure the database permissions are set 114 | correctly (Allow all IPs to connect or Docker container IPs only). 115 | 116 | ### Configure environment variables 117 | 118 | **Rename the `env.example` file to `.env` and fill in the following fields:** ``plaintext 119 | 120 | ```plaintext 121 | BASE_URL=https://studio-api.suno.ai 122 | SESSION_ID=cookie # This does not need to be changed. 123 | SQL_NAME= 126 | SQL_DK=3306 # database port 127 | ``` 128 | 129 | ### Enter the project directory 130 | 131 | ```bash 132 | cd Suno2openai 133 | ``` 134 | 135 | ### Update cookies 136 | 137 | Edit the ``update_cookie_to_sql.py`` file and fill the array below with your cookies: 138 | 139 | ```python 140 | cookies = ['cookie1', 'cookie2'] 141 | ``` 142 | 143 | ! [cookie location example](https://github.com/wlhtea/Suno2openai/assets/115779315/6edf9969-9eb6-420f-bfcd-dbf4b282ecbf) 144 | 145 | ### Start Docker 146 | 147 | ```bash 148 | docker compose build && docker compose up 149 | ``` 150 | 151 | ## **Notes**: 152 | 153 | - **Security group configuration**: make sure port 8000 is open. 154 | - **HTTPS support**: If the front-end project uses HTTPS, the reverse proxy URL for this project should also use HTTPS. 155 | 156 | ## 🤔 Frequently Asked Questions 157 | 158 | - Calling sunoapi directly in `chat-next-web` with the deployment url works, but not via `new-api`. You may need to 159 | check the source code of `new-api`. 160 | 161 | ## 🔌 Accessing new-api(one-api) 162 | 163 | Fill in the address of this project in the proxy settings of the channel in the format: `http://:8000`. It is 164 | recommended to use HTTPS and domain name. 165 | 166 | ## 🎉 The effect show 167 | 168 | ! [chat-next-web effect image](https://github.com/wlhtea/Suno2openai/assets/115779315/6495e840-b025-4667-82f6-19116ce71c8e) 169 | 170 | ## 💌 Call for Internships 171 | 172 | If you are interested in hosting a junior with experience in data analytics and front-end and back-end development for 173 | an internship, please contact: 174 | 175 | - 📧 **email**: 1544007699@qq.com 176 | 177 | **GIVE SUPPORT**: If this program has been helpful to you, please do not hesitate to give it a star ⭐! Any kind of 178 | support and suggestions are welcome, let's improve together! 179 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | log_config = uvicorn.config.LOGGING_CONFIG 4 | default_format = "%(asctime)s | %(levelname)s | %(message)s" 5 | access_format = r'%(asctime)s | %(levelname)s | %(client_addr)s: %(request_line)s %(status_code)s' 6 | 7 | log_config["formatters"]["default"]["fmt"] = default_format 8 | log_config["formatters"]["access"]["fmt"] = access_format 9 | 10 | uvicorn.run("main:app", host="0.0.0.0", port=7000) 11 | -------------------------------------------------------------------------------- /data/PromptException.py: -------------------------------------------------------------------------------- 1 | class PromptException(Exception): 2 | def __init__(self, message): 3 | super().__init__(message) 4 | self.message = message 5 | 6 | def __str__(self): 7 | return self.message 8 | -------------------------------------------------------------------------------- /data/cookie.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import time 3 | from http.cookies import SimpleCookie 4 | from threading import Thread 5 | 6 | import requests 7 | 8 | from util.config import PROXY 9 | from util.logger import logger 10 | from util.utils import COMMON_HEADERS 11 | 12 | 13 | class SunoCookie: 14 | def __init__(self): 15 | self.cookie = SimpleCookie() 16 | self.session_id = None 17 | self.token = None 18 | 19 | def load_cookie(self, cookie_str): 20 | self.cookie.load(cookie_str) 21 | 22 | def get_cookie(self): 23 | return ";".join([f"{i}={self.cookie.get(i).value}" for i in self.cookie.keys()]) 24 | 25 | def set_session_id(self, session_id): 26 | self.session_id = session_id 27 | 28 | def get_session_id(self): 29 | return self.session_id 30 | 31 | def get_token(self): 32 | return self.token 33 | 34 | def set_token(self, token: str): 35 | self.token = token 36 | 37 | 38 | def update_token(suno_cookie): 39 | headers = {"cookie": suno_cookie.get_cookie()} 40 | headers.update(COMMON_HEADERS) 41 | session_id = suno_cookie.get_session_id() 42 | 43 | resp = requests.post( 44 | url=f"https://clerk.suno.ai/v1/client/sessions/{session_id}/tokens?_clerk_js_version=4.70.5", 45 | headers=headers, 46 | proxies=PROXY 47 | ) 48 | 49 | resp_headers = dict(resp.headers) 50 | set_cookie = resp_headers.get("Set-Cookie") 51 | suno_cookie.load_cookie(set_cookie) 52 | token = resp.json().get("jwt") 53 | suno_cookie.set_token(token) 54 | # logger.info(set_cookie) 55 | # logger.info(f"*** token -> {token} ***") 56 | 57 | 58 | def keep_alive(suno_cookie: SunoCookie): 59 | while True: 60 | try: 61 | update_token(suno_cookie) 62 | except Exception as e: 63 | logger.info(e) 64 | finally: 65 | time.sleep(5) 66 | 67 | 68 | def start_keep_alive(suno_cookie: SunoCookie): 69 | t = Thread(target=keep_alive, args=(suno_cookie,)) 70 | t.start() 71 | 72 | 73 | suno_auth = SunoCookie() 74 | -------------------------------------------------------------------------------- /data/message.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import asyncio 3 | import json 4 | 5 | from fastapi import HTTPException 6 | from starlette.responses import StreamingResponse, JSONResponse 7 | 8 | from data.PromptException import PromptException 9 | from suno.suno import SongsGen 10 | from util.config import RETRIES 11 | from util.logger import logger 12 | from util.tool import get_clips_ids, check_status_complete, deleteSongID, calculate_token_costs 13 | from util.utils import generate_music, get_feed 14 | 15 | 16 | # 流式请求 17 | async def generate_data(start_time, db_manager, chat_user_message, chat_id, 18 | timeStamp, ModelVersion, tags=None, title=None, 19 | continue_at=None, continue_clip_id=None): 20 | if ModelVersion == "suno-v3": 21 | Model = "chirp-v3-0" 22 | elif ModelVersion == "suno-v3.5": 23 | Model = "chirp-v3-5" 24 | else: 25 | yield f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": str("请选择suno-v3 或者 suno-v3.5其中一个")}, "finish_reason": None}]})}\n\n""" 26 | yield f"""data:""" + ' ' + f"""[DONE]\n\n""" 27 | return 28 | 29 | data = { 30 | "gpt_description_prompt": f"{chat_user_message}", 31 | "prompt": "", 32 | "mv": Model, 33 | "title": "", 34 | "tags": "" 35 | } 36 | 37 | if continue_clip_id is not None: 38 | data = { 39 | "prompt": chat_user_message, 40 | "mv": Model, 41 | "title": title, 42 | "tags": tags, 43 | "continue_at": continue_at, 44 | "continue_clip_id": continue_clip_id 45 | } 46 | 47 | for try_count in range(RETRIES): 48 | cookie = None 49 | song_gen = None 50 | try: 51 | cookie = str(await db_manager.get_request_cookie()).strip() 52 | if cookie is None: 53 | raise RuntimeError("没有可用的cookie") 54 | else: 55 | song_gen = SongsGen(cookie) 56 | remaining_count = await song_gen.get_limit_left() 57 | if remaining_count == -1: 58 | await db_manager.delete_cookies(cookie) 59 | raise RuntimeError("该账号剩余次数为 -1,无法使用") 60 | 61 | # 测试并发集 62 | # yield f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": 63 | # "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, 64 | # "delta": {"content": str(cookie)}, "finish_reason": None}]})}\n\n""" 65 | # yield f"""data:""" + ' ' + f"""[DONE]\n\n""" 66 | # return 67 | 68 | _return_ids = False 69 | _return_tags = False 70 | _return_title = False 71 | _return_prompt = False 72 | _return_image_url = False 73 | _return_video_url = False 74 | _return_audio_url = False 75 | _return_Forever_url = False 76 | 77 | token, sid = await song_gen.get_auth_token(w=1) 78 | 79 | # suno_auth.set_session_id(sid) 80 | # suno_auth.load_cookie(cookie) 81 | 82 | response = await generate_music(data=data, token=token) 83 | # await asyncio.sleep(3) 84 | clip_ids = await get_clips_ids(response) 85 | song_id_1 = clip_ids[0] 86 | song_id_2 = clip_ids[1] 87 | if not song_id_1 and not song_id_2: 88 | raise Exception("生成clip_ids为空") 89 | 90 | tem_text = "\n### 🤯 Creating\n\n```suno\n{prompt:" + f"{chat_user_message}" + "}\n```\n\n" 91 | yield f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"role": "assistant", "content": tem_text}, "finish_reason": None}]})}\n\n""" 92 | for clip_id in clip_ids: 93 | count = 0 94 | while True: 95 | # 全部任务达成 96 | # if (_return_Forever_url and _return_ids and _return_tags and 97 | # _return_title and _return_prompt and _return_image_url and _return_audio_url): 98 | # break 99 | 100 | token, sid = await song_gen.get_auth_token(w=1) 101 | 102 | try: 103 | now_data = await get_feed(ids=clip_id, token=token) 104 | more_information_ = now_data[0]['metadata'] 105 | except: 106 | continue 107 | 108 | if (now_data and 109 | isinstance(now_data, list) and 110 | len(now_data) > 0 and 111 | isinstance(now_data[0], dict) and 112 | 'audio_url' in now_data[0] and 113 | now_data[0]['audio_url'] == "https://cdn1.suno.ai/None.mp3"): 114 | raise PromptException(f"### 🚨 违规\n\n- **歌曲提示词**:`{chat_user_message}`," 115 | f"存在违规词,歌曲创作失败😭\n\n### " 116 | f"👀 更多\n\n**🤗请更换提示词,我会为你重新创作**🎶✨\n") 117 | 118 | # 第一步:拿歌曲IDs 119 | if not _return_ids: 120 | try: 121 | song_id_text = (f"" 122 | f"### ⭐ 歌曲信息\n\n" 123 | f"- **🧩 ID1️⃣**:{song_id_1}\n" 124 | f"- **🧩 ID2️⃣**:{song_id_2}\n") 125 | yield str( 126 | f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": song_id_text}, "finish_reason": None}]})}\n\n""") 127 | _return_ids = True 128 | continue 129 | except: 130 | pass 131 | 132 | # 第二步:拿歌曲歌名 133 | elif not _return_title: 134 | try: 135 | title = now_data[0]["title"] 136 | if title != '': 137 | title_data = f"- **🤖 歌名**:{title} \n\n" 138 | yield """data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": title_data}, "finish_reason": None}]})}\n\n""" 139 | _return_title = True 140 | continue 141 | except: 142 | pass 143 | 144 | # 第三步:拿歌曲类型 145 | elif not _return_tags: 146 | try: 147 | tags = more_information_["tags"] 148 | if tags is not None and tags != "": 149 | tags_data = f"- **💄 类型**:{tags} \n\n" 150 | yield str( 151 | f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": tags_data}, "finish_reason": None}]})}\n\n""") 152 | _return_tags = True 153 | continue 154 | except: 155 | pass 156 | 157 | # 第四步:拿歌曲歌词 158 | elif not _return_prompt: 159 | try: 160 | prompt = more_information_["prompt"] 161 | if prompt is not None and prompt != '': 162 | prompt_data = f"### 📖 完整歌词\n\n```\n{prompt}\n```\n\n" 163 | yield str( 164 | f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": prompt_data}, "finish_reason": None}]})}\n\n""") 165 | _return_prompt = True 166 | continue 167 | except: 168 | pass 169 | 170 | # 第五步:拿歌曲图片 171 | elif not _return_image_url: 172 | try: 173 | if now_data[0].get('image_url') is not None: 174 | image_url_small_data = f"### 🖼️ 歌曲图片\n\n" 175 | image_url_lager_data = f"![image_large_url]({now_data[0]['image_large_url']}) \n\n### 🤩 即刻享受\n" 176 | yield f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": image_url_small_data}, "finish_reason": None}]})}\n\n""" 177 | yield f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": image_url_lager_data}, "finish_reason": None}]})}\n\n""" 178 | _return_image_url = True 179 | continue 180 | except: 181 | pass 182 | 183 | # 第六步:拿歌曲实时链接 184 | elif not _return_audio_url: 185 | try: 186 | if 'audio_url' in now_data[0]: 187 | audio_url_ = now_data[0]['audio_url'] 188 | if audio_url_ != '': 189 | audio_url_1 = f'https://audiopipe.suno.ai/?item_id={song_id_1}' 190 | audio_url_2 = f'https://audiopipe.suno.ai/?item_id={song_id_2}' 191 | 192 | audio_url_data_1 = f"\n- **🔗 实时音乐1️⃣**:{audio_url_1}" 193 | audio_url_data_2 = f"\n- **🔗 实时音乐2️⃣**:{audio_url_2}\n\n### 🚀 生成CDN链接中(2min~)\n\n" 194 | yield f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": audio_url_data_1}, "finish_reason": None}]})}\n\n""" 195 | yield f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": audio_url_data_2}, "finish_reason": None}]})}\n\n""" 196 | _return_audio_url = True 197 | continue 198 | except: 199 | pass 200 | 201 | # 第六步:拿歌曲CDN链接,没有获取到,则 202 | if (_return_ids and _return_tags and _return_title and _return_prompt and 203 | _return_image_url and _return_audio_url): 204 | if not _return_Forever_url: 205 | try: 206 | if check_status_complete(now_data, start_time): 207 | Aideo_Markdown_Conetent = (f"" 208 | f"\n\n### 🎷 CDN音乐链接\n\n" 209 | f"- **🎧 音乐1️⃣**:{'https://cdn1.suno.ai/' + clip_id + '.mp3'} \n" 210 | f"- **🎧 音乐2️⃣**:{'https://cdn1.suno.ai/' + song_id_2 + '.mp3'} \n") 211 | Video_Markdown_Conetent = (f"" 212 | f"\n### 📺 CDN视频链接\n\n" 213 | f"- **📽️ 视频1️⃣**:{'https://cdn1.suno.ai/' + song_id_1 + '.mp4'} \n" 214 | f"- **📽️ 视频2️⃣**:{'https://cdn1.suno.ai/' + song_id_2 + '.mp4'} \n" 215 | f"\n### 👀 更多\n\n" 216 | f"**🤗还想听更多歌吗,快来告诉我**🎶✨\n") 217 | yield str( 218 | f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": Aideo_Markdown_Conetent}, "finish_reason": None}]})}\n\n""") 219 | yield str( 220 | f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": Video_Markdown_Conetent}, "finish_reason": None}]})}\n\n""") 221 | yield f"""data:""" + ' ' + f"""[DONE]\n\n""" 222 | _return_Forever_url = True 223 | break 224 | 225 | else: 226 | count += 1 227 | if count % 34 == 0: 228 | content_wait = "🎵\n" 229 | else: 230 | content_wait = "🎵" 231 | yield f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": content_wait}, "finish_reason": None}]})}\n\n""" 232 | await asyncio.sleep(3) 233 | continue 234 | except: 235 | pass 236 | # 结束while 237 | break 238 | # 结束对songid的for重试 239 | break 240 | 241 | except PromptException as e: 242 | yield f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": str(e)}, "finish_reason": None}]})}\n\n""" 243 | yield f"""data:""" + ' ' + f"""[DONE]\n\n""" 244 | # 结束请求重试 245 | break 246 | 247 | except Exception as e: 248 | if try_count < RETRIES: 249 | logger.error(f"第 {try_count + 1} 次尝试歌曲失败,错误为:{str(e)},重试中......") 250 | continue 251 | else: 252 | logger.error(f"生成歌曲错误,尝试歌曲到达最大次数,错误为:{str(e)}") 253 | yield f"""data:""" + ' ' + f"""{json.dumps({"id": f"chatcmpl-{chat_id}", "object": "chat.completion.chunk", "model": ModelVersion, "created": timeStamp, "choices": [{"index": 0, "delta": {"content": str(e)}, "finish_reason": None}]})}\n\n""" 254 | yield f"""data:""" + ' ' + f"""[DONE]\n\n""" 255 | 256 | finally: 257 | if song_gen is not None: 258 | await song_gen.close_session() 259 | if cookie is not None: 260 | await deleteSongID(db_manager, cookie) 261 | 262 | 263 | # 返回消息,使用协程 264 | async def response_async(start_time, db_manager, data, content_all, chat_id, timeStamp, last_user_content, headers): 265 | if not data.stream: 266 | try: 267 | async for data_string in generate_data(start_time, db_manager, last_user_content, 268 | chat_id, timeStamp, data.model): 269 | try: 270 | json_data = data_string.split('data: ')[1].strip() 271 | 272 | parsed_data = json.loads(json_data) 273 | content = parsed_data['choices'][0]['delta']['content'] 274 | content_all += content 275 | except: 276 | pass 277 | except Exception as e: 278 | raise HTTPException(status_code=500, detail=f"生成数据时出错: {str(e)}") 279 | 280 | try: 281 | input_tokens, output_tokens = calculate_token_costs(last_user_content, content_all, 'gpt-3.5-turbo') 282 | except Exception as e: 283 | raise HTTPException(status_code=500, detail=f"计算 token 成本时出错: {str(e)}") 284 | 285 | json_string = { 286 | "id": f"chatcmpl-{chat_id}", 287 | "object": "chat.completion", 288 | "created": timeStamp, 289 | "model": data.model, 290 | "choices": [ 291 | { 292 | "index": 0, 293 | "message": { 294 | "role": "assistant", 295 | "content": content_all 296 | }, 297 | "finish_reason": "stop" 298 | } 299 | ], 300 | "usage": { 301 | "prompt_tokens": input_tokens, 302 | "completion_tokens": output_tokens, 303 | "total_tokens": input_tokens + output_tokens 304 | } 305 | } 306 | 307 | return json_string 308 | else: 309 | try: 310 | data_generator = generate_data(start_time, db_manager, last_user_content, chat_id, timeStamp, data.model) 311 | return StreamingResponse(data_generator, headers=headers, media_type="text/event-stream") 312 | except Exception as e: 313 | return JSONResponse(status_code=500, content={"detail": f"生成流式响应时出错: {str(e)}"}) 314 | 315 | 316 | # 线程用于请求 317 | def request_chat(start_time, db_manager, data, content_all, chat_id, timeStamp, last_user_content, headers): 318 | loop = asyncio.new_event_loop() 319 | result = None 320 | try: 321 | asyncio.set_event_loop(loop) 322 | result = loop.run_until_complete( 323 | response_async(start_time, db_manager, data, content_all, chat_id, timeStamp, last_user_content, headers)) 324 | except Exception as e: 325 | raise HTTPException(status_code=500, detail=f"请求聊天时出错: {str(e)}") 326 | finally: 327 | loop.close() 328 | return result 329 | -------------------------------------------------------------------------------- /data/schemas.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | from typing import Any 4 | from typing import List, Optional 5 | 6 | from pydantic import BaseModel 7 | from pydantic import Field 8 | 9 | 10 | class Response(BaseModel): 11 | code: Optional[int] = 0 12 | msg: Optional[str] = "success" 13 | data: Optional[Any] = None 14 | 15 | 16 | class GenerateBase(BaseModel): 17 | token: str = "" 18 | cookie: str = "" 19 | session_id: str = "" 20 | gpt_description_prompt: str = "" 21 | prompt: str = "" 22 | mv: str = "" 23 | title: str = "" 24 | tags: str = "" 25 | continue_at: Optional[str] = None 26 | continue_clip_id: Optional[str] = None 27 | 28 | 29 | class Message(BaseModel): 30 | role: str 31 | content: str 32 | 33 | 34 | class Data(BaseModel): 35 | model: str 36 | messages: List[Message] 37 | stream: Optional[bool] = None 38 | title: str = Field(None, description="song title") 39 | tags: str = Field(None, description="style of music") 40 | continue_at: Optional[int] = Field( 41 | default=None, 42 | description="continue a new clip from a previous song, format number", 43 | examples=[120], 44 | ) 45 | continue_clip_id: Optional[str] = None 46 | 47 | 48 | class Cookies(BaseModel): 49 | cookies: List[str] 50 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | suno-api: 5 | image: wlhtea/suno2openai:latest 6 | container_name: suno-api 7 | ports: 8 | - 8001:8000 9 | restart: always 10 | environment: 11 | # 默认 12 | BASE_URL: 'https://studio-api.suno.ai' 13 | # 默认 14 | SESSION_ID: 'cookie' 15 | # 数据表用户名(可以用root也可以不用) 16 | USER_NAME: 'root' 17 | # 数据表名称 18 | SQL_NAME: 'suno2api' 19 | # 数据表密码 20 | SQL_PASSWORD: '密码(和下面两个密码相同)' 21 | # mysql 服务ip 22 | SQL_IP: 'mysql' 23 | # mysql服务端口 (下面mysql端口对齐) 24 | SQL_DK: 8002 25 | # cookies管理接口前缀,一定要以/开头 26 | COOKIES_PREFIX: '' 27 | # 请求密钥 28 | AUTH_KEY: '' 29 | # 重试次数(默认5) 30 | RETRIES: 5 31 | # 添加刷新cookies时的批处理数量(默认10) 32 | BATCH_SIZE: 10 33 | # 最大请求时间(默认5min) 34 | MAX_TIME: 5 35 | TZ: Asia/Shanghai 36 | 37 | # 可以用远程或本地数据库 38 | # mysql: 39 | # image: mysql:8.0.37 40 | # ports: 41 | # - 8002:3306 42 | # container_name: mysql 43 | # restart: always 44 | # environment: 45 | # MYSQL_ROOT_PASSWORD: 密码 46 | # MYSQL_DATABASE: suno2api 47 | # MYSQL_USER: root 48 | # MYSQL_PASSWORD: 密码 49 | # volumes: 50 | # - db:/var/lib/mysql:rw 51 | # 52 | #volumes: 53 | # db: 54 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | BASE_URL=https://studio-api.suno.ai 2 | SESSION_ID=cookie(不用管) 3 | PROXY=代理(可选) 4 | SQL_NAME=suno2openai 5 | SQL_PASSWORD=EXsYNiCdY3HtjS2t 6 | SQL_IP=45.207.200.112 7 | SQL_DK=3306 8 | COOKIES_PREFIX=wlhtea 9 | AUTH_KEY=wlhtea 10 | RETRIES=5 11 | BATCH_SIZE=10 12 | MAX_TIME=300 13 | 14 | OpenManager=Ture/False 是否打开管理员账号密码 15 | VALID_USERNAME=wlhtea OpenManager为Ture时才配置管理员账号 否则删除即可 16 | VALID_USERNAME=wlhtea OpenManager为Ture时才配置管理员密码 否则删除即可 -------------------------------------------------------------------------------- /images/后端操作截图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlhtea/Suno2openai/2e638076a4418a3e84fe86884d05233df037af66/images/后端操作截图.png -------------------------------------------------------------------------------- /images/数据库可视化截图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlhtea/Suno2openai/2e638076a4418a3e84fe86884d05233df037af66/images/数据库可视化截图.png -------------------------------------------------------------------------------- /images/背景图.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlhtea/Suno2openai/2e638076a4418a3e84fe86884d05233df037af66/images/背景图.jpg -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import asyncio 3 | import datetime 4 | import json 5 | import time 6 | import warnings 7 | from contextlib import asynccontextmanager 8 | from typing import AsyncGenerator 9 | 10 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 11 | from apscheduler.triggers.interval import IntervalTrigger 12 | from chainlit.utils import mount_chainlit 13 | from fastapi import FastAPI, HTTPException, Query 14 | from fastapi import Header 15 | from fastapi.middleware.cors import CORSMiddleware 16 | from fastapi.responses import JSONResponse 17 | from starlette.responses import StreamingResponse 18 | 19 | from data import schemas 20 | from data.message import response_async 21 | from process import process_cookies 22 | from util.config import (SQL_IP, SQL_DK, USER_NAME, 23 | SQL_PASSWORD, SQL_NAME, COOKIES_PREFIX, 24 | BATCH_SIZE, AUTH_KEY) 25 | from util.logger import logger 26 | from util.sql_uilts import DatabaseManager 27 | from util.tool import generate_random_string_async, generate_timestamp_async 28 | 29 | warnings.filterwarnings("ignore") 30 | 31 | # 从环境变量中获取配置 32 | db_manager = DatabaseManager(SQL_IP, int(SQL_DK), USER_NAME, SQL_PASSWORD, SQL_NAME) 33 | process_cookie = process_cookies.processCookies(SQL_IP, int(SQL_DK), USER_NAME, SQL_PASSWORD, SQL_NAME) 34 | 35 | 36 | # executor = ThreadPoolExecutor(max_workers=300, thread_name_prefix="Music_thread") 37 | 38 | 39 | # 刷新cookies函数 40 | async def cron_refresh_cookies(): 41 | try: 42 | logger.info(f"==========================================") 43 | logger.info("开始刷新数据库里的 cookies.........") 44 | cookies = [item['cookie'] for item in await db_manager.get_cookies()] 45 | total_cookies = len(cookies) 46 | 47 | processed_count = 0 48 | for i in range(0, total_cookies, BATCH_SIZE): 49 | cookie_batch = cookies[i:i + BATCH_SIZE] 50 | for result in process_cookie.refresh_add_cookie(cookie_batch, BATCH_SIZE, False): 51 | if result: 52 | processed_count += 1 53 | 54 | success_percentage = (processed_count / total_cookies) * 100 if total_cookies > 0 else 100 55 | logger.info(f"所有 Cookies 刷新完毕。{processed_count}/{total_cookies} 个成功," 56 | f"成功率:({success_percentage:.2f}%)") 57 | 58 | except Exception as e: 59 | logger.error({"刷新 cookies 出现错误": str(e)}) 60 | 61 | 62 | # 删除无效cookies 63 | async def cron_delete_cookies(): 64 | try: 65 | logger.info("开始删除数据库里的无效cookies.........") 66 | cookies = [item['cookie'] for item in await db_manager.get_invalid_cookies()] 67 | delete_tasks = [] 68 | for cookie in cookies: 69 | delete_tasks.append(db_manager.delete_cookies(cookie)) 70 | 71 | results = await asyncio.gather(*delete_tasks, return_exceptions=True) 72 | success_count = sum(1 for result in results if result is True) 73 | fail_count = len(cookies) - success_count 74 | 75 | logger.info( 76 | {"message": "无效的 Cookies 删除成功。", "成功数量": success_count, "失败数量": fail_count}) 77 | logger.info(f"==========================================") 78 | 79 | except Exception as e: 80 | logger.error({"删除无效 cookies 出现错误": e}) 81 | 82 | 83 | # 先刷新在删除cookies 84 | async def cron_optimize_cookies(): 85 | await cron_refresh_cookies() 86 | await cron_delete_cookies() 87 | 88 | 89 | # 初始化所有songID 90 | async def init_delete_songID(): 91 | try: 92 | rows_updated = await db_manager.delete_songIDS() 93 | logger.info({"message": "Cookies songIDs 更新成功!", "rows_updated": rows_updated}) 94 | except HTTPException as http_exc: 95 | raise http_exc 96 | except Exception as e: 97 | logger.error(f"Unexpected error: {e}") 98 | 99 | 100 | # 生命周期管理 101 | @asynccontextmanager 102 | async def lifespan(app: FastAPI) -> AsyncGenerator: 103 | global db_manager 104 | try: 105 | await db_manager.create_pool() 106 | await db_manager.create_database_and_table() 107 | await init_delete_songID() 108 | logger.info("初始化 SQL 和 songID 成功!") 109 | except Exception as e: 110 | logger.error(f"初始化 SQL 或者 songID 失败: {str(e)}") 111 | raise 112 | 113 | # 初始化并启动 APScheduler 114 | scheduler = AsyncIOScheduler() 115 | scheduler.add_job(cron_optimize_cookies, IntervalTrigger(minutes=60), id='Refresh_and_delete_run') 116 | scheduler.start() 117 | 118 | try: 119 | yield 120 | finally: 121 | # 停止调度器 122 | scheduler.shutdown(wait=True) 123 | # 关闭数据库连接池 124 | await db_manager.close_db_pool() 125 | 126 | 127 | # FastAPI 应用初始化 128 | app = FastAPI(lifespan=lifespan) 129 | 130 | app.add_middleware( 131 | CORSMiddleware, 132 | allow_origins=["*"], 133 | allow_credentials=True, 134 | allow_methods=["*"], 135 | allow_headers=["*"], 136 | ) 137 | 138 | 139 | # mount_chainlit(app=app, target="background/BackManagement.py", path="") 140 | 141 | @app.post("/v1/chat/completions") 142 | async def get_last_user_message(data: schemas.Data, authorization: str = Header(...)): 143 | start_time = time.time() 144 | content_all = '' 145 | if SQL_IP == '' or SQL_PASSWORD == '' or SQL_NAME == '': 146 | raise ValueError("BASE_URL is not set") 147 | 148 | try: 149 | await verify_auth_header(authorization) 150 | except HTTPException as http_exc: 151 | raise http_exc 152 | 153 | try: 154 | chat_id = generate_random_string_async(29) 155 | timeStamp = generate_timestamp_async() 156 | except Exception as e: 157 | raise HTTPException(status_code=400, detail=f"生成聊天 ID 或时间戳时出错: {str(e)}") 158 | 159 | last_user_content = None 160 | for message in reversed(data.messages): 161 | if message.role == "user": 162 | last_user_content = message.content 163 | break 164 | 165 | if last_user_content is None: 166 | raise HTTPException(status_code=400, detail="No user message found") 167 | 168 | headers = { 169 | 'Cache-Control': 'no-cache', 170 | 'Content-Type': 'text/event-stream', 171 | 'Date': datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT'), 172 | 'Server': 'uvicorn', 173 | 'X-Accel-Buffering': 'no', 174 | 'Transfer-Encoding': 'chunked' 175 | } 176 | 177 | try: 178 | # 协程处理 179 | return await response_async(start_time, db_manager, data, content_all, 180 | chat_id, timeStamp, last_user_content, headers) 181 | except HTTPException as http_exc: 182 | raise http_exc 183 | 184 | # 线程处理 185 | # try: 186 | # future = executor.submit(start_time, request_chat, db_manager, data, content_all, chat_id, timeStamp, 187 | # last_user_content, headers) 188 | # return future.result() 189 | # except Exception as e: 190 | # raise HTTPException(status_code=500, detail=str(e)) 191 | 192 | 193 | # 授权检查 194 | async def verify_auth_header(authorization: str = Header(...)): 195 | if not authorization.startswith("Bearer "): 196 | raise HTTPException(status_code=401, detail="Authorization header missing or invalid") 197 | if authorization.strip() != f"Bearer {AUTH_KEY}": 198 | raise HTTPException(status_code=403, detail="Invalid authorization key") 199 | 200 | 201 | # 获取cookies的详细详细 202 | @app.get(f"/{COOKIES_PREFIX}/cookies") 203 | async def get_cookies(authorization: str = Header(...), cookies_type: str = Query(None)): 204 | try: 205 | await verify_auth_header(authorization) 206 | 207 | if cookies_type == "list": 208 | cookies = await db_manager.get_row_cookies() 209 | if not isinstance(cookies, list): 210 | cookies = [] 211 | return JSONResponse( 212 | content={ 213 | "cookies": cookies, 214 | "status": "success", 215 | "message": "Cookies retrieved successfully" 216 | } 217 | ) 218 | else: 219 | cookies = await db_manager.get_all_cookies() 220 | cookies_json = json.loads(cookies) 221 | valid_cookie_count = int(await db_manager.get_valid_cookies_count()) 222 | invalid_cookie_count = len(cookies_json) - valid_cookie_count 223 | remaining_count = int(await db_manager.get_cookies_count()) 224 | 225 | if remaining_count is None: 226 | remaining_count = 0 227 | 228 | logger.info({"message": "Cookies 获取成功。", "数量": len(cookies_json)}) 229 | logger.info("有效数量: " + str(valid_cookie_count)) 230 | logger.info("无效数量: " + str(invalid_cookie_count)) 231 | logger.info("剩余创作音乐次数: " + str(remaining_count)) 232 | 233 | return JSONResponse( 234 | content={ 235 | "cookie_count": len(cookies_json), 236 | "valid_cookie_count": valid_cookie_count, 237 | "invalid_cookie_count": invalid_cookie_count, 238 | "remaining_count": remaining_count, 239 | "process": cookies_json 240 | } 241 | ) 242 | except HTTPException as http_exc: 243 | raise http_exc 244 | except Exception as e: 245 | logger.error(f"Unexpected error: {e}") 246 | return JSONResponse(status_code=500, content={"error": str(e)}) 247 | 248 | 249 | @app.put(f"/{COOKIES_PREFIX}/cookies") 250 | async def add_cookies(data: schemas.Cookies, authorization: str = Header(...)): 251 | try: 252 | await verify_auth_header(authorization) 253 | logger.info(f"==========================================") 254 | logger.info("开始添加数据库里的 cookies.........") 255 | cookies = data.cookies 256 | total_cookies = len(cookies) 257 | 258 | async def stream_results(cookies,total_cookies): 259 | processed_count = 0 260 | for i in range(0, total_cookies, BATCH_SIZE): 261 | cookie_batch = cookies[i:i + BATCH_SIZE] 262 | for result in process_cookie.refresh_add_cookie(cookie_batch, BATCH_SIZE, False): 263 | if result: 264 | processed_count += 1 265 | # yield f"data: Cookie {processed_count}/{total_cookies} 添加成功!\n\n" 266 | else: 267 | logger.info(f"data: Cookie {processed_count}/{total_cookies} 添加失败!\n\n") 268 | # yield f"data: Cookie {processed_count}/{total_cookies} 添加失败!\n\n" 269 | 270 | success_percentage = (processed_count / total_cookies) * 100 if total_cookies > 0 else 100 271 | logger.info(f"所有 Cookies 添加完毕。{processed_count}/{total_cookies} 个成功," 272 | f"成功率:({success_percentage:.2f}%)") 273 | logger.info(f"==========================================") 274 | # yield f"data: 所有 Cookies 添加完毕。{processed_count}/{total_cookies} 个成功,成功率:({success_percentage:.2f}%)\n\n" 275 | # yield f"""data:""" + ' ' + f"""[DONE]\n\n""" 276 | return f"所有 Cookies 添加完毕。{processed_count}/{total_cookies} 个成功,成功率:({success_percentage:.2f}%)" 277 | 278 | # return StreamingResponse(stream_results(cookies,total_cookies), media_type="text/event-stream") 279 | # return JSONResponse({"message":stream_results(cookies,total_cookies)},status_code=200) 280 | messageResult = await stream_results(cookies, total_cookies) 281 | return JSONResponse({"messages":messageResult},status_code=200) 282 | 283 | except HTTPException as http_exc: 284 | raise http_exc 285 | except Exception as e: 286 | logger.error({"添加cookies出现错误": str(e)}) 287 | return JSONResponse(status_code=500, content={"添加cookies出现错误": str(e)}) 288 | 289 | 290 | # 删除cookie 291 | @app.delete(f"/{COOKIES_PREFIX}/cookies") 292 | async def delete_cookies(data: schemas.Cookies, authorization: str = Header(...)): 293 | try: 294 | await verify_auth_header(authorization) 295 | cookies = data.cookies 296 | delete_tasks = [] 297 | for cookie in cookies: 298 | delete_tasks.append(db_manager.delete_cookies(cookie)) 299 | 300 | results = await asyncio.gather(*delete_tasks, return_exceptions=True) 301 | success_count = sum(1 for result in results if result is True) 302 | fail_count = len(cookies) - success_count 303 | 304 | return JSONResponse( 305 | content={"message": "Cookies 成功删除!", "success_count": success_count, 306 | "fail_count": fail_count}) 307 | except HTTPException as http_exc: 308 | raise http_exc 309 | except Exception as e: 310 | return JSONResponse(status_code=500, content={"error": e}) 311 | 312 | 313 | # 请求刷新cookies 314 | @app.get(f"/{COOKIES_PREFIX}/refresh/cookies") 315 | async def refresh_cookies(authorization: str = Header(...)): 316 | try: 317 | await verify_auth_header(authorization) 318 | logger.info(f"==========================================") 319 | logger.info("开始刷新数据库里的 cookies.........") 320 | cookies = [item['cookie'] for item in await db_manager.get_cookies()] 321 | total_cookies = len(cookies) 322 | 323 | async def stream_results(): 324 | processed_count = 0 325 | for i in range(0, total_cookies, BATCH_SIZE): 326 | cookie_batch = cookies[i:i + BATCH_SIZE] 327 | for result in process_cookie.refresh_add_cookie(cookie_batch, BATCH_SIZE, False): 328 | if result: 329 | processed_count += 1 330 | # yield f"data: Cookie {processed_count}/{total_cookies} 刷新成功!\n\n" 331 | else: 332 | # yield f"data: Cookie {processed_count}/{total_cookies} 刷新失败!\n\n" 333 | logger.info(f"data: Cookie {processed_count}/{total_cookies} 刷新失败!\n\n") 334 | success_percentage = (processed_count / total_cookies) * 100 if total_cookies > 0 else 100 335 | logger.info(f"所有 Cookies 刷新完毕。{processed_count}/{total_cookies} 个成功," 336 | f"成功率:({success_percentage:.2f}%)") 337 | logger.info(f"==========================================") 338 | # yield f"data: 所有 Cookies 刷新完毕。{processed_count}/{total_cookies} 个成功,成功率:({success_percentage:.2f}%)\n\n" 339 | # yield f"""data:""" + ' ' + f"""[DONE]\n\n""" 340 | return f"data: 所有 Cookies 刷新完毕。{processed_count}/{total_cookies} 个成功,成功率:({success_percentage:.2f}%)\n\n" 341 | 342 | 343 | messgaesResultRefresh = await stream_results() 344 | # return StreamingResponse(stream_results(), media_type="text/event-stream") 345 | return JSONResponse({"messages":messgaesResultRefresh},status_code=200) 346 | except HTTPException as http_exc: 347 | raise http_exc 348 | except Exception as e: 349 | logger.error({"刷新cookies出现错误": str(e)}) 350 | return JSONResponse(status_code=500, content={"刷新cookies出现错误": str(e)}) 351 | 352 | 353 | # 删除cookie 354 | @app.delete(f"/{COOKIES_PREFIX}/refresh/cookies") 355 | async def delete_invalid_cookies(authorization: str = Header(...)): 356 | try: 357 | await verify_auth_header(authorization) 358 | logger.info(f"==========================================") 359 | logger.info("开始删除数据库里的无效cookies.........") 360 | cookies = [item['cookie'] for item in await db_manager.get_invalid_cookies()] 361 | delete_tasks = [] 362 | for cookie in cookies: 363 | delete_tasks.append(db_manager.delete_cookies(cookie)) 364 | 365 | results = await asyncio.gather(*delete_tasks, return_exceptions=True) 366 | success_count = sum(1 for result in results if result is True) 367 | fail_count = len(cookies) - success_count 368 | 369 | logger.info( 370 | {"message": "Invalid cookies 删除成功。", "成功数量": success_count, "失败数量": fail_count}) 371 | logger.info(f"==========================================") 372 | return JSONResponse( 373 | content={"message": "无效的cookies删除成功!", "success_count": success_count, 374 | "fail_count": fail_count}) 375 | except HTTPException as http_exc: 376 | raise http_exc 377 | except Exception as e: 378 | return JSONResponse(status_code=500, content={"error": e}) 379 | 380 | 381 | # 获取cookies的详细详细 382 | @app.delete(f"/{COOKIES_PREFIX}/songID/cookies") 383 | async def delete_songID(authorization: str = Header(...)): 384 | try: 385 | await verify_auth_header(authorization) 386 | rows_updated = await db_manager.delete_songIDS() 387 | return JSONResponse( 388 | content={"message": "Cookies songIDs 更新成功!", "rows_updated": rows_updated} 389 | ) 390 | except HTTPException as http_exc: 391 | raise http_exc 392 | except Exception as e: 393 | logger.error(f"Unexpected error: {e}") 394 | return JSONResponse(status_code=500, content={"error": str(e)}) 395 | 396 | -------------------------------------------------------------------------------- /process/process_cookies.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from concurrent.futures import ThreadPoolExecutor, as_completed 3 | 4 | from suno.suno import SongsGen 5 | from util.logger import logger 6 | from util.sql_uilts import DatabaseManager 7 | 8 | 9 | class processCookies: 10 | def __init__(self, sql_IP, sql_dk, user_name, sql_password, sql_name): 11 | self.host = sql_IP 12 | self.port = sql_dk 13 | self.user = user_name 14 | self.password = sql_password 15 | self.db_name = sql_name 16 | 17 | @staticmethod 18 | # 异步任务, 添加或刷新cookie 19 | async def cookies_task(db_manage, cookie, is_insert): 20 | tem_word = "添加" if is_insert else "刷新" 21 | remaining_count = -1 22 | song_gen = None 23 | try: 24 | song_gen = SongsGen(cookie) 25 | remaining_count = await song_gen.get_limit_left() 26 | if remaining_count == -1 and is_insert: 27 | logger.info(f"该账号剩余次数: {remaining_count},添加失败!") 28 | return False 29 | else: 30 | await db_manage.insert_or_update_cookie(cookie=cookie, count=remaining_count) 31 | return True 32 | except Exception as e: 33 | if not is_insert: 34 | await db_manage.insert_or_update_cookie(cookie=cookie, count=remaining_count) 35 | logger.error(f"{tem_word}成功,已将改cookie禁用:{e}") 36 | return False 37 | else: 38 | raise RuntimeError(f"该账号剩余次数: {remaining_count},添加失败") 39 | finally: 40 | if song_gen is not None: 41 | await song_gen.close_session() 42 | 43 | # 在当前线程的事件循环中运行任务添加或刷新cookie 44 | def fetch_limit_left_async(self, cookie, is_insert): 45 | db_manage = DatabaseManager(self.host, self.port, self.user, self.password, self.db_name) 46 | result = False 47 | loop = asyncio.new_event_loop() 48 | try: 49 | asyncio.set_event_loop(loop) 50 | result = loop.run_until_complete(self.cookies_task(db_manage, cookie, is_insert)) 51 | except Exception as e: 52 | result = False 53 | logger.error(str(e) + ":" + cookie) 54 | finally: 55 | loop.run_until_complete(db_manage.close_db_pool()) 56 | loop.close() 57 | return result 58 | 59 | # 添加或刷新cookie,多线程 60 | def refresh_add_cookie(self, cookies, batch_size, is_insert): 61 | # 使用 ThreadPoolExecutor 管理多线程 62 | try: 63 | with ThreadPoolExecutor(max_workers=batch_size) as executor: 64 | # 提交任务到线程池 65 | futures = [ 66 | executor.submit(self.fetch_limit_left_async, str(cookie).strip(), is_insert) for cookie in cookies] 67 | results = [] 68 | # 获取并处理结果 69 | for future in as_completed(futures): 70 | results.append(future.result()) 71 | return results 72 | except Exception as e: 73 | logger.error(f"refresh_add_cookie提交线程失败:{e}") 74 | return None 75 | 76 | # 添加cookie的函数 77 | # @staticmethod 78 | # async def fetch_limit_left(db_manager, cookie, is_insert: bool = False): 79 | # tem_word = "添加" if is_insert else "刷新" 80 | # song_gen = None 81 | # try: 82 | # song_gen = SongsGen(cookie) 83 | # remaining_count = await song_gen.get_limit_left() 84 | # if remaining_count == -1 and is_insert: 85 | # logger.info(f"该账号剩余次数: {remaining_count},添加失败!") 86 | # return False 87 | # logger.info(f"该账号剩余次数: {remaining_count},{tem_word}成功!") 88 | # await db_manager.insert_or_update_cookie(cookie=cookie, count=remaining_count) 89 | # return True 90 | # except Exception as e: 91 | # logger.error(cookie + f",{tem_word}失败:{e}") 92 | # return False 93 | # finally: 94 | # await song_gen.close_session() 95 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlhtea/Suno2openai/2e638076a4418a3e84fe86884d05233df037af66/public/favicon.png -------------------------------------------------------------------------------- /public/logo_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlhtea/Suno2openai/2e638076a4418a3e84fe86884d05233df037af66/public/logo_dark.png -------------------------------------------------------------------------------- /public/logo_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlhtea/Suno2openai/2e638076a4418a3e84fe86884d05233df037af66/public/logo_light.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="suno2openai", 5 | version="0.2.1", 6 | packages=find_packages(), 7 | install_requires=[ 8 | "aiohttp", 9 | "fake-useragent", 10 | "python-dotenv", 11 | ], 12 | ) -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 启动 Python 应用程序 4 | python app.py & 5 | 6 | # 启动 Streamlit 应用程序 7 | streamlit run background/main.py 8 | -------------------------------------------------------------------------------- /suno/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Suno AI API Client 3 | """ 4 | 5 | from .suno import SongsGen 6 | from .constants import CLERK_API_VERSION, CLERK_JS_VERSION, URLs, CaptchaConfig, DEFAULT_HEADERS 7 | 8 | __version__ = "0.2.1" 9 | __all__ = ["SongsGen", "CLERK_API_VERSION", "CLERK_JS_VERSION", "URLs", "CaptchaConfig", "DEFAULT_HEADERS"] -------------------------------------------------------------------------------- /suno/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | 常量配置文件 3 | 包含API版本、URL端点、站点密钥等配置信息 4 | """ 5 | 6 | from typing import Dict, List 7 | 8 | # API 版本 9 | CLERK_API_VERSION = "2024-10-01" 10 | CLERK_JS_VERSION = "5.43.6" 11 | 12 | # 重试配置 13 | MAX_RETRIES = 3 14 | RETRY_DELAY = 2 # seconds 15 | TOKEN_REFRESH_STATUS_CODES = [401, 403] # 需要刷新token的状态码 16 | RETRIABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504] # 可以重试的状态码 17 | 18 | # 任务状态 19 | class TaskStatus: 20 | """任务状态常量""" 21 | SUBMITTED = "submitted" 22 | PROCESSING = "processing" 23 | COMPLETE = "complete" 24 | FAILED = "failed" 25 | ERROR = "error" 26 | 27 | # 终态状态列表 28 | FINAL_STATES = [COMPLETE, FAILED, ERROR] 29 | # 可以重试的状态 30 | RETRIABLE_STATES = [FAILED, ERROR] 31 | 32 | # API 端点 33 | class URLs: 34 | """所有API端点的URL常量""" 35 | CLERK_BASE = "https://clerk.suno.com" 36 | SUNO_BASE = "https://studio-api.prod.suno.com" 37 | 38 | # Clerk认证相关 39 | VERIFY = f"{CLERK_BASE}/v1/client/verify" 40 | EXCHANGE_TOKEN = f"{CLERK_BASE}/v1/client/sessions/{{sid}}/tokens" 41 | 42 | # Suno API 43 | BILLING_INFO = f"{SUNO_BASE}/api/billing/info/" 44 | PLAYLIST = f"{SUNO_BASE}/api/playlist/{{playlist_id}}/" 45 | FEED = f"{SUNO_BASE}/api/feed/v2" 46 | 47 | # Captcha相关 48 | CAPSOLVER_CREATE = "https://api.capsolver.com/createTask" 49 | CAPSOLVER_RESULT = "https://api.capsolver.com/getTaskResult" 50 | 51 | # Check 52 | CHECK = f"{SUNO_BASE}/api/c/check" 53 | 54 | # Captcha配置 55 | class CaptchaConfig: 56 | """Captcha相关配置""" 57 | SITE_KEYS: List[str] = [ 58 | "0x4AAAAAAAFV93qQdS0ycilX" 59 | ] 60 | 61 | SITE_URLS: List[str] = [ 62 | f"{URLs.CLERK_BASE}/cloudflare/turnstile/v0/api.js?" 63 | f"render=explicit&" 64 | f"__clerk_api_version={CLERK_API_VERSION}&" 65 | f"_clerk_js_version={CLERK_JS_VERSION}" 66 | ] 67 | 68 | # 默认请求头 69 | DEFAULT_HEADERS: Dict[str, str] = { 70 | "accept": "*/*", 71 | "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", 72 | "content-type": "application/x-www-form-urlencoded", 73 | "origin": "https://suno.com", 74 | "referer": "https://suno.com/", 75 | "sec-ch-ua": '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A_Brand";v="24"', 76 | "cache-control": "no-cache", 77 | "pragma": "no-cache", 78 | "sec-ch-ua-mobile": "?0", 79 | "sec-ch-ua-platform": '"Windows"', 80 | "sec-fetch-dest": "empty", 81 | "sec-fetch-mode": "cors", 82 | "sec-fetch-site": "same-site" 83 | } 84 | 85 | # 音乐类型列表 86 | MUSIC_GENRES: List[str] = [ 87 | "African", "Asian", "South and southeast Asian", "Avant-garde", 88 | "Blues", "Caribbean and Caribbean-influenced", "Comedy", "Country", 89 | "Easy listening", "Electronic", "Folk", "Hip hop", "Jazz", 90 | "Latin", "Pop", "R&B and soul", "Rock", 91 | ] -------------------------------------------------------------------------------- /suno/http_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | HTTP客户端模块 3 | 处理所有HTTP请求、验证码和会话管理 4 | """ 5 | 6 | import aiohttp 7 | from typing import Optional, Dict, Any, Callable 8 | from util.logger import logger 9 | from .constants import URLs, CLERK_API_VERSION, CLERK_JS_VERSION 10 | 11 | class HttpClient: 12 | """ 13 | 异步HTTP客户端 14 | 处理请求、验证码和会话管理 15 | """ 16 | 17 | def __init__( 18 | self, 19 | base_headers: Dict[str, str], 20 | cookies: Dict[str, str], 21 | proxy: Optional[str] = None 22 | ): 23 | """ 24 | 初始化HTTP客户端 25 | 26 | Args: 27 | base_headers: 基础请求头 28 | cookies: Cookie字典 29 | proxy: 代理服务器URL(可选) 30 | """ 31 | self.base_headers = base_headers 32 | self.cookies = cookies 33 | self.proxy = proxy 34 | self.captcha_handler: Optional[Callable] = None 35 | 36 | def set_captcha_handler(self, handler: Callable) -> None: 37 | """ 38 | 设置验证码处理器 39 | 40 | Args: 41 | handler: 处理验证码的回调函数 42 | """ 43 | self.captcha_handler = handler 44 | 45 | async def close(self) -> None: 46 | """关闭客户端会话""" 47 | pass 48 | 49 | async def handle_401_response(self, url: str) -> Optional[Dict[str, Any]]: 50 | """ 51 | 处理401响应,获取验证码token并重试请求 52 | 53 | Args: 54 | url: 请求URL 55 | 56 | Returns: 57 | 响应数据字典或None(失败时) 58 | """ 59 | if not self.captcha_handler: 60 | logger.error("No captcha handler set") 61 | return None 62 | 63 | try: 64 | captcha_token = await self.captcha_handler(0, self.cookies) 65 | if not captcha_token: 66 | logger.error("Failed to get captcha token") 67 | return None 68 | 69 | # 准备验证码请求 70 | headers = { 71 | **self.base_headers, 72 | "accept": "*/*", 73 | "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", 74 | "content-type": "application/x-www-form-urlencoded", 75 | } 76 | 77 | form_data = f'captcha_token={captcha_token}&captcha_widget_type=invisible' 78 | 79 | async with aiohttp.ClientSession(cookies=self.cookies) as session: 80 | async with session.post( 81 | url, 82 | headers=headers, 83 | data=form_data, 84 | params={ 85 | "__clerk_api_version": CLERK_API_VERSION, 86 | "_clerk_js_version": CLERK_JS_VERSION 87 | } 88 | ) as response: 89 | if response.status >= 400: 90 | logger.error(f"Captcha request failed with status {response.status}") 91 | return None 92 | 93 | try: 94 | return await response.json() 95 | except Exception as e: 96 | logger.error(f"Failed to parse captcha response: {e}") 97 | return None 98 | 99 | except Exception as e: 100 | logger.error(f"Error during captcha request: {e}") 101 | return None 102 | 103 | async def request( 104 | self, 105 | method: str, 106 | url: str, 107 | **kwargs 108 | ) -> Optional[Dict[str, Any]]: 109 | """ 110 | 发送HTTP请求 111 | 112 | Args: 113 | method: HTTP方法 114 | url: 请求URL 115 | **kwargs: 传递给aiohttp的其他参数 116 | 117 | Returns: 118 | 响应数据字典或None(失败时) 119 | """ 120 | try: 121 | headers = {**self.base_headers} 122 | if 'headers' in kwargs: 123 | headers.update(kwargs.pop('headers')) 124 | 125 | async with aiohttp.ClientSession(cookies=self.cookies) as session: 126 | async with session.request( 127 | method, 128 | url, 129 | headers=headers, 130 | **kwargs 131 | ) as response: 132 | if response.status == 401: 133 | logger.info("Got 401, trying with captcha...") 134 | result = await self.handle_401_response(url) 135 | if result: 136 | return result 137 | logger.error("Failed to handle 401 response") 138 | return None 139 | 140 | if response.status >= 400: 141 | logger.error(f"Request failed with status {response.status}") 142 | return None 143 | 144 | return await response.json() 145 | 146 | except Exception as e: 147 | logger.error(f"Request failed: {e}") 148 | return None 149 | 150 | async def request_with_headers( 151 | self, 152 | method: str, 153 | url: str, 154 | **kwargs 155 | ) -> Optional[tuple[Dict[str, Any], Dict[str, str]]]: 156 | """ 157 | 发送HTTP请求并返回响应数据和头信息 158 | 159 | Args: 160 | method: HTTP方法 161 | url: 请求URL 162 | **kwargs: 传递给aiohttp的其他参数 163 | 164 | Returns: 165 | (响应数据字典, 响应头字典)元组或None(失败时) 166 | """ 167 | try: 168 | headers = {**self.base_headers} 169 | if 'headers' in kwargs: 170 | headers.update(kwargs.pop('headers')) 171 | 172 | async with aiohttp.ClientSession(cookies=self.cookies) as session: 173 | async with session.request( 174 | method, 175 | url, 176 | headers=headers, 177 | **kwargs 178 | ) as response: 179 | if response.status == 401: 180 | logger.info("Got 401, trying with captcha...") 181 | result = await self.handle_401_response(url) 182 | if result: 183 | return result, dict(response.headers) 184 | logger.error("Failed to handle 401 response") 185 | return None 186 | 187 | if response.status >= 400: 188 | logger.error(f"Request failed with status {response.status}") 189 | return None 190 | 191 | return await response.json(), dict(response.headers) 192 | 193 | except Exception as e: 194 | logger.error(f"Request failed: {e}") 195 | return None -------------------------------------------------------------------------------- /suno/suno.py: -------------------------------------------------------------------------------- 1 | """ 2 | Suno AI API Client 3 | 主要类实现 4 | """ 5 | 6 | from typing import Optional, Dict, Tuple, List 7 | from fake_useragent import UserAgent 8 | from util.logger import logger 9 | from util import utils 10 | from util.config import PROXY 11 | from .constants import ( 12 | URLs, DEFAULT_HEADERS, CaptchaConfig, 13 | CLERK_API_VERSION, CLERK_JS_VERSION, 14 | MAX_RETRIES, RETRY_DELAY, 15 | TOKEN_REFRESH_STATUS_CODES, 16 | RETRIABLE_STATUS_CODES, 17 | TaskStatus 18 | ) 19 | from .http_client import HttpClient 20 | import asyncio 21 | import aiohttp 22 | import uuid 23 | import json 24 | 25 | class SongsGen: 26 | def __init__(self, cookie: str, capsolver_apikey: str, device_id: str = None) -> None: 27 | if not cookie or not capsolver_apikey: 28 | raise ValueError("Cookie and capsolver_apikey are required") 29 | 30 | self.ua = UserAgent(browsers=["edge"]) 31 | self.base_headers = { 32 | **DEFAULT_HEADERS, 33 | "user-agent": self.ua.edge, 34 | } 35 | 36 | self.capsolver_apikey = capsolver_apikey 37 | 38 | # 设置私有的设备ID 39 | self._device_id = device_id or "5ee4b1c9-c17e-4e25-b913-8ce57ba43a54" 40 | 41 | # 解析并设置cookie 42 | if isinstance(cookie, str): 43 | self.cookie_dict = utils.parse_cookie_string(cookie) 44 | elif isinstance(cookie, dict): 45 | self.cookie_dict = cookie 46 | else: 47 | raise ValueError("Cookie must be a string or dictionary") 48 | 49 | if not self.cookie_dict: 50 | raise ValueError("Invalid cookie format") 51 | 52 | 53 | # Initialize HTTP clients 54 | self.token_client = HttpClient(self.base_headers, self.cookie_dict, PROXY) 55 | self.request_client = HttpClient(self.base_headers, self.cookie_dict, PROXY) 56 | 57 | # 设置验证码处理器 58 | self.token_client.set_captcha_handler(self.get_captcha_token) 59 | self.request_client.set_captcha_handler(self.get_captcha_token) 60 | 61 | # Clerk认证相关的实例变量 62 | self.auth_token: Optional[str] = None 63 | self.clerk_session_id: Optional[str] = None # 用于URL的session id 64 | self.user_id: Optional[str] = None 65 | self.session_expire_at: Optional[int] = None 66 | 67 | # 通知相关的实例变量 68 | self.notification_session_id: Optional[str] = None # 用于请求头的session id 69 | 70 | # Token缓存 71 | self._token_cache = {} # {clip_id: auth_token} 72 | 73 | self._closed = False 74 | 75 | async def __aenter__(self): 76 | """异步上下文管理器入口""" 77 | if self._closed: 78 | raise RuntimeError("SongsGen instance is closed") 79 | 80 | try: 81 | await self.init_clients() 82 | return self 83 | except Exception as e: 84 | logger.error(f"Failed to initialize SongsGen: {e}") 85 | await self.close() 86 | raise 87 | 88 | async def __aexit__(self, exc_type, exc_val, exc_tb): 89 | """异步上下文管理器退出""" 90 | try: 91 | await self.close() 92 | except Exception as e: 93 | logger.error(f"Error during cleanup: {e}") 94 | raise 95 | 96 | async def init_clients(self): 97 | """Initialize HTTP clients and get auth token""" 98 | if self._closed: 99 | raise RuntimeError("SongsGen instance is closed") 100 | 101 | try: 102 | # 只在auth_token为None时获取token 103 | if self.auth_token is None: 104 | self.auth_token = await self.get_auth_token() 105 | if self.auth_token: 106 | self.request_client.base_headers["Authorization"] = f"Bearer {self.auth_token}" 107 | else: 108 | raise RuntimeError("Failed to obtain auth token") 109 | else: 110 | logger.info("Auth token already exists, skipping token request") 111 | except Exception as e: 112 | logger.error(f"Failed to initialize clients: {e}") 113 | raise 114 | 115 | async def close(self): 116 | """Close all HTTP clients""" 117 | self._closed = True 118 | try: 119 | await asyncio.gather( 120 | self.token_client.close(), 121 | self.request_client.close(), 122 | return_exceptions=True 123 | ) 124 | except Exception as e: 125 | logger.error(f"Error during close: {e}") 126 | raise 127 | 128 | async def get_auth_token(self) -> Optional[str]: 129 | """Get authentication token with retry and CAPTCHA handling""" 130 | if self._closed: 131 | raise RuntimeError("SongsGen instance is closed") 132 | 133 | try: 134 | params = { 135 | "__clerk_api_version": CLERK_API_VERSION, 136 | "_clerk_js_version": CLERK_JS_VERSION, 137 | } 138 | 139 | # 不包含认证token的请求头 140 | headers = self._get_common_headers(include_auth=False) 141 | headers.update({ 142 | "content-type": "application/x-www-form-urlencoded", 143 | }) 144 | 145 | # 现在请求会自动处理401和验证码 146 | response = await self.token_client.request( 147 | "POST", 148 | URLs.VERIFY, 149 | params=params, 150 | headers=headers 151 | ) 152 | 153 | # 检查响应结构 154 | if not response: 155 | logger.error("Empty response received from token request") 156 | return None 157 | 158 | # 验证响应中是否包含必要的字段 159 | if not isinstance(response, dict): 160 | logger.error(f"Unexpected response type: {type(response)}") 161 | return None 162 | 163 | # 提取会话信息 164 | sessions = response.get('sessions', []) 165 | if not sessions: 166 | logger.error("No sessions found in response") 167 | return None 168 | 169 | session = sessions[0] # 获取第一个会话 170 | session_id = session.get('id') 171 | session_status = session.get('status') 172 | expire_at = session.get('expire_at') 173 | 174 | # 提取用户信息 175 | user = session.get('user', {}) 176 | user_id = user.get('id') 177 | email = user.get('email_addresses', [{}])[0].get('email_address') 178 | name = f"{user.get('first_name', '')} {user.get('last_name', '')}".strip() 179 | 180 | # 记录重要信息 181 | logger.info(f"Session ID: {session_id}") 182 | logger.info(f"Session Status: {session_status}") 183 | logger.info(f"Session Expires: {expire_at}") 184 | logger.info(f"User ID: {user_id}") 185 | logger.info(f"User Email: {email}") 186 | logger.info(f"User Name: {name}") 187 | 188 | # 获取JWT令牌 189 | last_active_token = session.get('last_active_token', {}) 190 | if not isinstance(last_active_token, dict): 191 | logger.error("Invalid token format in response") 192 | return None 193 | 194 | jwt = last_active_token.get('jwt') 195 | if not jwt: 196 | logger.error("No JWT token found in response") 197 | return None 198 | 199 | # 保存重要信息到实例变量 200 | self.clerk_session_id = session_id 201 | self.user_id = user_id 202 | self.session_expire_at = expire_at 203 | 204 | return jwt # 返回JWT token而不是整个response 205 | 206 | except Exception as e: 207 | logger.error(f"Failed to get auth token: {e}") 208 | return None 209 | 210 | async def verify(self) -> Optional[Dict]: 211 | """Verify session by touching the session endpoint""" 212 | if self._closed: 213 | raise RuntimeError("SongsGen instance is closed") 214 | 215 | if not self.clerk_session_id: 216 | logger.error("No session ID available") 217 | return None 218 | 219 | try: 220 | params = { 221 | "__clerk_api_version": CLERK_API_VERSION, 222 | "_clerk_js_version": CLERK_JS_VERSION, 223 | } 224 | 225 | url = f"{URLs.CLERK_BASE}/v1/client/sessions/{self.clerk_session_id}/touch" 226 | 227 | response = await self.request_client.request( 228 | "POST", 229 | url, 230 | params=params, 231 | headers={ 232 | "accept": "*/*", 233 | "accept-language": "en-US,en;q=0.9", 234 | "content-type": "application/x-www-form-urlencoded", 235 | "origin": "https://suno.com", 236 | "referer": "https://suno.com/", 237 | } 238 | ) 239 | 240 | if not isinstance(response, dict): 241 | logger.error(f"Unexpected response type: {type(response)}") 242 | return None 243 | 244 | return response 245 | 246 | except Exception as e: 247 | logger.error(f"Failed to verify session: {e}") 248 | return None 249 | 250 | async def get_limit_left(self) -> int: 251 | """Get remaining credits with retry""" 252 | if self._closed: 253 | raise RuntimeError("SongsGen instance is closed") 254 | 255 | try: 256 | response = await self.request_client.request("GET", URLs.BILLING_INFO) 257 | if not isinstance(response, dict): 258 | logger.error(f"Unexpected response type: {type(response)}") 259 | return -1 260 | 261 | credits = response.get("total_credits_left", 0) 262 | if not isinstance(credits, (int, float)): 263 | logger.error(f"Invalid credits value: {credits}") 264 | return -1 265 | 266 | return int(credits / 10) 267 | except Exception as e: 268 | logger.error(f"Failed to get remaining credits: {e}") 269 | return -1 270 | 271 | async def generate_music( 272 | self, 273 | token: str = None, 274 | prompt: str = None, 275 | title: str = None, 276 | tags: str = "", 277 | negative_tags: str = "", 278 | mv: str = "chirp-v4", 279 | generation_type: str = "TEXT", 280 | continue_clip_id: str = None, 281 | continue_at: int = None, 282 | infill_start_s: int = None, 283 | infill_end_s: int = None, 284 | task: str = None, 285 | artist_clip_id: str = None, 286 | artist_start_s: int = None, 287 | artist_end_s: int = None, 288 | metadata: dict = None 289 | ) -> Tuple[Optional[Dict], Optional[str]]: 290 | """ 291 | Generate music using the Suno API 292 | 293 | Args: 294 | prompt: The lyrics or text prompt for the music 295 | title: The title of the song 296 | tags: Comma-separated list of music style tags 297 | negative_tags: Comma-separated list of tags to avoid 298 | mv: Model version to use 299 | generation_type: Type of generation (TEXT, CONTINUE, etc) 300 | continue_clip_id: ID of clip to continue from 301 | continue_at: Timestamp to continue from 302 | infill_start_s: Start time for infill 303 | infill_end_s: End time for infill 304 | task: Task type 305 | artist_clip_id: Artist clip ID 306 | artist_start_s: Artist clip start time 307 | artist_end_s: Artist clip end time 308 | metadata: Additional metadata 309 | 310 | Returns: 311 | Tuple of (response data or None, error message or None) 312 | """ 313 | if self._closed: 314 | raise RuntimeError("SongsGen instance is closed") 315 | 316 | if not self.auth_token: 317 | logger.error("No auth token available") 318 | return None, "Authentication token not available" 319 | 320 | try: 321 | # Prepare request data 322 | data = { 323 | "token": token, 324 | "prompt": prompt, 325 | "generation_type": generation_type, 326 | "tags": tags, 327 | "negative_tags": negative_tags, 328 | "mv": mv, 329 | "title": title, 330 | "continue_clip_id": continue_clip_id, 331 | "continue_at": continue_at, 332 | "continued_aligned_prompt": None, 333 | "infill_start_s": infill_start_s, 334 | "infill_end_s": infill_end_s, 335 | "task": task, 336 | "artist_clip_id": artist_clip_id, 337 | "artist_start_s": artist_start_s, 338 | "artist_end_s": artist_end_s, 339 | "metadata": metadata or {"lyrics_model": "default"}, 340 | "make_instrumental": False, 341 | "user_uploaded_images_b64": [], 342 | } 343 | 344 | # Get headers and add content-type 345 | headers = self._get_common_headers() 346 | headers["content-type"] = "text/plain;charset=UTF-8" 347 | 348 | logger.info("Making request with headers:") 349 | for key, value in headers.items(): 350 | logger.info(f"{key}: {value}") 351 | 352 | logger.info("Request data:") 353 | logger.info(data) 354 | 355 | # Make request 356 | async with aiohttp.ClientSession() as session: 357 | try: 358 | async with session.post( 359 | f"{URLs.SUNO_BASE}/api/generate/v2/", 360 | headers=headers, 361 | json=data 362 | ) as response: 363 | # Log response headers 364 | logger.info("Response headers:") 365 | for key, value in response.headers.items(): 366 | logger.info(f"{key}: {value}") 367 | 368 | # Get response content 369 | content_type = response.headers.get('content-type', '') 370 | logger.info(f"Response content type: {content_type}") 371 | 372 | # Read raw response text first 373 | text = await response.text() 374 | logger.info("Raw response text:") 375 | logger.info(text) 376 | 377 | # Check status code 378 | if response.status != 200: 379 | logger.error("-" * 50) 380 | logger.error(f"Request failed with status {response.status} ({response.reason})") 381 | logger.error("-" * 50) 382 | logger.error("Response headers:") 383 | for key, value in response.headers.items(): 384 | logger.error(f"{key}: {value}") 385 | logger.error("-" * 50) 386 | logger.error("Response text:") 387 | logger.error(text) 388 | logger.error("-" * 50) 389 | logger.error("Request URL:") 390 | logger.error(f"{URLs.SUNO_BASE}/api/generate/v2/") 391 | logger.error("-" * 50) 392 | logger.error("Request headers:") 393 | for key, value in headers.items(): 394 | logger.error(f"{key}: {value}") 395 | logger.error("-" * 50) 396 | logger.error("Request data:") 397 | logger.error(json.dumps(data, indent=2)) 398 | logger.error("-" * 50) 399 | 400 | # 尝试解析错误信息 401 | try: 402 | error_data = json.loads(text) 403 | if "detail" in error_data: 404 | error_message = error_data["detail"] 405 | if "Insufficient credits" in error_message: 406 | logger.error("Insufficient credits to generate music") 407 | return None, "Insufficient credits to generate music" 408 | return None, error_message 409 | except json.JSONDecodeError: 410 | pass 411 | 412 | return None, f"Request failed with status {response.status}" 413 | 414 | # Try to parse as JSON if it looks like JSON 415 | try: 416 | if 'application/json' in content_type or text.strip().startswith('{'): 417 | response_data = await response.json() 418 | logger.info("Parsed JSON response:") 419 | logger.info(response_data) 420 | 421 | if not isinstance(response_data, dict): 422 | logger.error(f"Unexpected response type: {type(response_data)}") 423 | return None, "Unexpected response format" 424 | 425 | return response_data, None 426 | else: 427 | logger.error(f"Unexpected content type: {content_type}") 428 | logger.error(f"Response text: {text}") 429 | return None, "Unexpected response content type" 430 | 431 | except Exception as e: 432 | logger.error(f"Failed to parse response: {e}") 433 | logger.error(f"Response text: {text}") 434 | return None, f"Failed to parse response: {str(e)}" 435 | 436 | except aiohttp.ClientError as e: 437 | logger.error(f"HTTP request failed: {e}") 438 | logger.error(f"Request URL: {URLs.SUNO_BASE}/api/generate/v2/") 439 | logger.error("Request headers:") 440 | for key, value in headers.items(): 441 | logger.error(f"{key}: {value}") 442 | logger.error("Request data:") 443 | logger.error(json.dumps(data, indent=2)) 444 | return None, f"HTTP request failed: {str(e)}" 445 | 446 | except Exception as e: 447 | logger.error(f"Failed to generate music: {e}") 448 | logger.error(f"Error type: {type(e)}") 449 | import traceback 450 | logger.error(f"Traceback: {traceback.format_exc()}") 451 | return None, f"Failed to generate music: {str(e)}" 452 | 453 | async def get_captcha_token(self, combination_index: int, cookies: Optional[Dict] = None) -> Optional[str]: 454 | """Get CAPTCHA token with improved error handling and cookie support""" 455 | try: 456 | site_key_index = combination_index // len(CaptchaConfig.SITE_URLS) 457 | site_url_index = combination_index % len(CaptchaConfig.SITE_URLS) 458 | 459 | if site_key_index >= len(CaptchaConfig.SITE_KEYS): 460 | return None 461 | 462 | site_key = CaptchaConfig.SITE_KEYS[site_key_index] 463 | site_url = CaptchaConfig.SITE_URLS[site_url_index] 464 | 465 | logger.info(f"Getting captcha token with site_key: {site_key}") 466 | logger.info(f"Site URL: {site_url}") 467 | 468 | # 构建payload,添加cookies 469 | payload = { 470 | "clientKey": self.capsolver_apikey, 471 | "task": { 472 | "type": "AntiTurnstileTaskProxyLess", 473 | "websiteURL": site_url, 474 | "websiteKey": site_key, 475 | "metadata": { 476 | "action": "invisible", 477 | }, 478 | # 添加cookies支持 479 | "cookies": [ 480 | {"name": name, "value": value} 481 | for name, value in (cookies or {}).items() 482 | ] if cookies else [] 483 | } 484 | } 485 | 486 | logger.info("Sending captcha request with cookies...") 487 | 488 | # Create task 489 | response = await self.request_client.request( 490 | "POST", 491 | URLs.CAPSOLVER_CREATE, 492 | json=payload 493 | ) 494 | 495 | task_id = response.get("taskId") 496 | if not task_id: 497 | logger.error("No taskId in response") 498 | return None 499 | 500 | logger.info(f"Created captcha task with ID: {task_id}") 501 | 502 | # Poll for result 503 | max_attempts = 30 504 | for attempt in range(max_attempts): 505 | await asyncio.sleep(2) # Wait between checks 506 | 507 | status_response = await self.request_client.request( 508 | "POST", 509 | URLs.CAPSOLVER_RESULT, 510 | json={"clientKey": self.capsolver_apikey, "taskId": task_id} 511 | ) 512 | 513 | status = status_response.get("status") 514 | logger.info(f"Captcha status check {attempt + 1}: {status}") 515 | 516 | if status == "ready": 517 | solution = status_response.get("solution", {}) 518 | token = solution.get("token") 519 | logger.info(f"Solution: {status_response}") 520 | if token: 521 | logger.info(f"Got captcha token (first 20 chars): {token[:20]}...") 522 | return token 523 | else: 524 | logger.error("No token in solution") 525 | return None 526 | elif status == "failed": 527 | error = status_response.get("error") 528 | logger.error(f"Captcha task failed: {error}") 529 | break 530 | 531 | logger.error("Max attempts reached waiting for captcha") 532 | return None 533 | 534 | except Exception as e: 535 | logger.error(f"Failed to get CAPTCHA token: {e}") 536 | return None 537 | 538 | async def get_playlist(self, playlist_id: str, page: int = 0) -> Optional[str]: 539 | """ 540 | Get playlist information and store notification session-id from response headers 541 | 542 | Args: 543 | playlist_id: The ID of the playlist 544 | page: Page number for pagination (default: 0) 545 | 546 | Returns: 547 | notification_session_id string or None if failed 548 | """ 549 | if self._closed: 550 | raise RuntimeError("SongsGen instance is closed") 551 | 552 | if not self.auth_token: 553 | logger.error("No auth token available") 554 | return None 555 | 556 | try: 557 | # Make request 558 | url = URLs.PLAYLIST.format(playlist_id=playlist_id) 559 | params = {"page": page} 560 | 561 | result = await self.request_client.request_with_headers( 562 | "GET", 563 | url, 564 | headers=self._get_common_headers(), 565 | params=params 566 | ) 567 | 568 | if not result: 569 | return None 570 | 571 | _, response_headers = result 572 | 573 | # Get and store session-id from headers 574 | session_id = response_headers.get('session-id') 575 | if session_id: 576 | self.notification_session_id = session_id # 保存到实例变量 577 | return session_id 578 | else: 579 | logger.error("No session-id in response headers") 580 | return None 581 | 582 | except Exception as e: 583 | logger.error(f"Failed to get playlist: {e}") 584 | return None 585 | 586 | async def _refresh_token(self) -> bool: 587 | """ 588 | 刷新认证token 589 | 590 | Returns: 591 | bool: 刷新是否成功 592 | """ 593 | try: 594 | # 清空当前token 595 | self.auth_token = None 596 | 597 | # 获取新token 598 | new_token = await self.get_auth_token() 599 | if new_token: 600 | self.auth_token = new_token 601 | return True 602 | return False 603 | except Exception as e: 604 | logger.error(f"Failed to refresh token: {e}") 605 | return False 606 | 607 | async def _handle_feed_response(self, response: aiohttp.ClientResponse, clip_ids: list[str]) -> Tuple[Optional[Dict], bool]: 608 | """ 609 | 处理feed响应,包括token刷新逻辑 610 | 611 | Args: 612 | response: aiohttp响应对象 613 | clip_ids: 歌曲ID列表 614 | 615 | Returns: 616 | (响应数据, 是否需要重试)的元组 617 | """ 618 | try: 619 | if response.status == 200: 620 | data = await response.json() 621 | # 更新token缓存 622 | for clip_id in clip_ids: 623 | self._token_cache[clip_id] = self.auth_token 624 | return data, False 625 | 626 | if response.status in TOKEN_REFRESH_STATUS_CODES: 627 | logger.info("Token expired, refreshing...") 628 | # 清空token缓存 629 | self._token_cache = {} 630 | if await self._refresh_token(): 631 | logger.info("Token refreshed successfully") 632 | return None, True # 需要重试 633 | else: 634 | logger.error("Failed to refresh token") 635 | return None, False 636 | 637 | if response.status in RETRIABLE_STATUS_CODES: 638 | logger.warning(f"Retriable status code: {response.status}") 639 | return None, True 640 | 641 | if response.status == 400: 642 | error_text = await response.text() 643 | logger.error(f"Bad request (400): {error_text}") 644 | logger.error("Request headers:") 645 | for key, value in response.request_info.headers.items(): 646 | logger.error(f"{key}: {value}") 647 | return None, False 648 | 649 | logger.error(f"Unexpected response status: {response.status}") 650 | return None, False 651 | 652 | except Exception as e: 653 | logger.error(f"Error handling feed response: {e}") 654 | return None, True # 网络错误通常可以重试 655 | 656 | async def check_task_status( 657 | self, 658 | clip_ids: List[str], 659 | interval: int = 5, 660 | timeout: int = 300, 661 | check_callback: Optional[callable] = None 662 | ) -> Optional[Dict]: 663 | """ 664 | 持续检查任务状态直到完成或超时 665 | 666 | Args: 667 | clip_ids: 要检查的歌曲ID列表 668 | interval: 检查间隔(秒) 669 | timeout: 超时时间(秒) 670 | check_callback: 每次检查后的回调函数,接收当前状态作为参数 671 | 672 | Returns: 673 | 最终的任务状态数据或None 674 | """ 675 | if not clip_ids: 676 | logger.error("No clip IDs provided") 677 | return None 678 | 679 | start_time = asyncio.get_event_loop().time() 680 | retries = 0 681 | 682 | while True: 683 | try: 684 | # 检查是否超时 685 | if asyncio.get_event_loop().time() - start_time > timeout: 686 | logger.error("Task status check timed out") 687 | return None 688 | 689 | # 获取任务状态 690 | feed_data = await self.get_feed(clip_ids) 691 | if not feed_data: 692 | if retries >= MAX_RETRIES: 693 | logger.error("Max retries reached for task status check") 694 | return None 695 | retries += 1 696 | await asyncio.sleep(RETRY_DELAY) 697 | continue 698 | 699 | # 检查所有任务是否完成 700 | clips = feed_data.get("clips", []) 701 | all_completed = True 702 | has_failed = False 703 | status_summary = {} 704 | 705 | # 记录详细的响应信息 706 | logger.info("-" * 50) 707 | logger.info("Feed Response Details:") 708 | for clip in clips: 709 | clip_id = clip.get("id") 710 | status = clip.get("status") 711 | status_summary[clip_id] = status 712 | 713 | # 记录每个clip的详细信息 714 | logger.info(f"\nClip ID: {clip_id}") 715 | logger.info(f"Status: {status}") 716 | logger.info(f"Title: {clip.get('title')}") 717 | logger.info(f"Created At: {clip.get('created_at')}") 718 | logger.info(f"Updated At: {clip.get('updated_at')}") 719 | 720 | # 如果有错误信息,记录错误 721 | if error := clip.get("error"): 722 | logger.error(f"Error: {error}") 723 | 724 | # 如果有其他重要信息,也记录下来 725 | if metadata := clip.get("metadata"): 726 | logger.info(f"Metadata: {metadata}") 727 | 728 | if status not in TaskStatus.FINAL_STATES: 729 | all_completed = False 730 | elif status in TaskStatus.RETRIABLE_STATES: 731 | has_failed = True 732 | 733 | logger.info("\nStatus Summary:") 734 | for clip_id, status in status_summary.items(): 735 | logger.info(f"{clip_id}: {status}") 736 | logger.info("-" * 50) 737 | 738 | # 调用回调函数 739 | if check_callback: 740 | try: 741 | await check_callback(feed_data) 742 | except Exception as e: 743 | logger.error(f"Error in status check callback: {e}") 744 | 745 | if all_completed: 746 | if has_failed: 747 | logger.warning("Some tasks failed or errored") 748 | else: 749 | logger.info("All tasks completed successfully") 750 | return feed_data 751 | 752 | # 等待下一次检查 753 | await asyncio.sleep(interval) 754 | 755 | except Exception as e: 756 | logger.error(f"Error checking task status: {e}") 757 | if retries >= MAX_RETRIES: 758 | logger.error("Max retries reached") 759 | return None 760 | retries += 1 761 | await asyncio.sleep(RETRY_DELAY) 762 | 763 | async def get_feed(self, ids: list[str], page: int = 5000) -> Optional[Dict]: 764 | """ 765 | Get feed information for one or more songs with token refresh support 766 | 767 | Args: 768 | ids: List of song IDs to get status for 769 | page: Page size (default: 5000) 770 | 771 | Returns: 772 | Feed data dictionary or None if failed 773 | """ 774 | if self._closed: 775 | raise RuntimeError("SongsGen instance is closed") 776 | 777 | if not ids: 778 | logger.error("No IDs provided") 779 | return None 780 | 781 | retries = 0 782 | while retries < MAX_RETRIES: 783 | try: 784 | # 检查是否需要刷新token 785 | cached_token = next((self._token_cache.get(clip_id) for clip_id in ids), None) 786 | if cached_token and cached_token != self.auth_token: 787 | logger.info(f"Using cached token: {cached_token[:20]}...") 788 | self.auth_token = cached_token 789 | 790 | if not self.auth_token: 791 | logger.error("No auth token available") 792 | if not await self._refresh_token(): 793 | return None 794 | 795 | if not self.notification_session_id: 796 | logger.error("No notification session ID available") 797 | return None 798 | 799 | # Prepare query parameters 800 | params = { 801 | "ids": ",".join(ids), 802 | "page": page 803 | } 804 | 805 | # 获取当前请求要使用的headers 806 | headers = self._get_common_headers() 807 | logger.debug(f"Request headers: {headers}") 808 | 809 | # Make request 810 | async with aiohttp.ClientSession() as session: 811 | async with session.get( 812 | URLs.FEED, 813 | headers=headers, 814 | params=params 815 | ) as response: 816 | result, should_retry = await self._handle_feed_response(response, ids) 817 | if result: 818 | logger.info(result) 819 | return result 820 | if not should_retry: 821 | return None 822 | 823 | retries += 1 824 | if retries < MAX_RETRIES: 825 | delay = RETRY_DELAY * (2 ** retries) # 指数退避 826 | logger.info(f"Retrying in {delay} seconds...") 827 | await asyncio.sleep(delay) 828 | 829 | except Exception as e: 830 | logger.error(f"Failed to get feed (attempt {retries + 1}): {e}") 831 | retries += 1 832 | if retries < MAX_RETRIES: 833 | delay = RETRY_DELAY * (2 ** retries) 834 | await asyncio.sleep(delay) 835 | 836 | logger.error("Max retries reached for get_feed") 837 | return None 838 | 839 | def extract_clip_ids(self, generation_response: Dict) -> list[str]: 840 | """ 841 | 从生成音乐的响应中提取歌曲ID 842 | 843 | Args: 844 | generation_response: 生成音乐的响应数据 845 | 846 | Returns: 847 | 包含歌曲ID的列表,如果提取失败则返回空列表 848 | """ 849 | try: 850 | clips = generation_response.get('clips', []) 851 | clip_ids = [clip['id'] for clip in clips if 'id' in clip] 852 | 853 | if not clip_ids: 854 | logger.error("No clip IDs found in generation response") 855 | return [] 856 | 857 | logger.info(f"Extracted {len(clip_ids)} clip IDs: {clip_ids}") 858 | return clip_ids 859 | 860 | except Exception as e: 861 | logger.error(f"Failed to extract clip IDs: {e}") 862 | return [] 863 | 864 | def _get_common_headers(self, include_auth: bool = True) -> Dict[str, str]: 865 | """ 866 | 获取通用请求头 867 | 868 | Args: 869 | include_auth: 是否包含认证token 870 | 871 | Returns: 872 | 请求头字典 873 | """ 874 | headers = { 875 | "accept": "*/*", 876 | "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", 877 | "affiliate-id": "undefined", 878 | "device-id": self._device_id, 879 | "origin": "https://suno.com", 880 | "priority": "u=1, i", 881 | "referer": "https://suno.com/", 882 | "sec-ch-ua": '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"', 883 | "sec-ch-ua-mobile": "?0", 884 | "sec-ch-ua-platform": '"Windows"', 885 | "sec-fetch-dest": "empty", 886 | "sec-fetch-mode": "cors", 887 | "sec-fetch-site": "same-site", 888 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0" 889 | } 890 | 891 | # 添加认证token 892 | if include_auth and self.auth_token: 893 | headers["authorization"] = f"Bearer {self.auth_token}" 894 | 895 | # 添加session-id 896 | if self.notification_session_id: 897 | headers["session-id"] = self.notification_session_id 898 | 899 | return headers 900 | 901 | async def get_notification(self, after_datetime_utc: str = None) -> Optional[Dict]: 902 | """ 903 | Get notifications after a specific datetime 904 | 905 | Args: 906 | after_datetime_utc: UTC datetime string in format "YYYY-MM-DDTHH:MM:SS.sssZ" 907 | If None, defaults to a future date 908 | 909 | Returns: 910 | Notification data dictionary or None if failed 911 | """ 912 | if self._closed: 913 | raise RuntimeError("SongsGen instance is closed") 914 | 915 | if not self.auth_token: 916 | logger.error("No auth token available") 917 | return None 918 | 919 | try: 920 | # If no datetime provided, use a future date 921 | if not after_datetime_utc: 922 | # Use a date far in the future to ensure we get all notifications 923 | after_datetime_utc = "2025-01-19T09:58:43.194Z" 924 | 925 | # Prepare query parameters 926 | params = { 927 | "after_datetime_utc": after_datetime_utc 928 | } 929 | 930 | # Get headers 931 | headers = self._get_common_headers() 932 | 933 | # Make request 934 | async with aiohttp.ClientSession() as session: 935 | async with session.get( 936 | f"{URLs.SUNO_BASE}/api/notification", 937 | headers=headers, 938 | params=params 939 | ) as response: 940 | if response.status == 200: 941 | data = await response.json() 942 | logger.info(f"Got notification response: {data}") 943 | return data 944 | else: 945 | logger.error(f"Failed to get notifications: {response.status}") 946 | return None 947 | 948 | except Exception as e: 949 | logger.error(f"Failed to get notifications: {e}") 950 | return None 951 | 952 | async def check_generation_permission(self) -> Optional[Dict]: 953 | """ 954 | Check if user has permission to generate music 955 | 956 | Returns: 957 | Response data dictionary or None if failed 958 | """ 959 | if self._closed: 960 | raise RuntimeError("SongsGen instance is closed") 961 | 962 | if not self.auth_token: 963 | logger.error("No auth token available") 964 | return None 965 | 966 | try: 967 | # Get headers 968 | headers = self._get_common_headers() 969 | headers["content-type"] = "text/plain;charset=UTF-8" 970 | 971 | # Request data 972 | data = { 973 | "ctype": "generation" 974 | } 975 | 976 | # Make request 977 | async with aiohttp.ClientSession() as session: 978 | async with session.post( 979 | f"{URLs.SUNO_BASE}/api/c/check", 980 | headers=headers, 981 | json=data 982 | ) as response: 983 | if response.status == 200: 984 | data = await response.json() 985 | logger.info(f"Generation permission check response: {data}") 986 | return data 987 | else: 988 | logger.error(f"Failed to check generation permission: {response.status}") 989 | return None 990 | 991 | except Exception as e: 992 | logger.error(f"Failed to check generation permission: {e}") 993 | return None 994 | -------------------------------------------------------------------------------- /suno2openai.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.1 2 | Name: suno2openai 3 | Version: 0.2.1 4 | License-File: LICENSE 5 | Requires-Dist: aiohttp 6 | Requires-Dist: fake-useragent 7 | Requires-Dist: python-dotenv 8 | -------------------------------------------------------------------------------- /suno_ai.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.1 2 | Name: suno_ai 3 | Version: 0.1 4 | License-File: LICENSE 5 | Requires-Dist: aiohttp 6 | Requires-Dist: fake-useragent 7 | -------------------------------------------------------------------------------- /tests/test_suno.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from dotenv import load_dotenv 4 | from datetime import datetime 5 | import uuid 6 | import json 7 | # 加载环境变量 8 | load_dotenv('test.env') 9 | 10 | from suno import SongsGen, CLERK_API_VERSION, CLERK_JS_VERSION, URLs 11 | from util.logger import logger 12 | 13 | # 在这里填写你的cookie 14 | COOKIE = os.getenv('COOKIE') 15 | CAPSOLVER_APIKEY = os.getenv('capsolver_apikey') 16 | 17 | async def test_auth(songs_gen: SongsGen): 18 | """测试认证相关功能""" 19 | try: 20 | logger.info("Starting authentication test...") 21 | if songs_gen.auth_token: 22 | logger.info(f"Authentication successful!") 23 | logger.info(f"JWT Token (first 20 chars): {songs_gen.auth_token[:20]}...") 24 | 25 | # 验证实例变量是否正确设置 26 | logger.info(f"Clerk Session ID: {songs_gen.clerk_session_id}") 27 | logger.info(f"User ID: {songs_gen.user_id}") 28 | logger.info(f"Session Expires: {songs_gen.session_expire_at}") 29 | 30 | return songs_gen.auth_token 31 | else: 32 | logger.error("Failed to get authentication token") 33 | return None 34 | 35 | except Exception as e: 36 | logger.error(f"Auth test failed with error: {str(e)}") 37 | return None 38 | 39 | async def test_limit_left(songs_gen): 40 | """测试获取剩余次数""" 41 | try: 42 | limit = await songs_gen.get_limit_left() 43 | logger.info(f"Remaining credits: {limit}") 44 | return limit 45 | except Exception as e: 46 | logger.error(f"Get limit test failed: {e}") 47 | return -1 48 | 49 | async def test_verify(songs_gen): 50 | """测试验证""" 51 | try: 52 | result = await songs_gen.verify() 53 | logger.info(f"Verify result: {result}") 54 | return result 55 | except Exception as e: 56 | logger.error(f"Verify test failed: {e}") 57 | return None 58 | 59 | async def test_get_playlist(songs_gen): 60 | """测试获取播放列表notification session-id""" 61 | try: 62 | playlist_id = "0b9f43a6-7754-4069-9e3b-93b061be361d" 63 | notification_session_id = await songs_gen.get_playlist(playlist_id) 64 | if notification_session_id: 65 | logger.info(f"Got notification session-id: {notification_session_id}") 66 | return notification_session_id 67 | else: 68 | logger.error("Failed to get notification session-id") 69 | return None 70 | except Exception as e: 71 | logger.error(f"Get playlist test failed: {e}") 72 | return None 73 | 74 | async def generate_music(songs_gen): 75 | """测试生成音乐""" 76 | try: 77 | # 检查生成权限 78 | check_result = await songs_gen.check_generation_permission() 79 | if check_result is None: 80 | logger.error("Failed to check generation permission") 81 | return None, None 82 | 83 | # 获取通知连接 84 | notification_data = await songs_gen.get_notification() 85 | if notification_data is None: 86 | logger.error("Failed to establish notification connection") 87 | return None, None 88 | 89 | prompt = """[Intro] 90 | Hey hey 91 | 92 | [Chorus: Elton John type] 93 | Long time no see 94 | I've been finally 20 95 | You came to see how the view works 96 | And I love you like a new word 97 | 98 | [Post-Chorus] 99 | Wow 100 | 101 | [Chorus: MJ type] 102 | Where have you been? 103 | I got my air dirty now 104 | Past that dying bird 105 | You look like you can't believe it 106 | 107 | [Verse: Paul McCartney type] 108 | And I hate it when you call me 109 | I hate it when you miss me 110 | Come home to your new world 111 | I love you like a songbird 112 | 113 | [Verse 3] 114 | Maybe if you get yourself a little closer 115 | You'd see the best part of me 116 | Darling there's more than words to 117 | Everything that's shitty in the air again""" 118 | title = "Long Time No See" 119 | token = "P1_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.hadwYXNza2V5xQWl4F9ZWvTC4PKjA9_7UaEUvAGavE7TUZFXqRE5Gqy9zBjYxg84Z_rZMMpKEjwcl1wQ2RTZr6-0hi2vB30pHD82wHPsGe3-986iOT-zFOiMDW4PSWu38XuobMQNDjhX_EeEHRh1sgFS8baPQBYMAMmpGLhfutQk7J284CdhlCEuVe6c_VAWfvmQ_MVFRxQNVsIN_32_Du4N54vFFt0SPE-4hiLfiMNedvBe4vlAxPmdGoIgpNttYpxJbhkfNI2pBlwgCdltLir--jwT5o9kD2cu4WEXqjEhVUCWXMPqQahG4-onQ5FkG7VAEcymJ3Xp78OhmdzS_4WO_tw3T5vlnX2oPFFY_XH3lccNwPFoQExjPqrrtoGD6i1FtbVW294BmcgyEIqKMrb4m97gCqsnqROUDpnE_vvm0q3__AGj33szloiWH92Vn4-zL0BUOkJwR65OqUFN4ZlRNSD7mVxUM9h9-afPuPQPK7PF-WkAT0AugMQdbkYkHecSZ9XVKVaLI-EXp3lM6J5avr5YwTa5yt7iIkLCbHNtV7b_kDEDcfojkB3FCBYcd5pah9Jyh_LIIo4rAoLoC074LomvkuvuVdkKuFdi7ZJeqj5hG8tPV7b4WF84ylP95A7vAA4cTnjWtKciJ94SIMKcAwIloath4omzPIkBoSOO81nzwzrCq080fVLi6yxVnnhAq4kqa_84skMT_ryaUv3dGvxtKO48IT3pzq-dzjDf1zBkhqvxG7I8y4bZfHn5aq_BtDAYjn5GejD6BvOc57-pNIKwhVh6aqvVrLOHK54TGGkZX1m0yL67Z4jeR32PkDZMa0WQ7XBMznLn4dv1ad6E3EPYwvX302MXFSQTIcHckhBzjqYlJNluk7CmJ0rqbZ442OcW1V4clcE_tWqKd2oitE5yIBM-UHAejMdlE8_6MyLHOYKqXEF5aEgFsChr8ff68yuTaJj1Bd5xQugm7NNB0-kKuoFYr1zDvwwOdPX3R2i1lS5koogf8g2zY8pROMRb47Gubhqx5LSX7rWgr-6xr4J44AOEWkPkgMacUHNmblzbZz_LE2Z390JoyuVtVPnHRZrvQsGcZhTpAVVDHnUxd4sP7zKoMnkCBK5ww5u8LP3pzKMXe0MWfS0mQPZcails-KJNqKibax6wZA7Vu7GA5r8d6GktS2lwjETOunAvEaXRHpZ8yDWsAm3po_kjjYmPDHZezS9eMVmB45Wiasa2__Gp-awFNXaY1mkpQhlKZOQ4PU3c_uVG2QVYQKe2ovsJGV15aN3SsWExb-0rt6d4OjIF7gR_uqh4vYcFGSq0nOsIXeazV7MwVjq7xusVMyjJCVJB9YglU9rk2XFOdd0I1RWBACUB30QhgiW8-T3EusLY587d6y8BtqUgTjgObMi64GgUg9QT4qUPFZnDiglnsIEORUXzHevbH3ss0UKOtMJ7gTZHFtie9ACbIXIfVQ1TD8y8kIAFFf5_nhJD74gUK8EwtdxgpL6N8g-kf_0P_Y8X40s0j_0W5v_bOnhaXFQBImUymnTtqG2ggEjnfoOHwzgQEWb1AhBLyCex-vXLHEForPv4oW8oLAYUbfuOXgOsOEUn_bm0L-9auIYO8Vit2sx-BSvSNt7WqzQrABJG4obR35ToXxEeLfmIZ6kWAb2TE9PaFNAMsaRabUY5I0DNDyDeOmVxwqsXxbuxZFIVpqttT9-28xEjFo4pGP0aYmbdkj2MJHVMJ4xo7vxfPRD01M9hIAE6sWlJhnRPSSguwEZRXpOuN0Ud7Kho1HzFhzqRFi7V3vnX2JdiwTyJUMP-ACZntgSzKmwroxWoRE4WK2usDfZTtj1Db7vjTquARPTJvyVjWs5Xm7J9yfUksfWxvbO6uszAmgDl4kbm5QvSVlcS6p9y-cDBhzNZ3hEMgTCDMKg-FdOlwRs8XaPZmIKjZXhwzmeMya-oc2hhcmRfaWTODTtkKaJrcqg0N2ZjZTdlMqJwZAA.oKjo4bHJHmyoDXgUmvqRkD66aXv8moqPp3fq5ihS3SU" 120 | result, error_message = await songs_gen.generate_music(token=token, prompt=prompt, title=title, mv="chirp-v3-5") 121 | 122 | if error_message: 123 | logger.error(f"Failed to generate music: {error_message}") 124 | if "Insufficient credits" in error_message: 125 | logger.error("No credits available to generate music") 126 | return None, None 127 | return None, None 128 | 129 | if not result: 130 | logger.error("Failed to generate music with no error message") 131 | return None, None 132 | 133 | logger.info(f"Generate music result: {result}") 134 | 135 | # 提取歌曲ID 136 | clip_ids = songs_gen.extract_clip_ids(result) 137 | if clip_ids: 138 | logger.info(f"Successfully extracted {len(clip_ids)} clip IDs") 139 | for i, clip_id in enumerate(clip_ids, 1): 140 | logger.info(f"Clip {i}: {clip_id}") 141 | else: 142 | logger.error("Failed to extract clip IDs") 143 | return None, None 144 | 145 | return result, clip_ids 146 | 147 | except Exception as e: 148 | logger.error(f"Generate music test failed: {e}") 149 | return None, None 150 | 151 | async def test_get_feed(songs_gen, ids=None): 152 | """测试获取歌曲任务状态""" 153 | try: 154 | if ids is None or not ids: 155 | ids = ['9425da8a-ad79-455a-af54-70e5df52c7e7', 'e3ec7b95-e390-40dc-bb33-fa789aadca37'] 156 | 157 | logger.info(f"Checking status for clips: {ids}") 158 | 159 | # 获取初始状态 160 | feed_data = await songs_gen.get_feed(ids) 161 | if not feed_data: 162 | logger.error("Failed to get initial feed data") 163 | return None 164 | 165 | logger.info("\nInitial Feed Response:") 166 | logger.info("-" * 50) 167 | # 记录完整的响应数据 168 | logger.info(json.dumps(feed_data, indent=2)) 169 | logger.info("-" * 50) 170 | 171 | # 持续检查任务状态 172 | final_status = await songs_gen.check_task_status( 173 | ids, 174 | interval=2, # 每2秒检查一次 175 | timeout=300 # 最多等待5分钟 176 | ) 177 | 178 | if final_status: 179 | logger.info("\nFinal Feed Response:") 180 | logger.info("-" * 50) 181 | # 记录完整的最终响应数据 182 | logger.info(json.dumps(final_status, indent=2)) 183 | logger.info("-" * 50) 184 | return final_status 185 | else: 186 | logger.error("Failed to get final task status") 187 | return None 188 | 189 | except Exception as e: 190 | logger.error(f"Get feed test failed: {e}") 191 | return None 192 | 193 | async def main(): 194 | songs_gen = None 195 | try: 196 | device_id = str(uuid.uuid4()) 197 | async with SongsGen(COOKIE, CAPSOLVER_APIKEY, device_id=device_id) as songs_gen: 198 | # 使用同一个songs_gen实例进行测试 199 | auth_result = await test_auth(songs_gen) 200 | if not auth_result: 201 | logger.error("Authentication test failed") 202 | return 203 | 204 | # 测试获取剩余次数 205 | limit_result = await test_limit_left(songs_gen) 206 | if limit_result < 0: 207 | logger.error("Failed to get remaining credits") 208 | return 209 | 210 | # 测试获取播放列表 211 | playlist_result = await test_get_playlist(songs_gen) 212 | if not playlist_result: 213 | logger.error("Failed to get playlist") 214 | return 215 | 216 | # 测试生成音乐 217 | music_result, clip_ids = await generate_music(songs_gen) 218 | if not music_result: 219 | logger.error("Failed to generate music") 220 | return 221 | 222 | # 测试获取歌曲任务状态 223 | if clip_ids: 224 | feed_result = await test_get_feed(songs_gen, clip_ids) 225 | if not feed_result: 226 | logger.error("Failed to get feed data") 227 | return 228 | 229 | logger.info("All tests completed successfully") 230 | 231 | except Exception as e: 232 | logger.error(f"Test failed with error: {e}") 233 | finally: 234 | if songs_gen: 235 | await songs_gen.close() 236 | 237 | if __name__ == "__main__": 238 | asyncio.run(main()) 239 | -------------------------------------------------------------------------------- /util/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | from dotenv import load_dotenv 5 | 6 | from util.logger import logger 7 | 8 | load_dotenv(encoding="utf-8") 9 | 10 | # 版本号 11 | VERSION = "0.2.1" 12 | 13 | # BASE_URL 14 | BASE_URL = os.getenv('BASE_URL', 'https://studio-api.suno.ai') 15 | # SESSION_ID 16 | SESSION_ID = os.getenv('SESSION_ID') 17 | # 代理 18 | PROXY = os.getenv('PROXY', None) 19 | # 用户名 20 | USER_NAME = os.getenv('USER_NAME', '') 21 | # 数据库名 22 | SQL_NAME = os.getenv('SQL_NAME', '') 23 | # 数据库密码 24 | SQL_PASSWORD = os.getenv('SQL_PASSWORD', '') 25 | # 数据库IP 26 | SQL_IP = os.getenv('SQL_IP', '') 27 | # 数据库端口 28 | SQL_DK = os.getenv('SQL_DK', 3306) 29 | # cookies前缀 30 | COOKIES_PREFIX = os.getenv('COOKIES_PREFIX', "") 31 | # 鉴权key 32 | AUTH_KEY = os.getenv('AUTH_KEY', str(time.time())) 33 | # 重试次数 34 | RETRIES = int(os.getenv('RETRIES', 5)) 35 | # 添加刷新cookies时的批处理数量(默认10) 36 | BATCH_SIZE = int(os.getenv('BATCH_SIZE', 10)) 37 | # 最大等待时间(分钟) 38 | MAX_TIME = int(os.getenv('MAX_TIME', 5)) 39 | 40 | # 处理措施 41 | if not PROXY: 42 | PROXY = None 43 | 44 | # 记录配置信息 45 | logger.info("==========================================") 46 | logger.info(f"VERSION: {VERSION}") 47 | logger.info(f"BASE_URL: {BASE_URL}") 48 | logger.info(f"SESSION_ID: {SESSION_ID}") 49 | logger.info(f"PROXY: {PROXY}") 50 | logger.info(f"USER_NAME: {USER_NAME}") 51 | logger.info(f"SQL_NAME: {SQL_NAME}") 52 | logger.info(f"SQL_PASSWORD: {SQL_PASSWORD}") 53 | logger.info(f"SQL_IP: {SQL_IP}") 54 | logger.info(f"SQL_DK: {SQL_DK}") 55 | logger.info(f"COOKIES_PREFIX: {COOKIES_PREFIX}") 56 | logger.info(f"AUTH_KEY: {AUTH_KEY}") 57 | logger.info(f"RETRIES: {RETRIES}") 58 | logger.info(f"MAX_TIME: {MAX_TIME}") 59 | logger.info(f"BATCH_SIZE: {BATCH_SIZE}") 60 | logger.info("==========================================") 61 | -------------------------------------------------------------------------------- /util/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig(level=logging.INFO, format='%(asctime)s | %(levelname)s | %(message)s') 4 | 5 | 6 | class Logger: 7 | @staticmethod 8 | def info(message): 9 | logging.info(str(message)) 10 | 11 | @staticmethod 12 | def warning(message): 13 | logging.warning("\033[0;33m" + str(message) + "\033[0m") 14 | 15 | @staticmethod 16 | def error(message): 17 | logging.error("\033[0;31m" + "-" * 50 + '\n| ' + str(message) + "\033[0m" + "\n" + "└" + "-" * 80) 18 | 19 | @staticmethod 20 | def debug(message): 21 | logging.debug("\033[0;37m" + str(message) + "\033[0m") 22 | 23 | 24 | logger = Logger() 25 | -------------------------------------------------------------------------------- /util/sql_uilts.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta 3 | 4 | import aiomysql 5 | from fastapi import HTTPException 6 | from tenacity import retry, stop_after_attempt, wait_random 7 | 8 | from util.config import RETRIES 9 | from util.logger import logger 10 | 11 | 12 | class DatabaseManager: 13 | def __init__(self, host, port, user, password, db_name): 14 | self.host = host 15 | self.port = port 16 | self.user = user 17 | self.password = password 18 | self.db_name = db_name 19 | self.pool = None 20 | 21 | # 创建连接池 22 | async def create_pool(self): 23 | try: 24 | if self.pool is None: 25 | # 用于root账户密码新建数据库 26 | if self.user == 'root': 27 | connection = None 28 | try: 29 | connection = await aiomysql.connect( 30 | host=self.host, 31 | port=self.port, 32 | user=self.user, 33 | password=self.password, 34 | autocommit=True 35 | ) 36 | async with connection.cursor() as cursor: 37 | # 检查数据库是否存在 38 | await cursor.execute(f"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA " 39 | f"WHERE SCHEMA_NAME = '{self.db_name}'") 40 | result = await cursor.fetchone() 41 | # 如果不存在则创建数据库 42 | if not result: 43 | await cursor.execute(f"CREATE DATABASE {self.db_name}") 44 | logger.info(f"数据库 {self.db_name} 已创建.") 45 | except Exception as e: 46 | logger.error(f"发生错误: {e}") 47 | finally: 48 | if connection: 49 | connection.close() 50 | 51 | self.pool = await aiomysql.create_pool( 52 | host=self.host, 53 | port=self.port, 54 | user=self.user, 55 | password=self.password, 56 | db=self.db_name, 57 | maxsize=200, 58 | minsize=5, 59 | connect_timeout=10, 60 | pool_recycle=1800 61 | ) 62 | 63 | if self.pool is None: 64 | logger.error("连接池创建失败,返回值为 None。") 65 | except Exception as e: 66 | logger.error(f"创建连接池时发生错误: {e}") 67 | 68 | # 关闭连接池 69 | async def close_db_pool(self): 70 | if self.pool: 71 | self.pool.close() 72 | await self.pool.wait_closed() 73 | 74 | # 创建数据库和表 75 | async def create_database_and_table(self): 76 | await self.create_pool() 77 | async with self.pool.acquire() as conn: 78 | async with conn.cursor() as cursor: 79 | try: 80 | # await cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{self.db_name}`") 81 | # logger.info(f"Database `{self.db_name}` created or already exists.") 82 | # await cursor.execute(f"USE `{self.user}`") 83 | await cursor.execute(f""" 84 | CREATE TABLE IF NOT EXISTS suno2openai ( 85 | id INT AUTO_INCREMENT PRIMARY KEY, 86 | cookie TEXT NOT NULL, 87 | songID VARCHAR(255), 88 | songID2 VARCHAR(255), 89 | count INT, 90 | time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 91 | add_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 92 | captcha_token TEXT, 93 | UNIQUE(cookie(191)) 94 | ) 95 | """) 96 | # 查询表结构,检查是否存在 add_time 列 97 | await cursor.execute(''' 98 | SHOW COLUMNS FROM suno2openai LIKE 'add_time'; 99 | ''') 100 | column = await cursor.fetchone() 101 | 102 | if not column: 103 | # 如果 add_time 列不存在,添加该列 104 | await cursor.execute(''' 105 | ALTER TABLE suno2openai 106 | ADD COLUMN add_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP; 107 | ''') 108 | logger.info("成功添加 'add_time' 列。") 109 | else: 110 | logger.info("'add_time' 列已存在,跳过添加。") 111 | 112 | await conn.commit() 113 | except Exception as e: 114 | await conn.rollback() 115 | raise HTTPException(status_code=500, detail=f"{str(e)}") 116 | 117 | # 获得cookie 118 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 119 | async def get_request_cookie(self): 120 | async with self.pool.acquire() as conn: 121 | async with conn.cursor(aiomysql.DictCursor) as cursor: 122 | try: 123 | # 先查询一个不被锁定且可用的cookie 124 | await cursor.execute(''' 125 | SELECT cookie FROM suno2openai 126 | WHERE songID IS NULL AND songID2 IS NULL AND count > 0 127 | ORDER BY RAND() LIMIT 1 128 | LOCK IN SHARE MODE; 129 | ''') 130 | row = await cursor.fetchone() 131 | if not row: 132 | raise HTTPException(status_code=429, detail="未找到可用的suno cookie") 133 | 134 | cookie = row['cookie'] 135 | # 开始事务 136 | await conn.begin() 137 | try: 138 | # 第二个查询,锁定获取的cookie 139 | await cursor.execute(''' 140 | SELECT cookie FROM suno2openai 141 | WHERE cookie = %s AND songID IS NULL AND songID2 IS NULL AND count > 0 142 | LIMIT 1 FOR UPDATE; 143 | ''', (cookie,)) 144 | row = await cursor.fetchone() 145 | if not row: 146 | raise HTTPException(status_code=429, detail="并发更新cookie时发生并发冲突,重试中...") 147 | 148 | cookie = row['cookie'] 149 | # 然后更新选中的cookie 150 | await cursor.execute(''' 151 | UPDATE suno2openai 152 | SET count = count - 1, songID = %s, songID2 = %s, time = CURRENT_TIMESTAMP 153 | WHERE cookie = %s AND songID IS NULL AND songID2 IS NULL AND count > 0; 154 | ''', ("tmp", "tmp", cookie)) 155 | await conn.commit() 156 | return cookie 157 | except Exception as update_error: 158 | raise update_error 159 | 160 | except aiomysql.MySQLError as mysql_error: 161 | await conn.rollback() 162 | if '锁等待超时' in str(mysql_error): 163 | raise HTTPException(status_code=504, detail="数据库锁等待超时,请稍后再试") 164 | else: 165 | raise HTTPException(status_code=429, detail=f"数据库错误:{str(mysql_error)}") 166 | 167 | except Exception as e: 168 | await conn.rollback() 169 | raise HTTPException(status_code=429, detail=f"发生未知错误:{str(e)}") 170 | 171 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 172 | async def insert_or_update_cookie(self, cookie, songID=None, songID2=None, count=0): 173 | await self.create_pool() 174 | async with self.pool.acquire() as conn: 175 | try: 176 | async with conn.cursor() as cur: 177 | # 查询现有记录的 songID 和 songID2 178 | select_sql = """ 179 | SELECT songID, songID2, time FROM suno2openai 180 | WHERE cookie = %s 181 | """ 182 | await cur.execute(select_sql, (cookie,)) 183 | result = await cur.fetchone() 184 | 185 | if result: 186 | db_songID, db_songID2, record_time = result 187 | current_time = datetime.now() 188 | time_diff = current_time - record_time 189 | 190 | # 如果 songID 和 songID2 不为空,并且时间差超过10分钟,则清空 songID 和 songID2 191 | if db_songID is not None and db_songID2 is not None and time_diff > timedelta(minutes=10): 192 | update_sql = """ 193 | UPDATE suno2openai 194 | SET songID = NULL, songID2 = NULL 195 | WHERE cookie = %s 196 | """ 197 | await cur.execute(update_sql, (cookie,)) 198 | await conn.commit() 199 | 200 | # 插入或更新记录 201 | sql = """ 202 | INSERT INTO suno2openai (cookie, songID, songID2, count, time) 203 | VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP) 204 | ON DUPLICATE KEY UPDATE count = VALUES(count) 205 | """ 206 | await cur.execute(sql, (cookie, songID, songID2, count)) 207 | await conn.commit() 208 | except Exception as e: 209 | await conn.rollback() 210 | raise HTTPException(status_code=500, detail=f"{str(e)}") 211 | 212 | # 删除单个cookie的songID 213 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 214 | async def delete_song_ids(self, cookie): 215 | await self.create_pool() 216 | async with self.pool.acquire() as conn: 217 | try: 218 | # 开始事务 219 | await conn.begin() 220 | async with conn.cursor() as cur: 221 | # 锁定目标行以防止其他事务修改 222 | await cur.execute(''' 223 | SELECT cookie FROM suno2openai WHERE cookie = %s FOR UPDATE; 224 | ''', (cookie,)) 225 | await cur.execute(''' 226 | UPDATE suno2openai 227 | SET songID = NULL, songID2 = NULL 228 | WHERE cookie = %s 229 | ''', cookie) 230 | await conn.commit() 231 | except Exception as e: 232 | await conn.rollback() 233 | raise HTTPException(status_code=500, detail=f"{str(e)}") 234 | 235 | # 删除所有的songID 236 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 237 | async def delete_songIDS(self): 238 | await self.create_pool() 239 | async with self.pool.acquire() as conn: 240 | try: 241 | # 开始事务 242 | await conn.begin() 243 | async with conn.cursor() as cur: 244 | # 锁定目标行以防止其他事务修改 245 | await cur.execute(''' 246 | SELECT cookie FROM suno2openai FOR UPDATE; 247 | ''') 248 | await cur.execute(''' 249 | UPDATE suno2openai 250 | SET songID = NULL, songID2 = NULL; 251 | ''') 252 | await conn.commit() 253 | rows_updated = cur.rowcount 254 | return rows_updated 255 | except Exception as e: 256 | await conn.rollback() 257 | raise HTTPException(status_code=500, detail=f"{str(e)}") 258 | 259 | # 更新cookie的count 260 | async def update_cookie_count(self, cookie, count_increment, update=None): 261 | await self.create_pool() 262 | async with self.pool.acquire() as conn: 263 | try: 264 | # 开始事务 265 | await conn.begin() 266 | async with conn.cursor() as cur: 267 | # 锁定目标行以防止其他事务修改 268 | await cur.execute(''' 269 | SELECT cookie FROM suno2openai WHERE cookie = %s FOR UPDATE; 270 | ''', (cookie,)) 271 | if update is not None: 272 | await cur.execute(''' 273 | UPDATE suno2openai 274 | SET count = %s 275 | WHERE cookie = %s 276 | ''', (count_increment, cookie)) 277 | else: 278 | await cur.execute(''' 279 | UPDATE suno2openai 280 | SET count = count + %s 281 | WHERE cookie = %s 282 | ''', (count_increment, cookie)) 283 | await conn.commit() 284 | except Exception as e: 285 | await conn.rollback() 286 | raise HTTPException(status_code=500, detail=f"{str(e)}") 287 | 288 | async def query_cookies(self): 289 | await self.create_pool() 290 | async with self.pool.acquire() as conn: 291 | try: 292 | async with conn.cursor(aiomysql.DictCursor) as cur: 293 | await cur.execute('SELECT * FROM suno2openai') 294 | await conn.commit() 295 | return await cur.fetchall() 296 | except Exception as e: 297 | raise HTTPException(status_code=500, detail=f"{str(e)}") 298 | 299 | async def update_song_ids_by_cookie(self, cookie, songID1, songID2): 300 | await self.create_pool() 301 | async with self.pool.acquire() as conn: 302 | try: 303 | async with conn.cursor() as cur: 304 | await cur.execute(''' 305 | UPDATE suno2openai 306 | SET count = count - 1, songID = %s, songID2 = %s, time = CURRENT_TIMESTAMP 307 | WHERE cookie = %s 308 | ''', (songID1, songID2, cookie)) 309 | await conn.commit() 310 | except Exception as e: 311 | await conn.rollback() 312 | raise HTTPException(status_code=500, detail=f"{str(e)}") 313 | 314 | # 获取所有 process 的count总和 315 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 316 | async def get_cookies_count(self): 317 | await self.create_pool() 318 | try: 319 | async with self.pool.acquire() as conn: 320 | try: 321 | async with conn.cursor(aiomysql.DictCursor) as cur: 322 | await cur.execute("SELECT SUM(count) AS total_count FROM suno2openai") 323 | result = await cur.fetchone() 324 | await conn.commit() 325 | return result['total_count'] if result['total_count'] is not None else 0 326 | except Exception as e: 327 | raise HTTPException(status_code=500, detail=f"{str(e)}") 328 | except Exception as e: 329 | logger.error(f"Unexpected error: {e}") 330 | return 0 331 | 332 | # 获取有效的 process 的count总和 333 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 334 | async def get_valid_cookies_count(self): 335 | await self.create_pool() 336 | try: 337 | async with self.pool.acquire() as conn: 338 | try: 339 | async with conn.cursor(aiomysql.DictCursor) as cur: 340 | await cur.execute("SELECT COUNT(cookie) AS total_count FROM suno2openai WHERE count >= 0") 341 | result = await cur.fetchone() 342 | await conn.commit() 343 | return result['total_count'] if result['total_count'] is not None else 0 344 | except Exception as e: 345 | raise HTTPException(status_code=500, detail=f"{str(e)}") 346 | except Exception as e: 347 | logger.error(f"Unexpected error: {e}") 348 | return 0 349 | 350 | # 获取 process 351 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 352 | async def get_cookies(self): 353 | await self.create_pool() 354 | async with self.pool.acquire() as conn: 355 | try: 356 | async with conn.cursor(aiomysql.DictCursor) as cur: 357 | await cur.execute("SELECT cookie FROM suno2openai") 358 | await conn.commit() 359 | return await cur.fetchall() 360 | except Exception as e: 361 | raise HTTPException(status_code=500, detail=f"{str(e)}") 362 | 363 | # 获取无效的cookies 364 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 365 | async def get_invalid_cookies(self): 366 | await self.create_pool() 367 | async with self.pool.acquire() as conn: 368 | try: 369 | async with conn.cursor(aiomysql.DictCursor) as cur: 370 | await cur.execute("SELECT cookie FROM suno2openai WHERE count < 0") 371 | await conn.commit() 372 | return await cur.fetchall() 373 | except Exception as e: 374 | raise HTTPException(status_code=500, detail=f"{str(e)}") 375 | 376 | # 获取 process 和 count 377 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 378 | async def get_all_cookies(self): 379 | await self.create_pool() 380 | async with self.pool.acquire() as conn: 381 | try: 382 | async with conn.cursor(aiomysql.DictCursor) as cur: 383 | await cur.execute("SELECT cookie, songID, songID2, count, time, add_time FROM suno2openai") 384 | result = await cur.fetchall() 385 | # 将所有字段转化为字符串 386 | for row in result: 387 | for key in row: 388 | row[key] = str(row[key]) 389 | await conn.commit() 390 | return json.dumps(result) 391 | except Exception as e: 392 | raise HTTPException(status_code=500, detail=f"{str(e)}") 393 | 394 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 395 | async def get_row_cookies(self): 396 | try: 397 | await self.create_pool() 398 | async with self.pool.acquire() as conn: 399 | try: 400 | async with conn.cursor(aiomysql.DictCursor) as cur: 401 | await cur.execute("SELECT cookie FROM suno2openai") 402 | results = await cur.fetchall() 403 | cookies = [] 404 | for row in results: 405 | cookies.append(row['cookie']) 406 | await conn.commit() 407 | return cookies 408 | except Exception as e: 409 | logger.error(f"Database operation failed: {e}") 410 | raise HTTPException(status_code=500, detail=f"Database operation failed: {str(e)}") 411 | except Exception as e: 412 | logger.error(f"Failed to acquire connection pool: {e}") # Improved logging 413 | raise HTTPException(status_code=500, detail=f"Failed to acquire connection pool: {str(e)}") 414 | 415 | # 删除相应的cookies 416 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 417 | async def delete_cookies(self, cookie: str): 418 | await self.create_pool() 419 | async with self.pool.acquire() as conn: 420 | try: 421 | # 开始事务 422 | await conn.begin() 423 | async with conn.cursor(aiomysql.DictCursor) as cur: 424 | # 锁定目标行以防止其他事务修改 425 | await cur.execute(''' 426 | SELECT cookie FROM suno2openai WHERE cookie = %s FOR UPDATE; 427 | ''', (cookie,)) 428 | await cur.execute("DELETE FROM suno2openai WHERE cookie = %s", cookie) 429 | await conn.commit() 430 | return True 431 | except Exception as e: 432 | await conn.rollback() 433 | raise HTTPException(status_code=500, detail=f"{str(e)}") 434 | 435 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 436 | async def update_captcha_token(self, cookie: str, captcha_token: str): 437 | """更新指定cookie的captcha_token""" 438 | await self.create_pool() 439 | async with self.pool.acquire() as conn: 440 | try: 441 | async with conn.cursor() as cur: 442 | await cur.execute(''' 443 | UPDATE suno2openai 444 | SET captcha_token = %s 445 | WHERE cookie = %s 446 | ''', (captcha_token, cookie)) 447 | await conn.commit() 448 | except Exception as e: 449 | await conn.rollback() 450 | raise HTTPException(status_code=500, detail=f"更新captcha_token失败: {str(e)}") 451 | 452 | @retry(stop=stop_after_attempt(RETRIES + 2), wait=wait_random(min=0.10, max=0.3)) 453 | async def get_captcha_token(self, cookie: str) -> str: 454 | """获取指定cookie的captcha_token""" 455 | await self.create_pool() 456 | async with self.pool.acquire() as conn: 457 | try: 458 | async with conn.cursor(aiomysql.DictCursor) as cur: 459 | await cur.execute(''' 460 | SELECT captcha_token 461 | FROM suno2openai 462 | WHERE cookie = %s 463 | ''', (cookie,)) 464 | result = await cur.fetchone() 465 | return result['captcha_token'] if result else None 466 | except Exception as e: 467 | raise HTTPException(status_code=500, detail=f"获取captcha_token失败: {str(e)}") 468 | 469 | # async def main(): 470 | # db_manager = DatabaseManager('127.0.0.1', 3306, 'root', '12345678', 'WSunoAPI') 471 | # await db_manager.create_pool() 472 | # # await db_manager.create_database_and_table() 473 | # await db_manager.insert_cookie('example_cookie', 1, True) 474 | # await db_manager.update_cookie_count('example_cookie', 5) 475 | # await db_manager.update_cookie_working('example_cookie', False) 476 | # process = await db_manager.query_cookies() 477 | # cookie = await db_manager.get_non_working_cookie() 478 | # 479 | # if __name__ == "__main__": 480 | # asyncio.run(main()) 481 | -------------------------------------------------------------------------------- /util/tool.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import string 4 | import time 5 | 6 | import tiktoken 7 | 8 | from util.config import RETRIES, MAX_TIME 9 | from util.logger import logger 10 | 11 | 12 | def generate_random_string_async(length): 13 | return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) 14 | 15 | 16 | def generate_timestamp_async(): 17 | return int(time.time()) 18 | 19 | 20 | def calculate_token_costs(input_prompt: str, output_prompt: str, model_name: str) -> (int, int): 21 | encoding = tiktoken.encoding_for_model(model_name) 22 | 23 | # Encode the prompts 24 | input_tokens = encoding.encode(input_prompt) 25 | output_tokens = encoding.encode(output_prompt) 26 | 27 | # Count the tokens 28 | input_token_count = len(input_tokens) 29 | output_token_count = len(output_tokens) 30 | 31 | return input_token_count, output_token_count 32 | 33 | 34 | def check_status_complete(response, start_time): 35 | try: 36 | if not isinstance(response, list): 37 | raise ValueError("Invalid response format: expected a list") 38 | 39 | if time.time() - start_time > 60 * MAX_TIME: 40 | return True 41 | 42 | for item in response: 43 | if item.get("status", None) == "complete": 44 | return True 45 | 46 | return False 47 | except Exception as e: 48 | raise ValueError(f"Invalid JSON response: {e}") 49 | 50 | 51 | async def get_clips_ids(response: json): 52 | try: 53 | if 'clips' in response and isinstance(response['clips'], list): 54 | clip_ids = [clip['id'] for clip in response['clips']] 55 | return clip_ids 56 | else: 57 | raise ValueError("Invalid response format: 'clips' key not found or is not a list.") 58 | except json.JSONDecodeError: 59 | raise ValueError("Invalid JSON response") 60 | 61 | 62 | # async def get_token(): 63 | # cookieSelected = await db_manager.get_token() 64 | # return cookieSelected 65 | 66 | 67 | async def deleteSongID(db_manager, cookie): 68 | for attempt in range(RETRIES): 69 | try: 70 | await db_manager.delete_song_ids(cookie) 71 | return 72 | except Exception as e: 73 | if attempt > RETRIES - 1: 74 | logger.info(f"删除音乐songID失败: {e}") 75 | -------------------------------------------------------------------------------- /util/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from http.cookies import SimpleCookie 4 | 5 | import aiohttp 6 | from curl_cffi.requests import Cookies 7 | from dotenv import load_dotenv 8 | 9 | from util.config import PROXY 10 | 11 | load_dotenv() 12 | 13 | BASE_URL = os.getenv("BASE_URL") 14 | 15 | COMMON_HEADERS = { 16 | 'Content-Type': 'text/plain;charset=UTF-8', 17 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' 18 | 'Chrome/123.0.0.0 Safari/537.36', 19 | "Referer": "https://app.suno.ai/", 20 | "Origin": "https://app.suno.ai", 21 | } 22 | 23 | 24 | async def fetch(url, headers=None, data=None, method="POST", captcha_token=None): 25 | try: 26 | if headers is None: 27 | headers = {} 28 | headers.update(COMMON_HEADERS) 29 | 30 | # 如果提供了captcha_token,添加到请求头中 31 | if captcha_token: 32 | headers['X-Captcha-Token'] = captcha_token 33 | 34 | if data is not None: 35 | data = json.dumps(data) 36 | 37 | async with aiohttp.ClientSession() as session: 38 | async with session.request(method=method, url=url, data=data, headers=headers, proxy=PROXY) as resp: 39 | if resp.status != 200: 40 | raise ValueError(f"请求状态码:{resp.status},请求报错:{await resp.text()}") 41 | return await resp.json() 42 | except Exception as e: 43 | raise ValueError(f"Error fetching data:{e}") 44 | 45 | 46 | async def get_feed(ids, token): 47 | try: 48 | headers = { 49 | "Authorization": f"Bearer {token}" 50 | } 51 | api_url = f"{BASE_URL}/api/feed/?ids={ids}" 52 | response = await fetch(api_url, headers, method="GET") 53 | return response 54 | except Exception as e: 55 | raise ValueError(f"Error fetching feed: {e}") 56 | 57 | 58 | async def generate_music(data, token): 59 | try: 60 | headers = { 61 | "Accept": "*/*", 62 | "Accept-Encoding": "gzip, deflate, br, zstd", 63 | "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", 64 | "Access-Control-Request-Headers": "affiliate-id,authorization", 65 | "Access-Control-Request-Method": "POST", 66 | "Origin": "https://suno.com", 67 | "Priority": "u=1, i", 68 | "Referer": "https://suno.com/", 69 | "Sec-Fetch-Dest": "empty", 70 | "Sec-Fetch-Mode": "cors", 71 | "Sec-Fetch-Site": "cross-site", 72 | "Authorization": f"Bearer {token}", 73 | "Content-Type": "application/json", 74 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " 75 | "Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0", 76 | } 77 | api_url = f"{BASE_URL}/api/generate/v2/" 78 | response = await fetch(api_url, headers, data) 79 | return response 80 | except Exception as e: 81 | raise ValueError(f"Error generating music: {e}") 82 | 83 | 84 | async def generate_lyrics(prompt, token): 85 | try: 86 | headers = { 87 | "Authorization": f"Bearer {token}" 88 | } 89 | api_url = f"{BASE_URL}/api/generate/lyrics/" 90 | data = {"prompt": prompt} 91 | return await fetch(api_url, headers, data) 92 | except Exception as e: 93 | raise ValueError(f"Error generating lyrics: {e}") 94 | 95 | 96 | async def get_lyrics(lid, token): 97 | try: 98 | headers = { 99 | "Authorization": f"Bearer {token}" 100 | } 101 | api_url = f"{BASE_URL}/api/generate/lyrics/{lid}" 102 | return await fetch(api_url, headers, method="GET") 103 | except Exception as e: 104 | raise ValueError(f"Error getting lyrics: {e}") 105 | 106 | 107 | def parse_cookie_string(cookie_string: str) -> Cookies: 108 | cookie = SimpleCookie() 109 | cookie.load(cookie_string) 110 | cookies_dict = {} 111 | try: 112 | for key, morsel in cookie.items(): 113 | cookies_dict[key] = morsel.value 114 | except (IndexError, AttributeError) as e: 115 | raise Exception(f"解析cookie时出错: {e}") 116 | return Cookies(cookies_dict) 117 | --------------------------------------------------------------------------------