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 +21 -0
- snagvid-0.1.0/PKG-INFO +133 -0
- snagvid-0.1.0/README.md +105 -0
- snagvid-0.1.0/downloader/__init__.py +5 -0
- snagvid-0.1.0/downloader/cli.py +307 -0
- snagvid-0.1.0/downloader/core.py +166 -0
- snagvid-0.1.0/downloader/utils.py +300 -0
- snagvid-0.1.0/pyproject.toml +44 -0
- snagvid-0.1.0/setup.cfg +4 -0
- snagvid-0.1.0/snagvid.egg-info/PKG-INFO +133 -0
- snagvid-0.1.0/snagvid.egg-info/SOURCES.txt +13 -0
- snagvid-0.1.0/snagvid.egg-info/dependency_links.txt +1 -0
- snagvid-0.1.0/snagvid.egg-info/entry_points.txt +3 -0
- snagvid-0.1.0/snagvid.egg-info/requires.txt +4 -0
- snagvid-0.1.0/snagvid.egg-info/top_level.txt +1 -0
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
|
+
[](https://pypi.org/project/snagvid/)
|
|
34
|
+
[](https://opensource.org/licenses/MIT)
|
|
35
|
+
[](https://www.python.org/downloads/)
|
|
36
|
+
|
|
37
|
+

|
|
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.
|
snagvid-0.1.0/README.md
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/snagvid/)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://www.python.org/downloads/)
|
|
8
|
+
|
|
9
|
+

|
|
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,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*"]
|
snagvid-0.1.0/setup.cfg
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
|
+
[](https://pypi.org/project/snagvid/)
|
|
34
|
+
[](https://opensource.org/licenses/MIT)
|
|
35
|
+
[](https://www.python.org/downloads/)
|
|
36
|
+
|
|
37
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
downloader
|