lattifai 0.4.5__py3-none-any.whl → 0.4.6__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.
@@ -11,7 +11,7 @@ from pathlib import Path
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
  from ..client import AsyncLattifAI
14
- from ..io import SUBTITLE_FORMATS, GeminiReader, GeminiWriter, SubtitleIO
14
+ from ..io import SUBTITLE_FORMATS, GeminiWriter, SubtitleIO
15
15
  from .base import WorkflowAgent, WorkflowStep, setup_workflow_logger
16
16
  from .file_manager import FileExistenceManager
17
17
  from .gemini import GeminiTranscriber
@@ -31,7 +31,7 @@ class YouTubeDownloader:
31
31
  """
32
32
 
33
33
  def __init__(self):
34
- self.logger = setup_workflow_logger('youtube')
34
+ self.logger = setup_workflow_logger("youtube")
35
35
  # Check if yt-dlp is available
36
36
  self._check_ytdlp()
37
37
 
@@ -50,32 +50,32 @@ class YouTubeDownloader:
50
50
  Video ID (e.g., 'cprOj8PWepY')
51
51
  """
52
52
  patterns = [
53
- r'(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/)([a-zA-Z0-9_-]{11})',
54
- r'youtube\.com/embed/([a-zA-Z0-9_-]{11})',
55
- r'youtube\.com/v/([a-zA-Z0-9_-]{11})',
53
+ r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/)([a-zA-Z0-9_-]{11})",
54
+ r"youtube\.com/embed/([a-zA-Z0-9_-]{11})",
55
+ r"youtube\.com/v/([a-zA-Z0-9_-]{11})",
56
56
  ]
57
57
 
58
58
  for pattern in patterns:
59
59
  match = re.search(pattern, url)
60
60
  if match:
61
61
  return match.group(1)
62
- return 'youtube_media'
62
+ return "youtube_media"
63
63
 
64
64
  def _check_ytdlp(self):
65
65
  """Check if yt-dlp is installed"""
66
66
  try:
67
- result = subprocess.run(['yt-dlp', '--version'], capture_output=True, text=True, check=True)
68
- self.logger.info(f'yt-dlp version: {result.stdout.strip()}')
67
+ result = subprocess.run(["yt-dlp", "--version"], capture_output=True, text=True, check=True)
68
+ self.logger.info(f"yt-dlp version: {result.stdout.strip()}")
69
69
  except (subprocess.CalledProcessError, FileNotFoundError):
70
70
  raise RuntimeError(
71
- 'yt-dlp is not installed or not found in PATH. Please install it with: pip install yt-dlp'
71
+ "yt-dlp is not installed or not found in PATH. Please install it with: pip install yt-dlp"
72
72
  )
73
73
 
74
74
  async def get_video_info(self, url: str) -> Dict[str, Any]:
75
75
  """Get video metadata without downloading"""
76
- self.logger.info(f'🔍 Extracting video info for: {url}')
76
+ self.logger.info(f"🔍 Extracting video info for: {url}")
77
77
 
78
- cmd = ['yt-dlp', '--dump-json', '--no-download', url]
78
+ cmd = ["yt-dlp", "--dump-json", "--no-download", url]
79
79
 
80
80
  try:
81
81
  # Run in thread pool to avoid blocking
@@ -90,25 +90,25 @@ class YouTubeDownloader:
90
90
 
91
91
  # Extract relevant info
92
92
  info = {
93
- 'title': metadata.get('title', 'Unknown'),
94
- 'duration': metadata.get('duration', 0),
95
- 'uploader': metadata.get('uploader', 'Unknown'),
96
- 'upload_date': metadata.get('upload_date', 'Unknown'),
97
- 'view_count': metadata.get('view_count', 0),
98
- 'description': metadata.get('description', ''),
99
- 'thumbnail': metadata.get('thumbnail', ''),
100
- 'webpage_url': metadata.get('webpage_url', url),
93
+ "title": metadata.get("title", "Unknown"),
94
+ "duration": metadata.get("duration", 0),
95
+ "uploader": metadata.get("uploader", "Unknown"),
96
+ "upload_date": metadata.get("upload_date", "Unknown"),
97
+ "view_count": metadata.get("view_count", 0),
98
+ "description": metadata.get("description", ""),
99
+ "thumbnail": metadata.get("thumbnail", ""),
100
+ "webpage_url": metadata.get("webpage_url", url),
101
101
  }
102
102
 
103
103
  self.logger.info(f'✅ Video info extracted: {info["title"]}')
104
104
  return info
105
105
 
106
106
  except subprocess.CalledProcessError as e:
107
- self.logger.error(f'Failed to extract video info: {e.stderr}')
108
- raise RuntimeError(f'Failed to extract video info: {e.stderr}')
107
+ self.logger.error(f"Failed to extract video info: {e.stderr}")
108
+ raise RuntimeError(f"Failed to extract video info: {e.stderr}")
109
109
  except json.JSONDecodeError as e:
110
- self.logger.error(f'Failed to parse video metadata: {e}')
111
- raise RuntimeError(f'Failed to parse video metadata: {e}')
110
+ self.logger.error(f"Failed to parse video metadata: {e}")
111
+ raise RuntimeError(f"Failed to parse video metadata: {e}")
112
112
 
113
113
  async def download_media(
114
114
  self,
@@ -136,16 +136,16 @@ class YouTubeDownloader:
136
136
  media_format = media_format or self.media_format
137
137
 
138
138
  # Determine if format is audio or video
139
- audio_formats = ['mp3', 'wav', 'm4a', 'aac', 'opus', 'ogg', 'flac', 'aiff']
139
+ audio_formats = ["mp3", "wav", "m4a", "aac", "opus", "ogg", "flac", "aiff"]
140
140
  is_audio = media_format.lower() in audio_formats
141
141
 
142
142
  if is_audio:
143
- self.logger.info(f'🎵 Detected audio format: {media_format}')
143
+ self.logger.info(f"🎵 Detected audio format: {media_format}")
144
144
  return await self.download_audio(
145
145
  url=url, output_dir=output_dir, media_format=media_format, force_overwrite=force_overwrite
146
146
  )
147
147
  else:
148
- self.logger.info(f'🎬 Detected video format: {media_format}')
148
+ self.logger.info(f"🎬 Detected video format: {media_format}")
149
149
  return await self.download_video(
150
150
  url=url, output_dir=output_dir, video_format=media_format, force_overwrite=force_overwrite
151
151
  )
@@ -172,11 +172,11 @@ class YouTubeDownloader:
172
172
  Path to downloaded media file
173
173
  """
174
174
  target_dir = Path(output_dir).expanduser()
175
- media_type = 'audio' if is_audio else 'video'
176
- emoji = '🎵' if is_audio else '🎬'
175
+ media_type = "audio" if is_audio else "video"
176
+ emoji = "🎵" if is_audio else "🎬"
177
177
 
178
- self.logger.info(f'{emoji} Downloading {media_type} from: {url}')
179
- self.logger.info(f'📁 Output directory: {target_dir}')
178
+ self.logger.info(f"{emoji} Downloading {media_type} from: {url}")
179
+ self.logger.info(f"📁 Output directory: {target_dir}")
180
180
  self.logger.info(f'{"🎶" if is_audio else "🎥"} Media format: {media_format}')
181
181
 
182
182
  # Create output directory if it doesn't exist
@@ -187,57 +187,57 @@ class YouTubeDownloader:
187
187
  existing_files = FileExistenceManager.check_existing_files(video_id, str(target_dir), [media_format])
188
188
 
189
189
  # Handle existing files
190
- if existing_files['media'] and not force_overwrite:
190
+ if existing_files["media"] and not force_overwrite:
191
191
  if FileExistenceManager.is_interactive_mode():
192
192
  user_choice = FileExistenceManager.prompt_user_confirmation(
193
- {'media': existing_files['media']}, 'media download'
193
+ {"media": existing_files["media"]}, "media download"
194
194
  )
195
195
 
196
- if user_choice == 'cancel':
197
- raise RuntimeError('Media download cancelled by user')
198
- elif user_choice == 'overwrite':
196
+ if user_choice == "cancel":
197
+ raise RuntimeError("Media download cancelled by user")
198
+ elif user_choice == "overwrite":
199
199
  # Continue with download
200
200
  pass
201
- elif user_choice in existing_files['media']:
201
+ elif user_choice in existing_files["media"]:
202
202
  # User selected a specific file
203
- self.logger.info(f'✅ Using selected media file: {user_choice}')
203
+ self.logger.info(f"✅ Using selected media file: {user_choice}")
204
204
  return user_choice
205
205
  else:
206
206
  # Fallback: use first file
207
207
  self.logger.info(f'✅ Using existing media file: {existing_files["media"][0]}')
208
- return existing_files['media'][0]
208
+ return existing_files["media"][0]
209
209
  else:
210
210
  # Non-interactive mode: use existing file
211
211
  self.logger.info(f'✅ Using existing media file: {existing_files["media"][0]}')
212
- return existing_files['media'][0]
212
+ return existing_files["media"][0]
213
213
 
214
214
  # Generate output filename template
215
- output_template = str(target_dir / f'{video_id}.%(ext)s')
215
+ output_template = str(target_dir / f"{video_id}.%(ext)s")
216
216
 
217
217
  # Build yt-dlp command based on media type
218
218
  if is_audio:
219
219
  cmd = [
220
- 'yt-dlp',
221
- '--extract-audio',
222
- '--audio-format',
220
+ "yt-dlp",
221
+ "--extract-audio",
222
+ "--audio-format",
223
223
  media_format,
224
- '--audio-quality',
225
- '0', # Best quality
226
- '--output',
224
+ "--audio-quality",
225
+ "0", # Best quality
226
+ "--output",
227
227
  output_template,
228
- '--no-playlist',
228
+ "--no-playlist",
229
229
  url,
230
230
  ]
231
231
  else:
232
232
  cmd = [
233
- 'yt-dlp',
234
- '--format',
235
- 'bestvideo*+bestaudio/best',
236
- '--merge-output-format',
233
+ "yt-dlp",
234
+ "--format",
235
+ "bestvideo*+bestaudio/best",
236
+ "--merge-output-format",
237
237
  media_format,
238
- '--output',
238
+ "--output",
239
239
  output_template,
240
- '--no-playlist',
240
+ "--no-playlist",
241
241
  url,
242
242
  ]
243
243
 
@@ -248,45 +248,45 @@ class YouTubeDownloader:
248
248
  None, lambda: subprocess.run(cmd, capture_output=True, text=True, check=True)
249
249
  )
250
250
 
251
- self.logger.info(f'✅ {media_type.capitalize()} download completed')
251
+ self.logger.info(f"✅ {media_type.capitalize()} download completed")
252
252
 
253
253
  # Find the downloaded file
254
254
  # Try to parse from yt-dlp output first
255
255
  if is_audio:
256
- output_lines = result.stderr.strip().split('\n')
256
+ output_lines = result.stderr.strip().split("\n")
257
257
  for line in reversed(output_lines):
258
- if 'Destination:' in line or 'has already been downloaded' in line:
258
+ if "Destination:" in line or "has already been downloaded" in line:
259
259
  parts = line.split()
260
- filename = ' '.join(parts[1:]) if 'Destination:' in line else parts[0]
260
+ filename = " ".join(parts[1:]) if "Destination:" in line else parts[0]
261
261
  file_path = target_dir / filename
262
262
  if file_path.exists():
263
- self.logger.info(f'{emoji} Downloaded {media_type} file: {file_path}')
263
+ self.logger.info(f"{emoji} Downloaded {media_type} file: {file_path}")
264
264
  return str(file_path)
265
265
 
266
266
  # Check for expected file format
267
- expected_file = target_dir / f'{video_id}.{media_format}'
267
+ expected_file = target_dir / f"{video_id}.{media_format}"
268
268
  if expected_file.exists():
269
- self.logger.info(f'{emoji} Downloaded {media_type}: {expected_file}')
269
+ self.logger.info(f"{emoji} Downloaded {media_type}: {expected_file}")
270
270
  return str(expected_file)
271
271
 
272
272
  # Fallback: search for media files with this video_id
273
273
  if is_audio:
274
- fallback_extensions = [media_format, 'mp3', 'wav', 'm4a', 'aac']
274
+ fallback_extensions = [media_format, "mp3", "wav", "m4a", "aac"]
275
275
  else:
276
- fallback_extensions = [media_format, 'mp4', 'webm', 'mkv']
276
+ fallback_extensions = [media_format, "mp4", "webm", "mkv"]
277
277
 
278
278
  for ext in fallback_extensions:
279
- files = list(target_dir.glob(f'{video_id}*.{ext}'))
279
+ files = list(target_dir.glob(f"{video_id}*.{ext}"))
280
280
  if files:
281
281
  latest_file = max(files, key=os.path.getctime)
282
- self.logger.info(f'{emoji} Found {media_type} file: {latest_file}')
282
+ self.logger.info(f"{emoji} Found {media_type} file: {latest_file}")
283
283
  return str(latest_file)
284
284
 
285
- raise RuntimeError(f'Downloaded {media_type} file not found')
285
+ raise RuntimeError(f"Downloaded {media_type} file not found")
286
286
 
287
287
  except subprocess.CalledProcessError as e:
288
- self.logger.error(f'Failed to download {media_type}: {e.stderr}')
289
- raise RuntimeError(f'Failed to download {media_type}: {e.stderr}')
288
+ self.logger.error(f"Failed to download {media_type}: {e.stderr}")
289
+ raise RuntimeError(f"Failed to download {media_type}: {e.stderr}")
290
290
 
291
291
  async def download_audio(
292
292
  self,
@@ -314,7 +314,7 @@ class YouTubeDownloader:
314
314
  )
315
315
 
316
316
  async def download_video(
317
- self, url: str, output_dir: Optional[str] = None, video_format: str = 'mp4', force_overwrite: bool = False
317
+ self, url: str, output_dir: Optional[str] = None, video_format: str = "mp4", force_overwrite: bool = False
318
318
  ) -> str:
319
319
  """
320
320
  Download video from YouTube URL
@@ -368,54 +368,54 @@ class YouTubeDownloader:
368
368
  )
369
369
 
370
370
  # Handle existing subtitle files
371
- if existing_files['subtitle'] and not force_overwrite:
371
+ if existing_files["subtitle"] and not force_overwrite:
372
372
  if FileExistenceManager.is_interactive_mode():
373
373
  user_choice = FileExistenceManager.prompt_user_confirmation(
374
- {'subtitle': existing_files['subtitle']}, 'subtitle download'
374
+ {"subtitle": existing_files["subtitle"]}, "subtitle download"
375
375
  )
376
376
 
377
- if user_choice == 'cancel':
378
- raise RuntimeError('Subtitle download cancelled by user')
379
- elif user_choice == 'overwrite':
377
+ if user_choice == "cancel":
378
+ raise RuntimeError("Subtitle download cancelled by user")
379
+ elif user_choice == "overwrite":
380
380
  # Continue with download
381
381
  pass
382
- elif user_choice in existing_files['subtitle']:
382
+ elif user_choice in existing_files["subtitle"]:
383
383
  # User selected a specific file
384
384
  subtitle_file = Path(user_choice)
385
- self.logger.info(f'✅ Using selected subtitle file: {subtitle_file}')
385
+ self.logger.info(f"✅ Using selected subtitle file: {subtitle_file}")
386
386
  return str(subtitle_file)
387
387
  else:
388
388
  # Fallback: use first file
389
- subtitle_file = Path(existing_files['subtitle'][0])
390
- self.logger.info(f'✅ Using existing subtitle file: {subtitle_file}')
389
+ subtitle_file = Path(existing_files["subtitle"][0])
390
+ self.logger.info(f"✅ Using existing subtitle file: {subtitle_file}")
391
391
  return str(subtitle_file)
392
392
  else:
393
- subtitle_file = Path(existing_files['subtitle'][0])
394
- self.logger.info(f'🔍 Found existing subtitle: {subtitle_file}')
393
+ subtitle_file = Path(existing_files["subtitle"][0])
394
+ self.logger.info(f"🔍 Found existing subtitle: {subtitle_file}")
395
395
  return str(subtitle_file)
396
396
 
397
- self.logger.info(f'📥 Downloading subtitle for: {url}')
397
+ self.logger.info(f"📥 Downloading subtitle for: {url}")
398
398
  if subtitle_lang:
399
- self.logger.info(f'🎯 Targeting specific subtitle track: {subtitle_lang}')
399
+ self.logger.info(f"🎯 Targeting specific subtitle track: {subtitle_lang}")
400
400
 
401
- output_template = str(target_dir / f'{video_id}.%(ext)s')
401
+ output_template = str(target_dir / f"{video_id}.%(ext)s")
402
402
 
403
403
  # Configure yt-dlp options for subtitle download
404
404
  ytdlp_options = [
405
- 'yt-dlp',
406
- '--skip-download', # Don't download video/audio
407
- '--output',
405
+ "yt-dlp",
406
+ "--skip-download", # Don't download video/audio
407
+ "--output",
408
408
  output_template,
409
- '--sub-format',
410
- 'best', # Prefer best available format
409
+ "--sub-format",
410
+ "best", # Prefer best available format
411
411
  ]
412
412
 
413
413
  # Add subtitle language selection if specified
414
414
  if subtitle_lang:
415
- ytdlp_options.extend(['--write-sub', '--write-auto-sub', '--sub-langs', f'{subtitle_lang}.*'])
415
+ ytdlp_options.extend(["--write-sub", "--write-auto-sub", "--sub-langs", f"{subtitle_lang}.*"])
416
416
  else:
417
417
  # Download only manual subtitles (not auto-generated) in English to avoid rate limiting
418
- ytdlp_options.extend(['--write-sub', '--write-auto-sub'])
418
+ ytdlp_options.extend(["--write-sub", "--write-auto-sub"])
419
419
 
420
420
  ytdlp_options.append(url)
421
421
 
@@ -426,71 +426,71 @@ class YouTubeDownloader:
426
426
  None, lambda: subprocess.run(ytdlp_options, capture_output=True, text=True, check=True)
427
427
  )
428
428
 
429
- self.logger.info(f'yt-dlp transcript output: {result.stdout.strip()}')
429
+ self.logger.info(f"yt-dlp transcript output: {result.stdout.strip()}")
430
430
 
431
431
  # Find the downloaded transcript file
432
432
  subtitle_patterns = [
433
- f'{video_id}.*vtt',
434
- f'{video_id}.*srt',
435
- f'{video_id}.*sub',
436
- f'{video_id}.*sbv',
437
- f'{video_id}.*ssa',
438
- f'{video_id}.*ass',
433
+ f"{video_id}.*vtt",
434
+ f"{video_id}.*srt",
435
+ f"{video_id}.*sub",
436
+ f"{video_id}.*sbv",
437
+ f"{video_id}.*ssa",
438
+ f"{video_id}.*ass",
439
439
  ]
440
440
 
441
441
  subtitle_files = []
442
442
  for pattern in subtitle_patterns:
443
443
  _subtitle_files = list(target_dir.glob(pattern))
444
444
  for subtitle_file in _subtitle_files:
445
- self.logger.info(f'📥 Downloaded subtitle: {subtitle_file}')
445
+ self.logger.info(f"📥 Downloaded subtitle: {subtitle_file}")
446
446
  subtitle_files.extend(_subtitle_files)
447
447
 
448
448
  if not subtitle_files:
449
- self.logger.warning('No subtitle available for this video')
449
+ self.logger.warning("No subtitle available for this video")
450
450
  return None
451
451
 
452
452
  # If only one subtitle file, return it directly
453
453
  if len(subtitle_files) == 1:
454
- self.logger.info(f'✅ Using subtitle: {subtitle_files[0]}')
454
+ self.logger.info(f"✅ Using subtitle: {subtitle_files[0]}")
455
455
  return str(subtitle_files[0])
456
456
 
457
457
  # Multiple subtitle files found, let user choose
458
458
  if FileExistenceManager.is_interactive_mode():
459
- self.logger.info(f'📋 Found {len(subtitle_files)} subtitle files')
459
+ self.logger.info(f"📋 Found {len(subtitle_files)} subtitle files")
460
460
  # Use the enable_gemini_option parameter passed by caller
461
461
  subtitle_choice = FileExistenceManager.prompt_file_selection(
462
- file_type='subtitle',
462
+ file_type="subtitle",
463
463
  files=[str(f) for f in subtitle_files],
464
- operation='use',
464
+ operation="use",
465
465
  enable_gemini=enable_gemini_option,
466
466
  )
467
467
 
468
- if subtitle_choice == 'cancel':
469
- raise RuntimeError('Subtitle selection cancelled by user')
470
- elif subtitle_choice == 'gemini':
468
+ if subtitle_choice == "cancel":
469
+ raise RuntimeError("Subtitle selection cancelled by user")
470
+ elif subtitle_choice == "gemini":
471
471
  # User chose to transcribe with Gemini instead of using downloaded subtitles
472
- self.logger.info('✨ User selected Gemini transcription')
473
- return 'gemini' # Return special value to indicate Gemini transcription
472
+ self.logger.info("✨ User selected Gemini transcription")
473
+ return "gemini" # Return special value to indicate Gemini transcription
474
474
  elif subtitle_choice:
475
- self.logger.info(f'✅ Selected subtitle: {subtitle_choice}')
475
+ self.logger.info(f"✅ Selected subtitle: {subtitle_choice}")
476
476
  return subtitle_choice
477
477
  else:
478
478
  # Fallback to first file
479
- self.logger.info(f'✅ Using first subtitle: {subtitle_files[0]}')
479
+ self.logger.info(f"✅ Using first subtitle: {subtitle_files[0]}")
480
480
  return str(subtitle_files[0])
481
481
  else:
482
482
  # Non-interactive mode: use first file
483
- self.logger.info(f'✅ Using first subtitle: {subtitle_files[0]}')
483
+ self.logger.info(f"✅ Using first subtitle: {subtitle_files[0]}")
484
484
  return str(subtitle_files[0])
485
485
 
486
486
  except subprocess.CalledProcessError as e:
487
487
  error_msg = e.stderr.strip() if e.stderr else str(e)
488
- if 'No automatic or manual subtitles found' in error_msg:
489
- self.logger.warning('No subtitles available for this video')
488
+ if "No automatic or manual subtitles found" in error_msg:
489
+ self.logger.warning("No subtitles available for this video")
490
490
  return None
491
491
  else:
492
- self.logger.error(f'Failed to download transcript: {error_msg}')
493
- raise RuntimeError(f'Failed to download transcript: {error_msg}')
492
+ self.logger.error(f"Failed to download transcript: {error_msg}")
493
+ raise RuntimeError(f"Failed to download transcript: {error_msg}")
494
494
 
495
495
  async def list_available_subtitles(self, url: str) -> List[Dict[str, Any]]:
496
496
  """
@@ -502,9 +502,9 @@ class YouTubeDownloader:
502
502
  Returns:
503
503
  List of subtitle track information dictionaries
504
504
  """
505
- self.logger.info(f'📋 Listing available subtitles for: {url}')
505
+ self.logger.info(f"📋 Listing available subtitles for: {url}")
506
506
 
507
- cmd = ['yt-dlp', '--list-subs', '--no-download', url]
507
+ cmd = ["yt-dlp", "--list-subs", "--no-download", url]
508
508
 
509
509
  try:
510
510
  # Run in thread pool to avoid blocking
@@ -515,31 +515,31 @@ class YouTubeDownloader:
515
515
 
516
516
  # Parse the subtitle list output
517
517
  subtitle_info = []
518
- lines = result.stdout.strip().split('\n')
518
+ lines = result.stdout.strip().split("\n")
519
519
 
520
520
  # Look for the subtitle section (not automatic captions)
521
521
  in_subtitle_section = False
522
522
  for line in lines:
523
- if 'Available subtitles for' in line:
523
+ if "Available subtitles for" in line:
524
524
  in_subtitle_section = True
525
525
  continue
526
- elif 'Available automatic captions for' in line:
526
+ elif "Available automatic captions for" in line:
527
527
  in_subtitle_section = False
528
528
  continue
529
529
  elif in_subtitle_section and line.strip():
530
530
  # Skip header lines
531
- if 'Language' in line and 'Name' in line and 'Formats' in line:
531
+ if "Language" in line and "Name" in line and "Formats" in line:
532
532
  continue
533
533
 
534
534
  # Parse subtitle information
535
535
  # Format: "Language Name Formats" where formats are comma-separated
536
536
  # Example: "en-uYU-mmqFLq8 English - CC1 vtt, srt, ttml, srv3, srv2, srv1, json3"
537
537
 
538
- if line.strip() and not line.startswith('['):
538
+ if line.strip() and not line.startswith("["):
539
539
  # Split by multiple spaces to separate language, name, and formats
540
540
  import re
541
541
 
542
- parts = re.split(r'\s{2,}', line.strip())
542
+ parts = re.split(r"\s{2,}", line.strip())
543
543
 
544
544
  if len(parts) >= 2:
545
545
  # First part is language, last part is formats
@@ -547,25 +547,25 @@ class YouTubeDownloader:
547
547
  formats_str = parts[-1]
548
548
 
549
549
  # Split language and name - language is first word
550
- lang_name_parts = language_and_name.split(' ', 1)
550
+ lang_name_parts = language_and_name.split(" ", 1)
551
551
  language = lang_name_parts[0]
552
- name = lang_name_parts[1] if len(lang_name_parts) > 1 else ''
552
+ name = lang_name_parts[1] if len(lang_name_parts) > 1 else ""
553
553
 
554
554
  # If there are more than 2 parts, middle parts are also part of name
555
555
  if len(parts) > 2:
556
- name = ' '.join([name] + parts[1:-1]).strip()
556
+ name = " ".join([name] + parts[1:-1]).strip()
557
557
 
558
558
  # Parse formats - they are comma-separated
559
- formats = [f.strip() for f in formats_str.split(',') if f.strip()]
559
+ formats = [f.strip() for f in formats_str.split(",") if f.strip()]
560
560
 
561
- subtitle_info.append({'language': language, 'name': name, 'formats': formats})
561
+ subtitle_info.append({"language": language, "name": name, "formats": formats})
562
562
 
563
- self.logger.info(f'✅ Found {len(subtitle_info)} subtitle tracks')
563
+ self.logger.info(f"✅ Found {len(subtitle_info)} subtitle tracks")
564
564
  return subtitle_info
565
565
 
566
566
  except subprocess.CalledProcessError as e:
567
- self.logger.error(f'Failed to list subtitles: {e.stderr}')
568
- raise RuntimeError(f'Failed to list subtitles: {e.stderr}')
567
+ self.logger.error(f"Failed to list subtitles: {e.stderr}")
568
+ raise RuntimeError(f"Failed to list subtitles: {e.stderr}")
569
569
 
570
570
 
571
571
  class YouTubeSubtitleAgent(WorkflowAgent):
@@ -592,7 +592,7 @@ class YouTubeSubtitleAgent(WorkflowAgent):
592
592
  aligner: AsyncLattifAI,
593
593
  max_retries: int = 0,
594
594
  ):
595
- super().__init__('YouTube Subtitle Agent', max_retries)
595
+ super().__init__("YouTube Subtitle Agent", max_retries)
596
596
 
597
597
  # Components (injected)
598
598
  self.downloader = downloader
@@ -603,52 +603,52 @@ class YouTubeSubtitleAgent(WorkflowAgent):
603
603
  """Define the workflow steps"""
604
604
  return [
605
605
  WorkflowStep(
606
- name='Process YouTube URL', description='Extract video info and download video/audio', required=True
606
+ name="Process YouTube URL", description="Extract video info and download video/audio", required=True
607
607
  ),
608
608
  WorkflowStep(
609
- name='Transcribe Media',
610
- description='Download subtitle if available or transcribe the media file',
609
+ name="Transcribe Media",
610
+ description="Download subtitle if available or transcribe the media file",
611
611
  required=True,
612
612
  ),
613
- WorkflowStep(name='Align Subtitle', description='Align Subtitle with media using LattifAI', required=True),
613
+ WorkflowStep(name="Align Subtitle", description="Align Subtitle with media using LattifAI", required=True),
614
614
  WorkflowStep(
615
- name='Export Results', description='Export aligned subtitles in specified formats', required=True
615
+ name="Export Results", description="Export aligned subtitles in specified formats", required=True
616
616
  ),
617
617
  ]
618
618
 
619
619
  async def execute_step(self, step: WorkflowStep, context: Dict[str, Any]) -> Any:
620
620
  """Execute a single workflow step"""
621
621
 
622
- if step.name == 'Process YouTube URL':
622
+ if step.name == "Process YouTube URL":
623
623
  return await self._process_youtube_url(context)
624
624
 
625
- elif step.name == 'Transcribe Media':
625
+ elif step.name == "Transcribe Media":
626
626
  return await self._transcribe_media(context)
627
627
 
628
- elif step.name == 'Align Subtitle':
628
+ elif step.name == "Align Subtitle":
629
629
  return await self._align_subtitle(context)
630
630
 
631
- elif step.name == 'Export Results':
631
+ elif step.name == "Export Results":
632
632
  return await self._export_results(context)
633
633
 
634
634
  else:
635
- raise ValueError(f'Unknown step: {step.name}')
635
+ raise ValueError(f"Unknown step: {step.name}")
636
636
 
637
637
  async def _process_youtube_url(self, context: Dict[str, Any]) -> Dict[str, Any]:
638
638
  """Step 1: Process YouTube URL and download video"""
639
- url = context.get('url')
639
+ url = context.get("url")
640
640
  if not url:
641
- raise ValueError('YouTube URL is required')
641
+ raise ValueError("YouTube URL is required")
642
642
 
643
- output_dir = context.get('output_dir') or tempfile.gettempdir()
643
+ output_dir = context.get("output_dir") or tempfile.gettempdir()
644
644
  output_dir = Path(output_dir).expanduser()
645
645
  output_dir.mkdir(parents=True, exist_ok=True)
646
646
 
647
- media_format = context.get('media_format', 'mp4')
648
- force_overwrite = context.get('force_overwrite', False)
647
+ media_format = context.get("media_format", "mp4")
648
+ force_overwrite = context.get("force_overwrite", False)
649
649
 
650
- self.logger.info(f'🎥 Processing YouTube URL: {url}')
651
- self.logger.info(f'📦 Media format: {media_format}')
650
+ self.logger.info(f"🎥 Processing YouTube URL: {url}")
651
+ self.logger.info(f"📦 Media format: {media_format}")
652
652
 
653
653
  # Download media (audio or video) with runtime parameters
654
654
  media_path = await self.downloader.download_media(
@@ -668,51 +668,51 @@ class YouTubeSubtitleAgent(WorkflowAgent):
668
668
  enable_gemini_option=bool(self.transcriber.api_key),
669
669
  )
670
670
  if subtitle_path:
671
- self.logger.info(f'✅ Subtitle downloaded: {subtitle_path}')
671
+ self.logger.info(f"✅ Subtitle downloaded: {subtitle_path}")
672
672
  else:
673
- self.logger.info('ℹ️ No subtitles available for this video')
673
+ self.logger.info("ℹ️ No subtitles available for this video")
674
674
  except Exception as e:
675
- self.logger.warning(f'⚠️ Failed to download subtitles: {e}')
675
+ self.logger.warning(f"⚠️ Failed to download subtitles: {e}")
676
676
  # Continue without subtitles - will transcribe later if needed
677
677
 
678
678
  # Get video metadata
679
679
  metadata = await self.downloader.get_video_info(url)
680
680
 
681
681
  result = {
682
- 'url': url,
683
- 'video_path': media_path, # Keep 'video_path' key for backward compatibility
684
- 'audio_path': media_path, # Also add 'audio_path' for clarity
685
- 'metadata': metadata,
686
- 'video_format': media_format,
687
- 'output_dir': output_dir,
688
- 'force_overwrite': force_overwrite,
689
- 'downloaded_subtitle_path': subtitle_path, # Store downloaded subtitle path
682
+ "url": url,
683
+ "video_path": media_path, # Keep 'video_path' key for backward compatibility
684
+ "audio_path": media_path, # Also add 'audio_path' for clarity
685
+ "metadata": metadata,
686
+ "video_format": media_format,
687
+ "output_dir": output_dir,
688
+ "force_overwrite": force_overwrite,
689
+ "downloaded_subtitle_path": subtitle_path, # Store downloaded subtitle path
690
690
  }
691
691
 
692
- self.logger.info(f'✅ Media downloaded: {media_path}')
692
+ self.logger.info(f"✅ Media downloaded: {media_path}")
693
693
  return result
694
694
 
695
695
  async def _transcribe_media(self, context: Dict[str, Any]) -> Dict[str, Any]:
696
696
  """Step 2: Transcribe video using Gemini 2.5 Pro or use downloaded subtitle"""
697
- url = context.get('url')
698
- result = context.get('process_youtube_url_result', {})
699
- video_path = result.get('video_path')
700
- output_dir = result.get('output_dir')
701
- force_overwrite = result.get('force_overwrite', False)
702
- downloaded_subtitle_path = result.get('downloaded_subtitle_path')
697
+ url = context.get("url")
698
+ result = context.get("process_youtube_url_result", {})
699
+ video_path = result.get("video_path")
700
+ output_dir = result.get("output_dir")
701
+ force_overwrite = result.get("force_overwrite", False)
702
+ downloaded_subtitle_path = result.get("downloaded_subtitle_path")
703
703
 
704
704
  if not url or not video_path:
705
- raise ValueError('URL and video path not found in context')
705
+ raise ValueError("URL and video path not found in context")
706
706
 
707
707
  video_id = self.downloader.extract_video_id(url)
708
708
 
709
709
  # If subtitle was already downloaded in step 1 and user selected it, use it directly
710
- if downloaded_subtitle_path and downloaded_subtitle_path != 'gemini':
711
- self.logger.info(f'📥 Using subtitle: {downloaded_subtitle_path}')
712
- return {'subtitle_path': downloaded_subtitle_path}
710
+ if downloaded_subtitle_path and downloaded_subtitle_path != "gemini":
711
+ self.logger.info(f"📥 Using subtitle: {downloaded_subtitle_path}")
712
+ return {"subtitle_path": downloaded_subtitle_path}
713
713
 
714
714
  # Check for existing subtitles if subtitle was not downloaded yet
715
- self.logger.info('📥 Checking for existing subtitles...')
715
+ self.logger.info("📥 Checking for existing subtitles...")
716
716
 
717
717
  # Check for existing subtitle files (all formats including Gemini transcripts)
718
718
  existing_files = FileExistenceManager.check_existing_files(
@@ -722,57 +722,57 @@ class YouTubeSubtitleAgent(WorkflowAgent):
722
722
  )
723
723
 
724
724
  # Prompt user if subtitle exists and force_overwrite is not set
725
- if existing_files['subtitle'] and not force_overwrite:
725
+ if existing_files["subtitle"] and not force_overwrite:
726
726
  # Let user choose which subtitle file to use
727
727
  # Enable Gemini option if API key is available (check transcriber's api_key)
728
728
  has_gemini_key = bool(self.transcriber.api_key)
729
729
  subtitle_choice = FileExistenceManager.prompt_file_selection(
730
- file_type='subtitle',
731
- files=existing_files['subtitle'],
732
- operation='transcribe',
730
+ file_type="subtitle",
731
+ files=existing_files["subtitle"],
732
+ operation="transcribe",
733
733
  enable_gemini=has_gemini_key,
734
734
  )
735
735
 
736
- if subtitle_choice == 'cancel':
737
- raise RuntimeError('Transcription cancelled by user')
738
- elif subtitle_choice in ('overwrite', 'gemini'):
736
+ if subtitle_choice == "cancel":
737
+ raise RuntimeError("Transcription cancelled by user")
738
+ elif subtitle_choice in ("overwrite", "gemini"):
739
739
  # Continue to transcription below
740
740
  # For 'gemini', user explicitly chose to transcribe with Gemini
741
741
  pass
742
- elif subtitle_choice == 'use':
742
+ elif subtitle_choice == "use":
743
743
  # User chose to use existing subtitle files (use first one)
744
- subtitle_path = Path(existing_files['subtitle'][0])
745
- self.logger.info(f'🔁 Using existing subtitle: {subtitle_path}')
746
- return {'subtitle_path': str(subtitle_path)}
744
+ subtitle_path = Path(existing_files["subtitle"][0])
745
+ self.logger.info(f"🔁 Using existing subtitle: {subtitle_path}")
746
+ return {"subtitle_path": str(subtitle_path)}
747
747
  elif subtitle_choice: # User selected a specific file path
748
748
  # Use selected subtitle
749
749
  subtitle_path = Path(subtitle_choice)
750
- self.logger.info(f'🔁 Using existing subtitle: {subtitle_path}')
751
- return {'subtitle_path': str(subtitle_path)}
750
+ self.logger.info(f"🔁 Using existing subtitle: {subtitle_path}")
751
+ return {"subtitle_path": str(subtitle_path)}
752
752
  # If user_choice == 'overwrite' or 'gemini', continue to transcription below
753
753
 
754
754
  # TODO: support other Transcriber options
755
- self.logger.info('✨ Transcribing URL with Gemini 2.5 Pro...')
755
+ self.logger.info("✨ Transcribing URL with Gemini 2.5 Pro...")
756
756
  transcript = await self.transcriber.transcribe_url(url)
757
- subtitle_path = output_dir / f'{video_id}_Gemini.md'
758
- with open(subtitle_path, 'w', encoding='utf-8') as f:
757
+ subtitle_path = output_dir / f"{video_id}_Gemini.md"
758
+ with open(subtitle_path, "w", encoding="utf-8") as f:
759
759
  f.write(transcript)
760
- result = {'subtitle_path': str(subtitle_path)}
761
- self.logger.info(f'✅ Transcript generated: {len(transcript)} characters')
760
+ result = {"subtitle_path": str(subtitle_path)}
761
+ self.logger.info(f"✅ Transcript generated: {len(transcript)} characters")
762
762
  return result
763
763
 
764
764
  async def _align_subtitle(self, context: Dict[str, Any]) -> Dict[str, Any]:
765
765
  """Step 3: Align transcript with video using LattifAI"""
766
- result = context['process_youtube_url_result']
767
- media_path = result.get('video_path', result.get('audio_path'))
768
- subtitle_path = context.get('transcribe_media_result', {}).get('subtitle_path')
766
+ result = context["process_youtube_url_result"]
767
+ media_path = result.get("video_path", result.get("audio_path"))
768
+ subtitle_path = context.get("transcribe_media_result", {}).get("subtitle_path")
769
769
 
770
770
  if not media_path or not subtitle_path:
771
- raise ValueError('Video path and subtitle path are required')
771
+ raise ValueError("Video path and subtitle path are required")
772
772
 
773
- self.logger.info('🎯 Aligning subtitle with video...')
773
+ self.logger.info("🎯 Aligning subtitle with video...")
774
774
 
775
- if subtitle_path.endswith('_Gemini.md'):
775
+ if subtitle_path.endswith("_Gemini.md"):
776
776
  is_gemini_format = True
777
777
  else:
778
778
  is_gemini_format = False
@@ -781,57 +781,57 @@ class YouTubeSubtitleAgent(WorkflowAgent):
781
781
  self.logger.info(f'📄 Subtitle format: {"Gemini" if is_gemini_format else f"{subtitle_path.suffix}"}')
782
782
 
783
783
  original_subtitle_path = subtitle_path
784
- output_dir = result.get('output_dir')
785
- split_sentence = context.get('split_sentence', False)
786
- word_level = context.get('word_level', False)
787
- output_path = output_dir / f'{Path(media_path).stem}_aligned.ass'
784
+ output_dir = result.get("output_dir")
785
+ split_sentence = context.get("split_sentence", False)
786
+ word_level = context.get("word_level", False)
787
+ output_path = output_dir / f"{Path(media_path).stem}_aligned.ass"
788
788
 
789
789
  # Perform alignment with LattifAI (split_sentence and word_level passed as function parameters)
790
790
  aligned_result = await self.aligner.alignment(
791
791
  audio=media_path,
792
792
  subtitle=str(subtitle_path), # Use dialogue text for YouTube format, original for plain text
793
- format='gemini' if is_gemini_format else 'auto',
793
+ format="gemini" if is_gemini_format else "auto",
794
794
  split_sentence=split_sentence,
795
795
  return_details=word_level,
796
796
  output_subtitle_path=str(output_path),
797
797
  )
798
798
 
799
799
  result = {
800
- 'aligned_path': output_path,
801
- 'alignment_result': aligned_result,
802
- 'original_subtitle_path': original_subtitle_path,
803
- 'is_gemini_format': is_gemini_format,
800
+ "aligned_path": output_path,
801
+ "alignment_result": aligned_result,
802
+ "original_subtitle_path": original_subtitle_path,
803
+ "is_gemini_format": is_gemini_format,
804
804
  }
805
805
 
806
- self.logger.info('✅ Alignment completed')
806
+ self.logger.info("✅ Alignment completed")
807
807
  return result
808
808
 
809
809
  async def _export_results(self, context: Dict[str, Any]) -> Dict[str, Any]:
810
810
  """Step 4: Export results in specified format and update subtitle file"""
811
- align_result = context.get('align_subtitle_result', {})
812
- aligned_path = align_result.get('aligned_path')
813
- original_subtitle_path = align_result.get('original_subtitle_path')
814
- is_gemini_format = align_result.get('is_gemini_format', False)
815
- metadata = context.get('process_youtube_url_result', {}).get('metadata', {})
811
+ align_result = context.get("align_subtitle_result", {})
812
+ aligned_path = align_result.get("aligned_path")
813
+ original_subtitle_path = align_result.get("original_subtitle_path")
814
+ is_gemini_format = align_result.get("is_gemini_format", False)
815
+ metadata = context.get("process_youtube_url_result", {}).get("metadata", {})
816
816
 
817
817
  if not aligned_path:
818
- raise ValueError('Aligned subtitle path not found')
818
+ raise ValueError("Aligned subtitle path not found")
819
819
 
820
- output_format = context.get('output_format', 'srt')
821
- self.logger.info(f'📤 Exporting results in format: {output_format}')
820
+ output_format = context.get("output_format", "srt")
821
+ self.logger.info(f"📤 Exporting results in format: {output_format}")
822
822
 
823
- supervisions = SubtitleIO.read(aligned_path, format='ass')
823
+ supervisions = SubtitleIO.read(aligned_path, format="ass")
824
824
  exported_files = {}
825
825
 
826
826
  # Update original transcript file with aligned timestamps if YouTube format
827
827
  if is_gemini_format:
828
- assert Path(original_subtitle_path).exists(), 'Original subtitle path not found'
829
- self.logger.info('📝 Updating original transcript with aligned timestamps...')
828
+ assert Path(original_subtitle_path).exists(), "Original subtitle path not found"
829
+ self.logger.info("📝 Updating original transcript with aligned timestamps...")
830
830
 
831
831
  try:
832
832
  # Generate updated transcript file path
833
833
  original_path = Path(original_subtitle_path)
834
- updated_subtitle_path = original_path.parent / f'{original_path.stem}_LattifAI.md'
834
+ updated_subtitle_path = original_path.parent / f"{original_path.stem}_LattifAI.md"
835
835
 
836
836
  # Update timestamps in original transcript
837
837
  GeminiWriter.update_timestamps(
@@ -840,26 +840,26 @@ class YouTubeSubtitleAgent(WorkflowAgent):
840
840
  output_path=str(updated_subtitle_path),
841
841
  )
842
842
 
843
- exported_files['updated_transcript'] = str(updated_subtitle_path)
844
- self.logger.info(f'✅ Updated transcript: {updated_subtitle_path}')
843
+ exported_files["updated_transcript"] = str(updated_subtitle_path)
844
+ self.logger.info(f"✅ Updated transcript: {updated_subtitle_path}")
845
845
 
846
846
  except Exception as e:
847
- self.logger.warning(f'⚠️ Failed to update transcript timestamps: {e}')
847
+ self.logger.warning(f"⚠️ Failed to update transcript timestamps: {e}")
848
848
 
849
849
  # Export to requested subtitle format
850
850
  output_path = str(aligned_path).replace(
851
- '_aligned.ass', f'{"_Gemini" if is_gemini_format else ""}_LattifAI.{output_format}'
851
+ "_aligned.ass", f'{"_Gemini" if is_gemini_format else ""}_LattifAI.{output_format}'
852
852
  )
853
853
  SubtitleIO.write(supervisions, output_path=output_path)
854
854
  exported_files[output_format] = output_path
855
- self.logger.info(f'✅ Exported {output_format.upper()}: {output_path}')
855
+ self.logger.info(f"✅ Exported {output_format.upper()}: {output_path}")
856
856
 
857
857
  result = {
858
- 'exported_files': exported_files,
859
- 'metadata': metadata,
860
- 'subtitle_count': len(supervisions),
861
- 'is_gemini_format': is_gemini_format,
862
- 'original_subtitle_path': original_subtitle_path,
858
+ "exported_files": exported_files,
859
+ "metadata": metadata,
860
+ "subtitle_count": len(supervisions),
861
+ "is_gemini_format": is_gemini_format,
862
+ "original_subtitle_path": original_subtitle_path,
863
863
  }
864
864
 
865
865
  return result
@@ -868,9 +868,9 @@ class YouTubeSubtitleAgent(WorkflowAgent):
868
868
  self,
869
869
  url: str,
870
870
  output_dir: Optional[str] = None,
871
- media_format: str = 'mp4',
871
+ media_format: str = "mp4",
872
872
  force_overwrite: bool = False,
873
- output_format: str = 'srt',
873
+ output_format: str = "srt",
874
874
  split_sentence: bool = False,
875
875
  word_level: bool = False,
876
876
  ) -> Dict[str, Any]:
@@ -889,9 +889,9 @@ class YouTubeSubtitleAgent(WorkflowAgent):
889
889
  self,
890
890
  url: str,
891
891
  output_dir: Optional[str] = None,
892
- media_format: str = 'mp4',
892
+ media_format: str = "mp4",
893
893
  force_overwrite: bool = False,
894
- output_format: str = 'srt',
894
+ output_format: str = "srt",
895
895
  split_sentence: bool = False,
896
896
  word_level: bool = False,
897
897
  ) -> Dict[str, Any]:
@@ -922,10 +922,10 @@ class YouTubeSubtitleAgent(WorkflowAgent):
922
922
  )
923
923
 
924
924
  if result.is_success:
925
- return result.data.get('export_results_result', {})
925
+ return result.data.get("export_results_result", {})
926
926
  else:
927
927
  # Re-raise the original exception if available to preserve error type and context
928
928
  if result.exception:
929
929
  raise result.exception
930
930
  else:
931
- raise Exception(f'Workflow failed: {result.error}')
931
+ raise Exception(f"Workflow failed: {result.error}")