rsnarsna commited on
Commit
cce56a7
·
1 Parent(s): d4639a3

fix: Refactor polling configuration to use a list of dictionaries for accurate retry attempts; add 'innertubex' to requirements

Browse files
Files changed (2) hide show
  1. gemini_transcript.py +51 -93
  2. requirements.txt +2 -1
gemini_transcript.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import re
6
  import sys
7
  import json
@@ -18,7 +19,6 @@ from youtube_transcript_api import (
18
  YouTubeTranscriptApi,
19
  TranscriptsDisabled,
20
  NoTranscriptFound,
21
- VideoUnavailable,
22
  )
23
 
24
 
@@ -26,7 +26,11 @@ from youtube_transcript_api import (
26
  # CONFIG
27
  # ============================================================================
28
 
29
- GEMINI_API_KEY = "AIzaSyCNz5wQAyJ65kNRkwr0-1A-_Z6-lQzdcyc"
 
 
 
 
30
 
31
  GEMINI_MODELS = [
32
  "gemini-2.5-flash",
@@ -34,22 +38,28 @@ GEMINI_MODELS = [
34
  "gemini-2.5-pro",
35
  ]
36
 
37
- POLLING_CONFIG = {
38
- "attempt_1": {"wait_before": 0, "description": "Immediate attempt on trigger"},
39
- "attempt_2": {"wait_before": 300, "description": "Retry after 5 minutes"},
40
- "attempt_3": {"wait_before": 900, "description": "Retry after 15 minutes "},
41
- "attempt_4": {"wait_before": 900, "description": "Retry after 15 minutes 30 minutes completed"},
42
- "attempt_5": {"wait_before": 900, "description": "Retry after 15 minutes 45 minutes completed"},
43
- "attempt_6": {"wait_before": 900, "description": "Retry after 15 minutes 1 hour completed"},
44
- "attempt_7": {"wait_before": 900, "description": "Retry after 15 minutes 1 hour 15 minutes completed"},
45
- "attempt_8": {"wait_before": 900, "description": "Retry after 15 minutes 1 hour 30 minutes completed"},
46
- "attempt_3": {"wait_before": 900, "description": "Retry after 15 minutes 1 hour 45 minutes completed"},
47
- "attempt_4": {"wait_before": 900, "description": "Retry after 15 minutes 2 hours completed"},
48
- "attempt_5": {"wait_before": 900, "description": "Retry after 15 minutes 2 hours 15 minutes completed"},
49
- "attempt_6": {"wait_before": 900, "description": "Retry after 15 minutes 2 hours 30 minutes completed"},
50
- "attempt_7": {"wait_before": 900, "description": "Retry after 15 minutes 2 hours 45 minutes completed"},
51
- "attempt_8": {"wait_before": 900, "description": "Retry after 15 minutes 3 hours completed"},
52
- }
 
 
 
 
 
 
53
 
54
  SYSTEM_PROMPT = """
55
  You are an expert content summarizer and educator.
@@ -148,30 +158,21 @@ def _parse_vtt(content: str) -> str:
148
 
149
  for line in lines:
150
  stripped = line.strip()
151
- # Skip empty lines
152
  if not stripped:
153
  continue
154
- # Skip VTT header
155
  if stripped.startswith("WEBVTT"):
156
  continue
157
- # Skip metadata lines (Kind:, Language:, Style, NOTE, etc.)
158
  if re.match(r"^(Kind:|Language:|Style|NOTE)", stripped, re.IGNORECASE):
159
  continue
160
- # Skip timestamp lines (00:00:00.000 --> 00:00:05.000)
161
  if re.match(r"^\d{2}:\d{2}[:\.]\d{2}[\.:]\d{3}\s*-->\s*\d{2}:\d{2}", stripped):
162
  continue
163
- # Skip position/alignment metadata
164
  if re.match(r"^(position:|align:|line:|size:)", stripped, re.IGNORECASE):
165
  continue
166
- # Skip sequence numbers (pure digits)
167
  if stripped.isdigit():
168
  continue
169
- # Strip inline tags like <c>, </c>, <00:00:01.234>
170
- cleaned = re.sub(r"<[^>]+>", "", stripped)
171
- cleaned = cleaned.strip()
172
  if not cleaned:
173
  continue
174
- # Deduplicate consecutive identical lines
175
  if cleaned != prev_line:
176
  text_lines.append(cleaned)
177
  prev_line = cleaned
@@ -191,13 +192,10 @@ def _parse_srt(content: str) -> str:
191
  stripped = line.strip()
192
  if not stripped:
193
  continue
194
- # Skip sequence numbers
195
  if stripped.isdigit():
196
  continue
197
- # Skip timing lines
198
  if re.match(r"^\d{2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{2}:\d{2}", stripped):
199
  continue
200
- # Strip HTML-style tags
201
  cleaned = re.sub(r"<[^>]+>", "", stripped).strip()
202
  if cleaned:
203
  text_lines.append(cleaned)
@@ -240,12 +238,12 @@ class YouTubeTranscriptFetcher:
240
  self,
241
  youtube_url: str,
242
  languages: Optional[List[str]] = None,
243
- polling_config: dict = None,
244
  google_creds = None,
245
  ):
246
  self.youtube_url = youtube_url
247
  self.languages = languages or ["en", "en-US", "en-GB"]
248
- self.polling_config = polling_config or POLLING_CONFIG
249
  self.video_id = self._extract_video_id(youtube_url)
250
  self.api = YouTubeTranscriptApi()
251
  self.google_creds = google_creds
@@ -274,7 +272,7 @@ class YouTubeTranscriptFetcher:
274
  first_transcript = next(iter(transcript_list))
275
  logger.info("[Tier 1] Falling back to language: %s", first_transcript.language_code)
276
  transcript = first_transcript.fetch()
277
-
278
  return " ".join(item.text for item in transcript)
279
 
280
  def _try_all_tiers(self) -> tuple[str, str]:
@@ -291,8 +289,6 @@ class YouTubeTranscriptFetcher:
291
  logger.info("[Tier 1] ✅ youtube_transcript_api succeeded — %d chars", len(text))
292
  return text, "youtube_transcript_api"
293
  except TranscriptsDisabled as e:
294
- # Still try yt-dlp — sometimes auto-subs exist even when
295
- # the captions toggle is "disabled" for the transcript API
296
  errors.append(f"Tier1(TranscriptsDisabled): {e}")
297
  logger.warning("[Tier 1] Transcripts disabled, trying fallbacks...")
298
  except Exception as e:
@@ -336,33 +332,35 @@ class YouTubeTranscriptFetcher:
336
  On each polling attempt, all tiers are tried before waiting.
337
  Returns (transcript_text, extraction_method).
338
  """
339
- logger.info("Video ID : %s", self.video_id)
340
- logger.info("Polling attempts : %d", len(self.polling_config))
 
341
 
342
- attempts = list(self.polling_config.items())
 
343
 
344
- for idx, (attempt_key, config) in enumerate(attempts, start=1):
345
  wait_before = config["wait_before"]
346
  description = config["description"]
347
 
348
  if wait_before > 0:
349
  logger.info(
350
  "[%d/%d] %s — waiting %s before retry...",
351
- idx, len(attempts), description,
352
  _format_duration(wait_before),
353
  )
354
  time.sleep(wait_before)
355
 
356
  logger.info(
357
  "[%d/%d] %s — trying all transcript tiers...",
358
- idx, len(attempts), description,
359
  )
360
 
361
  try:
362
  text, method = self._try_all_tiers()
363
  logger.info(
364
  "[%d/%d] ✅ Transcript fetched via %s — %d characters",
365
- idx, len(attempts), method, len(text),
366
  )
367
  return text, method
368
 
@@ -371,21 +369,21 @@ class YouTubeTranscriptFetcher:
371
  raise
372
 
373
  except Exception as e:
374
- logger.warning("[%d/%d] All tiers failed: %s", idx, len(attempts), e)
375
 
376
- if idx < len(attempts):
377
- next_cfg = attempts[idx][1]
378
  logger.info(
379
  "[%d/%d] Will retry in %s (%s)",
380
- idx, len(attempts),
381
  _format_duration(next_cfg["wait_before"]),
382
  next_cfg["description"],
383
  )
384
  else:
385
- logger.error("All %d polling attempts exhausted.", len(attempts))
386
 
387
  raise RuntimeError(
388
- f"Transcript not available after {len(attempts)} attempts (~5 hours). "
389
  f"Video ID: {self.video_id}"
390
  )
391
 
@@ -409,38 +407,27 @@ class YtDlpTranscriptFetcher:
409
  self.languages = languages or ["en", "en-US", "en-GB"]
410
 
411
  def _find_subtitle_url(self, manual_subs: dict, auto_subs: dict) -> tuple[str, str]:
412
- """
413
- Search manual subtitles first, then auto-generated, for a
414
- matching language + preferred format.
415
- If requested languages are not available, fallback to any available language.
416
- Returns (url, format_ext).
417
- """
418
- # 1. Try preferred languages
419
  for subs_dict in (manual_subs, auto_subs):
420
  if not subs_dict:
421
  continue
422
  for lang in self.languages:
423
  if lang not in subs_dict:
424
  continue
425
- tracks = subs_dict[lang] # list of {ext, url, ...}
426
  if not tracks:
427
  continue
428
- # Try preferred formats in order
429
  for fmt in self.PREFERRED_FORMATS:
430
  for track in tracks:
431
  if track.get("ext") == fmt and track.get("url"):
432
  return track["url"], fmt
433
- # No preferred format matched — use first available with URL
434
  for track in tracks:
435
  if track.get("url"):
436
  return track["url"], track.get("ext", "vtt")
437
 
438
- # 2. Fallback to ANY available language
439
  logger.info("[yt-dlp] Preferred languages %s not found. Falling back to any available language.", self.languages)
440
  for subs_dict in (manual_subs, auto_subs):
441
  if not subs_dict:
442
  continue
443
- # Try preferred formats across all languages
444
  for fmt in self.PREFERRED_FORMATS:
445
  for lang, tracks in subs_dict.items():
446
  if not tracks:
@@ -449,7 +436,6 @@ class YtDlpTranscriptFetcher:
449
  if track.get("ext") == fmt and track.get("url"):
450
  logger.info("[yt-dlp] Falling back to language: %s", lang)
451
  return track["url"], fmt
452
- # No preferred format matched — use first available with URL across all languages
453
  for lang, tracks in subs_dict.items():
454
  if not tracks:
455
  continue
@@ -463,11 +449,6 @@ class YtDlpTranscriptFetcher:
463
  )
464
 
465
  def fetch(self) -> str:
466
- """
467
- Extract subtitle URL from video metadata, fetch content
468
- in-memory via HTTP, parse and return as plain text.
469
- No files are written to disk.
470
- """
471
  import yt_dlp
472
  import requests as _requests
473
 
@@ -504,7 +485,6 @@ class YtDlpTranscriptFetcher:
504
 
505
  logger.info("[yt-dlp] Fetching subtitle content (format=%s)", sub_fmt)
506
 
507
- # Fetch subtitle content in-memory
508
  try:
509
  resp = _requests.get(sub_url, timeout=30)
510
  resp.raise_for_status()
@@ -515,13 +495,11 @@ class YtDlpTranscriptFetcher:
515
  if not raw_content.strip():
516
  raise RuntimeError("Subtitle URL returned empty content.")
517
 
518
- # Parse based on format
519
  if sub_fmt in ("vtt",):
520
  text = _parse_vtt(raw_content)
521
  elif sub_fmt in ("srt",):
522
  text = _parse_srt(raw_content)
523
  else:
524
- # For srv1/srv2/srv3/ttml — strip all XML/HTML tags as fallback
525
  text = re.sub(r"<[^>]+>", "", raw_content)
526
  text = re.sub(r"\s+", " ", text).strip()
527
 
@@ -554,10 +532,6 @@ class YouTubeApiTranscriptFetcher:
554
  self.languages = languages or ["en", "en-US", "en-GB"]
555
 
556
  def fetch(self) -> str:
557
- """
558
- List caption tracks, find a matching language, and download.
559
- Returns plain text transcript.
560
- """
561
  if self.credentials is None:
562
  raise RuntimeError("No OAuth credentials provided for YouTube API.")
563
 
@@ -572,7 +546,6 @@ class YouTubeApiTranscriptFetcher:
572
  cache_discovery=False,
573
  )
574
 
575
- # Step 1: List caption tracks
576
  captions_response = youtube.captions().list(
577
  part="snippet",
578
  videoId=self.video_id,
@@ -584,13 +557,11 @@ class YouTubeApiTranscriptFetcher:
584
  f"No caption tracks found for video {self.video_id}"
585
  )
586
 
587
- # Step 2: Find best matching caption track
588
  caption_id = None
589
  for lang in self.languages:
590
  for item in items:
591
  snippet = item.get("snippet", {})
592
  if snippet.get("language", "") == lang:
593
- # Prefer non-auto-generated (manual) captions
594
  if snippet.get("trackKind") != "ASR":
595
  caption_id = item["id"]
596
  logger.info(
@@ -601,7 +572,6 @@ class YouTubeApiTranscriptFetcher:
601
  if caption_id:
602
  break
603
 
604
- # Fallback: accept any track in preferred languages
605
  if not caption_id:
606
  for lang in self.languages:
607
  for item in items:
@@ -627,13 +597,11 @@ class YouTubeApiTranscriptFetcher:
627
  f"No caption tracks found for video {self.video_id}"
628
  )
629
 
630
- # Step 3: Download caption content as SRT
631
  caption_content = youtube.captions().download(
632
  id=caption_id,
633
  tfmt="srt",
634
  ).execute()
635
 
636
- # Response may be bytes or string
637
  if isinstance(caption_content, bytes):
638
  caption_content = caption_content.decode("utf-8")
639
 
@@ -673,12 +641,10 @@ class GeminiSummarizer:
673
  """
674
 
675
  MAX_RETRIES = 5
676
- BASE_WAIT = 10 # seconds
677
- MAX_WAIT = 120 # seconds cap
678
 
679
- # Errors → retry same model with exponential backoff
680
  RETRYABLE = ["503", "502", "500", "UNAVAILABLE", "SERVICE_UNAVAILABLE"]
681
- # Errors → skip to next model immediately
682
  SKIP_TO_NEXT = ["429", "RESOURCE_EXHAUSTED", "quota", "404", "NOT_FOUND"]
683
 
684
  def __init__(
@@ -690,10 +656,6 @@ class GeminiSummarizer:
690
  self.models = models or GEMINI_MODELS
691
 
692
  def _call_api(self, transcript: str) -> tuple[str, str]:
693
- """
694
- Try each model in order with per-model retry + backoff.
695
- Returns (full_response_text, model_used).
696
- """
697
  overall_last_error = None
698
 
699
  for model in self.models:
@@ -764,10 +726,6 @@ class GeminiSummarizer:
764
  return full_text.strip(), ""
765
 
766
  def run(self, transcript: str) -> tuple[str, str, str]:
767
- """
768
- Summarize transcript.
769
- Returns (summary, qa, model_used) — nothing is written to disk.
770
- """
771
  full, model_used = self._call_api(transcript)
772
  summary, qa = self._split(full)
773
  logger.info("✅ Summarization complete — model: %s", model_used)
@@ -789,7 +747,7 @@ class TranscriptSummaryPipeline:
789
  self,
790
  youtube_url: str,
791
  languages: Optional[List[str]] = None,
792
- polling_config: dict = None,
793
  google_creds = None,
794
  ):
795
  self.youtube_url = youtube_url
 
2
 
3
  from __future__ import annotations
4
 
5
+ import os
6
  import re
7
  import sys
8
  import json
 
19
  YouTubeTranscriptApi,
20
  TranscriptsDisabled,
21
  NoTranscriptFound,
 
22
  )
23
 
24
 
 
26
  # CONFIG
27
  # ============================================================================
28
 
29
+ GEMINI_KEY = "AIzaSyCNz5wQAyJ65kNRkwr0-1A-_Z6-lQzdcyc"
30
+
31
+ # ── API Keys ────────────────────────────────────────────────────────────────
32
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", GEMINI_KEY)
33
+ YT_API_KEY = os.getenv("YT_API_KEY", "AIzaSyASnhRpV-YQQb4xvoggWIEm8nvrujerEos")
34
 
35
  GEMINI_MODELS = [
36
  "gemini-2.5-flash",
 
38
  "gemini-2.5-pro",
39
  ]
40
 
41
+ # ── FIX: Use a LIST of dicts, not a dict.
42
+ # A plain dict with duplicate keys like "attempt_3" silently drops all
43
+ # but the last definition, collapsing 13 intended attempts down to 4.
44
+ # A list preserves every entry in order.
45
+ def _polling_attempt(wait_before: int, description: str) -> dict:
46
+ return {"wait_before": wait_before, "description": description}
47
+
48
+ POLLING_CONFIG: list[dict] = [
49
+ _polling_attempt(0, "Immediate attempt on trigger"),
50
+ _polling_attempt(300, "Retry after 5 minutes"),
51
+ _polling_attempt(900, "Retry after 15 minutes (30 min total)"),
52
+ _polling_attempt(900, "Retry after 15 minutes (45 min total)"),
53
+ _polling_attempt(900, "Retry after 15 minutes (1 hr total)"),
54
+ _polling_attempt(900, "Retry after 15 minutes (1 hr 15 min total)"),
55
+ _polling_attempt(900, "Retry after 15 minutes (1 hr 30 min total)"),
56
+ _polling_attempt(900, "Retry after 15 minutes (1 hr 45 min total)"),
57
+ _polling_attempt(900, "Retry after 15 minutes (2 hr total)"),
58
+ _polling_attempt(900, "Retry after 15 minutes (2 hr 15 min total)"),
59
+ _polling_attempt(900, "Retry after 15 minutes (2 hr 30 min total)"),
60
+ _polling_attempt(900, "Retry after 15 minutes (2 hr 45 min total)"),
61
+ _polling_attempt(900, "Retry after 15 minutes (3 hr total)"),
62
+ ]
63
 
64
  SYSTEM_PROMPT = """
65
  You are an expert content summarizer and educator.
 
158
 
159
  for line in lines:
160
  stripped = line.strip()
 
161
  if not stripped:
162
  continue
 
163
  if stripped.startswith("WEBVTT"):
164
  continue
 
165
  if re.match(r"^(Kind:|Language:|Style|NOTE)", stripped, re.IGNORECASE):
166
  continue
 
167
  if re.match(r"^\d{2}:\d{2}[:\.]\d{2}[\.:]\d{3}\s*-->\s*\d{2}:\d{2}", stripped):
168
  continue
 
169
  if re.match(r"^(position:|align:|line:|size:)", stripped, re.IGNORECASE):
170
  continue
 
171
  if stripped.isdigit():
172
  continue
173
+ cleaned = re.sub(r"<[^>]+>", "", stripped).strip()
 
 
174
  if not cleaned:
175
  continue
 
176
  if cleaned != prev_line:
177
  text_lines.append(cleaned)
178
  prev_line = cleaned
 
192
  stripped = line.strip()
193
  if not stripped:
194
  continue
 
195
  if stripped.isdigit():
196
  continue
 
197
  if re.match(r"^\d{2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{2}:\d{2}", stripped):
198
  continue
 
199
  cleaned = re.sub(r"<[^>]+>", "", stripped).strip()
200
  if cleaned:
201
  text_lines.append(cleaned)
 
238
  self,
239
  youtube_url: str,
240
  languages: Optional[List[str]] = None,
241
+ polling_config: list[dict] = None, # ← list, not dict
242
  google_creds = None,
243
  ):
244
  self.youtube_url = youtube_url
245
  self.languages = languages or ["en", "en-US", "en-GB"]
246
+ self.polling_config = polling_config if polling_config is not None else POLLING_CONFIG
247
  self.video_id = self._extract_video_id(youtube_url)
248
  self.api = YouTubeTranscriptApi()
249
  self.google_creds = google_creds
 
272
  first_transcript = next(iter(transcript_list))
273
  logger.info("[Tier 1] Falling back to language: %s", first_transcript.language_code)
274
  transcript = first_transcript.fetch()
275
+
276
  return " ".join(item.text for item in transcript)
277
 
278
  def _try_all_tiers(self) -> tuple[str, str]:
 
289
  logger.info("[Tier 1] ✅ youtube_transcript_api succeeded — %d chars", len(text))
290
  return text, "youtube_transcript_api"
291
  except TranscriptsDisabled as e:
 
 
292
  errors.append(f"Tier1(TranscriptsDisabled): {e}")
293
  logger.warning("[Tier 1] Transcripts disabled, trying fallbacks...")
294
  except Exception as e:
 
332
  On each polling attempt, all tiers are tried before waiting.
333
  Returns (transcript_text, extraction_method).
334
  """
335
+ # ── FIX: polling_config is now a list, so len() and enumeration work correctly.
336
+ attempts = self.polling_config
337
+ total = len(attempts)
338
 
339
+ logger.info("Video ID : %s", self.video_id)
340
+ logger.info("Polling attempts : %d", total)
341
 
342
+ for idx, config in enumerate(attempts, start=1):
343
  wait_before = config["wait_before"]
344
  description = config["description"]
345
 
346
  if wait_before > 0:
347
  logger.info(
348
  "[%d/%d] %s — waiting %s before retry...",
349
+ idx, total, description,
350
  _format_duration(wait_before),
351
  )
352
  time.sleep(wait_before)
353
 
354
  logger.info(
355
  "[%d/%d] %s — trying all transcript tiers...",
356
+ idx, total, description,
357
  )
358
 
359
  try:
360
  text, method = self._try_all_tiers()
361
  logger.info(
362
  "[%d/%d] ✅ Transcript fetched via %s — %d characters",
363
+ idx, total, method, len(text),
364
  )
365
  return text, method
366
 
 
369
  raise
370
 
371
  except Exception as e:
372
+ logger.warning("[%d/%d] All tiers failed: %s", idx, total, e)
373
 
374
+ if idx < total:
375
+ next_cfg = attempts[idx] # idx is 1-based, list is 0-based → next item
376
  logger.info(
377
  "[%d/%d] Will retry in %s (%s)",
378
+ idx, total,
379
  _format_duration(next_cfg["wait_before"]),
380
  next_cfg["description"],
381
  )
382
  else:
383
+ logger.error("All %d polling attempts exhausted.", total)
384
 
385
  raise RuntimeError(
386
+ f"Transcript not available after {total} attempts (~3 hours). "
387
  f"Video ID: {self.video_id}"
388
  )
389
 
 
407
  self.languages = languages or ["en", "en-US", "en-GB"]
408
 
409
  def _find_subtitle_url(self, manual_subs: dict, auto_subs: dict) -> tuple[str, str]:
 
 
 
 
 
 
 
410
  for subs_dict in (manual_subs, auto_subs):
411
  if not subs_dict:
412
  continue
413
  for lang in self.languages:
414
  if lang not in subs_dict:
415
  continue
416
+ tracks = subs_dict[lang]
417
  if not tracks:
418
  continue
 
419
  for fmt in self.PREFERRED_FORMATS:
420
  for track in tracks:
421
  if track.get("ext") == fmt and track.get("url"):
422
  return track["url"], fmt
 
423
  for track in tracks:
424
  if track.get("url"):
425
  return track["url"], track.get("ext", "vtt")
426
 
 
427
  logger.info("[yt-dlp] Preferred languages %s not found. Falling back to any available language.", self.languages)
428
  for subs_dict in (manual_subs, auto_subs):
429
  if not subs_dict:
430
  continue
 
431
  for fmt in self.PREFERRED_FORMATS:
432
  for lang, tracks in subs_dict.items():
433
  if not tracks:
 
436
  if track.get("ext") == fmt and track.get("url"):
437
  logger.info("[yt-dlp] Falling back to language: %s", lang)
438
  return track["url"], fmt
 
439
  for lang, tracks in subs_dict.items():
440
  if not tracks:
441
  continue
 
449
  )
450
 
451
  def fetch(self) -> str:
 
 
 
 
 
452
  import yt_dlp
453
  import requests as _requests
454
 
 
485
 
486
  logger.info("[yt-dlp] Fetching subtitle content (format=%s)", sub_fmt)
487
 
 
488
  try:
489
  resp = _requests.get(sub_url, timeout=30)
490
  resp.raise_for_status()
 
495
  if not raw_content.strip():
496
  raise RuntimeError("Subtitle URL returned empty content.")
497
 
 
498
  if sub_fmt in ("vtt",):
499
  text = _parse_vtt(raw_content)
500
  elif sub_fmt in ("srt",):
501
  text = _parse_srt(raw_content)
502
  else:
 
503
  text = re.sub(r"<[^>]+>", "", raw_content)
504
  text = re.sub(r"\s+", " ", text).strip()
505
 
 
532
  self.languages = languages or ["en", "en-US", "en-GB"]
533
 
534
  def fetch(self) -> str:
 
 
 
 
535
  if self.credentials is None:
536
  raise RuntimeError("No OAuth credentials provided for YouTube API.")
537
 
 
546
  cache_discovery=False,
547
  )
548
 
 
549
  captions_response = youtube.captions().list(
550
  part="snippet",
551
  videoId=self.video_id,
 
557
  f"No caption tracks found for video {self.video_id}"
558
  )
559
 
 
560
  caption_id = None
561
  for lang in self.languages:
562
  for item in items:
563
  snippet = item.get("snippet", {})
564
  if snippet.get("language", "") == lang:
 
565
  if snippet.get("trackKind") != "ASR":
566
  caption_id = item["id"]
567
  logger.info(
 
572
  if caption_id:
573
  break
574
 
 
575
  if not caption_id:
576
  for lang in self.languages:
577
  for item in items:
 
597
  f"No caption tracks found for video {self.video_id}"
598
  )
599
 
 
600
  caption_content = youtube.captions().download(
601
  id=caption_id,
602
  tfmt="srt",
603
  ).execute()
604
 
 
605
  if isinstance(caption_content, bytes):
606
  caption_content = caption_content.decode("utf-8")
607
 
 
641
  """
642
 
643
  MAX_RETRIES = 5
644
+ BASE_WAIT = 10
645
+ MAX_WAIT = 120
646
 
 
647
  RETRYABLE = ["503", "502", "500", "UNAVAILABLE", "SERVICE_UNAVAILABLE"]
 
648
  SKIP_TO_NEXT = ["429", "RESOURCE_EXHAUSTED", "quota", "404", "NOT_FOUND"]
649
 
650
  def __init__(
 
656
  self.models = models or GEMINI_MODELS
657
 
658
  def _call_api(self, transcript: str) -> tuple[str, str]:
 
 
 
 
659
  overall_last_error = None
660
 
661
  for model in self.models:
 
726
  return full_text.strip(), ""
727
 
728
  def run(self, transcript: str) -> tuple[str, str, str]:
 
 
 
 
729
  full, model_used = self._call_api(transcript)
730
  summary, qa = self._split(full)
731
  logger.info("✅ Summarization complete — model: %s", model_used)
 
747
  self,
748
  youtube_url: str,
749
  languages: Optional[List[str]] = None,
750
+ polling_config: list[dict] = None, # ← list, not dict
751
  google_creds = None,
752
  ):
753
  self.youtube_url = youtube_url
requirements.txt CHANGED
@@ -7,4 +7,5 @@ requests
7
  youtube_transcript_api
8
  google-generativeai
9
  google-genai
10
- yt-dlp
 
 
7
  youtube_transcript_api
8
  google-generativeai
9
  google-genai
10
+ yt-dlp
11
+ innertubex