lattifai 1.2.0__py3-none-any.whl → 1.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. lattifai/__init__.py +0 -24
  2. lattifai/alignment/__init__.py +10 -1
  3. lattifai/alignment/lattice1_aligner.py +66 -58
  4. lattifai/alignment/lattice1_worker.py +1 -6
  5. lattifai/alignment/punctuation.py +38 -0
  6. lattifai/alignment/segmenter.py +1 -1
  7. lattifai/alignment/sentence_splitter.py +350 -0
  8. lattifai/alignment/text_align.py +440 -0
  9. lattifai/alignment/tokenizer.py +91 -220
  10. lattifai/caption/__init__.py +82 -6
  11. lattifai/caption/caption.py +335 -1143
  12. lattifai/caption/formats/__init__.py +199 -0
  13. lattifai/caption/formats/base.py +211 -0
  14. lattifai/caption/formats/gemini.py +722 -0
  15. lattifai/caption/formats/json.py +194 -0
  16. lattifai/caption/formats/lrc.py +309 -0
  17. lattifai/caption/formats/nle/__init__.py +9 -0
  18. lattifai/caption/formats/nle/audition.py +561 -0
  19. lattifai/caption/formats/nle/avid.py +423 -0
  20. lattifai/caption/formats/nle/fcpxml.py +549 -0
  21. lattifai/caption/formats/nle/premiere.py +589 -0
  22. lattifai/caption/formats/pysubs2.py +642 -0
  23. lattifai/caption/formats/sbv.py +147 -0
  24. lattifai/caption/formats/tabular.py +338 -0
  25. lattifai/caption/formats/textgrid.py +193 -0
  26. lattifai/caption/formats/ttml.py +652 -0
  27. lattifai/caption/formats/vtt.py +469 -0
  28. lattifai/caption/parsers/__init__.py +9 -0
  29. lattifai/caption/{text_parser.py → parsers/text_parser.py} +4 -2
  30. lattifai/caption/standardize.py +636 -0
  31. lattifai/caption/utils.py +474 -0
  32. lattifai/cli/__init__.py +2 -1
  33. lattifai/cli/caption.py +108 -1
  34. lattifai/cli/transcribe.py +4 -9
  35. lattifai/cli/youtube.py +4 -1
  36. lattifai/client.py +48 -84
  37. lattifai/config/__init__.py +11 -1
  38. lattifai/config/alignment.py +9 -2
  39. lattifai/config/caption.py +267 -23
  40. lattifai/config/media.py +20 -0
  41. lattifai/diarization/__init__.py +41 -1
  42. lattifai/mixin.py +36 -18
  43. lattifai/transcription/base.py +6 -1
  44. lattifai/transcription/lattifai.py +19 -54
  45. lattifai/utils.py +81 -13
  46. lattifai/workflow/__init__.py +28 -4
  47. lattifai/workflow/file_manager.py +2 -5
  48. lattifai/youtube/__init__.py +43 -0
  49. lattifai/youtube/client.py +1170 -0
  50. lattifai/youtube/types.py +23 -0
  51. lattifai-1.2.2.dist-info/METADATA +615 -0
  52. lattifai-1.2.2.dist-info/RECORD +76 -0
  53. {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/entry_points.txt +1 -2
  54. lattifai/caption/gemini_reader.py +0 -371
  55. lattifai/caption/gemini_writer.py +0 -173
  56. lattifai/cli/app_installer.py +0 -142
  57. lattifai/cli/server.py +0 -44
  58. lattifai/server/app.py +0 -427
  59. lattifai/workflow/youtube.py +0 -577
  60. lattifai-1.2.0.dist-info/METADATA +0 -1133
  61. lattifai-1.2.0.dist-info/RECORD +0 -57
  62. {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/WHEEL +0 -0
  63. {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/licenses/LICENSE +0 -0
  64. {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1170 @@
1
+ """
2
+ YouTube client for metadata extraction and media download using yt-dlp
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ try:
14
+ import yt_dlp
15
+ except ImportError:
16
+ yt_dlp = None
17
+
18
+ from ..config.caption import CAPTION_FORMATS
19
+ from ..errors import LattifAIError
20
+ from ..workflow.base import setup_workflow_logger
21
+ from ..workflow.file_manager import TRANSCRIBE_CHOICE, FileExistenceManager
22
+ from .types import CaptionTrack, VideoMetadata
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class YouTubeError(LattifAIError):
28
+ """Base error for YouTube operations"""
29
+
30
+ pass
31
+
32
+
33
+ class VideoUnavailableError(YouTubeError):
34
+ """Video is not available (private, deleted, etc)"""
35
+
36
+ pass
37
+
38
+
39
+ class YoutubeLoader:
40
+ """Lightweight YouTube metadata and caption content loader
41
+
42
+ Use this class when you need to:
43
+ - Fetch video metadata quickly
44
+ - Get caption content in memory (not save to disk)
45
+ - Support proxy and cookies configuration
46
+ """
47
+
48
+ def __init__(self, proxy: Optional[str] = None, cookies: Optional[str] = None):
49
+ if yt_dlp is None:
50
+ raise ImportError("yt-dlp is required. Install with `pip install yt-dlp`")
51
+
52
+ self.proxy = proxy
53
+ self.cookies = cookies
54
+
55
+ # Base configuration for metadata extraction
56
+ self._base_opts = {
57
+ "quiet": True,
58
+ "no_warnings": True,
59
+ "skip_download": True,
60
+ "extract_flat": False, # Need full info for captions
61
+ "youtube_include_dash_manifest": False,
62
+ "youtube_include_hls_manifest": False,
63
+ }
64
+
65
+ if self.proxy:
66
+ self._base_opts["proxy"] = self.proxy
67
+
68
+ if self.cookies:
69
+ self._base_opts["cookiefile"] = self.cookies
70
+
71
+ # Strategy: Prefer Android client to avoid PO Token issues on Web
72
+ # But for captions, sometimes Web is needed.
73
+ # We start with a robust default.
74
+ self._base_opts["extractor_args"] = {"youtube": {"player_client": ["android", "web"]}}
75
+
76
+ def get_video_info(self, video_id: str) -> Dict[str, Any]:
77
+ """
78
+ Fetch basic video metadata and list of available captions.
79
+ Returns a dict with 'metadata' (VideoMetadata) and 'captions' (List[CaptionTrack]).
80
+ """
81
+ url = f"https://www.youtube.com/watch?v={video_id}"
82
+ opts = {
83
+ **self._base_opts,
84
+ "writesubtitles": True,
85
+ "writeautomaticsub": True,
86
+ }
87
+
88
+ try:
89
+ with yt_dlp.YoutubeDL(opts) as ydl:
90
+ info = ydl.extract_info(url, download=False)
91
+
92
+ # Parse metadata
93
+ metadata = VideoMetadata(
94
+ video_id=info.get("id", video_id),
95
+ title=info.get("title", "Unknown"),
96
+ description=info.get("description", ""),
97
+ duration=float(info.get("duration", 0)),
98
+ thumbnail_url=info.get("thumbnail", ""),
99
+ channel_name=info.get("uploader", "Unknown"),
100
+ view_count=info.get("view_count", 0),
101
+ upload_date=info.get("upload_date"),
102
+ )
103
+
104
+ # Parse captions
105
+ tracks: List[CaptionTrack] = []
106
+
107
+ # Manual captions
108
+ subtitles = info.get("subtitles", {})
109
+ for lang, formats in subtitles.items():
110
+ for fmt in formats:
111
+ tracks.append(
112
+ CaptionTrack(
113
+ language_code=lang,
114
+ language_name=self._get_lang_name(formats),
115
+ kind="manual",
116
+ ext=fmt.get("ext", ""),
117
+ url=fmt.get("url"),
118
+ )
119
+ )
120
+
121
+ # Auto captions
122
+ auto_subs = info.get("automatic_captions", {})
123
+ for lang, formats in auto_subs.items():
124
+ for fmt in formats:
125
+ tracks.append(
126
+ CaptionTrack(
127
+ language_code=lang,
128
+ language_name=self._get_lang_name(formats),
129
+ kind="asr",
130
+ ext=fmt.get("ext", ""),
131
+ url=fmt.get("url"),
132
+ )
133
+ )
134
+
135
+ return {"metadata": metadata, "captions": tracks}
136
+
137
+ except yt_dlp.utils.DownloadError as e:
138
+ msg = str(e)
139
+ if "Sign in to confirm" in msg or "Private video" in msg:
140
+ raise VideoUnavailableError(f"Video {video_id} is unavailable: {msg}")
141
+ raise YouTubeError(f"yt-dlp failed: {msg}") from e
142
+ except Exception as e:
143
+ raise YouTubeError(f"Unexpected error: {str(e)}") from e
144
+
145
+ def get_caption(self, video_id: str, lang: str = "en") -> Dict[str, str]:
146
+ """
147
+ Fetch transcript for a specific language.
148
+ Returns a dict with 'content' (raw string) and 'fmt' (format extension).
149
+ """
150
+ url = f"https://www.youtube.com/watch?v={video_id}"
151
+
152
+ # We need to download json3 or vtt to parse.
153
+ # Ideally we want json3 for precision, but yt-dlp prefers vtt/srv3
154
+
155
+ opts = {
156
+ **self._base_opts,
157
+ "writesubtitles": True,
158
+ "writeautomaticsub": True,
159
+ "subtitleslangs": [lang],
160
+ "skip_download": True,
161
+ }
162
+
163
+ try:
164
+ with yt_dlp.YoutubeDL(opts) as ydl:
165
+ info = ydl.extract_info(url, download=False)
166
+
167
+ # Look for the requested language in subtitles or automatic_captions
168
+ subs = info.get("subtitles", {}).get(lang)
169
+ if not subs:
170
+ subs = info.get("automatic_captions", {}).get(lang)
171
+
172
+ if not subs:
173
+ raise YouTubeError(f"No captions found for language: {lang}")
174
+
175
+ # Sort to find best format (json3 > vtt > ttml > srv3)
176
+ best_fmt = self._find_best_format(subs)
177
+ if not best_fmt or not best_fmt.get("url"):
178
+ raise YouTubeError("Could not find a download URL for captions")
179
+
180
+ caption_url = best_fmt["url"]
181
+ ext = best_fmt.get("ext")
182
+ content = self._fetch_caption(caption_url)
183
+
184
+ return {"content": content, "fmt": ext}
185
+
186
+ except Exception as e:
187
+ raise YouTubeError(f"Failed to fetch transcript: {str(e)}") from e
188
+
189
+ def _get_lang_name(self, formats: List[Dict]) -> str:
190
+ if formats and "name" in formats[0]:
191
+ return formats[0]["name"]
192
+ return "Unknown"
193
+
194
+ def _find_best_format(self, formats: List[Dict]) -> Optional[Dict]:
195
+ # Prefer json3, then vtt
196
+ priority = ["json3", "vtt", "ttml", "srv3", "srv2", "srv1"]
197
+
198
+ for fmt_ext in priority:
199
+ for f in formats:
200
+ if f.get("ext") == fmt_ext:
201
+ return f
202
+ return formats[0] if formats else None
203
+
204
+ def _fetch_caption(self, url: str) -> str:
205
+ import requests
206
+
207
+ try:
208
+ resp = requests.get(url, proxies={"https": self.proxy} if self.proxy else None)
209
+ resp.raise_for_status()
210
+ return resp.text
211
+ except Exception as e:
212
+ logger.error(f"Error fetching caption: {e}")
213
+ raise YouTubeError("Failed to fetch caption content") from e
214
+
215
+ def get_audio_url(
216
+ self,
217
+ video_id: str,
218
+ format_preference: str = "m4a",
219
+ quality: str = "best",
220
+ audio_track_id: Optional[str] = None,
221
+ ) -> Dict[str, Any]:
222
+ """
223
+ Get direct audio-only stream URL for a YouTube video.
224
+
225
+ Args:
226
+ video_id: YouTube video ID
227
+ format_preference: Preferred audio format (m4a, webm, opus)
228
+ quality: Audio quality - "best" (highest bitrate), "medium" (~128kbps),
229
+ "low" (~50kbps), or specific bitrate like "128", "64"
230
+ audio_track_id: Specific audio track ID for multi-language videos (e.g., "en.2")
231
+
232
+ Returns:
233
+ Dict with url, mime_type, bitrate, content_length, format_id, ext
234
+ """
235
+ url = f"https://www.youtube.com/watch?v={video_id}"
236
+
237
+ # Use default yt-dlp config to get DASH formats with separate audio streams
238
+ opts = {
239
+ "quiet": True,
240
+ "no_warnings": True,
241
+ "skip_download": True,
242
+ "extract_flat": False,
243
+ "youtube_include_dash_manifest": True,
244
+ }
245
+ if self.proxy:
246
+ opts["proxy"] = self.proxy
247
+ if self.cookies:
248
+ opts["cookiefile"] = self.cookies
249
+
250
+ try:
251
+ with yt_dlp.YoutubeDL(opts) as ydl:
252
+ info = ydl.extract_info(url, download=False)
253
+
254
+ # Get all formats and filter for audio-only (no video track)
255
+ formats = info.get("formats", [])
256
+ audio_formats = [
257
+ f
258
+ for f in formats
259
+ if f.get("acodec") not in (None, "none")
260
+ and f.get("vcodec") in (None, "none")
261
+ and f.get("url") # Must have a direct URL
262
+ ]
263
+
264
+ if not audio_formats:
265
+ raise YouTubeError(
266
+ "No audio-only formats available. " "YouTube may require authentication for this video."
267
+ )
268
+
269
+ # Filter by audio_track_id if specified (for multi-language audio)
270
+ if audio_track_id:
271
+ # yt-dlp uses format_id patterns like "251-0" or "audio_track" field
272
+ # Try matching by format_id suffix or audio_track field
273
+ track_filtered = [
274
+ f
275
+ for f in audio_formats
276
+ if f.get("audio_track", {}).get("id") == audio_track_id
277
+ or (f.get("format_id") and audio_track_id in f.get("format_id", ""))
278
+ or f.get("language") == audio_track_id.split(".")[0] # e.g., "en" from "en.2"
279
+ ]
280
+ if track_filtered:
281
+ audio_formats = track_filtered
282
+ logger.info(f"Filtered to {len(audio_formats)} formats for audio_track_id={audio_track_id}")
283
+
284
+ # Parse quality parameter
285
+ # "best" = highest bitrate, "medium" ~128kbps, "low" ~50kbps
286
+ quality_tier = quality.lower()
287
+ if quality_tier == "best":
288
+ max_bitrate = float("inf")
289
+ elif quality_tier == "medium":
290
+ max_bitrate = 160 # Allow up to 160kbps for "medium"
291
+ elif quality_tier == "low":
292
+ max_bitrate = 70 # Allow up to 70kbps for "low"
293
+ elif quality_tier.isdigit():
294
+ max_bitrate = int(quality_tier) + 20 # Allow some tolerance
295
+ else:
296
+ max_bitrate = float("inf") # Default to best
297
+
298
+ # Sort by preference: format match > bitrate (within limit)
299
+ def score_format(f: Dict) -> tuple:
300
+ ext = f.get("ext", "")
301
+ ext_match = 2 if ext == format_preference else 0
302
+ # Prefer m4a/webm over other formats
303
+ common_format = 1 if ext in ("m4a", "webm", "opus") else 0
304
+ bitrate = f.get("abr") or f.get("tbr") or 0
305
+
306
+ # For quality tiers, filter then maximize
307
+ if bitrate <= max_bitrate:
308
+ quality_score = bitrate # Higher is better within limit
309
+ else:
310
+ quality_score = -1000 # Exclude formats exceeding limit
311
+
312
+ return (ext_match, common_format, quality_score)
313
+
314
+ audio_formats.sort(key=score_format, reverse=True)
315
+ best = audio_formats[0]
316
+
317
+ return {
318
+ "url": best.get("url"),
319
+ "mime_type": best.get("ext", format_preference),
320
+ "bitrate": best.get("abr") or best.get("tbr"),
321
+ "content_length": best.get("filesize") or best.get("filesize_approx"),
322
+ "format_id": best.get("format_id"),
323
+ "ext": best.get("ext"),
324
+ }
325
+
326
+ except yt_dlp.utils.DownloadError as e:
327
+ raise YouTubeError(f"Failed to get audio URL: {str(e)}") from e
328
+ except Exception as e:
329
+ raise YouTubeError(f"Unexpected error getting audio URL: {str(e)}") from e
330
+
331
+ def get_video_url(self, video_id: str, format_preference: str = "mp4", quality: str = "best") -> Dict[str, Any]:
332
+ """
333
+ Get direct video stream URL for a YouTube video.
334
+
335
+ Args:
336
+ video_id: YouTube video ID
337
+ format_preference: Preferred video format (mp4, webm)
338
+ quality: Video quality (best, 1080, 720, 480, 360)
339
+
340
+ Returns:
341
+ Dict with url, mime_type, width, height, fps, vcodec, acodec, bitrate, content_length, format_id, ext
342
+
343
+ Note:
344
+ Prioritizes formats that include both video AND audio to avoid silent videos.
345
+ YouTube separates high-quality video and audio streams; we prefer pre-muxed formats.
346
+ """
347
+ url = f"https://www.youtube.com/watch?v={video_id}"
348
+
349
+ # Use default yt-dlp config to get all available formats
350
+ opts = {
351
+ "quiet": True,
352
+ "no_warnings": True,
353
+ "skip_download": True,
354
+ "extract_flat": False,
355
+ "youtube_include_dash_manifest": True,
356
+ }
357
+ if self.proxy:
358
+ opts["proxy"] = self.proxy
359
+ if self.cookies:
360
+ opts["cookiefile"] = self.cookies
361
+
362
+ try:
363
+ with yt_dlp.YoutubeDL(opts) as ydl:
364
+ info = ydl.extract_info(url, download=False)
365
+
366
+ # Get all formats
367
+ formats = info.get("formats", [])
368
+
369
+ # Filter for video formats:
370
+ # - Must have video codec
371
+ # - Must have direct URL (not manifest/playlist)
372
+ # - Exclude HLS/DASH manifests (protocol contains m3u8 or dash)
373
+ def is_direct_video(f: Dict) -> bool:
374
+ if f.get("vcodec") in (None, "none"):
375
+ return False
376
+ url = f.get("url", "")
377
+ protocol = f.get("protocol", "")
378
+ # Exclude HLS manifests
379
+ if "m3u8" in protocol or ".m3u8" in url or "manifest.googlevideo.com" in url:
380
+ return False
381
+ # Exclude DASH manifests
382
+ if "dash" in protocol:
383
+ return False
384
+ return True
385
+
386
+ video_formats = [f for f in formats if is_direct_video(f)]
387
+
388
+ if not video_formats:
389
+ raise YouTubeError("No direct video formats available (only HLS/DASH manifests found)")
390
+
391
+ # Parse target height from quality parameter
392
+ target_height = None
393
+ if quality != "best" and quality.isdigit():
394
+ target_height = int(quality)
395
+
396
+ # Sort by preference: has_audio (MOST IMPORTANT) > format match > resolution > bitrate
397
+ # YouTube high-quality streams are often video-only; we MUST prefer formats with audio
398
+ def score_format(f: Dict) -> tuple:
399
+ ext = f.get("ext", "")
400
+ ext_match = 1 if ext == format_preference else 0
401
+ height = f.get("height") or 0
402
+ bitrate = f.get("tbr") or f.get("vbr") or 0
403
+ # has_audio is now the HIGHEST priority - video without audio is useless for most users
404
+ has_audio = 10 if f.get("acodec") not in (None, "none") else 0
405
+
406
+ # For quality filtering, penalize formats exceeding target
407
+ height_score = height
408
+ if target_height and height > target_height:
409
+ height_score = -1000 # Heavily penalize exceeding target
410
+
411
+ return (has_audio, ext_match, height_score, bitrate)
412
+
413
+ video_formats.sort(key=score_format, reverse=True)
414
+ best = video_formats[0]
415
+
416
+ # Log selection for debugging
417
+ logger.info(
418
+ f"Selected video format: {best.get('format_id')} "
419
+ f"({best.get('width')}x{best.get('height')}, "
420
+ f"vcodec={best.get('vcodec')}, acodec={best.get('acodec')})"
421
+ )
422
+
423
+ return {
424
+ "url": best.get("url"),
425
+ "mime_type": best.get("ext", format_preference),
426
+ "width": best.get("width"),
427
+ "height": best.get("height"),
428
+ "fps": best.get("fps"),
429
+ "vcodec": best.get("vcodec"),
430
+ "acodec": best.get("acodec"),
431
+ "bitrate": best.get("tbr") or best.get("vbr"),
432
+ "content_length": best.get("filesize") or best.get("filesize_approx"),
433
+ "format_id": best.get("format_id"),
434
+ "ext": best.get("ext"),
435
+ }
436
+
437
+ except yt_dlp.utils.DownloadError as e:
438
+ raise YouTubeError(f"Failed to get video URL: {str(e)}") from e
439
+ except Exception as e:
440
+ raise YouTubeError(f"Unexpected error getting video URL: {str(e)}") from e
441
+
442
+
443
+ class YouTubeDownloader:
444
+ """YouTube media and caption file downloader using yt-dlp
445
+
446
+ Use this class when you need to:
447
+ - Download audio/video files to disk
448
+ - Download caption files to disk
449
+ - Manage file existence and overwrite options
450
+ - Async download support
451
+ """
452
+
453
+ def __init__(self):
454
+ if yt_dlp is None:
455
+ raise ImportError("yt-dlp is required. Install with `pip install yt-dlp`")
456
+
457
+ self.logger = setup_workflow_logger("youtube")
458
+ self.logger.info(f"yt-dlp version: {yt_dlp.version.__version__}")
459
+
460
+ def _normalize_audio_quality(self, quality: str) -> str:
461
+ """
462
+ Normalize quality parameter for audio downloads.
463
+
464
+ Handles cross-type quality values (e.g., video resolution used for audio).
465
+
466
+ Args:
467
+ quality: Raw quality string
468
+
469
+ Returns:
470
+ Normalized audio quality string
471
+ """
472
+ quality_lower = quality.lower()
473
+
474
+ # Direct audio quality values
475
+ if quality_lower in ("best", "medium", "low"):
476
+ return quality_lower
477
+
478
+ # Numeric values need interpretation
479
+ if quality_lower.isdigit():
480
+ value = int(quality_lower)
481
+ # Values > 320 are likely video resolutions, not audio bitrates
482
+ if value > 320:
483
+ self.logger.warning(f"⚠️ Quality '{quality}' looks like video resolution, using 'best' for audio")
484
+ return "best"
485
+ # Values <= 320 are reasonable audio bitrates
486
+ return quality_lower
487
+
488
+ # Unknown value, default to best
489
+ return "best"
490
+
491
+ def _normalize_video_quality(self, quality: str) -> str:
492
+ """
493
+ Normalize quality parameter for video downloads.
494
+
495
+ Handles cross-type quality values (e.g., audio bitrate/quality used for video).
496
+
497
+ Args:
498
+ quality: Raw quality string
499
+
500
+ Returns:
501
+ Normalized video quality string
502
+ """
503
+ quality_lower = quality.lower()
504
+
505
+ # Map audio quality terms to video equivalents
506
+ if quality_lower == "low":
507
+ self.logger.info("🎬 Mapping audio quality 'low' to video 360p")
508
+ return "360"
509
+ elif quality_lower == "medium":
510
+ self.logger.info("🎬 Mapping audio quality 'medium' to video 720p")
511
+ return "720"
512
+ elif quality_lower == "best":
513
+ return "best"
514
+
515
+ # Numeric values
516
+ if quality_lower.isdigit():
517
+ value = int(quality_lower)
518
+ # Values <= 320 are likely audio bitrates, not video resolutions
519
+ if value <= 320:
520
+ self.logger.warning(f"⚠️ Quality '{quality}' looks like audio bitrate, using 'best' for video")
521
+ return "best"
522
+ # Values > 320 are reasonable video resolutions
523
+ return quality_lower
524
+
525
+ # Unknown value, default to best
526
+ return "best"
527
+
528
+ def _build_audio_format_selector(self, audio_track_id: Optional[str], quality: str = "best") -> str:
529
+ """
530
+ Build yt-dlp format selector string for audio track and quality selection.
531
+
532
+ Args:
533
+ audio_track_id: Audio track selection:
534
+ - "original": Select the original audio track (format_id contains "drc")
535
+ - Language code (e.g., "en", "ja"): Select by language
536
+ - Format ID (e.g., "251-drc"): Select specific format
537
+ - None: No filtering
538
+ quality: Audio quality:
539
+ - "best": Highest bitrate (default)
540
+ - "medium": ~128 kbps
541
+ - "low": ~50 kbps
542
+ - Numeric string (e.g., "128"): Target bitrate in kbps
543
+
544
+ Returns:
545
+ yt-dlp format selector string
546
+ """
547
+ # Normalize quality for audio context
548
+ quality_lower = self._normalize_audio_quality(quality)
549
+
550
+ # Build quality filter
551
+ quality_filter = ""
552
+ if quality_lower == "medium":
553
+ quality_filter = "[abr<=160]"
554
+ self.logger.info("🎵 Audio quality: medium (~128 kbps)")
555
+ elif quality_lower == "low":
556
+ quality_filter = "[abr<=70]"
557
+ self.logger.info("🎵 Audio quality: low (~50 kbps)")
558
+ elif quality_lower.isdigit():
559
+ max_bitrate = int(quality_lower) + 20 # Allow some tolerance
560
+ quality_filter = f"[abr<={max_bitrate}]"
561
+ self.logger.info(f"🎵 Audio quality: ~{quality_lower} kbps")
562
+ # "best" = no filter, use bestaudio
563
+
564
+ # Build track filter
565
+ if audio_track_id is None:
566
+ return f"bestaudio{quality_filter}/bestaudio/best"
567
+
568
+ if audio_track_id.lower() == "original":
569
+ self.logger.info("🎵 Selecting original audio track (format_id contains 'drc')")
570
+ return f"bestaudio[format_id*=drc]{quality_filter}/bestaudio{quality_filter}/bestaudio/best"
571
+
572
+ # Check if it looks like a format_id (contains hyphen or is numeric)
573
+ if "-" in audio_track_id or audio_track_id.isdigit():
574
+ self.logger.info(f"🎵 Selecting audio by format_id: {audio_track_id}")
575
+ return f"bestaudio[format_id={audio_track_id}]{quality_filter}/bestaudio{quality_filter}/bestaudio/best"
576
+
577
+ # Assume it's a language code
578
+ self.logger.info(f"🎵 Selecting audio by language: {audio_track_id}")
579
+ return f"bestaudio[language^={audio_track_id}]{quality_filter}/bestaudio{quality_filter}/bestaudio/best"
580
+
581
+ def _build_video_format_selector(self, audio_format_selector: str, quality: str = "best") -> str:
582
+ """
583
+ Build yt-dlp format selector string for video with quality selection.
584
+
585
+ Args:
586
+ audio_format_selector: Audio format selector from _build_audio_format_selector
587
+ quality: Video quality:
588
+ - "best": Highest resolution (default)
589
+ - "low": 360p
590
+ - "medium": 720p
591
+ - "1080", "720", "480", "360": Target resolution
592
+
593
+ Returns:
594
+ yt-dlp format selector string
595
+ """
596
+ # Normalize quality for video context
597
+ quality_lower = self._normalize_video_quality(quality)
598
+
599
+ if quality_lower.isdigit():
600
+ height = int(quality_lower)
601
+ self.logger.info(f"🎬 Video quality: {height}p")
602
+ return f"bestvideo[height<={height}]+{audio_format_selector}/best[height<={height}]/best"
603
+
604
+ # "best" or fallback
605
+ return f"bestvideo*+{audio_format_selector}/best"
606
+
607
+ @staticmethod
608
+ def extract_video_id(url: str) -> str:
609
+ """
610
+ Extract video ID from YouTube URL
611
+
612
+ Supports various YouTube URL formats:
613
+ - https://www.youtube.com/watch?v=VIDEO_ID
614
+ - https://youtu.be/VIDEO_ID
615
+ - https://www.youtube.com/shorts/VIDEO_ID
616
+ - https://m.youtube.com/watch?v=VIDEO_ID
617
+
618
+ Returns:
619
+ Video ID (e.g., 'cprOj8PWepY')
620
+ """
621
+ patterns = [
622
+ r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/)([a-zA-Z0-9_-]{11})",
623
+ r"youtube\.com/embed/([a-zA-Z0-9_-]{11})",
624
+ r"youtube\.com/v/([a-zA-Z0-9_-]{11})",
625
+ ]
626
+
627
+ for pattern in patterns:
628
+ match = re.search(pattern, url)
629
+ if match:
630
+ return match.group(1)
631
+ return "youtube_media"
632
+
633
+ async def get_video_info(self, url: str) -> Dict[str, Any]:
634
+ """Get video metadata without downloading"""
635
+ self.logger.info(f"🔍 Extracting video info for: {url}")
636
+
637
+ opts = {
638
+ "quiet": True,
639
+ "no_warnings": True,
640
+ "skip_download": True,
641
+ }
642
+
643
+ try:
644
+ # Run in thread pool to avoid blocking
645
+ loop = asyncio.get_event_loop()
646
+
647
+ def _extract_info():
648
+ with yt_dlp.YoutubeDL(opts) as ydl:
649
+ return ydl.extract_info(url, download=False)
650
+
651
+ metadata = await loop.run_in_executor(None, _extract_info)
652
+
653
+ # Extract relevant info
654
+ info = {
655
+ "title": metadata.get("title", "Unknown"),
656
+ "duration": metadata.get("duration", 0),
657
+ "uploader": metadata.get("uploader", "Unknown"),
658
+ "upload_date": metadata.get("upload_date", "Unknown"),
659
+ "view_count": metadata.get("view_count", 0),
660
+ "description": metadata.get("description", ""),
661
+ "thumbnail": metadata.get("thumbnail", ""),
662
+ "webpage_url": metadata.get("webpage_url", url),
663
+ }
664
+
665
+ self.logger.info(f'✅ Video info extracted: {info["title"]}')
666
+ return info
667
+
668
+ except yt_dlp.utils.DownloadError as e:
669
+ self.logger.error(f"Failed to extract video info: {str(e)}")
670
+ raise RuntimeError(f"Failed to extract video info: {str(e)}")
671
+ except Exception as e:
672
+ self.logger.error(f"Failed to parse video metadata: {str(e)}")
673
+ raise RuntimeError(f"Failed to parse video metadata: {str(e)}")
674
+
675
+ async def download_media(
676
+ self,
677
+ url: str,
678
+ output_dir: Optional[str] = None,
679
+ media_format: Optional[str] = None,
680
+ force_overwrite: bool = False,
681
+ audio_track_id: Optional[str] = "original",
682
+ quality: str = "best",
683
+ ) -> str:
684
+ """
685
+ Download media (audio or video) from YouTube URL based on format
686
+
687
+ This is a unified method that automatically selects between audio and video
688
+ download based on the media format extension.
689
+
690
+ Args:
691
+ url: YouTube URL
692
+ output_dir: Output directory (default: temp directory)
693
+ media_format: Media format - audio (mp3, wav, m4a, aac, opus, ogg, flac, aiff)
694
+ or video (mp4, webm, mkv, avi, mov, etc.) (default: mp3)
695
+ force_overwrite: Skip user confirmation and overwrite existing files
696
+ audio_track_id: Audio track selection for multi-language videos:
697
+ - "original": Select the original audio track (default)
698
+ - Language code (e.g., "en", "ja"): Select by language
699
+ - Format ID (e.g., "251-drc"): Select specific format
700
+ - None: No filtering, use yt-dlp default
701
+ quality: Media quality selection:
702
+ For audio: "best", "medium", "low", or bitrate like "128"
703
+ For video: "best", "1080", "720", "480", "360"
704
+
705
+ Returns:
706
+ Path to downloaded media file
707
+ """
708
+ media_format = media_format or "mp3"
709
+
710
+ # Determine if format is audio or video
711
+ audio_formats = ["mp3", "wav", "m4a", "aac", "opus", "ogg", "flac", "aiff"]
712
+ is_audio = media_format.lower() in audio_formats
713
+
714
+ if is_audio:
715
+ self.logger.info(f"🎵 Detected audio format: {media_format}")
716
+ return await self.download_audio(
717
+ url=url,
718
+ output_dir=output_dir,
719
+ media_format=media_format,
720
+ force_overwrite=force_overwrite,
721
+ audio_track_id=audio_track_id,
722
+ quality=quality,
723
+ )
724
+ else:
725
+ self.logger.info(f"🎬 Detected video format: {media_format}")
726
+ return await self.download_video(
727
+ url=url,
728
+ output_dir=output_dir,
729
+ video_format=media_format,
730
+ force_overwrite=force_overwrite,
731
+ audio_track_id=audio_track_id,
732
+ quality=quality,
733
+ )
734
+
735
+ async def _download_media_internal(
736
+ self,
737
+ url: str,
738
+ output_dir: str,
739
+ media_format: str,
740
+ is_audio: bool,
741
+ force_overwrite: bool = False,
742
+ audio_track_id: Optional[str] = "original",
743
+ quality: str = "best",
744
+ ) -> str:
745
+ """
746
+ Internal unified method for downloading audio or video from YouTube
747
+
748
+ Args:
749
+ url: YouTube URL
750
+ output_dir: Output directory
751
+ media_format: Media format (audio or video extension)
752
+ is_audio: True for audio download, False for video download
753
+ force_overwrite: Skip user confirmation and overwrite existing files
754
+ audio_track_id: Audio track selection for multi-language videos:
755
+ - "original": Select the original audio track (default)
756
+ - Language code (e.g., "en", "ja"): Select by language
757
+ - Format ID (e.g., "251-drc"): Select specific format
758
+ - None: No filtering, use yt-dlp default
759
+ quality: Media quality selection:
760
+ For audio: "best", "medium", "low", or bitrate like "128"
761
+ For video: "best", "1080", "720", "480", "360"
762
+
763
+ Returns:
764
+ Path to downloaded media file
765
+ """
766
+ target_dir = Path(output_dir).expanduser()
767
+ media_type = "audio" if is_audio else "video"
768
+ emoji = "🎵" if is_audio else "🎬"
769
+
770
+ self.logger.info(f"{emoji} Downloading {media_type} from: {url}")
771
+ self.logger.info(f"📁 Output directory: {target_dir}")
772
+ self.logger.info(f'{"🎶" if is_audio else "🎥"} Media format: {media_format}')
773
+
774
+ # Create output directory if it doesn't exist
775
+ target_dir.mkdir(parents=True, exist_ok=True)
776
+
777
+ # Extract video ID and check for existing files
778
+ video_id = self.extract_video_id(url)
779
+ existing_files = FileExistenceManager.check_existing_files(video_id, str(target_dir), [media_format])
780
+
781
+ # Handle existing files
782
+ if existing_files["media"] and not force_overwrite:
783
+ if FileExistenceManager.is_interactive_mode():
784
+ user_choice = FileExistenceManager.prompt_user_confirmation(
785
+ {"media": existing_files["media"]}, "media download"
786
+ )
787
+
788
+ if user_choice == "cancel":
789
+ raise RuntimeError("Media download cancelled by user")
790
+ elif user_choice == "overwrite":
791
+ # Continue with download
792
+ pass
793
+ elif user_choice in existing_files["media"]:
794
+ # User selected a specific file
795
+ return user_choice
796
+ else:
797
+ # Fallback: use first file
798
+ self.logger.info(f'✅ Using existing media file: {existing_files["media"][0]}')
799
+ return existing_files["media"][0]
800
+ else:
801
+ # Non-interactive mode: use existing file
802
+ self.logger.info(f'✅ Using existing media file: {existing_files["media"][0]}')
803
+ return existing_files["media"][0]
804
+
805
+ # Generate output filename template
806
+ output_template = str(target_dir / f"{video_id}.%(ext)s")
807
+
808
+ # Build format selector with audio track and quality filtering
809
+ audio_format_selector = self._build_audio_format_selector(audio_track_id, quality)
810
+
811
+ # Build yt-dlp options based on media type
812
+ if is_audio:
813
+ opts = {
814
+ "format": audio_format_selector,
815
+ "postprocessors": [
816
+ {
817
+ "key": "FFmpegExtractAudio",
818
+ "preferredcodec": media_format,
819
+ "preferredquality": "0", # Best quality for conversion
820
+ }
821
+ ],
822
+ "outtmpl": output_template,
823
+ "noplaylist": True,
824
+ "quiet": False,
825
+ "no_warnings": True,
826
+ }
827
+ else:
828
+ # For video, combine video with selected audio track
829
+ video_format_selector = self._build_video_format_selector(audio_format_selector, quality)
830
+ opts = {
831
+ "format": video_format_selector,
832
+ "merge_output_format": media_format,
833
+ "outtmpl": output_template,
834
+ "noplaylist": True,
835
+ "quiet": False,
836
+ "no_warnings": True,
837
+ }
838
+
839
+ try:
840
+ # Run in thread pool to avoid blocking
841
+ loop = asyncio.get_event_loop()
842
+
843
+ def _download():
844
+ with yt_dlp.YoutubeDL(opts) as ydl:
845
+ ydl.download([url])
846
+
847
+ await loop.run_in_executor(None, _download)
848
+
849
+ self.logger.info(f"✅ {media_type.capitalize()} download completed")
850
+
851
+ # Check for expected file format
852
+ expected_file = target_dir / f"{video_id}.{media_format}"
853
+ if expected_file.exists():
854
+ self.logger.info(f"{emoji} Downloaded {media_type}: {expected_file}")
855
+ return str(expected_file)
856
+
857
+ # Fallback: search for media files with this video_id
858
+ if is_audio:
859
+ fallback_extensions = [media_format, "mp3", "wav", "m4a", "aac"]
860
+ else:
861
+ fallback_extensions = [media_format, "mp4", "webm", "mkv"]
862
+
863
+ for ext in fallback_extensions:
864
+ files = list(target_dir.glob(f"{video_id}*.{ext}"))
865
+ if files:
866
+ latest_file = max(files, key=os.path.getctime)
867
+ self.logger.info(f"{emoji} Found {media_type} file: {latest_file}")
868
+ return str(latest_file)
869
+
870
+ raise RuntimeError(f"Downloaded {media_type} file not found")
871
+
872
+ except yt_dlp.utils.DownloadError as e:
873
+ self.logger.error(f"Failed to download {media_type}: {str(e)}")
874
+ raise RuntimeError(f"Failed to download {media_type}: {str(e)}")
875
+ except Exception as e:
876
+ self.logger.error(f"Failed to download {media_type}: {str(e)}")
877
+ raise RuntimeError(f"Failed to download {media_type}: {str(e)}")
878
+
879
+ async def download_audio(
880
+ self,
881
+ url: str,
882
+ output_dir: Optional[str] = None,
883
+ media_format: Optional[str] = None,
884
+ force_overwrite: bool = False,
885
+ audio_track_id: Optional[str] = "original",
886
+ quality: str = "best",
887
+ ) -> str:
888
+ """
889
+ Download audio from YouTube URL
890
+
891
+ Args:
892
+ url: YouTube URL
893
+ output_dir: Output directory (default: temp directory)
894
+ media_format: Audio format (default: mp3)
895
+ force_overwrite: Skip user confirmation and overwrite existing files
896
+ audio_track_id: Audio track selection for multi-language videos
897
+ quality: Audio quality ("best", "medium", "low", or bitrate like "128")
898
+
899
+ Returns:
900
+ Path to downloaded audio file
901
+ """
902
+ target_dir = output_dir or tempfile.gettempdir()
903
+ media_format = media_format or "mp3"
904
+ return await self._download_media_internal(
905
+ url,
906
+ target_dir,
907
+ media_format,
908
+ is_audio=True,
909
+ force_overwrite=force_overwrite,
910
+ audio_track_id=audio_track_id,
911
+ quality=quality,
912
+ )
913
+
914
+ async def download_video(
915
+ self,
916
+ url: str,
917
+ output_dir: Optional[str] = None,
918
+ video_format: str = "mp4",
919
+ force_overwrite: bool = False,
920
+ audio_track_id: Optional[str] = "original",
921
+ quality: str = "best",
922
+ ) -> str:
923
+ """
924
+ Download video from YouTube URL
925
+
926
+ Args:
927
+ url: YouTube URL
928
+ output_dir: Output directory (default: temp directory)
929
+ video_format: Video format
930
+ force_overwrite: Skip user confirmation and overwrite existing files
931
+ audio_track_id: Audio track selection for multi-language videos
932
+ quality: Video quality ("best", "1080", "720", "480", "360")
933
+
934
+ Returns:
935
+ Path to downloaded video file
936
+ """
937
+ target_dir = output_dir or tempfile.gettempdir()
938
+ return await self._download_media_internal(
939
+ url,
940
+ target_dir,
941
+ video_format,
942
+ is_audio=False,
943
+ force_overwrite=force_overwrite,
944
+ audio_track_id=audio_track_id,
945
+ quality=quality,
946
+ )
947
+
948
+ async def download_captions(
949
+ self,
950
+ url: str,
951
+ output_dir: str,
952
+ force_overwrite: bool = False,
953
+ source_lang: Optional[str] = None,
954
+ transcriber_name: Optional[str] = None,
955
+ ) -> Optional[str]:
956
+ """
957
+ Download video captions using yt-dlp
958
+
959
+ Args:
960
+ url: YouTube URL
961
+ output_dir: Output directory
962
+ force_overwrite: Skip user confirmation and overwrite existing files
963
+ source_lang: Specific caption language/track to download (e.g., 'en')
964
+ If None, downloads all available captions
965
+ transcriber_name: Name of the transcriber (for user prompts)
966
+ Returns:
967
+ Path to downloaded transcript file or None if not available
968
+ """
969
+ target_dir = Path(output_dir).expanduser()
970
+
971
+ # Create output directory if it doesn't exist
972
+ target_dir.mkdir(parents=True, exist_ok=True)
973
+
974
+ # Extract video ID and check for existing caption files
975
+ video_id = self.extract_video_id(url)
976
+ if not force_overwrite:
977
+ existing_files = FileExistenceManager.check_existing_files(
978
+ video_id, str(target_dir), caption_formats=CAPTION_FORMATS
979
+ )
980
+
981
+ # Handle existing caption files
982
+ if existing_files["caption"] and not force_overwrite:
983
+ if FileExistenceManager.is_interactive_mode():
984
+ user_choice = FileExistenceManager.prompt_user_confirmation(
985
+ {"caption": existing_files["caption"]}, "caption download", transcriber_name=transcriber_name
986
+ )
987
+
988
+ if user_choice == "cancel":
989
+ raise RuntimeError("Caption download cancelled by user")
990
+ elif user_choice == "overwrite":
991
+ # Continue with download
992
+ pass
993
+ elif user_choice == TRANSCRIBE_CHOICE:
994
+ return TRANSCRIBE_CHOICE
995
+ elif user_choice in existing_files["caption"]:
996
+ # User selected a specific file
997
+ caption_file = Path(user_choice)
998
+ self.logger.info(f"✅ Using selected caption file: {caption_file}")
999
+ return str(caption_file)
1000
+ else:
1001
+ # Fallback: use first file
1002
+ caption_file = Path(existing_files["caption"][0])
1003
+ self.logger.info(f"✅ Using existing caption file: {caption_file}")
1004
+ return str(caption_file)
1005
+ else:
1006
+ caption_file = Path(existing_files["caption"][0])
1007
+ self.logger.info(f"🔍 Found existing caption: {caption_file}")
1008
+ return str(caption_file)
1009
+
1010
+ self.logger.info(f"📥 Downloading caption for: {url}")
1011
+ if source_lang:
1012
+ self.logger.info(f"🎯 Targeting specific caption track: {source_lang}")
1013
+
1014
+ output_template = str(target_dir / f"{video_id}.%(ext)s")
1015
+
1016
+ # Configure yt-dlp options for caption download
1017
+ opts = {
1018
+ "skip_download": True, # Don't download video/audio
1019
+ "writesubtitles": True,
1020
+ "writeautomaticsub": True,
1021
+ "subtitlesformat": "best",
1022
+ "outtmpl": output_template,
1023
+ "quiet": False,
1024
+ "no_warnings": True,
1025
+ }
1026
+
1027
+ # Add caption language selection if specified
1028
+ if source_lang:
1029
+ opts["subtitleslangs"] = [f"{source_lang}*"]
1030
+
1031
+ try:
1032
+ # Run in thread pool to avoid blocking
1033
+ loop = asyncio.get_event_loop()
1034
+
1035
+ def _download_subs():
1036
+ with yt_dlp.YoutubeDL(opts) as ydl:
1037
+ ydl.download([url])
1038
+
1039
+ await loop.run_in_executor(None, _download_subs)
1040
+
1041
+ except yt_dlp.utils.DownloadError as e:
1042
+ error_msg = str(e)
1043
+
1044
+ # Check for specific error conditions
1045
+ if "No automatic or manual captions found" in error_msg:
1046
+ self.logger.warning("No captions available for this video")
1047
+ elif "HTTP Error 429" in error_msg or "Too Many Requests" in error_msg:
1048
+ self.logger.error("YouTube rate limit exceeded. Please try again later or use a different method.")
1049
+ self.logger.error(
1050
+ "YouTube rate limit exceeded (HTTP 429). "
1051
+ "Try again later or use --cookies option with authenticated cookies. "
1052
+ "See: https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp"
1053
+ )
1054
+ else:
1055
+ self.logger.error(f"Failed to download transcript: {error_msg}")
1056
+ except Exception as e:
1057
+ self.logger.error(f"Failed to download transcript: {str(e)}")
1058
+
1059
+ # Find the downloaded transcript file
1060
+ caption_patterns = [
1061
+ f"{video_id}.*vtt",
1062
+ f"{video_id}.*srt",
1063
+ f"{video_id}.*sub",
1064
+ f"{video_id}.*sbv",
1065
+ f"{video_id}.*ssa",
1066
+ f"{video_id}.*ass",
1067
+ ]
1068
+
1069
+ caption_files = []
1070
+ for pattern in caption_patterns:
1071
+ _caption_files = list(target_dir.glob(pattern))
1072
+ for caption_file in _caption_files:
1073
+ self.logger.info(f"📥 Downloaded caption: {caption_file}")
1074
+ caption_files.extend(_caption_files)
1075
+
1076
+ # If only one caption file, return it directly
1077
+ if len(caption_files) == 1:
1078
+ self.logger.info(f"✅ Using caption: {caption_files[0]}")
1079
+ return str(caption_files[0])
1080
+
1081
+ # Multiple caption files found, let user choose
1082
+ if FileExistenceManager.is_interactive_mode():
1083
+ self.logger.info(f"📋 Found {len(caption_files)} caption files")
1084
+ caption_choice = FileExistenceManager.prompt_file_selection(
1085
+ file_type="caption",
1086
+ files=[str(f) for f in caption_files],
1087
+ operation="use",
1088
+ transcriber_name=transcriber_name,
1089
+ )
1090
+
1091
+ if caption_choice == "cancel":
1092
+ raise RuntimeError("Caption selection cancelled by user")
1093
+ elif caption_choice == TRANSCRIBE_CHOICE:
1094
+ return caption_choice
1095
+ elif caption_choice:
1096
+ self.logger.info(f"✅ Selected caption: {caption_choice}")
1097
+ return caption_choice
1098
+ elif caption_files:
1099
+ # Fallback to first file
1100
+ self.logger.info(f"✅ Using first caption: {caption_files[0]}")
1101
+ return str(caption_files[0])
1102
+ else:
1103
+ self.logger.warning("No caption files available after download")
1104
+ return None
1105
+ elif caption_files:
1106
+ # Non-interactive mode: use first file
1107
+ self.logger.info(f"✅ Using first caption: {caption_files[0]}")
1108
+ return str(caption_files[0])
1109
+ else:
1110
+ self.logger.warning("No caption files available after download")
1111
+ return None
1112
+
1113
+ async def list_available_captions(self, url: str) -> List[Dict[str, Any]]:
1114
+ """
1115
+ List all available caption tracks for a YouTube video
1116
+
1117
+ Args:
1118
+ url: YouTube URL
1119
+
1120
+ Returns:
1121
+ List of caption track information dictionaries
1122
+ """
1123
+ self.logger.info(f"📋 Listing available captions for: {url}")
1124
+
1125
+ opts = {
1126
+ "skip_download": True,
1127
+ "listsubtitles": True,
1128
+ "quiet": True,
1129
+ "no_warnings": True,
1130
+ }
1131
+
1132
+ try:
1133
+ # Run in thread pool to avoid blocking
1134
+ loop = asyncio.get_event_loop()
1135
+
1136
+ def _get_info():
1137
+ with yt_dlp.YoutubeDL(opts) as ydl:
1138
+ return ydl.extract_info(url, download=False)
1139
+
1140
+ info = await loop.run_in_executor(None, _get_info)
1141
+
1142
+ caption_info = []
1143
+
1144
+ # Parse manual captions
1145
+ subtitles = info.get("subtitles", {})
1146
+ for lang, formats in subtitles.items():
1147
+ if formats:
1148
+ format_names = [f.get("ext", "") for f in formats]
1149
+ lang_name = formats[0].get("name", lang) if formats else lang
1150
+ caption_info.append(
1151
+ {"language": lang, "name": lang_name, "formats": format_names, "kind": "manual"}
1152
+ )
1153
+
1154
+ # Parse automatic captions
1155
+ auto_subs = info.get("automatic_captions", {})
1156
+ for lang, formats in auto_subs.items():
1157
+ if formats:
1158
+ format_names = [f.get("ext", "") for f in formats]
1159
+ lang_name = formats[0].get("name", lang) if formats else lang
1160
+ caption_info.append({"language": lang, "name": lang_name, "formats": format_names, "kind": "asr"})
1161
+
1162
+ self.logger.info(f"✅ Found {len(caption_info)} caption tracks")
1163
+ return caption_info
1164
+
1165
+ except yt_dlp.utils.DownloadError as e:
1166
+ self.logger.error(f"Failed to list captions: {str(e)}")
1167
+ raise RuntimeError(f"Failed to list captions: {str(e)}")
1168
+ except Exception as e:
1169
+ self.logger.error(f"Failed to list captions: {str(e)}")
1170
+ raise RuntimeError(f"Failed to list captions: {str(e)}")