From 88d74d0ad048fc88d6d5acfd955550a686d3ddba Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Sat, 8 Nov 2025 22:45:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E8=A7=A3=E6=9E=90=E6=8E=A5=E5=8F=A3=E3=80=81=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=AE=BE=E7=BD=AE=E7=94=A8=E6=88=B7=E5=90=8D=E3=80=81?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E9=A2=9D=E5=BA=A6=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/setting.toml | 4 +- config/setting_warp.toml | 1 - requirements.txt | 4 +- src/api/admin.py | 46 +++-- src/core/config.py | 19 +- src/core/database.py | 119 ++++++++--- src/core/models.py | 7 +- src/services/generation_handler.py | 64 +++++- src/services/load_balancer.py | 30 ++- src/services/sora_client.py | 92 +++++++++ src/services/token_manager.py | 308 +++++++++++++++++++++++++++-- static/manage.html | 54 +++-- 12 files changed, 663 insertions(+), 85 deletions(-) diff --git a/config/setting.toml b/config/setting.toml index 2bd9873..9ba56ba 100644 --- a/config/setting.toml +++ b/config/setting.toml @@ -29,7 +29,6 @@ image_timeout = 300 video_timeout = 1500 [admin] -video_cooldown_threshold = 30 error_ban_threshold = 3 [proxy] @@ -38,6 +37,9 @@ proxy_url = "" [watermark_free] watermark_free_enabled = false +parse_method = "third_party" +custom_parse_url = "" +custom_parse_token = "" [video_length] default_length = "10s" diff --git a/config/setting_warp.toml b/config/setting_warp.toml index a56e87e..9bfb3ce 100644 --- a/config/setting_warp.toml +++ b/config/setting_warp.toml @@ -29,7 +29,6 @@ image_timeout = 300 video_timeout = 1500 [admin] -video_cooldown_threshold = 30 error_ban_threshold = 3 [proxy] diff --git a/requirements.txt b/requirements.txt index 5c01f5f..707fe3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,6 @@ python-dotenv==1.0.1 pydantic==2.10.4 pydantic-settings==2.7.0 tomli==2.2.1 -toml \ No newline at end of file +toml +faker==24.0.0 +python-dateutil==2.8.2 \ No newline at end of file diff --git a/src/api/admin.py b/src/api/admin.py index e317ce2..e603dbf 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -79,7 +79,6 @@ class UpdateTokenRequest(BaseModel): remark: Optional[str] = None class UpdateAdminConfigRequest(BaseModel): - video_cooldown_threshold: int error_ban_threshold: int class UpdateProxyConfigRequest(BaseModel): @@ -109,6 +108,9 @@ class UpdateGenerationTimeoutRequest(BaseModel): class UpdateWatermarkFreeConfigRequest(BaseModel): watermark_free_enabled: bool + parse_method: Optional[str] = "third_party" # "third_party" or "custom" + custom_parse_url: Optional[str] = None + custom_parse_token: Optional[str] = None class UpdateVideoLengthConfigRequest(BaseModel): default_length: str # "10s" or "15s" @@ -139,7 +141,7 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]: """Get all tokens with statistics""" tokens = await token_manager.get_all_tokens() result = [] - + for token in tokens: stats = await db.get_token_stats(token.id) result.append({ @@ -167,9 +169,11 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]: "sora2_supported": token.sora2_supported, "sora2_invite_code": token.sora2_invite_code, "sora2_redeemed_count": token.sora2_redeemed_count, - "sora2_total_count": token.sora2_total_count + "sora2_total_count": token.sora2_total_count, + "sora2_remaining_count": token.sora2_remaining_count, + "sora2_cooldown_until": token.sora2_cooldown_until.isoformat() if token.sora2_cooldown_until else None }) - + return result @router.post("/api/tokens") @@ -275,7 +279,8 @@ async def test_token(token_id: int, token: str = Depends(verify_admin_token)): "sora2_supported": result.get("sora2_supported"), "sora2_invite_code": result.get("sora2_invite_code"), "sora2_redeemed_count": result.get("sora2_redeemed_count"), - "sora2_total_count": result.get("sora2_total_count") + "sora2_total_count": result.get("sora2_total_count"), + "sora2_remaining_count": result.get("sora2_remaining_count") }) return response @@ -316,7 +321,6 @@ async def get_admin_config(token: str = Depends(verify_admin_token)) -> dict: """Get admin configuration""" admin_config = await db.get_admin_config() return { - "video_cooldown_threshold": admin_config.video_cooldown_threshold, "error_ban_threshold": admin_config.error_ban_threshold, "api_key": config.api_key, "admin_username": config.admin_username, @@ -331,7 +335,6 @@ async def update_admin_config( """Update admin configuration""" try: admin_config = AdminConfig( - video_cooldown_threshold=request.video_cooldown_threshold, error_ban_threshold=request.error_ban_threshold ) await db.update_admin_config(admin_config) @@ -480,9 +483,12 @@ async def update_proxy_config( @router.get("/api/watermark-free/config") async def get_watermark_free_config(token: str = Depends(verify_admin_token)) -> dict: """Get watermark-free mode configuration""" - config = await db.get_watermark_free_config() + config_obj = await db.get_watermark_free_config() return { - "watermark_free_enabled": config.watermark_free_enabled + "watermark_free_enabled": config_obj.watermark_free_enabled, + "parse_method": config_obj.parse_method, + "custom_parse_url": config_obj.custom_parse_url, + "custom_parse_token": config_obj.custom_parse_token } @router.post("/api/watermark-free/config") @@ -492,7 +498,12 @@ async def update_watermark_free_config( ): """Update watermark-free mode configuration""" try: - await db.update_watermark_free_config(request.watermark_free_enabled) + await db.update_watermark_free_config( + request.watermark_free_enabled, + request.parse_method, + request.custom_parse_url, + request.custom_parse_token + ) # Update in-memory config from ..core.config import config @@ -549,13 +560,23 @@ async def activate_sora2( # Get new invite code after activation sora2_info = await token_manager.get_sora2_invite_code(token_obj.token) + # Get remaining count + sora2_remaining_count = 0 + try: + remaining_info = await token_manager.get_sora2_remaining_count(token_obj.token) + if remaining_info.get("success"): + sora2_remaining_count = remaining_info.get("remaining_count", 0) + except Exception as e: + print(f"Failed to get Sora2 remaining count: {e}") + # Update database await db.update_token_sora2( token_id, supported=True, invite_code=sora2_info.get("invite_code"), redeemed_count=sora2_info.get("redeemed_count", 0), - total_count=sora2_info.get("total_count", 0) + total_count=sora2_info.get("total_count", 0), + remaining_count=sora2_remaining_count ) return { @@ -564,7 +585,8 @@ async def activate_sora2( "already_accepted": result.get("already_accepted", False), "invite_code": sora2_info.get("invite_code"), "redeemed_count": sora2_info.get("redeemed_count", 0), - "total_count": sora2_info.get("total_count", 0) + "total_count": sora2_info.get("total_count", 0), + "sora2_remaining_count": sora2_remaining_count } else: return { diff --git a/src/core/config.py b/src/core/config.py index f323f68..5d47188 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -145,13 +145,28 @@ class Config: @property def watermark_free_enabled(self) -> bool: """Get watermark-free mode enabled status""" - return self._config.get("watermark_free", {}).get("enabled", False) + return self._config.get("watermark_free", {}).get("watermark_free_enabled", False) def set_watermark_free_enabled(self, enabled: bool): """Set watermark-free mode enabled/disabled""" if "watermark_free" not in self._config: self._config["watermark_free"] = {} - self._config["watermark_free"]["enabled"] = enabled + self._config["watermark_free"]["watermark_free_enabled"] = enabled + + @property + def watermark_free_parse_method(self) -> str: + """Get watermark-free parse method""" + return self._config.get("watermark_free", {}).get("parse_method", "third_party") + + @property + def watermark_free_custom_url(self) -> str: + """Get custom parse server URL""" + return self._config.get("watermark_free", {}).get("custom_parse_url", "") + + @property + def watermark_free_custom_token(self) -> str: + """Get custom parse server access token""" + return self._config.get("watermark_free", {}).get("custom_parse_token", "") # Global config instance config = Config() diff --git a/src/core/database.py b/src/core/database.py index d69358e..c5b2a07 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -47,7 +47,9 @@ class Database: sora2_supported BOOLEAN, sora2_invite_code TEXT, sora2_redeemed_count INTEGER DEFAULT 0, - sora2_total_count INTEGER DEFAULT 0 + sora2_total_count INTEGER DEFAULT 0, + sora2_remaining_count INTEGER DEFAULT 0, + sora2_cooldown_until TIMESTAMP ) """) @@ -71,7 +73,33 @@ class Database: await db.execute("ALTER TABLE tokens ADD COLUMN sora2_total_count INTEGER DEFAULT 0") except: pass # Column already exists - + + try: + await db.execute("ALTER TABLE tokens ADD COLUMN sora2_remaining_count INTEGER DEFAULT 0") + except: + pass # Column already exists + + try: + await db.execute("ALTER TABLE tokens ADD COLUMN sora2_cooldown_until TIMESTAMP") + except: + pass # Column already exists + + # Migrate watermark_free_config table - add new columns + try: + await db.execute("ALTER TABLE watermark_free_config ADD COLUMN parse_method TEXT DEFAULT 'third_party'") + except: + pass # Column already exists + + try: + await db.execute("ALTER TABLE watermark_free_config ADD COLUMN custom_parse_url TEXT") + except: + pass # Column already exists + + try: + await db.execute("ALTER TABLE watermark_free_config ADD COLUMN custom_parse_token TEXT") + except: + pass # Column already exists + # Token stats table await db.execute(""" CREATE TABLE IF NOT EXISTS token_stats ( @@ -122,7 +150,6 @@ class Database: await db.execute(""" CREATE TABLE IF NOT EXISTS admin_config ( id INTEGER PRIMARY KEY DEFAULT 1, - video_cooldown_threshold INTEGER DEFAULT 30, error_ban_threshold INTEGER DEFAULT 3, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) @@ -144,6 +171,9 @@ class Database: CREATE TABLE IF NOT EXISTS watermark_free_config ( id INTEGER PRIMARY KEY DEFAULT 1, watermark_free_enabled BOOLEAN DEFAULT 0, + parse_method TEXT DEFAULT 'third_party', + custom_parse_url TEXT, + custom_parse_token TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) @@ -167,8 +197,8 @@ class Database: # Insert default admin config await db.execute(""" - INSERT OR IGNORE INTO admin_config (id, video_cooldown_threshold, error_ban_threshold) - VALUES (1, 30, 3) + INSERT OR IGNORE INTO admin_config (id, error_ban_threshold) + VALUES (1, 3) """) # Insert default proxy config @@ -179,8 +209,8 @@ class Database: # Insert default watermark-free config await db.execute(""" - INSERT OR IGNORE INTO watermark_free_config (id, watermark_free_enabled) - VALUES (1, 0) + INSERT OR IGNORE INTO watermark_free_config (id, watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token) + VALUES (1, 0, 'third_party', NULL, NULL) """) # Insert default video length config @@ -196,14 +226,13 @@ class Database: async with aiosqlite.connect(self.db_path) as db: # Initialize admin config admin_config = config_dict.get("admin", {}) - video_cooldown_threshold = admin_config.get("video_cooldown_threshold", 30) error_ban_threshold = admin_config.get("error_ban_threshold", 3) await db.execute(""" UPDATE admin_config - SET video_cooldown_threshold = ?, error_ban_threshold = ?, updated_at = CURRENT_TIMESTAMP + SET error_ban_threshold = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 - """, (video_cooldown_threshold, error_ban_threshold)) + """, (error_ban_threshold,)) # Initialize proxy config proxy_config = config_dict.get("proxy", {}) @@ -221,12 +250,20 @@ class Database: # Initialize watermark-free config watermark_config = config_dict.get("watermark_free", {}) watermark_free_enabled = watermark_config.get("watermark_free_enabled", False) + parse_method = watermark_config.get("parse_method", "third_party") + custom_parse_url = watermark_config.get("custom_parse_url", "") + custom_parse_token = watermark_config.get("custom_parse_token", "") + + # Convert empty strings to None + custom_parse_url = custom_parse_url if custom_parse_url else None + custom_parse_token = custom_parse_token if custom_parse_token else None await db.execute(""" UPDATE watermark_free_config - SET watermark_free_enabled = ?, updated_at = CURRENT_TIMESTAMP + SET watermark_free_enabled = ?, parse_method = ?, custom_parse_url = ?, + custom_parse_token = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 - """, (watermark_free_enabled,)) + """, (watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token)) # Initialize video length config video_length_config = config_dict.get("video_length", {}) @@ -249,13 +286,14 @@ class Database: cursor = await db.execute(""" INSERT INTO tokens (token, email, username, name, st, rt, remark, expiry_time, is_active, plan_type, plan_title, subscription_end, sora2_supported, sora2_invite_code, - sora2_redeemed_count, sora2_total_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + sora2_redeemed_count, sora2_total_count, sora2_remaining_count, sora2_cooldown_until) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (token.token, token.email, "", token.name, token.st, token.rt, token.remark, token.expiry_time, token.is_active, token.plan_type, token.plan_title, token.subscription_end, token.sora2_supported, token.sora2_invite_code, - token.sora2_redeemed_count, token.sora2_total_count)) + token.sora2_redeemed_count, token.sora2_total_count, + token.sora2_remaining_count, token.sora2_cooldown_until)) await db.commit() token_id = cursor.lastrowid @@ -328,14 +366,30 @@ class Database: await db.commit() async def update_token_sora2(self, token_id: int, supported: bool, invite_code: Optional[str] = None, - redeemed_count: int = 0, total_count: int = 0): + redeemed_count: int = 0, total_count: int = 0, remaining_count: int = 0): """Update token Sora2 support info""" async with aiosqlite.connect(self.db_path) as db: await db.execute(""" UPDATE tokens - SET sora2_supported = ?, sora2_invite_code = ?, sora2_redeemed_count = ?, sora2_total_count = ? + SET sora2_supported = ?, sora2_invite_code = ?, sora2_redeemed_count = ?, sora2_total_count = ?, sora2_remaining_count = ? WHERE id = ? - """, (supported, invite_code, redeemed_count, total_count, token_id)) + """, (supported, invite_code, redeemed_count, total_count, remaining_count, token_id)) + await db.commit() + + async def update_token_sora2_remaining(self, token_id: int, remaining_count: int): + """Update token Sora2 remaining count""" + async with aiosqlite.connect(self.db_path) as db: + await db.execute(""" + UPDATE tokens SET sora2_remaining_count = ? WHERE id = ? + """, (remaining_count, token_id)) + await db.commit() + + async def update_token_sora2_cooldown(self, token_id: int, cooldown_until: Optional[datetime]): + """Update token Sora2 cooldown time""" + async with aiosqlite.connect(self.db_path) as db: + await db.execute(""" + UPDATE tokens SET sora2_cooldown_until = ? WHERE id = ? + """, (cooldown_until, token_id)) await db.commit() async def update_token_cooldown(self, token_id: int, cooled_until: datetime): @@ -533,10 +587,10 @@ class Database: """Update admin configuration""" async with aiosqlite.connect(self.db_path) as db: await db.execute(""" - UPDATE admin_config - SET video_cooldown_threshold = ?, error_ban_threshold = ?, updated_at = CURRENT_TIMESTAMP + UPDATE admin_config + SET error_ban_threshold = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 - """, (config.video_cooldown_threshold, config.error_ban_threshold)) + """, (config.error_ban_threshold,)) await db.commit() # Proxy config operations @@ -571,14 +625,25 @@ class Database: return WatermarkFreeConfig(**dict(row)) return WatermarkFreeConfig() - async def update_watermark_free_config(self, enabled: bool): + async def update_watermark_free_config(self, enabled: bool, parse_method: str = None, + custom_parse_url: str = None, custom_parse_token: str = None): """Update watermark-free configuration""" async with aiosqlite.connect(self.db_path) as db: - await db.execute(""" - UPDATE watermark_free_config - SET watermark_free_enabled = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = 1 - """, (enabled,)) + if parse_method is None and custom_parse_url is None and custom_parse_token is None: + # Only update enabled status + await db.execute(""" + UPDATE watermark_free_config + SET watermark_free_enabled = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + """, (enabled,)) + else: + # Update all fields + await db.execute(""" + UPDATE watermark_free_config + SET watermark_free_enabled = ?, parse_method = ?, custom_parse_url = ?, + custom_parse_token = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + """, (enabled, parse_method or "third_party", custom_parse_url, custom_parse_token)) await db.commit() # Video length config operations diff --git a/src/core/models.py b/src/core/models.py index 4ccd4e0..7d49d0f 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -27,6 +27,9 @@ class Token(BaseModel): sora2_invite_code: Optional[str] = None # Sora2邀请码 sora2_redeemed_count: int = 0 # Sora2已用次数 sora2_total_count: int = 0 # Sora2总次数 + # Sora2 剩余次数 + sora2_remaining_count: int = 0 # Sora2剩余可用次数 + sora2_cooldown_until: Optional[datetime] = None # Sora2冷却时间 class TokenStats(BaseModel): """Token statistics""" @@ -65,7 +68,6 @@ class RequestLog(BaseModel): class AdminConfig(BaseModel): """Admin configuration""" id: int = 1 - video_cooldown_threshold: int = 30 error_ban_threshold: int = 3 updated_at: Optional[datetime] = None @@ -81,6 +83,9 @@ class WatermarkFreeConfig(BaseModel): """Watermark-free mode configuration""" id: int = 1 watermark_free_enabled: bool = False + parse_method: str = "third_party" # "third_party" or "custom" + custom_parse_url: Optional[str] = None # Custom parse server URL + custom_parse_token: Optional[str] = None # Custom parse server access token created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/src/services/generation_handler.py b/src/services/generation_handler.py index 4f2e89b..7751f66 100644 --- a/src/services/generation_handler.py +++ b/src/services/generation_handler.py @@ -92,13 +92,13 @@ class GenerationHandler: is_video = model_config["type"] == "video" is_image = model_config["type"] == "image" - # Select token (with lock for image generation) - token_obj = await self.load_balancer.select_token(for_image_generation=is_image) + # Select token (with lock for image generation, Sora2 quota check for video generation) + token_obj = await self.load_balancer.select_token(for_image_generation=is_image, for_video_generation=is_video) if not token_obj: if is_image: raise Exception("No available tokens for image generation. All tokens are either disabled, cooling down, locked, or expired.") else: - raise Exception("No available tokens. All tokens are either disabled, cooling down, or expired.") + raise Exception("No available tokens for video generation. All tokens are either disabled, cooling down, Sora2 quota exhausted, don't support Sora2, or expired.") # Acquire lock for image generation if is_image: @@ -180,11 +180,7 @@ class GenerationHandler: yield chunk # Record success - await self.token_manager.record_success(token_obj.id) - - # Check cooldown for video - if is_video: - await self.token_manager.check_and_apply_cooldown(token_obj.id) + await self.token_manager.record_success(token_obj.id, is_video=is_video) # Release lock for image generation if is_image: @@ -231,6 +227,8 @@ class GenerationHandler: max_attempts = int(timeout / poll_interval) # Calculate max attempts based on timeout last_progress = 0 start_time = time.time() + last_heartbeat_time = start_time # Track last heartbeat for image generation + heartbeat_interval = 10 # Send heartbeat every 10 seconds for image generation debug_logger.log_info(f"Starting task polling: task_id={task_id}, is_video={is_video}, timeout={timeout}s, max_attempts={max_attempts}") @@ -315,6 +313,10 @@ class GenerationHandler: reasoning_content="**Video Generation Completed**\n\nWatermark-free mode enabled. Publishing video to get watermark-free version...\n" ) + # Get watermark-free config to determine parse method + watermark_config = await self.db.get_watermark_free_config() + parse_method = watermark_config.parse_method or "third_party" + # Post video to get watermark-free version try: debug_logger.log_info(f"Calling post_video_for_watermark_free with generation_id={generation_id}, prompt={prompt[:50]}...") @@ -328,8 +330,28 @@ class GenerationHandler: if not post_id: raise Exception("Failed to get post ID from publish API") - # Construct watermark-free video URL - watermark_free_url = f"https://oscdn2.dyysy.com/MP4/{post_id}.mp4" + # Get watermark-free video URL based on parse method + if parse_method == "custom": + # Use custom parse server + if not watermark_config.custom_parse_url or not watermark_config.custom_parse_token: + raise Exception("Custom parse server URL or token not configured") + + if stream: + yield self._format_stream_chunk( + reasoning_content=f"Video published successfully. Post ID: {post_id}\nUsing custom parse server to get watermark-free URL...\n" + ) + + debug_logger.log_info(f"Using custom parse server: {watermark_config.custom_parse_url}") + watermark_free_url = await self.sora_client.get_watermark_free_url_custom( + parse_url=watermark_config.custom_parse_url, + parse_token=watermark_config.custom_parse_token, + post_id=post_id + ) + else: + # Use third-party parse (default) + watermark_free_url = f"https://oscdn2.dyysy.com/MP4/{post_id}.mp4" + debug_logger.log_info(f"Using third-party parse server") + debug_logger.log_info(f"Watermark-free URL: {watermark_free_url}") if stream: @@ -439,8 +461,10 @@ class GenerationHandler: task_responses = result.get("task_responses", []) # Find matching task + task_found = False for task_resp in task_responses: if task_resp.get("id") == task_id: + task_found = True status = task_resp.get("status") progress = task_resp.get("progress_pct", 0) * 100 @@ -513,6 +537,26 @@ class GenerationHandler: reasoning_content=f"**Processing**\n\nGeneration in progress: {progress:.0f}% completed...\n" ) + # For image generation, send heartbeat every 10 seconds if no progress update + if not is_video and stream: + current_time = time.time() + if current_time - last_heartbeat_time >= heartbeat_interval: + last_heartbeat_time = current_time + elapsed = int(current_time - start_time) + yield self._format_stream_chunk( + reasoning_content=f"**Generating**\n\nImage generation in progress... ({elapsed}s elapsed)\n" + ) + + # If task not found in response, send heartbeat for image generation + if not task_found and not is_video and stream: + current_time = time.time() + if current_time - last_heartbeat_time >= heartbeat_interval: + last_heartbeat_time = current_time + elapsed = int(current_time - start_time) + yield self._format_stream_chunk( + reasoning_content=f"**Generating**\n\nImage generation in progress... ({elapsed}s elapsed)\n" + ) + # Progress update for stream mode (fallback if no status from API) if stream and attempt % 10 == 0: # Update every 10 attempts (roughly 20% intervals) estimated_progress = min(90, (attempt / max_attempts) * 100) diff --git a/src/services/load_balancer.py b/src/services/load_balancer.py index e371e89..6976bcd 100644 --- a/src/services/load_balancer.py +++ b/src/services/load_balancer.py @@ -14,12 +14,13 @@ class LoadBalancer: # Use image timeout from config as lock timeout self.token_lock = TokenLock(lock_timeout=config.image_timeout) - async def select_token(self, for_image_generation: bool = False) -> Optional[Token]: + async def select_token(self, for_image_generation: bool = False, for_video_generation: bool = False) -> Optional[Token]: """ Select a token using random load balancing Args: for_image_generation: If True, only select tokens that are not locked for image generation + for_video_generation: If True, filter out tokens with Sora2 quota exhausted (sora2_cooldown_until not expired) and tokens that don't support Sora2 Returns: Selected token or None if no available tokens @@ -29,6 +30,33 @@ class LoadBalancer: if not active_tokens: return None + # If for video generation, filter out tokens with Sora2 quota exhausted and tokens without Sora2 support + if for_video_generation: + from datetime import datetime + available_tokens = [] + for token in active_tokens: + # Skip tokens that don't support Sora2 + if not token.sora2_supported: + continue + + # Check if Sora2 cooldown has expired and refresh if needed + if token.sora2_cooldown_until and token.sora2_cooldown_until <= datetime.now(): + await self.token_manager.refresh_sora2_remaining_if_cooldown_expired(token.id) + # Reload token data after refresh + token = await self.token_manager.db.get_token(token.id) + + # Skip tokens that are in Sora2 cooldown (quota exhausted) + if token and token.sora2_cooldown_until and token.sora2_cooldown_until > datetime.now(): + continue + + if token: + available_tokens.append(token) + + if not available_tokens: + return None + + active_tokens = available_tokens + # If for image generation, filter out locked tokens if for_image_generation: available_tokens = [] diff --git a/src/services/sora_client.py b/src/services/sora_client.py index 3237b36..421ffd1 100644 --- a/src/services/sora_client.py +++ b/src/services/sora_client.py @@ -325,3 +325,95 @@ class SoraClient: raise Exception(error_msg) return True + + async def get_watermark_free_url_custom(self, parse_url: str, parse_token: str, post_id: str) -> str: + """Get watermark-free video URL from custom parse server + + Args: + parse_url: Custom parse server URL (e.g., http://example.com) + parse_token: Access token for custom parse server + post_id: Post ID to parse (e.g., s_690c0f574c3881918c3bc5b682a7e9fd) + + Returns: + Download link from custom parse server + + Raises: + Exception: If parse fails or token is invalid + """ + proxy_url = await self.proxy_manager.get_proxy_url() + + # Construct the share URL + share_url = f"https://sora.chatgpt.com/p/{post_id}" + + # Prepare request + json_data = { + "url": share_url, + "token": parse_token + } + + kwargs = { + "json": json_data, + "timeout": 30, + "impersonate": "chrome" + } + + if proxy_url: + kwargs["proxy"] = proxy_url + + try: + async with AsyncSession() as session: + # Record start time + start_time = time.time() + + # Make POST request to custom parse server + response = await session.post(f"{parse_url}/get-sora-link", **kwargs) + + # Calculate duration + duration_ms = (time.time() - start_time) * 1000 + + # Log response + debug_logger.log_response( + status_code=response.status_code, + headers=dict(response.headers), + body=response.text if response.text else "No content", + duration_ms=duration_ms + ) + + # Check status + if response.status_code != 200: + error_msg = f"Custom parse failed: {response.status_code} - {response.text}" + debug_logger.log_error( + error_message=error_msg, + status_code=response.status_code, + response_text=response.text + ) + raise Exception(error_msg) + + # Parse response + result = response.json() + + # Check for error in response + if "error" in result: + error_msg = f"Custom parse error: {result['error']}" + debug_logger.log_error( + error_message=error_msg, + status_code=401, + response_text=str(result) + ) + raise Exception(error_msg) + + # Extract download link + download_link = result.get("download_link") + if not download_link: + raise Exception("No download_link in custom parse response") + + debug_logger.log_info(f"Custom parse successful: {download_link}") + return download_link + + except Exception as e: + debug_logger.log_error( + error_message=f"Custom parse request failed: {str(e)}", + status_code=500, + response_text=str(e) + ) + raise diff --git a/src/services/token_manager.py b/src/services/token_manager.py index 811c45c..de7bacf 100644 --- a/src/services/token_manager.py +++ b/src/services/token_manager.py @@ -1,9 +1,11 @@ """Token management module""" import jwt import asyncio +import random from datetime import datetime, timedelta from typing import Optional, List, Dict, Any from curl_cffi.requests import AsyncSession +from faker import Faker from ..core.database import Database from ..core.models import Token, TokenStats from ..core.config import config @@ -16,6 +18,7 @@ class TokenManager: self.db = db self._lock = asyncio.Lock() self.proxy_manager = ProxyManager(db) + self.fake = Faker() async def decode_jwt(self, token: str) -> dict: """Decode JWT token without verification""" @@ -24,7 +27,37 @@ class TokenManager: return decoded except Exception as e: raise ValueError(f"Invalid JWT token: {str(e)}") - + + def _generate_random_username(self) -> str: + """Generate a random username using faker + + Returns: + A random username string + """ + # 生成真实姓名 + first_name = self.fake.first_name() + last_name = self.fake.last_name() + + # 去除姓名中的空格和特殊字符,只保留字母 + first_name_clean = ''.join(c for c in first_name if c.isalpha()) + last_name_clean = ''.join(c for c in last_name if c.isalpha()) + + # 生成1-4位随机数字 + random_digits = str(random.randint(1, 9999)) + + # 随机选择用户名格式 + format_choice = random.choice([ + f"{first_name_clean}{last_name_clean}{random_digits}", + f"{first_name_clean}.{last_name_clean}{random_digits}", + f"{first_name_clean}{random_digits}", + f"{last_name_clean}{random_digits}", + f"{first_name_clean[0]}{last_name_clean}{random_digits}", + f"{first_name_clean}{last_name_clean[0]}{random_digits}" + ]) + + # 转换为小写 + return format_choice.lower() + async def get_user_info(self, access_token: str) -> dict: """Get user info from Sora API""" proxy_url = await self.proxy_manager.get_proxy_url() @@ -178,6 +211,158 @@ class TokenManager: "invite_code": None } + async def get_sora2_remaining_count(self, access_token: str) -> dict: + """Get Sora2 remaining video count + + Returns: + { + "remaining_count": 27, + "rate_limit_reached": false, + "access_resets_in_seconds": 46833 + } + """ + proxy_url = await self.proxy_manager.get_proxy_url() + + print(f"🔍 开始获取Sora2剩余次数...") + + async with AsyncSession() as session: + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json" + } + + kwargs = { + "headers": headers, + "timeout": 30, + "impersonate": "chrome" # 自动生成 User-Agent 和浏览器指纹 + } + + if proxy_url: + kwargs["proxy"] = proxy_url + print(f"🌐 使用代理: {proxy_url}") + + response = await session.get( + "https://sora.chatgpt.com/backend/nf/check", + **kwargs + ) + + print(f"📥 响应状态码: {response.status_code}") + + if response.status_code == 200: + data = response.json() + print(f"✅ Sora2剩余次数获取成功: {data}") + + rate_limit_info = data.get("rate_limit_and_credit_balance", {}) + return { + "success": True, + "remaining_count": rate_limit_info.get("estimated_num_videos_remaining", 0), + "rate_limit_reached": rate_limit_info.get("rate_limit_reached", False), + "access_resets_in_seconds": rate_limit_info.get("access_resets_in_seconds", 0) + } + else: + print(f"❌ 获取Sora2剩余次数失败: {response.status_code}") + print(f"📄 响应内容: {response.text[:500]}") + return { + "success": False, + "remaining_count": 0, + "error": f"Failed to get remaining count: {response.status_code}" + } + + async def check_username_available(self, access_token: str, username: str) -> bool: + """Check if username is available + + Args: + access_token: Access token for authentication + username: Username to check + + Returns: + True if username is available, False otherwise + """ + proxy_url = await self.proxy_manager.get_proxy_url() + + print(f"🔍 检查用户名是否可用: {username}") + + async with AsyncSession() as session: + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + + kwargs = { + "headers": headers, + "json": {"username": username}, + "timeout": 30, + "impersonate": "chrome" + } + + if proxy_url: + kwargs["proxy"] = proxy_url + print(f"🌐 使用代理: {proxy_url}") + + response = await session.post( + "https://sora.chatgpt.com/backend/project_y/profile/username/check", + **kwargs + ) + + print(f"📥 响应状态码: {response.status_code}") + + if response.status_code == 200: + data = response.json() + available = data.get("available", False) + print(f"✅ 用户名检查结果: available={available}") + return available + else: + print(f"❌ 用户名检查失败: {response.status_code}") + print(f"📄 响应内容: {response.text[:500]}") + return False + + async def set_username(self, access_token: str, username: str) -> dict: + """Set username for the account + + Args: + access_token: Access token for authentication + username: Username to set + + Returns: + User profile information after setting username + """ + proxy_url = await self.proxy_manager.get_proxy_url() + + print(f"🔍 开始设置用户名: {username}") + + async with AsyncSession() as session: + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + + kwargs = { + "headers": headers, + "json": {"username": username}, + "timeout": 30, + "impersonate": "chrome" + } + + if proxy_url: + kwargs["proxy"] = proxy_url + print(f"🌐 使用代理: {proxy_url}") + + response = await session.post( + "https://sora.chatgpt.com/backend/project_y/profile/username/set", + **kwargs + ) + + print(f"📥 响应状态码: {response.status_code}") + + if response.status_code == 200: + data = response.json() + print(f"✅ 用户名设置成功: {data.get('username')}") + return data + else: + print(f"❌ 用户名设置失败: {response.status_code}") + print(f"📄 响应内容: {response.text[:500]}") + raise Exception(f"Failed to set username: {response.status_code}") + async def activate_sora2_invite(self, access_token: str, invite_code: str) -> dict: """Activate Sora2 with invite code""" import uuid @@ -375,16 +560,63 @@ class TokenManager: sora2_invite_code = None sora2_redeemed_count = 0 sora2_total_count = 0 + sora2_remaining_count = 0 try: sora2_info = await self.get_sora2_invite_code(token_value) sora2_supported = sora2_info.get("supported", False) sora2_invite_code = sora2_info.get("invite_code") sora2_redeemed_count = sora2_info.get("redeemed_count", 0) sora2_total_count = sora2_info.get("total_count", 0) + + # If Sora2 is supported, get remaining count + if sora2_supported: + try: + remaining_info = await self.get_sora2_remaining_count(token_value) + if remaining_info.get("success"): + sora2_remaining_count = remaining_info.get("remaining_count", 0) + print(f"✅ Sora2剩余次数: {sora2_remaining_count}") + except Exception as e: + print(f"Failed to get Sora2 remaining count: {e}") except Exception as e: # If API call fails, Sora2 info will be None print(f"Failed to get Sora2 info: {e}") + # Check and set username if needed + try: + # Get fresh user info to check username + user_info = await self.get_user_info(token_value) + username = user_info.get("username") + + # If username is null, need to set one + if username is None: + print(f"⚠️ 检测到用户名为null,需要设置用户名") + + # Generate random username + max_attempts = 5 + for attempt in range(max_attempts): + generated_username = self._generate_random_username() + print(f"🔄 尝试用户名 ({attempt + 1}/{max_attempts}): {generated_username}") + + # Check if username is available + if await self.check_username_available(token_value, generated_username): + # Set the username + try: + await self.set_username(token_value, generated_username) + print(f"✅ 用户名设置成功: {generated_username}") + break + except Exception as e: + print(f"❌ 用户名设置失败: {e}") + if attempt == max_attempts - 1: + print(f"⚠️ 达到最大尝试次数,跳过用户名设置") + else: + print(f"⚠️ 用户名 {generated_username} 已被占用,尝试下一个") + if attempt == max_attempts - 1: + print(f"⚠️ 达到最大尝试次数,跳过用户名设置") + else: + print(f"✅ 用户名已设置: {username}") + except Exception as e: + print(f"⚠️ 用户名检查/设置过程中出错: {e}") + # Create token object token = Token( token=token_value, @@ -401,7 +633,8 @@ class TokenManager: sora2_supported=sora2_supported, sora2_invite_code=sora2_invite_code, sora2_redeemed_count=sora2_redeemed_count, - sora2_total_count=sora2_total_count + sora2_total_count=sora2_total_count, + sora2_remaining_count=sora2_remaining_count ) # Save to database @@ -523,6 +756,16 @@ class TokenManager: sora2_invite_code = sora2_info.get("invite_code") sora2_redeemed_count = sora2_info.get("redeemed_count", 0) sora2_total_count = sora2_info.get("total_count", 0) + sora2_remaining_count = 0 + + # If Sora2 is supported, get remaining count + if sora2_supported: + try: + remaining_info = await self.get_sora2_remaining_count(token_data.token) + if remaining_info.get("success"): + sora2_remaining_count = remaining_info.get("remaining_count", 0) + except Exception as e: + print(f"Failed to get Sora2 remaining count: {e}") # Update token Sora2 info in database await self.db.update_token_sora2( @@ -530,7 +773,8 @@ class TokenManager: supported=sora2_supported, invite_code=sora2_invite_code, redeemed_count=sora2_redeemed_count, - total_count=sora2_total_count + total_count=sora2_total_count, + remaining_count=sora2_remaining_count ) return { @@ -541,7 +785,8 @@ class TokenManager: "sora2_supported": sora2_supported, "sora2_invite_code": sora2_invite_code, "sora2_redeemed_count": sora2_redeemed_count, - "sora2_total_count": sora2_total_count + "sora2_total_count": sora2_total_count, + "sora2_remaining_count": sora2_remaining_count } except Exception as e: return { @@ -569,16 +814,51 @@ class TokenManager: if stats and stats.error_count >= admin_config.error_ban_threshold: await self.db.update_token_status(token_id, False) - async def record_success(self, token_id: int): + async def record_success(self, token_id: int, is_video: bool = False): """Record successful request (reset error count)""" await self.db.reset_error_count(token_id) + + # Update Sora2 remaining count after video generation + if is_video: + try: + token_data = await self.db.get_token(token_id) + if token_data and token_data.sora2_supported: + remaining_info = await self.get_sora2_remaining_count(token_data.token) + if remaining_info.get("success"): + remaining_count = remaining_info.get("remaining_count", 0) + await self.db.update_token_sora2_remaining(token_id, remaining_count) + print(f"✅ 更新Token {token_id} 的Sora2剩余次数: {remaining_count}") + + # If remaining count is 0, set cooldown + if remaining_count == 0: + reset_seconds = remaining_info.get("access_resets_in_seconds", 0) + if reset_seconds > 0: + cooldown_until = datetime.now() + timedelta(seconds=reset_seconds) + await self.db.update_token_sora2_cooldown(token_id, cooldown_until) + print(f"⏱️ Token {token_id} 剩余次数为0,设置冷却时间至: {cooldown_until}") + except Exception as e: + print(f"Failed to update Sora2 remaining count: {e}") - async def check_and_apply_cooldown(self, token_id: int): - """Check if token should be cooled down""" - stats = await self.db.get_token_stats(token_id) - admin_config = await self.db.get_admin_config() - - if stats and stats.video_count >= admin_config.video_cooldown_threshold: - # Apply 12 hour cooldown - cooled_until = datetime.now() + timedelta(hours=12) - await self.db.update_token_cooldown(token_id, cooled_until) + async def refresh_sora2_remaining_if_cooldown_expired(self, token_id: int): + """Refresh Sora2 remaining count if cooldown has expired""" + try: + token_data = await self.db.get_token(token_id) + if not token_data or not token_data.sora2_supported: + return + + # Check if Sora2 cooldown has expired + if token_data.sora2_cooldown_until and token_data.sora2_cooldown_until <= datetime.now(): + print(f"🔄 Token {token_id} Sora2冷却已过期,正在刷新剩余次数...") + + try: + remaining_info = await self.get_sora2_remaining_count(token_data.token) + if remaining_info.get("success"): + remaining_count = remaining_info.get("remaining_count", 0) + await self.db.update_token_sora2_remaining(token_id, remaining_count) + # Clear cooldown + await self.db.update_token_sora2_cooldown(token_id, None) + print(f"✅ Token {token_id} Sora2剩余次数已刷新: {remaining_count}") + except Exception as e: + print(f"Failed to refresh Sora2 remaining count: {e}") + except Exception as e: + print(f"Error in refresh_sora2_remaining_if_cooldown_expired: {e}") diff --git a/static/manage.html b/static/manage.html index 5a67810..1ba4183 100644 --- a/static/manage.html +++ b/static/manage.html @@ -99,7 +99,7 @@ 过期时间 账户类型 Sora2 - 套餐到期 + 可用次数 图片 视频 错误 @@ -176,15 +176,10 @@ - +
-

视频生成次数限制

+

错误处理配置

-
- - -

Token 生成视频达到此次数后冷却 12 小时

-
@@ -257,11 +252,36 @@

开启后生成的视频将会被发布到sora平台并且提取返回无水印的视频,在缓存到本地后会自动删除发布的视频

+ + + +
@@ -496,7 +516,9 @@ 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`${dateStr} ${timeStr}`;const days=Math.floor(diff/864e5);if(days<7)return`${dateStr} ${timeStr}`;return`${dateStr} ${timeStr}`}, formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type}, 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`
支持${remaining}/${t.sora2_total_count}
`}else if(t.sora2_supported===false){return`不支持`}else{return'-'}}, - renderTokens=()=>{const tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>`${t.email}${t.is_active?'活跃':'禁用'}${formatExpiry(t.expiry_time)}${formatPlanType(t.plan_type)}${formatSora2(t)}${formatExpiry(t.subscription_end)}${t.image_count||0}${t.video_count||0}${t.error_count||0}${t.remark||'-'}`).join('')}, + 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`${formatPlanType(t.plan_type)}`}, + formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`${remaining}`}else{return'-'}}, + renderTokens=()=>{const tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>`${t.email}${t.is_active?'活跃':'禁用'}${formatExpiry(t.expiry_time)}${formatPlanTypeWithTooltip(t)}${formatSora2(t)}${formatSora2Remaining(t)}${t.image_count||0}${t.video_count||0}${t.error_count||0}${t.remark||'-'}`).join('')}, refreshTokens=async()=>{await loadTokens();await loadStats()}, openAddModal=()=>$('addModal').classList.remove('hidden'), closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenRemark').value='';$('addRTRefreshHint').classList.add('hidden')}, @@ -508,7 +530,7 @@ 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')}}, 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')}}, submitAddToken=async()=>{const at=$('addTokenAT').value.trim(),st=$('addTokenST').value.trim(),rt=$('addTokenRT').value.trim(),remark=$('addTokenRemark').value.trim();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})});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')}}, - 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})`}showToast(msg,'success');await refreshTokens()}else{showToast(`Token无效: ${d.message||'未知错误'}`,'error')}}catch(e){showToast('测试失败: '+e.message,'error')}}, + 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')}}, 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')}}, 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')}}, 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}}, @@ -516,15 +538,17 @@ openSora2Modal=(id)=>{$('sora2TokenId').value=id;$('sora2InviteCode').value='';$('sora2Modal').classList.remove('hidden')}, closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''}, 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')}}, - loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgVideoCooldown').value=d.video_cooldown_threshold||30;$('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)}}, - saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({video_cooldown_threshold:parseInt($('cfgVideoCooldown').value)||30,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')}}, + 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)}}, + 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')}}, 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')}}, 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')}}, 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}}, 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)}}, 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')}}, - 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}catch(e){console.error('加载无水印模式配置失败:',e)}}, - saveWatermarkFreeConfig=async()=>{try{const r=await apiRequest('/api/watermark-free/config',{method:'POST',body:JSON.stringify({watermark_free_enabled:$('cfgWatermarkFreeEnabled').checked})});if(!r)return;const d=await r.json();d.success?showToast('无水印模式配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}}, + 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)}}, + 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')}}, + toggleWatermarkFreeOptions=()=>{const enabled=$('cfgWatermarkFreeEnabled').checked;$('watermarkFreeOptions').style.display=enabled?'block':'none'}, + toggleCustomParseOptions=()=>{const method=$('cfgParseMethod').value;$('customParseOptions').style.display=method==='custom'?'block':'none'}, 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 timeout=d.config.timeout||7200;const baseUrl=d.config.base_url||'';const effectiveUrl=d.config.effective_base_url||'';console.log('设置超时时间:',timeout);console.log('设置域名:',baseUrl);console.log('生效URL:',effectiveUrl);$('cfgCacheTimeout').value=timeout;$('cfgCacheBaseUrl').value=baseUrl;if(effectiveUrl){$('cacheEffectiveUrlValue').textContent=effectiveUrl;$('cacheEffectiveUrl').classList.remove('hidden')}else{$('cacheEffectiveUrl').classList.add('hidden')}console.log('缓存配置加载成功')}else{console.error('缓存配置数据格式错误:',d)}}catch(e){console.error('加载缓存配置失败:',e);showToast('加载缓存配置失败: '+e.message,'error')}}, 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')}}, loadVideoLengthConfig=async()=>{try{const r=await apiRequest('/api/video/length/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgVideoDefaultLength').value=d.config.default_length||'10s'}else{console.error('视频时长配置数据格式错误:',d)}}catch(e){console.error('加载视频时长配置失败:',e);showToast('加载视频时长配置失败: '+e.message,'error')}},