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.
- lattifai/__init__.py +0 -24
- lattifai/alignment/__init__.py +10 -1
- lattifai/alignment/lattice1_aligner.py +66 -58
- lattifai/alignment/lattice1_worker.py +1 -6
- lattifai/alignment/punctuation.py +38 -0
- lattifai/alignment/segmenter.py +1 -1
- lattifai/alignment/sentence_splitter.py +350 -0
- lattifai/alignment/text_align.py +440 -0
- lattifai/alignment/tokenizer.py +91 -220
- lattifai/caption/__init__.py +82 -6
- lattifai/caption/caption.py +335 -1143
- lattifai/caption/formats/__init__.py +199 -0
- lattifai/caption/formats/base.py +211 -0
- lattifai/caption/formats/gemini.py +722 -0
- lattifai/caption/formats/json.py +194 -0
- lattifai/caption/formats/lrc.py +309 -0
- lattifai/caption/formats/nle/__init__.py +9 -0
- lattifai/caption/formats/nle/audition.py +561 -0
- lattifai/caption/formats/nle/avid.py +423 -0
- lattifai/caption/formats/nle/fcpxml.py +549 -0
- lattifai/caption/formats/nle/premiere.py +589 -0
- lattifai/caption/formats/pysubs2.py +642 -0
- lattifai/caption/formats/sbv.py +147 -0
- lattifai/caption/formats/tabular.py +338 -0
- lattifai/caption/formats/textgrid.py +193 -0
- lattifai/caption/formats/ttml.py +652 -0
- lattifai/caption/formats/vtt.py +469 -0
- lattifai/caption/parsers/__init__.py +9 -0
- lattifai/caption/{text_parser.py → parsers/text_parser.py} +4 -2
- lattifai/caption/standardize.py +636 -0
- lattifai/caption/utils.py +474 -0
- lattifai/cli/__init__.py +2 -1
- lattifai/cli/caption.py +108 -1
- lattifai/cli/transcribe.py +4 -9
- lattifai/cli/youtube.py +4 -1
- lattifai/client.py +48 -84
- lattifai/config/__init__.py +11 -1
- lattifai/config/alignment.py +9 -2
- lattifai/config/caption.py +267 -23
- lattifai/config/media.py +20 -0
- lattifai/diarization/__init__.py +41 -1
- lattifai/mixin.py +36 -18
- lattifai/transcription/base.py +6 -1
- lattifai/transcription/lattifai.py +19 -54
- lattifai/utils.py +81 -13
- lattifai/workflow/__init__.py +28 -4
- lattifai/workflow/file_manager.py +2 -5
- lattifai/youtube/__init__.py +43 -0
- lattifai/youtube/client.py +1170 -0
- lattifai/youtube/types.py +23 -0
- lattifai-1.2.2.dist-info/METADATA +615 -0
- lattifai-1.2.2.dist-info/RECORD +76 -0
- {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/entry_points.txt +1 -2
- lattifai/caption/gemini_reader.py +0 -371
- lattifai/caption/gemini_writer.py +0 -173
- lattifai/cli/app_installer.py +0 -142
- lattifai/cli/server.py +0 -44
- lattifai/server/app.py +0 -427
- lattifai/workflow/youtube.py +0 -577
- lattifai-1.2.0.dist-info/METADATA +0 -1133
- lattifai-1.2.0.dist-info/RECORD +0 -57
- {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/WHEEL +0 -0
- {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/licenses/LICENSE +0 -0
- {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)}")
|