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.
Files changed (55) hide show
  1. {evo_cli-0.11.2 → evo_cli-0.11.4}/PKG-INFO +23 -1
  2. {evo_cli-0.11.2 → evo_cli-0.11.4}/README.md +21 -0
  3. evo_cli-0.11.4/evo_cli/VERSION +1 -0
  4. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/cli.py +4 -0
  5. evo_cli-0.11.4/evo_cli/commands/download.py +552 -0
  6. evo_cli-0.11.4/evo_cli/commands/harness.py +213 -0
  7. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/PKG-INFO +23 -1
  8. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/SOURCES.txt +4 -0
  9. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/requires.txt +1 -0
  10. {evo_cli-0.11.2 → evo_cli-0.11.4}/pyproject.toml +1 -0
  11. evo_cli-0.11.4/tests/test_download.py +212 -0
  12. evo_cli-0.11.4/tests/test_harness.py +136 -0
  13. evo_cli-0.11.2/evo_cli/VERSION +0 -1
  14. {evo_cli-0.11.2 → evo_cli-0.11.4}/Containerfile +0 -0
  15. {evo_cli-0.11.2 → evo_cli-0.11.4}/HISTORY.md +0 -0
  16. {evo_cli-0.11.2 → evo_cli-0.11.4}/LICENSE +0 -0
  17. {evo_cli-0.11.2 → evo_cli-0.11.4}/MANIFEST.in +0 -0
  18. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/__init__.py +0 -0
  19. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/__main__.py +0 -0
  20. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/base.py +0 -0
  21. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/__init__.py +0 -0
  22. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/claude_code.py +0 -0
  23. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/cloudflare.py +0 -0
  24. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/fix_claude.py +0 -0
  25. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/gdrive.py +0 -0
  26. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/gh.py +0 -0
  27. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/hwid.py +0 -0
  28. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/hwid_reset.py +0 -0
  29. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/localproxy.py +0 -0
  30. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/mcp.py +0 -0
  31. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/miniconda.py +0 -0
  32. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/netcheck.py +0 -0
  33. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/opencode.py +0 -0
  34. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/plantuml.py +0 -0
  35. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/site2s.py +0 -0
  36. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/ssh.py +0 -0
  37. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/sysmon.py +0 -0
  38. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/commands/wifi.py +0 -0
  39. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/console.py +0 -0
  40. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli/mcp_registry.py +0 -0
  41. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/dependency_links.txt +0 -0
  42. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/entry_points.txt +0 -0
  43. {evo_cli-0.11.2 → evo_cli-0.11.4}/evo_cli.egg-info/top_level.txt +0 -0
  44. {evo_cli-0.11.2 → evo_cli-0.11.4}/setup.cfg +0 -0
  45. {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/__init__.py +0 -0
  46. {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_claude_code.py +0 -0
  47. {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_cli.py +0 -0
  48. {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_console.py +0 -0
  49. {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_fix_claude.py +0 -0
  50. {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_gh.py +0 -0
  51. {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_mcp.py +0 -0
  52. {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_opencode.py +0 -0
  53. {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_plantuml.py +0 -0
  54. {evo_cli-0.11.2 → evo_cli-0.11.4}/tests/test_sysmon.py +0 -0
  55. {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.2
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]")