snagvid 0.1.0__tar.gz

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.
snagvid-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Waqas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
snagvid-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: snagvid
3
+ Version: 0.1.0
4
+ Summary: Download videos from YouTube, X/Twitter, Instagram, Facebook, or almost any website straight from your terminal.
5
+ Author: Waqas
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/WaqasAhmad313/snagvid
8
+ Project-URL: Repository, https://github.com/WaqasAhmad313/snagvid
9
+ Project-URL: Issues, https://github.com/WaqasAhmad313/snagvid/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Multimedia :: Video
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: yt-dlp>=2024.1.1
24
+ Requires-Dist: click>=8.1
25
+ Requires-Dist: questionary>=2.0
26
+ Requires-Dist: rich>=13.0
27
+ Dynamic: license-file
28
+
29
+ # snagvid (`vdl`)
30
+
31
+ Download videos from YouTube, X/Twitter, Instagram, Facebook, or almost any website — straight from your terminal, no browser extensions, no sketchy websites.
32
+
33
+ [![PyPI version](https://img.shields.io/pypi/v/snagvid.svg)](https://pypi.org/project/snagvid/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
36
+
37
+ ![demo](docs/demo.gif)
38
+
39
+ ## Features
40
+
41
+ - 🧙 **Interactive CLI Wizard**: Simply type `vdl` to get guided prompts for URL, resolution, and output folder.
42
+ - 📊 **Smart Resolution & Size Detection**: Shows available resolutions with accurate combined video + audio download size estimates.
43
+ - 🎵 **Audio Extraction**: Easily extract high-quality audio tracks (e.g. MP3) directly.
44
+ - 🎨 **Clean Live Progress**: Real-time progress display with speed, ETA, and spinner animations powered by `rich`.
45
+ - 🌐 **1800+ Supported Sites**: Powered by `yt-dlp` for unmatched platform compatibility.
46
+ - ⚡ **Scripting Ready**: Pass URLs and flags directly (`vdl "<url>" -q 720p`) for automation.
47
+ - 🛡️ **Long Title Protection**: Automatic title truncation and ID tagging to prevent filesystem errors on long post captions.
48
+
49
+ ## Prerequisites
50
+
51
+ - **Python**: 3.9 or higher
52
+ - **FFmpeg**: Required for stream merging and audio extraction.
53
+ - **Linux**: `sudo apt install ffmpeg` / `sudo pacman -S ffmpeg`
54
+ - **macOS**: `brew install ffmpeg`
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install snagvid
60
+ # or with pipx for isolated global installation:
61
+ pipx install snagvid
62
+ ```
63
+
64
+ ### Local Development Install
65
+
66
+ ```bash
67
+ git clone https://github.com/WaqasAhmad313/snagvid.git
68
+ cd snagvid
69
+ python3 -m venv .venv
70
+ source .venv/bin/activate
71
+ pip install -e .
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ### Interactive Mode (Recommended)
77
+
78
+ Run `vdl` with no arguments to start the interactive wizard:
79
+
80
+ ```bash
81
+ vdl
82
+ ```
83
+
84
+ ### Direct / Scripted Usage
85
+
86
+ Pass arguments directly to bypass the interactive prompts:
87
+
88
+ ```bash
89
+ # Direct download best quality
90
+ vdl "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
91
+
92
+ # Download audio-only (MP3)
93
+ vdl "https://www.youtube.com/watch?v=dQw4w9WgXcQ" --audio
94
+
95
+ # Download specific quality to custom directory
96
+ vdl "https://www.youtube.com/watch?v=dQw4w9WgXcQ" -q 720p -o ~/Videos
97
+ ```
98
+
99
+ ### Configuration (`~/.vdlrc`)
100
+
101
+ You can save your default preferences in `~/.vdlrc` (INI format):
102
+
103
+ ```ini
104
+ [defaults]
105
+ output = ~/Videos
106
+ quality = 1080p
107
+ audio_format = mp3
108
+ retries = 5
109
+ ```
110
+
111
+ ## Supported Sites
112
+
113
+ `vdl` inherits complete extractor support from `yt-dlp`, covering 1800+ websites including YouTube, X/Twitter, Instagram, Facebook, TikTok, Twitch, Reddit, Vimeo, and SoundCloud.
114
+
115
+ For a full list of supported extractors, see the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supported_sites.md).
116
+
117
+ ## Responsible Use
118
+
119
+ This tool is intended for downloading content you own, have explicit permission to use, or that is otherwise legally accessible to you — e.g. personal backups, offline access to your own posts, content licensed for reuse. Respect the terms of service of the platform you're downloading from and applicable copyright law. The maintainers are not responsible for misuse.
120
+
121
+ ## Contributing
122
+
123
+ Contributions are welcome!
124
+
125
+ 1. Fork the repository
126
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
127
+ 3. Commit your changes (`git commit -m 'Add amazing feature'`)
128
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
129
+ 5. Open a Pull Request
130
+
131
+ ## License
132
+
133
+ Distributed under the MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,105 @@
1
+ # snagvid (`vdl`)
2
+
3
+ Download videos from YouTube, X/Twitter, Instagram, Facebook, or almost any website — straight from your terminal, no browser extensions, no sketchy websites.
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/snagvid.svg)](https://pypi.org/project/snagvid/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
8
+
9
+ ![demo](docs/demo.gif)
10
+
11
+ ## Features
12
+
13
+ - 🧙 **Interactive CLI Wizard**: Simply type `vdl` to get guided prompts for URL, resolution, and output folder.
14
+ - 📊 **Smart Resolution & Size Detection**: Shows available resolutions with accurate combined video + audio download size estimates.
15
+ - 🎵 **Audio Extraction**: Easily extract high-quality audio tracks (e.g. MP3) directly.
16
+ - 🎨 **Clean Live Progress**: Real-time progress display with speed, ETA, and spinner animations powered by `rich`.
17
+ - 🌐 **1800+ Supported Sites**: Powered by `yt-dlp` for unmatched platform compatibility.
18
+ - ⚡ **Scripting Ready**: Pass URLs and flags directly (`vdl "<url>" -q 720p`) for automation.
19
+ - 🛡️ **Long Title Protection**: Automatic title truncation and ID tagging to prevent filesystem errors on long post captions.
20
+
21
+ ## Prerequisites
22
+
23
+ - **Python**: 3.9 or higher
24
+ - **FFmpeg**: Required for stream merging and audio extraction.
25
+ - **Linux**: `sudo apt install ffmpeg` / `sudo pacman -S ffmpeg`
26
+ - **macOS**: `brew install ffmpeg`
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install snagvid
32
+ # or with pipx for isolated global installation:
33
+ pipx install snagvid
34
+ ```
35
+
36
+ ### Local Development Install
37
+
38
+ ```bash
39
+ git clone https://github.com/WaqasAhmad313/snagvid.git
40
+ cd snagvid
41
+ python3 -m venv .venv
42
+ source .venv/bin/activate
43
+ pip install -e .
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ### Interactive Mode (Recommended)
49
+
50
+ Run `vdl` with no arguments to start the interactive wizard:
51
+
52
+ ```bash
53
+ vdl
54
+ ```
55
+
56
+ ### Direct / Scripted Usage
57
+
58
+ Pass arguments directly to bypass the interactive prompts:
59
+
60
+ ```bash
61
+ # Direct download best quality
62
+ vdl "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
63
+
64
+ # Download audio-only (MP3)
65
+ vdl "https://www.youtube.com/watch?v=dQw4w9WgXcQ" --audio
66
+
67
+ # Download specific quality to custom directory
68
+ vdl "https://www.youtube.com/watch?v=dQw4w9WgXcQ" -q 720p -o ~/Videos
69
+ ```
70
+
71
+ ### Configuration (`~/.vdlrc`)
72
+
73
+ You can save your default preferences in `~/.vdlrc` (INI format):
74
+
75
+ ```ini
76
+ [defaults]
77
+ output = ~/Videos
78
+ quality = 1080p
79
+ audio_format = mp3
80
+ retries = 5
81
+ ```
82
+
83
+ ## Supported Sites
84
+
85
+ `vdl` inherits complete extractor support from `yt-dlp`, covering 1800+ websites including YouTube, X/Twitter, Instagram, Facebook, TikTok, Twitch, Reddit, Vimeo, and SoundCloud.
86
+
87
+ For a full list of supported extractors, see the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supported_sites.md).
88
+
89
+ ## Responsible Use
90
+
91
+ This tool is intended for downloading content you own, have explicit permission to use, or that is otherwise legally accessible to you — e.g. personal backups, offline access to your own posts, content licensed for reuse. Respect the terms of service of the platform you're downloading from and applicable copyright law. The maintainers are not responsible for misuse.
92
+
93
+ ## Contributing
94
+
95
+ Contributions are welcome!
96
+
97
+ 1. Fork the repository
98
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
99
+ 3. Commit your changes (`git commit -m 'Add amazing feature'`)
100
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
101
+ 5. Open a Pull Request
102
+
103
+ ## License
104
+
105
+ Distributed under the MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,5 @@
1
+ """
2
+ snagvid — a yt-dlp wrapper with a clean CLI.
3
+ """
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,307 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import click
5
+ import questionary
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from . import __version__
10
+ from .core import download, download_batch, download_from_file, QUALITY_PRESETS, extract_formats
11
+ from .utils import is_valid_url, list_formats, load_config, RichDownloadProgress
12
+
13
+ console = Console(highlight=False)
14
+
15
+
16
+ # ── vdl-cmd group ─────────────────────────────────────────────────────────────
17
+
18
+ @click.group(invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]})
19
+ @click.version_option(__version__, "-V", "--version")
20
+ @click.pass_context
21
+ def cli(ctx: click.Context) -> None:
22
+ """vdl -- download videos from YouTube, Twitter/X, and 1800+ other sites."""
23
+ if ctx.invoked_subcommand is None:
24
+ click.echo(ctx.get_help())
25
+
26
+
27
+ # ── Shared helpers ─────────────────────────────────────────────────────────────
28
+
29
+ def _common_options(func):
30
+ func = click.option("--output", "-o", default=None, metavar="DIR", help="Output directory.")(func)
31
+ func = click.option("--quality", "-q", default=None, metavar="PRESET", help=f"Quality preset: {', '.join(QUALITY_PRESETS)} or raw yt-dlp selector.")(func)
32
+ func = click.option("--audio", "-a", is_flag=True, default=False, help="Extract audio only.")(func)
33
+ func = click.option("--audio-format", default=None, metavar="CODEC", help="Audio codec (mp3, m4a, opus). Default: mp3.")(func)
34
+ func = click.option("--playlist", "-p", is_flag=True, default=False, help="Download entire playlist.")(func)
35
+ func = click.option("--retries", "-r", default=None, type=int, metavar="N", help="Retry count on network errors. Default: 3.")(func)
36
+ func = click.option("--verbose", "-v", is_flag=True, default=False, help="Show full error details on failure.")(func)
37
+ return func
38
+
39
+
40
+ def _resolve_opts(cfg, output, quality, audio, audio_format, playlist, retries):
41
+ return dict(
42
+ output_dir = output or cfg.get("output", "downloads"),
43
+ quality = quality or cfg.get("quality", "best"),
44
+ audio_only = audio,
45
+ audio_format = audio_format or cfg.get("audio_format", "mp3"),
46
+ playlist = playlist,
47
+ retries = retries if retries is not None else int(cfg.get("retries", 3)),
48
+ )
49
+
50
+
51
+ def _short_error(exc: Exception) -> str:
52
+ """Return only the first meaningful line of a yt-dlp error message."""
53
+ msg = str(exc).strip().removeprefix("ERROR: ")
54
+ return msg.splitlines()[0] if msg else type(exc).__name__
55
+
56
+
57
+ def _run_download(url: str, opts: dict, verbose: bool = False) -> None:
58
+ """
59
+ Core download routine shared by wizard and flag-based paths.
60
+ Uses RichDownloadProgress for a polished progress display.
61
+ Prints ✓ Done on success, ✗ Failed on error.
62
+ """
63
+ with RichDownloadProgress() as dp:
64
+ try:
65
+ download(
66
+ url,
67
+ **opts,
68
+ hook=dp.progress_hook,
69
+ postprocessor_hook=dp.postprocessor_hook,
70
+ )
71
+ except Exception as exc:
72
+ console.print(f"\n[bold red]✗ Failed:[/bold red] {_short_error(exc)}")
73
+ if verbose:
74
+ console.print_exception(show_locals=False)
75
+ raise # caller does sys.exit(1)
76
+
77
+ # Progress context has stopped; print the Done message outside it
78
+ if dp.final_path:
79
+ final = Path(dp.final_path).resolve()
80
+ # %(title).80s pads with spaces — strip for display only (file on disk is fine)
81
+ display_name = final.name.strip()
82
+ console.print(
83
+ f"\n[bold green]✓ Done[/bold green] — saved to "
84
+ f"[cyan]{display_name}[/cyan]"
85
+ )
86
+ console.print(f" [dim]{final}[/dim]")
87
+ else:
88
+ console.print("\n[bold green]✓ Done[/bold green]")
89
+
90
+
91
+ # ── Interactive wizard ─────────────────────────────────────────────────────────
92
+
93
+ def _fmt_size(n_bytes) -> str:
94
+ """Format bytes as ~XMB or ~XKB."""
95
+ if n_bytes is None:
96
+ return "?"
97
+ if n_bytes >= 1_048_576:
98
+ return f"~{n_bytes / 1_048_576:.0f}MB"
99
+ return f"~{n_bytes / 1024:.0f}KB"
100
+
101
+
102
+ def _build_format_choices(formats: list, duration: float | None = None) -> list:
103
+ """
104
+ Build a sorted questionary Choice list from yt-dlp format dicts.
105
+ - Video formats sorted by height descending, deduplicated by height.
106
+ - 'Audio only (mp3)' always last.
107
+ - Choice value: (yt-dlp format selector string, audio_only bool)
108
+
109
+ Size = video stream estimate + best audio stream estimate (realistic total).
110
+ """
111
+ def _stream_bytes(f: dict) -> float | None:
112
+ b = f.get("filesize") or f.get("filesize_approx")
113
+ if b:
114
+ return float(b)
115
+ tbr = f.get("tbr")
116
+ if tbr and duration:
117
+ return tbr * 1000 / 8 * duration
118
+ return None
119
+
120
+ best_audio_bytes: float | None = None
121
+ for f in formats:
122
+ if f.get("vcodec", "none") in ("none", None) and f.get("acodec", "none") not in ("none", None):
123
+ ab = _stream_bytes(f)
124
+ if ab and (best_audio_bytes is None or ab > best_audio_bytes):
125
+ best_audio_bytes = ab
126
+
127
+ by_height: dict[int, dict] = {}
128
+ for f in formats:
129
+ vcodec = f.get("vcodec", "none")
130
+ height = f.get("height")
131
+ if vcodec and vcodec != "none" and height:
132
+ existing = by_height.get(height)
133
+ if existing is None or (f.get("tbr") or 0) > (existing.get("tbr") or 0):
134
+ by_height[height] = f
135
+
136
+ choices = []
137
+ for height in sorted(by_height, reverse=True):
138
+ f = by_height[height]
139
+ ext = f.get("ext", "mp4")
140
+ vbytes = _stream_bytes(f)
141
+ total = (vbytes + best_audio_bytes) if (vbytes and best_audio_bytes) else vbytes
142
+ size_str = _fmt_size(total)
143
+ label = f"{height}p ({ext}, {size_str})"
144
+ selector = f"bestvideo[height<={height}]+bestaudio/best[height<={height}]"
145
+ choices.append(questionary.Choice(label, value=(selector, False)))
146
+
147
+ choices.append(questionary.Choice("Audio only (mp3)", value=("bestaudio/best", True)))
148
+ return choices
149
+
150
+
151
+ def _print_download_summary(url: str, mode_label: str, out_dir: str) -> None:
152
+ """Print a rich-formatted summary box before download starts."""
153
+ console.print()
154
+ console.rule("[bold]Video Downloader[/bold]")
155
+ grid = Table.grid(padding=(0, 2))
156
+ grid.add_column(style="dim", justify="right")
157
+ grid.add_column()
158
+ grid.add_row("URL", f"[cyan]{url}[/cyan]")
159
+ grid.add_row("Format", mode_label)
160
+ grid.add_row("Folder", str(Path(out_dir).expanduser().resolve()))
161
+ console.print(grid)
162
+ console.print()
163
+
164
+
165
+ def _run_wizard(cfg: dict) -> dict | None:
166
+ """
167
+ Launch the interactive download wizard.
168
+ Returns a dict of download kwargs, or None if the user aborted.
169
+ """
170
+ # ── Step 1: URL ──────────────────────────────────────────────────────────
171
+ url = questionary.text("Paste the video URL:").ask()
172
+ if not url or not url.strip():
173
+ return None
174
+ url = url.strip()
175
+
176
+ # ── Step 2: Format list ──────────────────────────────────────────────────
177
+ console.print(" [dim]Fetching available formats…[/dim]")
178
+ formats, duration = extract_formats(url)
179
+
180
+ if formats:
181
+ choices = _build_format_choices(formats, duration)
182
+ selection = questionary.select("Choose quality:", choices=choices).ask()
183
+ if selection is None:
184
+ return None
185
+ quality_selector, audio_only = selection
186
+ else:
187
+ console.print(" [dim](Format list unavailable — using simplified menu)[/dim]")
188
+ mode = questionary.select(
189
+ "Download as:",
190
+ choices=["Video (best quality)", "Audio only (mp3)"],
191
+ ).ask()
192
+ if mode is None:
193
+ return None
194
+ audio_only = mode.startswith("Audio")
195
+ quality_selector = "bestaudio/best" if audio_only else "best"
196
+
197
+ # ── Step 3: Output folder ────────────────────────────────────────────────
198
+ default_out = cfg.get("output", "downloads")
199
+ out_dir = questionary.path(
200
+ "Save to folder:", default=default_out, only_directories=True
201
+ ).ask()
202
+ if out_dir is None:
203
+ return None
204
+ out_dir = out_dir.strip() or default_out
205
+
206
+ # ── Step 4: Rich summary before download ─────────────────────────────────
207
+ mode_label = "Audio only (mp3)" if audio_only else f"Video ({quality_selector})"
208
+ _print_download_summary(url, mode_label, out_dir)
209
+
210
+ return dict(
211
+ url = url,
212
+ output_dir = str(Path(out_dir).expanduser()),
213
+ quality = quality_selector,
214
+ audio_only = audio_only,
215
+ audio_format = cfg.get("audio_format", "mp3"),
216
+ retries = int(cfg.get("retries", 3)),
217
+ playlist = False,
218
+ )
219
+
220
+
221
+ # ── vdl-cmd subcommands ────────────────────────────────────────────────────────
222
+
223
+ @cli.command("get")
224
+ @click.argument("url")
225
+ @_common_options
226
+ def cmd_get(url, output, quality, audio, audio_format, playlist, retries, verbose):
227
+ """Download a single video or audio track."""
228
+ if not is_valid_url(url):
229
+ console.print(f"[bold red]Error:[/bold red] '{url}' does not look like a valid URL.")
230
+ sys.exit(1)
231
+ cfg = load_config()
232
+ opts = _resolve_opts(cfg, output, quality, audio, audio_format, playlist, retries)
233
+ _print_download_summary(url, "Audio only (mp3)" if audio else f"Video ({opts['quality']})", opts["output_dir"])
234
+ try:
235
+ _run_download(url, opts, verbose=verbose)
236
+ except Exception:
237
+ sys.exit(1)
238
+
239
+
240
+ @cli.command("batch")
241
+ @click.argument("urlfile", type=click.Path(exists=True, dir_okay=False))
242
+ @_common_options
243
+ def cmd_batch(urlfile, output, quality, audio, audio_format, playlist, retries, verbose):
244
+ """Download all URLs listed in URLFILE (one per line)."""
245
+ cfg = load_config()
246
+ opts = _resolve_opts(cfg, output, quality, audio, audio_format, playlist, retries)
247
+ console.print(f"[bold]Batch downloading from:[/bold] {urlfile}")
248
+ try:
249
+ download_from_file(urlfile, **opts)
250
+ except Exception as exc:
251
+ console.print(f"[bold red]✗ Failed:[/bold red] {_short_error(exc)}")
252
+ if verbose:
253
+ console.print_exception(show_locals=False)
254
+ sys.exit(1)
255
+
256
+
257
+ @cli.command("formats")
258
+ @click.argument("url")
259
+ def cmd_formats(url):
260
+ """List all available formats for URL (like yt-dlp -F)."""
261
+ if not is_valid_url(url):
262
+ console.print(f"[bold red]Error:[/bold red] '{url}' does not look like a valid URL.")
263
+ sys.exit(1)
264
+ list_formats(url)
265
+
266
+
267
+ # ── vdl <url> (top-level shortcut — no URL → interactive wizard) ─────────────
268
+
269
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
270
+ @click.version_option(__version__, "-V", "--version")
271
+ @click.argument("url", required=False, default=None)
272
+ @_common_options
273
+ def main(url, output, quality, audio, audio_format, playlist, retries, verbose):
274
+ """Download a video from any supported site.
275
+
276
+ \b
277
+ Run WITHOUT a URL to launch the interactive wizard:
278
+ vdl
279
+
280
+ Run WITH a URL (and optional flags) for scripting:
281
+ vdl "https://youtu.be/..." -q 720p -o ~/Videos
282
+ """
283
+ cfg = load_config()
284
+
285
+ # ── Interactive wizard (no URL supplied) ──────────────────────────────────
286
+ if url is None:
287
+ wizard_result = _run_wizard(cfg)
288
+ if wizard_result is None:
289
+ console.print("[dim]Aborted.[/dim]")
290
+ sys.exit(0)
291
+ _url = wizard_result.pop("url")
292
+ try:
293
+ _run_download(_url, wizard_result, verbose=verbose)
294
+ except Exception:
295
+ sys.exit(1)
296
+ return
297
+
298
+ # ── Flag-based mode (URL supplied) — unchanged for scripting ─────────────
299
+ if not is_valid_url(url):
300
+ console.print(f"[bold red]Error:[/bold red] '{url}' does not look like a valid URL.")
301
+ sys.exit(1)
302
+ opts = _resolve_opts(cfg, output, quality, audio, audio_format, playlist, retries)
303
+ _print_download_summary(url, "Audio only (mp3)" if audio else f"Video ({opts['quality']})", opts["output_dir"])
304
+ try:
305
+ _run_download(url, opts, verbose=verbose)
306
+ except Exception:
307
+ sys.exit(1)
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ from pathlib import Path
6
+ from typing import Callable, List, Optional
7
+
8
+ import yt_dlp
9
+
10
+ from .utils import progress_hook
11
+
12
+
13
+ def _detect_js_runtimes() -> dict:
14
+ """
15
+ Return a yt-dlp ``js_runtimes`` dict of ``{runtime_name: config_dict}``
16
+ for whatever JS engines are available on this machine.
17
+ Supported names: node, deno, bun, quickjs (checked in priority order).
18
+ """
19
+ runtimes: dict = {}
20
+ for name in ("node", "bun", "deno", "quickjs"):
21
+ path = shutil.which(name)
22
+ if path:
23
+ runtimes[name] = {} # empty config = use discovered binary
24
+ return runtimes
25
+
26
+
27
+ QUALITY_PRESETS: dict[str, str] = {
28
+ "best": "bestvideo+bestaudio/best",
29
+ "1080p": "bestvideo[height<=1080]+bestaudio/best",
30
+ "720p": "bestvideo[height<=720]+bestaudio/best",
31
+ "480p": "bestvideo[height<=480]+bestaudio/best",
32
+ "360p": "bestvideo[height<=360]+bestaudio/best",
33
+ "worst": "worstvideo+worstaudio/worst",
34
+ "audio": "bestaudio/best",
35
+ }
36
+
37
+
38
+ def _resolve_format(quality: str, audio_only: bool) -> str:
39
+ if audio_only:
40
+ return QUALITY_PRESETS["audio"]
41
+ return QUALITY_PRESETS.get(quality, quality)
42
+
43
+
44
+ def extract_formats(url: str) -> tuple[list[dict], float | None]:
45
+ """
46
+ Run yt-dlp in info-only mode (no download) and return
47
+ ``(formats, duration_seconds)`` for *url*.
48
+
49
+ ``formats`` is the raw list of yt-dlp format dicts.
50
+ ``duration`` is the video length in seconds (None if unknown).
51
+
52
+ Returns ``([], None)`` if extraction fails or the site does not expose
53
+ a structured format list. Callers must always handle the empty-list path.
54
+ """
55
+ js_runtimes = _detect_js_runtimes()
56
+ ydl_opts: dict = {
57
+ "quiet": True,
58
+ "no_warnings": True,
59
+ "remote_components": ["ejs:github"],
60
+ }
61
+ if js_runtimes:
62
+ ydl_opts["js_runtimes"] = js_runtimes
63
+ try:
64
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
65
+ info = ydl.extract_info(url, download=False)
66
+ if not info:
67
+ return [], None
68
+ return info.get("formats", []), info.get("duration")
69
+ except Exception:
70
+ return [], None
71
+
72
+
73
+ def download(
74
+ url: str,
75
+ output_dir: str = "downloads",
76
+ audio_only: bool = False,
77
+ audio_format: str = "mp3",
78
+ quality: str = "best",
79
+ playlist: bool = False,
80
+ retries: int = 3,
81
+ extra_opts: Optional[dict] = None,
82
+ hook: Optional[Callable] = None,
83
+ postprocessor_hook: Optional[Callable] = None,
84
+ ) -> None:
85
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
86
+
87
+ fmt = _resolve_format(quality, audio_only)
88
+
89
+ # Auto-detect a JS runtime (node/deno) so yt-dlp can handle YouTube
90
+ js_runtimes = _detect_js_runtimes()
91
+
92
+ ydl_opts: dict = {
93
+ # Truncate title to 80 chars and append the video ID to avoid collisions.
94
+ # Facebook/Instagram use the post caption as the title (no separate short
95
+ # title field), so captions can be 100–300+ chars and include multi-byte
96
+ # Unicode — easily blowing past the 255-byte filename limit (ENAMETOOLONG /
97
+ # OSError errno 36). The ID suffix keeps filenames unique even when two
98
+ # videos share the first 80 characters of their caption.
99
+ "outtmpl": os.path.join(output_dir, "%(title).80s [%(id)s].%(ext)s"),
100
+ # Belt-and-suspenders: hard-cap the final on-disk filename at 150 bytes
101
+ # regardless of template expansion (yt-dlp trims the title portion first).
102
+ "trim_file_name": 150,
103
+ "format": fmt,
104
+ "noplaylist": not playlist,
105
+ "continuedl": True,
106
+ "retries": retries,
107
+ "fragment_retries": retries,
108
+ "progress_hooks": [hook or progress_hook],
109
+ # Silence yt-dlp's own console output — our rich hooks own the display.
110
+ "quiet": True,
111
+ "no_warnings": True,
112
+ "noprogress": True,
113
+ # Allow yt-dlp to fetch the EJS challenge solver from GitHub.
114
+ # This resolves YouTube signature & n-challenge obfuscation warnings.
115
+ "remote_components": ["ejs:github"],
116
+ }
117
+
118
+ if js_runtimes:
119
+ ydl_opts["js_runtimes"] = js_runtimes
120
+
121
+ if postprocessor_hook:
122
+ ydl_opts["postprocessor_hooks"] = [postprocessor_hook]
123
+
124
+ if audio_only:
125
+ ydl_opts["postprocessors"] = [
126
+ {
127
+ "key": "FFmpegExtractAudio",
128
+ "preferredcodec": audio_format,
129
+ }
130
+ ]
131
+ else:
132
+ ydl_opts["merge_output_format"] = "mp4"
133
+
134
+ if extra_opts:
135
+ ydl_opts.update(extra_opts)
136
+
137
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
138
+ ydl.download([url])
139
+
140
+
141
+ def download_batch(urls: List[str], **kwargs) -> None:
142
+ total = len(urls)
143
+ for idx, url in enumerate(urls, start=1):
144
+ print(f"\n[{idx}/{total}] {url}")
145
+ try:
146
+ download(url, **kwargs)
147
+ except yt_dlp.utils.DownloadError as exc:
148
+ print(f" Failed: {exc}")
149
+
150
+
151
+ def download_from_file(filepath: str, **kwargs) -> None:
152
+ path = Path(filepath)
153
+ if not path.exists():
154
+ raise FileNotFoundError(f"URL file not found: {filepath}")
155
+
156
+ urls = [
157
+ line.strip()
158
+ for line in path.read_text().splitlines()
159
+ if line.strip() and not line.strip().startswith("#")
160
+ ]
161
+
162
+ if not urls:
163
+ print("No URLs found in file.")
164
+ return
165
+
166
+ download_batch(urls, **kwargs)
@@ -0,0 +1,300 @@
1
+ """
2
+ utils.py — helpers for progress display, URL validation, format listing.
3
+ """
4
+
5
+ import re
6
+ import os
7
+ import sys
8
+ import configparser
9
+ import yt_dlp
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+ from rich.progress import (
14
+ BarColumn,
15
+ Progress,
16
+ ProgressColumn,
17
+ SpinnerColumn,
18
+ TaskProgressColumn,
19
+ TextColumn,
20
+ Task,
21
+ )
22
+ from rich.text import Text
23
+
24
+
25
+ # ── Legacy plain-text hook (used by test_manual.py and batch mode) ────────────
26
+
27
+ def progress_hook(d: dict) -> None:
28
+ """Plain-text yt-dlp progress callback (fallback for non-rich contexts)."""
29
+ status = d.get("status")
30
+
31
+ if status == "downloading":
32
+ percent = d.get("_percent_str", "?%").strip()
33
+ speed = d.get("_speed_str", "?/s").strip()
34
+ eta = d.get("_eta_str", "?s").strip()
35
+ downloaded = d.get("_downloaded_bytes_str", "").strip()
36
+ total = d.get("_total_bytes_str", "") or d.get("_total_bytes_estimate_str", "")
37
+ total = total.strip()
38
+ size_info = f"{downloaded}/{total}" if total else downloaded
39
+ sys.stdout.write(f"\r {percent:>6} {size_info} @ {speed} ETA {eta} ")
40
+ sys.stdout.flush()
41
+
42
+ elif status == "finished":
43
+ sys.stdout.write("\n Done. Post-processing...\n")
44
+ sys.stdout.flush()
45
+
46
+ elif status == "error":
47
+ sys.stdout.write("\n Download error.\n")
48
+ sys.stdout.flush()
49
+
50
+
51
+ # ── Rich progress columns ─────────────────────────────────────────────────────
52
+
53
+ class _ConditionalPercentColumn(TaskProgressColumn):
54
+ """Percentage column — hidden for indeterminate tasks (total is None)."""
55
+ def render(self, task: Task) -> Text: # type: ignore[override]
56
+ if task.total is None:
57
+ return Text("")
58
+ return super().render(task)
59
+
60
+
61
+ class _ConditionalSpeedColumn(ProgressColumn):
62
+ """Speed column — hidden for indeterminate tasks; uses yt-dlp's string."""
63
+ def render(self, task: Task) -> Text:
64
+ if task.total is None:
65
+ return Text("")
66
+ speed = (task.fields.get("speed") or "").strip()
67
+ if not speed or "Unknown" in speed:
68
+ return Text("")
69
+ return Text(speed, style="bold green")
70
+
71
+
72
+ class _ConditionalEtaColumn(ProgressColumn):
73
+ """ETA column — hidden for indeterminate tasks; uses yt-dlp's string."""
74
+ def render(self, task: Task) -> Text:
75
+ if task.total is None:
76
+ return Text("")
77
+ eta = (task.fields.get("eta") or "").strip()
78
+ if not eta or "Unknown" in eta:
79
+ return Text("")
80
+ return Text(f"ETA {eta}", style="yellow")
81
+
82
+
83
+ # ── Rich download progress context manager ────────────────────────────────────
84
+
85
+ class RichDownloadProgress:
86
+ """
87
+ Context manager that renders a clean Rich progress display for yt-dlp.
88
+
89
+ Provides two callbacks to pass into ``core.download()``:
90
+ - ``.progress_hook`` — replaces the plain-text progress_hook
91
+ - ``.postprocessor_hook`` — tracks ffmpeg merge / audio-extraction stages
92
+
93
+ After the ``with`` block exits, read ``.final_path`` for the absolute path
94
+ of the saved file (updated by postprocessor_hook so it reflects the final
95
+ converted file, e.g. the ``.mp3`` not the intermediate ``.webm``).
96
+
97
+ Visual layout per task::
98
+
99
+ ⠋ Downloading… ━━━━━━━━━━━━━━━━━━━━ 57% • 3.2MB/s ETA 0:00:03
100
+ ⠋ Merging video and audio… ━━━━━╸━━━━ (pulsing, no %)
101
+
102
+ DASH downloads (separate video + audio streams) produce two download bars
103
+ that each reach 100%; the postprocessor bar is then removed before the
104
+ Progress stops so the final printed state is clean.
105
+ """
106
+
107
+ def __init__(self) -> None:
108
+ self.final_path: str | None = None
109
+ self._task_id: int | None = None
110
+ self._pp_task_id: int | None = None
111
+
112
+ self._progress = Progress(
113
+ SpinnerColumn(finished_text="[green]✓[/green]"),
114
+ TextColumn("[bold]{task.description}"),
115
+ BarColumn(bar_width=38, style="cyan", complete_style="bright_cyan"),
116
+ _ConditionalPercentColumn(),
117
+ TextColumn("[dim]•[/dim]"),
118
+ _ConditionalSpeedColumn(),
119
+ _ConditionalEtaColumn(),
120
+ transient=False,
121
+ console=Console(stderr=False, highlight=False),
122
+ )
123
+
124
+ def __enter__(self) -> "RichDownloadProgress":
125
+ self._progress.start()
126
+ return self
127
+
128
+ def __exit__(self, *args: object) -> None:
129
+ self._progress.stop()
130
+
131
+ # ── yt-dlp callbacks ──────────────────────────────────────────────────────
132
+
133
+ def progress_hook(self, d: dict) -> None:
134
+ """yt-dlp progress_hook — updates the download bar.
135
+
136
+ Stream labelling:
137
+ - If the stream has a real video codec (vcodec != 'none') → "Downloading video…"
138
+ - Otherwise (audio-only DASH stream) → "Downloading audio…"
139
+ - If vcodec info is unavailable (direct-link sites) → "Downloading…"
140
+
141
+ The label is set once when the task is created and preserved on every
142
+ subsequent update tick (we never pass `description=` during updates).
143
+ """
144
+ status = d.get("status")
145
+
146
+ if status == "downloading":
147
+ total = d.get("total_bytes") or d.get("total_bytes_estimate")
148
+ downloaded = float(d.get("downloaded_bytes", 0))
149
+ speed = (d.get("_speed_str") or "").strip()
150
+ eta = (d.get("_eta_str") or "").strip()
151
+
152
+ if self._task_id is None:
153
+ # Determine stream type from info_dict on the FIRST call for this stream
154
+ info = d.get("info_dict") or {}
155
+ vcodec = info.get("vcodec", "")
156
+ if vcodec and vcodec != "none":
157
+ label = "Downloading video…"
158
+ elif vcodec == "none":
159
+ label = "Downloading audio…"
160
+ else:
161
+ label = "Downloading…" # fallback for sites with muxed streams
162
+
163
+ self._task_id = self._progress.add_task(
164
+ label,
165
+ total=float(total) if total else 100.0,
166
+ speed=speed,
167
+ eta=eta,
168
+ )
169
+ else:
170
+ # Update progress only — do NOT pass description so the label is preserved
171
+ kw: dict = dict(completed=downloaded, speed=speed, eta=eta)
172
+ if total:
173
+ kw["total"] = float(total)
174
+ self._progress.update(self._task_id, **kw)
175
+
176
+ elif status == "finished":
177
+ if self._task_id is not None:
178
+ task = self._progress.tasks[self._task_id]
179
+ self._progress.update(
180
+ self._task_id,
181
+ completed=task.total or 100.0,
182
+ speed="",
183
+ eta="",
184
+ )
185
+ # Reset so the next DASH stream gets its own fresh bar
186
+ self._task_id = None
187
+
188
+ # Tentative final path — may be overwritten by postprocessor_hook
189
+ info = d.get("info_dict", {})
190
+ path = info.get("filepath") or info.get("filename") or d.get("filename", "")
191
+ if path:
192
+ self.final_path = path
193
+
194
+ def postprocessor_hook(self, d: dict) -> None:
195
+ """yt-dlp postprocessor_hook — single spinner task for ffmpeg stages.
196
+
197
+ yt-dlp fires 'started'/'finished' multiple times for the same logical
198
+ postprocessor (e.g. ExtractAudio fires twice internally). We guard with
199
+ ``if self._pp_task_id is None`` so only ONE task is ever created per
200
+ download, no matter how many started events arrive.
201
+
202
+ Lifecycle:
203
+ started (first call) → create spinner task (total=None, animates)
204
+ started (duplicates) → no-op (task already exists)
205
+ finished (any call) → mark task complete (total=1) → ✓ checkmark
206
+ capture final filepath
207
+ MoveFiles finished → update final_path, task already ✓ so no change
208
+ """
209
+ status = d.get("status")
210
+ pp_name = d.get("postprocessor", "")
211
+
212
+ if status == "started":
213
+ # Determine label from the first meaningful postprocessor we see
214
+ if "ExtractAudio" in pp_name:
215
+ label = "Converting to mp3…"
216
+ elif "Merger" in pp_name:
217
+ label = "Merging video and audio…"
218
+ else:
219
+ return # ignore MoveFiles, metadata writers, etc.
220
+
221
+ # ONLY create the task once — guard against duplicate 'started' events
222
+ if self._pp_task_id is None:
223
+ # total=None → Rich renders pulsing indeterminate bar + spinning icon
224
+ self._pp_task_id = self._progress.add_task(
225
+ label,
226
+ total=None,
227
+ speed="",
228
+ eta="",
229
+ )
230
+
231
+ elif status == "finished":
232
+ if self._pp_task_id is not None:
233
+ # Mark complete → SpinnerColumn renders the green ✓ checkmark
234
+ self._progress.update(
235
+ self._pp_task_id,
236
+ total=1,
237
+ completed=1,
238
+ speed="",
239
+ eta="",
240
+ )
241
+ # Remove the task so the final printed state only shows the ✓ rows
242
+ # from the download phase (keeps the output clean)
243
+ self._progress.remove_task(self._pp_task_id)
244
+ self._pp_task_id = None
245
+
246
+ # Overwrite with the TRUE final path (e.g. .mp3 after audio extraction,
247
+ # final location after MoveFiles)
248
+ info = d.get("info_dict", {})
249
+ path = info.get("filepath") or info.get("filename") or ""
250
+ if path:
251
+ self.final_path = path
252
+
253
+
254
+ # ── URL validation ────────────────────────────────────────────────────────────
255
+
256
+ _URL_RE = re.compile(
257
+ r"^(https?://)?"
258
+ r"([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}"
259
+ r"(/[^\s]*)?$",
260
+ re.IGNORECASE,
261
+ )
262
+
263
+
264
+ def is_valid_url(url: str) -> bool:
265
+ """Return True if *url* looks like a plausible HTTP(S) URL."""
266
+ return bool(_URL_RE.match(url.strip()))
267
+
268
+
269
+ # ── Format listing ────────────────────────────────────────────────────────────
270
+
271
+ def list_formats(url: str) -> None:
272
+ """Print all available formats for *url*, mimicking ``yt-dlp -F``."""
273
+ ydl_opts = {
274
+ "listformats": True,
275
+ "quiet": False,
276
+ "no_warnings": False,
277
+ }
278
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
279
+ ydl.download([url])
280
+
281
+
282
+ # ── Config loader ─────────────────────────────────────────────────────────────
283
+
284
+ _CONFIG_PATH = Path.home() / ".vdlrc"
285
+
286
+
287
+ def load_config() -> dict:
288
+ """
289
+ Load ``~/.vdlrc`` (INI format, ``[defaults]`` section).
290
+ Returns a plain dict; missing keys are absent.
291
+ """
292
+ cfg = configparser.ConfigParser()
293
+ if _CONFIG_PATH.exists():
294
+ cfg.read(_CONFIG_PATH)
295
+
296
+ defaults: dict = {}
297
+ if cfg.has_section("defaults"):
298
+ for key, val in cfg.items("defaults"):
299
+ defaults[key] = os.path.expanduser(val)
300
+ return defaults
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "snagvid"
7
+ version = "0.1.0"
8
+ description = "Download videos from YouTube, X/Twitter, Instagram, Facebook, or almost any website straight from your terminal."
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "Waqas" }
12
+ ]
13
+ license = "MIT"
14
+ requires-python = ">=3.9"
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Multimedia :: Video",
26
+ ]
27
+ dependencies = [
28
+ "yt-dlp>=2024.1.1",
29
+ "click>=8.1",
30
+ "questionary>=2.0",
31
+ "rich>=13.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/WaqasAhmad313/snagvid"
36
+ Repository = "https://github.com/WaqasAhmad313/snagvid"
37
+ Issues = "https://github.com/WaqasAhmad313/snagvid/issues"
38
+
39
+ [project.scripts]
40
+ vdl = "downloader.cli:main"
41
+ vdl-cmd = "downloader.cli:cli"
42
+
43
+ [tool.setuptools.packages.find]
44
+ include = ["downloader*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: snagvid
3
+ Version: 0.1.0
4
+ Summary: Download videos from YouTube, X/Twitter, Instagram, Facebook, or almost any website straight from your terminal.
5
+ Author: Waqas
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/WaqasAhmad313/snagvid
8
+ Project-URL: Repository, https://github.com/WaqasAhmad313/snagvid
9
+ Project-URL: Issues, https://github.com/WaqasAhmad313/snagvid/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Multimedia :: Video
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: yt-dlp>=2024.1.1
24
+ Requires-Dist: click>=8.1
25
+ Requires-Dist: questionary>=2.0
26
+ Requires-Dist: rich>=13.0
27
+ Dynamic: license-file
28
+
29
+ # snagvid (`vdl`)
30
+
31
+ Download videos from YouTube, X/Twitter, Instagram, Facebook, or almost any website — straight from your terminal, no browser extensions, no sketchy websites.
32
+
33
+ [![PyPI version](https://img.shields.io/pypi/v/snagvid.svg)](https://pypi.org/project/snagvid/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
36
+
37
+ ![demo](docs/demo.gif)
38
+
39
+ ## Features
40
+
41
+ - 🧙 **Interactive CLI Wizard**: Simply type `vdl` to get guided prompts for URL, resolution, and output folder.
42
+ - 📊 **Smart Resolution & Size Detection**: Shows available resolutions with accurate combined video + audio download size estimates.
43
+ - 🎵 **Audio Extraction**: Easily extract high-quality audio tracks (e.g. MP3) directly.
44
+ - 🎨 **Clean Live Progress**: Real-time progress display with speed, ETA, and spinner animations powered by `rich`.
45
+ - 🌐 **1800+ Supported Sites**: Powered by `yt-dlp` for unmatched platform compatibility.
46
+ - ⚡ **Scripting Ready**: Pass URLs and flags directly (`vdl "<url>" -q 720p`) for automation.
47
+ - 🛡️ **Long Title Protection**: Automatic title truncation and ID tagging to prevent filesystem errors on long post captions.
48
+
49
+ ## Prerequisites
50
+
51
+ - **Python**: 3.9 or higher
52
+ - **FFmpeg**: Required for stream merging and audio extraction.
53
+ - **Linux**: `sudo apt install ffmpeg` / `sudo pacman -S ffmpeg`
54
+ - **macOS**: `brew install ffmpeg`
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install snagvid
60
+ # or with pipx for isolated global installation:
61
+ pipx install snagvid
62
+ ```
63
+
64
+ ### Local Development Install
65
+
66
+ ```bash
67
+ git clone https://github.com/WaqasAhmad313/snagvid.git
68
+ cd snagvid
69
+ python3 -m venv .venv
70
+ source .venv/bin/activate
71
+ pip install -e .
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ### Interactive Mode (Recommended)
77
+
78
+ Run `vdl` with no arguments to start the interactive wizard:
79
+
80
+ ```bash
81
+ vdl
82
+ ```
83
+
84
+ ### Direct / Scripted Usage
85
+
86
+ Pass arguments directly to bypass the interactive prompts:
87
+
88
+ ```bash
89
+ # Direct download best quality
90
+ vdl "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
91
+
92
+ # Download audio-only (MP3)
93
+ vdl "https://www.youtube.com/watch?v=dQw4w9WgXcQ" --audio
94
+
95
+ # Download specific quality to custom directory
96
+ vdl "https://www.youtube.com/watch?v=dQw4w9WgXcQ" -q 720p -o ~/Videos
97
+ ```
98
+
99
+ ### Configuration (`~/.vdlrc`)
100
+
101
+ You can save your default preferences in `~/.vdlrc` (INI format):
102
+
103
+ ```ini
104
+ [defaults]
105
+ output = ~/Videos
106
+ quality = 1080p
107
+ audio_format = mp3
108
+ retries = 5
109
+ ```
110
+
111
+ ## Supported Sites
112
+
113
+ `vdl` inherits complete extractor support from `yt-dlp`, covering 1800+ websites including YouTube, X/Twitter, Instagram, Facebook, TikTok, Twitch, Reddit, Vimeo, and SoundCloud.
114
+
115
+ For a full list of supported extractors, see the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supported_sites.md).
116
+
117
+ ## Responsible Use
118
+
119
+ This tool is intended for downloading content you own, have explicit permission to use, or that is otherwise legally accessible to you — e.g. personal backups, offline access to your own posts, content licensed for reuse. Respect the terms of service of the platform you're downloading from and applicable copyright law. The maintainers are not responsible for misuse.
120
+
121
+ ## Contributing
122
+
123
+ Contributions are welcome!
124
+
125
+ 1. Fork the repository
126
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
127
+ 3. Commit your changes (`git commit -m 'Add amazing feature'`)
128
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
129
+ 5. Open a Pull Request
130
+
131
+ ## License
132
+
133
+ Distributed under the MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ downloader/__init__.py
5
+ downloader/cli.py
6
+ downloader/core.py
7
+ downloader/utils.py
8
+ snagvid.egg-info/PKG-INFO
9
+ snagvid.egg-info/SOURCES.txt
10
+ snagvid.egg-info/dependency_links.txt
11
+ snagvid.egg-info/entry_points.txt
12
+ snagvid.egg-info/requires.txt
13
+ snagvid.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ vdl = downloader.cli:main
3
+ vdl-cmd = downloader.cli:cli
@@ -0,0 +1,4 @@
1
+ yt-dlp>=2024.1.1
2
+ click>=8.1
3
+ questionary>=2.0
4
+ rich>=13.0
@@ -0,0 +1 @@
1
+ downloader