Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
@@ -1,155 +1,491 @@
|
|
1 |
-
import
|
2 |
-
|
3 |
-
|
4 |
-
import
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
)
|
|
|
13 |
|
14 |
-
#
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
border-radius: 5px;
|
23 |
-
height: 3em;
|
24 |
-
background-color: #FF0000;
|
25 |
-
color: white;
|
26 |
-
}
|
27 |
-
.stTextInput>div>div>input {
|
28 |
-
border-radius: 5px;
|
29 |
-
}
|
30 |
-
.title-text {
|
31 |
-
font-size: 40px;
|
32 |
-
font-weight: bold;
|
33 |
-
text-align: center;
|
34 |
-
padding-bottom: 20px;
|
35 |
-
}
|
36 |
-
.subtitle-text {
|
37 |
-
font-size: 20px;
|
38 |
-
text-align: center;
|
39 |
-
color: #666666;
|
40 |
-
padding-bottom: 30px;
|
41 |
-
}
|
42 |
-
.success-text {
|
43 |
-
padding: 1rem;
|
44 |
-
border-radius: 5px;
|
45 |
-
background-color: #d4edda;
|
46 |
-
color: #155724;
|
47 |
-
margin-bottom: 1rem;
|
48 |
-
}
|
49 |
-
.stAlert > div {
|
50 |
-
padding: 1rem;
|
51 |
-
border-radius: 5px;
|
52 |
-
}
|
53 |
-
</style>
|
54 |
-
""", unsafe_allow_html=True)
|
55 |
|
56 |
-
|
57 |
-
|
58 |
-
url = query_params.get("url", "")
|
59 |
-
if url.startswith("https://shiappoutube.com"):
|
60 |
-
return url.replace("https://shiappoutube.com", "https://youtube.com")
|
61 |
-
return url
|
62 |
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
84 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
85 |
|
86 |
-
|
87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
95 |
)
|
96 |
|
97 |
-
|
98 |
-
|
99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
100 |
else:
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
109 |
|
110 |
-
|
111 |
-
""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
112 |
|
113 |
-
|
114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
115 |
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
152 |
)
|
153 |
|
154 |
-
|
155 |
-
|
|
|
|
1 |
+
import os
|
2 |
+
import re
|
3 |
+
import logging
|
4 |
+
import uuid
|
5 |
+
import time
|
6 |
+
from datetime import datetime, timezone, timedelta
|
7 |
+
from collections import defaultdict
|
8 |
+
from typing import Optional, Dict, Any, List
|
9 |
+
import asyncio
|
10 |
+
import subprocess
|
11 |
+
import json
|
12 |
+
import tempfile
|
13 |
+
|
14 |
+
from fastapi import FastAPI, HTTPException, Body, BackgroundTasks, Path, Request
|
15 |
+
from fastapi.responses import StreamingResponse
|
16 |
+
from pydantic import BaseModel, Field
|
17 |
+
|
18 |
+
import openai # For your custom API
|
19 |
+
import google.generativeai as genai # For Gemini API
|
20 |
+
from google.generativeai.types import GenerationConfig
|
21 |
+
|
22 |
+
# --- Imports for YouTube Transcript API ---
|
23 |
+
# Note: These are not directly used in the yt-dlp path but are good to keep if you ever add fallback methods.
|
24 |
+
from youtube_transcript_api import TranscriptsDisabled, NoTranscriptFound, CouldNotRetrieveTranscript
|
25 |
+
|
26 |
+
# --- Logging Configuration ---
|
27 |
+
logging.basicConfig(
|
28 |
+
level=logging.INFO,
|
29 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
30 |
+
datefmt='%Y-%m-%d %H:%M:%S'
|
31 |
)
|
32 |
+
logger = logging.getLogger(__name__)
|
33 |
|
34 |
+
# --- Configuration ---
|
35 |
+
CUSTOM_API_BASE_URL_DEFAULT = "https://api-q3ieh5raqfuad9o8.aistudio-app.com/v1"
|
36 |
+
CUSTOM_API_MODEL_DEFAULT = "gemma3:27b"
|
37 |
+
DEFAULT_GEMINI_MODEL = "gemini-1.5-flash-latest"
|
38 |
+
GEMINI_REQUEST_TIMEOUT_SECONDS = 300
|
39 |
+
SUMMARY_REQUEST_TIMEOUT_SECONDS = 180 # A separate timeout for the summary task
|
40 |
+
MAX_TRANSCRIPT_CHARS = 750000
|
41 |
+
COOKIES_FILE_PATH = "private.txt"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
|
43 |
+
# --- In-Memory Task Storage ---
|
44 |
+
tasks_db: Dict[str, Dict[str, Any]] = {}
|
|
|
|
|
|
|
|
|
45 |
|
46 |
+
# --- Pydantic Models ---
|
47 |
+
class ChatPayload(BaseModel):
|
48 |
+
message: str
|
49 |
+
temperature: float = Field(0.6, ge=0.0, le=1.0)
|
50 |
+
|
51 |
+
class GeminiTaskRequest(BaseModel):
|
52 |
+
message: str
|
53 |
+
url: Optional[str] = None
|
54 |
+
gemini_model: Optional[str] = None
|
55 |
+
api_key: Optional[str] = Field(None, description="Gemini API Key (optional; uses Space secret if not provided)")
|
56 |
+
|
57 |
+
class TaskSubmissionResponse(BaseModel):
|
58 |
+
task_id: str
|
59 |
+
status: str
|
60 |
+
task_detail_url: str
|
61 |
+
|
62 |
+
class TaskStatusResponse(BaseModel):
|
63 |
+
task_id: str
|
64 |
+
status: str
|
65 |
+
submitted_at: datetime
|
66 |
+
last_updated_at: datetime
|
67 |
+
result: Optional[str] = None
|
68 |
+
error: Optional[str] = None
|
69 |
+
|
70 |
+
# --- RateLimiter Class ---
|
71 |
+
class RateLimiter:
|
72 |
+
def __init__(self, max_requests: int, time_window: timedelta):
|
73 |
+
self.max_requests = max_requests
|
74 |
+
self.time_window = time_window
|
75 |
+
self.requests: Dict[str, list] = defaultdict(list)
|
76 |
+
|
77 |
+
def _cleanup_old_requests(self, user_ip: str) -> None:
|
78 |
+
current_time = time.time()
|
79 |
+
self.requests[user_ip] = [
|
80 |
+
timestamp for timestamp in self.requests[user_ip]
|
81 |
+
if current_time - timestamp < self.time_window.total_seconds()
|
82 |
+
]
|
83 |
+
|
84 |
+
def is_rate_limited(self, user_ip: str) -> bool:
|
85 |
+
self._cleanup_old_requests(user_ip)
|
86 |
+
current_count = len(self.requests[user_ip])
|
87 |
+
current_time = time.time()
|
88 |
+
self.requests[user_ip].append(current_time)
|
89 |
+
return (current_count + 1) > self.max_requests
|
90 |
+
|
91 |
+
def get_current_count(self, user_ip: str) -> int:
|
92 |
+
self._cleanup_old_requests(user_ip)
|
93 |
+
return len(self.requests[user_ip])
|
94 |
+
|
95 |
+
rate_limiter = RateLimiter(max_requests=15, time_window=timedelta(days=1))
|
96 |
+
|
97 |
+
def get_user_ip(request: Request) -> str:
|
98 |
+
forwarded = request.headers.get("X-Forwarded-For")
|
99 |
+
if forwarded:
|
100 |
+
return forwarded.split(",")[0]
|
101 |
+
return request.client.host
|
102 |
+
|
103 |
+
# --- FastAPI App Initialization ---
|
104 |
+
app = FastAPI(
|
105 |
+
title="Dual Chat & Async Gemini API with YouTube Transcript Summarizer",
|
106 |
+
description="Made by Cody from chrunos.com. Fetches YouTube transcripts for summarization, with a Gemini fallback.",
|
107 |
+
version="3.0.0"
|
108 |
+
)
|
109 |
+
|
110 |
+
# --- Helper Functions ---
|
111 |
+
def is_youtube_url(url: Optional[str]) -> bool:
|
112 |
+
if not url:
|
113 |
+
return False
|
114 |
+
youtube_regex = (
|
115 |
+
r'(https?://)?(www\.)?'
|
116 |
+
r'(youtube|youtu|youtube-nocookie)\.(com|be)/'
|
117 |
+
r'(watch\?v=|embed/|v/|shorts/|.+\?v=)?([^&=%\?]{11})'
|
118 |
)
|
119 |
+
return re.match(youtube_regex, url) is not None
|
120 |
+
|
121 |
+
def extract_video_id(url: str) -> Optional[str]:
|
122 |
+
if not is_youtube_url(url):
|
123 |
+
return None
|
124 |
+
patterns = [
|
125 |
+
r'(?:v=|\/)([0-9A-Za-z_-]{11}).*',
|
126 |
+
r'(?:embed\/|v\/|shorts\/)([0-9A-Za-z_-]{11}).*',
|
127 |
+
r'youtu\.be\/([0-9A-Za-z_-]{11}).*'
|
128 |
+
]
|
129 |
+
for pattern in patterns:
|
130 |
+
match = re.search(pattern, url)
|
131 |
+
if match:
|
132 |
+
return match.group(1)
|
133 |
+
logger.warning(f"Could not extract YouTube video ID from URL: {url}")
|
134 |
+
return None
|
135 |
|
136 |
+
def parse_vtt_content(vtt_content: str) -> str:
|
137 |
+
"""
|
138 |
+
Parse VTT subtitle content and extract only the text, removing timestamps,
|
139 |
+
formatting, and duplicate lines. This parser is designed to handle captions
|
140 |
+
that build up line-by-line.
|
141 |
+
"""
|
142 |
+
lines = vtt_content.split('\n')
|
143 |
+
text_lines = []
|
144 |
+
seen_lines = set()
|
145 |
+
|
146 |
+
for line in lines:
|
147 |
+
line = line.strip()
|
148 |
+
|
149 |
+
# Skip empty lines, WEBVTT header, NOTE lines, timestamp lines, and cue settings
|
150 |
+
if (not line or line.startswith('WEBVTT') or line.startswith('NOTE') or
|
151 |
+
'-->' in line or line.isdigit() or 'align:' in line or 'position:' in line):
|
152 |
+
continue
|
153 |
+
|
154 |
+
# Clean up HTML tags and entities
|
155 |
+
clean_line = re.sub(r'<[^>]+>', '', line)
|
156 |
+
clean_line = re.sub(r'&[^;]+;', ' ', clean_line)
|
157 |
+
clean_line = re.sub(r'\s+', ' ', clean_line).strip()
|
158 |
+
|
159 |
+
# Skip if line is empty after cleaning or if it's an exact duplicate
|
160 |
+
if not clean_line or clean_line in seen_lines:
|
161 |
+
continue
|
162 |
+
|
163 |
+
# This logic helps handle auto-generated captions where a new line
|
164 |
+
# contains the previous line plus new words.
|
165 |
+
if text_lines and (clean_line in text_lines[-1] or text_lines[-1] in clean_line):
|
166 |
+
# If the new line is longer, replace the previous one
|
167 |
+
if len(clean_line) > len(text_lines[-1]):
|
168 |
+
seen_lines.discard(text_lines[-1])
|
169 |
+
text_lines[-1] = clean_line
|
170 |
+
seen_lines.add(clean_line)
|
171 |
+
# Otherwise, if it's shorter or a substring, skip it
|
172 |
+
continue
|
173 |
|
174 |
+
seen_lines.add(clean_line)
|
175 |
+
text_lines.append(clean_line)
|
176 |
+
|
177 |
+
full_text = ' '.join(text_lines)
|
178 |
+
return re.sub(r'\s+', ' ', full_text).strip()
|
179 |
+
|
180 |
+
|
181 |
+
async def get_transcript_with_yt_dlp_cookies(video_id: str, task_id: str) -> Optional[str]:
|
182 |
+
"""
|
183 |
+
Fetches transcript using yt-dlp with a cookies file.
|
184 |
+
"""
|
185 |
+
logger.info(f"[Task {task_id}] Attempting transcript fetch for video ID: {video_id} using yt-dlp.")
|
186 |
+
|
187 |
+
if not os.path.exists(COOKIES_FILE_PATH):
|
188 |
+
logger.error(f"[Task {task_id}] Cookies file not found at {COOKIES_FILE_PATH}. Cannot fetch transcript.")
|
189 |
+
return None
|
190 |
+
|
191 |
+
try:
|
192 |
+
video_url = f"https://www.youtube.com/watch?v={video_id}"
|
193 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
194 |
+
cmd = [
|
195 |
+
"yt-dlp",
|
196 |
+
"--skip-download",
|
197 |
+
"--write-auto-subs",
|
198 |
+
"--write-subs",
|
199 |
+
"--sub-lang", "en",
|
200 |
+
"--sub-format", "vtt",
|
201 |
+
"--cookies", COOKIES_FILE_PATH,
|
202 |
+
"-o", os.path.join(temp_dir, "%(id)s.%(ext)s"),
|
203 |
+
video_url
|
204 |
+
]
|
205 |
+
|
206 |
+
logger.info(f"[Task {task_id}] Running yt-dlp command...")
|
207 |
+
result = await asyncio.to_thread(
|
208 |
+
subprocess.run, cmd, capture_output=True, text=True, timeout=60
|
209 |
)
|
210 |
|
211 |
+
if result.returncode != 0:
|
212 |
+
logger.error(f"[Task {task_id}] yt-dlp failed. Stderr: {result.stderr}")
|
213 |
+
return None
|
214 |
+
|
215 |
+
subtitle_file_path = os.path.join(temp_dir, f"{video_id}.en.vtt")
|
216 |
+
if not os.path.exists(subtitle_file_path):
|
217 |
+
logger.warning(f"[Task {task_id}] Subtitle file not found at {subtitle_file_path}.")
|
218 |
+
return None
|
219 |
+
|
220 |
+
logger.info(f"[Task {task_id}] Found subtitle file: {os.path.basename(subtitle_file_path)}")
|
221 |
+
with open(subtitle_file_path, 'r', encoding='utf-8') as f:
|
222 |
+
subtitle_content = f.read()
|
223 |
+
|
224 |
+
transcript_text = parse_vtt_content(subtitle_content)
|
225 |
+
if not transcript_text:
|
226 |
+
logger.warning(f"[Task {task_id}] No text extracted from VTT file.")
|
227 |
+
return None
|
228 |
+
|
229 |
+
logger.info(f"[Task {task_id}] Transcript fetched successfully. Length: {len(transcript_text)}")
|
230 |
+
if len(transcript_text) > MAX_TRANSCRIPT_CHARS:
|
231 |
+
logger.warning(f"[Task {task_id}] Truncating transcript from {len(transcript_text)} to {MAX_TRANSCRIPT_CHARS} chars.")
|
232 |
+
return transcript_text[:MAX_TRANSCRIPT_CHARS]
|
233 |
+
|
234 |
+
return transcript_text
|
235 |
+
|
236 |
+
except subprocess.TimeoutExpired:
|
237 |
+
logger.error(f"[Task {task_id}] yt-dlp command timed out for video ID: {video_id}")
|
238 |
+
return None
|
239 |
+
except Exception as e:
|
240 |
+
logger.error(f"[Task {task_id}] Error fetching transcript with yt-dlp: {e}", exc_info=True)
|
241 |
+
return None
|
242 |
+
|
243 |
+
# --- Internal Business Logic ---
|
244 |
+
|
245 |
+
async def get_summary_from_custom_api(message: str, temperature: float = 0.6) -> Optional[str]:
|
246 |
+
"""
|
247 |
+
Calls the custom API (used by /chat) to get a complete response.
|
248 |
+
This is an internal-facing function designed for non-streaming, complete results.
|
249 |
+
"""
|
250 |
+
custom_api_key_secret = os.getenv("CUSTOM_API_SECRET_KEY")
|
251 |
+
custom_api_base_url = os.getenv("CUSTOM_API_BASE_URL", CUSTOM_API_BASE_URL_DEFAULT)
|
252 |
+
custom_api_model = os.getenv("CUSTOM_API_MODEL", CUSTOM_API_MODEL_DEFAULT)
|
253 |
+
|
254 |
+
if not custom_api_key_secret:
|
255 |
+
logger.error("Custom API key ('CUSTOM_API_SECRET_KEY') is not configured for internal summary generation.")
|
256 |
+
return None
|
257 |
+
|
258 |
+
try:
|
259 |
+
logger.info(f"Requesting summary from Custom API ({custom_api_base_url}) with model {custom_api_model}.")
|
260 |
+
from openai import AsyncOpenAI
|
261 |
+
client = AsyncOpenAI(
|
262 |
+
api_key=custom_api_key_secret,
|
263 |
+
base_url=custom_api_base_url,
|
264 |
+
timeout=SUMMARY_REQUEST_TIMEOUT_SECONDS
|
265 |
+
)
|
266 |
+
|
267 |
+
completion = await client.chat.completions.create(
|
268 |
+
model=custom_api_model,
|
269 |
+
temperature=temperature,
|
270 |
+
messages=[{"role": "user", "content": message}],
|
271 |
+
stream=False # We need the full response for a summary
|
272 |
+
)
|
273 |
+
|
274 |
+
if completion.choices and completion.choices[0].message and completion.choices[0].message.content:
|
275 |
+
summary = completion.choices[0].message.content
|
276 |
+
logger.info(f"Successfully generated summary of length {len(summary)}.")
|
277 |
+
return summary.strip()
|
278 |
else:
|
279 |
+
logger.warning("Custom API call for summary returned no content.")
|
280 |
+
return None
|
281 |
+
|
282 |
+
except Exception as e:
|
283 |
+
logger.error(f"Error during internal Custom API call for summary: {e}", exc_info=True)
|
284 |
+
return None
|
285 |
+
|
286 |
+
|
287 |
+
async def process_gemini_request_background(
|
288 |
+
task_id: str,
|
289 |
+
user_message: str,
|
290 |
+
input_url: Optional[str],
|
291 |
+
requested_gemini_model: str,
|
292 |
+
gemini_key_to_use: str
|
293 |
+
):
|
294 |
+
"""
|
295 |
+
The fallback background process that sends the original request to the Gemini API.
|
296 |
+
This is used when a transcript cannot be obtained or summarization fails.
|
297 |
+
"""
|
298 |
+
logger.info(f"[Task {task_id}] Starting background Gemini processing. Model: {requested_gemini_model}, URL: {input_url}")
|
299 |
+
tasks_db[task_id]["status"] = "PROCESSING"
|
300 |
+
tasks_db[task_id]["last_updated_at"] = datetime.now(timezone.utc)
|
301 |
+
|
302 |
+
try:
|
303 |
+
genai.configure(api_key=gemini_key_to_use)
|
304 |
+
model_instance = genai.GenerativeModel(model_name=requested_gemini_model)
|
305 |
|
306 |
+
# This function now primarily handles the non-transcript case
|
307 |
+
content_parts = [{"text": user_message}]
|
308 |
+
if input_url:
|
309 |
+
logger.info(f"[Task {task_id}] Providing Gemini with the URL directly: {input_url}")
|
310 |
+
content_parts.append({
|
311 |
+
"file_data": {
|
312 |
+
"mime_type": "video/youtube", # Assuming Gemini can handle it
|
313 |
+
"file_uri": input_url
|
314 |
+
}
|
315 |
+
})
|
316 |
|
317 |
+
gemini_contents = [{"parts": content_parts}]
|
318 |
+
generation_config = GenerationConfig(candidate_count=1)
|
319 |
+
request_options = {"timeout": GEMINI_REQUEST_TIMEOUT_SECONDS}
|
320 |
+
|
321 |
+
logger.info(f"[Task {task_id}] Sending request to Gemini API...")
|
322 |
+
response = await model_instance.generate_content_async(
|
323 |
+
gemini_contents, stream=False, generation_config=generation_config, request_options=request_options
|
324 |
+
)
|
325 |
|
326 |
+
full_response_text = getattr(response, 'text', '')
|
327 |
+
if not full_response_text and hasattr(response, 'parts'):
|
328 |
+
full_response_text = ''.join(part.text for part in response.parts if hasattr(part, 'text'))
|
329 |
+
|
330 |
+
if not full_response_text and response.prompt_feedback and response.prompt_feedback.block_reason:
|
331 |
+
block_reason = response.prompt_feedback.block_reason.name
|
332 |
+
logger.warning(f"[Task {task_id}] Gemini content blocked: {block_reason}")
|
333 |
+
tasks_db[task_id]["status"] = "FAILED"
|
334 |
+
tasks_db[task_id]["error"] = f"Content blocked by Gemini due to: {block_reason}"
|
335 |
+
elif full_response_text:
|
336 |
+
logger.info(f"[Task {task_id}] Gemini processing successful. Result length: {len(full_response_text)}")
|
337 |
+
tasks_db[task_id]["status"] = "COMPLETED"
|
338 |
+
tasks_db[task_id]["result"] = full_response_text
|
339 |
+
else:
|
340 |
+
logger.warning(f"[Task {task_id}] Gemini returned no content and no block reason.")
|
341 |
+
tasks_db[task_id]["status"] = "FAILED"
|
342 |
+
tasks_db[task_id]["error"] = "Gemini returned no content."
|
343 |
+
|
344 |
+
except Exception as e:
|
345 |
+
logger.error(f"[Task {task_id}] Error during Gemini background processing: {e}", exc_info=True)
|
346 |
+
tasks_db[task_id]["status"] = "FAILED"
|
347 |
+
tasks_db[task_id]["error"] = str(e)
|
348 |
+
|
349 |
+
finally:
|
350 |
+
tasks_db[task_id]["last_updated_at"] = datetime.now(timezone.utc)
|
351 |
+
|
352 |
+
# --- API Endpoints ---
|
353 |
+
|
354 |
+
@app.post("/chat", response_class=StreamingResponse)
|
355 |
+
async def direct_chat(payload: ChatPayload, request: Request):
|
356 |
+
logger.info(f"Direct chat request. Message: '{payload.message[:50]}...'")
|
357 |
+
user_ip = get_user_ip(request)
|
358 |
+
|
359 |
+
if rate_limiter.is_rate_limited(user_ip):
|
360 |
+
raise HTTPException(
|
361 |
+
status_code=429,
|
362 |
+
detail={"error": "Rate limit exceeded. Please try again tomorrow."}
|
363 |
+
)
|
364 |
+
|
365 |
+
custom_api_key_secret = os.getenv("CUSTOM_API_SECRET_KEY")
|
366 |
+
custom_api_base_url = os.getenv("CUSTOM_API_BASE_URL", CUSTOM_API_BASE_URL_DEFAULT)
|
367 |
+
custom_api_model = os.getenv("CUSTOM_API_MODEL", CUSTOM_API_MODEL_DEFAULT)
|
368 |
+
|
369 |
+
if not custom_api_key_secret:
|
370 |
+
logger.error("'CUSTOM_API_SECRET_KEY' is not configured for /chat.")
|
371 |
+
raise HTTPException(status_code=500, detail="API key not configured.")
|
372 |
+
|
373 |
+
async def custom_api_streamer():
|
374 |
+
try:
|
375 |
+
from openai import AsyncOpenAI
|
376 |
+
client = AsyncOpenAI(
|
377 |
+
api_key=custom_api_key_secret,
|
378 |
+
base_url=custom_api_base_url,
|
379 |
+
timeout=60.0
|
380 |
+
)
|
381 |
+
stream = await client.chat.completions.create(
|
382 |
+
model=custom_api_model,
|
383 |
+
temperature=payload.temperature,
|
384 |
+
messages=[{"role": "user", "content": payload.message}],
|
385 |
+
stream=True
|
386 |
+
)
|
387 |
+
async for chunk in stream:
|
388 |
+
if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
|
389 |
+
yield chunk.choices[0].delta.content
|
390 |
+
except Exception as e:
|
391 |
+
logger.error(f"Error streaming from Custom API: {e}", exc_info=True)
|
392 |
+
yield f"Error processing with Custom API: {str(e)}"
|
393 |
+
|
394 |
+
return StreamingResponse(custom_api_streamer(), media_type="text/plain")
|
395 |
+
|
396 |
+
|
397 |
+
@app.post("/gemini/submit_task", response_model=TaskSubmissionResponse)
|
398 |
+
async def submit_gemini_task(
|
399 |
+
request_data: GeminiTaskRequest,
|
400 |
+
background_tasks: BackgroundTasks,
|
401 |
+
http_request: Request
|
402 |
+
):
|
403 |
+
task_id = str(uuid.uuid4())
|
404 |
+
logger.info(f"Received task {task_id}. URL: {request_data.url}")
|
405 |
+
|
406 |
+
# Initialize the task in the database
|
407 |
+
tasks_db[task_id] = {
|
408 |
+
"status": "PENDING", "result": None, "error": None,
|
409 |
+
"submitted_at": datetime.now(timezone.utc),
|
410 |
+
"last_updated_at": datetime.now(timezone.utc),
|
411 |
+
"request_params": request_data.model_dump()
|
412 |
+
}
|
413 |
+
|
414 |
+
# --- Primary Path: YouTube Transcript Summarization ---
|
415 |
+
video_id = None
|
416 |
+
if request_data.url and is_youtube_url(request_data.url):
|
417 |
+
video_id = extract_video_id(request_data.url)
|
418 |
+
|
419 |
+
if video_id:
|
420 |
+
transcript_text = await get_transcript_with_yt_dlp_cookies(video_id, task_id)
|
421 |
+
|
422 |
+
if transcript_text:
|
423 |
+
logger.info(f"[Task {task_id}] Transcript found. Proceeding with summarization.")
|
424 |
+
|
425 |
+
# Use the user's message as a prompt for the transcript
|
426 |
+
summarization_prompt = f"{request_data.message}\n\nVideo Transcript:\n{transcript_text}"
|
427 |
+
|
428 |
+
summary_text = await get_summary_from_custom_api(summarization_prompt)
|
429 |
+
|
430 |
+
if summary_text:
|
431 |
+
logger.info(f"[Task {task_id}] Summarization successful. Task complete.")
|
432 |
+
tasks_db[task_id]["status"] = "COMPLETED"
|
433 |
+
tasks_db[task_id]["result"] = summary_text
|
434 |
+
tasks_db[task_id]["last_updated_at"] = datetime.now(timezone.utc)
|
435 |
+
return TaskSubmissionResponse(
|
436 |
+
task_id=task_id,
|
437 |
+
status="COMPLETED",
|
438 |
+
task_detail_url=str(http_request.url_for('get_gemini_task_status', task_id=task_id))
|
439 |
+
)
|
440 |
+
else:
|
441 |
+
logger.warning(f"[Task {task_id}] Summarization via custom API failed.")
|
442 |
+
# Fall through to Gemini fallback
|
443 |
+
else:
|
444 |
+
logger.warning(f"[Task {task_id}] Transcript fetch failed.")
|
445 |
+
# Fall through to Gemini fallback
|
446 |
+
|
447 |
+
# --- Fallback Path: Gemini Background Processing ---
|
448 |
+
logger.info(f"[Task {task_id}] No transcript or summarization failed. Falling back to background Gemini task.")
|
449 |
+
|
450 |
+
gemini_key_to_use = request_data.api_key or os.getenv("GEMINI_API_KEY")
|
451 |
+
if not gemini_key_to_use:
|
452 |
+
logger.error(f"[Task {task_id}] Gemini API Key is missing.")
|
453 |
+
raise HTTPException(status_code=400, detail="Gemini API Key is required for the fallback process.")
|
454 |
+
|
455 |
+
requested_model = request_data.gemini_model or DEFAULT_GEMINI_MODEL
|
456 |
+
|
457 |
+
background_tasks.add_task(
|
458 |
+
process_gemini_request_background,
|
459 |
+
task_id,
|
460 |
+
request_data.message,
|
461 |
+
request_data.url,
|
462 |
+
requested_model,
|
463 |
+
gemini_key_to_use
|
464 |
+
)
|
465 |
+
|
466 |
+
logger.info(f"[Task {task_id}] Task submitted to background Gemini processing.")
|
467 |
+
return TaskSubmissionResponse(
|
468 |
+
task_id=task_id,
|
469 |
+
status="PENDING", # It's pending in the background
|
470 |
+
task_detail_url=str(http_request.url_for('get_gemini_task_status', task_id=task_id))
|
471 |
+
)
|
472 |
+
|
473 |
+
|
474 |
+
@app.get("/gemini/task/{task_id}", response_model=TaskStatusResponse)
|
475 |
+
async def get_gemini_task_status(task_id: str = Path(..., description="The ID of the task to retrieve")):
|
476 |
+
task = tasks_db.get(task_id)
|
477 |
+
if not task:
|
478 |
+
raise HTTPException(status_code=404, detail="Task ID not found.")
|
479 |
+
|
480 |
+
return TaskStatusResponse(
|
481 |
+
task_id=task_id,
|
482 |
+
status=task["status"],
|
483 |
+
submitted_at=task["submitted_at"],
|
484 |
+
last_updated_at=task["last_updated_at"],
|
485 |
+
result=task.get("result"),
|
486 |
+
error=task.get("error")
|
487 |
)
|
488 |
|
489 |
+
@app.get("/")
|
490 |
+
async def read_root():
|
491 |
+
return {"message": "API for YouTube summarization and Gemini tasks is running."}
|