Admin commited on
Commit
22a3c56
·
1 Parent(s): b4b63c6

整合sora2api

Browse files
.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
- # 环境变量 (在 Hugging Face Spaces 中设置)
11
- # ENV GEMINI_API_KEYS=your_key_1,your_key_2,your_key_3
 
12
 
13
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
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
- title: Google
3
- emoji: 📉
4
- colorFrom: gray
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ # Sora2API
2
+
3
+ <div align="center">
4
+
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/)
7
+ [![FastAPI](https://img.shields.io/badge/fastapi-0.119.0-green.svg)](https://fastapi.tiangolo.com/)
8
+ [![Docker](https://img.shields.io/badge/docker-supported-blue.svg)](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
- httpx
4
- python-dotenv
5
- requests
6
- apscheduler
 
 
 
 
 
 
 
 
 
 
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"![Generated Image]({url})" 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"![Generated Image]({content})"
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>