spatelier 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. analytics/__init__.py +1 -0
  2. analytics/reporter.py +497 -0
  3. cli/__init__.py +1 -0
  4. cli/app.py +147 -0
  5. cli/audio.py +129 -0
  6. cli/cli_analytics.py +320 -0
  7. cli/cli_utils.py +282 -0
  8. cli/error_handlers.py +122 -0
  9. cli/files.py +299 -0
  10. cli/update.py +325 -0
  11. cli/video.py +823 -0
  12. cli/worker.py +615 -0
  13. core/__init__.py +1 -0
  14. core/analytics_dashboard.py +368 -0
  15. core/base.py +303 -0
  16. core/base_service.py +69 -0
  17. core/config.py +345 -0
  18. core/database_service.py +116 -0
  19. core/decorators.py +263 -0
  20. core/error_handler.py +210 -0
  21. core/file_tracker.py +254 -0
  22. core/interactive_cli.py +366 -0
  23. core/interfaces.py +166 -0
  24. core/job_queue.py +437 -0
  25. core/logger.py +79 -0
  26. core/package_updater.py +469 -0
  27. core/progress.py +228 -0
  28. core/service_factory.py +295 -0
  29. core/streaming.py +299 -0
  30. core/worker.py +765 -0
  31. database/__init__.py +1 -0
  32. database/connection.py +265 -0
  33. database/metadata.py +516 -0
  34. database/models.py +288 -0
  35. database/repository.py +592 -0
  36. database/transcription_storage.py +219 -0
  37. modules/__init__.py +1 -0
  38. modules/audio/__init__.py +5 -0
  39. modules/audio/converter.py +197 -0
  40. modules/video/__init__.py +16 -0
  41. modules/video/converter.py +191 -0
  42. modules/video/fallback_extractor.py +334 -0
  43. modules/video/services/__init__.py +18 -0
  44. modules/video/services/audio_extraction_service.py +274 -0
  45. modules/video/services/download_service.py +852 -0
  46. modules/video/services/metadata_service.py +190 -0
  47. modules/video/services/playlist_service.py +445 -0
  48. modules/video/services/transcription_service.py +491 -0
  49. modules/video/transcription_service.py +385 -0
  50. modules/video/youtube_api.py +397 -0
  51. spatelier/__init__.py +33 -0
  52. spatelier-0.3.0.dist-info/METADATA +260 -0
  53. spatelier-0.3.0.dist-info/RECORD +59 -0
  54. spatelier-0.3.0.dist-info/WHEEL +5 -0
  55. spatelier-0.3.0.dist-info/entry_points.txt +2 -0
  56. spatelier-0.3.0.dist-info/licenses/LICENSE +21 -0
  57. spatelier-0.3.0.dist-info/top_level.txt +7 -0
  58. utils/__init__.py +1 -0
  59. utils/helpers.py +250 -0
cli/video.py ADDED
@@ -0,0 +1,823 @@
1
+ """
2
+ Video processing CLI commands.
3
+
4
+ This module provides command-line interfaces for video processing operations.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from core.base import ProcessingResult
16
+ from core.config import Config
17
+ from core.decorators import handle_errors, time_operation
18
+ from core.logger import get_logger
19
+ from core.progress import show_download_progress, track_progress
20
+
21
+ # Create the video CLI app
22
+ app = typer.Typer(
23
+ name="video",
24
+ help="Video processing commands",
25
+ rich_markup_mode="rich",
26
+ )
27
+
28
+ console = Console()
29
+
30
+
31
+ @app.command()
32
+ @handle_errors(context="video download", verbose=True)
33
+ @time_operation(verbose=True)
34
+ def download(
35
+ url: str = typer.Argument(
36
+ ...,
37
+ help="URL to download video from (supports channels, playlists, and single videos)",
38
+ ),
39
+ output: Optional[Path] = typer.Option(
40
+ None, "--output", "-o", help="Output file path or directory"
41
+ ),
42
+ quality: str = typer.Option("best", "--quality", "-q", help="Video quality"),
43
+ format: str = typer.Option("mp4", "--format", "-f", help="Output format"),
44
+ max_videos: int = typer.Option(
45
+ 10,
46
+ "--max-videos",
47
+ "-m",
48
+ help="Maximum number of videos to download (for channels/playlists)",
49
+ ),
50
+ transcribe: bool = typer.Option(
51
+ False,
52
+ "--transcribe/--no-transcribe",
53
+ help="Enable automatic transcription (use download-enhanced for transcription by default)",
54
+ ),
55
+ verbose: bool = typer.Option(
56
+ False, "--verbose", "-v", help="Enable verbose output"
57
+ ),
58
+ ):
59
+ """
60
+ Download video from URL.
61
+
62
+ Supports YouTube channels, playlists, single videos, and other popular video platforms.
63
+ Automatically detects channel URLs and converts them to playlist downloads.
64
+ """
65
+ # Lazy import - only import when command is actually called
66
+ from core.service_factory import ServiceFactory
67
+
68
+ config = Config()
69
+ logger = get_logger("video-download", verbose=verbose)
70
+
71
+ # Detect if this is a channel URL and convert to playlist
72
+ processed_url = url
73
+ is_channel = False
74
+ is_playlist = False
75
+
76
+ if "youtube.com" in url:
77
+ if "/playlist" in url or "list=" in url:
78
+ is_playlist = True
79
+ if "/@" in url and "/videos" not in url:
80
+ # Strip trailing slashes before appending /videos
81
+ processed_url = f"{url.rstrip('/')}/videos"
82
+ is_channel = True
83
+ elif "/channel/" in url and "/videos" not in url:
84
+ # Strip trailing slashes before appending /videos
85
+ processed_url = f"{url.rstrip('/')}/videos"
86
+ is_channel = True
87
+ elif "/videos" in url:
88
+ is_channel = True
89
+
90
+ if is_channel:
91
+ logger.info(f"Detected channel URL, converting to playlist: {processed_url}")
92
+ console.print(
93
+ f"[yellow]📺 Channel detected![/yellow] Converting to playlist download..."
94
+ )
95
+
96
+ with ServiceFactory(config, verbose=verbose) as services:
97
+ result = services.playlist.download_playlist(
98
+ url=processed_url,
99
+ output_path=output,
100
+ quality=quality,
101
+ format=format,
102
+ max_videos=max_videos,
103
+ transcribe=transcribe,
104
+ )
105
+
106
+ if result.is_successful():
107
+ console.print(
108
+ Panel(
109
+ f"[green]✓[/green] Channel download successful!\n"
110
+ f"Output: {result.output_path}\n"
111
+ f"Videos downloaded: {result.metadata.get('videos_downloaded', 'Unknown')}",
112
+ title="Success",
113
+ border_style="green",
114
+ )
115
+ )
116
+ else:
117
+ console.print(
118
+ Panel(
119
+ f"[red]✗[/red] Channel download failed: {result.message}",
120
+ title="Error",
121
+ border_style="red",
122
+ )
123
+ )
124
+ raise typer.Exit(1)
125
+ elif is_playlist:
126
+ logger.info(f"Detected playlist URL: {processed_url}")
127
+ console.print(f"[yellow]📼 Playlist detected![/yellow] Downloading playlist...")
128
+ with ServiceFactory(config, verbose=verbose) as services:
129
+ result = services.playlist.download_playlist(
130
+ url=processed_url,
131
+ output_path=output,
132
+ quality=quality,
133
+ format=format,
134
+ max_videos=max_videos,
135
+ transcribe=transcribe,
136
+ )
137
+
138
+ if result.is_successful():
139
+ transcribed = 0
140
+ embedded = 0
141
+ video_files = []
142
+ if transcribe and result.output_path:
143
+ playlist_dir = Path(result.output_path)
144
+ for ext in config.video_extensions:
145
+ video_files.extend(playlist_dir.rglob(f"*{ext}"))
146
+ if max_videos and len(video_files) > max_videos:
147
+ video_files = sorted(
148
+ video_files,
149
+ key=lambda path: path.stat().st_mtime,
150
+ reverse=True,
151
+ )[:max_videos]
152
+ for video_file in sorted(video_files):
153
+ if not video_file.is_file():
154
+ continue
155
+ media_record = services.repositories.media.get_by_file_path(
156
+ str(video_file)
157
+ )
158
+ media_file_id = media_record.id if media_record else None
159
+ transcribe_ok = services.transcription.transcribe_video(
160
+ video_file, media_file_id=media_file_id
161
+ )
162
+ if transcribe_ok:
163
+ transcribed += 1
164
+ embed_ok = services.transcription.embed_subtitles(
165
+ video_file, video_file, media_file_id=media_file_id
166
+ )
167
+ if embed_ok:
168
+ embedded += 1
169
+ else:
170
+ console.print(
171
+ Panel(
172
+ f"[yellow]![/yellow] Embedding failed: {video_file.name}",
173
+ title="Warning",
174
+ border_style="yellow",
175
+ )
176
+ )
177
+ else:
178
+ console.print(
179
+ Panel(
180
+ f"[yellow]![/yellow] Transcription failed: {video_file.name}",
181
+ title="Warning",
182
+ border_style="yellow",
183
+ )
184
+ )
185
+ console.print(
186
+ Panel(
187
+ f"[green]✓[/green] Playlist download successful!\n"
188
+ f"Output: {result.output_path}\n"
189
+ f"Videos downloaded: {result.metadata.get('successful_downloads', 'Unknown')}"
190
+ + (
191
+ f"\nTranscribed: {transcribed}/{len(video_files)}"
192
+ if transcribe
193
+ else ""
194
+ )
195
+ + (
196
+ f"\nEmbedded: {embedded}/{len(video_files)}"
197
+ if transcribe
198
+ else ""
199
+ ),
200
+ title="Success",
201
+ border_style="green",
202
+ )
203
+ )
204
+ else:
205
+ console.print(
206
+ Panel(
207
+ f"[red]✗[/red] Playlist download failed: {result.message}",
208
+ title="Error",
209
+ border_style="red",
210
+ )
211
+ )
212
+ raise typer.Exit(1)
213
+ else:
214
+ # Single video download
215
+ with ServiceFactory(config, verbose=verbose) as services:
216
+ result = services.video_download.download_video(
217
+ processed_url, output, quality=quality, format=format
218
+ )
219
+
220
+ if result.is_successful():
221
+ if transcribe and result.output_path:
222
+ media_file_id = (
223
+ result.metadata.get("media_file_id")
224
+ if result.metadata
225
+ else None
226
+ )
227
+ transcribe_ok = services.transcription.transcribe_video(
228
+ result.output_path, media_file_id=media_file_id
229
+ )
230
+ if transcribe_ok:
231
+ embed_ok = services.transcription.embed_subtitles(
232
+ result.output_path,
233
+ result.output_path,
234
+ media_file_id=media_file_id,
235
+ )
236
+ if not embed_ok:
237
+ console.print(
238
+ Panel(
239
+ "[yellow]![/yellow] Transcription completed but embedding failed.",
240
+ title="Warning",
241
+ border_style="yellow",
242
+ )
243
+ )
244
+ else:
245
+ console.print(
246
+ Panel(
247
+ "[yellow]![/yellow] Transcription failed. The original file is kept.\n"
248
+ 'Retry: spatelier video embed-subtitles "<path>" --transcription-model small',
249
+ title="Warning",
250
+ border_style="yellow",
251
+ )
252
+ )
253
+ console.print(
254
+ Panel(
255
+ f"[green]✓[/green] Video downloaded successfully!\n"
256
+ f"Output: {result.output_path}",
257
+ title="Success",
258
+ border_style="green",
259
+ )
260
+ )
261
+ else:
262
+ console.print(
263
+ Panel(
264
+ f"[red]✗[/red] Download failed: {result.message}",
265
+ title="Error",
266
+ border_style="red",
267
+ )
268
+ )
269
+ raise typer.Exit(1)
270
+
271
+
272
+ @app.command()
273
+ @handle_errors(context="enhanced video download", verbose=True)
274
+ @time_operation(verbose=True)
275
+ def download_enhanced(
276
+ url: str = typer.Argument(..., help="URL to download video from"),
277
+ output: Optional[Path] = typer.Option(
278
+ None, "--output", "-o", help="Output file path"
279
+ ),
280
+ quality: str = typer.Option("best", "--quality", "-q", help="Video quality"),
281
+ format: str = typer.Option("mp4", "--format", "-f", help="Output format"),
282
+ max_videos: int = typer.Option(
283
+ 10,
284
+ "--max-videos",
285
+ "-m",
286
+ help="Maximum number of videos to download (for channels/playlists)",
287
+ ),
288
+ transcribe: bool = typer.Option(
289
+ True,
290
+ "--transcribe/--no-transcribe",
291
+ help="Enable/disable automatic transcription",
292
+ ),
293
+ transcription_model: str = typer.Option(
294
+ "small",
295
+ "--transcription-model",
296
+ help="Whisper model size (tiny, base, small, medium, large)",
297
+ ),
298
+ transcription_language: str = typer.Option(
299
+ "en", "--transcription-language", help="Language code for transcription"
300
+ ),
301
+ use_fallback: bool = typer.Option(
302
+ True, "--fallback/--no-fallback", help="Enable/disable fallback URL extraction"
303
+ ),
304
+ verbose: bool = typer.Option(
305
+ False, "--verbose", "-v", help="Enable verbose output"
306
+ ),
307
+ ):
308
+ """
309
+ Download video with automatic transcription and fallback support.
310
+
311
+ Enhanced download with:
312
+ - Automatic transcription using OpenAI Whisper
313
+ - Fallback URL extraction when yt-dlp fails
314
+ - Analytics and storage in MongoDB
315
+
316
+ Supports YouTube, Vimeo, and other popular video platforms.
317
+ """
318
+ # Lazy import - only import when command is actually called
319
+ from core.service_factory import ServiceFactory
320
+
321
+ config = Config()
322
+ logger = get_logger("video-download-enhanced", verbose=verbose)
323
+
324
+ with ServiceFactory(config, verbose=verbose) as services:
325
+ processed_url = url
326
+ is_channel = False
327
+ is_playlist = False
328
+
329
+ if "youtube.com" in url:
330
+ if "/playlist" in url or "list=" in url:
331
+ is_playlist = True
332
+ if "/@" in url and "/videos" not in url:
333
+ processed_url = f"{url.rstrip('/')}/videos"
334
+ is_channel = True
335
+ elif "/channel/" in url and "/videos" not in url:
336
+ processed_url = f"{url.rstrip('/')}/videos"
337
+ is_channel = True
338
+ elif "/videos" in url:
339
+ is_channel = True
340
+
341
+ if is_channel:
342
+ logger.info(
343
+ f"Detected channel URL, converting to playlist: {processed_url}"
344
+ )
345
+ console.print(
346
+ "[yellow]📺 Channel detected![/yellow] Converting to playlist download..."
347
+ )
348
+ download_result = services.playlist.download_playlist(
349
+ url=processed_url,
350
+ output_path=output,
351
+ quality=quality,
352
+ format=format,
353
+ max_videos=max_videos,
354
+ transcribe=transcribe,
355
+ )
356
+ elif is_playlist:
357
+ logger.info(f"Detected playlist URL: {processed_url}")
358
+ console.print(
359
+ "[yellow]📼 Playlist detected![/yellow] Downloading playlist..."
360
+ )
361
+ download_result = services.playlist.download_playlist(
362
+ url=processed_url,
363
+ output_path=output,
364
+ quality=quality,
365
+ format=format,
366
+ max_videos=max_videos,
367
+ transcribe=transcribe,
368
+ )
369
+ else:
370
+ # First download the video
371
+ download_result = services.video_download.download_video(
372
+ processed_url, output, quality=quality, format=format
373
+ )
374
+
375
+ if not download_result.is_successful():
376
+ console.print(
377
+ Panel(
378
+ f"[red]✗[/red] Download failed: {download_result.message}",
379
+ title="Error",
380
+ border_style="red",
381
+ )
382
+ )
383
+ raise typer.Exit(1)
384
+
385
+ if transcribe and download_result.output_path:
386
+ if is_channel or is_playlist:
387
+ playlist_dir = Path(download_result.output_path)
388
+ video_files = []
389
+ for ext in config.video_extensions:
390
+ video_files.extend(playlist_dir.rglob(f"*{ext}"))
391
+ if max_videos and len(video_files) > max_videos:
392
+ video_files = sorted(
393
+ video_files,
394
+ key=lambda path: path.stat().st_mtime,
395
+ reverse=True,
396
+ )[:max_videos]
397
+ transcribed = 0
398
+ embedded = 0
399
+ for video_file in sorted(video_files):
400
+ if not video_file.is_file():
401
+ continue
402
+ media_record = services.repositories.media.get_by_file_path(
403
+ str(video_file)
404
+ )
405
+ media_file_id = media_record.id if media_record else None
406
+ transcribe_ok = services.transcription.transcribe_video(
407
+ video_file,
408
+ media_file_id=media_file_id,
409
+ language=transcription_language,
410
+ model_size=transcription_model,
411
+ )
412
+ if transcribe_ok:
413
+ transcribed += 1
414
+ embed_ok = services.transcription.embed_subtitles(
415
+ video_file, video_file, media_file_id=media_file_id
416
+ )
417
+ if embed_ok:
418
+ embedded += 1
419
+ else:
420
+ console.print(
421
+ Panel(
422
+ f"[yellow]![/yellow] Embedding failed: {video_file.name}",
423
+ title="Warning",
424
+ border_style="yellow",
425
+ )
426
+ )
427
+ else:
428
+ console.print(
429
+ Panel(
430
+ f"[yellow]![/yellow] Transcription failed: {video_file.name}",
431
+ title="Warning",
432
+ border_style="yellow",
433
+ )
434
+ )
435
+ result = download_result
436
+ result.message += f" (transcribed {transcribed}/{len(video_files)})"
437
+ else:
438
+ media_file_id = download_result.metadata.get("media_file_id")
439
+ transcribe_result = services.transcription.transcribe_video(
440
+ download_result.output_path,
441
+ media_file_id=media_file_id,
442
+ language=transcription_language,
443
+ model_size=transcription_model,
444
+ )
445
+
446
+ if transcribe_result:
447
+ # Embed subtitles into the original file
448
+ embed_result = services.transcription.embed_subtitles(
449
+ download_result.output_path,
450
+ download_result.output_path, # Use same file for output
451
+ media_file_id=media_file_id,
452
+ )
453
+
454
+ if embed_result:
455
+ result = download_result
456
+ result.message += " (with transcription and subtitles)"
457
+ # Keep the original output path
458
+ else:
459
+ result = download_result
460
+ result.add_warning(
461
+ "Transcription completed but subtitle embedding failed"
462
+ )
463
+ else:
464
+ result = download_result
465
+ result.add_warning("Download successful but transcription failed")
466
+ else:
467
+ result = download_result
468
+
469
+ if result.success:
470
+ console.print(
471
+ Panel(
472
+ f"[green]✓[/green] Video downloaded successfully!\n"
473
+ f"Output: {result.output_path}\n"
474
+ f"Method: {result.metadata.get('download_method', 'yt-dlp') if result.metadata else 'yt-dlp'}",
475
+ title="Success",
476
+ border_style="green",
477
+ )
478
+ )
479
+ else:
480
+ console.print(
481
+ Panel(
482
+ f"[red]✗[/red] Download failed: {result.message}",
483
+ title="Error",
484
+ border_style="red",
485
+ )
486
+ )
487
+ raise typer.Exit(1)
488
+
489
+
490
+ @app.command()
491
+ def download_playlist(
492
+ url: str = typer.Argument(..., help="Playlist URL to download"),
493
+ output: Optional[Path] = typer.Option(
494
+ None, "--output", "-o", help="Output directory (will create playlist folder)"
495
+ ),
496
+ quality: str = typer.Option("best", "--quality", "-q", help="Video quality"),
497
+ format: str = typer.Option("mp4", "--format", "-f", help="Output format"),
498
+ use_fallback: bool = typer.Option(
499
+ True, "--fallback/--no-fallback", help="Enable/disable fallback URL extraction"
500
+ ),
501
+ continue_download: bool = typer.Option(
502
+ True,
503
+ "--continue/--no-continue",
504
+ help="Continue from failed/incomplete downloads",
505
+ ),
506
+ verbose: bool = typer.Option(
507
+ False, "--verbose", "-v", help="Enable verbose output"
508
+ ),
509
+ ):
510
+ """
511
+ Download playlist with fallback support.
512
+
513
+ Enhanced playlist download with:
514
+ - Automatic folder creation with playlist name and ID
515
+ - Fallback URL extraction when yt-dlp fails
516
+ - Analytics and storage in MongoDB
517
+
518
+ Supports YouTube playlists and other platforms.
519
+ """
520
+ # Lazy import - only import when command is actually called
521
+ from core.service_factory import ServiceFactory
522
+
523
+ config = Config()
524
+
525
+ with ServiceFactory(config, verbose=verbose) as services:
526
+ # First download the playlist
527
+ playlist_result = services.playlist.download_playlist(
528
+ url, output_path=output, quality=quality, format=format
529
+ )
530
+
531
+ if not playlist_result.is_successful():
532
+ console.print(
533
+ Panel(
534
+ f"[red]✗[/red] Playlist download failed: {playlist_result.message}",
535
+ title="Error",
536
+ border_style="red",
537
+ )
538
+ )
539
+ raise typer.Exit(1)
540
+
541
+ # Build result message
542
+ metadata = playlist_result.metadata or {}
543
+ message = f"Playlist downloaded successfully: {metadata.get('total_videos', 0)} videos"
544
+
545
+ result = ProcessingResult.success_result(
546
+ message=message,
547
+ output_path=playlist_result.output_path,
548
+ metadata=playlist_result.metadata,
549
+ )
550
+
551
+ if result.is_successful():
552
+ metadata = result.metadata or {}
553
+ console.print(
554
+ Panel(
555
+ f"[green]✓[/green] Playlist downloaded successfully!\n"
556
+ f"Output: {result.output_path}\n"
557
+ f"Playlist: {metadata.get('playlist_title', 'Unknown')}\n"
558
+ f"Videos: {metadata.get('successful_downloads', 0)}/{metadata.get('total_videos', 0)}\n"
559
+ f"Transcription: {'Enabled' if metadata.get('transcription_enabled') else 'Disabled'}",
560
+ title="Success",
561
+ border_style="green",
562
+ )
563
+ )
564
+ else:
565
+ console.print(
566
+ Panel(
567
+ f"[red]✗[/red] Playlist download failed: {result.message}",
568
+ title="Error",
569
+ border_style="red",
570
+ )
571
+ )
572
+ raise typer.Exit(1)
573
+
574
+
575
+ @app.command()
576
+ def embed_subtitles(
577
+ video_file: Path = typer.Argument(..., help="Video file to embed subtitles into"),
578
+ output_file: Optional[Path] = typer.Option(
579
+ None,
580
+ "--output",
581
+ "-o",
582
+ help="Output video file (default: adds '_with_subs' to filename)",
583
+ ),
584
+ transcription_model: str = typer.Option(
585
+ "small",
586
+ "--transcription-model",
587
+ help="Whisper model size (tiny, base, small, medium, large)",
588
+ ),
589
+ transcription_language: str = typer.Option(
590
+ "en", "--transcription-language", help="Language code for transcription"
591
+ ),
592
+ verbose: bool = typer.Option(
593
+ False, "--verbose", "-v", help="Enable verbose output"
594
+ ),
595
+ ):
596
+ """
597
+ Embed subtitles into an existing video file.
598
+
599
+ Transcribes the video using OpenAI Whisper and embeds the subtitles directly
600
+ into the video file. The subtitle track will be named based on the detected language.
601
+
602
+ Example:
603
+ spatelier-video embed-subtitles video.mp4
604
+ spatelier-video embed-subtitles video.mp4 --output video_with_subs.mp4
605
+ """
606
+ # Lazy import - only import when command is actually called
607
+ from core.service_factory import ServiceFactory
608
+
609
+ config = Config()
610
+ logger = get_logger("video-embed-subtitles", verbose=verbose)
611
+
612
+ # Check if video file exists
613
+ if not video_file.exists():
614
+ console.print(
615
+ Panel(
616
+ f"[red]✗[/red] Video file not found: {video_file}",
617
+ title="Error",
618
+ border_style="red",
619
+ )
620
+ )
621
+ raise typer.Exit(1)
622
+
623
+ # Initialize services
624
+ with ServiceFactory(config, verbose=verbose) as services:
625
+ logger.info(f"Transcribing video: {video_file}")
626
+
627
+ # Transcribe the video (service handles initialization internally)
628
+ success = services.transcription.transcribe_video(
629
+ video_file, language=transcription_language, model_size=transcription_model
630
+ )
631
+
632
+ if not success:
633
+ console.print(
634
+ Panel(
635
+ "[yellow]![/yellow] Transcription failed. The original file is kept.\n"
636
+ 'Retry: spatelier video embed-subtitles "<path>" --transcription-model small',
637
+ title="Warning",
638
+ border_style="yellow",
639
+ )
640
+ )
641
+ return
642
+
643
+ logger.info("Transcription completed successfully")
644
+
645
+ # Embed subtitles into video (default: overwrite original file)
646
+ output_file = output_file or video_file
647
+ success = services.transcription.embed_subtitles(video_file, output_file)
648
+
649
+ if success:
650
+ console.print(
651
+ Panel(
652
+ f"[green]✓[/green] Subtitles embedded successfully!\n"
653
+ f"Input: {video_file}\n"
654
+ f"Output: {output_file}\n"
655
+ f"Language: {transcription_language}\n"
656
+ f"Model: {transcription_model}",
657
+ title="Success",
658
+ border_style="green",
659
+ )
660
+ )
661
+ else:
662
+ console.print(
663
+ Panel(
664
+ f"[red]✗[/red] Failed to embed subtitles into video",
665
+ title="Error",
666
+ border_style="red",
667
+ )
668
+ )
669
+ raise typer.Exit(1)
670
+
671
+
672
+ @app.command()
673
+ def extract_audio_from_url(
674
+ url: str = typer.Argument(..., help="YouTube video URL"),
675
+ output_dir: Optional[Path] = typer.Option(
676
+ None, "--output", "-o", help="Output directory"
677
+ ),
678
+ format: str = typer.Option(
679
+ "mp3", "--format", "-f", help="Audio format (mp3, wav, flac, aac, ogg, m4a)"
680
+ ),
681
+ bitrate: int = typer.Option(320, "--bitrate", "-b", help="Audio bitrate in kbps"),
682
+ verbose: bool = typer.Option(
683
+ False, "--verbose", "-v", help="Enable verbose output"
684
+ ),
685
+ ):
686
+ """
687
+ 🎵 Extract audio from YouTube video.
688
+
689
+ Downloads only the audio track from a YouTube video and saves it in your preferred format.
690
+ Perfect for getting music, podcasts, or any audio content from videos.
691
+ """
692
+ from modules.video.services.audio_extraction_service import AudioExtractionService
693
+
694
+ config = Config()
695
+ service = AudioExtractionService(config, verbose=verbose)
696
+
697
+ # Set default output directory
698
+ if output_dir is None:
699
+ from core.config import get_default_data_dir
700
+
701
+ repo_root = get_default_data_dir().parent
702
+ output_dir = repo_root / "audio_extracts"
703
+
704
+ try:
705
+ result = service.extract_audio_from_url(
706
+ url=url, output_dir=output_dir, format=format, bitrate=bitrate
707
+ )
708
+
709
+ if result.is_successful():
710
+ console.print(
711
+ Panel(
712
+ f"[green]✓[/green] Audio extracted successfully!\n"
713
+ f"File: {result.output_path.name}\n"
714
+ f"Size: {result.metadata.get('file_size_mb', 0):.1f} MB\n"
715
+ f"Format: {format.upper()}\n"
716
+ f"Bitrate: {bitrate} kbps",
717
+ title="Success",
718
+ border_style="green",
719
+ )
720
+ )
721
+ else:
722
+ console.print(
723
+ Panel(
724
+ f"[red]✗[/red] Audio extraction failed: {result.message}",
725
+ title="Error",
726
+ border_style="red",
727
+ )
728
+ )
729
+ raise typer.Exit(1)
730
+
731
+ except Exception as e:
732
+ console.print(
733
+ Panel(
734
+ f"[red]✗[/red] Audio extraction failed: {str(e)}",
735
+ title="Error",
736
+ border_style="red",
737
+ )
738
+ )
739
+ raise typer.Exit(1)
740
+
741
+
742
+ @app.command()
743
+ def convert(
744
+ input_file: Path = typer.Argument(..., help="Input video file"),
745
+ output_file: Path = typer.Argument(..., help="Output video file"),
746
+ quality: str = typer.Option("medium", "--quality", "-q", help="Output quality"),
747
+ codec: str = typer.Option("auto", "--codec", "-c", help="Video codec"),
748
+ verbose: bool = typer.Option(
749
+ False, "--verbose", "-v", help="Enable verbose output"
750
+ ),
751
+ ):
752
+ """
753
+ Convert video to different format.
754
+
755
+ Supports various input and output formats including MP4, AVI, MOV, etc.
756
+ """
757
+ # Lazy import - only import when command is actually called
758
+ from modules.video.converter import VideoConverter
759
+
760
+ config = Config()
761
+ logger = get_logger("video-convert", verbose=verbose)
762
+
763
+ converter = VideoConverter(config, verbose=verbose)
764
+ result = converter.convert(input_file, output_file, quality=quality, codec=codec)
765
+
766
+ if result.success:
767
+ console.print(
768
+ Panel(
769
+ f"[green]✓[/green] Video converted successfully!\n"
770
+ f"Output: {result.output_path}",
771
+ title="Success",
772
+ border_style="green",
773
+ )
774
+ )
775
+ else:
776
+ console.print(
777
+ Panel(
778
+ f"[red]✗[/red] Conversion failed: {result.message}",
779
+ title="Error",
780
+ border_style="red",
781
+ )
782
+ )
783
+ raise typer.Exit(1)
784
+
785
+
786
+ @app.command()
787
+ def info(
788
+ file_path: Path = typer.Argument(..., help="Video file to analyze"),
789
+ verbose: bool = typer.Option(
790
+ False, "--verbose", "-v", help="Enable verbose output"
791
+ ),
792
+ ):
793
+ """
794
+ Display detailed information about a video file.
795
+ """
796
+ config = Config()
797
+ logger = get_logger("video-info", verbose=verbose)
798
+
799
+ # This would use a video analyzer module
800
+ # analyzer = VideoAnalyzer(config, verbose=verbose)
801
+ # info = analyzer.analyze(file_path)
802
+
803
+ # For now, show basic file info
804
+ if not file_path.exists():
805
+ console.print(
806
+ Panel(
807
+ f"[red]✗[/red] File not found: {file_path}",
808
+ title="Error",
809
+ border_style="red",
810
+ )
811
+ )
812
+ raise typer.Exit(1)
813
+
814
+ # Create info table
815
+ table = Table(title=f"Video Information: {file_path.name}")
816
+ table.add_column("Property", style="cyan")
817
+ table.add_column("Value", style="magenta")
818
+
819
+ table.add_row("File Path", str(file_path))
820
+ table.add_row("File Size", f"{file_path.stat().st_size:,} bytes")
821
+ table.add_row("Format", file_path.suffix.upper())
822
+
823
+ console.print(table)