vidgrab 0.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vidgrab/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """vidgrab — YouTube downloader for maximum-quality footage."""
2
+
3
+ __version__ = "0.5.2"
vidgrab/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running the package directly with ``python -m vidgrab``."""
2
+
3
+ from .cli import main
4
+
5
+ main()
vidgrab/cli.py ADDED
@@ -0,0 +1,258 @@
1
+ """CLI entry point for vidgrab."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from . import __version__
14
+ from . import config as _cfg
15
+ from .downloader import DownloadConfig, Downloader
16
+ from .exceptions import FfmpegNotFoundError
17
+ from .models import DownloadResult
18
+
19
+ app = typer.Typer(
20
+ name="vidgrab",
21
+ help="Download YouTube videos at maximum quality for video editing.",
22
+ add_completion=True,
23
+ )
24
+ _CONSOLE: Console = Console()
25
+ _ERR_CONSOLE: Console = Console(stderr=True)
26
+
27
+
28
+ def _version_callback(value: bool) -> None:
29
+ if value:
30
+ _CONSOLE.print(f"vidgrab {__version__}")
31
+ raise typer.Exit()
32
+
33
+
34
+ @app.command()
35
+ def download(
36
+ urls: Annotated[
37
+ list[str] | None,
38
+ typer.Argument(help="One or more YouTube URLs to download."),
39
+ ] = None,
40
+ batch: Annotated[
41
+ Path | None,
42
+ typer.Option(
43
+ "--batch",
44
+ "-b",
45
+ help="Path to a .txt file with one URL per line.",
46
+ exists=True,
47
+ file_okay=True,
48
+ dir_okay=False,
49
+ readable=True,
50
+ ),
51
+ ] = None,
52
+ output_dir: Annotated[
53
+ Path | None,
54
+ typer.Option(
55
+ "--output",
56
+ "-o",
57
+ help="Directory to save downloaded files (default: ~/Downloads).",
58
+ ),
59
+ ] = None,
60
+ max_height: Annotated[
61
+ int | None,
62
+ typer.Option(
63
+ "--max-height",
64
+ help="Limit video resolution (e.g. 1080 for 1080p). Omit for maximum.",
65
+ min=144,
66
+ max=8640,
67
+ ),
68
+ ] = None,
69
+ playlist: Annotated[
70
+ bool,
71
+ typer.Option(
72
+ "--playlist",
73
+ help="Treat URLs as playlists and download all videos in them.",
74
+ ),
75
+ ] = False,
76
+ force: Annotated[
77
+ bool,
78
+ typer.Option(
79
+ "--force",
80
+ "-f",
81
+ help="Re-download even if the file already exists.",
82
+ ),
83
+ ] = False,
84
+ cookies: Annotated[
85
+ Path | None,
86
+ typer.Option(
87
+ "--cookies",
88
+ help="Path to a Netscape cookies file for age-restricted content.",
89
+ exists=True,
90
+ file_okay=True,
91
+ dir_okay=False,
92
+ readable=True,
93
+ ),
94
+ ] = None,
95
+ workers: Annotated[
96
+ int,
97
+ typer.Option(
98
+ "--workers",
99
+ "-w",
100
+ help="Number of parallel downloads.",
101
+ min=1,
102
+ max=8,
103
+ ),
104
+ ] = 3,
105
+ write_json: Annotated[
106
+ bool,
107
+ typer.Option(
108
+ "--write-json",
109
+ help="Save a .json sidecar file with video metadata alongside each download.",
110
+ ),
111
+ ] = False,
112
+ dry_run: Annotated[
113
+ bool,
114
+ typer.Option(
115
+ "--dry-run",
116
+ help="Show title, resolution and estimated size without downloading.",
117
+ ),
118
+ ] = False,
119
+ quiet: Annotated[
120
+ bool,
121
+ typer.Option(
122
+ "--quiet",
123
+ "-q",
124
+ help="Suppress all output except errors. Useful for scripting.",
125
+ ),
126
+ ] = False,
127
+ version: Annotated[
128
+ bool | None,
129
+ typer.Option(
130
+ "--version",
131
+ "-V",
132
+ callback=_version_callback,
133
+ is_eager=True,
134
+ help="Show version and exit.",
135
+ ),
136
+ ] = None,
137
+ ) -> None:
138
+ """Download YouTube videos at the highest available quality.
139
+
140
+ \b
141
+ Examples:
142
+ vidgrab https://youtu.be/dQw4w9WgXcQ
143
+ vidgrab https://youtu.be/dQw4w9WgXcQ --max-height 1080
144
+ vidgrab --batch urls.txt --output ~/Videos/raw (default: ~/Downloads)
145
+ vidgrab https://youtube.com/playlist?list=PLxxx --playlist
146
+ """
147
+ file_cfg = _cfg.load()
148
+ all_urls = _collect_urls(urls, batch)
149
+
150
+ _default_output = Path(file_cfg.get("output", Path.home() / "Downloads"))
151
+ config = DownloadConfig(
152
+ output_dir=output_dir if output_dir is not None else _default_output,
153
+ max_height=max_height if max_height is not None else file_cfg.get("max_height"),
154
+ cookies_file=cookies,
155
+ force=force,
156
+ workers=workers if workers != 3 else int(file_cfg.get("workers", 3)),
157
+ write_json=write_json,
158
+ dry_run=dry_run,
159
+ quiet=quiet,
160
+ )
161
+ try:
162
+ dl = Downloader(config)
163
+ except FfmpegNotFoundError as exc:
164
+ _ERR_CONSOLE.print(f"[red]Error:[/red] {exc}")
165
+ raise typer.Exit(code=1) from exc
166
+
167
+ if playlist:
168
+ all_urls = dl.expand_playlists(all_urls)
169
+
170
+ results = dl.download_batch(all_urls)
171
+ if not _print_summary(results, quiet=quiet):
172
+ raise typer.Exit(code=1)
173
+
174
+
175
+ def _collect_urls(positional: list[str] | None, batch_file: Path | None) -> list[str]:
176
+ """Merge positional URL args and batch file into a single list.
177
+
178
+ Raises:
179
+ typer.Exit: If no URLs are found.
180
+ """
181
+ all_urls: list[str] = list(positional or [])
182
+
183
+ if batch_file:
184
+ lines = batch_file.read_text(encoding="utf-8").splitlines()
185
+ all_urls += [
186
+ line.strip()
187
+ for line in lines
188
+ if line.strip() and not line.startswith("#")
189
+ ]
190
+
191
+ if not all_urls:
192
+ _ERR_CONSOLE.print(
193
+ "[red]Error:[/red] provide at least one URL or use --batch <file.txt>."
194
+ )
195
+ raise typer.Exit(code=1)
196
+
197
+ return all_urls
198
+
199
+
200
+ def _print_summary(results: list[DownloadResult], *, quiet: bool = False) -> bool:
201
+ """Render the download summary table and list any failed URLs.
202
+
203
+ Returns:
204
+ True if all downloads succeeded or were skipped, False if any failed.
205
+ """
206
+ failed = [r for r in results if not r.success]
207
+
208
+ if quiet:
209
+ return not failed
210
+
211
+ success = [r for r in results if r.success and not r.skipped]
212
+ skipped = [r for r in results if r.skipped]
213
+
214
+ _CONSOLE.print()
215
+ table = Table(title="Summary", show_header=True, header_style="bold")
216
+ table.add_column("Status", style="bold", width=10)
217
+ table.add_column("Count", justify="right")
218
+ table.add_row("[green]Downloaded[/green]", str(len(success)))
219
+ table.add_row("[yellow]Skipped[/yellow]", str(len(skipped)))
220
+ table.add_row("[red]Failed[/red]", str(len(failed)))
221
+ _CONSOLE.print(table)
222
+
223
+ if failed:
224
+ _CONSOLE.print("\n[red]Failed URLs:[/red]")
225
+ for r in failed:
226
+ _CONSOLE.print(f" • {r.url}")
227
+ if r.error:
228
+ _CONSOLE.print(f" [dim]{r.error}[/dim]")
229
+
230
+ return not failed
231
+
232
+
233
+ def main() -> None:
234
+ """Package entry point."""
235
+ # Show friendly intro when run without arguments
236
+ if len(sys.argv) == 1:
237
+ _CONSOLE.print(
238
+ "[bold cyan]vidgrab[/bold cyan] — Download YouTube videos at maximum quality\n"
239
+ )
240
+ _CONSOLE.print("[dim]Usage:[/dim]")
241
+ _CONSOLE.print(" vidgrab <URL> Download a single video")
242
+ _CONSOLE.print(" vidgrab --batch urls.txt Batch download from file")
243
+ _CONSOLE.print(" vidgrab --help Show all options")
244
+ _CONSOLE.print(" vidgrab --install-completion Install shell auto-complete\n")
245
+ _CONSOLE.print("[dim]Examples:[/dim]")
246
+ _CONSOLE.print(" vidgrab https://youtu.be/dQw4w9WgXcQ")
247
+ _CONSOLE.print(" vidgrab https://youtu.be/x --dry-run")
248
+ _CONSOLE.print(" vidgrab https://youtu.be/x --max-height 1080")
249
+ _CONSOLE.print(
250
+ "\n[bold]>>> Run [cyan]vidgrab --help[/cyan] for all options[/bold]"
251
+ )
252
+ sys.exit(0)
253
+
254
+ app()
255
+
256
+
257
+ if __name__ == "__main__":
258
+ main()
vidgrab/config.py ADDED
@@ -0,0 +1,24 @@
1
+ """User config file loader (~/.config/vidgrab/config.toml)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ _CONFIG_PATH = Path.home() / ".config" / "vidgrab" / "config.toml"
10
+
11
+
12
+ def load() -> dict[str, Any]:
13
+ """Read ~/.config/vidgrab/config.toml and return its contents.
14
+
15
+ Returns an empty dict if the file does not exist.
16
+ Silently ignores parse errors to avoid breaking the CLI on bad config.
17
+ """
18
+ if not _CONFIG_PATH.exists():
19
+ return {}
20
+ try:
21
+ with _CONFIG_PATH.open("rb") as fh:
22
+ return tomllib.load(fh)
23
+ except tomllib.TOMLDecodeError:
24
+ return {}