Chrunos commited on
Commit
97f80b5
·
verified ·
1 Parent(s): ce97e85

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +476 -140
app.py CHANGED
@@ -1,155 +1,491 @@
1
- import streamlit as st
2
- from summarizer import process_video
3
- from urllib.parse import urlparse, parse_qs
4
- import base64
5
-
6
- # Page configuration
7
- st.set_page_config(
8
- page_title="YouTube Summary AI",
9
- page_icon="📝",
10
- layout="centered",
11
- initial_sidebar_state="expanded"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  )
 
13
 
14
- # Custom CSS
15
- st.markdown("""
16
- <style>
17
- .main {
18
- padding: 2rem 3rem;
19
- }
20
- .stButton>button {
21
- width: 100%;
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
- def get_youtube_url_from_params():
57
- query_params = st.query_params
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
- def display_youtube_thumbnail(url):
64
- try:
65
- # Extract video ID from URL
66
- parsed_url = urlparse(url)
67
- if parsed_url.netloc == 'youtu.be':
68
- video_id = parsed_url.path[1:]
69
- else:
70
- video_id = parse_qs(parsed_url.query)['v'][0]
71
-
72
- # Display thumbnail
73
- thumbnail_url = f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg"
74
- st.image(thumbnail_url, use_container_width=True)
75
- except:
76
- pass
77
-
78
- def main():
79
- # Header
80
- st.markdown('<p class="title-text">YouTube Summary AI</p>', unsafe_allow_html=True)
81
- st.markdown(
82
- '<p class="subtitle-text">Transform YouTube videos into concise notes and summaries using AI</p>',
83
- unsafe_allow_html=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
- # Create two columns for the main content
87
- col1, col2 = st.columns([2, 1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
- with col1:
90
- youtube_url = get_youtube_url_from_params()
91
- if not youtube_url:
92
- youtube_url = st.text_input(
93
- "🎥 Enter YouTube Video URL",
94
- placeholder="https://youtube.com/watch?v=..."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  )
96
 
97
- with col2:
98
- if youtube_url:
99
- process_button = st.button("🚀 Generate Summary", type="primary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  else:
101
- process_button = st.button("🚀 Generate Summary", type="primary", disabled=True)
102
-
103
- # Display info box
104
- with st.expander("ℹ️ How to use"):
105
- st.markdown("""
106
- 1. Paste a YouTube video URL in the input field
107
- 2. Click 'Generate Summary' to process the video
108
- 3. Or simply replace 'youtube.com' with 'shiappoutube.com' in any YouTube URL
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
- Example: `https://shiappoutube.com/watch?v=VIDEO_ID`
111
- """)
 
 
 
 
 
 
 
 
112
 
113
- if youtube_url:
114
- display_youtube_thumbnail(youtube_url)
 
 
 
 
 
 
115
 
116
- if process_button:
117
- try:
118
- # Create placeholder for streaming output
119
- output_placeholder = st.empty()
120
-
121
- with st.spinner("🎯 Downloading video..."):
122
- # Process video and display streaming output
123
- notes_and_summary = process_video(youtube_url)
124
-
125
- # Display the results in a nice format
126
- st.success(" Processing complete!")
127
-
128
- # Create tabs for different sections
129
- tab1, tab2 = st.tabs(["📝 Notes & Summary", "🔗 Share"])
130
-
131
- with tab1:
132
- st.markdown("### Generated Content")
133
- st.write(notes_and_summary)
134
-
135
- with tab2:
136
- st.markdown("### Share this summary")
137
- share_url = youtube_url.replace("youtube.com", "shiappoutube.com")
138
- st.code(share_url, language="markdown")
139
- st.markdown("Copy this URL to share the summary with others!")
140
-
141
- except Exception as e:
142
- st.error(f"❌ An error occurred: {str(e)}")
143
- st.info("Please make sure you've entered a valid YouTube URL and try again.")
144
-
145
- # Footer
146
- st.markdown("---")
147
- st.markdown(
148
- "<div style='text-align: center; color: #666666;'>"
149
- "Made with ❤️ using Streamlit and AI"
150
- "</div>",
151
- unsafe_allow_html=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  )
153
 
154
- if __name__ == "__main__":
155
- main()
 
 
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."}