evo-cli 0.11.2__tar.gz → 0.11.4__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.
- {evo_cli-0.11.2 → evo_cli-0.11.4}/PKG-INFO +23 -1
- {evo_cli-0.11.2 → evo_cli-0.11.4}/README.md +21 -0
- evo_cli-0.11.4/evo_cli/VERSION +1 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/cli.py +4 -0
- evo_cli-0.11.4/evo_cli/commands/download.py +552 -0
- evo_cli-0.11.4/evo_cli/commands/harness.py +213 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/PKG-INFO +23 -1
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/SOURCES.txt +4 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/requires.txt +1 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/pyproject.toml +1 -0
- evo_cli-0.11.4/tests/test_download.py +212 -0
- evo_cli-0.11.4/tests/test_harness.py +136 -0
- evo_cli-0.11.2/evo_cli/VERSION +0 -1
- {evo_cli-0.11.2 → evo_cli-0.11.4}/Containerfile +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/HISTORY.md +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/LICENSE +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/MANIFEST.in +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/__init__.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/__main__.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/base.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/console.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/setup.cfg +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/__init__.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_claude_code.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_cli.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_console.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_gh.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_mcp.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_opencode.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_plantuml.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_sysmon.py +0 -0
- {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_wifi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evo_cli
|
|
3
|
-
Version: 0.11.
|
|
3
|
+
Version: 0.11.4
|
|
4
4
|
Summary: Evolution CLI - a developer toolbox for setting up dev machines
|
|
5
5
|
Author: maycuatroi
|
|
6
6
|
Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
|
|
@@ -16,6 +16,7 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
License-File: LICENSE
|
|
17
17
|
Requires-Dist: cryptography>=41.0
|
|
18
18
|
Requires-Dist: paramiko>=2.7.0
|
|
19
|
+
Requires-Dist: PyYAML>=6.0
|
|
19
20
|
Requires-Dist: rich>=13.0
|
|
20
21
|
Requires-Dist: rich-click>=1.8
|
|
21
22
|
Provides-Extra: test
|
|
@@ -112,3 +113,24 @@ Options:
|
|
|
112
113
|
- `-y, --yes` - Skip the confirmation prompt
|
|
113
114
|
- `-f, --force` - Apply the fix even if the version is not in the affected range
|
|
114
115
|
- `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
|
|
116
|
+
|
|
117
|
+
#### Harness Repositories
|
|
118
|
+
|
|
119
|
+
Fast-forward every available repository declared in a harness manifest:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
evo harness pull
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Run the command from a harness directory or any registered member repository. Use `--harness PATH`
|
|
126
|
+
when a repository belongs to multiple harnesses or the harness is not registered.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
evo harness pull --harness ~/github/my-project-harness
|
|
130
|
+
evo harness pull --repo backend --repo frontend
|
|
131
|
+
evo harness pull --dry-run
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
|
|
135
|
+
`present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
|
|
136
|
+
uses `git pull --ff-only` so the command never creates merge commits.
|
|
@@ -85,3 +85,24 @@ Options:
|
|
|
85
85
|
- `-y, --yes` - Skip the confirmation prompt
|
|
86
86
|
- `-f, --force` - Apply the fix even if the version is not in the affected range
|
|
87
87
|
- `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
|
|
88
|
+
|
|
89
|
+
#### Harness Repositories
|
|
90
|
+
|
|
91
|
+
Fast-forward every available repository declared in a harness manifest:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
evo harness pull
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Run the command from a harness directory or any registered member repository. Use `--harness PATH`
|
|
98
|
+
when a repository belongs to multiple harnesses or the harness is not registered.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
evo harness pull --harness ~/github/my-project-harness
|
|
102
|
+
evo harness pull --repo backend --repo frontend
|
|
103
|
+
evo harness pull --dry-run
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The command reads `harness.yaml` and its optional `harness.local.yaml` overlay. Repositories marked
|
|
107
|
+
`present: false` are skipped. Repositories with uncommitted changes are not modified, and every pull
|
|
108
|
+
uses `git pull --ff-only` so the command never creates merge commits.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.11.4
|
|
@@ -3,9 +3,11 @@ import rich_click as click
|
|
|
3
3
|
from evo_cli import __version__
|
|
4
4
|
from evo_cli.commands.claude_code import setup_claude
|
|
5
5
|
from evo_cli.commands.cloudflare import cfssh
|
|
6
|
+
from evo_cli.commands.download import download
|
|
6
7
|
from evo_cli.commands.fix_claude import f_claude
|
|
7
8
|
from evo_cli.commands.gdrive import gdrive
|
|
8
9
|
from evo_cli.commands.gh import setup_gh
|
|
10
|
+
from evo_cli.commands.harness import harness_group
|
|
9
11
|
from evo_cli.commands.hwid import hwid
|
|
10
12
|
from evo_cli.commands.hwid_reset import hwid_reset
|
|
11
13
|
from evo_cli.commands.localproxy import localproxy
|
|
@@ -54,8 +56,10 @@ setup_group.add_command(setupssh)
|
|
|
54
56
|
|
|
55
57
|
cli.add_command(setup_group)
|
|
56
58
|
cli.add_command(cfssh)
|
|
59
|
+
cli.add_command(download)
|
|
57
60
|
cli.add_command(f_claude)
|
|
58
61
|
cli.add_command(gdrive)
|
|
62
|
+
cli.add_command(harness_group)
|
|
59
63
|
cli.add_command(hwid)
|
|
60
64
|
cli.add_command(hwid_reset)
|
|
61
65
|
cli.add_command(site2s)
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import unquote, urlparse
|
|
8
|
+
|
|
9
|
+
import rich_click as click
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from evo_cli.console import (
|
|
15
|
+
console,
|
|
16
|
+
download_file,
|
|
17
|
+
info,
|
|
18
|
+
run_command,
|
|
19
|
+
step,
|
|
20
|
+
success,
|
|
21
|
+
warning,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
QUALITIES = ["best", "2160p", "1440p", "1080p", "720p", "480p", "360p", "worst"]
|
|
25
|
+
AUDIO_FORMATS = ["mp3", "m4a", "opus", "flac", "wav", "aac", "vorbis", "best"]
|
|
26
|
+
CONTAINERS = ["mp4", "mkv", "webm", "auto"]
|
|
27
|
+
BROWSERS = ["chrome", "edge", "firefox", "brave", "chromium", "opera", "vivaldi", "safari"]
|
|
28
|
+
JS_RUNTIMES = ["deno", "bun", "node"]
|
|
29
|
+
|
|
30
|
+
DIRECT_EXTS = {
|
|
31
|
+
".7z", ".apk", ".appimage", ".bin", ".bz2", ".csv", ".deb", ".dmg", ".doc", ".docx",
|
|
32
|
+
".epub", ".exe", ".gz", ".img", ".iso", ".jar", ".json", ".msi", ".pdf", ".ppt",
|
|
33
|
+
".pptx", ".rar", ".rpm", ".tar", ".tgz", ".txt", ".whl", ".xls", ".xlsx", ".xz",
|
|
34
|
+
".zip", ".zst",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
SITES = [
|
|
38
|
+
("YouTube", "video, playlist, live, shorts, music"),
|
|
39
|
+
("TikTok / Douyin", "video, no watermark"),
|
|
40
|
+
("Facebook", "video, reels, watch"),
|
|
41
|
+
("Instagram", "post, reels, stories (cần --cookies)"),
|
|
42
|
+
("X / Twitter", "video, spaces"),
|
|
43
|
+
("Twitch", "VOD, clip, live"),
|
|
44
|
+
("Vimeo", "video (cần --cookies nếu private)"),
|
|
45
|
+
("SoundCloud", "track, playlist"),
|
|
46
|
+
("Bilibili", "video, bangumi"),
|
|
47
|
+
("Reddit", "video + audio"),
|
|
48
|
+
("Dailymotion", "video"),
|
|
49
|
+
("Direct URL", ".zip .pdf .exe .iso ... tải thẳng"),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
FFMPEG_PACKAGES = {
|
|
53
|
+
"apt-get": "ffmpeg",
|
|
54
|
+
"dnf": "ffmpeg",
|
|
55
|
+
"yum": "ffmpeg",
|
|
56
|
+
"pacman": "ffmpeg",
|
|
57
|
+
"zypper": "ffmpeg",
|
|
58
|
+
"brew": "ffmpeg",
|
|
59
|
+
"winget": "Gyan.FFmpeg",
|
|
60
|
+
"choco": "ffmpeg",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
EPILOG = Text.from_markup(
|
|
64
|
+
"[bold]Examples[/bold]\n\n"
|
|
65
|
+
" [cyan]evo download <url>[/cyan] best quality, merged to MP4\n"
|
|
66
|
+
" [cyan]evo download <url> -q 1080p[/cyan] cap the resolution\n"
|
|
67
|
+
" [cyan]evo download <url> -a[/cyan] audio only, MP3\n"
|
|
68
|
+
" [cyan]evo download <url> -a --audio-format flac[/cyan] lossless audio\n"
|
|
69
|
+
" [cyan]evo download <url> -o D:\\Videos[/cyan] pick the output folder\n"
|
|
70
|
+
" [cyan]evo download <url> -s en,vi --thumbnail[/cyan] embed subtitles + cover art\n"
|
|
71
|
+
" [cyan]evo download <url> --section 10:00-15:00[/cyan] cut a clip out of a long video\n"
|
|
72
|
+
" [cyan]evo download <url> -p --archive[/cyan] whole playlist, skip what you have\n"
|
|
73
|
+
" [cyan]evo download <url> --cookies chrome[/cyan] private / age-gated / member-only\n"
|
|
74
|
+
" [cyan]evo download formats <url>[/cyan] list every available stream\n"
|
|
75
|
+
" [cyan]evo download check[/cyan] verify yt-dlp + ffmpeg\n"
|
|
76
|
+
" [cyan]evo download sites[/cyan] common supported sources\n\n"
|
|
77
|
+
"[dim]Powered by yt-dlp (1800+ sites) with ffmpeg for merging. Plain file URLs\n"
|
|
78
|
+
"(.zip, .pdf, .iso, ...) are fetched directly instead.[/dim]"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def default_output_dir():
|
|
83
|
+
env = os.environ.get("EVO_DOWNLOAD_DIR")
|
|
84
|
+
if env:
|
|
85
|
+
return Path(env)
|
|
86
|
+
downloads = Path.home() / "Downloads"
|
|
87
|
+
if downloads.is_dir():
|
|
88
|
+
return downloads
|
|
89
|
+
return Path.cwd()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ytdlp_command():
|
|
93
|
+
exe = shutil.which("yt-dlp")
|
|
94
|
+
if exe:
|
|
95
|
+
return [exe]
|
|
96
|
+
if importlib.util.find_spec("yt_dlp") is not None:
|
|
97
|
+
return [sys.executable, "-m", "yt_dlp"]
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ytdlp_version():
|
|
102
|
+
cmd = ytdlp_command()
|
|
103
|
+
if not cmd:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
result = subprocess.run(cmd + ["--version"], capture_output=True, text=True)
|
|
107
|
+
except OSError:
|
|
108
|
+
return None
|
|
109
|
+
return (result.stdout or "").strip() or None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def find_ffmpeg():
|
|
113
|
+
return shutil.which("ffmpeg")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ffmpeg_version():
|
|
117
|
+
exe = find_ffmpeg()
|
|
118
|
+
if not exe:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
result = subprocess.run([exe, "-version"], capture_output=True, text=True)
|
|
122
|
+
except OSError:
|
|
123
|
+
return None
|
|
124
|
+
first = ((result.stdout or "") + (result.stderr or "")).strip().splitlines()
|
|
125
|
+
return first[0] if first else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def find_js_runtime():
|
|
129
|
+
for name in JS_RUNTIMES:
|
|
130
|
+
path = shutil.which(name)
|
|
131
|
+
if path:
|
|
132
|
+
return name, path
|
|
133
|
+
return None, None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def install_ytdlp():
|
|
137
|
+
run_command([sys.executable, "-m", "pip", "install", "-U", "yt-dlp"], status="Installing yt-dlp")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def detect_package_manager():
|
|
141
|
+
for manager in ("apt-get", "dnf", "yum", "pacman", "zypper", "brew", "winget", "choco"):
|
|
142
|
+
if shutil.which(manager):
|
|
143
|
+
return manager
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def install_ffmpeg(assume_yes):
|
|
148
|
+
manager = detect_package_manager()
|
|
149
|
+
if not manager:
|
|
150
|
+
warning("No supported package manager found. Install ffmpeg manually.")
|
|
151
|
+
return False
|
|
152
|
+
package = FFMPEG_PACKAGES[manager]
|
|
153
|
+
if manager == "winget":
|
|
154
|
+
cmd = ["winget", "install", "-e", "--id", package]
|
|
155
|
+
elif manager == "brew":
|
|
156
|
+
cmd = ["brew", "install", package]
|
|
157
|
+
elif manager == "choco":
|
|
158
|
+
cmd = ["choco", "install", package] + (["-y"] if assume_yes else [])
|
|
159
|
+
elif manager == "pacman":
|
|
160
|
+
cmd = ["pacman", "-S", "--noconfirm" if assume_yes else "--needed", package]
|
|
161
|
+
else:
|
|
162
|
+
cmd = [manager, "install"] + (["-y"] if assume_yes else []) + [package]
|
|
163
|
+
if os.name != "nt" and manager not in ("brew",) and os.geteuid() != 0:
|
|
164
|
+
cmd = ["sudo"] + cmd
|
|
165
|
+
result = run_command(cmd, check=False)
|
|
166
|
+
return result.returncode == 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def ensure_ytdlp(assume_yes=False):
|
|
170
|
+
cmd = ytdlp_command()
|
|
171
|
+
if cmd:
|
|
172
|
+
return cmd
|
|
173
|
+
warning("yt-dlp is not installed.")
|
|
174
|
+
if not assume_yes and not click.confirm("Install yt-dlp now with pip?", default=True):
|
|
175
|
+
raise click.ClickException("yt-dlp is required. Install it with: pip install -U yt-dlp")
|
|
176
|
+
install_ytdlp()
|
|
177
|
+
cmd = ytdlp_command()
|
|
178
|
+
if not cmd:
|
|
179
|
+
raise click.ClickException("yt-dlp was installed but could not be found. Check your PATH.")
|
|
180
|
+
return cmd
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def format_selector(quality, container, has_ffmpeg):
|
|
184
|
+
if quality == "worst":
|
|
185
|
+
return "wv*+wa/w" if has_ffmpeg else "w"
|
|
186
|
+
height = None if quality == "best" else quality.rstrip("p")
|
|
187
|
+
limit = f"[height<={height}]" if height else ""
|
|
188
|
+
if not has_ffmpeg:
|
|
189
|
+
return f"b{limit}/b"
|
|
190
|
+
if container == "mp4":
|
|
191
|
+
return f"bv*{limit}[ext=mp4]+ba[ext=m4a]/bv*{limit}+ba/b{limit}/b"
|
|
192
|
+
return f"bv*{limit}+ba/b{limit}/b"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def output_template(outdir, playlist, name):
|
|
196
|
+
if name:
|
|
197
|
+
template = name
|
|
198
|
+
elif playlist:
|
|
199
|
+
template = "%(playlist_title)s/%(playlist_index)03d - %(title)s.%(ext)s"
|
|
200
|
+
else:
|
|
201
|
+
template = "%(title)s.%(ext)s"
|
|
202
|
+
return str(Path(outdir) / template)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def is_direct_file(url):
|
|
206
|
+
suffix = Path(unquote(urlparse(url).path)).suffix.lower()
|
|
207
|
+
return suffix in DIRECT_EXTS
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def direct_filename(url):
|
|
211
|
+
name = Path(unquote(urlparse(url).path)).name
|
|
212
|
+
return name or "download.bin"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def build_ytdlp_args(opts, has_ffmpeg):
|
|
216
|
+
args = []
|
|
217
|
+
runtime, _ = find_js_runtime()
|
|
218
|
+
if runtime and runtime != "deno":
|
|
219
|
+
args += ["--js-runtimes", runtime]
|
|
220
|
+
|
|
221
|
+
if opts["list_formats"]:
|
|
222
|
+
args += ["-F", "--no-playlist"]
|
|
223
|
+
return args
|
|
224
|
+
|
|
225
|
+
args += ["-o", output_template(opts["outdir"], opts["playlist"], opts["name"])]
|
|
226
|
+
args += ["--no-playlist"] if not opts["playlist"] else ["--yes-playlist"]
|
|
227
|
+
args += ["-N", str(opts["concurrent"])]
|
|
228
|
+
|
|
229
|
+
if opts["fmt"]:
|
|
230
|
+
args += ["-f", opts["fmt"]]
|
|
231
|
+
elif opts["audio_only"]:
|
|
232
|
+
args += ["-f", "ba/b", "-x", "--audio-format", opts["audio_format"], "--audio-quality", "0"]
|
|
233
|
+
else:
|
|
234
|
+
args += ["-f", format_selector(opts["quality"], opts["container"], has_ffmpeg)]
|
|
235
|
+
if has_ffmpeg and opts["container"] != "auto":
|
|
236
|
+
args += ["--merge-output-format", opts["container"]]
|
|
237
|
+
|
|
238
|
+
if has_ffmpeg:
|
|
239
|
+
args += ["--embed-metadata", "--embed-chapters"]
|
|
240
|
+
if opts["thumbnail"]:
|
|
241
|
+
args += ["--embed-thumbnail"]
|
|
242
|
+
if opts["subs"]:
|
|
243
|
+
args += ["--sub-langs", opts["subs"], "--write-subs", "--write-auto-subs"]
|
|
244
|
+
if has_ffmpeg:
|
|
245
|
+
args += ["--embed-subs"]
|
|
246
|
+
if opts["sponsorblock"]:
|
|
247
|
+
args += ["--sponsorblock-remove", "default"]
|
|
248
|
+
if opts["cookies"]:
|
|
249
|
+
args += ["--cookies-from-browser", opts["cookies"]]
|
|
250
|
+
if opts["archive"]:
|
|
251
|
+
args += ["--download-archive", str(Path(opts["outdir"]) / ".evo-download-archive.txt")]
|
|
252
|
+
if opts["section"]:
|
|
253
|
+
args += ["--download-sections", f"*{opts['section']}", "--force-keyframes-at-cuts"]
|
|
254
|
+
if opts["ascii_names"]:
|
|
255
|
+
args += ["--restrict-filenames"]
|
|
256
|
+
if opts["limit_rate"]:
|
|
257
|
+
args += ["-r", opts["limit_rate"]]
|
|
258
|
+
return args
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def report_environment():
|
|
262
|
+
version = ytdlp_version()
|
|
263
|
+
ffmpeg = ffmpeg_version()
|
|
264
|
+
runtime, runtime_path = find_js_runtime()
|
|
265
|
+
console.print()
|
|
266
|
+
table = Table(show_header=True, header_style="accent", expand=False)
|
|
267
|
+
table.add_column("Component", style="info", no_wrap=True)
|
|
268
|
+
table.add_column("Status", no_wrap=True)
|
|
269
|
+
table.add_column("Detail", style="dim")
|
|
270
|
+
table.add_row(
|
|
271
|
+
"yt-dlp",
|
|
272
|
+
"[success]ok[/success]" if version else "[error]missing[/error]",
|
|
273
|
+
version or "run `evo download install`",
|
|
274
|
+
)
|
|
275
|
+
table.add_row(
|
|
276
|
+
"ffmpeg",
|
|
277
|
+
"[success]ok[/success]" if ffmpeg else "[error]missing[/error]",
|
|
278
|
+
ffmpeg or "needed to merge video + audio and convert audio",
|
|
279
|
+
)
|
|
280
|
+
table.add_row(
|
|
281
|
+
"JS runtime",
|
|
282
|
+
"[success]ok[/success]" if runtime else "[warning]missing[/warning]",
|
|
283
|
+
f"{runtime} ({runtime_path})" if runtime else "optional - some sites need it to extract streams",
|
|
284
|
+
)
|
|
285
|
+
table.add_row("Output dir", "[info]-[/info]", str(default_output_dir()))
|
|
286
|
+
console.print(table)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def do_direct(url, outdir):
|
|
290
|
+
target = Path(outdir) / direct_filename(url)
|
|
291
|
+
Path(outdir).mkdir(parents=True, exist_ok=True)
|
|
292
|
+
info(f"Direct download: [accent]{url}[/accent]")
|
|
293
|
+
try:
|
|
294
|
+
download_file(url, str(target), target.name)
|
|
295
|
+
except OSError as exc:
|
|
296
|
+
raise click.ClickException(f"Download failed: {exc}")
|
|
297
|
+
success(f"Saved to [accent]{target}[/accent]")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def do_get(urls, opts, assume_yes):
|
|
301
|
+
if not urls:
|
|
302
|
+
raise click.ClickException("Give me at least one URL.")
|
|
303
|
+
|
|
304
|
+
outdir = Path(opts["outdir"])
|
|
305
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
|
|
307
|
+
direct = [u for u in urls if is_direct_file(u)]
|
|
308
|
+
media = [u for u in urls if u not in direct]
|
|
309
|
+
|
|
310
|
+
for url in direct:
|
|
311
|
+
do_direct(url, outdir)
|
|
312
|
+
|
|
313
|
+
if not media:
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
exe = ensure_ytdlp(assume_yes)
|
|
317
|
+
has_ffmpeg = bool(find_ffmpeg())
|
|
318
|
+
if not has_ffmpeg:
|
|
319
|
+
if opts["audio_only"]:
|
|
320
|
+
raise click.ClickException(
|
|
321
|
+
"ffmpeg is required to extract audio. Run `evo download install --with-deps`."
|
|
322
|
+
)
|
|
323
|
+
warning("ffmpeg not found: falling back to single-file streams (lower quality).")
|
|
324
|
+
info("Install it with `evo download install --with-deps` for the best quality.")
|
|
325
|
+
|
|
326
|
+
cmd = exe + build_ytdlp_args(opts, has_ffmpeg) + list(media)
|
|
327
|
+
if opts["dry_run"]:
|
|
328
|
+
console.print(f"[cmd]$ {escape(' '.join(cmd))}[/cmd]")
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
result = run_command(cmd, check=False)
|
|
332
|
+
if result.returncode != 0:
|
|
333
|
+
raise click.ClickException("yt-dlp failed. Try `evo download formats <url>` to inspect the source.")
|
|
334
|
+
if not opts["list_formats"]:
|
|
335
|
+
success(f"Saved to [accent]{outdir}[/accent]")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class DownloadGroup(click.RichGroup):
|
|
339
|
+
def parse_args(self, ctx, args):
|
|
340
|
+
if args and args[0] not in self.commands and args[0] not in ("-h", "--help"):
|
|
341
|
+
args = ["get", *args]
|
|
342
|
+
return super().parse_args(ctx, args)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@click.group(
|
|
346
|
+
"download",
|
|
347
|
+
cls=DownloadGroup,
|
|
348
|
+
epilog=EPILOG,
|
|
349
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
350
|
+
help=(
|
|
351
|
+
"Download video / audio from **common sources**.\n\n"
|
|
352
|
+
"Wraps `yt-dlp` (YouTube, TikTok, Facebook, Instagram, X, Twitch, Vimeo, "
|
|
353
|
+
"SoundCloud, Bilibili, Reddit, ... 1800+ sites) and uses `ffmpeg` to merge "
|
|
354
|
+
"video + audio into a single file. Plain file URLs are fetched directly.\n\n"
|
|
355
|
+
"`evo download <url>` is a shortcut for `evo download get <url>`."
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
def download():
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def get_options(func):
|
|
363
|
+
options = [
|
|
364
|
+
click.option("-o", "--output", "outdir", type=click.Path(file_okay=False),
|
|
365
|
+
default=None, help="Output folder. Default: `~/Downloads` (or `$EVO_DOWNLOAD_DIR`)."),
|
|
366
|
+
click.option("-q", "--quality", type=click.Choice(QUALITIES), default="best",
|
|
367
|
+
show_default=True, help="Cap the video resolution."),
|
|
368
|
+
click.option("-c", "--container", type=click.Choice(CONTAINERS), default="mp4",
|
|
369
|
+
show_default=True, help="Container to merge into. `auto` keeps whatever the site serves."),
|
|
370
|
+
click.option("-a", "--audio-only", is_flag=True, help="Grab the audio track only."),
|
|
371
|
+
click.option("--audio-format", type=click.Choice(AUDIO_FORMATS), default="mp3",
|
|
372
|
+
show_default=True, help="Audio codec to convert to with `-a`."),
|
|
373
|
+
click.option("-f", "--format", "fmt", default=None,
|
|
374
|
+
help="Raw yt-dlp format selector, overrides `-q` / `-a`."),
|
|
375
|
+
click.option("-p", "--playlist", is_flag=True,
|
|
376
|
+
help="Download the whole playlist / channel, not just the one video."),
|
|
377
|
+
click.option("-s", "--subs", default=None, metavar="LANGS",
|
|
378
|
+
help="Download and embed subtitles, e.g. `en,vi` or `all`."),
|
|
379
|
+
click.option("--thumbnail", is_flag=True, help="Embed the thumbnail as cover art."),
|
|
380
|
+
click.option("--sponsorblock", is_flag=True, help="Cut sponsor segments out (YouTube)."),
|
|
381
|
+
click.option("--cookies", type=click.Choice(BROWSERS), default=None,
|
|
382
|
+
help="Load cookies from a browser for private / age-gated / member-only media."),
|
|
383
|
+
click.option("--archive", is_flag=True,
|
|
384
|
+
help="Record what was downloaded and skip it next time."),
|
|
385
|
+
click.option("--section", default=None, metavar="RANGE",
|
|
386
|
+
help="Download only a time range, e.g. `10:00-15:00`."),
|
|
387
|
+
click.option("--ascii", "ascii_names", is_flag=True,
|
|
388
|
+
help="Restrict filenames to ASCII (no spaces or diacritics)."),
|
|
389
|
+
click.option("-N", "--concurrent", type=int, default=4, show_default=True,
|
|
390
|
+
help="Fragments to download in parallel."),
|
|
391
|
+
click.option("-r", "--limit-rate", default=None, metavar="RATE",
|
|
392
|
+
help="Cap the download speed, e.g. `2M`."),
|
|
393
|
+
click.option("--name", default=None, metavar="TEMPLATE",
|
|
394
|
+
help="yt-dlp output template, e.g. `%(id)s.%(ext)s`."),
|
|
395
|
+
click.option("-y", "--yes", "assume_yes", is_flag=True, help="Assume yes for install prompts."),
|
|
396
|
+
click.option("--dry-run", is_flag=True, help="Print the yt-dlp command instead of running it."),
|
|
397
|
+
]
|
|
398
|
+
for option in reversed(options):
|
|
399
|
+
func = option(func)
|
|
400
|
+
return func
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def collect_opts(outdir, quality, container, audio_only, audio_format, fmt, playlist, subs,
|
|
404
|
+
thumbnail, sponsorblock, cookies, archive, section, ascii_names, concurrent,
|
|
405
|
+
limit_rate, name, dry_run, list_formats=False):
|
|
406
|
+
return {
|
|
407
|
+
"outdir": Path(outdir) if outdir else default_output_dir(),
|
|
408
|
+
"quality": quality,
|
|
409
|
+
"container": container,
|
|
410
|
+
"audio_only": audio_only,
|
|
411
|
+
"audio_format": audio_format,
|
|
412
|
+
"fmt": fmt,
|
|
413
|
+
"playlist": playlist,
|
|
414
|
+
"subs": subs,
|
|
415
|
+
"thumbnail": thumbnail,
|
|
416
|
+
"sponsorblock": sponsorblock,
|
|
417
|
+
"cookies": cookies,
|
|
418
|
+
"archive": archive,
|
|
419
|
+
"section": section,
|
|
420
|
+
"ascii_names": ascii_names,
|
|
421
|
+
"concurrent": concurrent,
|
|
422
|
+
"limit_rate": limit_rate,
|
|
423
|
+
"name": name,
|
|
424
|
+
"dry_run": dry_run,
|
|
425
|
+
"list_formats": list_formats,
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@download.command(
|
|
430
|
+
"get",
|
|
431
|
+
epilog=EPILOG,
|
|
432
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
433
|
+
help=(
|
|
434
|
+
"Download one or more `URLS`.\n\n"
|
|
435
|
+
"Picks the best video + audio and merges them into a single MP4 next to your "
|
|
436
|
+
"other downloads. Use `-q` to cap the resolution, `-a` for audio only, and "
|
|
437
|
+
"`--cookies <browser>` when the media needs a login."
|
|
438
|
+
),
|
|
439
|
+
)
|
|
440
|
+
@click.argument("urls", nargs=-1, required=True, metavar="URLS...")
|
|
441
|
+
@get_options
|
|
442
|
+
def get_cmd(urls, outdir, quality, container, audio_only, audio_format, fmt, playlist, subs,
|
|
443
|
+
thumbnail, sponsorblock, cookies, archive, section, ascii_names, concurrent,
|
|
444
|
+
limit_rate, name, assume_yes, dry_run):
|
|
445
|
+
step("evo download")
|
|
446
|
+
opts = collect_opts(outdir, quality, container, audio_only, audio_format, fmt, playlist,
|
|
447
|
+
subs, thumbnail, sponsorblock, cookies, archive, section, ascii_names,
|
|
448
|
+
concurrent, limit_rate, name, dry_run)
|
|
449
|
+
do_get(list(urls), opts, assume_yes)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@download.command(
|
|
453
|
+
"audio",
|
|
454
|
+
epilog=EPILOG,
|
|
455
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
456
|
+
help="Download `URLS` as audio only. Shortcut for `evo download get -a`.",
|
|
457
|
+
)
|
|
458
|
+
@click.argument("urls", nargs=-1, required=True, metavar="URLS...")
|
|
459
|
+
@click.option("-o", "--output", "outdir", type=click.Path(file_okay=False), default=None,
|
|
460
|
+
help="Output folder. Default: `~/Downloads`.")
|
|
461
|
+
@click.option("--audio-format", type=click.Choice(AUDIO_FORMATS), default="mp3",
|
|
462
|
+
show_default=True, help="Audio codec to convert to.")
|
|
463
|
+
@click.option("-p", "--playlist", is_flag=True, help="Download the whole playlist.")
|
|
464
|
+
@click.option("--thumbnail", is_flag=True, help="Embed the thumbnail as cover art.")
|
|
465
|
+
@click.option("--cookies", type=click.Choice(BROWSERS), default=None, help="Load cookies from a browser.")
|
|
466
|
+
@click.option("-y", "--yes", "assume_yes", is_flag=True, help="Assume yes for install prompts.")
|
|
467
|
+
def audio_cmd(urls, outdir, audio_format, playlist, thumbnail, cookies, assume_yes):
|
|
468
|
+
step("evo download audio")
|
|
469
|
+
opts = collect_opts(outdir, "best", "auto", True, audio_format, None, playlist, None,
|
|
470
|
+
thumbnail, False, cookies, False, None, False, 4, None, None, False)
|
|
471
|
+
do_get(list(urls), opts, assume_yes)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@download.command(
|
|
475
|
+
"formats",
|
|
476
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
477
|
+
help="List every stream a `URL` offers, so you can pick one with `get -f <id>`.",
|
|
478
|
+
)
|
|
479
|
+
@click.argument("url")
|
|
480
|
+
@click.option("--cookies", type=click.Choice(BROWSERS), default=None, help="Load cookies from a browser.")
|
|
481
|
+
@click.option("-y", "--yes", "assume_yes", is_flag=True, help="Assume yes for install prompts.")
|
|
482
|
+
def formats_cmd(url, cookies, assume_yes):
|
|
483
|
+
step("evo download formats")
|
|
484
|
+
opts = collect_opts(None, "best", "auto", False, "mp3", None, False, None, False, False,
|
|
485
|
+
cookies, False, None, False, 4, None, None, False, list_formats=True)
|
|
486
|
+
do_get([url], opts, assume_yes)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@download.command(
|
|
490
|
+
"install",
|
|
491
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
492
|
+
help=(
|
|
493
|
+
"Install or update **yt-dlp** (via pip).\n\n"
|
|
494
|
+
"With `--with-deps` it also installs **ffmpeg** using whichever package manager "
|
|
495
|
+
"it detects (apt, dnf, pacman, brew, winget, choco)."
|
|
496
|
+
),
|
|
497
|
+
)
|
|
498
|
+
@click.option("--with-deps", is_flag=True, help="Also install ffmpeg.")
|
|
499
|
+
@click.option("-y", "--yes", "assume_yes", is_flag=True, help="Assume yes for package-manager prompts.")
|
|
500
|
+
def install_cmd(with_deps, assume_yes):
|
|
501
|
+
step("evo download install")
|
|
502
|
+
install_ytdlp()
|
|
503
|
+
if with_deps:
|
|
504
|
+
if find_ffmpeg():
|
|
505
|
+
info("ffmpeg already present.")
|
|
506
|
+
else:
|
|
507
|
+
info("Installing [accent]ffmpeg[/accent]")
|
|
508
|
+
install_ffmpeg(assume_yes)
|
|
509
|
+
report_environment()
|
|
510
|
+
if ytdlp_version():
|
|
511
|
+
success("Ready to download.")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@download.command(
|
|
515
|
+
"check",
|
|
516
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
517
|
+
help="Verify that yt-dlp, ffmpeg and a JS runtime are available.",
|
|
518
|
+
)
|
|
519
|
+
def check_cmd():
|
|
520
|
+
step("evo download check")
|
|
521
|
+
report_environment()
|
|
522
|
+
if not ytdlp_version():
|
|
523
|
+
raise click.ClickException("yt-dlp is missing. Run `evo download install`.")
|
|
524
|
+
if not find_ffmpeg():
|
|
525
|
+
warning("ffmpeg missing: merging and audio conversion will not work.")
|
|
526
|
+
info("Fix it with `evo download install --with-deps`.")
|
|
527
|
+
return
|
|
528
|
+
if not find_js_runtime()[0]:
|
|
529
|
+
warning("No JS runtime (deno / node / bun): a few sites may hand back fewer formats.")
|
|
530
|
+
success("Ready to download.")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@download.command(
|
|
534
|
+
"sites",
|
|
535
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
536
|
+
help="Show the common sources this command handles.",
|
|
537
|
+
)
|
|
538
|
+
@click.option("--all", "show_all", is_flag=True, help="List every extractor yt-dlp supports.")
|
|
539
|
+
def sites_cmd(show_all):
|
|
540
|
+
step("evo download sites")
|
|
541
|
+
if show_all:
|
|
542
|
+
exe = ensure_ytdlp(True)
|
|
543
|
+
run_command(exe + ["--list-extractors"], check=False)
|
|
544
|
+
return
|
|
545
|
+
table = Table(show_header=True, header_style="accent", expand=False)
|
|
546
|
+
table.add_column("Source", style="info", no_wrap=True)
|
|
547
|
+
table.add_column("Notes", style="dim")
|
|
548
|
+
for name, note in SITES:
|
|
549
|
+
table.add_row(name, note)
|
|
550
|
+
console.print(table)
|
|
551
|
+
console.print()
|
|
552
|
+
info("yt-dlp supports 1800+ sites. Full list: [accent]evo download sites --all[/accent]")
|