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 +3 -0
- vidgrab/__main__.py +5 -0
- vidgrab/cli.py +258 -0
- vidgrab/config.py +24 -0
- vidgrab/downloader.py +483 -0
- vidgrab/exceptions.py +52 -0
- vidgrab/models.py +92 -0
- vidgrab-0.5.2.dist-info/METADATA +649 -0
- vidgrab-0.5.2.dist-info/RECORD +12 -0
- vidgrab-0.5.2.dist-info/WHEEL +4 -0
- vidgrab-0.5.2.dist-info/entry_points.txt +3 -0
- vidgrab-0.5.2.dist-info/licenses/LICENSE +21 -0
vidgrab/__init__.py
ADDED
vidgrab/__main__.py
ADDED
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 {}
|