Spaces:
Build error
Build error
fix: enhance error handling in YouTube analysis and implement API key validation with rate limiting
Browse files- app/routes/analyze.py +1 -1
- app/routes/commend/router.py +53 -3
- app/routes/commend/youtube_service.py +21 -2
- app/routes/data_processing.py +6 -3
- app/services/preview_model.py +19 -18
app/routes/analyze.py
CHANGED
|
@@ -143,7 +143,7 @@ async def _analyze_youtube(
|
|
| 143 |
return AnalyzeResponse(
|
| 144 |
result=result,
|
| 145 |
warnings=yt_result.warnings,
|
| 146 |
-
errors=
|
| 147 |
)
|
| 148 |
|
| 149 |
except ValueError as e:
|
|
|
|
| 143 |
return AnalyzeResponse(
|
| 144 |
result=result,
|
| 145 |
warnings=yt_result.warnings,
|
| 146 |
+
errors=yt_result.errors,
|
| 147 |
)
|
| 148 |
|
| 149 |
except ValueError as e:
|
app/routes/commend/router.py
CHANGED
|
@@ -7,9 +7,10 @@ from __future__ import annotations
|
|
| 7 |
|
| 8 |
import os
|
| 9 |
import time
|
|
|
|
| 10 |
from typing import Literal, Optional
|
| 11 |
|
| 12 |
-
from fastapi import APIRouter, HTTPException, status
|
| 13 |
from pydantic import BaseModel, Field, field_validator
|
| 14 |
|
| 15 |
from .youtube_service import (
|
|
@@ -112,6 +113,48 @@ class CommendHealthResponse(BaseModel):
|
|
| 112 |
message: str
|
| 113 |
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
# --- API Endpoints ---
|
| 116 |
|
| 117 |
@router.get("/health", response_model=CommendHealthResponse)
|
|
@@ -156,7 +199,7 @@ async def get_video_info(request: GenerateCommentRequest):
|
|
| 156 |
return VideoDetailsResponse(**details)
|
| 157 |
|
| 158 |
|
| 159 |
-
@router.post("/generate", response_model=GenerateCommentResponse)
|
| 160 |
async def generate_youtube_comment(request: GenerateCommentRequest):
|
| 161 |
"""
|
| 162 |
Generate an AI-powered comment for a YouTube video.
|
|
@@ -217,7 +260,7 @@ async def generate_youtube_comment(request: GenerateCommentRequest):
|
|
| 217 |
)
|
| 218 |
|
| 219 |
|
| 220 |
-
@router.post("/post", response_model=PostCommentResponse)
|
| 221 |
async def post_comment_to_youtube(request: PostCommentRequest):
|
| 222 |
"""
|
| 223 |
Post a comment to YouTube.
|
|
@@ -225,7 +268,14 @@ async def post_comment_to_youtube(request: PostCommentRequest):
|
|
| 225 |
Note: This requires valid OAuth credentials with YouTube posting permissions.
|
| 226 |
Comments include an AI disclaimer as required by platform policies.
|
| 227 |
Each video can only receive ONE comment to prevent spam/bot detection.
|
|
|
|
| 228 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
video_id = extract_video_id(request.videoUrl)
|
| 230 |
|
| 231 |
# Check if already commented on this video
|
|
|
|
| 7 |
|
| 8 |
import os
|
| 9 |
import time
|
| 10 |
+
from collections import defaultdict
|
| 11 |
from typing import Literal, Optional
|
| 12 |
|
| 13 |
+
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
| 14 |
from pydantic import BaseModel, Field, field_validator
|
| 15 |
|
| 16 |
from .youtube_service import (
|
|
|
|
| 113 |
message: str
|
| 114 |
|
| 115 |
|
| 116 |
+
# --- Auth & Rate Limiting ---
|
| 117 |
+
|
| 118 |
+
_COMMEND_API_KEY = os.getenv("COMMEND_API_KEY")
|
| 119 |
+
_COMMEND_POSTING_ENABLED = os.getenv("COMMEND_ENABLE_POSTING", "false").lower() == "true"
|
| 120 |
+
|
| 121 |
+
# Simple in-memory rate limiter: IP -> list of timestamps
|
| 122 |
+
_rate_limit_store: dict[str, list[float]] = defaultdict(list)
|
| 123 |
+
_RATE_LIMIT_WINDOW = 60 # seconds
|
| 124 |
+
_RATE_LIMIT_MAX = 10 # requests per window
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _check_rate_limit(client_ip: str) -> None:
|
| 128 |
+
now = time.time()
|
| 129 |
+
window_start = now - _RATE_LIMIT_WINDOW
|
| 130 |
+
timestamps = _rate_limit_store[client_ip]
|
| 131 |
+
# Prune old entries
|
| 132 |
+
_rate_limit_store[client_ip] = [t for t in timestamps if t > window_start]
|
| 133 |
+
if len(_rate_limit_store[client_ip]) >= _RATE_LIMIT_MAX:
|
| 134 |
+
raise HTTPException(
|
| 135 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 136 |
+
detail="Rate limit exceeded. Please wait before making more requests."
|
| 137 |
+
)
|
| 138 |
+
_rate_limit_store[client_ip].append(now)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
async def _require_api_key(
|
| 142 |
+
request: Request,
|
| 143 |
+
x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
|
| 144 |
+
) -> None:
|
| 145 |
+
"""Validate API key if COMMEND_API_KEY is configured."""
|
| 146 |
+
if not _COMMEND_API_KEY:
|
| 147 |
+
# No key configured — allow (development mode)
|
| 148 |
+
return
|
| 149 |
+
if x_api_key != _COMMEND_API_KEY:
|
| 150 |
+
logger.warning(f"Unauthorized commend request from {request.client.host if request.client else 'unknown'}")
|
| 151 |
+
raise HTTPException(
|
| 152 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 153 |
+
detail="Invalid or missing API key."
|
| 154 |
+
)
|
| 155 |
+
_check_rate_limit(request.client.host if request.client else "unknown")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
# --- API Endpoints ---
|
| 159 |
|
| 160 |
@router.get("/health", response_model=CommendHealthResponse)
|
|
|
|
| 199 |
return VideoDetailsResponse(**details)
|
| 200 |
|
| 201 |
|
| 202 |
+
@router.post("/generate", response_model=GenerateCommentResponse, dependencies=[Depends(_require_api_key)])
|
| 203 |
async def generate_youtube_comment(request: GenerateCommentRequest):
|
| 204 |
"""
|
| 205 |
Generate an AI-powered comment for a YouTube video.
|
|
|
|
| 260 |
)
|
| 261 |
|
| 262 |
|
| 263 |
+
@router.post("/post", response_model=PostCommentResponse, dependencies=[Depends(_require_api_key)])
|
| 264 |
async def post_comment_to_youtube(request: PostCommentRequest):
|
| 265 |
"""
|
| 266 |
Post a comment to YouTube.
|
|
|
|
| 268 |
Note: This requires valid OAuth credentials with YouTube posting permissions.
|
| 269 |
Comments include an AI disclaimer as required by platform policies.
|
| 270 |
Each video can only receive ONE comment to prevent spam/bot detection.
|
| 271 |
+
Controlled by COMMEND_ENABLE_POSTING env (default: disabled).
|
| 272 |
"""
|
| 273 |
+
if not _COMMEND_POSTING_ENABLED:
|
| 274 |
+
raise HTTPException(
|
| 275 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 276 |
+
detail="Comment posting is disabled. Set COMMEND_ENABLE_POSTING=true to enable."
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
video_id = extract_video_id(request.videoUrl)
|
| 280 |
|
| 281 |
# Check if already commented on this video
|
app/routes/commend/youtube_service.py
CHANGED
|
@@ -13,6 +13,7 @@ import os
|
|
| 13 |
import re
|
| 14 |
import json
|
| 15 |
from typing import Optional, Tuple, List, Dict, Any
|
|
|
|
| 16 |
|
| 17 |
from google.oauth2.credentials import Credentials
|
| 18 |
from google.auth.transport.requests import Request
|
|
@@ -92,8 +93,24 @@ def _get_oauth_service():
|
|
| 92 |
raise ValueError(f"YouTube OAuth authentication failed: {e}")
|
| 93 |
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
def extract_video_id(video_url: str) -> Optional[str]:
|
| 96 |
-
"""YouTube URL'sinden video ID'sini çıkarır."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
patterns = [
|
| 98 |
r"(?<=v=)[^&#?]+",
|
| 99 |
r"(?<=be/)[^&#?]+",
|
|
@@ -104,7 +121,9 @@ def extract_video_id(video_url: str) -> Optional[str]:
|
|
| 104 |
for pattern in patterns:
|
| 105 |
match = re.search(pattern, video_url)
|
| 106 |
if match:
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
|
| 109 |
return None
|
| 110 |
|
|
|
|
| 13 |
import re
|
| 14 |
import json
|
| 15 |
from typing import Optional, Tuple, List, Dict, Any
|
| 16 |
+
from urllib.parse import urlparse
|
| 17 |
|
| 18 |
from google.oauth2.credentials import Credentials
|
| 19 |
from google.auth.transport.requests import Request
|
|
|
|
| 93 |
raise ValueError(f"YouTube OAuth authentication failed: {e}")
|
| 94 |
|
| 95 |
|
| 96 |
+
_YOUTUBE_HOSTS = frozenset({
|
| 97 |
+
"youtube.com", "www.youtube.com", "m.youtube.com",
|
| 98 |
+
"music.youtube.com", "youtu.be", "www.youtu.be",
|
| 99 |
+
})
|
| 100 |
+
_VIDEO_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{11}$")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
def extract_video_id(video_url: str) -> Optional[str]:
|
| 104 |
+
"""YouTube URL'sinden video ID'sini çıkarır (exact-host + 11-char ID)."""
|
| 105 |
+
try:
|
| 106 |
+
parsed = urlparse(video_url.strip())
|
| 107 |
+
except Exception:
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
host = (parsed.hostname or "").lower()
|
| 111 |
+
if host not in _YOUTUBE_HOSTS:
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
patterns = [
|
| 115 |
r"(?<=v=)[^&#?]+",
|
| 116 |
r"(?<=be/)[^&#?]+",
|
|
|
|
| 121 |
for pattern in patterns:
|
| 122 |
match = re.search(pattern, video_url)
|
| 123 |
if match:
|
| 124 |
+
candidate = match.group(0)
|
| 125 |
+
if _VIDEO_ID_RE.match(candidate):
|
| 126 |
+
return candidate
|
| 127 |
|
| 128 |
return None
|
| 129 |
|
app/routes/data_processing.py
CHANGED
|
@@ -22,14 +22,17 @@ async def process_audio_endpoint(
|
|
| 22 |
Process an audio file with the given augmentation options.
|
| 23 |
Returns the processed WAV file.
|
| 24 |
"""
|
|
|
|
| 25 |
logger.info(f"Received audio processing request for file: {file.filename}")
|
| 26 |
-
|
| 27 |
-
if not file.content_type.startswith("audio/"):
|
| 28 |
raise HTTPException(status_code=400, detail="Invalid file type. Must be audio.")
|
| 29 |
|
| 30 |
try:
|
| 31 |
-
# Read file content
|
| 32 |
content = await file.read()
|
|
|
|
|
|
|
| 33 |
|
| 34 |
# Process audio
|
| 35 |
processed_audio = process_audio(content, options)
|
|
|
|
| 22 |
Process an audio file with the given augmentation options.
|
| 23 |
Returns the processed WAV file.
|
| 24 |
"""
|
| 25 |
+
MAX_PAYLOAD_BYTES = 30 * 1024 * 1024 # 30 MB
|
| 26 |
logger.info(f"Received audio processing request for file: {file.filename}")
|
| 27 |
+
|
| 28 |
+
if not file.content_type or not file.content_type.startswith("audio/"):
|
| 29 |
raise HTTPException(status_code=400, detail="Invalid file type. Must be audio.")
|
| 30 |
|
| 31 |
try:
|
| 32 |
+
# Read file content with size guard
|
| 33 |
content = await file.read()
|
| 34 |
+
if len(content) > MAX_PAYLOAD_BYTES:
|
| 35 |
+
raise HTTPException(status_code=413, detail=f"File too large. Maximum size is {MAX_PAYLOAD_BYTES // (1024*1024)} MB.")
|
| 36 |
|
| 37 |
# Process audio
|
| 38 |
processed_audio = process_audio(content, options)
|
app/services/preview_model.py
CHANGED
|
@@ -30,22 +30,23 @@ class PreviewModel:
|
|
| 30 |
|
| 31 |
def analyze(self, fingerprint: str, context: dict | None = None) -> dict:
|
| 32 |
"""
|
| 33 |
-
Generate
|
| 34 |
-
|
| 35 |
Args:
|
| 36 |
fingerprint: Unique identifier for the content
|
| 37 |
context: Optional context like warnings or metadata
|
| 38 |
-
|
| 39 |
Returns:
|
| 40 |
Analysis result with prediction and features
|
| 41 |
"""
|
| 42 |
seed_value = self._generate_seed(fingerprint)
|
|
|
|
| 43 |
base_confidence = self._calculate_base_confidence(seed_value)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
features = self._generate_features(seed_value)
|
| 49 |
indicators = self._build_indicators(is_ai, confidence, context)
|
| 50 |
|
| 51 |
return {
|
|
@@ -83,23 +84,23 @@ class PreviewModel:
|
|
| 83 |
|
| 84 |
return 0.45 + weighted * 0.45
|
| 85 |
|
| 86 |
-
def _add_realistic_variance(self, base: float) -> float:
|
| 87 |
-
"""Add human-like variance to confidence score."""
|
| 88 |
-
variance =
|
| 89 |
adjusted = base + variance
|
| 90 |
-
|
| 91 |
if adjusted > 0.95:
|
| 92 |
-
adjusted = 0.95 -
|
| 93 |
elif adjusted < 0.51:
|
| 94 |
-
adjusted = 0.51 +
|
| 95 |
-
|
| 96 |
return max(0.51, min(0.97, adjusted))
|
| 97 |
|
| 98 |
-
def _generate_features(self, seed: float) -> dict:
|
| 99 |
-
"""Generate
|
| 100 |
def feature_score(offset: float) -> float:
|
| 101 |
raw = (seed + offset) % 1.0
|
| 102 |
-
noise =
|
| 103 |
return max(0.0, min(0.99, raw + noise))
|
| 104 |
|
| 105 |
return {
|
|
|
|
| 30 |
|
| 31 |
def analyze(self, fingerprint: str, context: dict | None = None) -> dict:
|
| 32 |
"""
|
| 33 |
+
Generate deterministic AI detection result from fingerprint.
|
| 34 |
+
|
| 35 |
Args:
|
| 36 |
fingerprint: Unique identifier for the content
|
| 37 |
context: Optional context like warnings or metadata
|
| 38 |
+
|
| 39 |
Returns:
|
| 40 |
Analysis result with prediction and features
|
| 41 |
"""
|
| 42 |
seed_value = self._generate_seed(fingerprint)
|
| 43 |
+
rng = random.Random(seed_value)
|
| 44 |
base_confidence = self._calculate_base_confidence(seed_value)
|
| 45 |
+
|
| 46 |
+
confidence = self._add_realistic_variance(base_confidence, rng)
|
| 47 |
+
is_ai = confidence > self.threshold
|
| 48 |
+
|
| 49 |
+
features = self._generate_features(seed_value, rng)
|
| 50 |
indicators = self._build_indicators(is_ai, confidence, context)
|
| 51 |
|
| 52 |
return {
|
|
|
|
| 84 |
|
| 85 |
return 0.45 + weighted * 0.45
|
| 86 |
|
| 87 |
+
def _add_realistic_variance(self, base: float, rng: random.Random) -> float:
|
| 88 |
+
"""Add deterministic human-like variance to confidence score."""
|
| 89 |
+
variance = rng.gauss(0, self._variance_scale)
|
| 90 |
adjusted = base + variance
|
| 91 |
+
|
| 92 |
if adjusted > 0.95:
|
| 93 |
+
adjusted = 0.95 - rng.uniform(0, 0.03)
|
| 94 |
elif adjusted < 0.51:
|
| 95 |
+
adjusted = 0.51 + rng.uniform(0, 0.02)
|
| 96 |
+
|
| 97 |
return max(0.51, min(0.97, adjusted))
|
| 98 |
|
| 99 |
+
def _generate_features(self, seed: float, rng: random.Random) -> dict:
|
| 100 |
+
"""Generate deterministic feature scores."""
|
| 101 |
def feature_score(offset: float) -> float:
|
| 102 |
raw = (seed + offset) % 1.0
|
| 103 |
+
noise = rng.gauss(0, 0.08)
|
| 104 |
return max(0.0, min(0.99, raw + noise))
|
| 105 |
|
| 106 |
return {
|