Admin
commited on
Commit
·
22a3c56
1
Parent(s):
b4b63c6
整合sora2api
Browse files- .gitignore +51 -0
- Dockerfile +4 -5
- LICENSE +22 -0
- README.md +406 -7
- config/setting.toml +46 -0
- config/setting_warp.toml +46 -0
- docker-compose.warp.yml +36 -0
- docker-compose.yml +14 -0
- logs.txt +0 -0
- main.py +12 -0
- requirements.txt +15 -6
- src/__init__.py +4 -0
- src/api/__init__.py +7 -0
- src/api/admin.py +776 -0
- src/api/routes.py +455 -0
- src/core/__init__.py +14 -0
- src/core/auth.py +39 -0
- src/core/config.py +212 -0
- src/core/database.py +1012 -0
- src/core/logger.py +226 -0
- src/core/models.py +146 -0
- src/main.py +144 -0
- src/services/__init__.py +17 -0
- src/services/file_cache.py +212 -0
- src/services/generation_handler.py +1475 -0
- src/services/load_balancer.py +94 -0
- src/services/proxy_manager.py +25 -0
- src/services/sora_client.py +614 -0
- src/services/token_lock.py +117 -0
- src/services/token_manager.py +947 -0
- static/login.html +53 -0
- static/manage.html +617 -0
.gitignore
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
|
| 23 |
+
# Virtual Environment
|
| 24 |
+
venv/
|
| 25 |
+
ENV/
|
| 26 |
+
env/
|
| 27 |
+
|
| 28 |
+
# Database
|
| 29 |
+
*.db
|
| 30 |
+
*.sqlite
|
| 31 |
+
*.sqlite3
|
| 32 |
+
data/*.db
|
| 33 |
+
data/*.sqlite
|
| 34 |
+
|
| 35 |
+
# IDE
|
| 36 |
+
.vscode/
|
| 37 |
+
.idea/
|
| 38 |
+
*.swp
|
| 39 |
+
*.swo
|
| 40 |
+
*~
|
| 41 |
+
|
| 42 |
+
# OS
|
| 43 |
+
.DS_Store
|
| 44 |
+
Thumbs.db
|
| 45 |
+
|
| 46 |
+
# Logs
|
| 47 |
+
*.log
|
| 48 |
+
|
| 49 |
+
# Environment
|
| 50 |
+
.env
|
| 51 |
+
.env.local
|
Dockerfile
CHANGED
|
@@ -2,12 +2,11 @@ FROM python:3.11-slim
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
COPY ./app /app/app
|
| 6 |
COPY requirements.txt .
|
| 7 |
-
|
| 8 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
|
|
|
| 12 |
|
| 13 |
-
CMD ["
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
|
|
|
| 5 |
COPY requirements.txt .
|
|
|
|
| 6 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 8000
|
| 11 |
|
| 12 |
+
CMD ["python", "main.py"]
|
LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 Sora2API Contributors
|
| 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
CHANGED
|
@@ -1,10 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
| 1 |
+
# Sora2API
|
| 2 |
+
|
| 3 |
+
<div align="center">
|
| 4 |
+
|
| 5 |
+
[](LICENSE)
|
| 6 |
+
[](https://www.python.org/)
|
| 7 |
+
[](https://fastapi.tiangolo.com/)
|
| 8 |
+
[](https://www.docker.com/)
|
| 9 |
+
|
| 10 |
+
**一个功能完整的 OpenAI 兼容 API 服务,为 Sora 提供统一的接口**
|
| 11 |
+
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 📋 目录
|
| 17 |
+
|
| 18 |
+
- [功能特性](#功能特性)
|
| 19 |
+
- [快速开始](#快速开始)
|
| 20 |
+
- [使用指南](#使用指南)
|
| 21 |
+
- [快速参考](#快速参考)
|
| 22 |
+
- [管理后台](#管理后台)
|
| 23 |
+
- [API 调用](#api-调用)
|
| 24 |
+
- [视频角色功能](#视频角色功能)
|
| 25 |
+
- [常见问题](#常见问题)
|
| 26 |
+
- [许可证](#许可证)
|
| 27 |
+
|
| 28 |
---
|
| 29 |
+
|
| 30 |
+
## ✨ 功能特性
|
| 31 |
+
|
| 32 |
+
### 核心功能
|
| 33 |
+
- 🎨 **文生图** - 根据文本描述生成图片
|
| 34 |
+
- 🖼️ **图生图** - 基于上传的图片进行创意变换
|
| 35 |
+
- 🎬 **文生视频** - 根据文本描述生成视频
|
| 36 |
+
- 🎥 **图生视频** - 基于图片生成相关视频
|
| 37 |
+
- 📊 **多尺寸支持** - 横屏、竖屏等多种规格
|
| 38 |
+
- 🎭 **视频角色功能** - 创建角色,生成角色视频
|
| 39 |
+
- 🎬 **Remix 功能** - 基于已有视频继续创作
|
| 40 |
+
|
| 41 |
+
### 高级特性
|
| 42 |
+
- 🔐 **Token 管理** - 支持多 Token 管理和轮询负载均衡
|
| 43 |
+
- 🌐 **代理支持** - 支持 HTTP 和 SOCKS5 代理
|
| 44 |
+
- 📝 **详细日志** - 完整的请求/响应日志记录
|
| 45 |
+
- 🔄 **异步处理** - 高效的异步任务处理
|
| 46 |
+
- 💾 **数据持久化** - SQLite 数据库存储
|
| 47 |
+
- 🎯 **OpenAI 兼容** - 完全兼容 OpenAI API 格式
|
| 48 |
+
- 🛡️ **安全认证** - API Key 验证和权限管理
|
| 49 |
+
- 📱 **Web 管理界面** - 直观的管理后台
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## 🚀 快速开始
|
| 54 |
+
|
| 55 |
+
### 前置要求
|
| 56 |
+
|
| 57 |
+
- Docker 和 Docker Compose(推荐)
|
| 58 |
+
- 或 Python 3.8+
|
| 59 |
+
|
| 60 |
+
### 方式一:Docker 部署(推荐)
|
| 61 |
+
|
| 62 |
+
#### 标准模式(不使用代理)
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
# 克隆项目
|
| 66 |
+
git clone https://github.com/TheSmallHanCat/sora2api.git
|
| 67 |
+
cd sora2api
|
| 68 |
+
|
| 69 |
+
# 启动服务
|
| 70 |
+
docker-compose up -d
|
| 71 |
+
|
| 72 |
+
# 查看日志
|
| 73 |
+
docker-compose logs -f
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
#### WARP 模式(使用代理)
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
# 使用 WARP 代理启动
|
| 80 |
+
docker-compose -f docker-compose.warp.yml up -d
|
| 81 |
+
|
| 82 |
+
# 查看日志
|
| 83 |
+
docker-compose -f docker-compose.warp.yml logs -f
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### 方式二:本地部署
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
# 克隆项目
|
| 90 |
+
git clone https://github.com/TheSmallHanCat/sora2api.git
|
| 91 |
+
cd sora2api
|
| 92 |
+
|
| 93 |
+
# 创建虚拟环境
|
| 94 |
+
python -m venv venv
|
| 95 |
+
|
| 96 |
+
# 激活虚拟环境
|
| 97 |
+
# Windows
|
| 98 |
+
venv\Scripts\activate
|
| 99 |
+
# Linux/Mac
|
| 100 |
+
source venv/bin/activate
|
| 101 |
+
|
| 102 |
+
# 安装依赖
|
| 103 |
+
pip install -r requirements.txt
|
| 104 |
+
|
| 105 |
+
# 启动服务
|
| 106 |
+
python main.py
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
### 首次启动
|
| 110 |
+
|
| 111 |
+
服务启动后,访问管理后台进行初始化配置:
|
| 112 |
+
|
| 113 |
+
- **地址**: http://localhost:8000
|
| 114 |
+
- **用户名**: `admin`
|
| 115 |
+
- **密码**: `admin`
|
| 116 |
+
|
| 117 |
+
⚠️ **重要**: 首次登录后请立即修改密码!
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
### 快速参考
|
| 122 |
+
|
| 123 |
+
| 功能 | 模型 | 说明 |
|
| 124 |
+
|------|------|------|
|
| 125 |
+
| 文生图 | `sora-image*` | 使用 `content` 为字符串 |
|
| 126 |
+
| 图生图 | `sora-image*` | 使用 `content` 数组 + `image_url` |
|
| 127 |
+
| 文生视频 | `sora-video*` | 使用 `content` 为字符串 |
|
| 128 |
+
| 图生视频 | `sora-video*` | 使用 `content` 数组 + `image_url` |
|
| 129 |
+
| 创建角色 | `sora-video*` | 使用 `content` 数组 + `input_video` |
|
| 130 |
+
| 角色生成视频 | `sora-video*` | 使用 `content` 数组 + `input_video` + 文本 |
|
| 131 |
+
| Remix | `sora-video*` | 在 `content` 中包含 Remix ID |
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
### API 调用
|
| 136 |
+
|
| 137 |
+
#### 基本信息(OpenAI标准格式,需要使用流式)
|
| 138 |
+
|
| 139 |
+
- **端点**: `http://localhost:8000/v1/chat/completions`
|
| 140 |
+
- **认证**: 在请求头中添加 `Authorization: Bearer YOUR_API_KEY`
|
| 141 |
+
- **默认 API Key**: `han1234`(建议修改)
|
| 142 |
+
|
| 143 |
+
#### 支持的模型
|
| 144 |
+
|
| 145 |
+
**图片模型**
|
| 146 |
+
|
| 147 |
+
| 模型 | 说明 | 尺寸 |
|
| 148 |
+
|------|------|------|
|
| 149 |
+
| `sora-image` | 文生图(默认) | 360×360 |
|
| 150 |
+
| `sora-image-landscape` | 文生图(横屏) | 540×360 |
|
| 151 |
+
| `sora-image-portrait` | 文生图(竖屏) | 360×540 |
|
| 152 |
+
|
| 153 |
+
**视频模型**
|
| 154 |
+
|
| 155 |
+
| 模型 | 时长 | 方向 | 说明 |
|
| 156 |
+
|------|------|------|------|
|
| 157 |
+
| `sora-video-10s` | 10秒 | 横屏 | 文生视频/图生视频 |
|
| 158 |
+
| `sora-video-15s` | 15秒 | 横屏 | 文生视频/图生视频 |
|
| 159 |
+
| `sora-video-landscape-10s` | 10秒 | 横屏 | 文生视频/图生视频 |
|
| 160 |
+
| `sora-video-landscape-15s` | 15秒 | 横屏 | 文生视频/图生视频 |
|
| 161 |
+
| `sora-video-portrait-10s` | 10秒 | 竖屏 | 文生视频/图生视频 |
|
| 162 |
+
| `sora-video-portrait-15s` | 15秒 | 竖屏 | 文生视频/图生视频 |
|
| 163 |
+
|
| 164 |
+
#### 请求示例
|
| 165 |
+
|
| 166 |
+
**文生图**
|
| 167 |
+
|
| 168 |
+
```bash
|
| 169 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 170 |
+
-H "Authorization: Bearer han1234" \
|
| 171 |
+
-H "Content-Type: application/json" \
|
| 172 |
+
-d '{
|
| 173 |
+
"model": "sora-image",
|
| 174 |
+
"messages": [
|
| 175 |
+
{
|
| 176 |
+
"role": "user",
|
| 177 |
+
"content": "一只可爱的小猫咪"
|
| 178 |
+
}
|
| 179 |
+
]
|
| 180 |
+
}'
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
**图��图**
|
| 184 |
+
|
| 185 |
+
```bash
|
| 186 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 187 |
+
-H "Authorization: Bearer han1234" \
|
| 188 |
+
-H "Content-Type: application/json" \
|
| 189 |
+
-d '{
|
| 190 |
+
"model": "sora-image",
|
| 191 |
+
"messages": [
|
| 192 |
+
{
|
| 193 |
+
"role": "user",
|
| 194 |
+
"content": [
|
| 195 |
+
{
|
| 196 |
+
"type": "text",
|
| 197 |
+
"text": "将这张图片变成油画风格"
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"type": "image_url",
|
| 201 |
+
"image_url": {
|
| 202 |
+
"url": "data:image/png;base64,<base64_encoded_image_data>"
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
]
|
| 206 |
+
}
|
| 207 |
+
],
|
| 208 |
+
"stream": true
|
| 209 |
+
}'
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
**文生视频**
|
| 213 |
+
|
| 214 |
+
```bash
|
| 215 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 216 |
+
-H "Authorization: Bearer han1234" \
|
| 217 |
+
-H "Content-Type: application/json" \
|
| 218 |
+
-d '{
|
| 219 |
+
"model": "sora-video-landscape-10s",
|
| 220 |
+
"messages": [
|
| 221 |
+
{
|
| 222 |
+
"role": "user",
|
| 223 |
+
"content": "一只小猫在草地上奔跑"
|
| 224 |
+
}
|
| 225 |
+
],
|
| 226 |
+
"stream": true
|
| 227 |
+
}'
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
**图生视频**
|
| 231 |
+
|
| 232 |
+
```bash
|
| 233 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 234 |
+
-H "Authorization: Bearer han1234" \
|
| 235 |
+
-H "Content-Type: application/json" \
|
| 236 |
+
-d '{
|
| 237 |
+
"model": "sora-video-landscape-10s",
|
| 238 |
+
"messages": [
|
| 239 |
+
{
|
| 240 |
+
"role": "user",
|
| 241 |
+
"content": [
|
| 242 |
+
{
|
| 243 |
+
"type": "text",
|
| 244 |
+
"text": "这只猫在跳舞"
|
| 245 |
+
},
|
| 246 |
+
{
|
| 247 |
+
"type": "image_url",
|
| 248 |
+
"image_url": {
|
| 249 |
+
"url": "data:image/png;base64,<base64_encoded_image_data>"
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
]
|
| 253 |
+
}
|
| 254 |
+
],
|
| 255 |
+
"stream": true
|
| 256 |
+
}'
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
**视频Remix(基于已有视频继续创作)**
|
| 260 |
+
|
| 261 |
+
```bash
|
| 262 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 263 |
+
-H "Authorization: Bearer han1234" \
|
| 264 |
+
-H "Content-Type: application/json" \
|
| 265 |
+
-d '{
|
| 266 |
+
"model": "sora-video-landscape-10s",
|
| 267 |
+
"messages": [
|
| 268 |
+
{
|
| 269 |
+
"role": "user",
|
| 270 |
+
"content": "https://sora.chatgpt.com/p/s_68e3a06dcd888191b150971da152c1f5改成水墨画风格"
|
| 271 |
+
}
|
| 272 |
+
]
|
| 273 |
+
}'
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
### 视频角色功能
|
| 277 |
+
|
| 278 |
+
Sora2API 支持**视频角色生成**功能。
|
| 279 |
+
|
| 280 |
+
#### 功能说明
|
| 281 |
+
|
| 282 |
+
- **角色创建**: 如果只有视频,无prompt,则生成角色自动提取角色信息,输出角色名
|
| 283 |
+
- **角色生成**: 有视频、prompt,则上传视频创建角色,使用角色和prompt进行生成,输出视频
|
| 284 |
+
|
| 285 |
+
#### API调用(OpenAI标准格式,需要使用流式)
|
| 286 |
+
|
| 287 |
+
**场景 1: 仅创建角色(不生成视频)**
|
| 288 |
+
|
| 289 |
+
上传视频提取角色信息,获取角色名称和头像。
|
| 290 |
+
|
| 291 |
+
```bash
|
| 292 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 293 |
+
-H "Authorization: Bearer han1234" \
|
| 294 |
+
-H "Content-Type: application/json" \
|
| 295 |
+
-d '{
|
| 296 |
+
"model": "sora-video-landscape-10s",
|
| 297 |
+
"messages": [
|
| 298 |
+
{
|
| 299 |
+
"role": "user",
|
| 300 |
+
"content": [
|
| 301 |
+
{
|
| 302 |
+
"type": "input_video",
|
| 303 |
+
"videoUrl": {
|
| 304 |
+
"url": "data:video/mp4;base64,<base64_encoded_video_data>"
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
]
|
| 308 |
+
}
|
| 309 |
+
],
|
| 310 |
+
"stream": true
|
| 311 |
+
}'
|
| 312 |
+
```
|
| 313 |
+
|
| 314 |
+
**场景 2: 创建角色并生成视频**
|
| 315 |
+
|
| 316 |
+
上传视频创建角色,然后使用该角色生成新视频。
|
| 317 |
+
|
| 318 |
+
```bash
|
| 319 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 320 |
+
-H "Authorization: Bearer han1234" \
|
| 321 |
+
-H "Content-Type: application/json" \
|
| 322 |
+
-d '{
|
| 323 |
+
"model": "sora-video-landscape-10s",
|
| 324 |
+
"messages": [
|
| 325 |
+
{
|
| 326 |
+
"role": "user",
|
| 327 |
+
"content": [
|
| 328 |
+
{
|
| 329 |
+
"type": "input_video",
|
| 330 |
+
"videoUrl": {
|
| 331 |
+
"url": "data:video/mp4;base64,<base64_encoded_video_data>"
|
| 332 |
+
}
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"type": "text",
|
| 336 |
+
"text": "角色做一个跳舞的动作"
|
| 337 |
+
}
|
| 338 |
+
]
|
| 339 |
+
}
|
| 340 |
+
],
|
| 341 |
+
"stream": true
|
| 342 |
+
}'
|
| 343 |
+
```
|
| 344 |
+
|
| 345 |
+
#### Python 代码示例
|
| 346 |
+
|
| 347 |
+
```python
|
| 348 |
+
import requests
|
| 349 |
+
import base64
|
| 350 |
+
|
| 351 |
+
# 读取视频文件并编码为 Base64
|
| 352 |
+
with open("video.mp4", "rb") as f:
|
| 353 |
+
video_data = base64.b64encode(f.read()).decode("utf-8")
|
| 354 |
+
|
| 355 |
+
# 仅创建角色
|
| 356 |
+
response = requests.post(
|
| 357 |
+
"http://localhost:8000/v1/chat/completions",
|
| 358 |
+
headers={
|
| 359 |
+
"Authorization": "Bearer han1234",
|
| 360 |
+
"Content-Type": "application/json"
|
| 361 |
+
},
|
| 362 |
+
json={
|
| 363 |
+
"model": "sora-video-landscape-10s",
|
| 364 |
+
"messages": [
|
| 365 |
+
{
|
| 366 |
+
"role": "user",
|
| 367 |
+
"content": [
|
| 368 |
+
{
|
| 369 |
+
"type": "input_video",
|
| 370 |
+
"videoUrl": {
|
| 371 |
+
"url": f"data:video/mp4;base64,{video_data}"
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
]
|
| 375 |
+
}
|
| 376 |
+
],
|
| 377 |
+
"stream": True
|
| 378 |
+
},
|
| 379 |
+
stream=True
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
# 处理流式响应
|
| 383 |
+
for line in response.iter_lines():
|
| 384 |
+
if line:
|
| 385 |
+
print(line.decode("utf-8"))
|
| 386 |
+
```
|
| 387 |
+
|
| 388 |
+
---
|
| 389 |
+
|
| 390 |
+
## 📄 许可证
|
| 391 |
+
|
| 392 |
+
本项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
|
| 393 |
+
|
| 394 |
+
---
|
| 395 |
+
|
| 396 |
+
## 🙏 致谢
|
| 397 |
+
|
| 398 |
+
感谢所有贡献者和使用者的支持!
|
| 399 |
+
|
| 400 |
+
---
|
| 401 |
+
|
| 402 |
+
## 📞 联系方式
|
| 403 |
+
|
| 404 |
+
- 提交 Issue:[GitHub Issues](https://github.com/TheSmallHanCat/sora2api/issues)
|
| 405 |
+
- 讨论:[GitHub Discussions](https://github.com/TheSmallHanCat/sora2api/discussions)
|
| 406 |
+
|
| 407 |
---
|
| 408 |
|
| 409 |
+
**⭐ 如果这个项目对你有帮助,请给个 Star!**
|
config/setting.toml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[global]
|
| 2 |
+
api_key = "han1234"
|
| 3 |
+
admin_username = "admin"
|
| 4 |
+
admin_password = "admin"
|
| 5 |
+
|
| 6 |
+
[sora]
|
| 7 |
+
base_url = "https://sora.chatgpt.com/backend"
|
| 8 |
+
timeout = 120
|
| 9 |
+
max_retries = 3
|
| 10 |
+
poll_interval = 2.5
|
| 11 |
+
max_poll_attempts = 600
|
| 12 |
+
|
| 13 |
+
[server]
|
| 14 |
+
host = "0.0.0.0"
|
| 15 |
+
port = 8000
|
| 16 |
+
|
| 17 |
+
[debug]
|
| 18 |
+
enabled = false
|
| 19 |
+
log_requests = true
|
| 20 |
+
log_responses = true
|
| 21 |
+
mask_token = true
|
| 22 |
+
|
| 23 |
+
[cache]
|
| 24 |
+
enabled = false
|
| 25 |
+
timeout = 600
|
| 26 |
+
base_url = "http://127.0.0.1:8000"
|
| 27 |
+
|
| 28 |
+
[generation]
|
| 29 |
+
image_timeout = 300
|
| 30 |
+
video_timeout = 1500
|
| 31 |
+
|
| 32 |
+
[admin]
|
| 33 |
+
error_ban_threshold = 3
|
| 34 |
+
|
| 35 |
+
[proxy]
|
| 36 |
+
proxy_enabled = false
|
| 37 |
+
proxy_url = ""
|
| 38 |
+
|
| 39 |
+
[watermark_free]
|
| 40 |
+
watermark_free_enabled = false
|
| 41 |
+
parse_method = "third_party"
|
| 42 |
+
custom_parse_url = ""
|
| 43 |
+
custom_parse_token = ""
|
| 44 |
+
|
| 45 |
+
[token_refresh]
|
| 46 |
+
at_auto_refresh_enabled = false
|
config/setting_warp.toml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[global]
|
| 2 |
+
api_key = "han1234"
|
| 3 |
+
admin_username = "admin"
|
| 4 |
+
admin_password = "admin"
|
| 5 |
+
|
| 6 |
+
[sora]
|
| 7 |
+
base_url = "https://sora.chatgpt.com/backend"
|
| 8 |
+
timeout = 120
|
| 9 |
+
max_retries = 3
|
| 10 |
+
poll_interval = 2.5
|
| 11 |
+
max_poll_attempts = 600
|
| 12 |
+
|
| 13 |
+
[server]
|
| 14 |
+
host = "0.0.0.0"
|
| 15 |
+
port = 8000
|
| 16 |
+
|
| 17 |
+
[debug]
|
| 18 |
+
enabled = false
|
| 19 |
+
log_requests = true
|
| 20 |
+
log_responses = true
|
| 21 |
+
mask_token = true
|
| 22 |
+
|
| 23 |
+
[cache]
|
| 24 |
+
enabled = true
|
| 25 |
+
timeout = 600
|
| 26 |
+
base_url = "http://127.0.0.1:8000"
|
| 27 |
+
|
| 28 |
+
[generation]
|
| 29 |
+
image_timeout = 300
|
| 30 |
+
video_timeout = 1500
|
| 31 |
+
|
| 32 |
+
[admin]
|
| 33 |
+
error_ban_threshold = 3
|
| 34 |
+
|
| 35 |
+
[proxy]
|
| 36 |
+
proxy_enabled = true
|
| 37 |
+
proxy_url = "socks5://warp:1080"
|
| 38 |
+
|
| 39 |
+
[watermark_free]
|
| 40 |
+
watermark_free_enabled = false
|
| 41 |
+
parse_method = "third_party"
|
| 42 |
+
custom_parse_url = ""
|
| 43 |
+
custom_parse_token = ""
|
| 44 |
+
|
| 45 |
+
[token_refresh]
|
| 46 |
+
at_auto_refresh_enabled = false
|
docker-compose.warp.yml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
sora2api:
|
| 5 |
+
image: thesmallhancat/sora2api:latest
|
| 6 |
+
container_name: sora2api
|
| 7 |
+
ports:
|
| 8 |
+
- "8000:8000"
|
| 9 |
+
volumes:
|
| 10 |
+
- ./data:/app/data
|
| 11 |
+
- ./config/setting_warp.toml:/app/config/setting.toml
|
| 12 |
+
environment:
|
| 13 |
+
- PYTHONUNBUFFERED=1
|
| 14 |
+
restart: unless-stopped
|
| 15 |
+
depends_on:
|
| 16 |
+
- warp
|
| 17 |
+
|
| 18 |
+
warp:
|
| 19 |
+
image: caomingjun/warp
|
| 20 |
+
container_name: warp
|
| 21 |
+
restart: always
|
| 22 |
+
devices:
|
| 23 |
+
- /dev/net/tun:/dev/net/tun
|
| 24 |
+
ports:
|
| 25 |
+
- "1080:1080"
|
| 26 |
+
environment:
|
| 27 |
+
- WARP_SLEEP=2
|
| 28 |
+
cap_add:
|
| 29 |
+
- MKNOD
|
| 30 |
+
- AUDIT_WRITE
|
| 31 |
+
- NET_ADMIN
|
| 32 |
+
sysctls:
|
| 33 |
+
- net.ipv6.conf.all.disable_ipv6=0
|
| 34 |
+
- net.ipv4.conf.all.src_valid_mark=1
|
| 35 |
+
volumes:
|
| 36 |
+
- ./data:/var/lib/cloudflare-warp
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
sora2api:
|
| 5 |
+
image: thesmallhancat/sora2api:latest
|
| 6 |
+
container_name: sora2api
|
| 7 |
+
ports:
|
| 8 |
+
- "8000:8000"
|
| 9 |
+
volumes:
|
| 10 |
+
- ./data:/app/data
|
| 11 |
+
- ./config/setting.toml:/app/config/setting.toml
|
| 12 |
+
environment:
|
| 13 |
+
- PYTHONUNBUFFERED=1
|
| 14 |
+
restart: unless-stopped
|
logs.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
main.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application launcher script"""
|
| 2 |
+
import uvicorn
|
| 3 |
+
from src.core.config import config
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
uvicorn.run(
|
| 7 |
+
"src.main:app",
|
| 8 |
+
host=config.server_host,
|
| 9 |
+
port=config.server_port,
|
| 10 |
+
reload=False
|
| 11 |
+
)
|
| 12 |
+
|
requirements.txt
CHANGED
|
@@ -1,6 +1,15 @@
|
|
| 1 |
-
fastapi
|
| 2 |
-
uvicorn
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.119.0
|
| 2 |
+
uvicorn[standard]==0.32.1
|
| 3 |
+
curl-cffi==0.13.0
|
| 4 |
+
pyjwt==2.10.1
|
| 5 |
+
python-multipart==0.0.20
|
| 6 |
+
aiosqlite==0.20.0
|
| 7 |
+
bcrypt==4.2.1
|
| 8 |
+
python-dotenv==1.0.1
|
| 9 |
+
pydantic==2.10.4
|
| 10 |
+
pydantic-settings==2.7.0
|
| 11 |
+
tomli==2.2.1
|
| 12 |
+
toml
|
| 13 |
+
faker==24.0.0
|
| 14 |
+
python-dateutil==2.8.2
|
| 15 |
+
httpx==0.28.1
|
src/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sora2API - OpenAI compatible Sora API proxy service"""
|
| 2 |
+
|
| 3 |
+
__version__ = "1.0.0"
|
| 4 |
+
|
src/api/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API routes module"""
|
| 2 |
+
|
| 3 |
+
from .routes import router as api_router
|
| 4 |
+
from .admin import router as admin_router
|
| 5 |
+
|
| 6 |
+
__all__ = ["api_router", "admin_router"]
|
| 7 |
+
|
src/api/admin.py
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin routes - Management endpoints"""
|
| 2 |
+
from fastapi import APIRouter, HTTPException, Depends, Header
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
import secrets
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from ..core.auth import AuthManager
|
| 8 |
+
from ..core.config import config
|
| 9 |
+
from ..services.token_manager import TokenManager
|
| 10 |
+
from ..services.proxy_manager import ProxyManager
|
| 11 |
+
from ..core.database import Database
|
| 12 |
+
from ..core.models import Token, AdminConfig, ProxyConfig
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
# Dependency injection
|
| 17 |
+
token_manager: TokenManager = None
|
| 18 |
+
proxy_manager: ProxyManager = None
|
| 19 |
+
db: Database = None
|
| 20 |
+
generation_handler = None
|
| 21 |
+
|
| 22 |
+
# Store active admin tokens (in production, use Redis or database)
|
| 23 |
+
active_admin_tokens = set()
|
| 24 |
+
|
| 25 |
+
def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database, gh=None):
|
| 26 |
+
"""Set dependencies"""
|
| 27 |
+
global token_manager, proxy_manager, db, generation_handler
|
| 28 |
+
token_manager = tm
|
| 29 |
+
proxy_manager = pm
|
| 30 |
+
db = database
|
| 31 |
+
generation_handler = gh
|
| 32 |
+
|
| 33 |
+
def verify_admin_token(authorization: str = Header(None)):
|
| 34 |
+
"""Verify admin token from Authorization header"""
|
| 35 |
+
if not authorization:
|
| 36 |
+
raise HTTPException(status_code=401, detail="Missing authorization header")
|
| 37 |
+
|
| 38 |
+
# Support both "Bearer token" and "token" formats
|
| 39 |
+
token = authorization
|
| 40 |
+
if authorization.startswith("Bearer "):
|
| 41 |
+
token = authorization[7:]
|
| 42 |
+
|
| 43 |
+
if token not in active_admin_tokens:
|
| 44 |
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
| 45 |
+
|
| 46 |
+
return token
|
| 47 |
+
|
| 48 |
+
# Request/Response models
|
| 49 |
+
class LoginRequest(BaseModel):
|
| 50 |
+
username: str
|
| 51 |
+
password: str
|
| 52 |
+
|
| 53 |
+
class LoginResponse(BaseModel):
|
| 54 |
+
success: bool
|
| 55 |
+
token: Optional[str] = None
|
| 56 |
+
message: Optional[str] = None
|
| 57 |
+
|
| 58 |
+
class AddTokenRequest(BaseModel):
|
| 59 |
+
token: str # Access Token (AT)
|
| 60 |
+
st: Optional[str] = None # Session Token (optional, for storage)
|
| 61 |
+
rt: Optional[str] = None # Refresh Token (optional, for storage)
|
| 62 |
+
remark: Optional[str] = None
|
| 63 |
+
image_enabled: bool = True # Enable image generation
|
| 64 |
+
video_enabled: bool = True # Enable video generation
|
| 65 |
+
|
| 66 |
+
class ST2ATRequest(BaseModel):
|
| 67 |
+
st: str # Session Token
|
| 68 |
+
|
| 69 |
+
class RT2ATRequest(BaseModel):
|
| 70 |
+
rt: str # Refresh Token
|
| 71 |
+
|
| 72 |
+
class UpdateTokenStatusRequest(BaseModel):
|
| 73 |
+
is_active: bool
|
| 74 |
+
|
| 75 |
+
class UpdateTokenRequest(BaseModel):
|
| 76 |
+
token: Optional[str] = None # Access Token
|
| 77 |
+
st: Optional[str] = None
|
| 78 |
+
rt: Optional[str] = None
|
| 79 |
+
remark: Optional[str] = None
|
| 80 |
+
image_enabled: Optional[bool] = None # Enable image generation
|
| 81 |
+
video_enabled: Optional[bool] = None # Enable video generation
|
| 82 |
+
|
| 83 |
+
class UpdateAdminConfigRequest(BaseModel):
|
| 84 |
+
error_ban_threshold: int
|
| 85 |
+
|
| 86 |
+
class UpdateProxyConfigRequest(BaseModel):
|
| 87 |
+
proxy_enabled: bool
|
| 88 |
+
proxy_url: Optional[str] = None
|
| 89 |
+
|
| 90 |
+
class UpdateAdminPasswordRequest(BaseModel):
|
| 91 |
+
old_password: str
|
| 92 |
+
new_password: str
|
| 93 |
+
username: Optional[str] = None # Optional: new username
|
| 94 |
+
|
| 95 |
+
class UpdateAPIKeyRequest(BaseModel):
|
| 96 |
+
new_api_key: str
|
| 97 |
+
|
| 98 |
+
class UpdateDebugConfigRequest(BaseModel):
|
| 99 |
+
enabled: bool
|
| 100 |
+
|
| 101 |
+
class UpdateCacheTimeoutRequest(BaseModel):
|
| 102 |
+
timeout: int # Cache timeout in seconds
|
| 103 |
+
|
| 104 |
+
class UpdateCacheBaseUrlRequest(BaseModel):
|
| 105 |
+
base_url: str # Cache base URL (e.g., https://yourdomain.com)
|
| 106 |
+
|
| 107 |
+
class UpdateGenerationTimeoutRequest(BaseModel):
|
| 108 |
+
image_timeout: Optional[int] = None # Image generation timeout in seconds
|
| 109 |
+
video_timeout: Optional[int] = None # Video generation timeout in seconds
|
| 110 |
+
|
| 111 |
+
class UpdateWatermarkFreeConfigRequest(BaseModel):
|
| 112 |
+
watermark_free_enabled: bool
|
| 113 |
+
parse_method: Optional[str] = "third_party" # "third_party" or "custom"
|
| 114 |
+
custom_parse_url: Optional[str] = None
|
| 115 |
+
custom_parse_token: Optional[str] = None
|
| 116 |
+
|
| 117 |
+
# Auth endpoints
|
| 118 |
+
@router.post("/api/login", response_model=LoginResponse)
|
| 119 |
+
async def login(request: LoginRequest):
|
| 120 |
+
"""Admin login"""
|
| 121 |
+
if AuthManager.verify_admin(request.username, request.password):
|
| 122 |
+
# Generate simple token
|
| 123 |
+
token = f"admin-{secrets.token_urlsafe(32)}"
|
| 124 |
+
# Store token in active tokens
|
| 125 |
+
active_admin_tokens.add(token)
|
| 126 |
+
return LoginResponse(success=True, token=token, message="Login successful")
|
| 127 |
+
else:
|
| 128 |
+
return LoginResponse(success=False, message="Invalid credentials")
|
| 129 |
+
|
| 130 |
+
@router.post("/api/logout")
|
| 131 |
+
async def logout(token: str = Depends(verify_admin_token)):
|
| 132 |
+
"""Admin logout"""
|
| 133 |
+
# Remove token from active tokens
|
| 134 |
+
active_admin_tokens.discard(token)
|
| 135 |
+
return {"success": True, "message": "Logged out successfully"}
|
| 136 |
+
|
| 137 |
+
# Token management endpoints
|
| 138 |
+
@router.get("/api/tokens")
|
| 139 |
+
async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]:
|
| 140 |
+
"""Get all tokens with statistics"""
|
| 141 |
+
tokens = await token_manager.get_all_tokens()
|
| 142 |
+
result = []
|
| 143 |
+
|
| 144 |
+
for token in tokens:
|
| 145 |
+
stats = await db.get_token_stats(token.id)
|
| 146 |
+
result.append({
|
| 147 |
+
"id": token.id,
|
| 148 |
+
"token": token.token, # 完整的Access Token
|
| 149 |
+
"st": token.st, # 完整的Session Token
|
| 150 |
+
"rt": token.rt, # 完整的Refresh Token
|
| 151 |
+
"email": token.email,
|
| 152 |
+
"name": token.name,
|
| 153 |
+
"remark": token.remark,
|
| 154 |
+
"expiry_time": token.expiry_time.isoformat() if token.expiry_time else None,
|
| 155 |
+
"is_active": token.is_active,
|
| 156 |
+
"cooled_until": token.cooled_until.isoformat() if token.cooled_until else None,
|
| 157 |
+
"created_at": token.created_at.isoformat() if token.created_at else None,
|
| 158 |
+
"last_used_at": token.last_used_at.isoformat() if token.last_used_at else None,
|
| 159 |
+
"use_count": token.use_count,
|
| 160 |
+
"image_count": stats.image_count if stats else 0,
|
| 161 |
+
"video_count": stats.video_count if stats else 0,
|
| 162 |
+
"error_count": stats.error_count if stats else 0,
|
| 163 |
+
# 订阅信息
|
| 164 |
+
"plan_type": token.plan_type,
|
| 165 |
+
"plan_title": token.plan_title,
|
| 166 |
+
"subscription_end": token.subscription_end.isoformat() if token.subscription_end else None,
|
| 167 |
+
# Sora2信息
|
| 168 |
+
"sora2_supported": token.sora2_supported,
|
| 169 |
+
"sora2_invite_code": token.sora2_invite_code,
|
| 170 |
+
"sora2_redeemed_count": token.sora2_redeemed_count,
|
| 171 |
+
"sora2_total_count": token.sora2_total_count,
|
| 172 |
+
"sora2_remaining_count": token.sora2_remaining_count,
|
| 173 |
+
"sora2_cooldown_until": token.sora2_cooldown_until.isoformat() if token.sora2_cooldown_until else None,
|
| 174 |
+
# 功能开关
|
| 175 |
+
"image_enabled": token.image_enabled,
|
| 176 |
+
"video_enabled": token.video_enabled
|
| 177 |
+
})
|
| 178 |
+
|
| 179 |
+
return result
|
| 180 |
+
|
| 181 |
+
@router.post("/api/tokens")
|
| 182 |
+
async def add_token(request: AddTokenRequest, token: str = Depends(verify_admin_token)):
|
| 183 |
+
"""Add a new Access Token"""
|
| 184 |
+
try:
|
| 185 |
+
new_token = await token_manager.add_token(
|
| 186 |
+
token_value=request.token,
|
| 187 |
+
st=request.st,
|
| 188 |
+
rt=request.rt,
|
| 189 |
+
remark=request.remark,
|
| 190 |
+
update_if_exists=False,
|
| 191 |
+
image_enabled=request.image_enabled,
|
| 192 |
+
video_enabled=request.video_enabled
|
| 193 |
+
)
|
| 194 |
+
return {"success": True, "message": "Token 添加成功", "token_id": new_token.id}
|
| 195 |
+
except ValueError as e:
|
| 196 |
+
# Token already exists
|
| 197 |
+
raise HTTPException(status_code=409, detail=str(e))
|
| 198 |
+
except Exception as e:
|
| 199 |
+
raise HTTPException(status_code=400, detail=f"添加 Token 失败: {str(e)}")
|
| 200 |
+
|
| 201 |
+
@router.post("/api/tokens/st2at")
|
| 202 |
+
async def st_to_at(request: ST2ATRequest, token: str = Depends(verify_admin_token)):
|
| 203 |
+
"""Convert Session Token to Access Token (only convert, not add to database)"""
|
| 204 |
+
try:
|
| 205 |
+
result = await token_manager.st_to_at(request.st)
|
| 206 |
+
return {
|
| 207 |
+
"success": True,
|
| 208 |
+
"message": "ST converted to AT successfully",
|
| 209 |
+
"access_token": result["access_token"],
|
| 210 |
+
"email": result.get("email"),
|
| 211 |
+
"expires": result.get("expires")
|
| 212 |
+
}
|
| 213 |
+
except Exception as e:
|
| 214 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 215 |
+
|
| 216 |
+
@router.post("/api/tokens/rt2at")
|
| 217 |
+
async def rt_to_at(request: RT2ATRequest, token: str = Depends(verify_admin_token)):
|
| 218 |
+
"""Convert Refresh Token to Access Token (only convert, not add to database)"""
|
| 219 |
+
try:
|
| 220 |
+
result = await token_manager.rt_to_at(request.rt)
|
| 221 |
+
return {
|
| 222 |
+
"success": True,
|
| 223 |
+
"message": "RT converted to AT successfully",
|
| 224 |
+
"access_token": result["access_token"],
|
| 225 |
+
"refresh_token": result.get("refresh_token"),
|
| 226 |
+
"expires_in": result.get("expires_in")
|
| 227 |
+
}
|
| 228 |
+
except Exception as e:
|
| 229 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 230 |
+
|
| 231 |
+
@router.put("/api/tokens/{token_id}/status")
|
| 232 |
+
async def update_token_status(
|
| 233 |
+
token_id: int,
|
| 234 |
+
request: UpdateTokenStatusRequest,
|
| 235 |
+
token: str = Depends(verify_admin_token)
|
| 236 |
+
):
|
| 237 |
+
"""Update token status"""
|
| 238 |
+
try:
|
| 239 |
+
await token_manager.update_token_status(token_id, request.is_active)
|
| 240 |
+
|
| 241 |
+
# Reset error count when enabling token
|
| 242 |
+
if request.is_active:
|
| 243 |
+
await token_manager.record_success(token_id)
|
| 244 |
+
|
| 245 |
+
return {"success": True, "message": "Token status updated"}
|
| 246 |
+
except Exception as e:
|
| 247 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 248 |
+
|
| 249 |
+
@router.post("/api/tokens/{token_id}/enable")
|
| 250 |
+
async def enable_token(token_id: int, token: str = Depends(verify_admin_token)):
|
| 251 |
+
"""Enable a token and reset error count"""
|
| 252 |
+
try:
|
| 253 |
+
await token_manager.enable_token(token_id)
|
| 254 |
+
return {"success": True, "message": "Token enabled", "is_active": 1, "error_count": 0}
|
| 255 |
+
except Exception as e:
|
| 256 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 257 |
+
|
| 258 |
+
@router.post("/api/tokens/{token_id}/disable")
|
| 259 |
+
async def disable_token(token_id: int, token: str = Depends(verify_admin_token)):
|
| 260 |
+
"""Disable a token"""
|
| 261 |
+
try:
|
| 262 |
+
await token_manager.disable_token(token_id)
|
| 263 |
+
return {"success": True, "message": "Token disabled", "is_active": 0}
|
| 264 |
+
except Exception as e:
|
| 265 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 266 |
+
|
| 267 |
+
@router.post("/api/tokens/{token_id}/test")
|
| 268 |
+
async def test_token(token_id: int, token: str = Depends(verify_admin_token)):
|
| 269 |
+
"""Test if a token is valid and refresh Sora2 info"""
|
| 270 |
+
try:
|
| 271 |
+
result = await token_manager.test_token(token_id)
|
| 272 |
+
response = {
|
| 273 |
+
"success": True,
|
| 274 |
+
"status": "success" if result["valid"] else "failed",
|
| 275 |
+
"message": result["message"],
|
| 276 |
+
"email": result.get("email"),
|
| 277 |
+
"username": result.get("username")
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
# Include Sora2 info if available
|
| 281 |
+
if result.get("valid"):
|
| 282 |
+
response.update({
|
| 283 |
+
"sora2_supported": result.get("sora2_supported"),
|
| 284 |
+
"sora2_invite_code": result.get("sora2_invite_code"),
|
| 285 |
+
"sora2_redeemed_count": result.get("sora2_redeemed_count"),
|
| 286 |
+
"sora2_total_count": result.get("sora2_total_count"),
|
| 287 |
+
"sora2_remaining_count": result.get("sora2_remaining_count")
|
| 288 |
+
})
|
| 289 |
+
|
| 290 |
+
return response
|
| 291 |
+
except Exception as e:
|
| 292 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 293 |
+
|
| 294 |
+
@router.delete("/api/tokens/{token_id}")
|
| 295 |
+
async def delete_token(token_id: int, token: str = Depends(verify_admin_token)):
|
| 296 |
+
"""Delete a token"""
|
| 297 |
+
try:
|
| 298 |
+
await token_manager.delete_token(token_id)
|
| 299 |
+
return {"success": True, "message": "Token deleted"}
|
| 300 |
+
except Exception as e:
|
| 301 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 302 |
+
|
| 303 |
+
@router.put("/api/tokens/{token_id}")
|
| 304 |
+
async def update_token(
|
| 305 |
+
token_id: int,
|
| 306 |
+
request: UpdateTokenRequest,
|
| 307 |
+
token: str = Depends(verify_admin_token)
|
| 308 |
+
):
|
| 309 |
+
"""Update token (AT, ST, RT, remark, image_enabled, video_enabled)"""
|
| 310 |
+
try:
|
| 311 |
+
await token_manager.update_token(
|
| 312 |
+
token_id=token_id,
|
| 313 |
+
token=request.token,
|
| 314 |
+
st=request.st,
|
| 315 |
+
rt=request.rt,
|
| 316 |
+
remark=request.remark,
|
| 317 |
+
image_enabled=request.image_enabled,
|
| 318 |
+
video_enabled=request.video_enabled
|
| 319 |
+
)
|
| 320 |
+
return {"success": True, "message": "Token updated"}
|
| 321 |
+
except Exception as e:
|
| 322 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 323 |
+
|
| 324 |
+
# Admin config endpoints
|
| 325 |
+
@router.get("/api/admin/config")
|
| 326 |
+
async def get_admin_config(token: str = Depends(verify_admin_token)) -> dict:
|
| 327 |
+
"""Get admin configuration"""
|
| 328 |
+
admin_config = await db.get_admin_config()
|
| 329 |
+
return {
|
| 330 |
+
"error_ban_threshold": admin_config.error_ban_threshold,
|
| 331 |
+
"api_key": config.api_key,
|
| 332 |
+
"admin_username": config.admin_username,
|
| 333 |
+
"debug_enabled": config.debug_enabled
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
@router.post("/api/admin/config")
|
| 337 |
+
async def update_admin_config(
|
| 338 |
+
request: UpdateAdminConfigRequest,
|
| 339 |
+
token: str = Depends(verify_admin_token)
|
| 340 |
+
):
|
| 341 |
+
"""Update admin configuration"""
|
| 342 |
+
try:
|
| 343 |
+
# Get current admin config to preserve username and password
|
| 344 |
+
current_config = await db.get_admin_config()
|
| 345 |
+
|
| 346 |
+
# Update only the error_ban_threshold, preserve username and password
|
| 347 |
+
current_config.error_ban_threshold = request.error_ban_threshold
|
| 348 |
+
|
| 349 |
+
await db.update_admin_config(current_config)
|
| 350 |
+
return {"success": True, "message": "Configuration updated"}
|
| 351 |
+
except Exception as e:
|
| 352 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 353 |
+
|
| 354 |
+
@router.post("/api/admin/password")
|
| 355 |
+
async def update_admin_password(
|
| 356 |
+
request: UpdateAdminPasswordRequest,
|
| 357 |
+
token: str = Depends(verify_admin_token)
|
| 358 |
+
):
|
| 359 |
+
"""Update admin password and/or username"""
|
| 360 |
+
try:
|
| 361 |
+
# Verify old password
|
| 362 |
+
if not AuthManager.verify_admin(config.admin_username, request.old_password):
|
| 363 |
+
raise HTTPException(status_code=400, detail="Old password is incorrect")
|
| 364 |
+
|
| 365 |
+
# Get current admin config from database
|
| 366 |
+
admin_config = await db.get_admin_config()
|
| 367 |
+
|
| 368 |
+
# Update password in database
|
| 369 |
+
admin_config.admin_password = request.new_password
|
| 370 |
+
|
| 371 |
+
# Update username if provided
|
| 372 |
+
if request.username:
|
| 373 |
+
admin_config.admin_username = request.username
|
| 374 |
+
|
| 375 |
+
# Update in database
|
| 376 |
+
await db.update_admin_config(admin_config)
|
| 377 |
+
|
| 378 |
+
# Update in-memory config
|
| 379 |
+
config.set_admin_password_from_db(request.new_password)
|
| 380 |
+
if request.username:
|
| 381 |
+
config.set_admin_username_from_db(request.username)
|
| 382 |
+
|
| 383 |
+
# Invalidate all admin tokens (force re-login)
|
| 384 |
+
active_admin_tokens.clear()
|
| 385 |
+
|
| 386 |
+
return {"success": True, "message": "Password updated successfully. Please login again."}
|
| 387 |
+
except HTTPException:
|
| 388 |
+
raise
|
| 389 |
+
except Exception as e:
|
| 390 |
+
raise HTTPException(status_code=500, detail=f"Failed to update password: {str(e)}")
|
| 391 |
+
|
| 392 |
+
@router.post("/api/admin/apikey")
|
| 393 |
+
async def update_api_key(
|
| 394 |
+
request: UpdateAPIKeyRequest,
|
| 395 |
+
token: str = Depends(verify_admin_token)
|
| 396 |
+
):
|
| 397 |
+
"""Update API key"""
|
| 398 |
+
try:
|
| 399 |
+
# Update in-memory config
|
| 400 |
+
config.api_key = request.new_api_key
|
| 401 |
+
|
| 402 |
+
return {"success": True, "message": "API key updated successfully"}
|
| 403 |
+
except Exception as e:
|
| 404 |
+
raise HTTPException(status_code=500, detail=f"Failed to update API key: {str(e)}")
|
| 405 |
+
|
| 406 |
+
@router.post("/api/admin/debug")
|
| 407 |
+
async def update_debug_config(
|
| 408 |
+
request: UpdateDebugConfigRequest,
|
| 409 |
+
token: str = Depends(verify_admin_token)
|
| 410 |
+
):
|
| 411 |
+
"""Update debug configuration"""
|
| 412 |
+
try:
|
| 413 |
+
# Update in-memory config
|
| 414 |
+
config.set_debug_enabled(request.enabled)
|
| 415 |
+
|
| 416 |
+
status = "enabled" if request.enabled else "disabled"
|
| 417 |
+
return {"success": True, "message": f"Debug mode {status}", "enabled": request.enabled}
|
| 418 |
+
except Exception as e:
|
| 419 |
+
raise HTTPException(status_code=500, detail=f"Failed to update debug config: {str(e)}")
|
| 420 |
+
|
| 421 |
+
# Proxy config endpoints
|
| 422 |
+
@router.get("/api/proxy/config")
|
| 423 |
+
async def get_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
|
| 424 |
+
"""Get proxy configuration"""
|
| 425 |
+
config = await proxy_manager.get_proxy_config()
|
| 426 |
+
return {
|
| 427 |
+
"proxy_enabled": config.proxy_enabled,
|
| 428 |
+
"proxy_url": config.proxy_url
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
@router.post("/api/proxy/config")
|
| 432 |
+
async def update_proxy_config(
|
| 433 |
+
request: UpdateProxyConfigRequest,
|
| 434 |
+
token: str = Depends(verify_admin_token)
|
| 435 |
+
):
|
| 436 |
+
"""Update proxy configuration"""
|
| 437 |
+
try:
|
| 438 |
+
await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url)
|
| 439 |
+
return {"success": True, "message": "Proxy configuration updated"}
|
| 440 |
+
except Exception as e:
|
| 441 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 442 |
+
|
| 443 |
+
# Watermark-free config endpoints
|
| 444 |
+
@router.get("/api/watermark-free/config")
|
| 445 |
+
async def get_watermark_free_config(token: str = Depends(verify_admin_token)) -> dict:
|
| 446 |
+
"""Get watermark-free mode configuration"""
|
| 447 |
+
config_obj = await db.get_watermark_free_config()
|
| 448 |
+
return {
|
| 449 |
+
"watermark_free_enabled": config_obj.watermark_free_enabled,
|
| 450 |
+
"parse_method": config_obj.parse_method,
|
| 451 |
+
"custom_parse_url": config_obj.custom_parse_url,
|
| 452 |
+
"custom_parse_token": config_obj.custom_parse_token
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
@router.post("/api/watermark-free/config")
|
| 456 |
+
async def update_watermark_free_config(
|
| 457 |
+
request: UpdateWatermarkFreeConfigRequest,
|
| 458 |
+
token: str = Depends(verify_admin_token)
|
| 459 |
+
):
|
| 460 |
+
"""Update watermark-free mode configuration"""
|
| 461 |
+
try:
|
| 462 |
+
await db.update_watermark_free_config(
|
| 463 |
+
request.watermark_free_enabled,
|
| 464 |
+
request.parse_method,
|
| 465 |
+
request.custom_parse_url,
|
| 466 |
+
request.custom_parse_token
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
# Update in-memory config
|
| 470 |
+
from ..core.config import config
|
| 471 |
+
config.set_watermark_free_enabled(request.watermark_free_enabled)
|
| 472 |
+
|
| 473 |
+
return {"success": True, "message": "Watermark-free mode configuration updated"}
|
| 474 |
+
except Exception as e:
|
| 475 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 476 |
+
|
| 477 |
+
# Statistics endpoints
|
| 478 |
+
@router.get("/api/stats")
|
| 479 |
+
async def get_stats(token: str = Depends(verify_admin_token)):
|
| 480 |
+
"""Get system statistics"""
|
| 481 |
+
tokens = await token_manager.get_all_tokens()
|
| 482 |
+
active_tokens = await token_manager.get_active_tokens()
|
| 483 |
+
|
| 484 |
+
total_images = 0
|
| 485 |
+
total_videos = 0
|
| 486 |
+
total_errors = 0
|
| 487 |
+
|
| 488 |
+
for token in tokens:
|
| 489 |
+
stats = await db.get_token_stats(token.id)
|
| 490 |
+
if stats:
|
| 491 |
+
total_images += stats.image_count
|
| 492 |
+
total_videos += stats.video_count
|
| 493 |
+
total_errors += stats.error_count
|
| 494 |
+
|
| 495 |
+
return {
|
| 496 |
+
"total_tokens": len(tokens),
|
| 497 |
+
"active_tokens": len(active_tokens),
|
| 498 |
+
"total_images": total_images,
|
| 499 |
+
"total_videos": total_videos,
|
| 500 |
+
"total_errors": total_errors
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
# Sora2 endpoints
|
| 504 |
+
@router.post("/api/tokens/{token_id}/sora2/activate")
|
| 505 |
+
async def activate_sora2(
|
| 506 |
+
token_id: int,
|
| 507 |
+
invite_code: str,
|
| 508 |
+
token: str = Depends(verify_admin_token)
|
| 509 |
+
):
|
| 510 |
+
"""Activate Sora2 with invite code"""
|
| 511 |
+
try:
|
| 512 |
+
# Get token
|
| 513 |
+
token_obj = await db.get_token(token_id)
|
| 514 |
+
if not token_obj:
|
| 515 |
+
raise HTTPException(status_code=404, detail="Token not found")
|
| 516 |
+
|
| 517 |
+
# Activate Sora2
|
| 518 |
+
result = await token_manager.activate_sora2_invite(token_obj.token, invite_code)
|
| 519 |
+
|
| 520 |
+
if result.get("success"):
|
| 521 |
+
# Get new invite code after activation
|
| 522 |
+
sora2_info = await token_manager.get_sora2_invite_code(token_obj.token)
|
| 523 |
+
|
| 524 |
+
# Get remaining count
|
| 525 |
+
sora2_remaining_count = 0
|
| 526 |
+
try:
|
| 527 |
+
remaining_info = await token_manager.get_sora2_remaining_count(token_obj.token)
|
| 528 |
+
if remaining_info.get("success"):
|
| 529 |
+
sora2_remaining_count = remaining_info.get("remaining_count", 0)
|
| 530 |
+
except Exception as e:
|
| 531 |
+
print(f"Failed to get Sora2 remaining count: {e}")
|
| 532 |
+
|
| 533 |
+
# Update database
|
| 534 |
+
await db.update_token_sora2(
|
| 535 |
+
token_id,
|
| 536 |
+
supported=True,
|
| 537 |
+
invite_code=sora2_info.get("invite_code"),
|
| 538 |
+
redeemed_count=sora2_info.get("redeemed_count", 0),
|
| 539 |
+
total_count=sora2_info.get("total_count", 0),
|
| 540 |
+
remaining_count=sora2_remaining_count
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
return {
|
| 544 |
+
"success": True,
|
| 545 |
+
"message": "Sora2 activated successfully",
|
| 546 |
+
"already_accepted": result.get("already_accepted", False),
|
| 547 |
+
"invite_code": sora2_info.get("invite_code"),
|
| 548 |
+
"redeemed_count": sora2_info.get("redeemed_count", 0),
|
| 549 |
+
"total_count": sora2_info.get("total_count", 0),
|
| 550 |
+
"sora2_remaining_count": sora2_remaining_count
|
| 551 |
+
}
|
| 552 |
+
else:
|
| 553 |
+
return {
|
| 554 |
+
"success": False,
|
| 555 |
+
"message": "Failed to activate Sora2"
|
| 556 |
+
}
|
| 557 |
+
except HTTPException:
|
| 558 |
+
raise
|
| 559 |
+
except Exception as e:
|
| 560 |
+
raise HTTPException(status_code=500, detail=f"Failed to activate Sora2: {str(e)}")
|
| 561 |
+
|
| 562 |
+
# Logs endpoints
|
| 563 |
+
@router.get("/api/logs")
|
| 564 |
+
async def get_logs(limit: int = 100, token: str = Depends(verify_admin_token)):
|
| 565 |
+
"""Get recent logs with token email"""
|
| 566 |
+
logs = await db.get_recent_logs(limit)
|
| 567 |
+
return [{
|
| 568 |
+
"id": log.get("id"),
|
| 569 |
+
"token_id": log.get("token_id"),
|
| 570 |
+
"token_email": log.get("token_email"),
|
| 571 |
+
"token_username": log.get("token_username"),
|
| 572 |
+
"operation": log.get("operation"),
|
| 573 |
+
"status_code": log.get("status_code"),
|
| 574 |
+
"duration": log.get("duration"),
|
| 575 |
+
"created_at": log.get("created_at")
|
| 576 |
+
} for log in logs]
|
| 577 |
+
|
| 578 |
+
# Cache config endpoints
|
| 579 |
+
@router.post("/api/cache/config")
|
| 580 |
+
async def update_cache_timeout(
|
| 581 |
+
request: UpdateCacheTimeoutRequest,
|
| 582 |
+
token: str = Depends(verify_admin_token)
|
| 583 |
+
):
|
| 584 |
+
"""Update cache timeout"""
|
| 585 |
+
try:
|
| 586 |
+
if request.timeout < 60:
|
| 587 |
+
raise HTTPException(status_code=400, detail="Cache timeout must be at least 60 seconds")
|
| 588 |
+
|
| 589 |
+
if request.timeout > 86400:
|
| 590 |
+
raise HTTPException(status_code=400, detail="Cache timeout cannot exceed 24 hours (86400 seconds)")
|
| 591 |
+
|
| 592 |
+
# Update in-memory config
|
| 593 |
+
config.set_cache_timeout(request.timeout)
|
| 594 |
+
|
| 595 |
+
# Update database
|
| 596 |
+
await db.update_cache_config(timeout=request.timeout)
|
| 597 |
+
|
| 598 |
+
# Update file cache timeout
|
| 599 |
+
if generation_handler:
|
| 600 |
+
generation_handler.file_cache.set_timeout(request.timeout)
|
| 601 |
+
|
| 602 |
+
return {
|
| 603 |
+
"success": True,
|
| 604 |
+
"message": f"Cache timeout updated to {request.timeout} seconds",
|
| 605 |
+
"timeout": request.timeout
|
| 606 |
+
}
|
| 607 |
+
except HTTPException:
|
| 608 |
+
raise
|
| 609 |
+
except Exception as e:
|
| 610 |
+
raise HTTPException(status_code=500, detail=f"Failed to update cache timeout: {str(e)}")
|
| 611 |
+
|
| 612 |
+
@router.post("/api/cache/base-url")
|
| 613 |
+
async def update_cache_base_url(
|
| 614 |
+
request: UpdateCacheBaseUrlRequest,
|
| 615 |
+
token: str = Depends(verify_admin_token)
|
| 616 |
+
):
|
| 617 |
+
"""Update cache base URL"""
|
| 618 |
+
try:
|
| 619 |
+
# Validate base URL format (optional, can be empty)
|
| 620 |
+
base_url = request.base_url.strip()
|
| 621 |
+
if base_url and not (base_url.startswith("http://") or base_url.startswith("https://")):
|
| 622 |
+
raise HTTPException(
|
| 623 |
+
status_code=400,
|
| 624 |
+
detail="Base URL must start with http:// or https://"
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
# Remove trailing slash
|
| 628 |
+
if base_url:
|
| 629 |
+
base_url = base_url.rstrip('/')
|
| 630 |
+
|
| 631 |
+
# Update in-memory config
|
| 632 |
+
config.set_cache_base_url(base_url)
|
| 633 |
+
|
| 634 |
+
# Update database
|
| 635 |
+
await db.update_cache_config(base_url=base_url)
|
| 636 |
+
|
| 637 |
+
return {
|
| 638 |
+
"success": True,
|
| 639 |
+
"message": f"Cache base URL updated to: {base_url or 'server address'}",
|
| 640 |
+
"base_url": base_url
|
| 641 |
+
}
|
| 642 |
+
except HTTPException:
|
| 643 |
+
raise
|
| 644 |
+
except Exception as e:
|
| 645 |
+
raise HTTPException(status_code=500, detail=f"Failed to update cache base URL: {str(e)}")
|
| 646 |
+
|
| 647 |
+
@router.get("/api/cache/config")
|
| 648 |
+
async def get_cache_config(token: str = Depends(verify_admin_token)):
|
| 649 |
+
"""Get cache configuration"""
|
| 650 |
+
return {
|
| 651 |
+
"success": True,
|
| 652 |
+
"config": {
|
| 653 |
+
"enabled": config.cache_enabled,
|
| 654 |
+
"timeout": config.cache_timeout,
|
| 655 |
+
"base_url": config.cache_base_url, # 返回实际配置的值,可能为空字符串
|
| 656 |
+
"effective_base_url": config.cache_base_url or f"http://{config.server_host}:{config.server_port}" # 实际生效的值
|
| 657 |
+
}
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
@router.post("/api/cache/enabled")
|
| 661 |
+
async def update_cache_enabled(
|
| 662 |
+
request: dict,
|
| 663 |
+
token: str = Depends(verify_admin_token)
|
| 664 |
+
):
|
| 665 |
+
"""Update cache enabled status"""
|
| 666 |
+
try:
|
| 667 |
+
enabled = request.get("enabled", True)
|
| 668 |
+
|
| 669 |
+
# Update in-memory config
|
| 670 |
+
config.set_cache_enabled(enabled)
|
| 671 |
+
|
| 672 |
+
# Update database
|
| 673 |
+
await db.update_cache_config(enabled=enabled)
|
| 674 |
+
|
| 675 |
+
return {
|
| 676 |
+
"success": True,
|
| 677 |
+
"message": f"Cache {'enabled' if enabled else 'disabled'} successfully",
|
| 678 |
+
"enabled": enabled
|
| 679 |
+
}
|
| 680 |
+
except Exception as e:
|
| 681 |
+
raise HTTPException(status_code=500, detail=f"Failed to update cache enabled status: {str(e)}")
|
| 682 |
+
|
| 683 |
+
# Generation timeout config endpoints
|
| 684 |
+
@router.get("/api/generation/timeout")
|
| 685 |
+
async def get_generation_timeout(token: str = Depends(verify_admin_token)):
|
| 686 |
+
"""Get generation timeout configuration"""
|
| 687 |
+
return {
|
| 688 |
+
"success": True,
|
| 689 |
+
"config": {
|
| 690 |
+
"image_timeout": config.image_timeout,
|
| 691 |
+
"video_timeout": config.video_timeout
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
@router.post("/api/generation/timeout")
|
| 696 |
+
async def update_generation_timeout(
|
| 697 |
+
request: UpdateGenerationTimeoutRequest,
|
| 698 |
+
token: str = Depends(verify_admin_token)
|
| 699 |
+
):
|
| 700 |
+
"""Update generation timeout configuration"""
|
| 701 |
+
try:
|
| 702 |
+
# Validate timeouts
|
| 703 |
+
if request.image_timeout is not None:
|
| 704 |
+
if request.image_timeout < 60:
|
| 705 |
+
raise HTTPException(status_code=400, detail="Image timeout must be at least 60 seconds")
|
| 706 |
+
if request.image_timeout > 3600:
|
| 707 |
+
raise HTTPException(status_code=400, detail="Image timeout cannot exceed 1 hour (3600 seconds)")
|
| 708 |
+
|
| 709 |
+
if request.video_timeout is not None:
|
| 710 |
+
if request.video_timeout < 60:
|
| 711 |
+
raise HTTPException(status_code=400, detail="Video timeout must be at least 60 seconds")
|
| 712 |
+
if request.video_timeout > 7200:
|
| 713 |
+
raise HTTPException(status_code=400, detail="Video timeout cannot exceed 2 hours (7200 seconds)")
|
| 714 |
+
|
| 715 |
+
# Update in-memory config
|
| 716 |
+
if request.image_timeout is not None:
|
| 717 |
+
config.set_image_timeout(request.image_timeout)
|
| 718 |
+
if request.video_timeout is not None:
|
| 719 |
+
config.set_video_timeout(request.video_timeout)
|
| 720 |
+
|
| 721 |
+
# Update database
|
| 722 |
+
await db.update_generation_config(
|
| 723 |
+
image_timeout=request.image_timeout,
|
| 724 |
+
video_timeout=request.video_timeout
|
| 725 |
+
)
|
| 726 |
+
|
| 727 |
+
# Update TokenLock timeout if image timeout was changed
|
| 728 |
+
if request.image_timeout is not None and generation_handler:
|
| 729 |
+
generation_handler.load_balancer.token_lock.set_lock_timeout(config.image_timeout)
|
| 730 |
+
|
| 731 |
+
return {
|
| 732 |
+
"success": True,
|
| 733 |
+
"message": "Generation timeout configuration updated",
|
| 734 |
+
"config": {
|
| 735 |
+
"image_timeout": config.image_timeout,
|
| 736 |
+
"video_timeout": config.video_timeout
|
| 737 |
+
}
|
| 738 |
+
}
|
| 739 |
+
except HTTPException:
|
| 740 |
+
raise
|
| 741 |
+
except Exception as e:
|
| 742 |
+
raise HTTPException(status_code=500, detail=f"Failed to update generation timeout: {str(e)}")
|
| 743 |
+
|
| 744 |
+
# AT auto refresh config endpoints
|
| 745 |
+
@router.get("/api/token-refresh/config")
|
| 746 |
+
async def get_at_auto_refresh_config(token: str = Depends(verify_admin_token)):
|
| 747 |
+
"""Get AT auto refresh configuration"""
|
| 748 |
+
return {
|
| 749 |
+
"success": True,
|
| 750 |
+
"config": {
|
| 751 |
+
"at_auto_refresh_enabled": config.at_auto_refresh_enabled
|
| 752 |
+
}
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
@router.post("/api/token-refresh/enabled")
|
| 756 |
+
async def update_at_auto_refresh_enabled(
|
| 757 |
+
request: dict,
|
| 758 |
+
token: str = Depends(verify_admin_token)
|
| 759 |
+
):
|
| 760 |
+
"""Update AT auto refresh enabled status"""
|
| 761 |
+
try:
|
| 762 |
+
enabled = request.get("enabled", False)
|
| 763 |
+
|
| 764 |
+
# Update in-memory config
|
| 765 |
+
config.set_at_auto_refresh_enabled(enabled)
|
| 766 |
+
|
| 767 |
+
# Update database
|
| 768 |
+
await db.update_token_refresh_config(enabled)
|
| 769 |
+
|
| 770 |
+
return {
|
| 771 |
+
"success": True,
|
| 772 |
+
"message": f"AT auto refresh {'enabled' if enabled else 'disabled'} successfully",
|
| 773 |
+
"enabled": enabled
|
| 774 |
+
}
|
| 775 |
+
except Exception as e:
|
| 776 |
+
raise HTTPException(status_code=500, detail=f"Failed to update AT auto refresh enabled status: {str(e)}")
|
src/api/routes.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API routes - OpenAI compatible endpoints"""
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException,Request
|
| 3 |
+
from fastapi.responses import StreamingResponse, JSONResponse,HTMLResponse
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import List
|
| 6 |
+
import json
|
| 7 |
+
import re
|
| 8 |
+
from ..core.auth import verify_api_key_header
|
| 9 |
+
from ..core.models import ChatCompletionRequest
|
| 10 |
+
from ..services.generation_handler import GenerationHandler, MODEL_CONFIG
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
# Dependency injection will be set up in main.py
|
| 15 |
+
generation_handler: GenerationHandler = None
|
| 16 |
+
|
| 17 |
+
def set_generation_handler(handler: GenerationHandler):
|
| 18 |
+
"""Set generation handler instance"""
|
| 19 |
+
global generation_handler
|
| 20 |
+
generation_handler = handler
|
| 21 |
+
|
| 22 |
+
def _extract_remix_id(text: str) -> str:
|
| 23 |
+
"""Extract remix ID from text
|
| 24 |
+
|
| 25 |
+
Supports two formats:
|
| 26 |
+
1. Full URL: https://sora.chatgpt.com/p/s_68e3a06dcd888191b150971da152c1f5
|
| 27 |
+
2. Short ID: s_68e3a06dcd888191b150971da152c1f5
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
text: Text to search for remix ID
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Remix ID (s_[a-f0-9]{32}) or empty string if not found
|
| 34 |
+
"""
|
| 35 |
+
if not text:
|
| 36 |
+
return ""
|
| 37 |
+
|
| 38 |
+
# Match Sora share link format: s_[a-f0-9]{32}
|
| 39 |
+
match = re.search(r's_[a-f0-9]{32}', text)
|
| 40 |
+
if match:
|
| 41 |
+
return match.group(0)
|
| 42 |
+
|
| 43 |
+
return ""
|
| 44 |
+
|
| 45 |
+
@router.get("/v1/models")
|
| 46 |
+
async def list_models(api_key: str = Depends(verify_api_key_header)):
|
| 47 |
+
"""List available models"""
|
| 48 |
+
models = []
|
| 49 |
+
|
| 50 |
+
for model_id, config in MODEL_CONFIG.items():
|
| 51 |
+
description = f"{config['type'].capitalize()} generation"
|
| 52 |
+
if config['type'] == 'image':
|
| 53 |
+
description += f" - {config['width']}x{config['height']}"
|
| 54 |
+
else:
|
| 55 |
+
description += f" - {config['orientation']}"
|
| 56 |
+
|
| 57 |
+
models.append({
|
| 58 |
+
"id": model_id,
|
| 59 |
+
"object": "model",
|
| 60 |
+
"owned_by": "sora2api",
|
| 61 |
+
"description": description
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
"object": "list",
|
| 66 |
+
"data": models
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
@router.post("/v1/chat/completions")
|
| 70 |
+
async def create_chat_completion(
|
| 71 |
+
request: ChatCompletionRequest,
|
| 72 |
+
api_key: str = Depends(verify_api_key_header)
|
| 73 |
+
):
|
| 74 |
+
"""Create chat completion (unified endpoint for image and video generation)"""
|
| 75 |
+
try:
|
| 76 |
+
# Extract prompt from messages
|
| 77 |
+
if not request.messages:
|
| 78 |
+
raise HTTPException(status_code=400, detail="Messages cannot be empty")
|
| 79 |
+
|
| 80 |
+
last_message = request.messages[-1]
|
| 81 |
+
content = last_message.content
|
| 82 |
+
|
| 83 |
+
# Handle both string and array format (OpenAI multimodal)
|
| 84 |
+
prompt = ""
|
| 85 |
+
image_data = request.image # Default to request.image if provided
|
| 86 |
+
video_data = request.video # Video parameter
|
| 87 |
+
remix_target_id = request.remix_target_id # Remix target ID
|
| 88 |
+
|
| 89 |
+
if isinstance(content, str):
|
| 90 |
+
# Simple string format
|
| 91 |
+
prompt = content
|
| 92 |
+
# Extract remix_target_id from prompt if not already provided
|
| 93 |
+
if not remix_target_id:
|
| 94 |
+
remix_target_id = _extract_remix_id(prompt)
|
| 95 |
+
elif isinstance(content, list):
|
| 96 |
+
# Array format (OpenAI multimodal)
|
| 97 |
+
for item in content:
|
| 98 |
+
if isinstance(item, dict):
|
| 99 |
+
if item.get("type") == "text":
|
| 100 |
+
prompt = item.get("text", "")
|
| 101 |
+
# Extract remix_target_id from prompt if not already provided
|
| 102 |
+
if not remix_target_id:
|
| 103 |
+
remix_target_id = _extract_remix_id(prompt)
|
| 104 |
+
elif item.get("type") == "image_url":
|
| 105 |
+
# Extract base64 image from data URI
|
| 106 |
+
image_url = item.get("image_url", {})
|
| 107 |
+
url = image_url.get("url", "")
|
| 108 |
+
if url.startswith("data:image"):
|
| 109 |
+
# Extract base64 data from data URI
|
| 110 |
+
if "base64," in url:
|
| 111 |
+
image_data = url.split("base64,", 1)[1]
|
| 112 |
+
else:
|
| 113 |
+
image_data = url
|
| 114 |
+
elif item.get("type") == "input_video":
|
| 115 |
+
# Extract video from input_video
|
| 116 |
+
video_url = item.get("videoUrl", {})
|
| 117 |
+
url = video_url.get("url", "")
|
| 118 |
+
if url.startswith("data:video") or url.startswith("data:application"):
|
| 119 |
+
# Extract base64 data from data URI
|
| 120 |
+
if "base64," in url:
|
| 121 |
+
video_data = url.split("base64,", 1)[1]
|
| 122 |
+
else:
|
| 123 |
+
video_data = url
|
| 124 |
+
else:
|
| 125 |
+
# It's a URL, pass it as-is (will be downloaded in generation_handler)
|
| 126 |
+
video_data = url
|
| 127 |
+
else:
|
| 128 |
+
raise HTTPException(status_code=400, detail="Invalid content format")
|
| 129 |
+
|
| 130 |
+
# Validate model
|
| 131 |
+
if request.model not in MODEL_CONFIG:
|
| 132 |
+
raise HTTPException(status_code=400, detail=f"Invalid model: {request.model}")
|
| 133 |
+
|
| 134 |
+
# Check if this is a video model
|
| 135 |
+
model_config = MODEL_CONFIG[request.model]
|
| 136 |
+
is_video_model = model_config["type"] == "video"
|
| 137 |
+
|
| 138 |
+
# For video models with video parameter, we need streaming
|
| 139 |
+
if is_video_model and (video_data or remix_target_id):
|
| 140 |
+
if not request.stream:
|
| 141 |
+
# Non-streaming mode: only check availability
|
| 142 |
+
result = None
|
| 143 |
+
async for chunk in generation_handler.handle_generation(
|
| 144 |
+
model=request.model,
|
| 145 |
+
prompt=prompt,
|
| 146 |
+
image=image_data,
|
| 147 |
+
video=video_data,
|
| 148 |
+
remix_target_id=remix_target_id,
|
| 149 |
+
stream=False
|
| 150 |
+
):
|
| 151 |
+
result = chunk
|
| 152 |
+
|
| 153 |
+
if result:
|
| 154 |
+
import json
|
| 155 |
+
return JSONResponse(content=json.loads(result))
|
| 156 |
+
else:
|
| 157 |
+
return JSONResponse(
|
| 158 |
+
status_code=500,
|
| 159 |
+
content={
|
| 160 |
+
"error": {
|
| 161 |
+
"message": "Availability check failed",
|
| 162 |
+
"type": "server_error",
|
| 163 |
+
"param": None,
|
| 164 |
+
"code": None
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
# Handle streaming
|
| 170 |
+
if request.stream:
|
| 171 |
+
async def generate():
|
| 172 |
+
import json as json_module # Import inside function to avoid scope issues
|
| 173 |
+
try:
|
| 174 |
+
async for chunk in generation_handler.handle_generation(
|
| 175 |
+
model=request.model,
|
| 176 |
+
prompt=prompt,
|
| 177 |
+
image=image_data,
|
| 178 |
+
video=video_data,
|
| 179 |
+
remix_target_id=remix_target_id,
|
| 180 |
+
stream=True
|
| 181 |
+
):
|
| 182 |
+
yield chunk
|
| 183 |
+
except Exception as e:
|
| 184 |
+
# Return OpenAI-compatible error format
|
| 185 |
+
error_response = {
|
| 186 |
+
"error": {
|
| 187 |
+
"message": str(e),
|
| 188 |
+
"type": "server_error",
|
| 189 |
+
"param": None,
|
| 190 |
+
"code": None
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
error_chunk = f'data: {json_module.dumps(error_response)}\n\n'
|
| 194 |
+
yield error_chunk
|
| 195 |
+
yield 'data: [DONE]\n\n'
|
| 196 |
+
|
| 197 |
+
return StreamingResponse(
|
| 198 |
+
generate(),
|
| 199 |
+
media_type="text/event-stream",
|
| 200 |
+
headers={
|
| 201 |
+
"Cache-Control": "no-cache",
|
| 202 |
+
"Connection": "keep-alive",
|
| 203 |
+
"X-Accel-Buffering": "no"
|
| 204 |
+
}
|
| 205 |
+
)
|
| 206 |
+
else:
|
| 207 |
+
# Non-streaming response (availability check only)
|
| 208 |
+
result = None
|
| 209 |
+
async for chunk in generation_handler.handle_generation(
|
| 210 |
+
model=request.model,
|
| 211 |
+
prompt=prompt,
|
| 212 |
+
image=image_data,
|
| 213 |
+
video=video_data,
|
| 214 |
+
remix_target_id=remix_target_id,
|
| 215 |
+
stream=False
|
| 216 |
+
):
|
| 217 |
+
result = chunk
|
| 218 |
+
|
| 219 |
+
if result:
|
| 220 |
+
import json
|
| 221 |
+
return JSONResponse(content=json.loads(result))
|
| 222 |
+
else:
|
| 223 |
+
# Return OpenAI-compatible error format
|
| 224 |
+
return JSONResponse(
|
| 225 |
+
status_code=500,
|
| 226 |
+
content={
|
| 227 |
+
"error": {
|
| 228 |
+
"message": "Availability check failed",
|
| 229 |
+
"type": "server_error",
|
| 230 |
+
"param": None,
|
| 231 |
+
"code": None
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
except Exception as e:
|
| 237 |
+
return JSONResponse(
|
| 238 |
+
status_code=500,
|
| 239 |
+
content={
|
| 240 |
+
"error": {
|
| 241 |
+
"message": str(e),
|
| 242 |
+
"type": "server_error",
|
| 243 |
+
"param": None,
|
| 244 |
+
"code": None
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
@router.post("/v1/tasks")
|
| 250 |
+
async def submit_task(
|
| 251 |
+
request: ChatCompletionRequest,
|
| 252 |
+
api_key: str = Depends(verify_api_key_header)
|
| 253 |
+
):
|
| 254 |
+
"""Submit an asynchronous generation task"""
|
| 255 |
+
try:
|
| 256 |
+
# Extract prompt from messages (reuse logic from create_chat_completion)
|
| 257 |
+
if not request.messages:
|
| 258 |
+
raise HTTPException(status_code=400, detail="Messages cannot be empty")
|
| 259 |
+
|
| 260 |
+
last_message = request.messages[-1]
|
| 261 |
+
content = last_message.content
|
| 262 |
+
|
| 263 |
+
prompt = ""
|
| 264 |
+
image_data = request.image
|
| 265 |
+
video_data = request.video
|
| 266 |
+
remix_target_id = request.remix_target_id
|
| 267 |
+
|
| 268 |
+
if isinstance(content, str):
|
| 269 |
+
prompt = content
|
| 270 |
+
if not remix_target_id:
|
| 271 |
+
remix_target_id = _extract_remix_id(prompt)
|
| 272 |
+
elif isinstance(content, list):
|
| 273 |
+
for item in content:
|
| 274 |
+
if isinstance(item, dict):
|
| 275 |
+
if item.get("type") == "text":
|
| 276 |
+
prompt = item.get("text", "")
|
| 277 |
+
if not remix_target_id:
|
| 278 |
+
remix_target_id = _extract_remix_id(prompt)
|
| 279 |
+
elif item.get("type") == "image_url":
|
| 280 |
+
image_url = item.get("image_url", {})
|
| 281 |
+
url = image_url.get("url", "")
|
| 282 |
+
if url.startswith("data:image"):
|
| 283 |
+
if "base64," in url:
|
| 284 |
+
image_data = url.split("base64,", 1)[1]
|
| 285 |
+
else:
|
| 286 |
+
image_data = url
|
| 287 |
+
elif item.get("type") == "input_video":
|
| 288 |
+
video_url = item.get("videoUrl", {})
|
| 289 |
+
url = video_url.get("url", "")
|
| 290 |
+
if url.startswith("data:video") or url.startswith("data:application"):
|
| 291 |
+
if "base64," in url:
|
| 292 |
+
video_data = url.split("base64,", 1)[1]
|
| 293 |
+
else:
|
| 294 |
+
video_data = url
|
| 295 |
+
else:
|
| 296 |
+
video_data = url
|
| 297 |
+
|
| 298 |
+
task_id = await generation_handler.submit_generation_task(
|
| 299 |
+
model=request.model,
|
| 300 |
+
prompt=prompt,
|
| 301 |
+
image=image_data,
|
| 302 |
+
video=video_data,
|
| 303 |
+
remix_target_id=remix_target_id
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
return {
|
| 307 |
+
"id": task_id,
|
| 308 |
+
"object": "task",
|
| 309 |
+
"created": int(datetime.now().timestamp()),
|
| 310 |
+
"status": "processing"
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
except Exception as e:
|
| 314 |
+
return JSONResponse(
|
| 315 |
+
status_code=500,
|
| 316 |
+
content={
|
| 317 |
+
"error": {
|
| 318 |
+
"message": str(e),
|
| 319 |
+
"type": "server_error",
|
| 320 |
+
"param": None,
|
| 321 |
+
"code": None
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
@router.get("/v1/tasks/{task_id}")
|
| 327 |
+
async def get_task_status(
|
| 328 |
+
task_id: str,
|
| 329 |
+
api_key: str = Depends(verify_api_key_header)
|
| 330 |
+
):
|
| 331 |
+
"""Query task status"""
|
| 332 |
+
try:
|
| 333 |
+
task = await generation_handler.db.get_task(task_id)
|
| 334 |
+
if not task:
|
| 335 |
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
| 336 |
+
|
| 337 |
+
response = {
|
| 338 |
+
"id": task.task_id,
|
| 339 |
+
"object": "task",
|
| 340 |
+
"status": task.status,
|
| 341 |
+
"created": int(task.created_at.timestamp()) if task.created_at else 0,
|
| 342 |
+
"model": task.model,
|
| 343 |
+
"progress": f"{task.progress:.0f}%"
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
if task.status == "completed":
|
| 347 |
+
response["result"] = {
|
| 348 |
+
"url": json.loads(task.result_urls)[0] if task.result_urls else None
|
| 349 |
+
}
|
| 350 |
+
elif task.status == "failed":
|
| 351 |
+
response["error"] = {
|
| 352 |
+
"message": task.error_message
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
return response
|
| 356 |
+
|
| 357 |
+
except HTTPException:
|
| 358 |
+
raise
|
| 359 |
+
except Exception as e:
|
| 360 |
+
return JSONResponse(
|
| 361 |
+
status_code=500,
|
| 362 |
+
content={
|
| 363 |
+
"error": {
|
| 364 |
+
"message": str(e),
|
| 365 |
+
"type": "server_error",
|
| 366 |
+
"param": None,
|
| 367 |
+
"code": None
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
@router.post("/v1beta/models/gemini-3-pro-image-preview:generateContent")
|
| 374 |
+
async def proxy_gemini_vision(request: Request, key: str):
|
| 375 |
+
"""
|
| 376 |
+
Direct proxy for gemini-3-pro-image-preview:generateContent
|
| 377 |
+
"""
|
| 378 |
+
try:
|
| 379 |
+
body = await request.json()
|
| 380 |
+
target_url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key={key}"
|
| 381 |
+
|
| 382 |
+
headers = {
|
| 383 |
+
"Content-Type": "application/json"
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
# Use httpx for async request
|
| 387 |
+
import httpx
|
| 388 |
+
async with httpx.AsyncClient() as client:
|
| 389 |
+
response = await client.post(target_url, json=body, headers=headers, timeout=60)
|
| 390 |
+
|
| 391 |
+
# Forward the status code and content
|
| 392 |
+
if response.status_code != 200:
|
| 393 |
+
return JSONResponse(status_code=response.status_code, content=response.json())
|
| 394 |
+
|
| 395 |
+
return response.json()
|
| 396 |
+
|
| 397 |
+
except Exception as e:
|
| 398 |
+
# logger.error(f"Proxy error: {str(e)}")
|
| 399 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
@router.get("/", response_class=HTMLResponse)
|
| 404 |
+
async def root():
|
| 405 |
+
html_content = f"""
|
| 406 |
+
<!DOCTYPE html>
|
| 407 |
+
<html>
|
| 408 |
+
<head>
|
| 409 |
+
<title>Gemini API 代理服务</title>
|
| 410 |
+
<style>
|
| 411 |
+
body {{
|
| 412 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 413 |
+
max-width: 800px;
|
| 414 |
+
margin: 0 auto;
|
| 415 |
+
padding: 20px;
|
| 416 |
+
line-height: 1.6;
|
| 417 |
+
}}
|
| 418 |
+
h1 {{
|
| 419 |
+
color: #333;
|
| 420 |
+
text-align: center;
|
| 421 |
+
margin-bottom: 30px;
|
| 422 |
+
}}
|
| 423 |
+
.info-box {{
|
| 424 |
+
background-color: #f8f9fa;
|
| 425 |
+
border: 1px solid #dee2e6;
|
| 426 |
+
border-radius: 4px;
|
| 427 |
+
padding: 20px;
|
| 428 |
+
margin-bottom: 20px;
|
| 429 |
+
}}
|
| 430 |
+
.status {{
|
| 431 |
+
color: #28a745;
|
| 432 |
+
font-weight: bold;
|
| 433 |
+
}}
|
| 434 |
+
</style>
|
| 435 |
+
</head>
|
| 436 |
+
<body>
|
| 437 |
+
<h1>🤖 Gemini API 代理服务</h1>
|
| 438 |
+
|
| 439 |
+
<div class="info-box">
|
| 440 |
+
<h2>🟢 运行状态</h2>
|
| 441 |
+
<p class="status">服务运行中</p>
|
| 442 |
+
<p>可用API密钥数量: {len(key_manager.api_keys)}</p>
|
| 443 |
+
<p>可用模型数量: {len(GeminiClient.AVAILABLE_MODELS)}</p>
|
| 444 |
+
</div>
|
| 445 |
+
|
| 446 |
+
<div class="info-box">
|
| 447 |
+
<h2>⚙️ 环境配置</h2>
|
| 448 |
+
<p>每分钟请求限制: {MAX_REQUESTS_PER_MINUTE}</p>
|
| 449 |
+
<p>每IP每日请求限制: {MAX_REQUESTS_PER_DAY_PER_IP}</p>
|
| 450 |
+
<p>最大重试次数: {len(key_manager.api_keys)}</p>
|
| 451 |
+
</div>
|
| 452 |
+
</body>
|
| 453 |
+
</html>
|
| 454 |
+
"""
|
| 455 |
+
return html_content
|
src/core/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core modules"""
|
| 2 |
+
|
| 3 |
+
from .config import config
|
| 4 |
+
from .database import Database
|
| 5 |
+
from .models import *
|
| 6 |
+
from .auth import AuthManager, verify_api_key_header
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"config",
|
| 10 |
+
"Database",
|
| 11 |
+
"AuthManager",
|
| 12 |
+
"verify_api_key_header",
|
| 13 |
+
]
|
| 14 |
+
|
src/core/auth.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication module"""
|
| 2 |
+
import bcrypt
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from fastapi import HTTPException, Security
|
| 5 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 6 |
+
from .config import config
|
| 7 |
+
|
| 8 |
+
security = HTTPBearer()
|
| 9 |
+
|
| 10 |
+
class AuthManager:
|
| 11 |
+
"""Authentication manager"""
|
| 12 |
+
|
| 13 |
+
@staticmethod
|
| 14 |
+
def verify_api_key(api_key: str) -> bool:
|
| 15 |
+
"""Verify API key"""
|
| 16 |
+
return api_key == config.api_key
|
| 17 |
+
|
| 18 |
+
@staticmethod
|
| 19 |
+
def verify_admin(username: str, password: str) -> bool:
|
| 20 |
+
"""Verify admin credentials"""
|
| 21 |
+
# Compare with current config (which may be from database or config file)
|
| 22 |
+
return username == config.admin_username and password == config.admin_password
|
| 23 |
+
|
| 24 |
+
@staticmethod
|
| 25 |
+
def hash_password(password: str) -> str:
|
| 26 |
+
"""Hash password"""
|
| 27 |
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
| 28 |
+
|
| 29 |
+
@staticmethod
|
| 30 |
+
def verify_password(password: str, hashed: str) -> bool:
|
| 31 |
+
"""Verify password"""
|
| 32 |
+
return bcrypt.checkpw(password.encode(), hashed.encode())
|
| 33 |
+
|
| 34 |
+
async def verify_api_key_header(credentials: HTTPAuthorizationCredentials = Security(security)) -> str:
|
| 35 |
+
"""Verify API key from Authorization header"""
|
| 36 |
+
api_key = credentials.credentials
|
| 37 |
+
if not AuthManager.verify_api_key(api_key):
|
| 38 |
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
| 39 |
+
return api_key
|
src/core/config.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration management"""
|
| 2 |
+
import tomli
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Dict, Any, Optional
|
| 5 |
+
|
| 6 |
+
class Config:
|
| 7 |
+
"""Application configuration"""
|
| 8 |
+
|
| 9 |
+
def __init__(self):
|
| 10 |
+
self._config = self._load_config()
|
| 11 |
+
self._admin_username: Optional[str] = None
|
| 12 |
+
self._admin_password: Optional[str] = None
|
| 13 |
+
|
| 14 |
+
def _load_config(self) -> Dict[str, Any]:
|
| 15 |
+
"""Load configuration from setting.toml"""
|
| 16 |
+
config_path = Path(__file__).parent.parent.parent / "config" / "setting.toml"
|
| 17 |
+
with open(config_path, "rb") as f:
|
| 18 |
+
return tomli.load(f)
|
| 19 |
+
|
| 20 |
+
def reload_config(self):
|
| 21 |
+
"""Reload configuration from file"""
|
| 22 |
+
self._config = self._load_config()
|
| 23 |
+
|
| 24 |
+
def get_raw_config(self) -> Dict[str, Any]:
|
| 25 |
+
"""Get raw configuration dictionary"""
|
| 26 |
+
return self._config
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def admin_username(self) -> str:
|
| 30 |
+
# If admin_username is set from database, use it; otherwise fall back to config file
|
| 31 |
+
if self._admin_username is not None:
|
| 32 |
+
return self._admin_username
|
| 33 |
+
return self._config["global"]["admin_username"]
|
| 34 |
+
|
| 35 |
+
@admin_username.setter
|
| 36 |
+
def admin_username(self, value: str):
|
| 37 |
+
self._admin_username = value
|
| 38 |
+
self._config["global"]["admin_username"] = value
|
| 39 |
+
|
| 40 |
+
def set_admin_username_from_db(self, username: str):
|
| 41 |
+
"""Set admin username from database"""
|
| 42 |
+
self._admin_username = username
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def sora_base_url(self) -> str:
|
| 46 |
+
return self._config["sora"]["base_url"]
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def sora_timeout(self) -> int:
|
| 50 |
+
return self._config["sora"]["timeout"]
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def sora_max_retries(self) -> int:
|
| 54 |
+
return self._config["sora"]["max_retries"]
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def poll_interval(self) -> float:
|
| 58 |
+
return self._config["sora"]["poll_interval"]
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def max_poll_attempts(self) -> int:
|
| 62 |
+
return self._config["sora"]["max_poll_attempts"]
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def server_host(self) -> str:
|
| 66 |
+
return self._config["server"]["host"]
|
| 67 |
+
|
| 68 |
+
@property
|
| 69 |
+
def server_port(self) -> int:
|
| 70 |
+
return self._config["server"]["port"]
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def debug_enabled(self) -> bool:
|
| 74 |
+
return self._config.get("debug", {}).get("enabled", False)
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def debug_log_requests(self) -> bool:
|
| 78 |
+
return self._config.get("debug", {}).get("log_requests", True)
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def debug_log_responses(self) -> bool:
|
| 82 |
+
return self._config.get("debug", {}).get("log_responses", True)
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def debug_mask_token(self) -> bool:
|
| 86 |
+
return self._config.get("debug", {}).get("mask_token", True)
|
| 87 |
+
|
| 88 |
+
# Mutable properties for runtime updates
|
| 89 |
+
@property
|
| 90 |
+
def api_key(self) -> str:
|
| 91 |
+
return self._config["global"]["api_key"]
|
| 92 |
+
|
| 93 |
+
@api_key.setter
|
| 94 |
+
def api_key(self, value: str):
|
| 95 |
+
self._config["global"]["api_key"] = value
|
| 96 |
+
|
| 97 |
+
@property
|
| 98 |
+
def admin_password(self) -> str:
|
| 99 |
+
# If admin_password is set from database, use it; otherwise fall back to config file
|
| 100 |
+
if self._admin_password is not None:
|
| 101 |
+
return self._admin_password
|
| 102 |
+
return self._config["global"]["admin_password"]
|
| 103 |
+
|
| 104 |
+
@admin_password.setter
|
| 105 |
+
def admin_password(self, value: str):
|
| 106 |
+
self._admin_password = value
|
| 107 |
+
self._config["global"]["admin_password"] = value
|
| 108 |
+
|
| 109 |
+
def set_admin_password_from_db(self, password: str):
|
| 110 |
+
"""Set admin password from database"""
|
| 111 |
+
self._admin_password = password
|
| 112 |
+
|
| 113 |
+
def set_debug_enabled(self, enabled: bool):
|
| 114 |
+
"""Set debug mode enabled/disabled"""
|
| 115 |
+
if "debug" not in self._config:
|
| 116 |
+
self._config["debug"] = {}
|
| 117 |
+
self._config["debug"]["enabled"] = enabled
|
| 118 |
+
|
| 119 |
+
@property
|
| 120 |
+
def cache_timeout(self) -> int:
|
| 121 |
+
"""Get cache timeout in seconds"""
|
| 122 |
+
return self._config.get("cache", {}).get("timeout", 7200)
|
| 123 |
+
|
| 124 |
+
def set_cache_timeout(self, timeout: int):
|
| 125 |
+
"""Set cache timeout in seconds"""
|
| 126 |
+
if "cache" not in self._config:
|
| 127 |
+
self._config["cache"] = {}
|
| 128 |
+
self._config["cache"]["timeout"] = timeout
|
| 129 |
+
|
| 130 |
+
@property
|
| 131 |
+
def cache_base_url(self) -> str:
|
| 132 |
+
"""Get cache base URL"""
|
| 133 |
+
return self._config.get("cache", {}).get("base_url", "")
|
| 134 |
+
|
| 135 |
+
def set_cache_base_url(self, base_url: str):
|
| 136 |
+
"""Set cache base URL"""
|
| 137 |
+
if "cache" not in self._config:
|
| 138 |
+
self._config["cache"] = {}
|
| 139 |
+
self._config["cache"]["base_url"] = base_url
|
| 140 |
+
|
| 141 |
+
@property
|
| 142 |
+
def cache_enabled(self) -> bool:
|
| 143 |
+
"""Get cache enabled status"""
|
| 144 |
+
return self._config.get("cache", {}).get("enabled", True)
|
| 145 |
+
|
| 146 |
+
def set_cache_enabled(self, enabled: bool):
|
| 147 |
+
"""Set cache enabled status"""
|
| 148 |
+
if "cache" not in self._config:
|
| 149 |
+
self._config["cache"] = {}
|
| 150 |
+
self._config["cache"]["enabled"] = enabled
|
| 151 |
+
|
| 152 |
+
@property
|
| 153 |
+
def image_timeout(self) -> int:
|
| 154 |
+
"""Get image generation timeout in seconds"""
|
| 155 |
+
return self._config.get("generation", {}).get("image_timeout", 300)
|
| 156 |
+
|
| 157 |
+
def set_image_timeout(self, timeout: int):
|
| 158 |
+
"""Set image generation timeout in seconds"""
|
| 159 |
+
if "generation" not in self._config:
|
| 160 |
+
self._config["generation"] = {}
|
| 161 |
+
self._config["generation"]["image_timeout"] = timeout
|
| 162 |
+
|
| 163 |
+
@property
|
| 164 |
+
def video_timeout(self) -> int:
|
| 165 |
+
"""Get video generation timeout in seconds"""
|
| 166 |
+
return self._config.get("generation", {}).get("video_timeout", 1500)
|
| 167 |
+
|
| 168 |
+
def set_video_timeout(self, timeout: int):
|
| 169 |
+
"""Set video generation timeout in seconds"""
|
| 170 |
+
if "generation" not in self._config:
|
| 171 |
+
self._config["generation"] = {}
|
| 172 |
+
self._config["generation"]["video_timeout"] = timeout
|
| 173 |
+
|
| 174 |
+
@property
|
| 175 |
+
def watermark_free_enabled(self) -> bool:
|
| 176 |
+
"""Get watermark-free mode enabled status"""
|
| 177 |
+
return self._config.get("watermark_free", {}).get("watermark_free_enabled", False)
|
| 178 |
+
|
| 179 |
+
def set_watermark_free_enabled(self, enabled: bool):
|
| 180 |
+
"""Set watermark-free mode enabled/disabled"""
|
| 181 |
+
if "watermark_free" not in self._config:
|
| 182 |
+
self._config["watermark_free"] = {}
|
| 183 |
+
self._config["watermark_free"]["watermark_free_enabled"] = enabled
|
| 184 |
+
|
| 185 |
+
@property
|
| 186 |
+
def watermark_free_parse_method(self) -> str:
|
| 187 |
+
"""Get watermark-free parse method"""
|
| 188 |
+
return self._config.get("watermark_free", {}).get("parse_method", "third_party")
|
| 189 |
+
|
| 190 |
+
@property
|
| 191 |
+
def watermark_free_custom_url(self) -> str:
|
| 192 |
+
"""Get custom parse server URL"""
|
| 193 |
+
return self._config.get("watermark_free", {}).get("custom_parse_url", "")
|
| 194 |
+
|
| 195 |
+
@property
|
| 196 |
+
def watermark_free_custom_token(self) -> str:
|
| 197 |
+
"""Get custom parse server access token"""
|
| 198 |
+
return self._config.get("watermark_free", {}).get("custom_parse_token", "")
|
| 199 |
+
|
| 200 |
+
@property
|
| 201 |
+
def at_auto_refresh_enabled(self) -> bool:
|
| 202 |
+
"""Get AT auto refresh enabled status"""
|
| 203 |
+
return self._config.get("token_refresh", {}).get("at_auto_refresh_enabled", False)
|
| 204 |
+
|
| 205 |
+
def set_at_auto_refresh_enabled(self, enabled: bool):
|
| 206 |
+
"""Set AT auto refresh enabled/disabled"""
|
| 207 |
+
if "token_refresh" not in self._config:
|
| 208 |
+
self._config["token_refresh"] = {}
|
| 209 |
+
self._config["token_refresh"]["at_auto_refresh_enabled"] = enabled
|
| 210 |
+
|
| 211 |
+
# Global config instance
|
| 212 |
+
config = Config()
|
src/core/database.py
ADDED
|
@@ -0,0 +1,1012 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database storage layer"""
|
| 2 |
+
import aiosqlite
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional, List
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from .models import Token, TokenStats, Task, RequestLog, AdminConfig, ProxyConfig, WatermarkFreeConfig, CacheConfig, GenerationConfig, TokenRefreshConfig
|
| 8 |
+
|
| 9 |
+
class Database:
|
| 10 |
+
"""SQLite database manager"""
|
| 11 |
+
|
| 12 |
+
def __init__(self, db_path: str = None):
|
| 13 |
+
if db_path is None:
|
| 14 |
+
# Store database in data directory
|
| 15 |
+
data_dir = Path(__file__).parent.parent.parent / "data"
|
| 16 |
+
data_dir.mkdir(exist_ok=True)
|
| 17 |
+
db_path = str(data_dir / "hancat.db")
|
| 18 |
+
self.db_path = db_path
|
| 19 |
+
|
| 20 |
+
def db_exists(self) -> bool:
|
| 21 |
+
"""Check if database file exists"""
|
| 22 |
+
return Path(self.db_path).exists()
|
| 23 |
+
|
| 24 |
+
async def _table_exists(self, db, table_name: str) -> bool:
|
| 25 |
+
"""Check if a table exists in the database"""
|
| 26 |
+
cursor = await db.execute(
|
| 27 |
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
| 28 |
+
(table_name,)
|
| 29 |
+
)
|
| 30 |
+
result = await cursor.fetchone()
|
| 31 |
+
return result is not None
|
| 32 |
+
|
| 33 |
+
async def _column_exists(self, db, table_name: str, column_name: str) -> bool:
|
| 34 |
+
"""Check if a column exists in a table"""
|
| 35 |
+
try:
|
| 36 |
+
cursor = await db.execute(f"PRAGMA table_info({table_name})")
|
| 37 |
+
columns = await cursor.fetchall()
|
| 38 |
+
return any(col[1] == column_name for col in columns)
|
| 39 |
+
except:
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
async def _ensure_config_rows(self, db, config_dict: dict = None):
|
| 43 |
+
"""Ensure all config tables have their default rows
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
db: Database connection
|
| 47 |
+
config_dict: Configuration dictionary from setting.toml (optional)
|
| 48 |
+
"""
|
| 49 |
+
# Ensure admin_config has a row
|
| 50 |
+
cursor = await db.execute("SELECT COUNT(*) FROM admin_config")
|
| 51 |
+
count = await cursor.fetchone()
|
| 52 |
+
if count[0] == 0:
|
| 53 |
+
# Get admin credentials from config_dict if provided, otherwise use defaults
|
| 54 |
+
admin_username = "admin"
|
| 55 |
+
admin_password = "admin"
|
| 56 |
+
error_ban_threshold = 3
|
| 57 |
+
|
| 58 |
+
if config_dict:
|
| 59 |
+
global_config = config_dict.get("global", {})
|
| 60 |
+
admin_username = global_config.get("admin_username", "admin")
|
| 61 |
+
admin_password = global_config.get("admin_password", "admin")
|
| 62 |
+
|
| 63 |
+
admin_config = config_dict.get("admin", {})
|
| 64 |
+
error_ban_threshold = admin_config.get("error_ban_threshold", 3)
|
| 65 |
+
|
| 66 |
+
await db.execute("""
|
| 67 |
+
INSERT INTO admin_config (id, admin_username, admin_password, error_ban_threshold)
|
| 68 |
+
VALUES (1, ?, ?, ?)
|
| 69 |
+
""", (admin_username, admin_password, error_ban_threshold))
|
| 70 |
+
|
| 71 |
+
# Ensure proxy_config has a row
|
| 72 |
+
cursor = await db.execute("SELECT COUNT(*) FROM proxy_config")
|
| 73 |
+
count = await cursor.fetchone()
|
| 74 |
+
if count[0] == 0:
|
| 75 |
+
# Get proxy config from config_dict if provided, otherwise use defaults
|
| 76 |
+
proxy_enabled = False
|
| 77 |
+
proxy_url = None
|
| 78 |
+
|
| 79 |
+
if config_dict:
|
| 80 |
+
proxy_config = config_dict.get("proxy", {})
|
| 81 |
+
proxy_enabled = proxy_config.get("proxy_enabled", False)
|
| 82 |
+
proxy_url = proxy_config.get("proxy_url", "")
|
| 83 |
+
# Convert empty string to None
|
| 84 |
+
proxy_url = proxy_url if proxy_url else None
|
| 85 |
+
|
| 86 |
+
await db.execute("""
|
| 87 |
+
INSERT INTO proxy_config (id, proxy_enabled, proxy_url)
|
| 88 |
+
VALUES (1, ?, ?)
|
| 89 |
+
""", (proxy_enabled, proxy_url))
|
| 90 |
+
|
| 91 |
+
# Ensure watermark_free_config has a row
|
| 92 |
+
cursor = await db.execute("SELECT COUNT(*) FROM watermark_free_config")
|
| 93 |
+
count = await cursor.fetchone()
|
| 94 |
+
if count[0] == 0:
|
| 95 |
+
# Get watermark-free config from config_dict if provided, otherwise use defaults
|
| 96 |
+
watermark_free_enabled = False
|
| 97 |
+
parse_method = "third_party"
|
| 98 |
+
custom_parse_url = None
|
| 99 |
+
custom_parse_token = None
|
| 100 |
+
|
| 101 |
+
if config_dict:
|
| 102 |
+
watermark_config = config_dict.get("watermark_free", {})
|
| 103 |
+
watermark_free_enabled = watermark_config.get("watermark_free_enabled", False)
|
| 104 |
+
parse_method = watermark_config.get("parse_method", "third_party")
|
| 105 |
+
custom_parse_url = watermark_config.get("custom_parse_url", "")
|
| 106 |
+
custom_parse_token = watermark_config.get("custom_parse_token", "")
|
| 107 |
+
|
| 108 |
+
# Convert empty strings to None
|
| 109 |
+
custom_parse_url = custom_parse_url if custom_parse_url else None
|
| 110 |
+
custom_parse_token = custom_parse_token if custom_parse_token else None
|
| 111 |
+
|
| 112 |
+
await db.execute("""
|
| 113 |
+
INSERT INTO watermark_free_config (id, watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token)
|
| 114 |
+
VALUES (1, ?, ?, ?, ?)
|
| 115 |
+
""", (watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token))
|
| 116 |
+
|
| 117 |
+
# Ensure cache_config has a row
|
| 118 |
+
cursor = await db.execute("SELECT COUNT(*) FROM cache_config")
|
| 119 |
+
count = await cursor.fetchone()
|
| 120 |
+
if count[0] == 0:
|
| 121 |
+
# Get cache config from config_dict if provided, otherwise use defaults
|
| 122 |
+
cache_enabled = False
|
| 123 |
+
cache_timeout = 600
|
| 124 |
+
cache_base_url = None
|
| 125 |
+
|
| 126 |
+
if config_dict:
|
| 127 |
+
cache_config = config_dict.get("cache", {})
|
| 128 |
+
cache_enabled = cache_config.get("enabled", False)
|
| 129 |
+
cache_timeout = cache_config.get("timeout", 600)
|
| 130 |
+
cache_base_url = cache_config.get("base_url", "")
|
| 131 |
+
# Convert empty string to None
|
| 132 |
+
cache_base_url = cache_base_url if cache_base_url else None
|
| 133 |
+
|
| 134 |
+
await db.execute("""
|
| 135 |
+
INSERT INTO cache_config (id, cache_enabled, cache_timeout, cache_base_url)
|
| 136 |
+
VALUES (1, ?, ?, ?)
|
| 137 |
+
""", (cache_enabled, cache_timeout, cache_base_url))
|
| 138 |
+
|
| 139 |
+
# Ensure generation_config has a row
|
| 140 |
+
cursor = await db.execute("SELECT COUNT(*) FROM generation_config")
|
| 141 |
+
count = await cursor.fetchone()
|
| 142 |
+
if count[0] == 0:
|
| 143 |
+
# Get generation config from config_dict if provided, otherwise use defaults
|
| 144 |
+
image_timeout = 300
|
| 145 |
+
video_timeout = 1500
|
| 146 |
+
|
| 147 |
+
if config_dict:
|
| 148 |
+
generation_config = config_dict.get("generation", {})
|
| 149 |
+
image_timeout = generation_config.get("image_timeout", 300)
|
| 150 |
+
video_timeout = generation_config.get("video_timeout", 1500)
|
| 151 |
+
|
| 152 |
+
await db.execute("""
|
| 153 |
+
INSERT INTO generation_config (id, image_timeout, video_timeout)
|
| 154 |
+
VALUES (1, ?, ?)
|
| 155 |
+
""", (image_timeout, video_timeout))
|
| 156 |
+
|
| 157 |
+
# Ensure token_refresh_config has a row
|
| 158 |
+
cursor = await db.execute("SELECT COUNT(*) FROM token_refresh_config")
|
| 159 |
+
count = await cursor.fetchone()
|
| 160 |
+
if count[0] == 0:
|
| 161 |
+
# Get token refresh config from config_dict if provided, otherwise use defaults
|
| 162 |
+
at_auto_refresh_enabled = False
|
| 163 |
+
|
| 164 |
+
if config_dict:
|
| 165 |
+
token_refresh_config = config_dict.get("token_refresh", {})
|
| 166 |
+
at_auto_refresh_enabled = token_refresh_config.get("at_auto_refresh_enabled", False)
|
| 167 |
+
|
| 168 |
+
await db.execute("""
|
| 169 |
+
INSERT INTO token_refresh_config (id, at_auto_refresh_enabled)
|
| 170 |
+
VALUES (1, ?)
|
| 171 |
+
""", (at_auto_refresh_enabled,))
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
async def check_and_migrate_db(self, config_dict: dict = None):
|
| 175 |
+
"""Check database integrity and perform migrations if needed
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
config_dict: Configuration dictionary from setting.toml (optional)
|
| 179 |
+
Used to initialize new tables with values from setting.toml
|
| 180 |
+
"""
|
| 181 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 182 |
+
print("Checking database integrity and performing migrations...")
|
| 183 |
+
|
| 184 |
+
# Check and add missing columns to tokens table
|
| 185 |
+
if await self._table_exists(db, "tokens"):
|
| 186 |
+
columns_to_add = [
|
| 187 |
+
("sora2_supported", "BOOLEAN"),
|
| 188 |
+
("sora2_invite_code", "TEXT"),
|
| 189 |
+
("sora2_redeemed_count", "INTEGER DEFAULT 0"),
|
| 190 |
+
("sora2_total_count", "INTEGER DEFAULT 0"),
|
| 191 |
+
("sora2_remaining_count", "INTEGER DEFAULT 0"),
|
| 192 |
+
("sora2_cooldown_until", "TIMESTAMP"),
|
| 193 |
+
("image_enabled", "BOOLEAN DEFAULT 1"),
|
| 194 |
+
("video_enabled", "BOOLEAN DEFAULT 1"),
|
| 195 |
+
]
|
| 196 |
+
|
| 197 |
+
for col_name, col_type in columns_to_add:
|
| 198 |
+
if not await self._column_exists(db, "tokens", col_name):
|
| 199 |
+
try:
|
| 200 |
+
await db.execute(f"ALTER TABLE tokens ADD COLUMN {col_name} {col_type}")
|
| 201 |
+
print(f" ✓ Added column '{col_name}' to tokens table")
|
| 202 |
+
except Exception as e:
|
| 203 |
+
print(f" ✗ Failed to add column '{col_name}': {e}")
|
| 204 |
+
|
| 205 |
+
# Check and add missing columns to admin_config table
|
| 206 |
+
if await self._table_exists(db, "admin_config"):
|
| 207 |
+
columns_to_add = [
|
| 208 |
+
("admin_username", "TEXT DEFAULT 'admin'"),
|
| 209 |
+
("admin_password", "TEXT DEFAULT 'admin'"),
|
| 210 |
+
]
|
| 211 |
+
|
| 212 |
+
for col_name, col_type in columns_to_add:
|
| 213 |
+
if not await self._column_exists(db, "admin_config", col_name):
|
| 214 |
+
try:
|
| 215 |
+
await db.execute(f"ALTER TABLE admin_config ADD COLUMN {col_name} {col_type}")
|
| 216 |
+
print(f" ✓ Added column '{col_name}' to admin_config table")
|
| 217 |
+
except Exception as e:
|
| 218 |
+
print(f" ✗ Failed to add column '{col_name}': {e}")
|
| 219 |
+
|
| 220 |
+
# Check and add missing columns to watermark_free_config table
|
| 221 |
+
if await self._table_exists(db, "watermark_free_config"):
|
| 222 |
+
columns_to_add = [
|
| 223 |
+
("parse_method", "TEXT DEFAULT 'third_party'"),
|
| 224 |
+
("custom_parse_url", "TEXT"),
|
| 225 |
+
("custom_parse_token", "TEXT"),
|
| 226 |
+
]
|
| 227 |
+
|
| 228 |
+
for col_name, col_type in columns_to_add:
|
| 229 |
+
if not await self._column_exists(db, "watermark_free_config", col_name):
|
| 230 |
+
try:
|
| 231 |
+
await db.execute(f"ALTER TABLE watermark_free_config ADD COLUMN {col_name} {col_type}")
|
| 232 |
+
print(f" ✓ Added column '{col_name}' to watermark_free_config table")
|
| 233 |
+
except Exception as e:
|
| 234 |
+
print(f" ✗ Failed to add column '{col_name}': {e}")
|
| 235 |
+
|
| 236 |
+
# Ensure all config tables have their default rows
|
| 237 |
+
# Pass config_dict if available to initialize from setting.toml
|
| 238 |
+
await self._ensure_config_rows(db, config_dict)
|
| 239 |
+
|
| 240 |
+
await db.commit()
|
| 241 |
+
print("Database migration check completed.")
|
| 242 |
+
|
| 243 |
+
async def init_db(self):
|
| 244 |
+
"""Initialize database tables - creates all tables and ensures data integrity"""
|
| 245 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 246 |
+
# Tokens table
|
| 247 |
+
await db.execute("""
|
| 248 |
+
CREATE TABLE IF NOT EXISTS tokens (
|
| 249 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 250 |
+
token TEXT UNIQUE NOT NULL,
|
| 251 |
+
email TEXT NOT NULL,
|
| 252 |
+
username TEXT NOT NULL,
|
| 253 |
+
name TEXT NOT NULL,
|
| 254 |
+
st TEXT,
|
| 255 |
+
rt TEXT,
|
| 256 |
+
remark TEXT,
|
| 257 |
+
expiry_time TIMESTAMP,
|
| 258 |
+
is_active BOOLEAN DEFAULT 1,
|
| 259 |
+
cooled_until TIMESTAMP,
|
| 260 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 261 |
+
last_used_at TIMESTAMP,
|
| 262 |
+
use_count INTEGER DEFAULT 0,
|
| 263 |
+
plan_type TEXT,
|
| 264 |
+
plan_title TEXT,
|
| 265 |
+
subscription_end TIMESTAMP,
|
| 266 |
+
sora2_supported BOOLEAN,
|
| 267 |
+
sora2_invite_code TEXT,
|
| 268 |
+
sora2_redeemed_count INTEGER DEFAULT 0,
|
| 269 |
+
sora2_total_count INTEGER DEFAULT 0,
|
| 270 |
+
sora2_remaining_count INTEGER DEFAULT 0,
|
| 271 |
+
sora2_cooldown_until TIMESTAMP,
|
| 272 |
+
image_enabled BOOLEAN DEFAULT 1,
|
| 273 |
+
video_enabled BOOLEAN DEFAULT 1
|
| 274 |
+
)
|
| 275 |
+
""")
|
| 276 |
+
|
| 277 |
+
# Token stats table
|
| 278 |
+
await db.execute("""
|
| 279 |
+
CREATE TABLE IF NOT EXISTS token_stats (
|
| 280 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 281 |
+
token_id INTEGER NOT NULL,
|
| 282 |
+
image_count INTEGER DEFAULT 0,
|
| 283 |
+
video_count INTEGER DEFAULT 0,
|
| 284 |
+
error_count INTEGER DEFAULT 0,
|
| 285 |
+
last_error_at TIMESTAMP,
|
| 286 |
+
FOREIGN KEY (token_id) REFERENCES tokens(id)
|
| 287 |
+
)
|
| 288 |
+
""")
|
| 289 |
+
|
| 290 |
+
# Tasks table
|
| 291 |
+
await db.execute("""
|
| 292 |
+
CREATE TABLE IF NOT EXISTS tasks (
|
| 293 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 294 |
+
task_id TEXT UNIQUE NOT NULL,
|
| 295 |
+
token_id INTEGER NOT NULL,
|
| 296 |
+
model TEXT NOT NULL,
|
| 297 |
+
prompt TEXT NOT NULL,
|
| 298 |
+
status TEXT NOT NULL DEFAULT 'processing',
|
| 299 |
+
progress FLOAT DEFAULT 0,
|
| 300 |
+
result_urls TEXT,
|
| 301 |
+
error_message TEXT,
|
| 302 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 303 |
+
completed_at TIMESTAMP,
|
| 304 |
+
FOREIGN KEY (token_id) REFERENCES tokens(id)
|
| 305 |
+
)
|
| 306 |
+
""")
|
| 307 |
+
|
| 308 |
+
# Request logs table
|
| 309 |
+
await db.execute("""
|
| 310 |
+
CREATE TABLE IF NOT EXISTS request_logs (
|
| 311 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 312 |
+
token_id INTEGER,
|
| 313 |
+
operation TEXT NOT NULL,
|
| 314 |
+
request_body TEXT,
|
| 315 |
+
response_body TEXT,
|
| 316 |
+
status_code INTEGER NOT NULL,
|
| 317 |
+
duration FLOAT NOT NULL,
|
| 318 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 319 |
+
FOREIGN KEY (token_id) REFERENCES tokens(id)
|
| 320 |
+
)
|
| 321 |
+
""")
|
| 322 |
+
|
| 323 |
+
# Admin config table
|
| 324 |
+
await db.execute("""
|
| 325 |
+
CREATE TABLE IF NOT EXISTS admin_config (
|
| 326 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 327 |
+
admin_username TEXT DEFAULT 'admin',
|
| 328 |
+
admin_password TEXT DEFAULT 'admin',
|
| 329 |
+
error_ban_threshold INTEGER DEFAULT 3,
|
| 330 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 331 |
+
)
|
| 332 |
+
""")
|
| 333 |
+
|
| 334 |
+
# Proxy config table
|
| 335 |
+
await db.execute("""
|
| 336 |
+
CREATE TABLE IF NOT EXISTS proxy_config (
|
| 337 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 338 |
+
proxy_enabled BOOLEAN DEFAULT 0,
|
| 339 |
+
proxy_url TEXT,
|
| 340 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 341 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 342 |
+
)
|
| 343 |
+
""")
|
| 344 |
+
|
| 345 |
+
# Watermark-free config table
|
| 346 |
+
await db.execute("""
|
| 347 |
+
CREATE TABLE IF NOT EXISTS watermark_free_config (
|
| 348 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 349 |
+
watermark_free_enabled BOOLEAN DEFAULT 0,
|
| 350 |
+
parse_method TEXT DEFAULT 'third_party',
|
| 351 |
+
custom_parse_url TEXT,
|
| 352 |
+
custom_parse_token TEXT,
|
| 353 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 354 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 355 |
+
)
|
| 356 |
+
""")
|
| 357 |
+
|
| 358 |
+
# Cache config table
|
| 359 |
+
await db.execute("""
|
| 360 |
+
CREATE TABLE IF NOT EXISTS cache_config (
|
| 361 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 362 |
+
cache_enabled BOOLEAN DEFAULT 0,
|
| 363 |
+
cache_timeout INTEGER DEFAULT 600,
|
| 364 |
+
cache_base_url TEXT,
|
| 365 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 366 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 367 |
+
)
|
| 368 |
+
""")
|
| 369 |
+
|
| 370 |
+
# Generation config table
|
| 371 |
+
await db.execute("""
|
| 372 |
+
CREATE TABLE IF NOT EXISTS generation_config (
|
| 373 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 374 |
+
image_timeout INTEGER DEFAULT 300,
|
| 375 |
+
video_timeout INTEGER DEFAULT 1500,
|
| 376 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 377 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 378 |
+
)
|
| 379 |
+
""")
|
| 380 |
+
|
| 381 |
+
# Token refresh config table
|
| 382 |
+
await db.execute("""
|
| 383 |
+
CREATE TABLE IF NOT EXISTS token_refresh_config (
|
| 384 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 385 |
+
at_auto_refresh_enabled BOOLEAN DEFAULT 0,
|
| 386 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 387 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 388 |
+
)
|
| 389 |
+
""")
|
| 390 |
+
|
| 391 |
+
# Create indexes
|
| 392 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
|
| 393 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_status ON tasks(status)")
|
| 394 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_token_active ON tokens(is_active)")
|
| 395 |
+
|
| 396 |
+
await db.commit()
|
| 397 |
+
|
| 398 |
+
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
|
| 399 |
+
"""
|
| 400 |
+
Initialize database configuration from setting.toml
|
| 401 |
+
|
| 402 |
+
Args:
|
| 403 |
+
config_dict: Configuration dictionary from setting.toml
|
| 404 |
+
is_first_startup: If True, only update if row doesn't exist. If False, always update.
|
| 405 |
+
"""
|
| 406 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 407 |
+
# On first startup, ensure all config rows exist with values from setting.toml
|
| 408 |
+
if is_first_startup:
|
| 409 |
+
await self._ensure_config_rows(db, config_dict)
|
| 410 |
+
|
| 411 |
+
# Initialize admin config
|
| 412 |
+
admin_config = config_dict.get("admin", {})
|
| 413 |
+
error_ban_threshold = admin_config.get("error_ban_threshold", 3)
|
| 414 |
+
|
| 415 |
+
# Get admin credentials from global config
|
| 416 |
+
global_config = config_dict.get("global", {})
|
| 417 |
+
admin_username = global_config.get("admin_username", "admin")
|
| 418 |
+
admin_password = global_config.get("admin_password", "admin")
|
| 419 |
+
|
| 420 |
+
if not is_first_startup:
|
| 421 |
+
# On upgrade, update the configuration
|
| 422 |
+
await db.execute("""
|
| 423 |
+
UPDATE admin_config
|
| 424 |
+
SET admin_username = ?, admin_password = ?, error_ban_threshold = ?, updated_at = CURRENT_TIMESTAMP
|
| 425 |
+
WHERE id = 1
|
| 426 |
+
""", (admin_username, admin_password, error_ban_threshold))
|
| 427 |
+
|
| 428 |
+
# Initialize proxy config
|
| 429 |
+
proxy_config = config_dict.get("proxy", {})
|
| 430 |
+
proxy_enabled = proxy_config.get("proxy_enabled", False)
|
| 431 |
+
proxy_url = proxy_config.get("proxy_url", "")
|
| 432 |
+
# Convert empty string to None
|
| 433 |
+
proxy_url = proxy_url if proxy_url else None
|
| 434 |
+
|
| 435 |
+
if is_first_startup:
|
| 436 |
+
await db.execute("""
|
| 437 |
+
INSERT OR IGNORE INTO proxy_config (id, proxy_enabled, proxy_url)
|
| 438 |
+
VALUES (1, ?, ?)
|
| 439 |
+
""", (proxy_enabled, proxy_url))
|
| 440 |
+
else:
|
| 441 |
+
await db.execute("""
|
| 442 |
+
UPDATE proxy_config
|
| 443 |
+
SET proxy_enabled = ?, proxy_url = ?, updated_at = CURRENT_TIMESTAMP
|
| 444 |
+
WHERE id = 1
|
| 445 |
+
""", (proxy_enabled, proxy_url))
|
| 446 |
+
|
| 447 |
+
# Initialize watermark-free config
|
| 448 |
+
watermark_config = config_dict.get("watermark_free", {})
|
| 449 |
+
watermark_free_enabled = watermark_config.get("watermark_free_enabled", False)
|
| 450 |
+
parse_method = watermark_config.get("parse_method", "third_party")
|
| 451 |
+
custom_parse_url = watermark_config.get("custom_parse_url", "")
|
| 452 |
+
custom_parse_token = watermark_config.get("custom_parse_token", "")
|
| 453 |
+
|
| 454 |
+
# Convert empty strings to None
|
| 455 |
+
custom_parse_url = custom_parse_url if custom_parse_url else None
|
| 456 |
+
custom_parse_token = custom_parse_token if custom_parse_token else None
|
| 457 |
+
|
| 458 |
+
if is_first_startup:
|
| 459 |
+
await db.execute("""
|
| 460 |
+
INSERT OR IGNORE INTO watermark_free_config (id, watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token)
|
| 461 |
+
VALUES (1, ?, ?, ?, ?)
|
| 462 |
+
""", (watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token))
|
| 463 |
+
else:
|
| 464 |
+
await db.execute("""
|
| 465 |
+
UPDATE watermark_free_config
|
| 466 |
+
SET watermark_free_enabled = ?, parse_method = ?, custom_parse_url = ?,
|
| 467 |
+
custom_parse_token = ?, updated_at = CURRENT_TIMESTAMP
|
| 468 |
+
WHERE id = 1
|
| 469 |
+
""", (watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token))
|
| 470 |
+
|
| 471 |
+
# Initialize cache config
|
| 472 |
+
cache_config = config_dict.get("cache", {})
|
| 473 |
+
cache_enabled = cache_config.get("enabled", False)
|
| 474 |
+
cache_timeout = cache_config.get("timeout", 600)
|
| 475 |
+
cache_base_url = cache_config.get("base_url", "")
|
| 476 |
+
# Convert empty string to None
|
| 477 |
+
cache_base_url = cache_base_url if cache_base_url else None
|
| 478 |
+
|
| 479 |
+
if is_first_startup:
|
| 480 |
+
await db.execute("""
|
| 481 |
+
INSERT OR IGNORE INTO cache_config (id, cache_enabled, cache_timeout, cache_base_url)
|
| 482 |
+
VALUES (1, ?, ?, ?)
|
| 483 |
+
""", (cache_enabled, cache_timeout, cache_base_url))
|
| 484 |
+
else:
|
| 485 |
+
await db.execute("""
|
| 486 |
+
UPDATE cache_config
|
| 487 |
+
SET cache_enabled = ?, cache_timeout = ?, cache_base_url = ?, updated_at = CURRENT_TIMESTAMP
|
| 488 |
+
WHERE id = 1
|
| 489 |
+
""", (cache_enabled, cache_timeout, cache_base_url))
|
| 490 |
+
|
| 491 |
+
# Initialize generation config
|
| 492 |
+
generation_config = config_dict.get("generation", {})
|
| 493 |
+
image_timeout = generation_config.get("image_timeout", 300)
|
| 494 |
+
video_timeout = generation_config.get("video_timeout", 1500)
|
| 495 |
+
|
| 496 |
+
if is_first_startup:
|
| 497 |
+
await db.execute("""
|
| 498 |
+
INSERT OR IGNORE INTO generation_config (id, image_timeout, video_timeout)
|
| 499 |
+
VALUES (1, ?, ?)
|
| 500 |
+
""", (image_timeout, video_timeout))
|
| 501 |
+
else:
|
| 502 |
+
await db.execute("""
|
| 503 |
+
UPDATE generation_config
|
| 504 |
+
SET image_timeout = ?, video_timeout = ?, updated_at = CURRENT_TIMESTAMP
|
| 505 |
+
WHERE id = 1
|
| 506 |
+
""", (image_timeout, video_timeout))
|
| 507 |
+
|
| 508 |
+
# Initialize token refresh config
|
| 509 |
+
token_refresh_config = config_dict.get("token_refresh", {})
|
| 510 |
+
at_auto_refresh_enabled = token_refresh_config.get("at_auto_refresh_enabled", False)
|
| 511 |
+
|
| 512 |
+
if is_first_startup:
|
| 513 |
+
await db.execute("""
|
| 514 |
+
INSERT OR IGNORE INTO token_refresh_config (id, at_auto_refresh_enabled)
|
| 515 |
+
VALUES (1, ?)
|
| 516 |
+
""", (at_auto_refresh_enabled,))
|
| 517 |
+
else:
|
| 518 |
+
await db.execute("""
|
| 519 |
+
UPDATE token_refresh_config
|
| 520 |
+
SET at_auto_refresh_enabled = ?, updated_at = CURRENT_TIMESTAMP
|
| 521 |
+
WHERE id = 1
|
| 522 |
+
""", (at_auto_refresh_enabled,))
|
| 523 |
+
|
| 524 |
+
await db.commit()
|
| 525 |
+
|
| 526 |
+
# Token operations
|
| 527 |
+
async def add_token(self, token: Token) -> int:
|
| 528 |
+
"""Add a new token"""
|
| 529 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 530 |
+
cursor = await db.execute("""
|
| 531 |
+
INSERT INTO tokens (token, email, username, name, st, rt, remark, expiry_time, is_active,
|
| 532 |
+
plan_type, plan_title, subscription_end, sora2_supported, sora2_invite_code,
|
| 533 |
+
sora2_redeemed_count, sora2_total_count, sora2_remaining_count, sora2_cooldown_until,
|
| 534 |
+
image_enabled, video_enabled)
|
| 535 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 536 |
+
""", (token.token, token.email, "", token.name, token.st, token.rt,
|
| 537 |
+
token.remark, token.expiry_time, token.is_active,
|
| 538 |
+
token.plan_type, token.plan_title, token.subscription_end,
|
| 539 |
+
token.sora2_supported, token.sora2_invite_code,
|
| 540 |
+
token.sora2_redeemed_count, token.sora2_total_count,
|
| 541 |
+
token.sora2_remaining_count, token.sora2_cooldown_until,
|
| 542 |
+
token.image_enabled, token.video_enabled))
|
| 543 |
+
await db.commit()
|
| 544 |
+
token_id = cursor.lastrowid
|
| 545 |
+
|
| 546 |
+
# Create stats entry
|
| 547 |
+
await db.execute("""
|
| 548 |
+
INSERT INTO token_stats (token_id) VALUES (?)
|
| 549 |
+
""", (token_id,))
|
| 550 |
+
await db.commit()
|
| 551 |
+
|
| 552 |
+
return token_id
|
| 553 |
+
|
| 554 |
+
async def get_token(self, token_id: int) -> Optional[Token]:
|
| 555 |
+
"""Get token by ID"""
|
| 556 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 557 |
+
db.row_factory = aiosqlite.Row
|
| 558 |
+
cursor = await db.execute("SELECT * FROM tokens WHERE id = ?", (token_id,))
|
| 559 |
+
row = await cursor.fetchone()
|
| 560 |
+
if row:
|
| 561 |
+
return Token(**dict(row))
|
| 562 |
+
return None
|
| 563 |
+
|
| 564 |
+
async def get_token_by_value(self, token: str) -> Optional[Token]:
|
| 565 |
+
"""Get token by value"""
|
| 566 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 567 |
+
db.row_factory = aiosqlite.Row
|
| 568 |
+
cursor = await db.execute("SELECT * FROM tokens WHERE token = ?", (token,))
|
| 569 |
+
row = await cursor.fetchone()
|
| 570 |
+
if row:
|
| 571 |
+
return Token(**dict(row))
|
| 572 |
+
return None
|
| 573 |
+
|
| 574 |
+
async def get_active_tokens(self) -> List[Token]:
|
| 575 |
+
"""Get all active tokens (enabled, not cooled down, not expired)"""
|
| 576 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 577 |
+
db.row_factory = aiosqlite.Row
|
| 578 |
+
cursor = await db.execute("""
|
| 579 |
+
SELECT * FROM tokens
|
| 580 |
+
WHERE is_active = 1
|
| 581 |
+
AND (cooled_until IS NULL OR cooled_until < CURRENT_TIMESTAMP)
|
| 582 |
+
AND expiry_time > CURRENT_TIMESTAMP
|
| 583 |
+
ORDER BY last_used_at ASC NULLS FIRST
|
| 584 |
+
""")
|
| 585 |
+
rows = await cursor.fetchall()
|
| 586 |
+
return [Token(**dict(row)) for row in rows]
|
| 587 |
+
|
| 588 |
+
async def get_all_tokens(self) -> List[Token]:
|
| 589 |
+
"""Get all tokens"""
|
| 590 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 591 |
+
db.row_factory = aiosqlite.Row
|
| 592 |
+
cursor = await db.execute("SELECT * FROM tokens ORDER BY created_at DESC")
|
| 593 |
+
rows = await cursor.fetchall()
|
| 594 |
+
return [Token(**dict(row)) for row in rows]
|
| 595 |
+
|
| 596 |
+
async def update_token_usage(self, token_id: int):
|
| 597 |
+
"""Update token usage"""
|
| 598 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 599 |
+
await db.execute("""
|
| 600 |
+
UPDATE tokens
|
| 601 |
+
SET last_used_at = CURRENT_TIMESTAMP, use_count = use_count + 1
|
| 602 |
+
WHERE id = ?
|
| 603 |
+
""", (token_id,))
|
| 604 |
+
await db.commit()
|
| 605 |
+
|
| 606 |
+
async def update_token_status(self, token_id: int, is_active: bool):
|
| 607 |
+
"""Update token status"""
|
| 608 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 609 |
+
await db.execute("""
|
| 610 |
+
UPDATE tokens SET is_active = ? WHERE id = ?
|
| 611 |
+
""", (is_active, token_id))
|
| 612 |
+
await db.commit()
|
| 613 |
+
|
| 614 |
+
async def update_token_sora2(self, token_id: int, supported: bool, invite_code: Optional[str] = None,
|
| 615 |
+
redeemed_count: int = 0, total_count: int = 0, remaining_count: int = 0):
|
| 616 |
+
"""Update token Sora2 support info"""
|
| 617 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 618 |
+
await db.execute("""
|
| 619 |
+
UPDATE tokens
|
| 620 |
+
SET sora2_supported = ?, sora2_invite_code = ?, sora2_redeemed_count = ?, sora2_total_count = ?, sora2_remaining_count = ?
|
| 621 |
+
WHERE id = ?
|
| 622 |
+
""", (supported, invite_code, redeemed_count, total_count, remaining_count, token_id))
|
| 623 |
+
await db.commit()
|
| 624 |
+
|
| 625 |
+
async def update_token_sora2_remaining(self, token_id: int, remaining_count: int):
|
| 626 |
+
"""Update token Sora2 remaining count"""
|
| 627 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 628 |
+
await db.execute("""
|
| 629 |
+
UPDATE tokens SET sora2_remaining_count = ? WHERE id = ?
|
| 630 |
+
""", (remaining_count, token_id))
|
| 631 |
+
await db.commit()
|
| 632 |
+
|
| 633 |
+
async def update_token_sora2_cooldown(self, token_id: int, cooldown_until: Optional[datetime]):
|
| 634 |
+
"""Update token Sora2 cooldown time"""
|
| 635 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 636 |
+
await db.execute("""
|
| 637 |
+
UPDATE tokens SET sora2_cooldown_until = ? WHERE id = ?
|
| 638 |
+
""", (cooldown_until, token_id))
|
| 639 |
+
await db.commit()
|
| 640 |
+
|
| 641 |
+
async def update_token_cooldown(self, token_id: int, cooled_until: datetime):
|
| 642 |
+
"""Update token cooldown"""
|
| 643 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 644 |
+
await db.execute("""
|
| 645 |
+
UPDATE tokens SET cooled_until = ? WHERE id = ?
|
| 646 |
+
""", (cooled_until, token_id))
|
| 647 |
+
await db.commit()
|
| 648 |
+
|
| 649 |
+
async def delete_token(self, token_id: int):
|
| 650 |
+
"""Delete token"""
|
| 651 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 652 |
+
await db.execute("DELETE FROM token_stats WHERE token_id = ?", (token_id,))
|
| 653 |
+
await db.execute("DELETE FROM tokens WHERE id = ?", (token_id,))
|
| 654 |
+
await db.commit()
|
| 655 |
+
|
| 656 |
+
async def update_token(self, token_id: int,
|
| 657 |
+
token: Optional[str] = None,
|
| 658 |
+
st: Optional[str] = None,
|
| 659 |
+
rt: Optional[str] = None,
|
| 660 |
+
remark: Optional[str] = None,
|
| 661 |
+
expiry_time: Optional[datetime] = None,
|
| 662 |
+
plan_type: Optional[str] = None,
|
| 663 |
+
plan_title: Optional[str] = None,
|
| 664 |
+
subscription_end: Optional[datetime] = None,
|
| 665 |
+
image_enabled: Optional[bool] = None,
|
| 666 |
+
video_enabled: Optional[bool] = None):
|
| 667 |
+
"""Update token (AT, ST, RT, remark, expiry_time, subscription info, image_enabled, video_enabled)"""
|
| 668 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 669 |
+
# Build dynamic update query
|
| 670 |
+
updates = []
|
| 671 |
+
params = []
|
| 672 |
+
|
| 673 |
+
if token is not None:
|
| 674 |
+
updates.append("token = ?")
|
| 675 |
+
params.append(token)
|
| 676 |
+
|
| 677 |
+
if st is not None:
|
| 678 |
+
updates.append("st = ?")
|
| 679 |
+
params.append(st)
|
| 680 |
+
|
| 681 |
+
if rt is not None:
|
| 682 |
+
updates.append("rt = ?")
|
| 683 |
+
params.append(rt)
|
| 684 |
+
|
| 685 |
+
if remark is not None:
|
| 686 |
+
updates.append("remark = ?")
|
| 687 |
+
params.append(remark)
|
| 688 |
+
|
| 689 |
+
if expiry_time is not None:
|
| 690 |
+
updates.append("expiry_time = ?")
|
| 691 |
+
params.append(expiry_time)
|
| 692 |
+
|
| 693 |
+
if plan_type is not None:
|
| 694 |
+
updates.append("plan_type = ?")
|
| 695 |
+
params.append(plan_type)
|
| 696 |
+
|
| 697 |
+
if plan_title is not None:
|
| 698 |
+
updates.append("plan_title = ?")
|
| 699 |
+
params.append(plan_title)
|
| 700 |
+
|
| 701 |
+
if subscription_end is not None:
|
| 702 |
+
updates.append("subscription_end = ?")
|
| 703 |
+
params.append(subscription_end)
|
| 704 |
+
|
| 705 |
+
if image_enabled is not None:
|
| 706 |
+
updates.append("image_enabled = ?")
|
| 707 |
+
params.append(image_enabled)
|
| 708 |
+
|
| 709 |
+
if video_enabled is not None:
|
| 710 |
+
updates.append("video_enabled = ?")
|
| 711 |
+
params.append(video_enabled)
|
| 712 |
+
|
| 713 |
+
if updates:
|
| 714 |
+
params.append(token_id)
|
| 715 |
+
query = f"UPDATE tokens SET {', '.join(updates)} WHERE id = ?"
|
| 716 |
+
await db.execute(query, params)
|
| 717 |
+
await db.commit()
|
| 718 |
+
|
| 719 |
+
# Token stats operations
|
| 720 |
+
async def get_token_stats(self, token_id: int) -> Optional[TokenStats]:
|
| 721 |
+
"""Get token statistics"""
|
| 722 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 723 |
+
db.row_factory = aiosqlite.Row
|
| 724 |
+
cursor = await db.execute("SELECT * FROM token_stats WHERE token_id = ?", (token_id,))
|
| 725 |
+
row = await cursor.fetchone()
|
| 726 |
+
if row:
|
| 727 |
+
return TokenStats(**dict(row))
|
| 728 |
+
return None
|
| 729 |
+
|
| 730 |
+
async def increment_image_count(self, token_id: int):
|
| 731 |
+
"""Increment image generation count"""
|
| 732 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 733 |
+
await db.execute("""
|
| 734 |
+
UPDATE token_stats SET image_count = image_count + 1 WHERE token_id = ?
|
| 735 |
+
""", (token_id,))
|
| 736 |
+
await db.commit()
|
| 737 |
+
|
| 738 |
+
async def increment_video_count(self, token_id: int):
|
| 739 |
+
"""Increment video generation count"""
|
| 740 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 741 |
+
await db.execute("""
|
| 742 |
+
UPDATE token_stats SET video_count = video_count + 1 WHERE token_id = ?
|
| 743 |
+
""", (token_id,))
|
| 744 |
+
await db.commit()
|
| 745 |
+
|
| 746 |
+
async def increment_error_count(self, token_id: int):
|
| 747 |
+
"""Increment error count"""
|
| 748 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 749 |
+
await db.execute("""
|
| 750 |
+
UPDATE token_stats
|
| 751 |
+
SET error_count = error_count + 1, last_error_at = CURRENT_TIMESTAMP
|
| 752 |
+
WHERE token_id = ?
|
| 753 |
+
""", (token_id,))
|
| 754 |
+
await db.commit()
|
| 755 |
+
|
| 756 |
+
async def reset_error_count(self, token_id: int):
|
| 757 |
+
"""Reset error count"""
|
| 758 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 759 |
+
await db.execute("""
|
| 760 |
+
UPDATE token_stats SET error_count = 0 WHERE token_id = ?
|
| 761 |
+
""", (token_id,))
|
| 762 |
+
await db.commit()
|
| 763 |
+
|
| 764 |
+
# Task operations
|
| 765 |
+
async def create_task(self, task: Task) -> int:
|
| 766 |
+
"""Create a new task"""
|
| 767 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 768 |
+
cursor = await db.execute("""
|
| 769 |
+
INSERT INTO tasks (task_id, token_id, model, prompt, status, progress)
|
| 770 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 771 |
+
""", (task.task_id, task.token_id, task.model, task.prompt, task.status, task.progress))
|
| 772 |
+
await db.commit()
|
| 773 |
+
return cursor.lastrowid
|
| 774 |
+
|
| 775 |
+
async def update_task(self, task_id: str, status: str, progress: float,
|
| 776 |
+
result_urls: Optional[str] = None, error_message: Optional[str] = None):
|
| 777 |
+
"""Update task status"""
|
| 778 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 779 |
+
completed_at = datetime.now() if status in ["completed", "failed"] else None
|
| 780 |
+
await db.execute("""
|
| 781 |
+
UPDATE tasks
|
| 782 |
+
SET status = ?, progress = ?, result_urls = ?, error_message = ?, completed_at = ?
|
| 783 |
+
WHERE task_id = ?
|
| 784 |
+
""", (status, progress, result_urls, error_message, completed_at, task_id))
|
| 785 |
+
await db.commit()
|
| 786 |
+
|
| 787 |
+
async def get_task(self, task_id: str) -> Optional[Task]:
|
| 788 |
+
"""Get task by ID"""
|
| 789 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 790 |
+
db.row_factory = aiosqlite.Row
|
| 791 |
+
cursor = await db.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,))
|
| 792 |
+
row = await cursor.fetchone()
|
| 793 |
+
if row:
|
| 794 |
+
return Task(**dict(row))
|
| 795 |
+
return None
|
| 796 |
+
|
| 797 |
+
# Request log operations
|
| 798 |
+
async def log_request(self, log: RequestLog):
|
| 799 |
+
"""Log a request"""
|
| 800 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 801 |
+
await db.execute("""
|
| 802 |
+
INSERT INTO request_logs (token_id, operation, request_body, response_body, status_code, duration)
|
| 803 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 804 |
+
""", (log.token_id, log.operation, log.request_body, log.response_body,
|
| 805 |
+
log.status_code, log.duration))
|
| 806 |
+
await db.commit()
|
| 807 |
+
|
| 808 |
+
async def get_recent_logs(self, limit: int = 100) -> List[dict]:
|
| 809 |
+
"""Get recent logs with token email"""
|
| 810 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 811 |
+
db.row_factory = aiosqlite.Row
|
| 812 |
+
cursor = await db.execute("""
|
| 813 |
+
SELECT
|
| 814 |
+
rl.id,
|
| 815 |
+
rl.token_id,
|
| 816 |
+
rl.operation,
|
| 817 |
+
rl.request_body,
|
| 818 |
+
rl.response_body,
|
| 819 |
+
rl.status_code,
|
| 820 |
+
rl.duration,
|
| 821 |
+
rl.created_at,
|
| 822 |
+
t.email as token_email
|
| 823 |
+
FROM request_logs rl
|
| 824 |
+
LEFT JOIN tokens t ON rl.token_id = t.id
|
| 825 |
+
ORDER BY rl.created_at DESC
|
| 826 |
+
LIMIT ?
|
| 827 |
+
""", (limit,))
|
| 828 |
+
rows = await cursor.fetchall()
|
| 829 |
+
return [dict(row) for row in rows]
|
| 830 |
+
|
| 831 |
+
# Admin config operations
|
| 832 |
+
async def get_admin_config(self) -> AdminConfig:
|
| 833 |
+
"""Get admin configuration"""
|
| 834 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 835 |
+
db.row_factory = aiosqlite.Row
|
| 836 |
+
cursor = await db.execute("SELECT * FROM admin_config WHERE id = 1")
|
| 837 |
+
row = await cursor.fetchone()
|
| 838 |
+
if row:
|
| 839 |
+
return AdminConfig(**dict(row))
|
| 840 |
+
# If no row exists, return a default config with placeholder values
|
| 841 |
+
# This should not happen in normal operation as _ensure_config_rows should create it
|
| 842 |
+
return AdminConfig(admin_username="admin", admin_password="admin")
|
| 843 |
+
|
| 844 |
+
async def update_admin_config(self, config: AdminConfig):
|
| 845 |
+
"""Update admin configuration"""
|
| 846 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 847 |
+
await db.execute("""
|
| 848 |
+
UPDATE admin_config
|
| 849 |
+
SET admin_username = ?, admin_password = ?, error_ban_threshold = ?, updated_at = CURRENT_TIMESTAMP
|
| 850 |
+
WHERE id = 1
|
| 851 |
+
""", (config.admin_username, config.admin_password, config.error_ban_threshold))
|
| 852 |
+
await db.commit()
|
| 853 |
+
|
| 854 |
+
# Proxy config operations
|
| 855 |
+
async def get_proxy_config(self) -> ProxyConfig:
|
| 856 |
+
"""Get proxy configuration"""
|
| 857 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 858 |
+
db.row_factory = aiosqlite.Row
|
| 859 |
+
cursor = await db.execute("SELECT * FROM proxy_config WHERE id = 1")
|
| 860 |
+
row = await cursor.fetchone()
|
| 861 |
+
if row:
|
| 862 |
+
return ProxyConfig(**dict(row))
|
| 863 |
+
# If no row exists, return a default config
|
| 864 |
+
# This should not happen in normal operation as _ensure_config_rows should create it
|
| 865 |
+
return ProxyConfig(proxy_enabled=False)
|
| 866 |
+
|
| 867 |
+
async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str]):
|
| 868 |
+
"""Update proxy configuration"""
|
| 869 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 870 |
+
await db.execute("""
|
| 871 |
+
UPDATE proxy_config
|
| 872 |
+
SET proxy_enabled = ?, proxy_url = ?, updated_at = CURRENT_TIMESTAMP
|
| 873 |
+
WHERE id = 1
|
| 874 |
+
""", (enabled, proxy_url))
|
| 875 |
+
await db.commit()
|
| 876 |
+
|
| 877 |
+
# Watermark-free config operations
|
| 878 |
+
async def get_watermark_free_config(self) -> WatermarkFreeConfig:
|
| 879 |
+
"""Get watermark-free configuration"""
|
| 880 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 881 |
+
db.row_factory = aiosqlite.Row
|
| 882 |
+
cursor = await db.execute("SELECT * FROM watermark_free_config WHERE id = 1")
|
| 883 |
+
row = await cursor.fetchone()
|
| 884 |
+
if row:
|
| 885 |
+
return WatermarkFreeConfig(**dict(row))
|
| 886 |
+
# If no row exists, return a default config
|
| 887 |
+
# This should not happen in normal operation as _ensure_config_rows should create it
|
| 888 |
+
return WatermarkFreeConfig(watermark_free_enabled=False, parse_method="third_party")
|
| 889 |
+
|
| 890 |
+
async def update_watermark_free_config(self, enabled: bool, parse_method: str = None,
|
| 891 |
+
custom_parse_url: str = None, custom_parse_token: str = None):
|
| 892 |
+
"""Update watermark-free configuration"""
|
| 893 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 894 |
+
if parse_method is None and custom_parse_url is None and custom_parse_token is None:
|
| 895 |
+
# Only update enabled status
|
| 896 |
+
await db.execute("""
|
| 897 |
+
UPDATE watermark_free_config
|
| 898 |
+
SET watermark_free_enabled = ?, updated_at = CURRENT_TIMESTAMP
|
| 899 |
+
WHERE id = 1
|
| 900 |
+
""", (enabled,))
|
| 901 |
+
else:
|
| 902 |
+
# Update all fields
|
| 903 |
+
await db.execute("""
|
| 904 |
+
UPDATE watermark_free_config
|
| 905 |
+
SET watermark_free_enabled = ?, parse_method = ?, custom_parse_url = ?,
|
| 906 |
+
custom_parse_token = ?, updated_at = CURRENT_TIMESTAMP
|
| 907 |
+
WHERE id = 1
|
| 908 |
+
""", (enabled, parse_method or "third_party", custom_parse_url, custom_parse_token))
|
| 909 |
+
await db.commit()
|
| 910 |
+
|
| 911 |
+
# Cache config operations
|
| 912 |
+
async def get_cache_config(self) -> CacheConfig:
|
| 913 |
+
"""Get cache configuration"""
|
| 914 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 915 |
+
db.row_factory = aiosqlite.Row
|
| 916 |
+
cursor = await db.execute("SELECT * FROM cache_config WHERE id = 1")
|
| 917 |
+
row = await cursor.fetchone()
|
| 918 |
+
if row:
|
| 919 |
+
return CacheConfig(**dict(row))
|
| 920 |
+
# If no row exists, return a default config
|
| 921 |
+
# This should not happen in normal operation as _ensure_config_rows should create it
|
| 922 |
+
return CacheConfig(cache_enabled=False, cache_timeout=600)
|
| 923 |
+
|
| 924 |
+
async def update_cache_config(self, enabled: bool = None, timeout: int = None, base_url: Optional[str] = None):
|
| 925 |
+
"""Update cache configuration"""
|
| 926 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 927 |
+
# Get current config first
|
| 928 |
+
db.row_factory = aiosqlite.Row
|
| 929 |
+
cursor = await db.execute("SELECT * FROM cache_config WHERE id = 1")
|
| 930 |
+
row = await cursor.fetchone()
|
| 931 |
+
|
| 932 |
+
if row:
|
| 933 |
+
current = dict(row)
|
| 934 |
+
# Update only provided fields
|
| 935 |
+
new_enabled = enabled if enabled is not None else current.get("cache_enabled", False)
|
| 936 |
+
new_timeout = timeout if timeout is not None else current.get("cache_timeout", 600)
|
| 937 |
+
new_base_url = base_url if base_url is not None else current.get("cache_base_url")
|
| 938 |
+
else:
|
| 939 |
+
new_enabled = enabled if enabled is not None else False
|
| 940 |
+
new_timeout = timeout if timeout is not None else 600
|
| 941 |
+
new_base_url = base_url
|
| 942 |
+
|
| 943 |
+
# Convert empty string to None
|
| 944 |
+
new_base_url = new_base_url if new_base_url else None
|
| 945 |
+
|
| 946 |
+
await db.execute("""
|
| 947 |
+
UPDATE cache_config
|
| 948 |
+
SET cache_enabled = ?, cache_timeout = ?, cache_base_url = ?, updated_at = CURRENT_TIMESTAMP
|
| 949 |
+
WHERE id = 1
|
| 950 |
+
""", (new_enabled, new_timeout, new_base_url))
|
| 951 |
+
await db.commit()
|
| 952 |
+
|
| 953 |
+
# Generation config operations
|
| 954 |
+
async def get_generation_config(self) -> GenerationConfig:
|
| 955 |
+
"""Get generation configuration"""
|
| 956 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 957 |
+
db.row_factory = aiosqlite.Row
|
| 958 |
+
cursor = await db.execute("SELECT * FROM generation_config WHERE id = 1")
|
| 959 |
+
row = await cursor.fetchone()
|
| 960 |
+
if row:
|
| 961 |
+
return GenerationConfig(**dict(row))
|
| 962 |
+
# If no row exists, return a default config
|
| 963 |
+
# This should not happen in normal operation as _ensure_config_rows should create it
|
| 964 |
+
return GenerationConfig(image_timeout=300, video_timeout=1500)
|
| 965 |
+
|
| 966 |
+
async def update_generation_config(self, image_timeout: int = None, video_timeout: int = None):
|
| 967 |
+
"""Update generation configuration"""
|
| 968 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 969 |
+
# Get current config first
|
| 970 |
+
db.row_factory = aiosqlite.Row
|
| 971 |
+
cursor = await db.execute("SELECT * FROM generation_config WHERE id = 1")
|
| 972 |
+
row = await cursor.fetchone()
|
| 973 |
+
|
| 974 |
+
if row:
|
| 975 |
+
current = dict(row)
|
| 976 |
+
# Update only provided fields
|
| 977 |
+
new_image_timeout = image_timeout if image_timeout is not None else current.get("image_timeout", 300)
|
| 978 |
+
new_video_timeout = video_timeout if video_timeout is not None else current.get("video_timeout", 1500)
|
| 979 |
+
else:
|
| 980 |
+
new_image_timeout = image_timeout if image_timeout is not None else 300
|
| 981 |
+
new_video_timeout = video_timeout if video_timeout is not None else 1500
|
| 982 |
+
|
| 983 |
+
await db.execute("""
|
| 984 |
+
UPDATE generation_config
|
| 985 |
+
SET image_timeout = ?, video_timeout = ?, updated_at = CURRENT_TIMESTAMP
|
| 986 |
+
WHERE id = 1
|
| 987 |
+
""", (new_image_timeout, new_video_timeout))
|
| 988 |
+
await db.commit()
|
| 989 |
+
|
| 990 |
+
# Token refresh config operations
|
| 991 |
+
async def get_token_refresh_config(self) -> TokenRefreshConfig:
|
| 992 |
+
"""Get token refresh configuration"""
|
| 993 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 994 |
+
db.row_factory = aiosqlite.Row
|
| 995 |
+
cursor = await db.execute("SELECT * FROM token_refresh_config WHERE id = 1")
|
| 996 |
+
row = await cursor.fetchone()
|
| 997 |
+
if row:
|
| 998 |
+
return TokenRefreshConfig(**dict(row))
|
| 999 |
+
# If no row exists, return a default config
|
| 1000 |
+
# This should not happen in normal operation as _ensure_config_rows should create it
|
| 1001 |
+
return TokenRefreshConfig(at_auto_refresh_enabled=False)
|
| 1002 |
+
|
| 1003 |
+
async def update_token_refresh_config(self, at_auto_refresh_enabled: bool):
|
| 1004 |
+
"""Update token refresh configuration"""
|
| 1005 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 1006 |
+
await db.execute("""
|
| 1007 |
+
UPDATE token_refresh_config
|
| 1008 |
+
SET at_auto_refresh_enabled = ?, updated_at = CURRENT_TIMESTAMP
|
| 1009 |
+
WHERE id = 1
|
| 1010 |
+
""", (at_auto_refresh_enabled,))
|
| 1011 |
+
await db.commit()
|
| 1012 |
+
|
src/core/logger.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Debug logger module for detailed API request/response logging"""
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Dict, Any, Optional
|
| 7 |
+
from .config import config
|
| 8 |
+
|
| 9 |
+
class DebugLogger:
|
| 10 |
+
"""Debug logger for API requests and responses"""
|
| 11 |
+
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self.log_file = Path("logs.txt")
|
| 14 |
+
self._setup_logger()
|
| 15 |
+
|
| 16 |
+
def _setup_logger(self):
|
| 17 |
+
"""Setup file logger"""
|
| 18 |
+
# Create logger
|
| 19 |
+
self.logger = logging.getLogger("debug_logger")
|
| 20 |
+
self.logger.setLevel(logging.DEBUG)
|
| 21 |
+
|
| 22 |
+
# Remove existing handlers
|
| 23 |
+
self.logger.handlers.clear()
|
| 24 |
+
|
| 25 |
+
# Create file handler
|
| 26 |
+
file_handler = logging.FileHandler(
|
| 27 |
+
self.log_file,
|
| 28 |
+
mode='a',
|
| 29 |
+
encoding='utf-8'
|
| 30 |
+
)
|
| 31 |
+
file_handler.setLevel(logging.DEBUG)
|
| 32 |
+
|
| 33 |
+
# Create formatter
|
| 34 |
+
formatter = logging.Formatter(
|
| 35 |
+
'%(message)s',
|
| 36 |
+
datefmt='%Y-%m-%d %H:%M:%S'
|
| 37 |
+
)
|
| 38 |
+
file_handler.setFormatter(formatter)
|
| 39 |
+
|
| 40 |
+
# Add handler
|
| 41 |
+
self.logger.addHandler(file_handler)
|
| 42 |
+
|
| 43 |
+
# Prevent propagation to root logger
|
| 44 |
+
self.logger.propagate = False
|
| 45 |
+
|
| 46 |
+
def _mask_token(self, token: str) -> str:
|
| 47 |
+
"""Mask token for logging (show first 6 and last 6 characters)"""
|
| 48 |
+
if not config.debug_mask_token or len(token) <= 12:
|
| 49 |
+
return token
|
| 50 |
+
return f"{token[:6]}...{token[-6:]}"
|
| 51 |
+
|
| 52 |
+
def _format_timestamp(self) -> str:
|
| 53 |
+
"""Format current timestamp"""
|
| 54 |
+
return datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
|
| 55 |
+
|
| 56 |
+
def _write_separator(self, char: str = "=", length: int = 100):
|
| 57 |
+
"""Write separator line"""
|
| 58 |
+
self.logger.info(char * length)
|
| 59 |
+
|
| 60 |
+
def log_request(
|
| 61 |
+
self,
|
| 62 |
+
method: str,
|
| 63 |
+
url: str,
|
| 64 |
+
headers: Dict[str, str],
|
| 65 |
+
body: Optional[Any] = None,
|
| 66 |
+
files: Optional[Dict] = None,
|
| 67 |
+
proxy: Optional[str] = None
|
| 68 |
+
):
|
| 69 |
+
"""Log API request details to log.txt"""
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
self._write_separator()
|
| 73 |
+
self.logger.info(f"🔵 [REQUEST] {self._format_timestamp()}")
|
| 74 |
+
self._write_separator("-")
|
| 75 |
+
|
| 76 |
+
# Basic info
|
| 77 |
+
self.logger.info(f"Method: {method}")
|
| 78 |
+
self.logger.info(f"URL: {url}")
|
| 79 |
+
|
| 80 |
+
# Headers
|
| 81 |
+
self.logger.info("\n📋 Headers:")
|
| 82 |
+
masked_headers = dict(headers)
|
| 83 |
+
if "Authorization" in masked_headers:
|
| 84 |
+
auth_value = masked_headers["Authorization"]
|
| 85 |
+
if auth_value.startswith("Bearer "):
|
| 86 |
+
token = auth_value[7:]
|
| 87 |
+
masked_headers["Authorization"] = f"Bearer {self._mask_token(token)}"
|
| 88 |
+
|
| 89 |
+
for key, value in masked_headers.items():
|
| 90 |
+
self.logger.info(f" {key}: {value}")
|
| 91 |
+
|
| 92 |
+
# Body
|
| 93 |
+
if body is not None:
|
| 94 |
+
self.logger.info("\n📦 Request Body:")
|
| 95 |
+
if isinstance(body, (dict, list)):
|
| 96 |
+
body_str = json.dumps(body, indent=2, ensure_ascii=False)
|
| 97 |
+
self.logger.info(body_str)
|
| 98 |
+
else:
|
| 99 |
+
self.logger.info(str(body))
|
| 100 |
+
|
| 101 |
+
# Files
|
| 102 |
+
if files:
|
| 103 |
+
self.logger.info("\n📎 Files:")
|
| 104 |
+
try:
|
| 105 |
+
# Handle both dict and CurlMime objects
|
| 106 |
+
if hasattr(files, 'keys') and callable(getattr(files, 'keys', None)):
|
| 107 |
+
for key in files.keys():
|
| 108 |
+
self.logger.info(f" {key}: <file data>")
|
| 109 |
+
else:
|
| 110 |
+
# CurlMime or other non-dict objects
|
| 111 |
+
self.logger.info(" <multipart form data>")
|
| 112 |
+
except (AttributeError, TypeError):
|
| 113 |
+
# Fallback for objects that don't support iteration
|
| 114 |
+
self.logger.info(" <binary file data>")
|
| 115 |
+
|
| 116 |
+
# Proxy
|
| 117 |
+
if proxy:
|
| 118 |
+
self.logger.info(f"\n🌐 Proxy: {proxy}")
|
| 119 |
+
|
| 120 |
+
self._write_separator()
|
| 121 |
+
self.logger.info("") # Empty line
|
| 122 |
+
|
| 123 |
+
except Exception as e:
|
| 124 |
+
self.logger.error(f"Error logging request: {e}")
|
| 125 |
+
|
| 126 |
+
def log_response(
|
| 127 |
+
self,
|
| 128 |
+
status_code: int,
|
| 129 |
+
headers: Dict[str, str],
|
| 130 |
+
body: Any,
|
| 131 |
+
duration_ms: Optional[float] = None
|
| 132 |
+
):
|
| 133 |
+
"""Log API response details to log.txt"""
|
| 134 |
+
|
| 135 |
+
try:
|
| 136 |
+
self._write_separator()
|
| 137 |
+
self.logger.info(f"🟢 [RESPONSE] {self._format_timestamp()}")
|
| 138 |
+
self._write_separator("-")
|
| 139 |
+
|
| 140 |
+
# Status
|
| 141 |
+
status_emoji = "✅" if 200 <= status_code < 300 else "❌"
|
| 142 |
+
self.logger.info(f"Status: {status_code} {status_emoji}")
|
| 143 |
+
|
| 144 |
+
# Duration
|
| 145 |
+
if duration_ms is not None:
|
| 146 |
+
self.logger.info(f"Duration: {duration_ms:.2f}ms")
|
| 147 |
+
|
| 148 |
+
# Headers
|
| 149 |
+
self.logger.info("\n📋 Response Headers:")
|
| 150 |
+
for key, value in headers.items():
|
| 151 |
+
self.logger.info(f" {key}: {value}")
|
| 152 |
+
|
| 153 |
+
# Body
|
| 154 |
+
self.logger.info("\n📦 Response Body:")
|
| 155 |
+
if isinstance(body, (dict, list)):
|
| 156 |
+
body_str = json.dumps(body, indent=2, ensure_ascii=False)
|
| 157 |
+
self.logger.info(body_str)
|
| 158 |
+
elif isinstance(body, str):
|
| 159 |
+
# Try to parse as JSON
|
| 160 |
+
try:
|
| 161 |
+
parsed = json.loads(body)
|
| 162 |
+
body_str = json.dumps(parsed, indent=2, ensure_ascii=False)
|
| 163 |
+
self.logger.info(body_str)
|
| 164 |
+
except:
|
| 165 |
+
# Not JSON, log as text (limit length)
|
| 166 |
+
if len(body) > 2000:
|
| 167 |
+
self.logger.info(f"{body[:2000]}... (truncated)")
|
| 168 |
+
else:
|
| 169 |
+
self.logger.info(body)
|
| 170 |
+
else:
|
| 171 |
+
self.logger.info(str(body))
|
| 172 |
+
|
| 173 |
+
self._write_separator()
|
| 174 |
+
self.logger.info("") # Empty line
|
| 175 |
+
|
| 176 |
+
except Exception as e:
|
| 177 |
+
self.logger.error(f"Error logging response: {e}")
|
| 178 |
+
|
| 179 |
+
def log_error(
|
| 180 |
+
self,
|
| 181 |
+
error_message: str,
|
| 182 |
+
status_code: Optional[int] = None,
|
| 183 |
+
response_text: Optional[str] = None
|
| 184 |
+
):
|
| 185 |
+
"""Log API error details to log.txt"""
|
| 186 |
+
|
| 187 |
+
try:
|
| 188 |
+
self._write_separator()
|
| 189 |
+
self.logger.info(f"🔴 [ERROR] {self._format_timestamp()}")
|
| 190 |
+
self._write_separator("-")
|
| 191 |
+
|
| 192 |
+
if status_code:
|
| 193 |
+
self.logger.info(f"Status Code: {status_code}")
|
| 194 |
+
|
| 195 |
+
self.logger.info(f"Error Message: {error_message}")
|
| 196 |
+
|
| 197 |
+
if response_text:
|
| 198 |
+
self.logger.info("\n📦 Error Response:")
|
| 199 |
+
# Try to parse as JSON
|
| 200 |
+
try:
|
| 201 |
+
parsed = json.loads(response_text)
|
| 202 |
+
body_str = json.dumps(parsed, indent=2, ensure_ascii=False)
|
| 203 |
+
self.logger.info(body_str)
|
| 204 |
+
except:
|
| 205 |
+
# Not JSON, log as text
|
| 206 |
+
if len(response_text) > 2000:
|
| 207 |
+
self.logger.info(f"{response_text[:2000]}... (truncated)")
|
| 208 |
+
else:
|
| 209 |
+
self.logger.info(response_text)
|
| 210 |
+
|
| 211 |
+
self._write_separator()
|
| 212 |
+
self.logger.info("") # Empty line
|
| 213 |
+
|
| 214 |
+
except Exception as e:
|
| 215 |
+
self.logger.error(f"Error logging error: {e}")
|
| 216 |
+
|
| 217 |
+
def log_info(self, message: str):
|
| 218 |
+
"""Log general info message to log.txt"""
|
| 219 |
+
try:
|
| 220 |
+
self.logger.info(f"ℹ️ [{self._format_timestamp()}] {message}")
|
| 221 |
+
except Exception as e:
|
| 222 |
+
self.logger.error(f"Error logging info: {e}")
|
| 223 |
+
|
| 224 |
+
# Global debug logger instance
|
| 225 |
+
debug_logger = DebugLogger()
|
| 226 |
+
|
src/core/models.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data models"""
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional, List, Union
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
|
| 6 |
+
class Token(BaseModel):
|
| 7 |
+
"""Token model"""
|
| 8 |
+
id: Optional[int] = None
|
| 9 |
+
token: str
|
| 10 |
+
email: str
|
| 11 |
+
name: Optional[str] = ""
|
| 12 |
+
st: Optional[str] = None
|
| 13 |
+
rt: Optional[str] = None
|
| 14 |
+
remark: Optional[str] = None
|
| 15 |
+
expiry_time: Optional[datetime] = None
|
| 16 |
+
is_active: bool = True
|
| 17 |
+
cooled_until: Optional[datetime] = None
|
| 18 |
+
created_at: Optional[datetime] = None
|
| 19 |
+
last_used_at: Optional[datetime] = None
|
| 20 |
+
use_count: int = 0
|
| 21 |
+
# 订阅信息
|
| 22 |
+
plan_type: Optional[str] = None # 账户类型,如 chatgpt_team
|
| 23 |
+
plan_title: Optional[str] = None # 套餐名称,如 ChatGPT Business
|
| 24 |
+
subscription_end: Optional[datetime] = None # 套餐到期时间
|
| 25 |
+
# Sora2 支持信息
|
| 26 |
+
sora2_supported: Optional[bool] = None # 是否支持Sora2
|
| 27 |
+
sora2_invite_code: Optional[str] = None # Sora2邀请码
|
| 28 |
+
sora2_redeemed_count: int = 0 # Sora2已用次数
|
| 29 |
+
sora2_total_count: int = 0 # Sora2总次数
|
| 30 |
+
# Sora2 剩余次数
|
| 31 |
+
sora2_remaining_count: int = 0 # Sora2剩余可用次数
|
| 32 |
+
sora2_cooldown_until: Optional[datetime] = None # Sora2冷却时间
|
| 33 |
+
# 功能开关
|
| 34 |
+
image_enabled: bool = True # 是否启用图片生成
|
| 35 |
+
video_enabled: bool = True # 是否启用视频生成
|
| 36 |
+
|
| 37 |
+
class TokenStats(BaseModel):
|
| 38 |
+
"""Token statistics"""
|
| 39 |
+
id: Optional[int] = None
|
| 40 |
+
token_id: int
|
| 41 |
+
image_count: int = 0
|
| 42 |
+
video_count: int = 0
|
| 43 |
+
error_count: int = 0
|
| 44 |
+
last_error_at: Optional[datetime] = None
|
| 45 |
+
|
| 46 |
+
class Task(BaseModel):
|
| 47 |
+
"""Task model"""
|
| 48 |
+
id: Optional[int] = None
|
| 49 |
+
task_id: str
|
| 50 |
+
token_id: int
|
| 51 |
+
model: str
|
| 52 |
+
prompt: str
|
| 53 |
+
status: str = "processing" # processing/completed/failed
|
| 54 |
+
progress: float = 0.0
|
| 55 |
+
result_urls: Optional[str] = None # JSON array
|
| 56 |
+
error_message: Optional[str] = None
|
| 57 |
+
created_at: Optional[datetime] = None
|
| 58 |
+
completed_at: Optional[datetime] = None
|
| 59 |
+
|
| 60 |
+
class RequestLog(BaseModel):
|
| 61 |
+
"""Request log model"""
|
| 62 |
+
id: Optional[int] = None
|
| 63 |
+
token_id: Optional[int] = None
|
| 64 |
+
operation: str
|
| 65 |
+
request_body: Optional[str] = None
|
| 66 |
+
response_body: Optional[str] = None
|
| 67 |
+
status_code: int
|
| 68 |
+
duration: float
|
| 69 |
+
created_at: Optional[datetime] = None
|
| 70 |
+
|
| 71 |
+
class AdminConfig(BaseModel):
|
| 72 |
+
"""Admin configuration"""
|
| 73 |
+
id: int = 1
|
| 74 |
+
admin_username: str # Read from database, initialized from setting.toml on first startup
|
| 75 |
+
admin_password: str # Read from database, initialized from setting.toml on first startup
|
| 76 |
+
error_ban_threshold: int = 3
|
| 77 |
+
updated_at: Optional[datetime] = None
|
| 78 |
+
|
| 79 |
+
class ProxyConfig(BaseModel):
|
| 80 |
+
"""Proxy configuration"""
|
| 81 |
+
id: int = 1
|
| 82 |
+
proxy_enabled: bool # Read from database, initialized from setting.toml on first startup
|
| 83 |
+
proxy_url: Optional[str] = None # Read from database, initialized from setting.toml on first startup
|
| 84 |
+
created_at: Optional[datetime] = None
|
| 85 |
+
updated_at: Optional[datetime] = None
|
| 86 |
+
|
| 87 |
+
class WatermarkFreeConfig(BaseModel):
|
| 88 |
+
"""Watermark-free mode configuration"""
|
| 89 |
+
id: int = 1
|
| 90 |
+
watermark_free_enabled: bool # Read from database, initialized from setting.toml on first startup
|
| 91 |
+
parse_method: str # Read from database, initialized from setting.toml on first startup
|
| 92 |
+
custom_parse_url: Optional[str] = None # Read from database, initialized from setting.toml on first startup
|
| 93 |
+
custom_parse_token: Optional[str] = None # Read from database, initialized from setting.toml on first startup
|
| 94 |
+
created_at: Optional[datetime] = None
|
| 95 |
+
updated_at: Optional[datetime] = None
|
| 96 |
+
|
| 97 |
+
class CacheConfig(BaseModel):
|
| 98 |
+
"""Cache configuration"""
|
| 99 |
+
id: int = 1
|
| 100 |
+
cache_enabled: bool # Read from database, initialized from setting.toml on first startup
|
| 101 |
+
cache_timeout: int # Read from database, initialized from setting.toml on first startup
|
| 102 |
+
cache_base_url: Optional[str] = None # Read from database, initialized from setting.toml on first startup
|
| 103 |
+
created_at: Optional[datetime] = None
|
| 104 |
+
updated_at: Optional[datetime] = None
|
| 105 |
+
|
| 106 |
+
class GenerationConfig(BaseModel):
|
| 107 |
+
"""Generation timeout configuration"""
|
| 108 |
+
id: int = 1
|
| 109 |
+
image_timeout: int # Read from database, initialized from setting.toml on first startup
|
| 110 |
+
video_timeout: int # Read from database, initialized from setting.toml on first startup
|
| 111 |
+
created_at: Optional[datetime] = None
|
| 112 |
+
updated_at: Optional[datetime] = None
|
| 113 |
+
|
| 114 |
+
class TokenRefreshConfig(BaseModel):
|
| 115 |
+
"""Token refresh configuration"""
|
| 116 |
+
id: int = 1
|
| 117 |
+
at_auto_refresh_enabled: bool # Read from database, initialized from setting.toml on first startup
|
| 118 |
+
created_at: Optional[datetime] = None
|
| 119 |
+
updated_at: Optional[datetime] = None
|
| 120 |
+
|
| 121 |
+
# API Request/Response models
|
| 122 |
+
class ChatMessage(BaseModel):
|
| 123 |
+
role: str
|
| 124 |
+
content: Union[str, List[dict]] # Support both string and array format (OpenAI multimodal)
|
| 125 |
+
|
| 126 |
+
class ChatCompletionRequest(BaseModel):
|
| 127 |
+
model: str
|
| 128 |
+
messages: List[ChatMessage]
|
| 129 |
+
image: Optional[str] = None
|
| 130 |
+
video: Optional[str] = None # Base64 encoded video file
|
| 131 |
+
remix_target_id: Optional[str] = None # Sora share link video ID for remix
|
| 132 |
+
stream: bool = False
|
| 133 |
+
max_tokens: Optional[int] = None
|
| 134 |
+
|
| 135 |
+
class ChatCompletionChoice(BaseModel):
|
| 136 |
+
index: int
|
| 137 |
+
message: Optional[dict] = None
|
| 138 |
+
delta: Optional[dict] = None
|
| 139 |
+
finish_reason: Optional[str] = None
|
| 140 |
+
|
| 141 |
+
class ChatCompletionResponse(BaseModel):
|
| 142 |
+
id: str
|
| 143 |
+
object: str = "chat.completion"
|
| 144 |
+
created: int
|
| 145 |
+
model: str
|
| 146 |
+
choices: List[ChatCompletionChoice]
|
src/main.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main application entry point"""
|
| 2 |
+
import uvicorn
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.responses import FileResponse, HTMLResponse
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
# Import modules
|
| 10 |
+
from .core.config import config
|
| 11 |
+
from .core.database import Database
|
| 12 |
+
from .services.token_manager import TokenManager
|
| 13 |
+
from .services.proxy_manager import ProxyManager
|
| 14 |
+
from .services.load_balancer import LoadBalancer
|
| 15 |
+
from .services.sora_client import SoraClient
|
| 16 |
+
from .services.generation_handler import GenerationHandler
|
| 17 |
+
from .api import routes as api_routes
|
| 18 |
+
from .api import admin as admin_routes
|
| 19 |
+
|
| 20 |
+
# Initialize FastAPI app
|
| 21 |
+
app = FastAPI(
|
| 22 |
+
title="Sora2API",
|
| 23 |
+
description="OpenAI compatible API for Sora",
|
| 24 |
+
version="1.0.0"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# CORS middleware
|
| 28 |
+
app.add_middleware(
|
| 29 |
+
CORSMiddleware,
|
| 30 |
+
allow_origins=["*"],
|
| 31 |
+
allow_credentials=True,
|
| 32 |
+
allow_methods=["*"],
|
| 33 |
+
allow_headers=["*"],
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Initialize components
|
| 37 |
+
db = Database()
|
| 38 |
+
token_manager = TokenManager(db)
|
| 39 |
+
proxy_manager = ProxyManager(db)
|
| 40 |
+
load_balancer = LoadBalancer(token_manager)
|
| 41 |
+
sora_client = SoraClient(proxy_manager)
|
| 42 |
+
generation_handler = GenerationHandler(sora_client, token_manager, load_balancer, db, proxy_manager)
|
| 43 |
+
|
| 44 |
+
# Set dependencies for route modules
|
| 45 |
+
api_routes.set_generation_handler(generation_handler)
|
| 46 |
+
admin_routes.set_dependencies(token_manager, proxy_manager, db, generation_handler)
|
| 47 |
+
|
| 48 |
+
# Include routers
|
| 49 |
+
app.include_router(api_routes.router)
|
| 50 |
+
app.include_router(admin_routes.router)
|
| 51 |
+
|
| 52 |
+
# Static files
|
| 53 |
+
static_dir = Path(__file__).parent.parent / "static"
|
| 54 |
+
static_dir.mkdir(exist_ok=True)
|
| 55 |
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
| 56 |
+
|
| 57 |
+
# Cache files (tmp directory)
|
| 58 |
+
tmp_dir = Path(__file__).parent.parent / "tmp"
|
| 59 |
+
tmp_dir.mkdir(exist_ok=True)
|
| 60 |
+
app.mount("/tmp", StaticFiles(directory=str(tmp_dir)), name="tmp")
|
| 61 |
+
|
| 62 |
+
# Frontend routes
|
| 63 |
+
@app.get("/", response_class=HTMLResponse)
|
| 64 |
+
async def root():
|
| 65 |
+
"""Redirect to login page"""
|
| 66 |
+
return """
|
| 67 |
+
<!DOCTYPE html>
|
| 68 |
+
<html>
|
| 69 |
+
<head>
|
| 70 |
+
<meta http-equiv="refresh" content="0; url=/login">
|
| 71 |
+
</head>
|
| 72 |
+
<body>
|
| 73 |
+
<p>Redirecting to login...</p>
|
| 74 |
+
</body>
|
| 75 |
+
</html>
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
@app.get("/login", response_class=FileResponse)
|
| 79 |
+
async def login_page():
|
| 80 |
+
"""Serve login page"""
|
| 81 |
+
return FileResponse(str(static_dir / "login.html"))
|
| 82 |
+
|
| 83 |
+
@app.get("/manage", response_class=FileResponse)
|
| 84 |
+
async def manage_page():
|
| 85 |
+
"""Serve management page"""
|
| 86 |
+
return FileResponse(str(static_dir / "manage.html"))
|
| 87 |
+
|
| 88 |
+
@app.on_event("startup")
|
| 89 |
+
async def startup_event():
|
| 90 |
+
"""Initialize database on startup"""
|
| 91 |
+
# Get config from setting.toml
|
| 92 |
+
config_dict = config.get_raw_config()
|
| 93 |
+
|
| 94 |
+
# Check if database exists
|
| 95 |
+
is_first_startup = not db.db_exists()
|
| 96 |
+
|
| 97 |
+
# Initialize database tables
|
| 98 |
+
await db.init_db()
|
| 99 |
+
|
| 100 |
+
# Handle database initialization based on startup type
|
| 101 |
+
if is_first_startup:
|
| 102 |
+
print("🎉 First startup detected. Initializing database and configuration from setting.toml...")
|
| 103 |
+
await db.init_config_from_toml(config_dict, is_first_startup=True)
|
| 104 |
+
print("✓ Database and configuration initialized successfully.")
|
| 105 |
+
else:
|
| 106 |
+
print("🔄 Existing database detected. Checking for missing tables and columns...")
|
| 107 |
+
await db.check_and_migrate_db(config_dict)
|
| 108 |
+
print("✓ Database migration check completed.")
|
| 109 |
+
|
| 110 |
+
# Load admin credentials from database
|
| 111 |
+
admin_config = await db.get_admin_config()
|
| 112 |
+
config.set_admin_username_from_db(admin_config.admin_username)
|
| 113 |
+
config.set_admin_password_from_db(admin_config.admin_password)
|
| 114 |
+
|
| 115 |
+
# Load cache configuration from database
|
| 116 |
+
cache_config = await db.get_cache_config()
|
| 117 |
+
config.set_cache_enabled(cache_config.cache_enabled)
|
| 118 |
+
config.set_cache_timeout(cache_config.cache_timeout)
|
| 119 |
+
config.set_cache_base_url(cache_config.cache_base_url or "")
|
| 120 |
+
|
| 121 |
+
# Load generation configuration from database
|
| 122 |
+
generation_config = await db.get_generation_config()
|
| 123 |
+
config.set_image_timeout(generation_config.image_timeout)
|
| 124 |
+
config.set_video_timeout(generation_config.video_timeout)
|
| 125 |
+
|
| 126 |
+
# Load token refresh configuration from database
|
| 127 |
+
token_refresh_config = await db.get_token_refresh_config()
|
| 128 |
+
config.set_at_auto_refresh_enabled(token_refresh_config.at_auto_refresh_enabled)
|
| 129 |
+
|
| 130 |
+
# Start file cache cleanup task
|
| 131 |
+
await generation_handler.file_cache.start_cleanup_task()
|
| 132 |
+
|
| 133 |
+
@app.on_event("shutdown")
|
| 134 |
+
async def shutdown_event():
|
| 135 |
+
"""Cleanup on shutdown"""
|
| 136 |
+
await generation_handler.file_cache.stop_cleanup_task()
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
uvicorn.run(
|
| 140 |
+
"src.main:app",
|
| 141 |
+
host=config.server_host,
|
| 142 |
+
port=config.server_port,
|
| 143 |
+
reload=False
|
| 144 |
+
)
|
src/services/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Business services module"""
|
| 2 |
+
|
| 3 |
+
from .token_manager import TokenManager
|
| 4 |
+
from .proxy_manager import ProxyManager
|
| 5 |
+
from .load_balancer import LoadBalancer
|
| 6 |
+
from .sora_client import SoraClient
|
| 7 |
+
from .generation_handler import GenerationHandler, MODEL_CONFIG
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"TokenManager",
|
| 11 |
+
"ProxyManager",
|
| 12 |
+
"LoadBalancer",
|
| 13 |
+
"SoraClient",
|
| 14 |
+
"GenerationHandler",
|
| 15 |
+
"MODEL_CONFIG",
|
| 16 |
+
]
|
| 17 |
+
|
src/services/file_cache.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""File caching service"""
|
| 2 |
+
import os
|
| 3 |
+
import asyncio
|
| 4 |
+
import hashlib
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from curl_cffi.requests import AsyncSession
|
| 10 |
+
from ..core.config import config
|
| 11 |
+
from ..core.logger import debug_logger
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class FileCache:
|
| 15 |
+
"""File caching service for images and videos"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, cache_dir: str = "tmp", default_timeout: int = 7200, proxy_manager=None):
|
| 18 |
+
"""
|
| 19 |
+
Initialize file cache
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
cache_dir: Cache directory path
|
| 23 |
+
default_timeout: Default cache timeout in seconds (default: 2 hours)
|
| 24 |
+
proxy_manager: ProxyManager instance for downloading files
|
| 25 |
+
"""
|
| 26 |
+
self.cache_dir = Path(cache_dir)
|
| 27 |
+
self.cache_dir.mkdir(exist_ok=True)
|
| 28 |
+
self.default_timeout = default_timeout
|
| 29 |
+
self.proxy_manager = proxy_manager
|
| 30 |
+
self._cleanup_task = None
|
| 31 |
+
|
| 32 |
+
async def start_cleanup_task(self):
|
| 33 |
+
"""Start background cleanup task"""
|
| 34 |
+
if self._cleanup_task is None:
|
| 35 |
+
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
| 36 |
+
|
| 37 |
+
async def stop_cleanup_task(self):
|
| 38 |
+
"""Stop background cleanup task"""
|
| 39 |
+
if self._cleanup_task:
|
| 40 |
+
self._cleanup_task.cancel()
|
| 41 |
+
try:
|
| 42 |
+
await self._cleanup_task
|
| 43 |
+
except asyncio.CancelledError:
|
| 44 |
+
pass
|
| 45 |
+
self._cleanup_task = None
|
| 46 |
+
|
| 47 |
+
async def _cleanup_loop(self):
|
| 48 |
+
"""Background task to clean up expired files"""
|
| 49 |
+
while True:
|
| 50 |
+
try:
|
| 51 |
+
await asyncio.sleep(300) # Check every 5 minutes
|
| 52 |
+
await self._cleanup_expired_files()
|
| 53 |
+
except asyncio.CancelledError:
|
| 54 |
+
break
|
| 55 |
+
except Exception as e:
|
| 56 |
+
debug_logger.log_error(
|
| 57 |
+
error_message=f"Cleanup task error: {str(e)}",
|
| 58 |
+
status_code=0,
|
| 59 |
+
response_text=""
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
async def _cleanup_expired_files(self):
|
| 63 |
+
"""Remove expired cache files"""
|
| 64 |
+
try:
|
| 65 |
+
current_time = time.time()
|
| 66 |
+
removed_count = 0
|
| 67 |
+
|
| 68 |
+
for file_path in self.cache_dir.iterdir():
|
| 69 |
+
if file_path.is_file():
|
| 70 |
+
# Check file age
|
| 71 |
+
file_age = current_time - file_path.stat().st_mtime
|
| 72 |
+
if file_age > self.default_timeout:
|
| 73 |
+
try:
|
| 74 |
+
file_path.unlink()
|
| 75 |
+
removed_count += 1
|
| 76 |
+
debug_logger.log_info(f"Removed expired cache file: {file_path.name}")
|
| 77 |
+
except Exception as e:
|
| 78 |
+
debug_logger.log_error(
|
| 79 |
+
error_message=f"Failed to remove file {file_path.name}: {str(e)}",
|
| 80 |
+
status_code=0,
|
| 81 |
+
response_text=""
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
if removed_count > 0:
|
| 85 |
+
debug_logger.log_info(f"Cleanup completed: removed {removed_count} expired files")
|
| 86 |
+
|
| 87 |
+
except Exception as e:
|
| 88 |
+
debug_logger.log_error(
|
| 89 |
+
error_message=f"Cleanup error: {str(e)}",
|
| 90 |
+
status_code=0,
|
| 91 |
+
response_text=""
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def _generate_cache_filename(self, url: str, media_type: str) -> str:
|
| 95 |
+
"""
|
| 96 |
+
Generate cache filename from URL
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
url: Original URL
|
| 100 |
+
media_type: 'image' or 'video'
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
Cache filename
|
| 104 |
+
"""
|
| 105 |
+
# Use URL hash as filename
|
| 106 |
+
url_hash = hashlib.md5(url.encode()).hexdigest()
|
| 107 |
+
|
| 108 |
+
# Determine extension
|
| 109 |
+
if media_type == "video":
|
| 110 |
+
ext = ".mp4"
|
| 111 |
+
else:
|
| 112 |
+
ext = ".png"
|
| 113 |
+
|
| 114 |
+
return f"{url_hash}{ext}"
|
| 115 |
+
|
| 116 |
+
async def download_and_cache(self, url: str, media_type: str) -> str:
|
| 117 |
+
"""
|
| 118 |
+
Download file from URL and cache it locally
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
url: File URL to download
|
| 122 |
+
media_type: 'image' or 'video'
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
Local cache filename
|
| 126 |
+
"""
|
| 127 |
+
filename = self._generate_cache_filename(url, media_type)
|
| 128 |
+
file_path = self.cache_dir / filename
|
| 129 |
+
|
| 130 |
+
# Check if already cached and not expired
|
| 131 |
+
if file_path.exists():
|
| 132 |
+
file_age = time.time() - file_path.stat().st_mtime
|
| 133 |
+
if file_age < self.default_timeout:
|
| 134 |
+
debug_logger.log_info(f"Cache hit: {filename}")
|
| 135 |
+
return filename
|
| 136 |
+
else:
|
| 137 |
+
# Remove expired file
|
| 138 |
+
try:
|
| 139 |
+
file_path.unlink()
|
| 140 |
+
except Exception:
|
| 141 |
+
pass
|
| 142 |
+
|
| 143 |
+
# Download file
|
| 144 |
+
debug_logger.log_info(f"Downloading file from: {url}")
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
# Get proxy if available
|
| 148 |
+
proxy_url = None
|
| 149 |
+
if self.proxy_manager:
|
| 150 |
+
proxy_config = await self.proxy_manager.get_proxy_config()
|
| 151 |
+
if proxy_config.proxy_enabled and proxy_config.proxy_url:
|
| 152 |
+
proxy_url = proxy_config.proxy_url
|
| 153 |
+
|
| 154 |
+
# Download with proxy support
|
| 155 |
+
async with AsyncSession() as session:
|
| 156 |
+
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
| 157 |
+
response = await session.get(url, timeout=60, proxies=proxies)
|
| 158 |
+
|
| 159 |
+
if response.status_code != 200:
|
| 160 |
+
raise Exception(f"Download failed: HTTP {response.status_code}")
|
| 161 |
+
|
| 162 |
+
# Save to cache
|
| 163 |
+
with open(file_path, 'wb') as f:
|
| 164 |
+
f.write(response.content)
|
| 165 |
+
|
| 166 |
+
debug_logger.log_info(f"File cached: {filename} ({len(response.content)} bytes)")
|
| 167 |
+
return filename
|
| 168 |
+
|
| 169 |
+
except Exception as e:
|
| 170 |
+
debug_logger.log_error(
|
| 171 |
+
error_message=f"Failed to download file: {str(e)}",
|
| 172 |
+
status_code=0,
|
| 173 |
+
response_text=str(e)
|
| 174 |
+
)
|
| 175 |
+
raise Exception(f"Failed to cache file: {str(e)}")
|
| 176 |
+
|
| 177 |
+
def get_cache_path(self, filename: str) -> Path:
|
| 178 |
+
"""Get full path to cached file"""
|
| 179 |
+
return self.cache_dir / filename
|
| 180 |
+
|
| 181 |
+
def set_timeout(self, timeout: int):
|
| 182 |
+
"""Set cache timeout in seconds"""
|
| 183 |
+
self.default_timeout = timeout
|
| 184 |
+
debug_logger.log_info(f"Cache timeout updated to {timeout} seconds")
|
| 185 |
+
|
| 186 |
+
def get_timeout(self) -> int:
|
| 187 |
+
"""Get current cache timeout"""
|
| 188 |
+
return self.default_timeout
|
| 189 |
+
|
| 190 |
+
async def clear_all(self):
|
| 191 |
+
"""Clear all cached files"""
|
| 192 |
+
try:
|
| 193 |
+
removed_count = 0
|
| 194 |
+
for file_path in self.cache_dir.iterdir():
|
| 195 |
+
if file_path.is_file():
|
| 196 |
+
try:
|
| 197 |
+
file_path.unlink()
|
| 198 |
+
removed_count += 1
|
| 199 |
+
except Exception:
|
| 200 |
+
pass
|
| 201 |
+
|
| 202 |
+
debug_logger.log_info(f"Cache cleared: removed {removed_count} files")
|
| 203 |
+
return removed_count
|
| 204 |
+
|
| 205 |
+
except Exception as e:
|
| 206 |
+
debug_logger.log_error(
|
| 207 |
+
error_message=f"Failed to clear cache: {str(e)}",
|
| 208 |
+
status_code=0,
|
| 209 |
+
response_text=""
|
| 210 |
+
)
|
| 211 |
+
raise
|
| 212 |
+
|
src/services/generation_handler.py
ADDED
|
@@ -0,0 +1,1475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generation handling module"""
|
| 2 |
+
import json
|
| 3 |
+
import asyncio
|
| 4 |
+
import base64
|
| 5 |
+
import time
|
| 6 |
+
import random
|
| 7 |
+
import re
|
| 8 |
+
from typing import Optional, AsyncGenerator, Dict, Any
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from .sora_client import SoraClient
|
| 11 |
+
from .token_manager import TokenManager
|
| 12 |
+
from .load_balancer import LoadBalancer
|
| 13 |
+
from .file_cache import FileCache
|
| 14 |
+
from ..core.database import Database
|
| 15 |
+
from ..core.models import Task, RequestLog
|
| 16 |
+
from ..core.config import config
|
| 17 |
+
from ..core.logger import debug_logger
|
| 18 |
+
|
| 19 |
+
# Model configuration
|
| 20 |
+
MODEL_CONFIG = {
|
| 21 |
+
"sora-image": {
|
| 22 |
+
"type": "image",
|
| 23 |
+
"width": 360,
|
| 24 |
+
"height": 360
|
| 25 |
+
},
|
| 26 |
+
"sora-image-landscape": {
|
| 27 |
+
"type": "image",
|
| 28 |
+
"width": 540,
|
| 29 |
+
"height": 360
|
| 30 |
+
},
|
| 31 |
+
"sora-image-portrait": {
|
| 32 |
+
"type": "image",
|
| 33 |
+
"width": 360,
|
| 34 |
+
"height": 540
|
| 35 |
+
},
|
| 36 |
+
# Video models with 10s duration (300 frames)
|
| 37 |
+
"sora-video-10s": {
|
| 38 |
+
"type": "video",
|
| 39 |
+
"orientation": "landscape",
|
| 40 |
+
"n_frames": 300
|
| 41 |
+
},
|
| 42 |
+
"sora-video-landscape-10s": {
|
| 43 |
+
"type": "video",
|
| 44 |
+
"orientation": "landscape",
|
| 45 |
+
"n_frames": 300
|
| 46 |
+
},
|
| 47 |
+
"sora-video-portrait-10s": {
|
| 48 |
+
"type": "video",
|
| 49 |
+
"orientation": "portrait",
|
| 50 |
+
"n_frames": 300
|
| 51 |
+
},
|
| 52 |
+
# Video models with 15s duration (450 frames)
|
| 53 |
+
"sora-video-15s": {
|
| 54 |
+
"type": "video",
|
| 55 |
+
"orientation": "landscape",
|
| 56 |
+
"n_frames": 450
|
| 57 |
+
},
|
| 58 |
+
"sora-video-landscape-15s": {
|
| 59 |
+
"type": "video",
|
| 60 |
+
"orientation": "landscape",
|
| 61 |
+
"n_frames": 450
|
| 62 |
+
},
|
| 63 |
+
"sora-video-portrait-15s": {
|
| 64 |
+
"type": "video",
|
| 65 |
+
"orientation": "portrait",
|
| 66 |
+
"n_frames": 450
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
class GenerationHandler:
|
| 71 |
+
"""Handle generation requests"""
|
| 72 |
+
|
| 73 |
+
def __init__(self, sora_client: SoraClient, token_manager: TokenManager,
|
| 74 |
+
load_balancer: LoadBalancer, db: Database, proxy_manager=None):
|
| 75 |
+
self.sora_client = sora_client
|
| 76 |
+
self.token_manager = token_manager
|
| 77 |
+
self.load_balancer = load_balancer
|
| 78 |
+
self.db = db
|
| 79 |
+
self.file_cache = FileCache(
|
| 80 |
+
cache_dir="tmp",
|
| 81 |
+
default_timeout=config.cache_timeout,
|
| 82 |
+
proxy_manager=proxy_manager
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def _get_base_url(self) -> str:
|
| 86 |
+
"""Get base URL for cache files"""
|
| 87 |
+
# Reload config to get latest values
|
| 88 |
+
config.reload_config()
|
| 89 |
+
|
| 90 |
+
# Use configured cache base URL if available
|
| 91 |
+
if config.cache_base_url:
|
| 92 |
+
return config.cache_base_url.rstrip('/')
|
| 93 |
+
# Otherwise use server address
|
| 94 |
+
return f"http://{config.server_host}:{config.server_port}"
|
| 95 |
+
|
| 96 |
+
def _decode_base64_image(self, image_str: str) -> bytes:
|
| 97 |
+
"""Decode base64 image"""
|
| 98 |
+
# Remove data URI prefix if present
|
| 99 |
+
if "," in image_str:
|
| 100 |
+
image_str = image_str.split(",", 1)[1]
|
| 101 |
+
return base64.b64decode(image_str)
|
| 102 |
+
|
| 103 |
+
def _decode_base64_video(self, video_str: str) -> bytes:
|
| 104 |
+
"""Decode base64 video"""
|
| 105 |
+
# Remove data URI prefix if present
|
| 106 |
+
if "," in video_str:
|
| 107 |
+
video_str = video_str.split(",", 1)[1]
|
| 108 |
+
return base64.b64decode(video_str)
|
| 109 |
+
|
| 110 |
+
def _process_character_username(self, username_hint: str) -> str:
|
| 111 |
+
"""Process character username from API response
|
| 112 |
+
|
| 113 |
+
Logic:
|
| 114 |
+
1. Remove prefix (e.g., "blackwill." from "blackwill.meowliusma68")
|
| 115 |
+
2. Keep the remaining part (e.g., "meowliusma68")
|
| 116 |
+
3. Append 3 random digits
|
| 117 |
+
4. Return final username (e.g., "meowliusma68123")
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
username_hint: Original username from API (e.g., "blackwill.meowliusma68")
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
Processed username with 3 random digits appended
|
| 124 |
+
"""
|
| 125 |
+
# Split by dot and take the last part
|
| 126 |
+
if "." in username_hint:
|
| 127 |
+
base_username = username_hint.split(".")[-1]
|
| 128 |
+
else:
|
| 129 |
+
base_username = username_hint
|
| 130 |
+
|
| 131 |
+
# Generate 3 random digits
|
| 132 |
+
random_digits = str(random.randint(100, 999))
|
| 133 |
+
|
| 134 |
+
# Return final username
|
| 135 |
+
final_username = f"{base_username}{random_digits}"
|
| 136 |
+
debug_logger.log_info(f"Processed username: {username_hint} -> {final_username}")
|
| 137 |
+
|
| 138 |
+
return final_username
|
| 139 |
+
|
| 140 |
+
def _clean_remix_link_from_prompt(self, prompt: str) -> str:
|
| 141 |
+
"""Remove remix link from prompt
|
| 142 |
+
|
| 143 |
+
Removes both formats:
|
| 144 |
+
1. Full URL: https://sora.chatgpt.com/p/s_68e3a06dcd888191b150971da152c1f5
|
| 145 |
+
2. Short ID: s_68e3a06dcd888191b150971da152c1f5
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
prompt: Original prompt that may contain remix link
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
Cleaned prompt without remix link
|
| 152 |
+
"""
|
| 153 |
+
if not prompt:
|
| 154 |
+
return prompt
|
| 155 |
+
|
| 156 |
+
# Remove full URL format: https://sora.chatgpt.com/p/s_[a-f0-9]{32}
|
| 157 |
+
cleaned = re.sub(r'https://sora\.chatgpt\.com/p/s_[a-f0-9]{32}', '', prompt)
|
| 158 |
+
|
| 159 |
+
# Remove short ID format: s_[a-f0-9]{32}
|
| 160 |
+
cleaned = re.sub(r's_[a-f0-9]{32}', '', cleaned)
|
| 161 |
+
|
| 162 |
+
# Clean up extra whitespace
|
| 163 |
+
cleaned = ' '.join(cleaned.split())
|
| 164 |
+
|
| 165 |
+
debug_logger.log_info(f"Cleaned prompt: '{prompt}' -> '{cleaned}'")
|
| 166 |
+
|
| 167 |
+
return cleaned
|
| 168 |
+
|
| 169 |
+
async def _download_file(self, url: str) -> bytes:
|
| 170 |
+
"""Download file from URL
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
url: File URL
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
File bytes
|
| 177 |
+
"""
|
| 178 |
+
from curl_cffi.requests import AsyncSession
|
| 179 |
+
|
| 180 |
+
proxy_url = await self.load_balancer.proxy_manager.get_proxy_url()
|
| 181 |
+
|
| 182 |
+
kwargs = {
|
| 183 |
+
"timeout": 30,
|
| 184 |
+
"impersonate": "chrome"
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
if proxy_url:
|
| 188 |
+
kwargs["proxy"] = proxy_url
|
| 189 |
+
|
| 190 |
+
async with AsyncSession() as session:
|
| 191 |
+
response = await session.get(url, **kwargs)
|
| 192 |
+
if response.status_code != 200:
|
| 193 |
+
raise Exception(f"Failed to download file: {response.status_code}")
|
| 194 |
+
return response.content
|
| 195 |
+
|
| 196 |
+
async def check_token_availability(self, is_image: bool, is_video: bool) -> bool:
|
| 197 |
+
"""Check if tokens are available for the given model type
|
| 198 |
+
|
| 199 |
+
Args:
|
| 200 |
+
is_image: Whether checking for image generation
|
| 201 |
+
is_video: Whether checking for video generation
|
| 202 |
+
|
| 203 |
+
Returns:
|
| 204 |
+
True if available tokens exist, False otherwise
|
| 205 |
+
"""
|
| 206 |
+
token_obj = await self.load_balancer.select_token(for_image_generation=is_image, for_video_generation=is_video)
|
| 207 |
+
return token_obj is not None
|
| 208 |
+
|
| 209 |
+
async def _run_background_poll(self, polling_generator):
|
| 210 |
+
"""Run polling generator in background until completion"""
|
| 211 |
+
try:
|
| 212 |
+
async for _ in polling_generator:
|
| 213 |
+
pass
|
| 214 |
+
except Exception as e:
|
| 215 |
+
debug_logger.log_error(f"Background polling failed: {str(e)}")
|
| 216 |
+
|
| 217 |
+
async def submit_generation_task(self, model: str, prompt: str,
|
| 218 |
+
image: Optional[str] = None,
|
| 219 |
+
video: Optional[str] = None,
|
| 220 |
+
remix_target_id: Optional[str] = None) -> str:
|
| 221 |
+
"""Submit generation task and return task ID immediately
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
model: Model name
|
| 225 |
+
prompt: Generation prompt
|
| 226 |
+
image: Base64 encoded image
|
| 227 |
+
video: Base64 encoded video or video URL
|
| 228 |
+
remix_target_id: Sora share link video ID for remix
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
Task ID
|
| 232 |
+
"""
|
| 233 |
+
# Validate model
|
| 234 |
+
if model not in MODEL_CONFIG:
|
| 235 |
+
raise ValueError(f"Invalid model: {model}")
|
| 236 |
+
|
| 237 |
+
model_config = MODEL_CONFIG[model]
|
| 238 |
+
is_video = model_config["type"] == "video"
|
| 239 |
+
is_image = model_config["type"] == "image"
|
| 240 |
+
|
| 241 |
+
# Handle remix flow
|
| 242 |
+
if is_video and remix_target_id:
|
| 243 |
+
return await self._submit_remix_task(remix_target_id, prompt, model_config)
|
| 244 |
+
|
| 245 |
+
# Helper to check tokens
|
| 246 |
+
token_obj = await self.load_balancer.select_token(for_image_generation=is_image, for_video_generation=is_video)
|
| 247 |
+
if not token_obj:
|
| 248 |
+
if is_image:
|
| 249 |
+
raise Exception("No available tokens for image generation")
|
| 250 |
+
else:
|
| 251 |
+
raise Exception("No available tokens for video generation")
|
| 252 |
+
|
| 253 |
+
# Handle video character flows (not fully supported in async yet, treating as standard generation if possible)
|
| 254 |
+
# For now, if video is provided for character creation, we might need a separate flow.
|
| 255 |
+
# But for standard video generation (text-to-video), let's proceed.
|
| 256 |
+
# If video is provided, it might be image-to-video or character flow.
|
| 257 |
+
pass_video_to_poll = False
|
| 258 |
+
media_id = None
|
| 259 |
+
|
| 260 |
+
# Acquire lock for image generation
|
| 261 |
+
if is_image:
|
| 262 |
+
lock_acquired = await self.load_balancer.token_lock.acquire_lock(token_obj.id)
|
| 263 |
+
if not lock_acquired:
|
| 264 |
+
raise Exception(f"Failed to acquire lock for token {token_obj.id}")
|
| 265 |
+
|
| 266 |
+
try:
|
| 267 |
+
# Upload image if provided
|
| 268 |
+
if image:
|
| 269 |
+
image_data = self._decode_base64_image(image)
|
| 270 |
+
media_id = await self.sora_client.upload_image(image_data, token_obj.token)
|
| 271 |
+
|
| 272 |
+
# Generate
|
| 273 |
+
task_id = None
|
| 274 |
+
if is_video:
|
| 275 |
+
n_frames = model_config.get("n_frames", 300)
|
| 276 |
+
# Note: Character flows with video input are complex to unify here.
|
| 277 |
+
# If prompt is present, we assume standard generation.
|
| 278 |
+
task_id = await self.sora_client.generate_video(
|
| 279 |
+
prompt, token_obj.token,
|
| 280 |
+
orientation=model_config["orientation"],
|
| 281 |
+
media_id=media_id,
|
| 282 |
+
n_frames=n_frames
|
| 283 |
+
)
|
| 284 |
+
else:
|
| 285 |
+
task_id = await self.sora_client.generate_image(
|
| 286 |
+
prompt, token_obj.token,
|
| 287 |
+
width=model_config["width"],
|
| 288 |
+
height=model_config["height"],
|
| 289 |
+
media_id=media_id
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# Save task to database
|
| 293 |
+
task = Task(
|
| 294 |
+
task_id=task_id,
|
| 295 |
+
token_id=token_obj.id,
|
| 296 |
+
model=model,
|
| 297 |
+
prompt=prompt,
|
| 298 |
+
status="processing",
|
| 299 |
+
progress=0.0
|
| 300 |
+
)
|
| 301 |
+
await self.db.create_task(task)
|
| 302 |
+
|
| 303 |
+
# Record usage
|
| 304 |
+
await self.token_manager.record_usage(token_obj.id, is_video=is_video)
|
| 305 |
+
|
| 306 |
+
# Start background polling
|
| 307 |
+
polling_gen = self._poll_task_result(
|
| 308 |
+
task_id, token_obj.token, is_video, stream=False, prompt=prompt, token_id=token_obj.id
|
| 309 |
+
)
|
| 310 |
+
asyncio.create_task(self._run_background_poll(polling_gen))
|
| 311 |
+
|
| 312 |
+
return task_id
|
| 313 |
+
|
| 314 |
+
except Exception as e:
|
| 315 |
+
if is_image and token_obj:
|
| 316 |
+
await self.load_balancer.token_lock.release_lock(token_obj.id)
|
| 317 |
+
raise e
|
| 318 |
+
|
| 319 |
+
async def _submit_remix_task(self, remix_target_id: str, prompt: str, model_config: Dict) -> str:
|
| 320 |
+
"""Submit remix task"""
|
| 321 |
+
token_obj = await self.load_balancer.select_token(for_video_generation=True)
|
| 322 |
+
if not token_obj:
|
| 323 |
+
raise Exception("No available tokens for remix generation")
|
| 324 |
+
|
| 325 |
+
try:
|
| 326 |
+
clean_prompt = self._clean_remix_link_from_prompt(prompt)
|
| 327 |
+
n_frames = model_config.get("n_frames", 300)
|
| 328 |
+
|
| 329 |
+
# Call remix API
|
| 330 |
+
task_id = await self.sora_client.remix_video(
|
| 331 |
+
remix_target_id=remix_target_id,
|
| 332 |
+
prompt=clean_prompt,
|
| 333 |
+
token=token_obj.token,
|
| 334 |
+
orientation=model_config["orientation"],
|
| 335 |
+
n_frames=n_frames
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
# Save task via DB
|
| 339 |
+
task = Task(
|
| 340 |
+
task_id=task_id,
|
| 341 |
+
token_id=token_obj.id,
|
| 342 |
+
model=f"sora-video-{model_config['orientation']}",
|
| 343 |
+
prompt=f"remix:{remix_target_id} {clean_prompt}",
|
| 344 |
+
status="processing",
|
| 345 |
+
progress=0.0
|
| 346 |
+
)
|
| 347 |
+
await self.db.create_task(task)
|
| 348 |
+
|
| 349 |
+
# Record usage
|
| 350 |
+
await self.token_manager.record_usage(token_obj.id, is_video=True)
|
| 351 |
+
|
| 352 |
+
# Start background polling
|
| 353 |
+
polling_gen = self._poll_task_result(
|
| 354 |
+
task_id, token_obj.token, True, False, clean_prompt, token_obj.id
|
| 355 |
+
)
|
| 356 |
+
asyncio.create_task(self._run_background_poll(polling_gen))
|
| 357 |
+
|
| 358 |
+
return task_id
|
| 359 |
+
|
| 360 |
+
except Exception as e:
|
| 361 |
+
if token_obj:
|
| 362 |
+
await self.token_manager.record_error(token_obj.id)
|
| 363 |
+
raise e
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
async def handle_generation(self, model: str, prompt: str,
|
| 367 |
+
image: Optional[str] = None,
|
| 368 |
+
video: Optional[str] = None,
|
| 369 |
+
remix_target_id: Optional[str] = None,
|
| 370 |
+
stream: bool = True) -> AsyncGenerator[str, None]:
|
| 371 |
+
"""Handle generation request
|
| 372 |
+
|
| 373 |
+
Args:
|
| 374 |
+
model: Model name
|
| 375 |
+
prompt: Generation prompt
|
| 376 |
+
image: Base64 encoded image
|
| 377 |
+
video: Base64 encoded video or video URL
|
| 378 |
+
remix_target_id: Sora share link video ID for remix
|
| 379 |
+
stream: Whether to stream response
|
| 380 |
+
"""
|
| 381 |
+
start_time = time.time()
|
| 382 |
+
|
| 383 |
+
# Validate model
|
| 384 |
+
if model not in MODEL_CONFIG:
|
| 385 |
+
raise ValueError(f"Invalid model: {model}")
|
| 386 |
+
|
| 387 |
+
model_config = MODEL_CONFIG[model]
|
| 388 |
+
is_video = model_config["type"] == "video"
|
| 389 |
+
is_image = model_config["type"] == "image"
|
| 390 |
+
|
| 391 |
+
# Non-streaming mode: only check availability
|
| 392 |
+
if not stream:
|
| 393 |
+
available = await self.check_token_availability(is_image, is_video)
|
| 394 |
+
if available:
|
| 395 |
+
if is_image:
|
| 396 |
+
message = "All tokens available for image generation. Please enable streaming to use the generation feature."
|
| 397 |
+
else:
|
| 398 |
+
message = "All tokens available for video generation. Please enable streaming to use the generation feature."
|
| 399 |
+
else:
|
| 400 |
+
if is_image:
|
| 401 |
+
message = "No available models for image generation"
|
| 402 |
+
else:
|
| 403 |
+
message = "No available models for video generation"
|
| 404 |
+
|
| 405 |
+
yield self._format_non_stream_response(message, is_availability_check=True)
|
| 406 |
+
return
|
| 407 |
+
|
| 408 |
+
# Handle character creation and remix flows for video models
|
| 409 |
+
if is_video:
|
| 410 |
+
# Remix flow: remix_target_id provided
|
| 411 |
+
if remix_target_id:
|
| 412 |
+
async for chunk in self._handle_remix(remix_target_id, prompt, model_config):
|
| 413 |
+
yield chunk
|
| 414 |
+
return
|
| 415 |
+
|
| 416 |
+
# Character creation flow: video provided
|
| 417 |
+
if video:
|
| 418 |
+
# Decode video if it's base64
|
| 419 |
+
video_data = self._decode_base64_video(video) if video.startswith("data:") or not video.startswith("http") else video
|
| 420 |
+
|
| 421 |
+
# If no prompt, just create character and return
|
| 422 |
+
if not prompt:
|
| 423 |
+
async for chunk in self._handle_character_creation_only(video_data, model_config):
|
| 424 |
+
yield chunk
|
| 425 |
+
return
|
| 426 |
+
else:
|
| 427 |
+
# If prompt provided, create character and generate video
|
| 428 |
+
async for chunk in self._handle_character_and_video_generation(video_data, prompt, model_config):
|
| 429 |
+
yield chunk
|
| 430 |
+
return
|
| 431 |
+
|
| 432 |
+
# Streaming mode: proceed with actual generation
|
| 433 |
+
# Select token (with lock for image generation, Sora2 quota check for video generation)
|
| 434 |
+
token_obj = await self.load_balancer.select_token(for_image_generation=is_image, for_video_generation=is_video)
|
| 435 |
+
if not token_obj:
|
| 436 |
+
if is_image:
|
| 437 |
+
raise Exception("No available tokens for image generation. All tokens are either disabled, cooling down, locked, or expired.")
|
| 438 |
+
else:
|
| 439 |
+
raise Exception("No available tokens for video generation. All tokens are either disabled, cooling down, Sora2 quota exhausted, don't support Sora2, or expired.")
|
| 440 |
+
|
| 441 |
+
# Acquire lock for image generation
|
| 442 |
+
if is_image:
|
| 443 |
+
lock_acquired = await self.load_balancer.token_lock.acquire_lock(token_obj.id)
|
| 444 |
+
if not lock_acquired:
|
| 445 |
+
raise Exception(f"Failed to acquire lock for token {token_obj.id}")
|
| 446 |
+
|
| 447 |
+
task_id = None
|
| 448 |
+
is_first_chunk = True # Track if this is the first chunk
|
| 449 |
+
|
| 450 |
+
try:
|
| 451 |
+
# Upload image if provided
|
| 452 |
+
media_id = None
|
| 453 |
+
if image:
|
| 454 |
+
if stream:
|
| 455 |
+
yield self._format_stream_chunk(
|
| 456 |
+
reasoning_content="**Image Upload Begins**\n\nUploading image to server...\n",
|
| 457 |
+
is_first=is_first_chunk
|
| 458 |
+
)
|
| 459 |
+
is_first_chunk = False
|
| 460 |
+
|
| 461 |
+
image_data = self._decode_base64_image(image)
|
| 462 |
+
media_id = await self.sora_client.upload_image(image_data, token_obj.token)
|
| 463 |
+
|
| 464 |
+
if stream:
|
| 465 |
+
yield self._format_stream_chunk(
|
| 466 |
+
reasoning_content="Image uploaded successfully. Proceeding to generation...\n"
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
# Generate
|
| 470 |
+
if stream:
|
| 471 |
+
if is_first_chunk:
|
| 472 |
+
yield self._format_stream_chunk(
|
| 473 |
+
reasoning_content="**Generation Process Begins**\n\nInitializing generation request...\n",
|
| 474 |
+
is_first=True
|
| 475 |
+
)
|
| 476 |
+
is_first_chunk = False
|
| 477 |
+
else:
|
| 478 |
+
yield self._format_stream_chunk(
|
| 479 |
+
reasoning_content="**Generation Process Begins**\n\nInitializing generation request...\n"
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
if is_video:
|
| 483 |
+
# Get n_frames from model configuration
|
| 484 |
+
n_frames = model_config.get("n_frames", 300) # Default to 300 frames (10s)
|
| 485 |
+
|
| 486 |
+
task_id = await self.sora_client.generate_video(
|
| 487 |
+
prompt, token_obj.token,
|
| 488 |
+
orientation=model_config["orientation"],
|
| 489 |
+
media_id=media_id,
|
| 490 |
+
n_frames=n_frames
|
| 491 |
+
)
|
| 492 |
+
else:
|
| 493 |
+
task_id = await self.sora_client.generate_image(
|
| 494 |
+
prompt, token_obj.token,
|
| 495 |
+
width=model_config["width"],
|
| 496 |
+
height=model_config["height"],
|
| 497 |
+
media_id=media_id
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
# Save task to database
|
| 501 |
+
task = Task(
|
| 502 |
+
task_id=task_id,
|
| 503 |
+
token_id=token_obj.id,
|
| 504 |
+
model=model,
|
| 505 |
+
prompt=prompt,
|
| 506 |
+
status="processing",
|
| 507 |
+
progress=0.0
|
| 508 |
+
)
|
| 509 |
+
await self.db.create_task(task)
|
| 510 |
+
|
| 511 |
+
# Record usage
|
| 512 |
+
await self.token_manager.record_usage(token_obj.id, is_video=is_video)
|
| 513 |
+
|
| 514 |
+
# Poll for results with timeout
|
| 515 |
+
async for chunk in self._poll_task_result(task_id, token_obj.token, is_video, stream, prompt, token_obj.id):
|
| 516 |
+
yield chunk
|
| 517 |
+
|
| 518 |
+
# Record success
|
| 519 |
+
await self.token_manager.record_success(token_obj.id, is_video=is_video)
|
| 520 |
+
|
| 521 |
+
# Release lock for image generation
|
| 522 |
+
if is_image:
|
| 523 |
+
await self.load_balancer.token_lock.release_lock(token_obj.id)
|
| 524 |
+
|
| 525 |
+
# Log successful request
|
| 526 |
+
duration = time.time() - start_time
|
| 527 |
+
await self._log_request(
|
| 528 |
+
token_obj.id,
|
| 529 |
+
f"generate_{model_config['type']}",
|
| 530 |
+
{"model": model, "prompt": prompt, "has_image": image is not None},
|
| 531 |
+
{"task_id": task_id, "status": "success"},
|
| 532 |
+
200,
|
| 533 |
+
duration
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
except Exception as e:
|
| 537 |
+
# Release lock for image generation on error
|
| 538 |
+
if is_image and token_obj:
|
| 539 |
+
await self.load_balancer.token_lock.release_lock(token_obj.id)
|
| 540 |
+
|
| 541 |
+
# Record error
|
| 542 |
+
if token_obj:
|
| 543 |
+
await self.token_manager.record_error(token_obj.id)
|
| 544 |
+
|
| 545 |
+
# Log failed request
|
| 546 |
+
duration = time.time() - start_time
|
| 547 |
+
await self._log_request(
|
| 548 |
+
token_obj.id if token_obj else None,
|
| 549 |
+
f"generate_{model_config['type'] if model_config else 'unknown'}",
|
| 550 |
+
{"model": model, "prompt": prompt, "has_image": image is not None},
|
| 551 |
+
{"error": str(e)},
|
| 552 |
+
500,
|
| 553 |
+
duration
|
| 554 |
+
)
|
| 555 |
+
raise e
|
| 556 |
+
|
| 557 |
+
async def _poll_task_result(self, task_id: str, token: str, is_video: bool,
|
| 558 |
+
stream: bool, prompt: str, token_id: int = None) -> AsyncGenerator[str, None]:
|
| 559 |
+
"""Poll for task result with timeout"""
|
| 560 |
+
# Get timeout from config
|
| 561 |
+
timeout = config.video_timeout if is_video else config.image_timeout
|
| 562 |
+
poll_interval = config.poll_interval
|
| 563 |
+
max_attempts = int(timeout / poll_interval) # Calculate max attempts based on timeout
|
| 564 |
+
last_progress = 0
|
| 565 |
+
start_time = time.time()
|
| 566 |
+
last_heartbeat_time = start_time # Track last heartbeat for image generation
|
| 567 |
+
heartbeat_interval = 10 # Send heartbeat every 10 seconds for image generation
|
| 568 |
+
last_status_output_time = start_time # Track last status output time for video generation
|
| 569 |
+
video_status_interval = 30 # Output status every 30 seconds for video generation
|
| 570 |
+
|
| 571 |
+
debug_logger.log_info(f"Starting task polling: task_id={task_id}, is_video={is_video}, timeout={timeout}s, max_attempts={max_attempts}")
|
| 572 |
+
|
| 573 |
+
# Check and log watermark-free mode status at the beginning
|
| 574 |
+
if is_video:
|
| 575 |
+
watermark_free_config = await self.db.get_watermark_free_config()
|
| 576 |
+
debug_logger.log_info(f"Watermark-free mode: {'ENABLED' if watermark_free_config.watermark_free_enabled else 'DISABLED'}")
|
| 577 |
+
|
| 578 |
+
for attempt in range(max_attempts):
|
| 579 |
+
# Check if timeout exceeded
|
| 580 |
+
elapsed_time = time.time() - start_time
|
| 581 |
+
if elapsed_time > timeout:
|
| 582 |
+
debug_logger.log_error(
|
| 583 |
+
error_message=f"Task timeout: {elapsed_time:.1f}s > {timeout}s",
|
| 584 |
+
status_code=408,
|
| 585 |
+
response_text=f"Task {task_id} timed out after {elapsed_time:.1f} seconds"
|
| 586 |
+
)
|
| 587 |
+
# Release lock if this is an image generation task
|
| 588 |
+
if not is_video and token_id:
|
| 589 |
+
await self.load_balancer.token_lock.release_lock(token_id)
|
| 590 |
+
debug_logger.log_info(f"Released lock for token {token_id} due to timeout")
|
| 591 |
+
|
| 592 |
+
await self.db.update_task(task_id, "failed", 0, error_message=f"Generation timeout after {elapsed_time:.1f} seconds")
|
| 593 |
+
raise Exception(f"Upstream API timeout: Generation exceeded {timeout} seconds limit")
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
await asyncio.sleep(poll_interval)
|
| 597 |
+
|
| 598 |
+
try:
|
| 599 |
+
if is_video:
|
| 600 |
+
# Get pending tasks to check progress
|
| 601 |
+
pending_tasks = await self.sora_client.get_pending_tasks(token)
|
| 602 |
+
|
| 603 |
+
# Find matching task in pending tasks
|
| 604 |
+
task_found = False
|
| 605 |
+
for task in pending_tasks:
|
| 606 |
+
if task.get("id") == task_id:
|
| 607 |
+
task_found = True
|
| 608 |
+
# Update progress
|
| 609 |
+
progress_pct = task.get("progress_pct")
|
| 610 |
+
# Handle null progress at the beginning
|
| 611 |
+
if progress_pct is None:
|
| 612 |
+
progress_pct = 0
|
| 613 |
+
else:
|
| 614 |
+
progress_pct = int(progress_pct * 100)
|
| 615 |
+
|
| 616 |
+
# Update last_progress for tracking
|
| 617 |
+
last_progress = progress_pct
|
| 618 |
+
status = task.get("status", "processing")
|
| 619 |
+
|
| 620 |
+
# Output status every 30 seconds (not just when progress changes)
|
| 621 |
+
current_time = time.time()
|
| 622 |
+
if stream and (current_time - last_status_output_time >= video_status_interval):
|
| 623 |
+
last_status_output_time = current_time
|
| 624 |
+
debug_logger.log_info(f"Task {task_id} progress: {progress_pct}% (status: {status})")
|
| 625 |
+
yield self._format_stream_chunk(
|
| 626 |
+
reasoning_content=f"**Video Generation Progress**: {progress_pct}% ({status})\n"
|
| 627 |
+
)
|
| 628 |
+
break
|
| 629 |
+
|
| 630 |
+
# If task not found in pending tasks, it's completed - fetch from drafts
|
| 631 |
+
if not task_found:
|
| 632 |
+
debug_logger.log_info(f"Task {task_id} not found in pending tasks, fetching from drafts...")
|
| 633 |
+
result = await self.sora_client.get_video_drafts(token)
|
| 634 |
+
items = result.get("items", [])
|
| 635 |
+
|
| 636 |
+
# Find matching task in drafts
|
| 637 |
+
for item in items:
|
| 638 |
+
if item.get("task_id") == task_id:
|
| 639 |
+
# Check if watermark-free mode is enabled
|
| 640 |
+
watermark_free_config = await self.db.get_watermark_free_config()
|
| 641 |
+
watermark_free_enabled = watermark_free_config.watermark_free_enabled
|
| 642 |
+
|
| 643 |
+
if watermark_free_enabled:
|
| 644 |
+
# Watermark-free mode: post video and get watermark-free URL
|
| 645 |
+
debug_logger.log_info(f"Entering watermark-free mode for task {task_id}")
|
| 646 |
+
generation_id = item.get("id")
|
| 647 |
+
debug_logger.log_info(f"Generation ID: {generation_id}")
|
| 648 |
+
if not generation_id:
|
| 649 |
+
raise Exception("Generation ID not found in video draft")
|
| 650 |
+
|
| 651 |
+
if stream:
|
| 652 |
+
yield self._format_stream_chunk(
|
| 653 |
+
reasoning_content="**Video Generation Completed**\n\nWatermark-free mode enabled. Publishing video to get watermark-free version...\n"
|
| 654 |
+
)
|
| 655 |
+
|
| 656 |
+
# Get watermark-free config to determine parse method
|
| 657 |
+
watermark_config = await self.db.get_watermark_free_config()
|
| 658 |
+
parse_method = watermark_config.parse_method or "third_party"
|
| 659 |
+
|
| 660 |
+
# Post video to get watermark-free version
|
| 661 |
+
try:
|
| 662 |
+
debug_logger.log_info(f"Calling post_video_for_watermark_free with generation_id={generation_id}, prompt={prompt[:50]}...")
|
| 663 |
+
post_id = await self.sora_client.post_video_for_watermark_free(
|
| 664 |
+
generation_id=generation_id,
|
| 665 |
+
prompt=prompt,
|
| 666 |
+
token=token
|
| 667 |
+
)
|
| 668 |
+
debug_logger.log_info(f"Received post_id: {post_id}")
|
| 669 |
+
|
| 670 |
+
if not post_id:
|
| 671 |
+
raise Exception("Failed to get post ID from publish API")
|
| 672 |
+
|
| 673 |
+
# Get watermark-free video URL based on parse method
|
| 674 |
+
if parse_method == "custom":
|
| 675 |
+
# Use custom parse server
|
| 676 |
+
if not watermark_config.custom_parse_url or not watermark_config.custom_parse_token:
|
| 677 |
+
raise Exception("Custom parse server URL or token not configured")
|
| 678 |
+
|
| 679 |
+
if stream:
|
| 680 |
+
yield self._format_stream_chunk(
|
| 681 |
+
reasoning_content=f"Video published successfully. Post ID: {post_id}\nUsing custom parse server to get watermark-free URL...\n"
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
debug_logger.log_info(f"Using custom parse server: {watermark_config.custom_parse_url}")
|
| 685 |
+
watermark_free_url = await self.sora_client.get_watermark_free_url_custom(
|
| 686 |
+
parse_url=watermark_config.custom_parse_url,
|
| 687 |
+
parse_token=watermark_config.custom_parse_token,
|
| 688 |
+
post_id=post_id
|
| 689 |
+
)
|
| 690 |
+
else:
|
| 691 |
+
# Use third-party parse (default)
|
| 692 |
+
watermark_free_url = f"https://oscdn2.dyysy.com/MP4/{post_id}.mp4"
|
| 693 |
+
debug_logger.log_info(f"Using third-party parse server")
|
| 694 |
+
|
| 695 |
+
debug_logger.log_info(f"Watermark-free URL: {watermark_free_url}")
|
| 696 |
+
|
| 697 |
+
if stream:
|
| 698 |
+
yield self._format_stream_chunk(
|
| 699 |
+
reasoning_content=f"Video published successfully. Post ID: {post_id}\nNow {'caching' if config.cache_enabled else 'preparing'} watermark-free video...\n"
|
| 700 |
+
)
|
| 701 |
+
|
| 702 |
+
# Cache watermark-free video (if cache enabled)
|
| 703 |
+
if config.cache_enabled:
|
| 704 |
+
try:
|
| 705 |
+
cached_filename = await self.file_cache.download_and_cache(watermark_free_url, "video")
|
| 706 |
+
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
|
| 707 |
+
if stream:
|
| 708 |
+
yield self._format_stream_chunk(
|
| 709 |
+
reasoning_content="Watermark-free video cached successfully. Preparing final response...\n"
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
# Delete the published post after caching
|
| 713 |
+
try:
|
| 714 |
+
debug_logger.log_info(f"Deleting published post: {post_id}")
|
| 715 |
+
await self.sora_client.delete_post(post_id, token)
|
| 716 |
+
debug_logger.log_info(f"Published post deleted successfully: {post_id}")
|
| 717 |
+
if stream:
|
| 718 |
+
yield self._format_stream_chunk(
|
| 719 |
+
reasoning_content="Published post deleted successfully.\n"
|
| 720 |
+
)
|
| 721 |
+
except Exception as delete_error:
|
| 722 |
+
debug_logger.log_error(
|
| 723 |
+
error_message=f"Failed to delete published post {post_id}: {str(delete_error)}",
|
| 724 |
+
status_code=500,
|
| 725 |
+
response_text=str(delete_error)
|
| 726 |
+
)
|
| 727 |
+
if stream:
|
| 728 |
+
yield self._format_stream_chunk(
|
| 729 |
+
reasoning_content=f"Warning: Failed to delete published post - {str(delete_error)}\n"
|
| 730 |
+
)
|
| 731 |
+
except Exception as cache_error:
|
| 732 |
+
# Fallback to watermark-free URL if caching fails
|
| 733 |
+
local_url = watermark_free_url
|
| 734 |
+
if stream:
|
| 735 |
+
yield self._format_stream_chunk(
|
| 736 |
+
reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original watermark-free URL instead...\n"
|
| 737 |
+
)
|
| 738 |
+
else:
|
| 739 |
+
# Cache disabled: use watermark-free URL directly
|
| 740 |
+
local_url = watermark_free_url
|
| 741 |
+
if stream:
|
| 742 |
+
yield self._format_stream_chunk(
|
| 743 |
+
reasoning_content="Cache is disabled. Using watermark-free URL directly...\n"
|
| 744 |
+
)
|
| 745 |
+
|
| 746 |
+
except Exception as publish_error:
|
| 747 |
+
# Fallback to normal mode if publish fails
|
| 748 |
+
debug_logger.log_error(
|
| 749 |
+
error_message=f"Watermark-free mode failed: {str(publish_error)}",
|
| 750 |
+
status_code=500,
|
| 751 |
+
response_text=str(publish_error)
|
| 752 |
+
)
|
| 753 |
+
if stream:
|
| 754 |
+
yield self._format_stream_chunk(
|
| 755 |
+
reasoning_content=f"Warning: Failed to get watermark-free version - {str(publish_error)}\nFalling back to normal video...\n"
|
| 756 |
+
)
|
| 757 |
+
# Use downloadable_url instead of url
|
| 758 |
+
url = item.get("downloadable_url") or item.get("url")
|
| 759 |
+
if not url:
|
| 760 |
+
raise Exception("Video URL not found")
|
| 761 |
+
if config.cache_enabled:
|
| 762 |
+
try:
|
| 763 |
+
cached_filename = await self.file_cache.download_and_cache(url, "video")
|
| 764 |
+
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
|
| 765 |
+
except Exception as cache_error:
|
| 766 |
+
local_url = url
|
| 767 |
+
else:
|
| 768 |
+
local_url = url
|
| 769 |
+
else:
|
| 770 |
+
# Normal mode: use downloadable_url instead of url
|
| 771 |
+
url = item.get("downloadable_url") or item.get("url")
|
| 772 |
+
if url:
|
| 773 |
+
# Cache video file (if cache enabled)
|
| 774 |
+
if config.cache_enabled:
|
| 775 |
+
if stream:
|
| 776 |
+
yield self._format_stream_chunk(
|
| 777 |
+
reasoning_content="**Video Generation Completed**\n\nVideo generation successful. Now caching the video file...\n"
|
| 778 |
+
)
|
| 779 |
+
|
| 780 |
+
try:
|
| 781 |
+
cached_filename = await self.file_cache.download_and_cache(url, "video")
|
| 782 |
+
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
|
| 783 |
+
if stream:
|
| 784 |
+
yield self._format_stream_chunk(
|
| 785 |
+
reasoning_content="Video file cached successfully. Preparing final response...\n"
|
| 786 |
+
)
|
| 787 |
+
except Exception as cache_error:
|
| 788 |
+
# Fallback to original URL if caching fails
|
| 789 |
+
local_url = url
|
| 790 |
+
if stream:
|
| 791 |
+
yield self._format_stream_chunk(
|
| 792 |
+
reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original URL instead...\n"
|
| 793 |
+
)
|
| 794 |
+
else:
|
| 795 |
+
# Cache disabled: use original URL directly
|
| 796 |
+
local_url = url
|
| 797 |
+
if stream:
|
| 798 |
+
yield self._format_stream_chunk(
|
| 799 |
+
reasoning_content="**Video Generation Completed**\n\nCache is disabled. Using original URL directly...\n"
|
| 800 |
+
)
|
| 801 |
+
|
| 802 |
+
# Task completed
|
| 803 |
+
await self.db.update_task(
|
| 804 |
+
task_id, "completed", 100.0,
|
| 805 |
+
result_urls=json.dumps([local_url])
|
| 806 |
+
)
|
| 807 |
+
|
| 808 |
+
if stream:
|
| 809 |
+
# Final response with content
|
| 810 |
+
yield self._format_stream_chunk(
|
| 811 |
+
content=f"```html\n<video src='{local_url}' controls></video>\n```",
|
| 812 |
+
finish_reason="STOP"
|
| 813 |
+
)
|
| 814 |
+
yield "data: [DONE]\n\n"
|
| 815 |
+
return
|
| 816 |
+
else:
|
| 817 |
+
result = await self.sora_client.get_image_tasks(token)
|
| 818 |
+
task_responses = result.get("task_responses", [])
|
| 819 |
+
|
| 820 |
+
# Find matching task
|
| 821 |
+
task_found = False
|
| 822 |
+
for task_resp in task_responses:
|
| 823 |
+
if task_resp.get("id") == task_id:
|
| 824 |
+
task_found = True
|
| 825 |
+
status = task_resp.get("status")
|
| 826 |
+
print("status:"+status+",progress_pct:"+task_resp.get("progress_pct", 0))
|
| 827 |
+
progress = task_resp.get("progress_pct", 0) * 100
|
| 828 |
+
|
| 829 |
+
if status == "succeeded":
|
| 830 |
+
# Extract URLs
|
| 831 |
+
generations = task_resp.get("generations", [])
|
| 832 |
+
urls = [gen.get("url") for gen in generations if gen.get("url")]
|
| 833 |
+
|
| 834 |
+
if urls:
|
| 835 |
+
# Cache image files
|
| 836 |
+
if stream:
|
| 837 |
+
yield self._format_stream_chunk(
|
| 838 |
+
reasoning_content=f"**Image Generation Completed**\n\nImage generation successful. Now caching {len(urls)} image(s)...\n"
|
| 839 |
+
)
|
| 840 |
+
|
| 841 |
+
base_url = self._get_base_url()
|
| 842 |
+
local_urls = []
|
| 843 |
+
|
| 844 |
+
# Check if cache is enabled
|
| 845 |
+
if config.cache_enabled:
|
| 846 |
+
for idx, url in enumerate(urls):
|
| 847 |
+
try:
|
| 848 |
+
cached_filename = await self.file_cache.download_and_cache(url, "image")
|
| 849 |
+
local_url = f"{base_url}/tmp/{cached_filename}"
|
| 850 |
+
local_urls.append(local_url)
|
| 851 |
+
if stream and len(urls) > 1:
|
| 852 |
+
yield self._format_stream_chunk(
|
| 853 |
+
reasoning_content=f"Cached image {idx + 1}/{len(urls)}...\n"
|
| 854 |
+
)
|
| 855 |
+
except Exception as cache_error:
|
| 856 |
+
# Fallback to original URL if caching fails
|
| 857 |
+
local_urls.append(url)
|
| 858 |
+
if stream:
|
| 859 |
+
yield self._format_stream_chunk(
|
| 860 |
+
reasoning_content=f"Warning: Failed to cache image {idx + 1} - {str(cache_error)}\nUsing original URL instead...\n"
|
| 861 |
+
)
|
| 862 |
+
|
| 863 |
+
if stream and all(u.startswith(base_url) for u in local_urls):
|
| 864 |
+
yield self._format_stream_chunk(
|
| 865 |
+
reasoning_content="All images cached successfully. Preparing final response...\n"
|
| 866 |
+
)
|
| 867 |
+
else:
|
| 868 |
+
# Cache disabled: use original URLs directly
|
| 869 |
+
local_urls = urls
|
| 870 |
+
if stream:
|
| 871 |
+
yield self._format_stream_chunk(
|
| 872 |
+
reasoning_content="Cache is disabled. Using original URLs directly...\n"
|
| 873 |
+
)
|
| 874 |
+
|
| 875 |
+
await self.db.update_task(
|
| 876 |
+
task_id, "completed", 100.0,
|
| 877 |
+
result_urls=json.dumps(local_urls)
|
| 878 |
+
)
|
| 879 |
+
|
| 880 |
+
if stream:
|
| 881 |
+
# Final response with content (Markdown format)
|
| 882 |
+
content_markdown = "\n".join([f"" for url in local_urls])
|
| 883 |
+
yield self._format_stream_chunk(
|
| 884 |
+
content=content_markdown,
|
| 885 |
+
finish_reason="STOP"
|
| 886 |
+
)
|
| 887 |
+
yield "data: [DONE]\n\n"
|
| 888 |
+
return
|
| 889 |
+
|
| 890 |
+
elif status == "failed":
|
| 891 |
+
error_msg = task_resp.get("error_message", "Generation failed")
|
| 892 |
+
await self.db.update_task(task_id, "failed", progress, error_message=error_msg)
|
| 893 |
+
raise Exception(error_msg)
|
| 894 |
+
|
| 895 |
+
elif status == "processing":
|
| 896 |
+
# Update progress only if changed significantly
|
| 897 |
+
if progress > last_progress + 20: # Update every 20%
|
| 898 |
+
last_progress = progress
|
| 899 |
+
await self.db.update_task(task_id, "processing", progress)
|
| 900 |
+
|
| 901 |
+
if stream:
|
| 902 |
+
yield self._format_stream_chunk(
|
| 903 |
+
reasoning_content=f"**Processing**\n\nGeneration in progress: {progress:.0f}% completed...\n"
|
| 904 |
+
)
|
| 905 |
+
|
| 906 |
+
# For image generation, send heartbeat every 10 seconds if no progress update
|
| 907 |
+
if not is_video and stream:
|
| 908 |
+
current_time = time.time()
|
| 909 |
+
if current_time - last_heartbeat_time >= heartbeat_interval:
|
| 910 |
+
last_heartbeat_time = current_time
|
| 911 |
+
elapsed = int(current_time - start_time)
|
| 912 |
+
yield self._format_stream_chunk(
|
| 913 |
+
reasoning_content=f"Image generation in progress... ({elapsed}s elapsed)\n"
|
| 914 |
+
)
|
| 915 |
+
|
| 916 |
+
# If task not found in response, send heartbeat for image generation
|
| 917 |
+
if not task_found and not is_video and stream:
|
| 918 |
+
current_time = time.time()
|
| 919 |
+
if current_time - last_heartbeat_time >= heartbeat_interval:
|
| 920 |
+
last_heartbeat_time = current_time
|
| 921 |
+
elapsed = int(current_time - start_time)
|
| 922 |
+
yield self._format_stream_chunk(
|
| 923 |
+
reasoning_content=f"Image generation in progress... ({elapsed}s elapsed)\n"
|
| 924 |
+
)
|
| 925 |
+
|
| 926 |
+
# Progress update for stream mode (fallback if no status from API)
|
| 927 |
+
if stream and attempt % 10 == 0: # Update every 10 attempts (roughly 20% intervals)
|
| 928 |
+
estimated_progress = min(90, (attempt / max_attempts) * 100)
|
| 929 |
+
if estimated_progress > last_progress + 20: # Update every 20%
|
| 930 |
+
last_progress = estimated_progress
|
| 931 |
+
yield self._format_stream_chunk(
|
| 932 |
+
reasoning_content=f"**Processing**\n\nGeneration in progress: {estimated_progress:.0f}% completed (estimated)...\n"
|
| 933 |
+
)
|
| 934 |
+
|
| 935 |
+
except Exception as e:
|
| 936 |
+
if attempt >= max_attempts - 1:
|
| 937 |
+
raise e
|
| 938 |
+
continue
|
| 939 |
+
|
| 940 |
+
# Timeout - release lock if image generation
|
| 941 |
+
if not is_video and token_id:
|
| 942 |
+
await self.load_balancer.token_lock.release_lock(token_id)
|
| 943 |
+
debug_logger.log_info(f"Released lock for token {token_id} due to max attempts reached")
|
| 944 |
+
|
| 945 |
+
await self.db.update_task(task_id, "failed", 0, error_message=f"Generation timeout after {timeout} seconds")
|
| 946 |
+
raise Exception(f"Upstream API timeout: Generation exceeded {timeout} seconds limit")
|
| 947 |
+
|
| 948 |
+
def _format_stream_chunk(self, content: str = None, reasoning_content: str = None,
|
| 949 |
+
finish_reason: str = None, is_first: bool = False) -> str:
|
| 950 |
+
"""Format streaming response chunk
|
| 951 |
+
|
| 952 |
+
Args:
|
| 953 |
+
content: Final response content (for user-facing output)
|
| 954 |
+
reasoning_content: Thinking/reasoning process content
|
| 955 |
+
finish_reason: Finish reason (e.g., "STOP")
|
| 956 |
+
is_first: Whether this is the first chunk (includes role)
|
| 957 |
+
"""
|
| 958 |
+
chunk_id = f"chatcmpl-{int(datetime.now().timestamp() * 1000)}"
|
| 959 |
+
|
| 960 |
+
delta = {}
|
| 961 |
+
|
| 962 |
+
# Add role for first chunk
|
| 963 |
+
if is_first:
|
| 964 |
+
delta["role"] = "assistant"
|
| 965 |
+
|
| 966 |
+
# Add content fields
|
| 967 |
+
if content is not None:
|
| 968 |
+
delta["content"] = content
|
| 969 |
+
else:
|
| 970 |
+
delta["content"] = None
|
| 971 |
+
|
| 972 |
+
if reasoning_content is not None:
|
| 973 |
+
delta["reasoning_content"] = reasoning_content
|
| 974 |
+
else:
|
| 975 |
+
delta["reasoning_content"] = None
|
| 976 |
+
|
| 977 |
+
delta["tool_calls"] = None
|
| 978 |
+
|
| 979 |
+
response = {
|
| 980 |
+
"id": chunk_id,
|
| 981 |
+
"object": "chat.completion.chunk",
|
| 982 |
+
"created": int(datetime.now().timestamp()),
|
| 983 |
+
"model": "sora",
|
| 984 |
+
"choices": [{
|
| 985 |
+
"index": 0,
|
| 986 |
+
"delta": delta,
|
| 987 |
+
"finish_reason": finish_reason,
|
| 988 |
+
"native_finish_reason": finish_reason
|
| 989 |
+
}],
|
| 990 |
+
"usage": {
|
| 991 |
+
"prompt_tokens": 0
|
| 992 |
+
}
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
# Add completion tokens for final chunk
|
| 996 |
+
if finish_reason:
|
| 997 |
+
response["usage"]["completion_tokens"] = 1
|
| 998 |
+
response["usage"]["total_tokens"] = 1
|
| 999 |
+
|
| 1000 |
+
return f'data: {json.dumps(response)}\n\n'
|
| 1001 |
+
|
| 1002 |
+
def _format_non_stream_response(self, content: str, media_type: str = None, is_availability_check: bool = False) -> str:
|
| 1003 |
+
"""Format non-streaming response
|
| 1004 |
+
|
| 1005 |
+
Args:
|
| 1006 |
+
content: Response content (either URL for generation or message for availability check)
|
| 1007 |
+
media_type: Type of media ("video", "image") - only used for generation responses
|
| 1008 |
+
is_availability_check: Whether this is an availability check response
|
| 1009 |
+
"""
|
| 1010 |
+
if not is_availability_check:
|
| 1011 |
+
# Generation response with media
|
| 1012 |
+
if media_type == "video":
|
| 1013 |
+
content = f"```html\n<video src='{content}' controls></video>\n```"
|
| 1014 |
+
else:
|
| 1015 |
+
content = f""
|
| 1016 |
+
|
| 1017 |
+
response = {
|
| 1018 |
+
"id": f"chatcmpl-{datetime.now().timestamp()}",
|
| 1019 |
+
"object": "chat.completion",
|
| 1020 |
+
"created": int(datetime.now().timestamp()),
|
| 1021 |
+
"model": "sora",
|
| 1022 |
+
"choices": [{
|
| 1023 |
+
"index": 0,
|
| 1024 |
+
"message": {
|
| 1025 |
+
"role": "assistant",
|
| 1026 |
+
"content": content
|
| 1027 |
+
},
|
| 1028 |
+
"finish_reason": "stop"
|
| 1029 |
+
}]
|
| 1030 |
+
}
|
| 1031 |
+
return json.dumps(response)
|
| 1032 |
+
|
| 1033 |
+
async def _log_request(self, token_id: Optional[int], operation: str,
|
| 1034 |
+
request_data: Dict[str, Any], response_data: Dict[str, Any],
|
| 1035 |
+
status_code: int, duration: float):
|
| 1036 |
+
"""Log request to database"""
|
| 1037 |
+
try:
|
| 1038 |
+
log = RequestLog(
|
| 1039 |
+
token_id=token_id,
|
| 1040 |
+
operation=operation,
|
| 1041 |
+
request_body=json.dumps(request_data),
|
| 1042 |
+
response_body=json.dumps(response_data),
|
| 1043 |
+
status_code=status_code,
|
| 1044 |
+
duration=duration
|
| 1045 |
+
)
|
| 1046 |
+
await self.db.log_request(log)
|
| 1047 |
+
except Exception as e:
|
| 1048 |
+
# Don't fail the request if logging fails
|
| 1049 |
+
print(f"Failed to log request: {e}")
|
| 1050 |
+
|
| 1051 |
+
# ==================== Character Creation and Remix Handlers ====================
|
| 1052 |
+
|
| 1053 |
+
async def _handle_character_creation_only(self, video_data, model_config: Dict) -> AsyncGenerator[str, None]:
|
| 1054 |
+
"""Handle character creation only (no video generation)
|
| 1055 |
+
|
| 1056 |
+
Flow:
|
| 1057 |
+
1. Download video if URL, or use bytes directly
|
| 1058 |
+
2. Upload video to create character
|
| 1059 |
+
3. Poll for character processing
|
| 1060 |
+
4. Download and cache avatar
|
| 1061 |
+
5. Upload avatar
|
| 1062 |
+
6. Finalize character
|
| 1063 |
+
7. Set character as public
|
| 1064 |
+
8. Return success message
|
| 1065 |
+
"""
|
| 1066 |
+
token_obj = await self.load_balancer.select_token(for_video_generation=True)
|
| 1067 |
+
if not token_obj:
|
| 1068 |
+
raise Exception("No available tokens for character creation")
|
| 1069 |
+
|
| 1070 |
+
try:
|
| 1071 |
+
yield self._format_stream_chunk(
|
| 1072 |
+
reasoning_content="**Character Creation Begins**\n\nInitializing character creation...\n",
|
| 1073 |
+
is_first=True
|
| 1074 |
+
)
|
| 1075 |
+
|
| 1076 |
+
# Handle video URL or bytes
|
| 1077 |
+
if isinstance(video_data, str):
|
| 1078 |
+
# It's a URL, download it
|
| 1079 |
+
yield self._format_stream_chunk(
|
| 1080 |
+
reasoning_content="Downloading video file...\n"
|
| 1081 |
+
)
|
| 1082 |
+
video_bytes = await self._download_file(video_data)
|
| 1083 |
+
else:
|
| 1084 |
+
video_bytes = video_data
|
| 1085 |
+
|
| 1086 |
+
# Step 1: Upload video
|
| 1087 |
+
yield self._format_stream_chunk(
|
| 1088 |
+
reasoning_content="Uploading video file...\n"
|
| 1089 |
+
)
|
| 1090 |
+
cameo_id = await self.sora_client.upload_character_video(video_bytes, token_obj.token)
|
| 1091 |
+
debug_logger.log_info(f"Video uploaded, cameo_id: {cameo_id}")
|
| 1092 |
+
|
| 1093 |
+
# Step 2: Poll for character processing
|
| 1094 |
+
yield self._format_stream_chunk(
|
| 1095 |
+
reasoning_content="Processing video to extract character...\n"
|
| 1096 |
+
)
|
| 1097 |
+
cameo_status = await self._poll_cameo_status(cameo_id, token_obj.token)
|
| 1098 |
+
debug_logger.log_info(f"Cameo status: {cameo_status}")
|
| 1099 |
+
|
| 1100 |
+
# Extract character info immediately after polling completes
|
| 1101 |
+
username_hint = cameo_status.get("username_hint", "character")
|
| 1102 |
+
display_name = cameo_status.get("display_name_hint", "Character")
|
| 1103 |
+
|
| 1104 |
+
# Process username: remove prefix and add 3 random digits
|
| 1105 |
+
username = self._process_character_username(username_hint)
|
| 1106 |
+
|
| 1107 |
+
# Output character name immediately
|
| 1108 |
+
yield self._format_stream_chunk(
|
| 1109 |
+
reasoning_content=f"✨ 角色已识别: {display_name} (@{username})\n"
|
| 1110 |
+
)
|
| 1111 |
+
|
| 1112 |
+
# Step 3: Download and cache avatar
|
| 1113 |
+
yield self._format_stream_chunk(
|
| 1114 |
+
reasoning_content="Downloading character avatar...\n"
|
| 1115 |
+
)
|
| 1116 |
+
profile_asset_url = cameo_status.get("profile_asset_url")
|
| 1117 |
+
if not profile_asset_url:
|
| 1118 |
+
raise Exception("Profile asset URL not found in cameo status")
|
| 1119 |
+
|
| 1120 |
+
avatar_data = await self.sora_client.download_character_image(profile_asset_url)
|
| 1121 |
+
debug_logger.log_info(f"Avatar downloaded, size: {len(avatar_data)} bytes")
|
| 1122 |
+
|
| 1123 |
+
# Step 4: Upload avatar
|
| 1124 |
+
yield self._format_stream_chunk(
|
| 1125 |
+
reasoning_content="Uploading character avatar...\n"
|
| 1126 |
+
)
|
| 1127 |
+
asset_pointer = await self.sora_client.upload_character_image(avatar_data, token_obj.token)
|
| 1128 |
+
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
|
| 1129 |
+
|
| 1130 |
+
# Step 5: Finalize character
|
| 1131 |
+
yield self._format_stream_chunk(
|
| 1132 |
+
reasoning_content="Finalizing character creation...\n"
|
| 1133 |
+
)
|
| 1134 |
+
# instruction_set_hint is a string, but instruction_set in cameo_status might be an array
|
| 1135 |
+
instruction_set = cameo_status.get("instruction_set_hint") or cameo_status.get("instruction_set")
|
| 1136 |
+
|
| 1137 |
+
character_id = await self.sora_client.finalize_character(
|
| 1138 |
+
cameo_id=cameo_id,
|
| 1139 |
+
username=username,
|
| 1140 |
+
display_name=display_name,
|
| 1141 |
+
profile_asset_pointer=asset_pointer,
|
| 1142 |
+
instruction_set=instruction_set,
|
| 1143 |
+
token=token_obj.token
|
| 1144 |
+
)
|
| 1145 |
+
debug_logger.log_info(f"Character finalized, character_id: {character_id}")
|
| 1146 |
+
|
| 1147 |
+
# Step 6: Set character as public
|
| 1148 |
+
yield self._format_stream_chunk(
|
| 1149 |
+
reasoning_content="Setting character as public...\n"
|
| 1150 |
+
)
|
| 1151 |
+
await self.sora_client.set_character_public(cameo_id, token_obj.token)
|
| 1152 |
+
debug_logger.log_info(f"Character set as public")
|
| 1153 |
+
|
| 1154 |
+
# Step 7: Return success message
|
| 1155 |
+
yield self._format_stream_chunk(
|
| 1156 |
+
content=f"角色创建成功,角色名@{username}",
|
| 1157 |
+
finish_reason="STOP"
|
| 1158 |
+
)
|
| 1159 |
+
yield "data: [DONE]\n\n"
|
| 1160 |
+
|
| 1161 |
+
except Exception as e:
|
| 1162 |
+
debug_logger.log_error(
|
| 1163 |
+
error_message=f"Character creation failed: {str(e)}",
|
| 1164 |
+
status_code=500,
|
| 1165 |
+
response_text=str(e)
|
| 1166 |
+
)
|
| 1167 |
+
raise
|
| 1168 |
+
|
| 1169 |
+
async def _handle_character_and_video_generation(self, video_data, prompt: str, model_config: Dict) -> AsyncGenerator[str, None]:
|
| 1170 |
+
"""Handle character creation and video generation
|
| 1171 |
+
|
| 1172 |
+
Flow:
|
| 1173 |
+
1. Download video if URL, or use bytes directly
|
| 1174 |
+
2. Upload video to create character
|
| 1175 |
+
3. Poll for character processing
|
| 1176 |
+
4. Download and cache avatar
|
| 1177 |
+
5. Upload avatar
|
| 1178 |
+
6. Finalize character
|
| 1179 |
+
7. Generate video with character (@username + prompt)
|
| 1180 |
+
8. Delete character
|
| 1181 |
+
9. Return video result
|
| 1182 |
+
"""
|
| 1183 |
+
token_obj = await self.load_balancer.select_token(for_video_generation=True)
|
| 1184 |
+
if not token_obj:
|
| 1185 |
+
raise Exception("No available tokens for video generation")
|
| 1186 |
+
|
| 1187 |
+
character_id = None
|
| 1188 |
+
try:
|
| 1189 |
+
yield self._format_stream_chunk(
|
| 1190 |
+
reasoning_content="**Character Creation and Video Generation Begins**\n\nInitializing...\n",
|
| 1191 |
+
is_first=True
|
| 1192 |
+
)
|
| 1193 |
+
|
| 1194 |
+
# Handle video URL or bytes
|
| 1195 |
+
if isinstance(video_data, str):
|
| 1196 |
+
# It's a URL, download it
|
| 1197 |
+
yield self._format_stream_chunk(
|
| 1198 |
+
reasoning_content="Downloading video file...\n"
|
| 1199 |
+
)
|
| 1200 |
+
video_bytes = await self._download_file(video_data)
|
| 1201 |
+
else:
|
| 1202 |
+
video_bytes = video_data
|
| 1203 |
+
|
| 1204 |
+
# Step 1: Upload video
|
| 1205 |
+
yield self._format_stream_chunk(
|
| 1206 |
+
reasoning_content="Uploading video file...\n"
|
| 1207 |
+
)
|
| 1208 |
+
cameo_id = await self.sora_client.upload_character_video(video_bytes, token_obj.token)
|
| 1209 |
+
debug_logger.log_info(f"Video uploaded, cameo_id: {cameo_id}")
|
| 1210 |
+
|
| 1211 |
+
# Step 2: Poll for character processing
|
| 1212 |
+
yield self._format_stream_chunk(
|
| 1213 |
+
reasoning_content="Processing video to extract character...\n"
|
| 1214 |
+
)
|
| 1215 |
+
cameo_status = await self._poll_cameo_status(cameo_id, token_obj.token)
|
| 1216 |
+
debug_logger.log_info(f"Cameo status: {cameo_status}")
|
| 1217 |
+
|
| 1218 |
+
# Extract character info immediately after polling completes
|
| 1219 |
+
username_hint = cameo_status.get("username_hint", "character")
|
| 1220 |
+
display_name = cameo_status.get("display_name_hint", "Character")
|
| 1221 |
+
|
| 1222 |
+
# Process username: remove prefix and add 3 random digits
|
| 1223 |
+
username = self._process_character_username(username_hint)
|
| 1224 |
+
|
| 1225 |
+
# Output character name immediately
|
| 1226 |
+
yield self._format_stream_chunk(
|
| 1227 |
+
reasoning_content=f"✨ 角色已识别: {display_name} (@{username})\n"
|
| 1228 |
+
)
|
| 1229 |
+
|
| 1230 |
+
# Step 3: Download and cache avatar
|
| 1231 |
+
yield self._format_stream_chunk(
|
| 1232 |
+
reasoning_content="Downloading character avatar...\n"
|
| 1233 |
+
)
|
| 1234 |
+
profile_asset_url = cameo_status.get("profile_asset_url")
|
| 1235 |
+
if not profile_asset_url:
|
| 1236 |
+
raise Exception("Profile asset URL not found in cameo status")
|
| 1237 |
+
|
| 1238 |
+
avatar_data = await self.sora_client.download_character_image(profile_asset_url)
|
| 1239 |
+
debug_logger.log_info(f"Avatar downloaded, size: {len(avatar_data)} bytes")
|
| 1240 |
+
|
| 1241 |
+
# Step 4: Upload avatar
|
| 1242 |
+
yield self._format_stream_chunk(
|
| 1243 |
+
reasoning_content="Uploading character avatar...\n"
|
| 1244 |
+
)
|
| 1245 |
+
asset_pointer = await self.sora_client.upload_character_image(avatar_data, token_obj.token)
|
| 1246 |
+
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
|
| 1247 |
+
|
| 1248 |
+
# Step 5: Finalize character
|
| 1249 |
+
yield self._format_stream_chunk(
|
| 1250 |
+
reasoning_content="Finalizing character creation...\n"
|
| 1251 |
+
)
|
| 1252 |
+
# instruction_set_hint is a string, but instruction_set in cameo_status might be an array
|
| 1253 |
+
instruction_set = cameo_status.get("instruction_set_hint") or cameo_status.get("instruction_set")
|
| 1254 |
+
|
| 1255 |
+
character_id = await self.sora_client.finalize_character(
|
| 1256 |
+
cameo_id=cameo_id,
|
| 1257 |
+
username=username,
|
| 1258 |
+
display_name=display_name,
|
| 1259 |
+
profile_asset_pointer=asset_pointer,
|
| 1260 |
+
instruction_set=instruction_set,
|
| 1261 |
+
token=token_obj.token
|
| 1262 |
+
)
|
| 1263 |
+
debug_logger.log_info(f"Character finalized, character_id: {character_id}")
|
| 1264 |
+
|
| 1265 |
+
# Step 6: Generate video with character
|
| 1266 |
+
yield self._format_stream_chunk(
|
| 1267 |
+
reasoning_content="**Video Generation Process Begins**\n\nGenerating video with character...\n"
|
| 1268 |
+
)
|
| 1269 |
+
|
| 1270 |
+
# Prepend @username to prompt
|
| 1271 |
+
full_prompt = f"@{username} {prompt}"
|
| 1272 |
+
debug_logger.log_info(f"Full prompt: {full_prompt}")
|
| 1273 |
+
|
| 1274 |
+
# Get n_frames from model configuration
|
| 1275 |
+
n_frames = model_config.get("n_frames", 300) # Default to 300 frames (10s)
|
| 1276 |
+
|
| 1277 |
+
task_id = await self.sora_client.generate_video(
|
| 1278 |
+
full_prompt, token_obj.token,
|
| 1279 |
+
orientation=model_config["orientation"],
|
| 1280 |
+
n_frames=n_frames
|
| 1281 |
+
)
|
| 1282 |
+
debug_logger.log_info(f"Video generation started, task_id: {task_id}")
|
| 1283 |
+
|
| 1284 |
+
# Save task to database
|
| 1285 |
+
task = Task(
|
| 1286 |
+
task_id=task_id,
|
| 1287 |
+
token_id=token_obj.id,
|
| 1288 |
+
model=f"sora-video-{model_config['orientation']}",
|
| 1289 |
+
prompt=full_prompt,
|
| 1290 |
+
status="processing",
|
| 1291 |
+
progress=0.0
|
| 1292 |
+
)
|
| 1293 |
+
await self.db.create_task(task)
|
| 1294 |
+
|
| 1295 |
+
# Record usage
|
| 1296 |
+
await self.token_manager.record_usage(token_obj.id, is_video=True)
|
| 1297 |
+
|
| 1298 |
+
# Poll for results
|
| 1299 |
+
async for chunk in self._poll_task_result(task_id, token_obj.token, True, True, full_prompt, token_obj.id):
|
| 1300 |
+
yield chunk
|
| 1301 |
+
|
| 1302 |
+
# Record success
|
| 1303 |
+
await self.token_manager.record_success(token_obj.id, is_video=True)
|
| 1304 |
+
|
| 1305 |
+
except Exception as e:
|
| 1306 |
+
# Record error
|
| 1307 |
+
if token_obj:
|
| 1308 |
+
await self.token_manager.record_error(token_obj.id)
|
| 1309 |
+
debug_logger.log_error(
|
| 1310 |
+
error_message=f"Character and video generation failed: {str(e)}",
|
| 1311 |
+
status_code=500,
|
| 1312 |
+
response_text=str(e)
|
| 1313 |
+
)
|
| 1314 |
+
raise
|
| 1315 |
+
finally:
|
| 1316 |
+
# Step 7: Delete character
|
| 1317 |
+
if character_id:
|
| 1318 |
+
try:
|
| 1319 |
+
yield self._format_stream_chunk(
|
| 1320 |
+
reasoning_content="Cleaning up temporary character...\n"
|
| 1321 |
+
)
|
| 1322 |
+
await self.sora_client.delete_character(character_id, token_obj.token)
|
| 1323 |
+
debug_logger.log_info(f"Character deleted: {character_id}")
|
| 1324 |
+
except Exception as e:
|
| 1325 |
+
debug_logger.log_error(
|
| 1326 |
+
error_message=f"Failed to delete character: {str(e)}",
|
| 1327 |
+
status_code=500,
|
| 1328 |
+
response_text=str(e)
|
| 1329 |
+
)
|
| 1330 |
+
|
| 1331 |
+
async def _handle_remix(self, remix_target_id: str, prompt: str, model_config: Dict) -> AsyncGenerator[str, None]:
|
| 1332 |
+
"""Handle remix video generation
|
| 1333 |
+
|
| 1334 |
+
Flow:
|
| 1335 |
+
1. Select token
|
| 1336 |
+
2. Clean remix link from prompt
|
| 1337 |
+
3. Call remix API
|
| 1338 |
+
4. Poll for results
|
| 1339 |
+
5. Return video result
|
| 1340 |
+
"""
|
| 1341 |
+
token_obj = await self.load_balancer.select_token(for_video_generation=True)
|
| 1342 |
+
if not token_obj:
|
| 1343 |
+
raise Exception("No available tokens for remix generation")
|
| 1344 |
+
|
| 1345 |
+
task_id = None
|
| 1346 |
+
try:
|
| 1347 |
+
yield self._format_stream_chunk(
|
| 1348 |
+
reasoning_content="**Remix Generation Process Begins**\n\nInitializing remix request...\n",
|
| 1349 |
+
is_first=True
|
| 1350 |
+
)
|
| 1351 |
+
|
| 1352 |
+
# Clean remix link from prompt to avoid duplication
|
| 1353 |
+
clean_prompt = self._clean_remix_link_from_prompt(prompt)
|
| 1354 |
+
|
| 1355 |
+
# Get n_frames from model configuration
|
| 1356 |
+
n_frames = model_config.get("n_frames", 300) # Default to 300 frames (10s)
|
| 1357 |
+
|
| 1358 |
+
# Call remix API
|
| 1359 |
+
yield self._format_stream_chunk(
|
| 1360 |
+
reasoning_content="Sending remix request to server...\n"
|
| 1361 |
+
)
|
| 1362 |
+
task_id = await self.sora_client.remix_video(
|
| 1363 |
+
remix_target_id=remix_target_id,
|
| 1364 |
+
prompt=clean_prompt,
|
| 1365 |
+
token=token_obj.token,
|
| 1366 |
+
orientation=model_config["orientation"],
|
| 1367 |
+
n_frames=n_frames
|
| 1368 |
+
)
|
| 1369 |
+
debug_logger.log_info(f"Remix generation started, task_id: {task_id}")
|
| 1370 |
+
|
| 1371 |
+
# Save task to database
|
| 1372 |
+
task = Task(
|
| 1373 |
+
task_id=task_id,
|
| 1374 |
+
token_id=token_obj.id,
|
| 1375 |
+
model=f"sora-video-{model_config['orientation']}",
|
| 1376 |
+
prompt=f"remix:{remix_target_id} {clean_prompt}",
|
| 1377 |
+
status="processing",
|
| 1378 |
+
progress=0.0
|
| 1379 |
+
)
|
| 1380 |
+
await self.db.create_task(task)
|
| 1381 |
+
|
| 1382 |
+
# Record usage
|
| 1383 |
+
await self.token_manager.record_usage(token_obj.id, is_video=True)
|
| 1384 |
+
|
| 1385 |
+
# Poll for results
|
| 1386 |
+
async for chunk in self._poll_task_result(task_id, token_obj.token, True, True, clean_prompt, token_obj.id):
|
| 1387 |
+
yield chunk
|
| 1388 |
+
|
| 1389 |
+
# Record success
|
| 1390 |
+
await self.token_manager.record_success(token_obj.id, is_video=True)
|
| 1391 |
+
|
| 1392 |
+
except Exception as e:
|
| 1393 |
+
# Record error
|
| 1394 |
+
if token_obj:
|
| 1395 |
+
await self.token_manager.record_error(token_obj.id)
|
| 1396 |
+
debug_logger.log_error(
|
| 1397 |
+
error_message=f"Remix generation failed: {str(e)}",
|
| 1398 |
+
status_code=500,
|
| 1399 |
+
response_text=str(e)
|
| 1400 |
+
)
|
| 1401 |
+
raise
|
| 1402 |
+
|
| 1403 |
+
async def _poll_cameo_status(self, cameo_id: str, token: str, timeout: int = 600, poll_interval: int = 5) -> Dict[str, Any]:
|
| 1404 |
+
"""Poll for cameo (character) processing status
|
| 1405 |
+
|
| 1406 |
+
Args:
|
| 1407 |
+
cameo_id: The cameo ID
|
| 1408 |
+
token: Access token
|
| 1409 |
+
timeout: Maximum time to wait in seconds
|
| 1410 |
+
poll_interval: Time between polls in seconds
|
| 1411 |
+
|
| 1412 |
+
Returns:
|
| 1413 |
+
Cameo status dictionary with display_name_hint, username_hint, profile_asset_url, instruction_set_hint
|
| 1414 |
+
"""
|
| 1415 |
+
start_time = time.time()
|
| 1416 |
+
max_attempts = int(timeout / poll_interval)
|
| 1417 |
+
consecutive_errors = 0
|
| 1418 |
+
max_consecutive_errors = 3 # Allow up to 3 consecutive errors before failing
|
| 1419 |
+
|
| 1420 |
+
for attempt in range(max_attempts):
|
| 1421 |
+
elapsed_time = time.time() - start_time
|
| 1422 |
+
if elapsed_time > timeout:
|
| 1423 |
+
raise Exception(f"Cameo processing timeout after {elapsed_time:.1f} seconds")
|
| 1424 |
+
|
| 1425 |
+
await asyncio.sleep(poll_interval)
|
| 1426 |
+
|
| 1427 |
+
try:
|
| 1428 |
+
status = await self.sora_client.get_cameo_status(cameo_id, token)
|
| 1429 |
+
current_status = status.get("status")
|
| 1430 |
+
status_message = status.get("status_message", "")
|
| 1431 |
+
|
| 1432 |
+
# Reset error counter on successful request
|
| 1433 |
+
consecutive_errors = 0
|
| 1434 |
+
|
| 1435 |
+
debug_logger.log_info(f"Cameo status: {current_status} (message: {status_message}) (attempt {attempt + 1}/{max_attempts})")
|
| 1436 |
+
|
| 1437 |
+
# Check if processing is complete
|
| 1438 |
+
# Primary condition: status_message == "Completed" means processing is done
|
| 1439 |
+
if status_message == "Completed":
|
| 1440 |
+
debug_logger.log_info(f"Cameo processing completed (status: {current_status}, message: {status_message})")
|
| 1441 |
+
return status
|
| 1442 |
+
|
| 1443 |
+
# Fallback condition: finalized status
|
| 1444 |
+
if current_status == "finalized":
|
| 1445 |
+
debug_logger.log_info(f"Cameo processing completed (status: {current_status}, message: {status_message})")
|
| 1446 |
+
return status
|
| 1447 |
+
|
| 1448 |
+
except Exception as e:
|
| 1449 |
+
consecutive_errors += 1
|
| 1450 |
+
error_msg = str(e)
|
| 1451 |
+
|
| 1452 |
+
# Log error with context
|
| 1453 |
+
debug_logger.log_error(
|
| 1454 |
+
error_message=f"Failed to get cameo status (attempt {attempt + 1}/{max_attempts}, consecutive errors: {consecutive_errors}): {error_msg}",
|
| 1455 |
+
status_code=500,
|
| 1456 |
+
response_text=error_msg
|
| 1457 |
+
)
|
| 1458 |
+
|
| 1459 |
+
# Check if it's a TLS/connection error
|
| 1460 |
+
is_tls_error = "TLS" in error_msg or "curl" in error_msg or "OPENSSL" in error_msg
|
| 1461 |
+
|
| 1462 |
+
if is_tls_error:
|
| 1463 |
+
# For TLS errors, use exponential backoff
|
| 1464 |
+
backoff_time = min(poll_interval * (2 ** (consecutive_errors - 1)), 30)
|
| 1465 |
+
debug_logger.log_info(f"TLS error detected, using exponential backoff: {backoff_time}s")
|
| 1466 |
+
await asyncio.sleep(backoff_time)
|
| 1467 |
+
|
| 1468 |
+
# Fail if too many consecutive errors
|
| 1469 |
+
if consecutive_errors >= max_consecutive_errors:
|
| 1470 |
+
raise Exception(f"Too many consecutive errors ({consecutive_errors}) while polling cameo status: {error_msg}")
|
| 1471 |
+
|
| 1472 |
+
# Continue polling on error
|
| 1473 |
+
continue
|
| 1474 |
+
|
| 1475 |
+
raise Exception(f"Cameo processing timeout after {timeout} seconds")
|
src/services/load_balancer.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load balancing module"""
|
| 2 |
+
import random
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from ..core.models import Token
|
| 5 |
+
from ..core.config import config
|
| 6 |
+
from .token_manager import TokenManager
|
| 7 |
+
from .token_lock import TokenLock
|
| 8 |
+
|
| 9 |
+
class LoadBalancer:
|
| 10 |
+
"""Token load balancer with random selection and image generation lock"""
|
| 11 |
+
|
| 12 |
+
def __init__(self, token_manager: TokenManager):
|
| 13 |
+
self.token_manager = token_manager
|
| 14 |
+
# Use image timeout from config as lock timeout
|
| 15 |
+
self.token_lock = TokenLock(lock_timeout=config.image_timeout)
|
| 16 |
+
|
| 17 |
+
async def select_token(self, for_image_generation: bool = False, for_video_generation: bool = False) -> Optional[Token]:
|
| 18 |
+
"""
|
| 19 |
+
Select a token using random load balancing
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
for_image_generation: If True, only select tokens that are not locked for image generation and have image_enabled=True
|
| 23 |
+
for_video_generation: If True, filter out tokens with Sora2 quota exhausted (sora2_cooldown_until not expired), tokens that don't support Sora2, and tokens with video_enabled=False
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
Selected token or None if no available tokens
|
| 27 |
+
"""
|
| 28 |
+
# Try to auto-refresh tokens expiring within 24 hours if enabled
|
| 29 |
+
if config.at_auto_refresh_enabled:
|
| 30 |
+
all_tokens = await self.token_manager.get_all_tokens()
|
| 31 |
+
for token in all_tokens:
|
| 32 |
+
if token.is_active and token.expiry_time:
|
| 33 |
+
from datetime import datetime
|
| 34 |
+
time_until_expiry = token.expiry_time - datetime.now()
|
| 35 |
+
hours_until_expiry = time_until_expiry.total_seconds() / 3600
|
| 36 |
+
# Refresh if expiry is within 24 hours
|
| 37 |
+
if hours_until_expiry <= 24:
|
| 38 |
+
await self.token_manager.auto_refresh_expiring_token(token.id)
|
| 39 |
+
|
| 40 |
+
active_tokens = await self.token_manager.get_active_tokens()
|
| 41 |
+
|
| 42 |
+
if not active_tokens:
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
# If for video generation, filter out tokens with Sora2 quota exhausted and tokens without Sora2 support
|
| 46 |
+
if for_video_generation:
|
| 47 |
+
from datetime import datetime
|
| 48 |
+
available_tokens = []
|
| 49 |
+
for token in active_tokens:
|
| 50 |
+
# Skip tokens that don't have video enabled
|
| 51 |
+
if not token.video_enabled:
|
| 52 |
+
continue
|
| 53 |
+
|
| 54 |
+
# Skip tokens that don't support Sora2
|
| 55 |
+
if not token.sora2_supported:
|
| 56 |
+
continue
|
| 57 |
+
|
| 58 |
+
# Check if Sora2 cooldown has expired and refresh if needed
|
| 59 |
+
if token.sora2_cooldown_until and token.sora2_cooldown_until <= datetime.now():
|
| 60 |
+
await self.token_manager.refresh_sora2_remaining_if_cooldown_expired(token.id)
|
| 61 |
+
# Reload token data after refresh
|
| 62 |
+
token = await self.token_manager.db.get_token(token.id)
|
| 63 |
+
|
| 64 |
+
# Skip tokens that are in Sora2 cooldown (quota exhausted)
|
| 65 |
+
if token and token.sora2_cooldown_until and token.sora2_cooldown_until > datetime.now():
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
if token:
|
| 69 |
+
available_tokens.append(token)
|
| 70 |
+
|
| 71 |
+
if not available_tokens:
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
active_tokens = available_tokens
|
| 75 |
+
|
| 76 |
+
# If for image generation, filter out locked tokens and tokens without image enabled
|
| 77 |
+
if for_image_generation:
|
| 78 |
+
available_tokens = []
|
| 79 |
+
for token in active_tokens:
|
| 80 |
+
# Skip tokens that don't have image enabled
|
| 81 |
+
if not token.image_enabled:
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
if not await self.token_lock.is_locked(token.id):
|
| 85 |
+
available_tokens.append(token)
|
| 86 |
+
|
| 87 |
+
if not available_tokens:
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
# Random selection from available tokens
|
| 91 |
+
return random.choice(available_tokens)
|
| 92 |
+
else:
|
| 93 |
+
# For video generation, no lock needed
|
| 94 |
+
return random.choice(active_tokens)
|
src/services/proxy_manager.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Proxy management module"""
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from ..core.database import Database
|
| 4 |
+
from ..core.models import ProxyConfig
|
| 5 |
+
|
| 6 |
+
class ProxyManager:
|
| 7 |
+
"""Proxy configuration manager"""
|
| 8 |
+
|
| 9 |
+
def __init__(self, db: Database):
|
| 10 |
+
self.db = db
|
| 11 |
+
|
| 12 |
+
async def get_proxy_url(self) -> Optional[str]:
|
| 13 |
+
"""Get proxy URL if enabled, otherwise return None"""
|
| 14 |
+
config = await self.db.get_proxy_config()
|
| 15 |
+
if config.proxy_enabled and config.proxy_url:
|
| 16 |
+
return config.proxy_url
|
| 17 |
+
return None
|
| 18 |
+
|
| 19 |
+
async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str]):
|
| 20 |
+
"""Update proxy configuration"""
|
| 21 |
+
await self.db.update_proxy_config(enabled, proxy_url)
|
| 22 |
+
|
| 23 |
+
async def get_proxy_config(self) -> ProxyConfig:
|
| 24 |
+
"""Get proxy configuration"""
|
| 25 |
+
return await self.db.get_proxy_config()
|
src/services/sora_client.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sora API client module"""
|
| 2 |
+
import base64
|
| 3 |
+
import io
|
| 4 |
+
import time
|
| 5 |
+
import random
|
| 6 |
+
import string
|
| 7 |
+
from typing import Optional, Dict, Any
|
| 8 |
+
from curl_cffi.requests import AsyncSession
|
| 9 |
+
from curl_cffi import CurlMime
|
| 10 |
+
from .proxy_manager import ProxyManager
|
| 11 |
+
from ..core.config import config
|
| 12 |
+
from ..core.logger import debug_logger
|
| 13 |
+
|
| 14 |
+
class SoraClient:
|
| 15 |
+
"""Sora API client with proxy support"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, proxy_manager: ProxyManager):
|
| 18 |
+
self.proxy_manager = proxy_manager
|
| 19 |
+
self.base_url = config.sora_base_url
|
| 20 |
+
self.timeout = config.sora_timeout
|
| 21 |
+
|
| 22 |
+
@staticmethod
|
| 23 |
+
def _generate_sentinel_token() -> str:
|
| 24 |
+
"""
|
| 25 |
+
生成 openai-sentinel-token
|
| 26 |
+
根据测试文件的逻辑,传入任意随机字符即可
|
| 27 |
+
生成10-20个字符的随机字符串(字母+数字)
|
| 28 |
+
"""
|
| 29 |
+
length = random.randint(10, 20)
|
| 30 |
+
random_str = ''.join(random.choices(string.ascii_letters + string.digits, k=length))
|
| 31 |
+
return random_str
|
| 32 |
+
|
| 33 |
+
async def _make_request(self, method: str, endpoint: str, token: str,
|
| 34 |
+
json_data: Optional[Dict] = None,
|
| 35 |
+
multipart: Optional[Dict] = None,
|
| 36 |
+
add_sentinel_token: bool = False) -> Dict[str, Any]:
|
| 37 |
+
"""Make HTTP request with proxy support
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
method: HTTP method (GET/POST)
|
| 41 |
+
endpoint: API endpoint
|
| 42 |
+
token: Access token
|
| 43 |
+
json_data: JSON request body
|
| 44 |
+
multipart: Multipart form data (for file uploads)
|
| 45 |
+
add_sentinel_token: Whether to add openai-sentinel-token header (only for generation requests)
|
| 46 |
+
"""
|
| 47 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 48 |
+
|
| 49 |
+
headers = {
|
| 50 |
+
"Authorization": f"Bearer {token}"
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# 只在生成请求时添加 sentinel token
|
| 54 |
+
if add_sentinel_token:
|
| 55 |
+
headers["openai-sentinel-token"] = self._generate_sentinel_token()
|
| 56 |
+
|
| 57 |
+
if not multipart:
|
| 58 |
+
headers["Content-Type"] = "application/json"
|
| 59 |
+
|
| 60 |
+
async with AsyncSession() as session:
|
| 61 |
+
url = f"{self.base_url}{endpoint}"
|
| 62 |
+
|
| 63 |
+
kwargs = {
|
| 64 |
+
"headers": headers,
|
| 65 |
+
"timeout": self.timeout,
|
| 66 |
+
"impersonate": "chrome" # 自动生成 User-Agent 和浏览器指纹
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if proxy_url:
|
| 70 |
+
kwargs["proxy"] = proxy_url
|
| 71 |
+
|
| 72 |
+
if json_data:
|
| 73 |
+
kwargs["json"] = json_data
|
| 74 |
+
|
| 75 |
+
if multipart:
|
| 76 |
+
kwargs["multipart"] = multipart
|
| 77 |
+
|
| 78 |
+
# Log request
|
| 79 |
+
debug_logger.log_request(
|
| 80 |
+
method=method,
|
| 81 |
+
url=url,
|
| 82 |
+
headers=headers,
|
| 83 |
+
body=json_data,
|
| 84 |
+
files=multipart,
|
| 85 |
+
proxy=proxy_url
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Record start time
|
| 89 |
+
start_time = time.time()
|
| 90 |
+
|
| 91 |
+
# Make request
|
| 92 |
+
if method == "GET":
|
| 93 |
+
response = await session.get(url, **kwargs)
|
| 94 |
+
elif method == "POST":
|
| 95 |
+
response = await session.post(url, **kwargs)
|
| 96 |
+
else:
|
| 97 |
+
raise ValueError(f"Unsupported method: {method}")
|
| 98 |
+
|
| 99 |
+
# Calculate duration
|
| 100 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 101 |
+
|
| 102 |
+
# Parse response
|
| 103 |
+
try:
|
| 104 |
+
response_json = response.json()
|
| 105 |
+
except:
|
| 106 |
+
response_json = None
|
| 107 |
+
|
| 108 |
+
# Log response
|
| 109 |
+
debug_logger.log_response(
|
| 110 |
+
status_code=response.status_code,
|
| 111 |
+
headers=dict(response.headers),
|
| 112 |
+
body=response_json if response_json else response.text,
|
| 113 |
+
duration_ms=duration_ms
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# Check status
|
| 117 |
+
if response.status_code not in [200, 201]:
|
| 118 |
+
error_msg = f"API request failed: {response.status_code} - {response.text}"
|
| 119 |
+
debug_logger.log_error(
|
| 120 |
+
error_message=error_msg,
|
| 121 |
+
status_code=response.status_code,
|
| 122 |
+
response_text=response.text
|
| 123 |
+
)
|
| 124 |
+
raise Exception(error_msg)
|
| 125 |
+
|
| 126 |
+
return response_json if response_json else response.json()
|
| 127 |
+
|
| 128 |
+
async def get_user_info(self, token: str) -> Dict[str, Any]:
|
| 129 |
+
"""Get user information"""
|
| 130 |
+
return await self._make_request("GET", "/me", token)
|
| 131 |
+
|
| 132 |
+
async def upload_image(self, image_data: bytes, token: str, filename: str = "image.png") -> str:
|
| 133 |
+
"""Upload image and return media_id
|
| 134 |
+
|
| 135 |
+
使用 CurlMime 对象上传文件(curl_cffi 的正确方式)
|
| 136 |
+
参考:https://curl-cffi.readthedocs.io/en/latest/quick_start.html#uploads
|
| 137 |
+
"""
|
| 138 |
+
# 检测图片类型
|
| 139 |
+
mime_type = "image/png"
|
| 140 |
+
if filename.lower().endswith('.jpg') or filename.lower().endswith('.jpeg'):
|
| 141 |
+
mime_type = "image/jpeg"
|
| 142 |
+
elif filename.lower().endswith('.webp'):
|
| 143 |
+
mime_type = "image/webp"
|
| 144 |
+
|
| 145 |
+
# 创建 CurlMime 对象
|
| 146 |
+
mp = CurlMime()
|
| 147 |
+
|
| 148 |
+
# 添加文件部分
|
| 149 |
+
mp.addpart(
|
| 150 |
+
name="file",
|
| 151 |
+
content_type=mime_type,
|
| 152 |
+
filename=filename,
|
| 153 |
+
data=image_data
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# 添加文件名字段
|
| 157 |
+
mp.addpart(
|
| 158 |
+
name="file_name",
|
| 159 |
+
data=filename.encode('utf-8')
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
result = await self._make_request("POST", "/uploads", token, multipart=mp)
|
| 163 |
+
return result["id"]
|
| 164 |
+
|
| 165 |
+
async def generate_image(self, prompt: str, token: str, width: int = 360,
|
| 166 |
+
height: int = 360, media_id: Optional[str] = None) -> str:
|
| 167 |
+
"""Generate image (text-to-image or image-to-image)"""
|
| 168 |
+
operation = "remix" if media_id else "simple_compose"
|
| 169 |
+
|
| 170 |
+
inpaint_items = []
|
| 171 |
+
if media_id:
|
| 172 |
+
inpaint_items = [{
|
| 173 |
+
"type": "image",
|
| 174 |
+
"frame_index": 0,
|
| 175 |
+
"upload_media_id": media_id
|
| 176 |
+
}]
|
| 177 |
+
|
| 178 |
+
json_data = {
|
| 179 |
+
"type": "image_gen",
|
| 180 |
+
"operation": operation,
|
| 181 |
+
"prompt": prompt,
|
| 182 |
+
"width": width,
|
| 183 |
+
"height": height,
|
| 184 |
+
"n_variants": 1,
|
| 185 |
+
"n_frames": 1,
|
| 186 |
+
"inpaint_items": inpaint_items
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
# 生成请求需要添加 sentinel token
|
| 190 |
+
result = await self._make_request("POST", "/video_gen", token, json_data=json_data, add_sentinel_token=True)
|
| 191 |
+
return result["id"]
|
| 192 |
+
|
| 193 |
+
async def generate_video(self, prompt: str, token: str, orientation: str = "landscape",
|
| 194 |
+
media_id: Optional[str] = None, n_frames: int = 450) -> str:
|
| 195 |
+
"""Generate video (text-to-video or image-to-video)"""
|
| 196 |
+
inpaint_items = []
|
| 197 |
+
if media_id:
|
| 198 |
+
inpaint_items = [{
|
| 199 |
+
"kind": "upload",
|
| 200 |
+
"upload_id": media_id
|
| 201 |
+
}]
|
| 202 |
+
|
| 203 |
+
json_data = {
|
| 204 |
+
"kind": "video",
|
| 205 |
+
"prompt": prompt,
|
| 206 |
+
"orientation": orientation,
|
| 207 |
+
"size": "small",
|
| 208 |
+
"n_frames": n_frames,
|
| 209 |
+
"model": "sy_8",
|
| 210 |
+
"inpaint_items": inpaint_items
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
# 生成请求需要添加 sentinel token
|
| 214 |
+
result = await self._make_request("POST", "/nf/create", token, json_data=json_data, add_sentinel_token=True)
|
| 215 |
+
return result["id"]
|
| 216 |
+
|
| 217 |
+
async def get_image_tasks(self, token: str, limit: int = 20) -> Dict[str, Any]:
|
| 218 |
+
"""Get recent image generation tasks"""
|
| 219 |
+
return await self._make_request("GET", f"/v2/recent_tasks?limit={limit}", token)
|
| 220 |
+
|
| 221 |
+
async def get_video_drafts(self, token: str, limit: int = 15) -> Dict[str, Any]:
|
| 222 |
+
"""Get recent video drafts"""
|
| 223 |
+
return await self._make_request("GET", f"/project_y/profile/drafts?limit={limit}", token)
|
| 224 |
+
|
| 225 |
+
async def get_pending_tasks(self, token: str) -> list:
|
| 226 |
+
"""Get pending video generation tasks
|
| 227 |
+
|
| 228 |
+
Returns:
|
| 229 |
+
List of pending tasks with progress information
|
| 230 |
+
"""
|
| 231 |
+
result = await self._make_request("GET", "/nf/pending", token)
|
| 232 |
+
# The API returns a list directly
|
| 233 |
+
return result if isinstance(result, list) else []
|
| 234 |
+
|
| 235 |
+
async def post_video_for_watermark_free(self, generation_id: str, prompt: str, token: str) -> str:
|
| 236 |
+
"""Post video to get watermark-free version
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
generation_id: The generation ID (e.g., gen_01k9btrqrnen792yvt703dp0tq)
|
| 240 |
+
prompt: The original generation prompt
|
| 241 |
+
token: Access token
|
| 242 |
+
|
| 243 |
+
Returns:
|
| 244 |
+
Post ID (e.g., s_690ce161c2488191a3476e9969911522)
|
| 245 |
+
"""
|
| 246 |
+
json_data = {
|
| 247 |
+
"attachments_to_create": [
|
| 248 |
+
{
|
| 249 |
+
"generation_id": generation_id,
|
| 250 |
+
"kind": "sora"
|
| 251 |
+
}
|
| 252 |
+
],
|
| 253 |
+
"post_text": prompt
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
# 发布请求需要添加 sentinel token
|
| 257 |
+
result = await self._make_request("POST", "/project_y/post", token, json_data=json_data, add_sentinel_token=True)
|
| 258 |
+
|
| 259 |
+
# 返回 post.id
|
| 260 |
+
return result.get("post", {}).get("id", "")
|
| 261 |
+
|
| 262 |
+
async def delete_post(self, post_id: str, token: str) -> bool:
|
| 263 |
+
"""Delete a published post
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
post_id: The post ID (e.g., s_690ce161c2488191a3476e9969911522)
|
| 267 |
+
token: Access token
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
True if deletion was successful
|
| 271 |
+
"""
|
| 272 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 273 |
+
|
| 274 |
+
headers = {
|
| 275 |
+
"Authorization": f"Bearer {token}"
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
async with AsyncSession() as session:
|
| 279 |
+
url = f"{self.base_url}/project_y/post/{post_id}"
|
| 280 |
+
|
| 281 |
+
kwargs = {
|
| 282 |
+
"headers": headers,
|
| 283 |
+
"timeout": self.timeout,
|
| 284 |
+
"impersonate": "chrome"
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
if proxy_url:
|
| 288 |
+
kwargs["proxy"] = proxy_url
|
| 289 |
+
|
| 290 |
+
# Log request
|
| 291 |
+
debug_logger.log_request(
|
| 292 |
+
method="DELETE",
|
| 293 |
+
url=url,
|
| 294 |
+
headers=headers,
|
| 295 |
+
body=None,
|
| 296 |
+
files=None,
|
| 297 |
+
proxy=proxy_url
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
# Record start time
|
| 301 |
+
start_time = time.time()
|
| 302 |
+
|
| 303 |
+
# Make DELETE request
|
| 304 |
+
response = await session.delete(url, **kwargs)
|
| 305 |
+
|
| 306 |
+
# Calculate duration
|
| 307 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 308 |
+
|
| 309 |
+
# Log response
|
| 310 |
+
debug_logger.log_response(
|
| 311 |
+
status_code=response.status_code,
|
| 312 |
+
headers=dict(response.headers),
|
| 313 |
+
body=response.text if response.text else "No content",
|
| 314 |
+
duration_ms=duration_ms
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
# Check status (DELETE typically returns 204 No Content or 200 OK)
|
| 318 |
+
if response.status_code not in [200, 204]:
|
| 319 |
+
error_msg = f"Delete post failed: {response.status_code} - {response.text}"
|
| 320 |
+
debug_logger.log_error(
|
| 321 |
+
error_message=error_msg,
|
| 322 |
+
status_code=response.status_code,
|
| 323 |
+
response_text=response.text
|
| 324 |
+
)
|
| 325 |
+
raise Exception(error_msg)
|
| 326 |
+
|
| 327 |
+
return True
|
| 328 |
+
|
| 329 |
+
async def get_watermark_free_url_custom(self, parse_url: str, parse_token: str, post_id: str) -> str:
|
| 330 |
+
"""Get watermark-free video URL from custom parse server
|
| 331 |
+
|
| 332 |
+
Args:
|
| 333 |
+
parse_url: Custom parse server URL (e.g., http://example.com)
|
| 334 |
+
parse_token: Access token for custom parse server
|
| 335 |
+
post_id: Post ID to parse (e.g., s_690c0f574c3881918c3bc5b682a7e9fd)
|
| 336 |
+
|
| 337 |
+
Returns:
|
| 338 |
+
Download link from custom parse server
|
| 339 |
+
|
| 340 |
+
Raises:
|
| 341 |
+
Exception: If parse fails or token is invalid
|
| 342 |
+
"""
|
| 343 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 344 |
+
|
| 345 |
+
# Construct the share URL
|
| 346 |
+
share_url = f"https://sora.chatgpt.com/p/{post_id}"
|
| 347 |
+
|
| 348 |
+
# Prepare request
|
| 349 |
+
json_data = {
|
| 350 |
+
"url": share_url,
|
| 351 |
+
"token": parse_token
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
kwargs = {
|
| 355 |
+
"json": json_data,
|
| 356 |
+
"timeout": 30,
|
| 357 |
+
"impersonate": "chrome"
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
if proxy_url:
|
| 361 |
+
kwargs["proxy"] = proxy_url
|
| 362 |
+
|
| 363 |
+
try:
|
| 364 |
+
async with AsyncSession() as session:
|
| 365 |
+
# Record start time
|
| 366 |
+
start_time = time.time()
|
| 367 |
+
|
| 368 |
+
# Make POST request to custom parse server
|
| 369 |
+
response = await session.post(f"{parse_url}/get-sora-link", **kwargs)
|
| 370 |
+
|
| 371 |
+
# Calculate duration
|
| 372 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 373 |
+
|
| 374 |
+
# Log response
|
| 375 |
+
debug_logger.log_response(
|
| 376 |
+
status_code=response.status_code,
|
| 377 |
+
headers=dict(response.headers),
|
| 378 |
+
body=response.text if response.text else "No content",
|
| 379 |
+
duration_ms=duration_ms
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
# Check status
|
| 383 |
+
if response.status_code != 200:
|
| 384 |
+
error_msg = f"Custom parse failed: {response.status_code} - {response.text}"
|
| 385 |
+
debug_logger.log_error(
|
| 386 |
+
error_message=error_msg,
|
| 387 |
+
status_code=response.status_code,
|
| 388 |
+
response_text=response.text
|
| 389 |
+
)
|
| 390 |
+
raise Exception(error_msg)
|
| 391 |
+
|
| 392 |
+
# Parse response
|
| 393 |
+
result = response.json()
|
| 394 |
+
|
| 395 |
+
# Check for error in response
|
| 396 |
+
if "error" in result:
|
| 397 |
+
error_msg = f"Custom parse error: {result['error']}"
|
| 398 |
+
debug_logger.log_error(
|
| 399 |
+
error_message=error_msg,
|
| 400 |
+
status_code=401,
|
| 401 |
+
response_text=str(result)
|
| 402 |
+
)
|
| 403 |
+
raise Exception(error_msg)
|
| 404 |
+
|
| 405 |
+
# Extract download link
|
| 406 |
+
download_link = result.get("download_link")
|
| 407 |
+
if not download_link:
|
| 408 |
+
raise Exception("No download_link in custom parse response")
|
| 409 |
+
|
| 410 |
+
debug_logger.log_info(f"Custom parse successful: {download_link}")
|
| 411 |
+
return download_link
|
| 412 |
+
|
| 413 |
+
except Exception as e:
|
| 414 |
+
debug_logger.log_error(
|
| 415 |
+
error_message=f"Custom parse request failed: {str(e)}",
|
| 416 |
+
status_code=500,
|
| 417 |
+
response_text=str(e)
|
| 418 |
+
)
|
| 419 |
+
raise
|
| 420 |
+
|
| 421 |
+
# ==================== Character Creation Methods ====================
|
| 422 |
+
|
| 423 |
+
async def upload_character_video(self, video_data: bytes, token: str) -> str:
|
| 424 |
+
"""Upload character video and return cameo_id
|
| 425 |
+
|
| 426 |
+
Args:
|
| 427 |
+
video_data: Video file bytes
|
| 428 |
+
token: Access token
|
| 429 |
+
|
| 430 |
+
Returns:
|
| 431 |
+
cameo_id
|
| 432 |
+
"""
|
| 433 |
+
mp = CurlMime()
|
| 434 |
+
mp.addpart(
|
| 435 |
+
name="file",
|
| 436 |
+
content_type="video/mp4",
|
| 437 |
+
filename="video.mp4",
|
| 438 |
+
data=video_data
|
| 439 |
+
)
|
| 440 |
+
mp.addpart(
|
| 441 |
+
name="timestamps",
|
| 442 |
+
data=b"0,3"
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
result = await self._make_request("POST", "/characters/upload", token, multipart=mp)
|
| 446 |
+
return result.get("id")
|
| 447 |
+
|
| 448 |
+
async def get_cameo_status(self, cameo_id: str, token: str) -> Dict[str, Any]:
|
| 449 |
+
"""Get character (cameo) processing status
|
| 450 |
+
|
| 451 |
+
Args:
|
| 452 |
+
cameo_id: The cameo ID returned from upload_character_video
|
| 453 |
+
token: Access token
|
| 454 |
+
|
| 455 |
+
Returns:
|
| 456 |
+
Dictionary with status, display_name_hint, username_hint, profile_asset_url, instruction_set_hint
|
| 457 |
+
"""
|
| 458 |
+
return await self._make_request("GET", f"/project_y/cameos/in_progress/{cameo_id}", token)
|
| 459 |
+
|
| 460 |
+
async def download_character_image(self, image_url: str) -> bytes:
|
| 461 |
+
"""Download character image from URL
|
| 462 |
+
|
| 463 |
+
Args:
|
| 464 |
+
image_url: The profile_asset_url from cameo status
|
| 465 |
+
|
| 466 |
+
Returns:
|
| 467 |
+
Image file bytes
|
| 468 |
+
"""
|
| 469 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 470 |
+
|
| 471 |
+
kwargs = {
|
| 472 |
+
"timeout": self.timeout,
|
| 473 |
+
"impersonate": "chrome"
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
if proxy_url:
|
| 477 |
+
kwargs["proxy"] = proxy_url
|
| 478 |
+
|
| 479 |
+
async with AsyncSession() as session:
|
| 480 |
+
response = await session.get(image_url, **kwargs)
|
| 481 |
+
if response.status_code != 200:
|
| 482 |
+
raise Exception(f"Failed to download image: {response.status_code}")
|
| 483 |
+
return response.content
|
| 484 |
+
|
| 485 |
+
async def finalize_character(self, cameo_id: str, username: str, display_name: str,
|
| 486 |
+
profile_asset_pointer: str, instruction_set, token: str) -> str:
|
| 487 |
+
"""Finalize character creation
|
| 488 |
+
|
| 489 |
+
Args:
|
| 490 |
+
cameo_id: The cameo ID
|
| 491 |
+
username: Character username
|
| 492 |
+
display_name: Character display name
|
| 493 |
+
profile_asset_pointer: Asset pointer from upload_character_image
|
| 494 |
+
instruction_set: Character instruction set (not used by API, always set to None)
|
| 495 |
+
token: Access token
|
| 496 |
+
|
| 497 |
+
Returns:
|
| 498 |
+
character_id
|
| 499 |
+
"""
|
| 500 |
+
# Note: API always expects instruction_set to be null
|
| 501 |
+
# The instruction_set parameter is kept for backward compatibility but not used
|
| 502 |
+
_ = instruction_set # Suppress unused parameter warning
|
| 503 |
+
json_data = {
|
| 504 |
+
"cameo_id": cameo_id,
|
| 505 |
+
"username": username,
|
| 506 |
+
"display_name": display_name,
|
| 507 |
+
"profile_asset_pointer": profile_asset_pointer,
|
| 508 |
+
"instruction_set": None,
|
| 509 |
+
"safety_instruction_set": None
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
result = await self._make_request("POST", "/characters/finalize", token, json_data=json_data)
|
| 513 |
+
return result.get("character", {}).get("character_id")
|
| 514 |
+
|
| 515 |
+
async def set_character_public(self, cameo_id: str, token: str) -> bool:
|
| 516 |
+
"""Set character as public
|
| 517 |
+
|
| 518 |
+
Args:
|
| 519 |
+
cameo_id: The cameo ID
|
| 520 |
+
token: Access token
|
| 521 |
+
|
| 522 |
+
Returns:
|
| 523 |
+
True if successful
|
| 524 |
+
"""
|
| 525 |
+
json_data = {"visibility": "public"}
|
| 526 |
+
await self._make_request("POST", f"/project_y/cameos/by_id/{cameo_id}/update_v2", token, json_data=json_data)
|
| 527 |
+
return True
|
| 528 |
+
|
| 529 |
+
async def upload_character_image(self, image_data: bytes, token: str) -> str:
|
| 530 |
+
"""Upload character image and return asset_pointer
|
| 531 |
+
|
| 532 |
+
Args:
|
| 533 |
+
image_data: Image file bytes
|
| 534 |
+
token: Access token
|
| 535 |
+
|
| 536 |
+
Returns:
|
| 537 |
+
asset_pointer
|
| 538 |
+
"""
|
| 539 |
+
mp = CurlMime()
|
| 540 |
+
mp.addpart(
|
| 541 |
+
name="file",
|
| 542 |
+
content_type="image/webp",
|
| 543 |
+
filename="profile.webp",
|
| 544 |
+
data=image_data
|
| 545 |
+
)
|
| 546 |
+
mp.addpart(
|
| 547 |
+
name="use_case",
|
| 548 |
+
data=b"profile"
|
| 549 |
+
)
|
| 550 |
+
|
| 551 |
+
result = await self._make_request("POST", "/project_y/file/upload", token, multipart=mp)
|
| 552 |
+
return result.get("asset_pointer")
|
| 553 |
+
|
| 554 |
+
async def delete_character(self, character_id: str, token: str) -> bool:
|
| 555 |
+
"""Delete a character
|
| 556 |
+
|
| 557 |
+
Args:
|
| 558 |
+
character_id: The character ID
|
| 559 |
+
token: Access token
|
| 560 |
+
|
| 561 |
+
Returns:
|
| 562 |
+
True if successful
|
| 563 |
+
"""
|
| 564 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 565 |
+
|
| 566 |
+
headers = {
|
| 567 |
+
"Authorization": f"Bearer {token}"
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
async with AsyncSession() as session:
|
| 571 |
+
url = f"{self.base_url}/project_y/characters/{character_id}"
|
| 572 |
+
|
| 573 |
+
kwargs = {
|
| 574 |
+
"headers": headers,
|
| 575 |
+
"timeout": self.timeout,
|
| 576 |
+
"impersonate": "chrome"
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
if proxy_url:
|
| 580 |
+
kwargs["proxy"] = proxy_url
|
| 581 |
+
|
| 582 |
+
response = await session.delete(url, **kwargs)
|
| 583 |
+
if response.status_code not in [200, 204]:
|
| 584 |
+
raise Exception(f"Failed to delete character: {response.status_code}")
|
| 585 |
+
return True
|
| 586 |
+
|
| 587 |
+
async def remix_video(self, remix_target_id: str, prompt: str, token: str,
|
| 588 |
+
orientation: str = "portrait", n_frames: int = 450) -> str:
|
| 589 |
+
"""Generate video using remix (based on existing video)
|
| 590 |
+
|
| 591 |
+
Args:
|
| 592 |
+
remix_target_id: The video ID from Sora share link (e.g., s_690d100857248191b679e6de12db840e)
|
| 593 |
+
prompt: Generation prompt
|
| 594 |
+
token: Access token
|
| 595 |
+
orientation: Video orientation (portrait/landscape)
|
| 596 |
+
n_frames: Number of frames
|
| 597 |
+
|
| 598 |
+
Returns:
|
| 599 |
+
task_id
|
| 600 |
+
"""
|
| 601 |
+
json_data = {
|
| 602 |
+
"kind": "video",
|
| 603 |
+
"prompt": prompt,
|
| 604 |
+
"inpaint_items": [],
|
| 605 |
+
"remix_target_id": remix_target_id,
|
| 606 |
+
"cameo_ids": [],
|
| 607 |
+
"cameo_replacements": {},
|
| 608 |
+
"model": "sy_8",
|
| 609 |
+
"orientation": orientation,
|
| 610 |
+
"n_frames": n_frames
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
result = await self._make_request("POST", "/nf/create", token, json_data=json_data, add_sentinel_token=True)
|
| 614 |
+
return result.get("id")
|
src/services/token_lock.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Token lock manager for image generation"""
|
| 2 |
+
import asyncio
|
| 3 |
+
import time
|
| 4 |
+
from typing import Dict, Optional
|
| 5 |
+
from ..core.logger import debug_logger
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TokenLock:
|
| 9 |
+
"""Token lock manager for image generation (single-threaded per token)"""
|
| 10 |
+
|
| 11 |
+
def __init__(self, lock_timeout: int = 300):
|
| 12 |
+
"""
|
| 13 |
+
Initialize token lock manager
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
lock_timeout: Lock timeout in seconds (default: 300s = 5 minutes)
|
| 17 |
+
"""
|
| 18 |
+
self.lock_timeout = lock_timeout
|
| 19 |
+
self._locks: Dict[int, float] = {} # token_id -> lock_timestamp
|
| 20 |
+
self._lock = asyncio.Lock() # Protect _locks dict
|
| 21 |
+
|
| 22 |
+
async def acquire_lock(self, token_id: int) -> bool:
|
| 23 |
+
"""
|
| 24 |
+
Try to acquire lock for image generation
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
token_id: Token ID
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
True if lock acquired, False if already locked
|
| 31 |
+
"""
|
| 32 |
+
async with self._lock:
|
| 33 |
+
current_time = time.time()
|
| 34 |
+
|
| 35 |
+
# Check if token is locked
|
| 36 |
+
if token_id in self._locks:
|
| 37 |
+
lock_time = self._locks[token_id]
|
| 38 |
+
|
| 39 |
+
# Check if lock expired
|
| 40 |
+
if current_time - lock_time > self.lock_timeout:
|
| 41 |
+
# Lock expired, remove it
|
| 42 |
+
debug_logger.log_info(f"Token {token_id} lock expired, releasing")
|
| 43 |
+
del self._locks[token_id]
|
| 44 |
+
else:
|
| 45 |
+
# Lock still valid
|
| 46 |
+
remaining = self.lock_timeout - (current_time - lock_time)
|
| 47 |
+
debug_logger.log_info(f"Token {token_id} is locked, remaining: {remaining:.1f}s")
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
# Acquire lock
|
| 51 |
+
self._locks[token_id] = current_time
|
| 52 |
+
debug_logger.log_info(f"Token {token_id} lock acquired")
|
| 53 |
+
return True
|
| 54 |
+
|
| 55 |
+
async def release_lock(self, token_id: int):
|
| 56 |
+
"""
|
| 57 |
+
Release lock for token
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
token_id: Token ID
|
| 61 |
+
"""
|
| 62 |
+
async with self._lock:
|
| 63 |
+
if token_id in self._locks:
|
| 64 |
+
del self._locks[token_id]
|
| 65 |
+
debug_logger.log_info(f"Token {token_id} lock released")
|
| 66 |
+
|
| 67 |
+
async def is_locked(self, token_id: int) -> bool:
|
| 68 |
+
"""
|
| 69 |
+
Check if token is locked
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
token_id: Token ID
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
True if locked, False otherwise
|
| 76 |
+
"""
|
| 77 |
+
async with self._lock:
|
| 78 |
+
if token_id not in self._locks:
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
current_time = time.time()
|
| 82 |
+
lock_time = self._locks[token_id]
|
| 83 |
+
|
| 84 |
+
# Check if expired
|
| 85 |
+
if current_time - lock_time > self.lock_timeout:
|
| 86 |
+
# Expired, remove lock
|
| 87 |
+
del self._locks[token_id]
|
| 88 |
+
return False
|
| 89 |
+
|
| 90 |
+
return True
|
| 91 |
+
|
| 92 |
+
async def cleanup_expired_locks(self):
|
| 93 |
+
"""Clean up expired locks"""
|
| 94 |
+
async with self._lock:
|
| 95 |
+
current_time = time.time()
|
| 96 |
+
expired_tokens = []
|
| 97 |
+
|
| 98 |
+
for token_id, lock_time in self._locks.items():
|
| 99 |
+
if current_time - lock_time > self.lock_timeout:
|
| 100 |
+
expired_tokens.append(token_id)
|
| 101 |
+
|
| 102 |
+
for token_id in expired_tokens:
|
| 103 |
+
del self._locks[token_id]
|
| 104 |
+
debug_logger.log_info(f"Cleaned up expired lock for token {token_id}")
|
| 105 |
+
|
| 106 |
+
if expired_tokens:
|
| 107 |
+
debug_logger.log_info(f"Cleaned up {len(expired_tokens)} expired locks")
|
| 108 |
+
|
| 109 |
+
def get_locked_tokens(self) -> list:
|
| 110 |
+
"""Get list of currently locked token IDs"""
|
| 111 |
+
return list(self._locks.keys())
|
| 112 |
+
|
| 113 |
+
def set_lock_timeout(self, timeout: int):
|
| 114 |
+
"""Set lock timeout in seconds"""
|
| 115 |
+
self.lock_timeout = timeout
|
| 116 |
+
debug_logger.log_info(f"Lock timeout updated to {timeout} seconds")
|
| 117 |
+
|
src/services/token_manager.py
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Token management module"""
|
| 2 |
+
import jwt
|
| 3 |
+
import asyncio
|
| 4 |
+
import random
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from typing import Optional, List, Dict, Any
|
| 7 |
+
from curl_cffi.requests import AsyncSession
|
| 8 |
+
from faker import Faker
|
| 9 |
+
from ..core.database import Database
|
| 10 |
+
from ..core.models import Token, TokenStats
|
| 11 |
+
from ..core.config import config
|
| 12 |
+
from .proxy_manager import ProxyManager
|
| 13 |
+
|
| 14 |
+
class TokenManager:
|
| 15 |
+
"""Token lifecycle manager"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, db: Database):
|
| 18 |
+
self.db = db
|
| 19 |
+
self._lock = asyncio.Lock()
|
| 20 |
+
self.proxy_manager = ProxyManager(db)
|
| 21 |
+
self.fake = Faker()
|
| 22 |
+
|
| 23 |
+
async def decode_jwt(self, token: str) -> dict:
|
| 24 |
+
"""Decode JWT token without verification"""
|
| 25 |
+
try:
|
| 26 |
+
decoded = jwt.decode(token, options={"verify_signature": False})
|
| 27 |
+
return decoded
|
| 28 |
+
except Exception as e:
|
| 29 |
+
raise ValueError(f"Invalid JWT token: {str(e)}")
|
| 30 |
+
|
| 31 |
+
def _generate_random_username(self) -> str:
|
| 32 |
+
"""Generate a random username using faker
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
A random username string
|
| 36 |
+
"""
|
| 37 |
+
# 生成真实姓名
|
| 38 |
+
first_name = self.fake.first_name()
|
| 39 |
+
last_name = self.fake.last_name()
|
| 40 |
+
|
| 41 |
+
# 去除姓名中的空格和特殊字符,只保留字母
|
| 42 |
+
first_name_clean = ''.join(c for c in first_name if c.isalpha())
|
| 43 |
+
last_name_clean = ''.join(c for c in last_name if c.isalpha())
|
| 44 |
+
|
| 45 |
+
# 生成1-4位随机数字
|
| 46 |
+
random_digits = str(random.randint(1, 9999))
|
| 47 |
+
|
| 48 |
+
# 随机选择用户名格式
|
| 49 |
+
format_choice = random.choice([
|
| 50 |
+
f"{first_name_clean}{last_name_clean}{random_digits}",
|
| 51 |
+
f"{first_name_clean}.{last_name_clean}{random_digits}",
|
| 52 |
+
f"{first_name_clean}{random_digits}",
|
| 53 |
+
f"{last_name_clean}{random_digits}",
|
| 54 |
+
f"{first_name_clean[0]}{last_name_clean}{random_digits}",
|
| 55 |
+
f"{first_name_clean}{last_name_clean[0]}{random_digits}"
|
| 56 |
+
])
|
| 57 |
+
|
| 58 |
+
# 转换为小写
|
| 59 |
+
return format_choice.lower()
|
| 60 |
+
|
| 61 |
+
async def get_user_info(self, access_token: str) -> dict:
|
| 62 |
+
"""Get user info from Sora API"""
|
| 63 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 64 |
+
|
| 65 |
+
async with AsyncSession() as session:
|
| 66 |
+
headers = {
|
| 67 |
+
"Authorization": f"Bearer {access_token}",
|
| 68 |
+
"Accept": "application/json",
|
| 69 |
+
"Origin": "https://sora.chatgpt.com",
|
| 70 |
+
"Referer": "https://sora.chatgpt.com/"
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
kwargs = {
|
| 74 |
+
"headers": headers,
|
| 75 |
+
"timeout": 30,
|
| 76 |
+
"impersonate": "chrome" # 自动生成 User-Agent 和浏览器指纹
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if proxy_url:
|
| 80 |
+
kwargs["proxy"] = proxy_url
|
| 81 |
+
|
| 82 |
+
response = await session.get(
|
| 83 |
+
f"{config.sora_base_url}/me",
|
| 84 |
+
**kwargs
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
if response.status_code != 200:
|
| 88 |
+
raise ValueError(f"Failed to get user info: {response.status_code}")
|
| 89 |
+
|
| 90 |
+
return response.json()
|
| 91 |
+
|
| 92 |
+
async def get_subscription_info(self, token: str) -> Dict[str, Any]:
|
| 93 |
+
"""Get subscription information from Sora API
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
{
|
| 97 |
+
"plan_type": "chatgpt_team",
|
| 98 |
+
"plan_title": "ChatGPT Business",
|
| 99 |
+
"subscription_end": "2025-11-13T16:58:21Z"
|
| 100 |
+
}
|
| 101 |
+
"""
|
| 102 |
+
print(f"🔍 开始获取订阅信息...")
|
| 103 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 104 |
+
|
| 105 |
+
headers = {
|
| 106 |
+
"Authorization": f"Bearer {token}"
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
async with AsyncSession() as session:
|
| 110 |
+
url = "https://sora.chatgpt.com/backend/billing/subscriptions"
|
| 111 |
+
print(f"📡 请求 URL: {url}")
|
| 112 |
+
print(f"🔑 使用 Token: {token[:30]}...")
|
| 113 |
+
|
| 114 |
+
kwargs = {
|
| 115 |
+
"headers": headers,
|
| 116 |
+
"timeout": 30,
|
| 117 |
+
"impersonate": "chrome" # 自动生成 User-Agent 和浏览器指纹
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
if proxy_url:
|
| 121 |
+
kwargs["proxy"] = proxy_url
|
| 122 |
+
print(f"🌐 使用代理: {proxy_url}")
|
| 123 |
+
|
| 124 |
+
response = await session.get(url, **kwargs)
|
| 125 |
+
print(f"📥 响应状态码: {response.status_code}")
|
| 126 |
+
|
| 127 |
+
if response.status_code == 200:
|
| 128 |
+
data = response.json()
|
| 129 |
+
print(f"📦 响应数据: {data}")
|
| 130 |
+
|
| 131 |
+
# 提取第一个订阅信息
|
| 132 |
+
if data.get("data") and len(data["data"]) > 0:
|
| 133 |
+
subscription = data["data"][0]
|
| 134 |
+
plan = subscription.get("plan", {})
|
| 135 |
+
|
| 136 |
+
result = {
|
| 137 |
+
"plan_type": plan.get("id", ""),
|
| 138 |
+
"plan_title": plan.get("title", ""),
|
| 139 |
+
"subscription_end": subscription.get("end_ts", "")
|
| 140 |
+
}
|
| 141 |
+
print(f"✅ 订阅信息提取成功: {result}")
|
| 142 |
+
return result
|
| 143 |
+
|
| 144 |
+
print(f"⚠️ 响应数据中没有订阅信息")
|
| 145 |
+
return {
|
| 146 |
+
"plan_type": "",
|
| 147 |
+
"plan_title": "",
|
| 148 |
+
"subscription_end": ""
|
| 149 |
+
}
|
| 150 |
+
else:
|
| 151 |
+
error_msg = f"Failed to get subscription info: {response.status_code}"
|
| 152 |
+
print(f"❌ {error_msg}")
|
| 153 |
+
print(f"📄 响应内容: {response.text[:500]}")
|
| 154 |
+
raise Exception(error_msg)
|
| 155 |
+
|
| 156 |
+
async def get_sora2_invite_code(self, access_token: str) -> dict:
|
| 157 |
+
"""Get Sora2 invite code"""
|
| 158 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 159 |
+
|
| 160 |
+
print(f"🔍 开始获取Sora2邀请码...")
|
| 161 |
+
|
| 162 |
+
async with AsyncSession() as session:
|
| 163 |
+
headers = {
|
| 164 |
+
"Authorization": f"Bearer {access_token}",
|
| 165 |
+
"Accept": "application/json"
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
kwargs = {
|
| 169 |
+
"headers": headers,
|
| 170 |
+
"timeout": 30,
|
| 171 |
+
"impersonate": "chrome" # 自动生成 User-Agent 和浏览器指纹
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
if proxy_url:
|
| 175 |
+
kwargs["proxy"] = proxy_url
|
| 176 |
+
print(f"🌐 使用代理: {proxy_url}")
|
| 177 |
+
|
| 178 |
+
response = await session.get(
|
| 179 |
+
"https://sora.chatgpt.com/backend/project_y/invite/mine",
|
| 180 |
+
**kwargs
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
print(f"📥 响应状态码: {response.status_code}")
|
| 184 |
+
|
| 185 |
+
if response.status_code == 200:
|
| 186 |
+
data = response.json()
|
| 187 |
+
print(f"✅ Sora2邀请码获取成功: {data}")
|
| 188 |
+
return {
|
| 189 |
+
"supported": True,
|
| 190 |
+
"invite_code": data.get("invite_code"),
|
| 191 |
+
"redeemed_count": data.get("redeemed_count", 0),
|
| 192 |
+
"total_count": data.get("total_count", 0)
|
| 193 |
+
}
|
| 194 |
+
else:
|
| 195 |
+
# Check if it's 401 unauthorized
|
| 196 |
+
try:
|
| 197 |
+
error_data = response.json()
|
| 198 |
+
if error_data.get("error", {}).get("message", "").startswith("401"):
|
| 199 |
+
print(f"⚠️ Token不支持Sora2")
|
| 200 |
+
return {
|
| 201 |
+
"supported": False,
|
| 202 |
+
"invite_code": None
|
| 203 |
+
}
|
| 204 |
+
except:
|
| 205 |
+
pass
|
| 206 |
+
|
| 207 |
+
print(f"❌ 获取Sora2邀请码失败: {response.status_code}")
|
| 208 |
+
print(f"📄 响应内容: {response.text[:500]}")
|
| 209 |
+
return {
|
| 210 |
+
"supported": False,
|
| 211 |
+
"invite_code": None
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
async def get_sora2_remaining_count(self, access_token: str) -> dict:
|
| 215 |
+
"""Get Sora2 remaining video count
|
| 216 |
+
|
| 217 |
+
Returns:
|
| 218 |
+
{
|
| 219 |
+
"remaining_count": 27,
|
| 220 |
+
"rate_limit_reached": false,
|
| 221 |
+
"access_resets_in_seconds": 46833
|
| 222 |
+
}
|
| 223 |
+
"""
|
| 224 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 225 |
+
|
| 226 |
+
print(f"🔍 开始获取Sora2剩余次数...")
|
| 227 |
+
|
| 228 |
+
async with AsyncSession() as session:
|
| 229 |
+
headers = {
|
| 230 |
+
"Authorization": f"Bearer {access_token}",
|
| 231 |
+
"Accept": "application/json"
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
kwargs = {
|
| 235 |
+
"headers": headers,
|
| 236 |
+
"timeout": 30,
|
| 237 |
+
"impersonate": "chrome" # 自动生成 User-Agent 和浏览器指纹
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
if proxy_url:
|
| 241 |
+
kwargs["proxy"] = proxy_url
|
| 242 |
+
print(f"🌐 使用代理: {proxy_url}")
|
| 243 |
+
|
| 244 |
+
response = await session.get(
|
| 245 |
+
"https://sora.chatgpt.com/backend/nf/check",
|
| 246 |
+
**kwargs
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
print(f"📥 响应状态码: {response.status_code}")
|
| 250 |
+
|
| 251 |
+
if response.status_code == 200:
|
| 252 |
+
data = response.json()
|
| 253 |
+
print(f"✅ Sora2剩余次数获取成功: {data}")
|
| 254 |
+
|
| 255 |
+
rate_limit_info = data.get("rate_limit_and_credit_balance", {})
|
| 256 |
+
return {
|
| 257 |
+
"success": True,
|
| 258 |
+
"remaining_count": rate_limit_info.get("estimated_num_videos_remaining", 0),
|
| 259 |
+
"rate_limit_reached": rate_limit_info.get("rate_limit_reached", False),
|
| 260 |
+
"access_resets_in_seconds": rate_limit_info.get("access_resets_in_seconds", 0)
|
| 261 |
+
}
|
| 262 |
+
else:
|
| 263 |
+
print(f"❌ 获取Sora2剩余次数失败: {response.status_code}")
|
| 264 |
+
print(f"📄 响应内容: {response.text[:500]}")
|
| 265 |
+
return {
|
| 266 |
+
"success": False,
|
| 267 |
+
"remaining_count": 0,
|
| 268 |
+
"error": f"Failed to get remaining count: {response.status_code}"
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
async def check_username_available(self, access_token: str, username: str) -> bool:
|
| 272 |
+
"""Check if username is available
|
| 273 |
+
|
| 274 |
+
Args:
|
| 275 |
+
access_token: Access token for authentication
|
| 276 |
+
username: Username to check
|
| 277 |
+
|
| 278 |
+
Returns:
|
| 279 |
+
True if username is available, False otherwise
|
| 280 |
+
"""
|
| 281 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 282 |
+
|
| 283 |
+
print(f"🔍 检查用户名是否可用: {username}")
|
| 284 |
+
|
| 285 |
+
async with AsyncSession() as session:
|
| 286 |
+
headers = {
|
| 287 |
+
"Authorization": f"Bearer {access_token}",
|
| 288 |
+
"Content-Type": "application/json"
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
kwargs = {
|
| 292 |
+
"headers": headers,
|
| 293 |
+
"json": {"username": username},
|
| 294 |
+
"timeout": 30,
|
| 295 |
+
"impersonate": "chrome"
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
if proxy_url:
|
| 299 |
+
kwargs["proxy"] = proxy_url
|
| 300 |
+
print(f"🌐 使用代理: {proxy_url}")
|
| 301 |
+
|
| 302 |
+
response = await session.post(
|
| 303 |
+
"https://sora.chatgpt.com/backend/project_y/profile/username/check",
|
| 304 |
+
**kwargs
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
print(f"📥 响应状态码: {response.status_code}")
|
| 308 |
+
|
| 309 |
+
if response.status_code == 200:
|
| 310 |
+
data = response.json()
|
| 311 |
+
available = data.get("available", False)
|
| 312 |
+
print(f"✅ 用户名检查结果: available={available}")
|
| 313 |
+
return available
|
| 314 |
+
else:
|
| 315 |
+
print(f"❌ 用户名检查失败: {response.status_code}")
|
| 316 |
+
print(f"📄 响应内容: {response.text[:500]}")
|
| 317 |
+
return False
|
| 318 |
+
|
| 319 |
+
async def set_username(self, access_token: str, username: str) -> dict:
|
| 320 |
+
"""Set username for the account
|
| 321 |
+
|
| 322 |
+
Args:
|
| 323 |
+
access_token: Access token for authentication
|
| 324 |
+
username: Username to set
|
| 325 |
+
|
| 326 |
+
Returns:
|
| 327 |
+
User profile information after setting username
|
| 328 |
+
"""
|
| 329 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 330 |
+
|
| 331 |
+
print(f"🔍 开始设置用户名: {username}")
|
| 332 |
+
|
| 333 |
+
async with AsyncSession() as session:
|
| 334 |
+
headers = {
|
| 335 |
+
"Authorization": f"Bearer {access_token}",
|
| 336 |
+
"Content-Type": "application/json"
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
kwargs = {
|
| 340 |
+
"headers": headers,
|
| 341 |
+
"json": {"username": username},
|
| 342 |
+
"timeout": 30,
|
| 343 |
+
"impersonate": "chrome"
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
if proxy_url:
|
| 347 |
+
kwargs["proxy"] = proxy_url
|
| 348 |
+
print(f"🌐 使用代理: {proxy_url}")
|
| 349 |
+
|
| 350 |
+
response = await session.post(
|
| 351 |
+
"https://sora.chatgpt.com/backend/project_y/profile/username/set",
|
| 352 |
+
**kwargs
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
print(f"📥 响应状态码: {response.status_code}")
|
| 356 |
+
|
| 357 |
+
if response.status_code == 200:
|
| 358 |
+
data = response.json()
|
| 359 |
+
print(f"✅ 用户名设置成功: {data.get('username')}")
|
| 360 |
+
return data
|
| 361 |
+
else:
|
| 362 |
+
print(f"❌ 用户名设置失败: {response.status_code}")
|
| 363 |
+
print(f"📄 响应内容: {response.text[:500]}")
|
| 364 |
+
raise Exception(f"Failed to set username: {response.status_code}")
|
| 365 |
+
|
| 366 |
+
async def activate_sora2_invite(self, access_token: str, invite_code: str) -> dict:
|
| 367 |
+
"""Activate Sora2 with invite code"""
|
| 368 |
+
import uuid
|
| 369 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 370 |
+
|
| 371 |
+
print(f"🔍 开始激活Sora2邀请码: {invite_code}")
|
| 372 |
+
print(f"🔑 Access Token 前缀: {access_token[:50]}...")
|
| 373 |
+
|
| 374 |
+
async with AsyncSession() as session:
|
| 375 |
+
# 生成设备ID
|
| 376 |
+
device_id = str(uuid.uuid4())
|
| 377 |
+
|
| 378 |
+
# 只设置必要的头,让 impersonate 处理其他
|
| 379 |
+
headers = {
|
| 380 |
+
"authorization": f"Bearer {access_token}",
|
| 381 |
+
"cookie": f"oai-did={device_id}"
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
print(f"🆔 设备ID: {device_id}")
|
| 385 |
+
print(f"📦 请求体: {{'invite_code': '{invite_code}'}}")
|
| 386 |
+
|
| 387 |
+
kwargs = {
|
| 388 |
+
"headers": headers,
|
| 389 |
+
"json": {"invite_code": invite_code},
|
| 390 |
+
"timeout": 30,
|
| 391 |
+
"impersonate": "chrome120" # 使用 chrome120 让库自动处理 UA 等头
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
if proxy_url:
|
| 395 |
+
kwargs["proxy"] = proxy_url
|
| 396 |
+
print(f"🌐 使用代理: {proxy_url}")
|
| 397 |
+
|
| 398 |
+
response = await session.post(
|
| 399 |
+
"https://sora.chatgpt.com/backend/project_y/invite/accept",
|
| 400 |
+
**kwargs
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
print(f"📥 响应状态码: {response.status_code}")
|
| 404 |
+
|
| 405 |
+
if response.status_code == 200:
|
| 406 |
+
data = response.json()
|
| 407 |
+
print(f"✅ Sora2激活成功: {data}")
|
| 408 |
+
return {
|
| 409 |
+
"success": data.get("success", False),
|
| 410 |
+
"already_accepted": data.get("already_accepted", False)
|
| 411 |
+
}
|
| 412 |
+
else:
|
| 413 |
+
print(f"❌ Sora2激活失败: {response.status_code}")
|
| 414 |
+
print(f"📄 响应内容: {response.text[:500]}")
|
| 415 |
+
raise Exception(f"Failed to activate Sora2: {response.status_code}")
|
| 416 |
+
|
| 417 |
+
async def st_to_at(self, session_token: str) -> dict:
|
| 418 |
+
"""Convert Session Token to Access Token"""
|
| 419 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 420 |
+
|
| 421 |
+
async with AsyncSession() as session:
|
| 422 |
+
headers = {
|
| 423 |
+
"Cookie": f"__Secure-next-auth.session-token={session_token}",
|
| 424 |
+
"Accept": "application/json",
|
| 425 |
+
"Origin": "https://sora.chatgpt.com",
|
| 426 |
+
"Referer": "https://sora.chatgpt.com/"
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
kwargs = {
|
| 430 |
+
"headers": headers,
|
| 431 |
+
"timeout": 30,
|
| 432 |
+
"impersonate": "chrome" # 自动生成 User-Agent 和浏览器指纹
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
if proxy_url:
|
| 436 |
+
kwargs["proxy"] = proxy_url
|
| 437 |
+
|
| 438 |
+
response = await session.get(
|
| 439 |
+
"https://sora.chatgpt.com/api/auth/session",
|
| 440 |
+
**kwargs
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
if response.status_code != 200:
|
| 444 |
+
raise ValueError(f"Failed to convert ST to AT: {response.status_code}")
|
| 445 |
+
|
| 446 |
+
data = response.json()
|
| 447 |
+
return {
|
| 448 |
+
"access_token": data.get("accessToken"),
|
| 449 |
+
"email": data.get("user", {}).get("email"),
|
| 450 |
+
"expires": data.get("expires")
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
async def rt_to_at(self, refresh_token: str) -> dict:
|
| 454 |
+
"""Convert Refresh Token to Access Token"""
|
| 455 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 456 |
+
|
| 457 |
+
async with AsyncSession() as session:
|
| 458 |
+
headers = {
|
| 459 |
+
"Accept": "application/json",
|
| 460 |
+
"Content-Type": "application/json"
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
kwargs = {
|
| 464 |
+
"headers": headers,
|
| 465 |
+
"json": {
|
| 466 |
+
"client_id": "app_LlGpXReQgckcGGUo2JrYvtJK",
|
| 467 |
+
"grant_type": "refresh_token",
|
| 468 |
+
"redirect_uri": "com.openai.chat://auth0.openai.com/ios/com.openai.chat/callback",
|
| 469 |
+
"refresh_token": refresh_token
|
| 470 |
+
},
|
| 471 |
+
"timeout": 30,
|
| 472 |
+
"impersonate": "chrome" # 自动生成 User-Agent 和浏览器指纹
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
if proxy_url:
|
| 476 |
+
kwargs["proxy"] = proxy_url
|
| 477 |
+
|
| 478 |
+
response = await session.post(
|
| 479 |
+
"https://auth.openai.com/oauth/token",
|
| 480 |
+
**kwargs
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
if response.status_code != 200:
|
| 484 |
+
raise ValueError(f"Failed to convert RT to AT: {response.status_code} - {response.text}")
|
| 485 |
+
|
| 486 |
+
data = response.json()
|
| 487 |
+
return {
|
| 488 |
+
"access_token": data.get("access_token"),
|
| 489 |
+
"refresh_token": data.get("refresh_token"),
|
| 490 |
+
"expires_in": data.get("expires_in")
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
async def add_token(self, token_value: str,
|
| 494 |
+
st: Optional[str] = None,
|
| 495 |
+
rt: Optional[str] = None,
|
| 496 |
+
remark: Optional[str] = None,
|
| 497 |
+
update_if_exists: bool = False,
|
| 498 |
+
image_enabled: bool = True,
|
| 499 |
+
video_enabled: bool = True) -> Token:
|
| 500 |
+
"""Add a new Access Token to database
|
| 501 |
+
|
| 502 |
+
Args:
|
| 503 |
+
token_value: Access Token
|
| 504 |
+
st: Session Token (optional)
|
| 505 |
+
rt: Refresh Token (optional)
|
| 506 |
+
remark: Remark (optional)
|
| 507 |
+
update_if_exists: If True, update existing token instead of raising error
|
| 508 |
+
image_enabled: Enable image generation (default: True)
|
| 509 |
+
video_enabled: Enable video generation (default: True)
|
| 510 |
+
|
| 511 |
+
Returns:
|
| 512 |
+
Token object
|
| 513 |
+
|
| 514 |
+
Raises:
|
| 515 |
+
ValueError: If token already exists and update_if_exists is False
|
| 516 |
+
"""
|
| 517 |
+
# Check if token already exists
|
| 518 |
+
existing_token = await self.db.get_token_by_value(token_value)
|
| 519 |
+
if existing_token:
|
| 520 |
+
if not update_if_exists:
|
| 521 |
+
raise ValueError(f"Token 已存在(邮箱: {existing_token.email})。如需更新,请先删除旧 Token 或使用更新功能。")
|
| 522 |
+
# Update existing token
|
| 523 |
+
return await self.update_existing_token(existing_token.id, token_value, st, rt, remark)
|
| 524 |
+
|
| 525 |
+
# Decode JWT to get expiry time and email
|
| 526 |
+
decoded = await self.decode_jwt(token_value)
|
| 527 |
+
|
| 528 |
+
# Extract expiry time from JWT
|
| 529 |
+
expiry_time = datetime.fromtimestamp(decoded.get("exp", 0)) if "exp" in decoded else None
|
| 530 |
+
|
| 531 |
+
# Extract email from JWT (OpenAI JWT format)
|
| 532 |
+
jwt_email = None
|
| 533 |
+
if "https://api.openai.com/profile" in decoded:
|
| 534 |
+
jwt_email = decoded["https://api.openai.com/profile"].get("email")
|
| 535 |
+
|
| 536 |
+
# Get user info from Sora API
|
| 537 |
+
try:
|
| 538 |
+
user_info = await self.get_user_info(token_value)
|
| 539 |
+
email = user_info.get("email", jwt_email or "")
|
| 540 |
+
name = user_info.get("name") or ""
|
| 541 |
+
except Exception as e:
|
| 542 |
+
# If API call fails, use JWT data
|
| 543 |
+
email = jwt_email or ""
|
| 544 |
+
name = email.split("@")[0] if email else ""
|
| 545 |
+
|
| 546 |
+
# Get subscription info from Sora API
|
| 547 |
+
plan_type = None
|
| 548 |
+
plan_title = None
|
| 549 |
+
subscription_end = None
|
| 550 |
+
try:
|
| 551 |
+
sub_info = await self.get_subscription_info(token_value)
|
| 552 |
+
plan_type = sub_info.get("plan_type")
|
| 553 |
+
plan_title = sub_info.get("plan_title")
|
| 554 |
+
# Parse subscription end time
|
| 555 |
+
if sub_info.get("subscription_end"):
|
| 556 |
+
from dateutil import parser
|
| 557 |
+
subscription_end = parser.parse(sub_info["subscription_end"])
|
| 558 |
+
except Exception as e:
|
| 559 |
+
# If API call fails, subscription info will be None
|
| 560 |
+
print(f"Failed to get subscription info: {e}")
|
| 561 |
+
|
| 562 |
+
# Get Sora2 invite code
|
| 563 |
+
sora2_supported = None
|
| 564 |
+
sora2_invite_code = None
|
| 565 |
+
sora2_redeemed_count = 0
|
| 566 |
+
sora2_total_count = 0
|
| 567 |
+
sora2_remaining_count = 0
|
| 568 |
+
try:
|
| 569 |
+
sora2_info = await self.get_sora2_invite_code(token_value)
|
| 570 |
+
sora2_supported = sora2_info.get("supported", False)
|
| 571 |
+
sora2_invite_code = sora2_info.get("invite_code")
|
| 572 |
+
sora2_redeemed_count = sora2_info.get("redeemed_count", 0)
|
| 573 |
+
sora2_total_count = sora2_info.get("total_count", 0)
|
| 574 |
+
|
| 575 |
+
# If Sora2 is supported, get remaining count
|
| 576 |
+
if sora2_supported:
|
| 577 |
+
try:
|
| 578 |
+
remaining_info = await self.get_sora2_remaining_count(token_value)
|
| 579 |
+
if remaining_info.get("success"):
|
| 580 |
+
sora2_remaining_count = remaining_info.get("remaining_count", 0)
|
| 581 |
+
print(f"✅ Sora2剩余次数: {sora2_remaining_count}")
|
| 582 |
+
except Exception as e:
|
| 583 |
+
print(f"Failed to get Sora2 remaining count: {e}")
|
| 584 |
+
except Exception as e:
|
| 585 |
+
# If API call fails, Sora2 info will be None
|
| 586 |
+
print(f"Failed to get Sora2 info: {e}")
|
| 587 |
+
|
| 588 |
+
# Check and set username if needed
|
| 589 |
+
try:
|
| 590 |
+
# Get fresh user info to check username
|
| 591 |
+
user_info = await self.get_user_info(token_value)
|
| 592 |
+
username = user_info.get("username")
|
| 593 |
+
|
| 594 |
+
# If username is null, need to set one
|
| 595 |
+
if username is None:
|
| 596 |
+
print(f"⚠️ 检测到用户名为null,需要设置用户名")
|
| 597 |
+
|
| 598 |
+
# Generate random username
|
| 599 |
+
max_attempts = 5
|
| 600 |
+
for attempt in range(max_attempts):
|
| 601 |
+
generated_username = self._generate_random_username()
|
| 602 |
+
print(f"🔄 尝试用户名 ({attempt + 1}/{max_attempts}): {generated_username}")
|
| 603 |
+
|
| 604 |
+
# Check if username is available
|
| 605 |
+
if await self.check_username_available(token_value, generated_username):
|
| 606 |
+
# Set the username
|
| 607 |
+
try:
|
| 608 |
+
await self.set_username(token_value, generated_username)
|
| 609 |
+
print(f"✅ 用户名设置成功: {generated_username}")
|
| 610 |
+
break
|
| 611 |
+
except Exception as e:
|
| 612 |
+
print(f"❌ 用户名设置失败: {e}")
|
| 613 |
+
if attempt == max_attempts - 1:
|
| 614 |
+
print(f"⚠️ 达到最大尝试次数,跳过用户名设置")
|
| 615 |
+
else:
|
| 616 |
+
print(f"⚠️ 用户名 {generated_username} 已被占用,尝试下一个")
|
| 617 |
+
if attempt == max_attempts - 1:
|
| 618 |
+
print(f"⚠️ 达到最大尝试次数,跳过用户名设置")
|
| 619 |
+
else:
|
| 620 |
+
print(f"✅ 用户名已设置: {username}")
|
| 621 |
+
except Exception as e:
|
| 622 |
+
print(f"⚠️ 用户名检查/设置过程中出错: {e}")
|
| 623 |
+
|
| 624 |
+
# Create token object
|
| 625 |
+
token = Token(
|
| 626 |
+
token=token_value,
|
| 627 |
+
email=email,
|
| 628 |
+
name=name,
|
| 629 |
+
st=st,
|
| 630 |
+
rt=rt,
|
| 631 |
+
remark=remark,
|
| 632 |
+
expiry_time=expiry_time,
|
| 633 |
+
is_active=True,
|
| 634 |
+
plan_type=plan_type,
|
| 635 |
+
plan_title=plan_title,
|
| 636 |
+
subscription_end=subscription_end,
|
| 637 |
+
sora2_supported=sora2_supported,
|
| 638 |
+
sora2_invite_code=sora2_invite_code,
|
| 639 |
+
sora2_redeemed_count=sora2_redeemed_count,
|
| 640 |
+
sora2_total_count=sora2_total_count,
|
| 641 |
+
sora2_remaining_count=sora2_remaining_count,
|
| 642 |
+
image_enabled=image_enabled,
|
| 643 |
+
video_enabled=video_enabled
|
| 644 |
+
)
|
| 645 |
+
|
| 646 |
+
# Save to database
|
| 647 |
+
token_id = await self.db.add_token(token)
|
| 648 |
+
token.id = token_id
|
| 649 |
+
|
| 650 |
+
return token
|
| 651 |
+
|
| 652 |
+
async def update_existing_token(self, token_id: int, token_value: str,
|
| 653 |
+
st: Optional[str] = None,
|
| 654 |
+
rt: Optional[str] = None,
|
| 655 |
+
remark: Optional[str] = None) -> Token:
|
| 656 |
+
"""Update an existing token with new information"""
|
| 657 |
+
# Decode JWT to get expiry time
|
| 658 |
+
decoded = await self.decode_jwt(token_value)
|
| 659 |
+
expiry_time = datetime.fromtimestamp(decoded.get("exp", 0)) if "exp" in decoded else None
|
| 660 |
+
|
| 661 |
+
# Get user info from Sora API
|
| 662 |
+
jwt_email = None
|
| 663 |
+
if "https://api.openai.com/profile" in decoded:
|
| 664 |
+
jwt_email = decoded["https://api.openai.com/profile"].get("email")
|
| 665 |
+
|
| 666 |
+
try:
|
| 667 |
+
user_info = await self.get_user_info(token_value)
|
| 668 |
+
email = user_info.get("email", jwt_email or "")
|
| 669 |
+
name = user_info.get("name", "")
|
| 670 |
+
except Exception as e:
|
| 671 |
+
email = jwt_email or ""
|
| 672 |
+
name = email.split("@")[0] if email else ""
|
| 673 |
+
|
| 674 |
+
# Get subscription info from Sora API
|
| 675 |
+
plan_type = None
|
| 676 |
+
plan_title = None
|
| 677 |
+
subscription_end = None
|
| 678 |
+
try:
|
| 679 |
+
sub_info = await self.get_subscription_info(token_value)
|
| 680 |
+
plan_type = sub_info.get("plan_type")
|
| 681 |
+
plan_title = sub_info.get("plan_title")
|
| 682 |
+
if sub_info.get("subscription_end"):
|
| 683 |
+
from dateutil import parser
|
| 684 |
+
subscription_end = parser.parse(sub_info["subscription_end"])
|
| 685 |
+
except Exception as e:
|
| 686 |
+
print(f"Failed to get subscription info: {e}")
|
| 687 |
+
|
| 688 |
+
# Update token in database
|
| 689 |
+
await self.db.update_token(
|
| 690 |
+
token_id=token_id,
|
| 691 |
+
token=token_value,
|
| 692 |
+
st=st,
|
| 693 |
+
rt=rt,
|
| 694 |
+
remark=remark,
|
| 695 |
+
expiry_time=expiry_time,
|
| 696 |
+
plan_type=plan_type,
|
| 697 |
+
plan_title=plan_title,
|
| 698 |
+
subscription_end=subscription_end
|
| 699 |
+
)
|
| 700 |
+
|
| 701 |
+
# Get updated token
|
| 702 |
+
updated_token = await self.db.get_token(token_id)
|
| 703 |
+
return updated_token
|
| 704 |
+
|
| 705 |
+
async def delete_token(self, token_id: int):
|
| 706 |
+
"""Delete a token"""
|
| 707 |
+
await self.db.delete_token(token_id)
|
| 708 |
+
|
| 709 |
+
async def update_token(self, token_id: int,
|
| 710 |
+
token: Optional[str] = None,
|
| 711 |
+
st: Optional[str] = None,
|
| 712 |
+
rt: Optional[str] = None,
|
| 713 |
+
remark: Optional[str] = None,
|
| 714 |
+
image_enabled: Optional[bool] = None,
|
| 715 |
+
video_enabled: Optional[bool] = None):
|
| 716 |
+
"""Update token (AT, ST, RT, remark, image_enabled, video_enabled)"""
|
| 717 |
+
# If token (AT) is updated, decode JWT to get new expiry time
|
| 718 |
+
expiry_time = None
|
| 719 |
+
if token:
|
| 720 |
+
try:
|
| 721 |
+
decoded = await self.decode_jwt(token)
|
| 722 |
+
expiry_time = datetime.fromtimestamp(decoded.get("exp", 0)) if "exp" in decoded else None
|
| 723 |
+
except Exception:
|
| 724 |
+
pass # If JWT decode fails, keep expiry_time as None
|
| 725 |
+
|
| 726 |
+
await self.db.update_token(token_id, token=token, st=st, rt=rt, remark=remark, expiry_time=expiry_time,
|
| 727 |
+
image_enabled=image_enabled, video_enabled=video_enabled)
|
| 728 |
+
|
| 729 |
+
async def get_active_tokens(self) -> List[Token]:
|
| 730 |
+
"""Get all active tokens (not cooled down)"""
|
| 731 |
+
return await self.db.get_active_tokens()
|
| 732 |
+
|
| 733 |
+
async def get_all_tokens(self) -> List[Token]:
|
| 734 |
+
"""Get all tokens"""
|
| 735 |
+
return await self.db.get_all_tokens()
|
| 736 |
+
|
| 737 |
+
async def update_token_status(self, token_id: int, is_active: bool):
|
| 738 |
+
"""Update token active status"""
|
| 739 |
+
await self.db.update_token_status(token_id, is_active)
|
| 740 |
+
|
| 741 |
+
async def enable_token(self, token_id: int):
|
| 742 |
+
"""Enable a token and reset error count"""
|
| 743 |
+
await self.db.update_token_status(token_id, True)
|
| 744 |
+
# Reset error count when enabling (in token_stats table)
|
| 745 |
+
await self.db.reset_error_count(token_id)
|
| 746 |
+
|
| 747 |
+
async def disable_token(self, token_id: int):
|
| 748 |
+
"""Disable a token"""
|
| 749 |
+
await self.db.update_token_status(token_id, False)
|
| 750 |
+
|
| 751 |
+
async def test_token(self, token_id: int) -> dict:
|
| 752 |
+
"""Test if a token is valid by calling Sora API and refresh Sora2 info"""
|
| 753 |
+
# Get token from database
|
| 754 |
+
token_data = await self.db.get_token(token_id)
|
| 755 |
+
if not token_data:
|
| 756 |
+
return {"valid": False, "message": "Token not found"}
|
| 757 |
+
|
| 758 |
+
try:
|
| 759 |
+
# Try to get user info from Sora API
|
| 760 |
+
user_info = await self.get_user_info(token_data.token)
|
| 761 |
+
|
| 762 |
+
# Refresh Sora2 invite code and counts
|
| 763 |
+
sora2_info = await self.get_sora2_invite_code(token_data.token)
|
| 764 |
+
sora2_supported = sora2_info.get("supported", False)
|
| 765 |
+
sora2_invite_code = sora2_info.get("invite_code")
|
| 766 |
+
sora2_redeemed_count = sora2_info.get("redeemed_count", 0)
|
| 767 |
+
sora2_total_count = sora2_info.get("total_count", 0)
|
| 768 |
+
sora2_remaining_count = 0
|
| 769 |
+
|
| 770 |
+
# If Sora2 is supported, get remaining count
|
| 771 |
+
if sora2_supported:
|
| 772 |
+
try:
|
| 773 |
+
remaining_info = await self.get_sora2_remaining_count(token_data.token)
|
| 774 |
+
if remaining_info.get("success"):
|
| 775 |
+
sora2_remaining_count = remaining_info.get("remaining_count", 0)
|
| 776 |
+
except Exception as e:
|
| 777 |
+
print(f"Failed to get Sora2 remaining count: {e}")
|
| 778 |
+
|
| 779 |
+
# Update token Sora2 info in database
|
| 780 |
+
await self.db.update_token_sora2(
|
| 781 |
+
token_id,
|
| 782 |
+
supported=sora2_supported,
|
| 783 |
+
invite_code=sora2_invite_code,
|
| 784 |
+
redeemed_count=sora2_redeemed_count,
|
| 785 |
+
total_count=sora2_total_count,
|
| 786 |
+
remaining_count=sora2_remaining_count
|
| 787 |
+
)
|
| 788 |
+
|
| 789 |
+
return {
|
| 790 |
+
"valid": True,
|
| 791 |
+
"message": "Token is valid",
|
| 792 |
+
"email": user_info.get("email"),
|
| 793 |
+
"username": user_info.get("username"),
|
| 794 |
+
"sora2_supported": sora2_supported,
|
| 795 |
+
"sora2_invite_code": sora2_invite_code,
|
| 796 |
+
"sora2_redeemed_count": sora2_redeemed_count,
|
| 797 |
+
"sora2_total_count": sora2_total_count,
|
| 798 |
+
"sora2_remaining_count": sora2_remaining_count
|
| 799 |
+
}
|
| 800 |
+
except Exception as e:
|
| 801 |
+
return {
|
| 802 |
+
"valid": False,
|
| 803 |
+
"message": f"Token is invalid: {str(e)}"
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
async def record_usage(self, token_id: int, is_video: bool = False):
|
| 807 |
+
"""Record token usage"""
|
| 808 |
+
await self.db.update_token_usage(token_id)
|
| 809 |
+
|
| 810 |
+
if is_video:
|
| 811 |
+
await self.db.increment_video_count(token_id)
|
| 812 |
+
else:
|
| 813 |
+
await self.db.increment_image_count(token_id)
|
| 814 |
+
|
| 815 |
+
async def record_error(self, token_id: int):
|
| 816 |
+
"""Record token error"""
|
| 817 |
+
await self.db.increment_error_count(token_id)
|
| 818 |
+
|
| 819 |
+
# Check if should ban
|
| 820 |
+
stats = await self.db.get_token_stats(token_id)
|
| 821 |
+
admin_config = await self.db.get_admin_config()
|
| 822 |
+
|
| 823 |
+
if stats and stats.error_count >= admin_config.error_ban_threshold:
|
| 824 |
+
await self.db.update_token_status(token_id, False)
|
| 825 |
+
|
| 826 |
+
async def record_success(self, token_id: int, is_video: bool = False):
|
| 827 |
+
"""Record successful request (reset error count)"""
|
| 828 |
+
await self.db.reset_error_count(token_id)
|
| 829 |
+
|
| 830 |
+
# Update Sora2 remaining count after video generation
|
| 831 |
+
if is_video:
|
| 832 |
+
try:
|
| 833 |
+
token_data = await self.db.get_token(token_id)
|
| 834 |
+
if token_data and token_data.sora2_supported:
|
| 835 |
+
remaining_info = await self.get_sora2_remaining_count(token_data.token)
|
| 836 |
+
if remaining_info.get("success"):
|
| 837 |
+
remaining_count = remaining_info.get("remaining_count", 0)
|
| 838 |
+
await self.db.update_token_sora2_remaining(token_id, remaining_count)
|
| 839 |
+
print(f"✅ 更新Token {token_id} 的Sora2剩余次数: {remaining_count}")
|
| 840 |
+
|
| 841 |
+
# If remaining count is 0, set cooldown
|
| 842 |
+
if remaining_count == 0:
|
| 843 |
+
reset_seconds = remaining_info.get("access_resets_in_seconds", 0)
|
| 844 |
+
if reset_seconds > 0:
|
| 845 |
+
cooldown_until = datetime.now() + timedelta(seconds=reset_seconds)
|
| 846 |
+
await self.db.update_token_sora2_cooldown(token_id, cooldown_until)
|
| 847 |
+
print(f"⏱️ Token {token_id} 剩余次数为0,设置冷却时间至: {cooldown_until}")
|
| 848 |
+
except Exception as e:
|
| 849 |
+
print(f"Failed to update Sora2 remaining count: {e}")
|
| 850 |
+
|
| 851 |
+
async def refresh_sora2_remaining_if_cooldown_expired(self, token_id: int):
|
| 852 |
+
"""Refresh Sora2 remaining count if cooldown has expired"""
|
| 853 |
+
try:
|
| 854 |
+
token_data = await self.db.get_token(token_id)
|
| 855 |
+
if not token_data or not token_data.sora2_supported:
|
| 856 |
+
return
|
| 857 |
+
|
| 858 |
+
# Check if Sora2 cooldown has expired
|
| 859 |
+
if token_data.sora2_cooldown_until and token_data.sora2_cooldown_until <= datetime.now():
|
| 860 |
+
print(f"🔄 Token {token_id} Sora2冷却已过期,正在刷新剩余次数...")
|
| 861 |
+
|
| 862 |
+
try:
|
| 863 |
+
remaining_info = await self.get_sora2_remaining_count(token_data.token)
|
| 864 |
+
if remaining_info.get("success"):
|
| 865 |
+
remaining_count = remaining_info.get("remaining_count", 0)
|
| 866 |
+
await self.db.update_token_sora2_remaining(token_id, remaining_count)
|
| 867 |
+
# Clear cooldown
|
| 868 |
+
await self.db.update_token_sora2_cooldown(token_id, None)
|
| 869 |
+
print(f"✅ Token {token_id} Sora2剩余次数已刷新: {remaining_count}")
|
| 870 |
+
except Exception as e:
|
| 871 |
+
print(f"Failed to refresh Sora2 remaining count: {e}")
|
| 872 |
+
except Exception as e:
|
| 873 |
+
print(f"Error in refresh_sora2_remaining_if_cooldown_expired: {e}")
|
| 874 |
+
|
| 875 |
+
async def auto_refresh_expiring_token(self, token_id: int) -> bool:
|
| 876 |
+
"""
|
| 877 |
+
Auto refresh token when expiry time is within 24 hours using ST or RT
|
| 878 |
+
|
| 879 |
+
Returns:
|
| 880 |
+
True if refresh successful, False otherwise
|
| 881 |
+
"""
|
| 882 |
+
try:
|
| 883 |
+
token_data = await self.db.get_token(token_id)
|
| 884 |
+
if not token_data:
|
| 885 |
+
return False
|
| 886 |
+
|
| 887 |
+
# Check if token is expiring within 24 hours
|
| 888 |
+
if not token_data.expiry_time:
|
| 889 |
+
return False # No expiry time set
|
| 890 |
+
|
| 891 |
+
time_until_expiry = token_data.expiry_time - datetime.now()
|
| 892 |
+
hours_until_expiry = time_until_expiry.total_seconds() / 3600
|
| 893 |
+
|
| 894 |
+
# Only refresh if expiry is within 24 hours (1440 minutes)
|
| 895 |
+
if hours_until_expiry > 24:
|
| 896 |
+
return False # Token not expiring soon
|
| 897 |
+
|
| 898 |
+
if hours_until_expiry < 0:
|
| 899 |
+
# Token already expired, still try to refresh
|
| 900 |
+
print(f"🔄 Token {token_id} 已过期,尝试自动刷新...")
|
| 901 |
+
else:
|
| 902 |
+
print(f"🔄 Token {token_id} 将在 {hours_until_expiry:.1f} 小时后过期,尝试自动刷新...")
|
| 903 |
+
|
| 904 |
+
# Priority: ST > RT
|
| 905 |
+
new_at = None
|
| 906 |
+
new_st = None
|
| 907 |
+
new_rt = None
|
| 908 |
+
|
| 909 |
+
if token_data.st:
|
| 910 |
+
# Try to refresh using ST
|
| 911 |
+
try:
|
| 912 |
+
print(f"📝 使用 ST 刷新 Token {token_id}...")
|
| 913 |
+
result = await self.st_to_at(token_data.st)
|
| 914 |
+
new_at = result.get("access_token")
|
| 915 |
+
# ST refresh doesn't return new ST, so keep the old one
|
| 916 |
+
new_st = token_data.st
|
| 917 |
+
print(f"✅ 使用 ST 刷新成功")
|
| 918 |
+
except Exception as e:
|
| 919 |
+
print(f"❌ 使用 ST 刷新失败: {e}")
|
| 920 |
+
new_at = None
|
| 921 |
+
|
| 922 |
+
if not new_at and token_data.rt:
|
| 923 |
+
# Try to refresh using RT
|
| 924 |
+
try:
|
| 925 |
+
print(f"📝 使用 RT 刷新 Token {token_id}...")
|
| 926 |
+
result = await self.rt_to_at(token_data.rt)
|
| 927 |
+
new_at = result.get("access_token")
|
| 928 |
+
new_rt = result.get("refresh_token", token_data.rt) # RT might be updated
|
| 929 |
+
print(f"✅ 使用 RT 刷新成功")
|
| 930 |
+
except Exception as e:
|
| 931 |
+
print(f"❌ 使用 RT 刷新失败: {e}")
|
| 932 |
+
new_at = None
|
| 933 |
+
|
| 934 |
+
if new_at:
|
| 935 |
+
# Update token with new AT
|
| 936 |
+
await self.update_token(token_id, token=new_at, st=new_st, rt=new_rt)
|
| 937 |
+
print(f"✅ Token {token_id} 已自动刷新")
|
| 938 |
+
return True
|
| 939 |
+
else:
|
| 940 |
+
# No ST or RT, disable token
|
| 941 |
+
print(f"⚠️ Token {token_id} 无法刷新(无 ST 或 RT),已禁用")
|
| 942 |
+
await self.disable_token(token_id)
|
| 943 |
+
return False
|
| 944 |
+
|
| 945 |
+
except Exception as e:
|
| 946 |
+
print(f"❌ 自动刷新 Token {token_id} 失败: {e}")
|
| 947 |
+
return False
|
static/login.html
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="h-full">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>登录 - Sora2API</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<style>
|
| 9 |
+
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
|
| 10 |
+
.animate-slide-up{animation:slide-up .3s ease-out}
|
| 11 |
+
</style>
|
| 12 |
+
<script>
|
| 13 |
+
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
|
| 14 |
+
</script>
|
| 15 |
+
</head>
|
| 16 |
+
<body class="h-full bg-background text-foreground antialiased">
|
| 17 |
+
<div class="flex min-h-full flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
|
| 18 |
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
| 19 |
+
<div class="text-center">
|
| 20 |
+
<h1 class="text-4xl font-bold">Sora2API</h1>
|
| 21 |
+
<p class="mt-2 text-sm text-muted-foreground">管理员控制台</p>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
| 26 |
+
<div class="bg-background py-8 px-4 sm:px-10 rounded-lg">
|
| 27 |
+
<form id="loginForm" class="space-y-6">
|
| 28 |
+
<div class="space-y-2">
|
| 29 |
+
<label for="username" class="text-sm font-medium">账户</label>
|
| 30 |
+
<input type="text" id="username" name="username" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" placeholder="请输入账户">
|
| 31 |
+
</div>
|
| 32 |
+
<div class="space-y-2">
|
| 33 |
+
<label for="password" class="text-sm font-medium">密码</label>
|
| 34 |
+
<input type="password" id="password" name="password" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" placeholder="请输入密码">
|
| 35 |
+
</div>
|
| 36 |
+
<button type="submit" id="loginButton" class="inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-10 w-full disabled:opacity-50">登录</button>
|
| 37 |
+
</form>
|
| 38 |
+
|
| 39 |
+
<div class="mt-6 text-center text-xs text-muted-foreground">
|
| 40 |
+
<p>Sora2API © 2025</p>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<script>
|
| 47 |
+
const form=document.getElementById('loginForm'),btn=document.getElementById('loginButton');
|
| 48 |
+
form.addEventListener('submit',async(e)=>{e.preventDefault();btn.disabled=true;btn.textContent='登录中...';try{const fd=new FormData(form),r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:fd.get('username'),password:fd.get('password')})});const d=await r.json();d.success?(localStorage.setItem('adminToken',d.token),location.href='/manage'):showToast(d.message||'登录失败','error')}catch(e){showToast('网络错误,请稍后重试','error')}finally{btn.disabled=false;btn.textContent='登录'}});
|
| 49 |
+
function showToast(m,t='error'){const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.error} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)}
|
| 50 |
+
window.addEventListener('DOMContentLoaded',()=>{const t=localStorage.getItem('adminToken');t&&fetch('/api/stats',{headers:{Authorization:`Bearer ${t}`}}).then(r=>{if(r.ok)location.href='/manage'})});
|
| 51 |
+
</script>
|
| 52 |
+
</body>
|
| 53 |
+
</html>
|
static/manage.html
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="h-full">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>管理控制台 - Sora2API</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<style>
|
| 9 |
+
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
|
| 10 |
+
.animate-slide-up{animation:slide-up .3s ease-out}
|
| 11 |
+
.tab-btn{transition:all .2s ease}
|
| 12 |
+
</style>
|
| 13 |
+
<script>
|
| 14 |
+
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
|
| 15 |
+
</script>
|
| 16 |
+
</head>
|
| 17 |
+
<body class="h-full bg-background text-foreground antialiased">
|
| 18 |
+
<!-- 导航栏 -->
|
| 19 |
+
<header class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur">
|
| 20 |
+
<div class="mx-auto flex h-14 max-w-7xl items-center px-6">
|
| 21 |
+
<div class="mr-4 flex items-baseline gap-3">
|
| 22 |
+
<span class="font-bold text-xl">Sora2API</span>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="flex flex-1 items-center justify-end gap-3">
|
| 25 |
+
<a href="https://github.com/TheSmallHanCat/sora2api" target="_blank" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5" title="GitHub 仓库">
|
| 26 |
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
| 27 |
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
| 28 |
+
</svg>
|
| 29 |
+
</a>
|
| 30 |
+
<button onclick="logout()" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5 gap-1">
|
| 31 |
+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 32 |
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
| 33 |
+
<polyline points="16 17 21 12 16 7"/>
|
| 34 |
+
<line x1="21" y1="12" x2="9" y2="12"/>
|
| 35 |
+
</svg>
|
| 36 |
+
退出
|
| 37 |
+
</button>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</header>
|
| 41 |
+
|
| 42 |
+
<main class="mx-auto max-w-7xl px-6 py-6">
|
| 43 |
+
<!-- Tab 导航 -->
|
| 44 |
+
<div class="border-b border-border mb-6">
|
| 45 |
+
<nav class="flex space-x-8">
|
| 46 |
+
<button onclick="switchTab('tokens')" id="tabTokens" class="tab-btn border-b-2 border-primary text-sm font-medium py-3 px-1">Token 管理</button>
|
| 47 |
+
<button onclick="switchTab('settings')" id="tabSettings" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">系统配置</button>
|
| 48 |
+
<button onclick="switchTab('logs')" id="tabLogs" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">请求日志</button>
|
| 49 |
+
</nav>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<!-- Token 管理面板 -->
|
| 53 |
+
<div id="panelTokens">
|
| 54 |
+
<!-- 统计卡片 -->
|
| 55 |
+
<div class="grid gap-4 grid-cols-2 md:grid-cols-5 mb-6">
|
| 56 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 57 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">Token 总数</p>
|
| 58 |
+
<h3 class="text-xl font-bold" id="statTotal">-</h3>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 61 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">活跃 Token</p>
|
| 62 |
+
<h3 class="text-xl font-bold text-green-600" id="statActive">-</h3>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 65 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">总图片数</p>
|
| 66 |
+
<h3 class="text-xl font-bold text-blue-600" id="statImages">-</h3>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 69 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">总视频数</p>
|
| 70 |
+
<h3 class="text-xl font-bold text-purple-600" id="statVideos">-</h3>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 73 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">错误次数</p>
|
| 74 |
+
<h3 class="text-xl font-bold text-destructive" id="statErrors">-</h3>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<!-- Token 列表 -->
|
| 79 |
+
<div class="rounded-lg border border-border bg-background">
|
| 80 |
+
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
|
| 81 |
+
<h3 class="text-lg font-semibold">Token 列表</h3>
|
| 82 |
+
<div class="flex items-center gap-3">
|
| 83 |
+
<!-- 自动刷新AT标签和开关 -->
|
| 84 |
+
<div class="flex items-center gap-2">
|
| 85 |
+
<span class="text-xs text-muted-foreground">自动刷新AT</span>
|
| 86 |
+
<div class="relative inline-flex items-center group">
|
| 87 |
+
<label class="inline-flex items-center cursor-pointer">
|
| 88 |
+
<input type="checkbox" id="atAutoRefreshToggle" onchange="toggleATAutoRefresh()" class="sr-only peer">
|
| 89 |
+
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
| 90 |
+
</label>
|
| 91 |
+
<!-- 悬浮提示 -->
|
| 92 |
+
<div class="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
|
| 93 |
+
Token距离过期<24h时自动使用ST或RT刷新AT
|
| 94 |
+
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
<button onclick="refreshTokens()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新">
|
| 99 |
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 100 |
+
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
| 101 |
+
</svg>
|
| 102 |
+
</button>
|
| 103 |
+
<button onclick="openAddModal()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3">
|
| 104 |
+
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 105 |
+
<line x1="12" y1="5" x2="12" y2="19"/>
|
| 106 |
+
<line x1="5" y1="12" x2="19" y2="12"/>
|
| 107 |
+
</svg>
|
| 108 |
+
<span class="text-sm font-medium">新增</span>
|
| 109 |
+
</button>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div class="relative w-full overflow-auto">
|
| 114 |
+
<table class="w-full text-sm">
|
| 115 |
+
<thead>
|
| 116 |
+
<tr class="border-b border-border">
|
| 117 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">邮箱</th>
|
| 118 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态</th>
|
| 119 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">过期时间</th>
|
| 120 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">账户类型</th>
|
| 121 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Sora2</th>
|
| 122 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">可用次数</th>
|
| 123 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">图片</th>
|
| 124 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">视频</th>
|
| 125 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">错误</th>
|
| 126 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">备注</th>
|
| 127 |
+
<th class="h-10 px-3 text-right align-middle font-medium text-muted-foreground">操作</th>
|
| 128 |
+
</tr>
|
| 129 |
+
</thead>
|
| 130 |
+
<tbody id="tokenTableBody" class="divide-y divide-border">
|
| 131 |
+
<!-- 动态填充 -->
|
| 132 |
+
</tbody>
|
| 133 |
+
</table>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<!-- 系统配置面板 -->
|
| 139 |
+
<div id="panelSettings" class="hidden">
|
| 140 |
+
<div class="grid gap-6 lg:grid-cols-2">
|
| 141 |
+
<!-- 安全配置 -->
|
| 142 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 143 |
+
<h3 class="text-lg font-semibold mb-4">安全配置</h3>
|
| 144 |
+
<div class="space-y-4">
|
| 145 |
+
<div>
|
| 146 |
+
<label class="text-sm font-medium mb-2 block">管理员用户名</label>
|
| 147 |
+
<input id="cfgAdminUsername" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
| 148 |
+
<p class="text-xs text-muted-foreground mt-1">管理员用户名</p>
|
| 149 |
+
</div>
|
| 150 |
+
<div>
|
| 151 |
+
<label class="text-sm font-medium mb-2 block">旧密码</label>
|
| 152 |
+
<input id="cfgOldPassword" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入旧密码">
|
| 153 |
+
</div>
|
| 154 |
+
<div>
|
| 155 |
+
<label class="text-sm font-medium mb-2 block">新密码</label>
|
| 156 |
+
<input id="cfgNewPassword" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入新密码">
|
| 157 |
+
</div>
|
| 158 |
+
<button onclick="updateAdminPassword()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">修改密码</button>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<!-- API 密钥配置 -->
|
| 163 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 164 |
+
<h3 class="text-lg font-semibold mb-4">API 密钥配置</h3>
|
| 165 |
+
<div class="space-y-4">
|
| 166 |
+
<div>
|
| 167 |
+
<label class="text-sm font-medium mb-2 block">当前 API Key</label>
|
| 168 |
+
<input id="cfgCurrentAPIKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" readonly disabled>
|
| 169 |
+
<p class="text-xs text-muted-foreground mt-1">当前使用的 API Key(只读)</p>
|
| 170 |
+
</div>
|
| 171 |
+
<div>
|
| 172 |
+
<label class="text-sm font-medium mb-2 block">新 API Key</label>
|
| 173 |
+
<input id="cfgNewAPIKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入新的 API Key">
|
| 174 |
+
<p class="text-xs text-muted-foreground mt-1">用于客户端调用 API 的密钥</p>
|
| 175 |
+
</div>
|
| 176 |
+
<button onclick="updateAPIKey()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">更新 API Key</button>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
<!-- 代理配置 -->
|
| 181 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 182 |
+
<h3 class="text-lg font-semibold mb-4">代理配置</h3>
|
| 183 |
+
<div class="space-y-4">
|
| 184 |
+
<div>
|
| 185 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 186 |
+
<input type="checkbox" id="cfgProxyEnabled" class="h-4 w-4 rounded border-input">
|
| 187 |
+
<span class="text-sm font-medium">启用代理</span>
|
| 188 |
+
</label>
|
| 189 |
+
</div>
|
| 190 |
+
<div>
|
| 191 |
+
<label class="text-sm font-medium mb-2 block">代理地址</label>
|
| 192 |
+
<input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
|
| 193 |
+
<p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
|
| 194 |
+
</div>
|
| 195 |
+
<button onclick="saveProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
<!-- 错误处理配置 -->
|
| 200 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 201 |
+
<h3 class="text-lg font-semibold mb-4">错误处理配置</h3>
|
| 202 |
+
<div class="space-y-4">
|
| 203 |
+
<div>
|
| 204 |
+
<label class="text-sm font-medium mb-2 block">错误封禁阈值</label>
|
| 205 |
+
<input id="cfgErrorBan" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3">
|
| 206 |
+
<p class="text-xs text-muted-foreground mt-1">Token 连续错误达到此次数后自动禁用</p>
|
| 207 |
+
</div>
|
| 208 |
+
<button onclick="saveAdminConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<!-- 缓存配置 -->
|
| 213 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 214 |
+
<h3 class="text-lg font-semibold mb-4">缓存配置</h3>
|
| 215 |
+
<div class="space-y-4">
|
| 216 |
+
<div>
|
| 217 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 218 |
+
<input type="checkbox" id="cfgCacheEnabled" class="h-4 w-4 rounded border-input" onchange="toggleCacheOptions()">
|
| 219 |
+
<span class="text-sm font-medium">启用缓存</span>
|
| 220 |
+
</label>
|
| 221 |
+
<p class="text-xs text-muted-foreground mt-1">关闭后,生成的图片和视频将直接输出原始链接,不会缓存到本地</p>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
<!-- 缓存配置选项 -->
|
| 225 |
+
<div id="cacheOptions" style="display: none;" class="space-y-4 pt-4 border-t border-border">
|
| 226 |
+
<div>
|
| 227 |
+
<label class="text-sm font-medium mb-2 block">缓存超时时间(秒)</label>
|
| 228 |
+
<input id="cfgCacheTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="7200" min="60" max="86400">
|
| 229 |
+
<p class="text-xs text-muted-foreground mt-1">文件缓存超时时间,范围:60-86400 秒(1分钟-24小时)</p>
|
| 230 |
+
</div>
|
| 231 |
+
<div>
|
| 232 |
+
<label class="text-sm font-medium mb-2 block">缓存文件访问域名(请使用当前服务的地址)</label>
|
| 233 |
+
<input id="cfgCacheBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://yourdomain.com">
|
| 234 |
+
<p class="text-xs text-muted-foreground mt-1">留空则使用服务器地址,例如:https://yourdomain.com</p>
|
| 235 |
+
</div>
|
| 236 |
+
<div id="cacheEffectiveUrl" class="rounded-md bg-muted p-3 hidden">
|
| 237 |
+
<p class="text-xs text-muted-foreground">
|
| 238 |
+
<strong>🌐 当前生效的访问地址:</strong><code id="cacheEffectiveUrlValue" class="bg-background px-1 py-0.5 rounded"></code>
|
| 239 |
+
</p>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
|
| 243 |
+
<button onclick="saveCacheConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<!-- 生成超时配置 -->
|
| 248 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 249 |
+
<h3 class="text-lg font-semibold mb-4">生成超时配置</h3>
|
| 250 |
+
<div class="space-y-4">
|
| 251 |
+
<div>
|
| 252 |
+
<label class="text-sm font-medium mb-2 block">图片生成超时时间(秒)</label>
|
| 253 |
+
<input id="cfgImageTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="300" min="60" max="3600">
|
| 254 |
+
<p class="text-xs text-muted-foreground mt-1">图片生成超时时间,范围:60-3600 秒(1分钟-1小时),超时后自动释放Token锁</p>
|
| 255 |
+
</div>
|
| 256 |
+
<div>
|
| 257 |
+
<label class="text-sm font-medium mb-2 block">视频生成超时时间(秒)</label>
|
| 258 |
+
<input id="cfgVideoTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="1500" min="60" max="7200">
|
| 259 |
+
<p class="text-xs text-muted-foreground mt-1">视频生成超时时间,范围:60-7200 秒(1分钟-2小时),超时后返回上游API超时错误</p>
|
| 260 |
+
</div>
|
| 261 |
+
<button onclick="saveGenerationTimeout()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<!-- 无水印模式配置 -->
|
| 266 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 267 |
+
<h3 class="text-lg font-semibold mb-4">无水印模式配置</h3>
|
| 268 |
+
<div class="space-y-4">
|
| 269 |
+
<div>
|
| 270 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 271 |
+
<input type="checkbox" id="cfgWatermarkFreeEnabled" class="h-4 w-4 rounded border-input" onchange="toggleWatermarkFreeOptions()">
|
| 272 |
+
<span class="text-sm font-medium">开启无水印模式</span>
|
| 273 |
+
</label>
|
| 274 |
+
<p class="text-xs text-muted-foreground mt-2">开启后生成的视频将会被发布到sora平台并且提取返回无水印的视频,在缓存到本地后会自动删除发布的视频(需要开启缓存功能)</p>
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
<!-- 解析方式选择 -->
|
| 278 |
+
<div id="watermarkFreeOptions" style="display: none;" class="space-y-4 pt-4 border-t border-border">
|
| 279 |
+
<div>
|
| 280 |
+
<label class="text-sm font-medium">解析方式</label>
|
| 281 |
+
<select id="cfgParseMethod" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground" onchange="toggleCustomParseOptions()">
|
| 282 |
+
<option value="third_party">第三方解析</option>
|
| 283 |
+
<option value="custom">自定义解析接口</option>
|
| 284 |
+
</select>
|
| 285 |
+
</div>
|
| 286 |
+
|
| 287 |
+
<!-- 自定义解析配置 -->
|
| 288 |
+
<div id="customParseOptions" style="display: none;" class="space-y-4">
|
| 289 |
+
<div>
|
| 290 |
+
<label class="text-sm font-medium">解析服务器地址</label>
|
| 291 |
+
<input type="text" id="cfgCustomParseUrl" placeholder="请输入解析服务器地址 (例如: http://example.com)" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground">
|
| 292 |
+
<p class="text-xs text-muted-foreground mt-1"><a href="https://github.com/tibbar213/sora-downloader" target="_blank" class="text-blue-600 hover:text-blue-800 underline">部署自定义解析服务器</a></p>
|
| 293 |
+
</div>
|
| 294 |
+
<div>
|
| 295 |
+
<label class="text-sm font-medium">访问密钥</label>
|
| 296 |
+
<input type="password" id="cfgCustomParseToken" placeholder="请输入访问密钥" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground">
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
<button onclick="saveWatermarkFreeConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
|
| 305 |
+
<!-- 调试配置 -->
|
| 306 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 307 |
+
<h3 class="text-lg font-semibold mb-4">调试配置</h3>
|
| 308 |
+
<div class="space-y-4">
|
| 309 |
+
<div>
|
| 310 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 311 |
+
<input type="checkbox" id="cfgDebugEnabled" class="h-4 w-4 rounded border-input" onchange="toggleDebugMode()">
|
| 312 |
+
<span class="text-sm font-medium">启用调试模式</span>
|
| 313 |
+
</label>
|
| 314 |
+
<p class="text-xs text-muted-foreground mt-2">开启后,详细的上游API请求和响应日志将写入 <code class="bg-muted px-1 py-0.5 rounded">logs.txt</code> 文件,重启生效</p>
|
| 315 |
+
</div>
|
| 316 |
+
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-3 border border-yellow-200 dark:border-yellow-800">
|
| 317 |
+
<p class="text-xs text-yellow-800 dark:text-yellow-200">
|
| 318 |
+
⚠️ <strong>注意:</strong>调试模式会产生非常非常大量的日志,仅限Debug时候开启,否则磁盘boom
|
| 319 |
+
</p>
|
| 320 |
+
</div>
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
<!-- 请求日志面板 -->
|
| 327 |
+
<div id="panelLogs" class="hidden">
|
| 328 |
+
<div class="rounded-lg border border-border bg-background">
|
| 329 |
+
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
|
| 330 |
+
<h3 class="text-lg font-semibold">请求日志</h3>
|
| 331 |
+
<button onclick="refreshLogs()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新">
|
| 332 |
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 333 |
+
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
| 334 |
+
</svg>
|
| 335 |
+
</button>
|
| 336 |
+
</div>
|
| 337 |
+
<div class="relative w-full overflow-auto max-h-[600px]">
|
| 338 |
+
<table class="w-full text-sm">
|
| 339 |
+
<thead class="sticky top-0 bg-background">
|
| 340 |
+
<tr class="border-b border-border">
|
| 341 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">操作</th>
|
| 342 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Token邮箱</th>
|
| 343 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态码</th>
|
| 344 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">耗时(秒)</th>
|
| 345 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">时间</th>
|
| 346 |
+
</tr>
|
| 347 |
+
</thead>
|
| 348 |
+
<tbody id="logsTableBody" class="divide-y divide-border">
|
| 349 |
+
<!-- 动态填充 -->
|
| 350 |
+
</tbody>
|
| 351 |
+
</table>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
|
| 356 |
+
<!-- 页脚 -->
|
| 357 |
+
<footer class="mt-12 pt-6 border-t border-border text-center text-xs text-muted-foreground">
|
| 358 |
+
<p>© 2025 <a href="https://linux.do/u/thesmallhancat/summary" target="_blank" class="no-underline hover:underline" style="color: inherit;">TheSmallHanCat</a> && <a href="https://linux.do/u/tibbar/summary" target="_blank" class="no-underline hover:underline" style="color: inherit;">Tibbar</a>. All rights reserved.</p>
|
| 359 |
+
</footer>
|
| 360 |
+
</main>
|
| 361 |
+
|
| 362 |
+
<!-- 添加 Token 模态框 -->
|
| 363 |
+
<div id="addModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto">
|
| 364 |
+
<div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto">
|
| 365 |
+
<div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background">
|
| 366 |
+
<h3 class="text-lg font-semibold">添加 Token</h3>
|
| 367 |
+
<button onclick="closeAddModal()" class="text-muted-foreground hover:text-foreground">
|
| 368 |
+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 369 |
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
| 370 |
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
| 371 |
+
</svg>
|
| 372 |
+
</button>
|
| 373 |
+
</div>
|
| 374 |
+
<div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
| 375 |
+
<!-- Access Token -->
|
| 376 |
+
<div class="space-y-2">
|
| 377 |
+
<label class="text-sm font-medium">Access Token (AT) <span class="text-red-500">*</span></label>
|
| 378 |
+
<textarea id="addTokenAT" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Access Token 或使用下方 ST/RT 转换"></textarea>
|
| 379 |
+
<p class="text-xs text-muted-foreground">格式: eyJh... (JWT格式)</p>
|
| 380 |
+
</div>
|
| 381 |
+
|
| 382 |
+
<!-- Session Token -->
|
| 383 |
+
<div class="space-y-2">
|
| 384 |
+
<label class="text-sm font-medium">Session Token (ST) <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 385 |
+
<div class="flex gap-2">
|
| 386 |
+
<textarea id="addTokenST" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Session Token 后点击转换"></textarea>
|
| 387 |
+
<button onclick="convertST2AT()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 px-4 whitespace-nowrap h-auto">
|
| 388 |
+
ST→AT
|
| 389 |
+
</button>
|
| 390 |
+
</div>
|
| 391 |
+
<p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token</p>
|
| 392 |
+
</div>
|
| 393 |
+
|
| 394 |
+
<!-- Refresh Token -->
|
| 395 |
+
<div class="space-y-2">
|
| 396 |
+
<label class="text-sm font-medium">Refresh Token (RT) <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 397 |
+
<div class="flex gap-2">
|
| 398 |
+
<textarea id="addTokenRT" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Refresh Token 后点击转换"></textarea>
|
| 399 |
+
<button onclick="convertRT2AT()" class="inline-flex items-center justify-center rounded-md bg-green-600 text-white hover:bg-green-700 px-4 whitespace-nowrap h-auto">
|
| 400 |
+
RT→AT
|
| 401 |
+
</button>
|
| 402 |
+
</div>
|
| 403 |
+
<p class="text-xs text-muted-foreground">从移动端或其他客户端获取</p>
|
| 404 |
+
<p id="addRTRefreshHint" class="text-xs text-green-600 font-medium hidden">✓ RT已被刷新,已填入更新后的RT</p>
|
| 405 |
+
</div>
|
| 406 |
+
|
| 407 |
+
<!-- Remark -->
|
| 408 |
+
<div class="space-y-2">
|
| 409 |
+
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 410 |
+
<input id="addTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息">
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
<!-- 功能开关 -->
|
| 414 |
+
<div class="space-y-3 pt-2 border-t border-border">
|
| 415 |
+
<label class="text-sm font-medium">功能开关</label>
|
| 416 |
+
<div class="space-y-2">
|
| 417 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 418 |
+
<input type="checkbox" id="addTokenImageEnabled" checked class="h-4 w-4 rounded border-input">
|
| 419 |
+
<span class="text-sm font-medium">启用图片生成</span>
|
| 420 |
+
</label>
|
| 421 |
+
</div>
|
| 422 |
+
<div class="space-y-2">
|
| 423 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 424 |
+
<input type="checkbox" id="addTokenVideoEnabled" checked class="h-4 w-4 rounded border-input">
|
| 425 |
+
<span class="text-sm font-medium">启用视频生成</span>
|
| 426 |
+
</label>
|
| 427 |
+
</div>
|
| 428 |
+
</div>
|
| 429 |
+
</div>
|
| 430 |
+
<div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background">
|
| 431 |
+
<button onclick="closeAddModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
|
| 432 |
+
<button id="addTokenBtn" onclick="submitAddToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
|
| 433 |
+
<span id="addTokenBtnText">添加</span>
|
| 434 |
+
<svg id="addTokenBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 435 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 436 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 437 |
+
</svg>
|
| 438 |
+
</button>
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
|
| 443 |
+
<!-- 编辑 Token 模态框 -->
|
| 444 |
+
<div id="editModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto">
|
| 445 |
+
<div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto">
|
| 446 |
+
<div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background">
|
| 447 |
+
<h3 class="text-lg font-semibold">编辑 Token</h3>
|
| 448 |
+
<button onclick="closeEditModal()" class="text-muted-foreground hover:text-foreground">
|
| 449 |
+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 450 |
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
| 451 |
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
| 452 |
+
</svg>
|
| 453 |
+
</button>
|
| 454 |
+
</div>
|
| 455 |
+
<div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
| 456 |
+
<input type="hidden" id="editTokenId">
|
| 457 |
+
|
| 458 |
+
<!-- Access Token -->
|
| 459 |
+
<div class="space-y-2">
|
| 460 |
+
<label class="text-sm font-medium">Access Token (AT) <span class="text-red-500">*</span></label>
|
| 461 |
+
<textarea id="editTokenAT" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Access Token 或使用下方 ST/RT 转换"></textarea>
|
| 462 |
+
<p class="text-xs text-muted-foreground">格式: eyJh... (JWT格式)</p>
|
| 463 |
+
</div>
|
| 464 |
+
|
| 465 |
+
<!-- Session Token -->
|
| 466 |
+
<div class="space-y-2">
|
| 467 |
+
<label class="text-sm font-medium">Session Token (ST) <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 468 |
+
<div class="flex gap-2">
|
| 469 |
+
<textarea id="editTokenST" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Session Token 后点击转换"></textarea>
|
| 470 |
+
<button onclick="convertEditST2AT()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 px-4 whitespace-nowrap h-auto">
|
| 471 |
+
ST→AT
|
| 472 |
+
</button>
|
| 473 |
+
</div>
|
| 474 |
+
<p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token</p>
|
| 475 |
+
</div>
|
| 476 |
+
|
| 477 |
+
<!-- Refresh Token -->
|
| 478 |
+
<div class="space-y-2">
|
| 479 |
+
<label class="text-sm font-medium">Refresh Token (RT) <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 480 |
+
<div class="flex gap-2">
|
| 481 |
+
<textarea id="editTokenRT" rows="2" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="输入 Refresh Token 后点击转换"></textarea>
|
| 482 |
+
<button onclick="convertEditRT2AT()" class="inline-flex items-center justify-center rounded-md bg-green-600 text-white hover:bg-green-700 px-4 whitespace-nowrap h-auto">
|
| 483 |
+
RT→AT
|
| 484 |
+
</button>
|
| 485 |
+
</div>
|
| 486 |
+
<p class="text-xs text-muted-foreground">从移动端或其他客户端获取</p>
|
| 487 |
+
<p id="editRTRefreshHint" class="text-xs text-green-600 font-medium hidden">✓ RT已被刷新,已填入更新后的RT</p>
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
<!-- Remark -->
|
| 491 |
+
<div class="space-y-2">
|
| 492 |
+
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 493 |
+
<input id="editTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息">
|
| 494 |
+
</div>
|
| 495 |
+
|
| 496 |
+
<!-- 功能开关 -->
|
| 497 |
+
<div class="space-y-3 pt-2 border-t border-border">
|
| 498 |
+
<label class="text-sm font-medium">功能开关</label>
|
| 499 |
+
<div class="space-y-2">
|
| 500 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 501 |
+
<input type="checkbox" id="editTokenImageEnabled" class="h-4 w-4 rounded border-input">
|
| 502 |
+
<span class="text-sm font-medium">启用图片生成</span>
|
| 503 |
+
</label>
|
| 504 |
+
</div>
|
| 505 |
+
<div class="space-y-2">
|
| 506 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 507 |
+
<input type="checkbox" id="editTokenVideoEnabled" class="h-4 w-4 rounded border-input">
|
| 508 |
+
<span class="text-sm font-medium">启用视频生成</span>
|
| 509 |
+
</label>
|
| 510 |
+
</div>
|
| 511 |
+
</div>
|
| 512 |
+
</div>
|
| 513 |
+
<div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background">
|
| 514 |
+
<button onclick="closeEditModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
|
| 515 |
+
<button id="editTokenBtn" onclick="submitEditToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
|
| 516 |
+
<span id="editTokenBtnText">保存</span>
|
| 517 |
+
<svg id="editTokenBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 518 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 519 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 520 |
+
</svg>
|
| 521 |
+
</button>
|
| 522 |
+
</div>
|
| 523 |
+
</div>
|
| 524 |
+
</div>
|
| 525 |
+
|
| 526 |
+
<!-- Sora2 激活模态框 -->
|
| 527 |
+
<div id="sora2Modal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
|
| 528 |
+
<div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
|
| 529 |
+
<div class="flex items-center justify-between p-5 border-b border-border">
|
| 530 |
+
<h3 class="text-lg font-semibold">激活 Sora2</h3>
|
| 531 |
+
<button onclick="closeSora2Modal()" class="text-muted-foreground hover:text-foreground">
|
| 532 |
+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 533 |
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
| 534 |
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
| 535 |
+
</svg>
|
| 536 |
+
</button>
|
| 537 |
+
</div>
|
| 538 |
+
<div class="p-5 space-y-4">
|
| 539 |
+
<input type="hidden" id="sora2TokenId">
|
| 540 |
+
<div>
|
| 541 |
+
<label class="text-sm font-medium mb-2 block">Sora2 邀请码</label>
|
| 542 |
+
<input id="sora2InviteCode" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入6位邀请码,例如:0ZSKEG">
|
| 543 |
+
<p class="text-xs text-muted-foreground mt-1">输入Sora2邀请码以激活该Token的Sora2功能</p>
|
| 544 |
+
</div>
|
| 545 |
+
</div>
|
| 546 |
+
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
|
| 547 |
+
<button onclick="closeSora2Modal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
|
| 548 |
+
<button id="sora2ActivateBtn" onclick="submitSora2Activate()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
|
| 549 |
+
<span id="sora2ActivateBtnText">激活</span>
|
| 550 |
+
<svg id="sora2ActivateBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 551 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 552 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 553 |
+
</svg>
|
| 554 |
+
</button>
|
| 555 |
+
</div>
|
| 556 |
+
</div>
|
| 557 |
+
</div>
|
| 558 |
+
|
| 559 |
+
<script>
|
| 560 |
+
let allTokens=[];
|
| 561 |
+
const $=(id)=>document.getElementById(id),
|
| 562 |
+
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
|
| 563 |
+
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
|
| 564 |
+
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=d.total_images||0;$('statVideos').textContent=d.total_videos||0;$('statErrors').textContent=d.total_errors||0}catch(e){console.error('加载统计失败:',e)}},
|
| 565 |
+
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
|
| 566 |
+
formatExpiry=exp=>{if(!exp)return'-';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});if(diff<0)return`<span class="text-red-600">${dateStr} ${timeStr}</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600">${dateStr} ${timeStr}</span>`;return`${dateStr} ${timeStr}`},
|
| 567 |
+
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
|
| 568 |
+
formatSora2=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_total_count-t.sora2_redeemed_count;const tooltipText=`邀请码: ${t.sora2_invite_code||'无'}\n可用次数: ${remaining}/${t.sora2_total_count}\n已用次数: ${t.sora2_redeemed_count}`;return`<div class="inline-flex items-center gap-1"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-green-50 text-green-700 cursor-pointer" title="${tooltipText}" onclick="copySora2Code('${t.sora2_invite_code||''}')">支持</span><span class="text-xs text-muted-foreground" title="${tooltipText}">${remaining}/${t.sora2_total_count}</span></div>`}else if(t.sora2_supported===false){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-700 cursor-pointer" title="点击使用邀请码激活" onclick="openSora2Modal(${t.id})">不支持</span>`}else{return'-'}},
|
| 569 |
+
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
|
| 570 |
+
formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`<span class="text-xs">${remaining}</span>`}else{return'-'}},
|
| 571 |
+
renderTokens=()=>{const tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=(t.video_enabled&&t.sora2_supported)?`${t.video_count||0}`:'-';return`<tr><td class="py-2.5 px-3">${t.email}</td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700'}">${t.is_active?'活跃':'禁用'}</span></td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3 text-xs">${formatSora2(t)}</td><td class="py-2.5 px-3">${formatSora2Remaining(t)}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('')},
|
| 572 |
+
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
| 573 |
+
openAddModal=()=>$('addModal').classList.remove('hidden'),
|
| 574 |
+
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addRTRefreshHint').classList.add('hidden')},
|
| 575 |
+
openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenAT').value=token.token||'';$('editTokenST').value=token.st||'';$('editTokenRT').value=token.rt||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editModal').classList.remove('hidden')},
|
| 576 |
+
closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').value='';$('editTokenRemark').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editRTRefreshHint').classList.add('hidden')},
|
| 577 |
+
submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').value.trim(),remark=$('editTokenRemark').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked;if(!id)return showToast('Token ID无效','error');if(!at)return showToast('请输入 Access Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({token:at,st:st||null,rt:rt||null,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}},
|
| 578 |
+
convertST2AT=async()=>{const st=$('addTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
| 579 |
+
convertRT2AT=async()=>{const rt=$('addTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const hint=$('addRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;if(d.refresh_token){$('addTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
| 580 |
+
convertEditST2AT=async()=>{const st=$('editTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
| 581 |
+
convertEditRT2AT=async()=>{const rt=$('editTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const hint=$('editRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;if(d.refresh_token){$('editTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
| 582 |
+
submitAddToken=async()=>{const at=$('addTokenAT').value.trim(),st=$('addTokenST').value.trim(),rt=$('addTokenRT').value.trim(),remark=$('addTokenRemark').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked;if(!at)return showToast('请输入 Access Token 或使用 ST/RT 转换','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({token:at,st:st||null,rt:rt||null,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}if(r.status===409){const d=await r.json();const msg=d.detail||'Token 已存在';btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');if(confirm(msg+'\n\n是否删除旧 Token 后重新添加?')){const existingToken=allTokens.find(t=>t.token===at);if(existingToken){const deleted=await deleteToken(existingToken.id,true);if(deleted){showToast('正在重新添加...','info');setTimeout(()=>submitAddToken(),500)}else{showToast('删除旧 Token 失败','error')}}}return}const d=await r.json();if(d.success){closeAddModal();await refreshTokens();showToast('Token添加成功','success')}else{showToast('添加失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('添加失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden')}},
|
| 583 |
+
testToken=async(id)=>{try{showToast('正在测试Token...','info');const r=await apiRequest(`/api/tokens/${id}/test`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success&&d.status==='success'){let msg=`Token有效!用户: ${d.email||'未知'}`;if(d.sora2_supported){const remaining=d.sora2_total_count-d.sora2_redeemed_count;msg+=`\nSora2: 支持 (${remaining}/${d.sora2_total_count})`;if(d.sora2_remaining_count!==undefined){msg+=`\n可用次数: ${d.sora2_remaining_count}`}}showToast(msg,'success');await refreshTokens()}else{showToast(`Token无效: ${d.message||'未知错误'}`,'error')}}catch(e){showToast('测试失败: '+e.message,'error')}},
|
| 584 |
+
toggleToken=async(id,isActive)=>{const action=isActive?'disable':'enable';try{const r=await apiRequest(`/api/tokens/${id}/${action}`,{method:'POST'});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast(isActive?'Token已禁用':'Token已启用','success')):showToast('操作失败','error')}catch(e){showToast('操作失败: '+e.message,'error')}},
|
| 585 |
+
toggleTokenStatus=async(id,active)=>{try{const r=await apiRequest(`/api/tokens/${id}/status`,{method:'PUT',body:JSON.stringify({is_active:active})});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast('状态更新成功','success')):showToast('更新失败','error')}catch(e){showToast('更新失败: '+e.message,'error')}},
|
| 586 |
+
deleteToken=async(id,skipConfirm=false)=>{if(!skipConfirm&&!confirm('确定要删除这个Token吗?'))return;try{const r=await apiRequest(`/api/tokens/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();if(!skipConfirm)showToast('删除成功','success');return true}else{if(!skipConfirm)showToast('删除失败','error');return false}}catch(e){if(!skipConfirm)showToast('删除失败: '+e.message,'error');return false}},
|
| 587 |
+
copySora2Code=async(code)=>{if(!code){showToast('没有可复制的邀请码','error');return}try{if(navigator.clipboard&&navigator.clipboard.writeText){await navigator.clipboard.writeText(code);showToast(`邀请码已复制: ${code}`,'success')}else{const textarea=document.createElement('textarea');textarea.value=code;textarea.style.position='fixed';textarea.style.opacity='0';document.body.appendChild(textarea);textarea.select();const success=document.execCommand('copy');document.body.removeChild(textarea);if(success){showToast(`邀请码已复制: ${code}`,'success')}else{showToast('复制失败: 浏览器不支持','error')}}}catch(e){showToast('复制失败: '+e.message,'error')}},
|
| 588 |
+
openSora2Modal=(id)=>{$('sora2TokenId').value=id;$('sora2InviteCode').value='';$('sora2Modal').classList.remove('hidden')},
|
| 589 |
+
closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''},
|
| 590 |
+
submitSora2Activate=async()=>{const tokenId=parseInt($('sora2TokenId').value),inviteCode=$('sora2InviteCode').value.trim();if(!tokenId)return showToast('Token ID无效','error');if(!inviteCode)return showToast('请输入邀请码','error');if(inviteCode.length!==6)return showToast('邀请码必须是6位','error');const btn=$('sora2ActivateBtn'),btnText=$('sora2ActivateBtnText'),btnSpinner=$('sora2ActivateBtnSpinner');btn.disabled=true;btnText.textContent='激活中...';btnSpinner.classList.remove('hidden');try{showToast('正在激活Sora2...','info');const r=await apiRequest(`/api/tokens/${tokenId}/sora2/activate?invite_code=${inviteCode}`,{method:'POST'});if(!r){btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeSora2Modal();await refreshTokens();if(d.already_accepted){showToast('Sora2已激活(之前已接受)','success')}else{showToast(`Sora2激活成功!邀请码: ${d.invite_code||'无'}`,'success')}}else{showToast('激活失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('激活失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden')}},
|
| 591 |
+
loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}},
|
| 592 |
+
saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
| 593 |
+
updateAdminPassword=async()=>{const username=$('cfgAdminUsername').value.trim(),oldPwd=$('cfgOldPassword').value.trim(),newPwd=$('cfgNewPassword').value.trim();if(!oldPwd||!newPwd)return showToast('请输入旧密码和新密码','error');if(newPwd.length<4)return showToast('新密码至少4个字符','error');try{const r=await apiRequest('/api/admin/password',{method:'POST',body:JSON.stringify({username:username||undefined,old_password:oldPwd,new_password:newPwd})});if(!r)return;const d=await r.json();if(d.success){showToast('密码修改成功,请重新登录','success');setTimeout(()=>{localStorage.removeItem('adminToken');location.href='/login'},2000)}else{showToast('修改失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}},
|
| 594 |
+
updateAPIKey=async()=>{const newKey=$('cfgNewAPIKey').value.trim();if(!newKey)return showToast('请输入新的 API Key','error');if(newKey.length<6)return showToast('API Key 至少6个字符','error');if(!confirm('确定要更新 API Key 吗?更新后需要通知所有客户端使用新密钥。'))return;try{const r=await apiRequest('/api/admin/apikey',{method:'POST',body:JSON.stringify({new_api_key:newKey})});if(!r)return;const d=await r.json();if(d.success){showToast('API Key 更新成功','success');$('cfgCurrentAPIKey').value=newKey;$('cfgNewAPIKey').value=''}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
|
| 595 |
+
toggleDebugMode=async()=>{const enabled=$('cfgDebugEnabled').checked;try{const r=await apiRequest('/api/admin/debug',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r)return;const d=await r.json();if(d.success){showToast(enabled?'调试模式已开启':'调试模式已关闭','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('cfgDebugEnabled').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('cfgDebugEnabled').checked=!enabled}},
|
| 596 |
+
loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}},
|
| 597 |
+
saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
| 598 |
+
loadWatermarkFreeConfig=async()=>{try{const r=await apiRequest('/api/watermark-free/config');if(!r)return;const d=await r.json();$('cfgWatermarkFreeEnabled').checked=d.watermark_free_enabled||false;$('cfgParseMethod').value=d.parse_method||'third_party';$('cfgCustomParseUrl').value=d.custom_parse_url||'';$('cfgCustomParseToken').value=d.custom_parse_token||'';toggleWatermarkFreeOptions();toggleCustomParseOptions()}catch(e){console.error('加载无水印模式配置失败:',e)}},
|
| 599 |
+
saveWatermarkFreeConfig=async()=>{try{const enabled=$('cfgWatermarkFreeEnabled').checked,parseMethod=$('cfgParseMethod').value,customUrl=$('cfgCustomParseUrl').value.trim(),customToken=$('cfgCustomParseToken').value.trim();if(enabled&&parseMethod==='custom'){if(!customUrl)return showToast('请输入解析服务器地址','error');if(!customToken)return showToast('请输入访问密钥','error')}const r=await apiRequest('/api/watermark-free/config',{method:'POST',body:JSON.stringify({watermark_free_enabled:enabled,parse_method:parseMethod,custom_parse_url:customUrl||null,custom_parse_token:customToken||null})});if(!r)return;const d=await r.json();d.success?showToast('无水印模式配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
| 600 |
+
toggleWatermarkFreeOptions=()=>{const enabled=$('cfgWatermarkFreeEnabled').checked;$('watermarkFreeOptions').style.display=enabled?'block':'none'},
|
| 601 |
+
toggleCustomParseOptions=()=>{const method=$('cfgParseMethod').value;$('customParseOptions').style.display=method==='custom'?'block':'none'},
|
| 602 |
+
toggleCacheOptions=()=>{const enabled=$('cfgCacheEnabled').checked;$('cacheOptions').style.display=enabled?'block':'none'},
|
| 603 |
+
loadCacheConfig=async()=>{try{console.log('开始加载缓存配置...');const r=await apiRequest('/api/cache/config');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('缓存配置数据:',d);if(d.success&&d.config){const enabled=d.config.enabled!==false;const timeout=d.config.timeout||7200;const baseUrl=d.config.base_url||'';const effectiveUrl=d.config.effective_base_url||'';console.log('设置缓存启用:',enabled);console.log('设置超时时间:',timeout);console.log('设置域名:',baseUrl);console.log('生效URL:',effectiveUrl);$('cfgCacheEnabled').checked=enabled;$('cfgCacheTimeout').value=timeout;$('cfgCacheBaseUrl').value=baseUrl;if(effectiveUrl){$('cacheEffectiveUrlValue').textContent=effectiveUrl;$('cacheEffectiveUrl').classList.remove('hidden')}else{$('cacheEffectiveUrl').classList.add('hidden')}toggleCacheOptions();console.log('缓存配置加载成功')}else{console.error('缓存配置数据格式错误:',d)}}catch(e){console.error('加载缓存配置失败:',e);showToast('加载缓存配置失败: '+e.message,'error')}},
|
| 604 |
+
loadGenerationTimeout=async()=>{try{console.log('开始加载生成超时配置...');const r=await apiRequest('/api/generation/timeout');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('生成超时配置数据:',d);if(d.success&&d.config){const imageTimeout=d.config.image_timeout||300;const videoTimeout=d.config.video_timeout||1500;console.log('设置图片超时:',imageTimeout);console.log('设置视频超时:',videoTimeout);$('cfgImageTimeout').value=imageTimeout;$('cfgVideoTimeout').value=videoTimeout;console.log('生成超时配置加载成功')}else{console.error('生成超时配置数据格式错误:',d)}}catch(e){console.error('加载生成超时配置失败:',e);showToast('加载生成超时配置失败: '+e.message,'error')}},
|
| 605 |
+
saveCacheConfig=async()=>{const enabled=$('cfgCacheEnabled').checked,timeout=parseInt($('cfgCacheTimeout').value)||7200,baseUrl=$('cfgCacheBaseUrl').value.trim();console.log('保存缓存配置:',{enabled,timeout,baseUrl});if(timeout<60||timeout>86400)return showToast('缓存超时时间必须在 60-86400 秒之间','error');if(baseUrl&&!baseUrl.startsWith('http://')&&!baseUrl.startsWith('https://'))return showToast('域名必须以 http:// 或 https:// 开头','error');try{console.log('保存缓存启用状态...');const r0=await apiRequest('/api/cache/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r0){console.error('保存缓存启用状态请求失败');return}const d0=await r0.json();console.log('缓存启用状态保存结果:',d0);if(!d0.success){console.error('保存缓存启用状态失败:',d0);return showToast('保存缓存启用状态失败','error')}console.log('保存超时时间...');const r1=await apiRequest('/api/cache/config',{method:'POST',body:JSON.stringify({timeout:timeout})});if(!r1){console.error('保存超时时间请求失败');return}const d1=await r1.json();console.log('超时时间保存结果:',d1);if(!d1.success){console.error('保存超时时间失败:',d1);return showToast('保存超时时间失败','error')}console.log('保存域名...');const r2=await apiRequest('/api/cache/base-url',{method:'POST',body:JSON.stringify({base_url:baseUrl})});if(!r2){console.error('保存域名请求失败');return}const d2=await r2.json();console.log('域名保存结果:',d2);if(d2.success){showToast('缓存配置保存成功','success');console.log('等待配置文件写入完成...');await new Promise(r=>setTimeout(r,200));console.log('重新加载配置...');await loadCacheConfig()}else{console.error('保存域名失败:',d2);showToast('保存域名失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
|
| 606 |
+
saveGenerationTimeout=async()=>{const imageTimeout=parseInt($('cfgImageTimeout').value)||300,videoTimeout=parseInt($('cfgVideoTimeout').value)||1500;console.log('保存生成超时配置:',{imageTimeout,videoTimeout});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('生成超时配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadGenerationTimeout()}else{console.error('保存失败:',d);showToast('保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
|
| 607 |
+
toggleATAutoRefresh=async()=>{try{const enabled=$('atAutoRefreshToggle').checked;const r=await apiRequest('/api/token-refresh/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r){$('atAutoRefreshToggle').checked=!enabled;return}const d=await r.json();if(d.success){showToast(enabled?'AT自动刷新已启用':'AT自动刷新已禁用','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('atAutoRefreshToggle').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('atAutoRefreshToggle').checked=!enabled}},
|
| 608 |
+
loadATAutoRefreshConfig=async()=>{try{const r=await apiRequest('/api/token-refresh/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('atAutoRefreshToggle').checked=d.config.at_auto_refresh_enabled||false}else{console.error('AT自动刷新配置数据格式错误:',d)}}catch(e){console.error('加载AT自动刷新配置失败:',e)}},
|
| 609 |
+
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>`<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${l.status_code}</span></td><td class="py-2.5 px-3">${l.duration.toFixed(2)}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td></tr>`).join('')}catch(e){console.error('加载日志失败:',e)}},
|
| 610 |
+
refreshLogs=async()=>{await loadLogs()},
|
| 611 |
+
showToast=(m,t='info')=>{const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)},
|
| 612 |
+
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
|
| 613 |
+
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig()}else if(t==='logs'){loadLogs()}};
|
| 614 |
+
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
|
| 615 |
+
</script>
|
| 616 |
+
</body>
|
| 617 |
+
</html>
|