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.
- analytics/__init__.py +1 -0
- analytics/reporter.py +497 -0
- cli/__init__.py +1 -0
- cli/app.py +147 -0
- cli/audio.py +129 -0
- cli/cli_analytics.py +320 -0
- cli/cli_utils.py +282 -0
- cli/error_handlers.py +122 -0
- cli/files.py +299 -0
- cli/update.py +325 -0
- cli/video.py +823 -0
- cli/worker.py +615 -0
- core/__init__.py +1 -0
- core/analytics_dashboard.py +368 -0
- core/base.py +303 -0
- core/base_service.py +69 -0
- core/config.py +345 -0
- core/database_service.py +116 -0
- core/decorators.py +263 -0
- core/error_handler.py +210 -0
- core/file_tracker.py +254 -0
- core/interactive_cli.py +366 -0
- core/interfaces.py +166 -0
- core/job_queue.py +437 -0
- core/logger.py +79 -0
- core/package_updater.py +469 -0
- core/progress.py +228 -0
- core/service_factory.py +295 -0
- core/streaming.py +299 -0
- core/worker.py +765 -0
- database/__init__.py +1 -0
- database/connection.py +265 -0
- database/metadata.py +516 -0
- database/models.py +288 -0
- database/repository.py +592 -0
- database/transcription_storage.py +219 -0
- modules/__init__.py +1 -0
- modules/audio/__init__.py +5 -0
- modules/audio/converter.py +197 -0
- modules/video/__init__.py +16 -0
- modules/video/converter.py +191 -0
- modules/video/fallback_extractor.py +334 -0
- modules/video/services/__init__.py +18 -0
- modules/video/services/audio_extraction_service.py +274 -0
- modules/video/services/download_service.py +852 -0
- modules/video/services/metadata_service.py +190 -0
- modules/video/services/playlist_service.py +445 -0
- modules/video/services/transcription_service.py +491 -0
- modules/video/transcription_service.py +385 -0
- modules/video/youtube_api.py +397 -0
- spatelier/__init__.py +33 -0
- spatelier-0.3.0.dist-info/METADATA +260 -0
- spatelier-0.3.0.dist-info/RECORD +59 -0
- spatelier-0.3.0.dist-info/WHEEL +5 -0
- spatelier-0.3.0.dist-info/entry_points.txt +2 -0
- spatelier-0.3.0.dist-info/licenses/LICENSE +21 -0
- spatelier-0.3.0.dist-info/top_level.txt +7 -0
- utils/__init__.py +1 -0
- 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)
|