evo-cli 0.20.2__tar.gz → 0.21.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.
- {evo_cli-0.20.2 → evo_cli-0.21.0}/PKG-INFO +1 -1
- evo_cli-0.21.0/evo_cli/VERSION +1 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/download.py +191 -9
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_download.py +125 -0
- evo_cli-0.20.2/evo_cli/VERSION +0 -1
- {evo_cli-0.20.2 → evo_cli-0.21.0}/Containerfile +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/HISTORY.md +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/LICENSE +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/MANIFEST.in +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/README.md +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/base.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/cli.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/__init__.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/_model.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/_mutate.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/_server.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/check.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/clone.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/edit.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/index-CsALFg16.css +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/index-DwEXL5pV.js +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/serp.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/console.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/credentials/registry.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/serp/__init__.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/serp/client.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/serp/creds.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/serp/errors.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/serp/install.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/serp/render.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli.egg-info/SOURCES.txt +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/pyproject.toml +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/setup.cfg +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/__init__.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_claude_code.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_cli.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_console.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_cred.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_gh.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_harness.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_harness_clone.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_mcp.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_opencode.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_plantuml.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_serp.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_sysmon.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_tts.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_update.py +0 -0
- {evo_cli-0.20.2 → evo_cli-0.21.0}/tests/test_wifi.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.21.0
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import datetime
|
|
2
2
|
import importlib.util
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import shutil
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
8
|
+
import tempfile
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
from urllib.parse import unquote, urlparse
|
|
9
11
|
|
|
@@ -28,6 +30,17 @@ CONTAINERS = ["mp4", "mkv", "webm", "auto"]
|
|
|
28
30
|
BROWSERS = ["chrome", "edge", "firefox", "brave", "chromium", "opera", "vivaldi", "safari"]
|
|
29
31
|
JS_RUNTIMES = ["deno", "bun", "node"]
|
|
30
32
|
YTDLP_STALE_DAYS = 60
|
|
33
|
+
# Codecs an MP4 is actually expected to carry. Facebook reels and YouTube serve
|
|
34
|
+
# their best streams as VP9 / AV1, and muxing those into MP4 gives a file that
|
|
35
|
+
# QuickTime, Photos and iMovie open with sound but a black picture - the picture
|
|
36
|
+
# is there, macOS just has no VP9 decoder. Anything outside this set gets
|
|
37
|
+
# re-encoded to H.264 so the download plays everywhere.
|
|
38
|
+
PLAYABLE_VCODECS = frozenset({"h264", "hevc", "mpeg4", "mjpeg", "prores"})
|
|
39
|
+
PLAYABLE_ACODECS = frozenset({"aac", "mp3", "ac3", "eac3", "alac"})
|
|
40
|
+
# VP9 and AV1 fit the same picture into fewer bits than H.264 does, so matching
|
|
41
|
+
# the source bitrate would visibly soften the result.
|
|
42
|
+
H264_BITRATE_HEADROOM = 1.5
|
|
43
|
+
MIN_H264_BITRATE = 1_000_000
|
|
31
44
|
# Facebook reels and Instagram posts hand back the whole caption as the title,
|
|
32
45
|
# which walks straight past the 255-byte limit filesystems put on a single path
|
|
33
46
|
# component. `.<N>B` truncates the field to N bytes (before yt-dlp swaps `:` and
|
|
@@ -107,6 +120,7 @@ EPILOG = Text.from_markup(
|
|
|
107
120
|
" [cyan]evo download <url> --section 10:00-15:00[/cyan] cut a clip out of a long video\n"
|
|
108
121
|
" [cyan]evo download <url> -p --archive[/cyan] whole playlist, skip what you have\n"
|
|
109
122
|
" [cyan]evo download <url> --cookies chrome[/cyan] private / age-gated / member-only\n"
|
|
123
|
+
" [cyan]evo download <url> --keep-codec[/cyan] keep VP9 / AV1, skip the H.264 pass\n"
|
|
110
124
|
" [cyan]evo download formats <url>[/cyan] list every available stream\n"
|
|
111
125
|
" [cyan]evo download check[/cyan] verify yt-dlp + ffmpeg\n"
|
|
112
126
|
" [cyan]evo download sites[/cyan] common supported sources\n\n"
|
|
@@ -193,6 +207,137 @@ def ffmpeg_version():
|
|
|
193
207
|
return first[0] if first else None
|
|
194
208
|
|
|
195
209
|
|
|
210
|
+
def find_ffprobe():
|
|
211
|
+
return shutil.which("ffprobe")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def probe_media(path):
|
|
215
|
+
"""Codecs and bitrate of `path`, or None when ffprobe cannot read it."""
|
|
216
|
+
exe = find_ffprobe()
|
|
217
|
+
if not exe:
|
|
218
|
+
return None
|
|
219
|
+
cmd = [
|
|
220
|
+
exe,
|
|
221
|
+
"-v",
|
|
222
|
+
"error",
|
|
223
|
+
"-show_entries",
|
|
224
|
+
"stream=codec_type,codec_name,bit_rate:format=bit_rate,duration",
|
|
225
|
+
"-of",
|
|
226
|
+
"json",
|
|
227
|
+
str(path),
|
|
228
|
+
]
|
|
229
|
+
try:
|
|
230
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
231
|
+
except OSError:
|
|
232
|
+
return None
|
|
233
|
+
if result.returncode != 0:
|
|
234
|
+
return None
|
|
235
|
+
try:
|
|
236
|
+
probed = json.loads(result.stdout or "{}")
|
|
237
|
+
except ValueError:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
streams = probed.get("streams") or []
|
|
241
|
+
video = next((s for s in streams if s.get("codec_type") == "video"), None)
|
|
242
|
+
if not video:
|
|
243
|
+
return None
|
|
244
|
+
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
245
|
+
return {
|
|
246
|
+
"vcodec": video.get("codec_name"),
|
|
247
|
+
"acodec": audio.get("codec_name") if audio else None,
|
|
248
|
+
"vbitrate": int_or_none(video.get("bit_rate")) or int_or_none(probed.get("format", {}).get("bit_rate")),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def int_or_none(value):
|
|
253
|
+
try:
|
|
254
|
+
return int(value)
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def h264_encoder():
|
|
260
|
+
"""The fastest H.264 encoder available. VideoToolbox is hardware-backed and
|
|
261
|
+
always present on macOS; elsewhere the software encoder is the safe bet,
|
|
262
|
+
since an nvenc/qsv build only works when the matching GPU is there too."""
|
|
263
|
+
if sys.platform == "darwin":
|
|
264
|
+
exe = find_ffmpeg()
|
|
265
|
+
try:
|
|
266
|
+
result = subprocess.run([exe, "-v", "error", "-encoders"], capture_output=True, text=True)
|
|
267
|
+
except OSError:
|
|
268
|
+
return "libx264"
|
|
269
|
+
if "h264_videotoolbox" in (result.stdout or ""):
|
|
270
|
+
return "h264_videotoolbox"
|
|
271
|
+
return "libx264"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def transcode_args(source, target, encoder, media):
|
|
275
|
+
# `-map 0` keeps every stream and the embedded metadata; only the video
|
|
276
|
+
# track goes through the encoder, the rest is copied as-is.
|
|
277
|
+
args = [find_ffmpeg(), "-v", "warning", "-stats", "-y", "-i", str(source), "-map", "0"]
|
|
278
|
+
args += ["-c:v", encoder, "-pix_fmt", "yuv420p"]
|
|
279
|
+
if encoder == "libx264":
|
|
280
|
+
args += ["-crf", "20", "-preset", "veryfast"]
|
|
281
|
+
else:
|
|
282
|
+
bitrate = media.get("vbitrate")
|
|
283
|
+
target_bitrate = max(int(bitrate * H264_BITRATE_HEADROOM), MIN_H264_BITRATE) if bitrate else MIN_H264_BITRATE
|
|
284
|
+
args += ["-b:v", str(target_bitrate)]
|
|
285
|
+
if media.get("acodec") in PLAYABLE_ACODECS:
|
|
286
|
+
args += ["-c:a", "copy"]
|
|
287
|
+
else:
|
|
288
|
+
args += ["-c:a", "aac", "-b:a", "192k"]
|
|
289
|
+
args += ["-c:s", "copy", "-movflags", "+faststart", str(target)]
|
|
290
|
+
return args
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def make_playable(path):
|
|
294
|
+
"""Re-encode `path` to H.264 when its video codec is one MP4 players choke on.
|
|
295
|
+
|
|
296
|
+
Returns True when the file was converted, False when it was already fine or
|
|
297
|
+
the conversion did not work out (in which case the download is left alone).
|
|
298
|
+
"""
|
|
299
|
+
path = Path(path)
|
|
300
|
+
if not path.is_file():
|
|
301
|
+
return False
|
|
302
|
+
media = probe_media(path)
|
|
303
|
+
if not media or media["vcodec"] in PLAYABLE_VCODECS:
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
warning(f"{path.name}: {media['vcodec']} in MP4 does not play in QuickTime / Photos.")
|
|
307
|
+
# A short temp name next to the download: the title can already be near the
|
|
308
|
+
# file name limit, and os.replace is only atomic within one filesystem.
|
|
309
|
+
target = path.with_name(f".evo-recode-{os.getpid()}{path.suffix}")
|
|
310
|
+
encoder = h264_encoder()
|
|
311
|
+
info(f"Converting to H.264 with [accent]{encoder}[/accent] (keeps the resolution).")
|
|
312
|
+
result = run_command(transcode_args(path, target, encoder, media), check=False)
|
|
313
|
+
if result.returncode != 0 and encoder != "libx264":
|
|
314
|
+
warning(f"{encoder} failed, retrying with libx264.")
|
|
315
|
+
encoder = "libx264"
|
|
316
|
+
result = run_command(transcode_args(path, target, encoder, media), check=False)
|
|
317
|
+
if result.returncode != 0 or not target.is_file():
|
|
318
|
+
target.unlink(missing_ok=True)
|
|
319
|
+
warning("Conversion failed. Keeping the original file - play it with VLC or IINA.")
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
os.replace(target, path)
|
|
323
|
+
success(f"Converted [accent]{path.name}[/accent] to H.264.")
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def downloaded_paths(manifest):
|
|
328
|
+
"""Files yt-dlp reported writing, deduplicated and in order."""
|
|
329
|
+
try:
|
|
330
|
+
lines = Path(manifest).read_text(encoding="utf-8").splitlines()
|
|
331
|
+
except OSError:
|
|
332
|
+
return []
|
|
333
|
+
seen = {}
|
|
334
|
+
for line in lines:
|
|
335
|
+
line = line.strip()
|
|
336
|
+
if line:
|
|
337
|
+
seen[line] = None
|
|
338
|
+
return list(seen)
|
|
339
|
+
|
|
340
|
+
|
|
196
341
|
def find_js_runtime():
|
|
197
342
|
for name in JS_RUNTIMES:
|
|
198
343
|
path = shutil.which(name)
|
|
@@ -282,7 +427,7 @@ def direct_filename(url):
|
|
|
282
427
|
return name or "download.bin"
|
|
283
428
|
|
|
284
429
|
|
|
285
|
-
def build_ytdlp_args(opts, has_ffmpeg):
|
|
430
|
+
def build_ytdlp_args(opts, has_ffmpeg, manifest=None):
|
|
286
431
|
args = []
|
|
287
432
|
runtime, _ = find_js_runtime()
|
|
288
433
|
if runtime and runtime != "deno":
|
|
@@ -293,6 +438,8 @@ def build_ytdlp_args(opts, has_ffmpeg):
|
|
|
293
438
|
return args
|
|
294
439
|
|
|
295
440
|
args += ["-o", output_template(opts["outdir"], opts["playlist"], opts["name"])]
|
|
441
|
+
if manifest:
|
|
442
|
+
args += ["--print-to-file", "after_move:filepath", str(manifest)]
|
|
296
443
|
args += ["--no-playlist"] if not opts["playlist"] else ["--yes-playlist"]
|
|
297
444
|
args += ["-N", str(opts["concurrent"])]
|
|
298
445
|
|
|
@@ -328,6 +475,21 @@ def build_ytdlp_args(opts, has_ffmpeg):
|
|
|
328
475
|
return args
|
|
329
476
|
|
|
330
477
|
|
|
478
|
+
def wants_playable_output(opts, has_ffmpeg):
|
|
479
|
+
"""Whether the H.264 conversion applies to this run.
|
|
480
|
+
|
|
481
|
+
Only for MP4, the container people pick precisely because it plays
|
|
482
|
+
everywhere - `-c auto` / `webm` / `mkv` mean the codec was not the point.
|
|
483
|
+
"""
|
|
484
|
+
return (
|
|
485
|
+
has_ffmpeg
|
|
486
|
+
and not opts["keep_codec"]
|
|
487
|
+
and not opts["audio_only"]
|
|
488
|
+
and not opts["list_formats"]
|
|
489
|
+
and opts["container"] == "mp4"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
331
493
|
def report_environment():
|
|
332
494
|
version = ytdlp_version()
|
|
333
495
|
ffmpeg = ffmpeg_version()
|
|
@@ -395,18 +557,29 @@ def do_get(urls, opts, assume_yes):
|
|
|
395
557
|
warning("ffmpeg not found: falling back to single-file streams (lower quality).")
|
|
396
558
|
info("Install it with `evo download install --with-deps` for the best quality.")
|
|
397
559
|
|
|
398
|
-
|
|
560
|
+
recoding = wants_playable_output(opts, has_ffmpeg)
|
|
561
|
+
manifest = Path(tempfile.mkdtemp(prefix="evo-download-")) / "files.txt" if recoding else None
|
|
562
|
+
|
|
563
|
+
cmd = exe + build_ytdlp_args(opts, has_ffmpeg, manifest) + list(media)
|
|
399
564
|
if opts["dry_run"]:
|
|
400
565
|
console.print(f"[cmd]$ {escape(' '.join(cmd))}[/cmd]")
|
|
401
566
|
return
|
|
402
567
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
568
|
+
try:
|
|
569
|
+
result = run_command(cmd, check=False)
|
|
570
|
+
if result.returncode != 0:
|
|
571
|
+
if ytdlp_stale_age() is not None:
|
|
572
|
+
raise click.ClickException(
|
|
573
|
+
"yt-dlp failed and the installed build is stale. Run `evo download install`, then try again."
|
|
574
|
+
)
|
|
575
|
+
raise click.ClickException("yt-dlp failed. Try `evo download formats <url>` to inspect the source.")
|
|
576
|
+
if manifest:
|
|
577
|
+
for path in downloaded_paths(manifest):
|
|
578
|
+
make_playable(path)
|
|
579
|
+
finally:
|
|
580
|
+
if manifest:
|
|
581
|
+
shutil.rmtree(manifest.parent, ignore_errors=True)
|
|
582
|
+
|
|
410
583
|
if not opts["list_formats"]:
|
|
411
584
|
success(f"Saved to [accent]{outdir}[/accent]")
|
|
412
585
|
|
|
@@ -491,6 +664,11 @@ def get_options(func):
|
|
|
491
664
|
click.option(
|
|
492
665
|
"--ascii", "ascii_names", is_flag=True, help="Restrict filenames to ASCII (no spaces or diacritics)."
|
|
493
666
|
),
|
|
667
|
+
click.option(
|
|
668
|
+
"--keep-codec",
|
|
669
|
+
is_flag=True,
|
|
670
|
+
help="Keep the codec the site served instead of converting VP9 / AV1 to H.264.",
|
|
671
|
+
),
|
|
494
672
|
click.option(
|
|
495
673
|
"-N", "--concurrent", type=int, default=4, show_default=True, help="Fragments to download in parallel."
|
|
496
674
|
),
|
|
@@ -524,6 +702,7 @@ def collect_opts(
|
|
|
524
702
|
name,
|
|
525
703
|
dry_run,
|
|
526
704
|
list_formats=False,
|
|
705
|
+
keep_codec=False,
|
|
527
706
|
):
|
|
528
707
|
return {
|
|
529
708
|
"outdir": Path(outdir) if outdir else default_output_dir(),
|
|
@@ -545,6 +724,7 @@ def collect_opts(
|
|
|
545
724
|
"name": name,
|
|
546
725
|
"dry_run": dry_run,
|
|
547
726
|
"list_formats": list_formats,
|
|
727
|
+
"keep_codec": keep_codec,
|
|
548
728
|
}
|
|
549
729
|
|
|
550
730
|
|
|
@@ -577,6 +757,7 @@ def get_cmd(
|
|
|
577
757
|
archive,
|
|
578
758
|
section,
|
|
579
759
|
ascii_names,
|
|
760
|
+
keep_codec,
|
|
580
761
|
concurrent,
|
|
581
762
|
limit_rate,
|
|
582
763
|
name,
|
|
@@ -603,6 +784,7 @@ def get_cmd(
|
|
|
603
784
|
limit_rate,
|
|
604
785
|
name,
|
|
605
786
|
dry_run,
|
|
787
|
+
keep_codec=keep_codec,
|
|
606
788
|
)
|
|
607
789
|
do_get(list(urls), opts, assume_yes)
|
|
608
790
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import datetime
|
|
2
|
+
import subprocess
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
4
5
|
import pytest
|
|
@@ -112,6 +113,130 @@ def test_title_cap_leaves_room_for_the_suffixes_ytdlp_appends():
|
|
|
112
113
|
assert dl.TITLE_BYTE_LIMIT + len(".f1049104804294604v.mp4.part") < 255
|
|
113
114
|
|
|
114
115
|
|
|
116
|
+
def test_build_args_records_what_it_downloaded(opts, tmp_path):
|
|
117
|
+
args = dl.build_ytdlp_args(opts, has_ffmpeg=True, manifest=tmp_path / "files.txt")
|
|
118
|
+
assert args[args.index("--print-to-file") + 1] == "after_move:filepath"
|
|
119
|
+
assert args[args.index("--print-to-file") + 2] == str(tmp_path / "files.txt")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_build_args_without_manifest_stays_quiet(opts):
|
|
123
|
+
assert "--print-to-file" not in dl.build_ytdlp_args(opts, has_ffmpeg=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_downloaded_paths_drops_blanks_and_repeats(tmp_path):
|
|
127
|
+
manifest = tmp_path / "files.txt"
|
|
128
|
+
manifest.write_text("/a.mp4\n\n/b.mp4\n/a.mp4\n", encoding="utf-8")
|
|
129
|
+
assert dl.downloaded_paths(manifest) == ["/a.mp4", "/b.mp4"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_downloaded_paths_survives_a_missing_manifest(tmp_path):
|
|
133
|
+
assert dl.downloaded_paths(tmp_path / "nope.txt") == []
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_mp4_downloads_get_the_h264_pass(opts):
|
|
137
|
+
assert dl.wants_playable_output(opts, has_ffmpeg=True) is True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.mark.parametrize(
|
|
141
|
+
"key, value",
|
|
142
|
+
[("keep_codec", True), ("audio_only", True), ("list_formats", True), ("container", "webm")],
|
|
143
|
+
)
|
|
144
|
+
def test_h264_pass_is_skipped_when_it_does_not_apply(opts, key, value):
|
|
145
|
+
opts[key] = value
|
|
146
|
+
assert dl.wants_playable_output(opts, has_ffmpeg=True) is False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_h264_pass_needs_ffmpeg(opts):
|
|
150
|
+
assert dl.wants_playable_output(opts, has_ffmpeg=False) is False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_transcode_keeps_a_playable_audio_track(monkeypatch, tmp_path):
|
|
154
|
+
monkeypatch.setattr(dl, "find_ffmpeg", lambda: "ffmpeg")
|
|
155
|
+
args = dl.transcode_args(tmp_path / "in.mp4", tmp_path / "out.mp4", "libx264", {"acodec": "aac"})
|
|
156
|
+
assert args[args.index("-c:v") + 1] == "libx264"
|
|
157
|
+
assert args[args.index("-c:a") + 1] == "copy"
|
|
158
|
+
assert "-crf" in args
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_transcode_re_encodes_an_unplayable_audio_track(monkeypatch, tmp_path):
|
|
162
|
+
monkeypatch.setattr(dl, "find_ffmpeg", lambda: "ffmpeg")
|
|
163
|
+
args = dl.transcode_args(tmp_path / "in.mp4", tmp_path / "out.mp4", "libx264", {"acodec": "opus"})
|
|
164
|
+
assert args[args.index("-c:a") + 1] == "aac"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_hardware_encoder_gets_a_bitrate_instead_of_a_crf(monkeypatch, tmp_path):
|
|
168
|
+
monkeypatch.setattr(dl, "find_ffmpeg", lambda: "ffmpeg")
|
|
169
|
+
media = {"acodec": "aac", "vbitrate": 2_000_000}
|
|
170
|
+
args = dl.transcode_args(tmp_path / "in.mp4", tmp_path / "out.mp4", "h264_videotoolbox", media)
|
|
171
|
+
# H.264 needs more bits than VP9 for the same picture.
|
|
172
|
+
assert args[args.index("-b:v") + 1] == str(int(2_000_000 * dl.H264_BITRATE_HEADROOM))
|
|
173
|
+
assert "-crf" not in args
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_hardware_encoder_falls_back_to_a_floor_bitrate(monkeypatch, tmp_path):
|
|
177
|
+
monkeypatch.setattr(dl, "find_ffmpeg", lambda: "ffmpeg")
|
|
178
|
+
args = dl.transcode_args(tmp_path / "in.mp4", tmp_path / "out.mp4", "h264_videotoolbox", {"acodec": "aac"})
|
|
179
|
+
assert args[args.index("-b:v") + 1] == str(dl.MIN_H264_BITRATE)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_playable_codec_is_left_alone(monkeypatch, tmp_path):
|
|
183
|
+
video = tmp_path / "clip.mp4"
|
|
184
|
+
video.write_bytes(b"data")
|
|
185
|
+
monkeypatch.setattr(dl, "probe_media", lambda path: {"vcodec": "h264", "acodec": "aac", "vbitrate": 1})
|
|
186
|
+
assert dl.make_playable(video) is False
|
|
187
|
+
assert video.read_bytes() == b"data"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_vp9_is_converted_in_place(monkeypatch, tmp_path):
|
|
191
|
+
video = tmp_path / "clip.mp4"
|
|
192
|
+
video.write_bytes(b"vp9 data")
|
|
193
|
+
monkeypatch.setattr(dl, "probe_media", lambda path: {"vcodec": "vp9", "acodec": "aac", "vbitrate": 1})
|
|
194
|
+
monkeypatch.setattr(dl, "find_ffmpeg", lambda: "ffmpeg")
|
|
195
|
+
monkeypatch.setattr(dl, "h264_encoder", lambda: "libx264")
|
|
196
|
+
|
|
197
|
+
def fake_ffmpeg(cmd, check=True):
|
|
198
|
+
Path(cmd[-1]).write_bytes(b"h264 data")
|
|
199
|
+
return subprocess.CompletedProcess(cmd, 0)
|
|
200
|
+
|
|
201
|
+
monkeypatch.setattr(dl, "run_command", fake_ffmpeg)
|
|
202
|
+
assert dl.make_playable(video) is True
|
|
203
|
+
assert video.read_bytes() == b"h264 data"
|
|
204
|
+
assert list(tmp_path.iterdir()) == [video]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_a_failed_conversion_keeps_the_download(monkeypatch, tmp_path):
|
|
208
|
+
video = tmp_path / "clip.mp4"
|
|
209
|
+
video.write_bytes(b"vp9 data")
|
|
210
|
+
monkeypatch.setattr(dl, "probe_media", lambda path: {"vcodec": "vp9", "acodec": "aac", "vbitrate": 1})
|
|
211
|
+
monkeypatch.setattr(dl, "find_ffmpeg", lambda: "ffmpeg")
|
|
212
|
+
monkeypatch.setattr(dl, "h264_encoder", lambda: "libx264")
|
|
213
|
+
monkeypatch.setattr(dl, "run_command", lambda cmd, check=True: subprocess.CompletedProcess(cmd, 1))
|
|
214
|
+
assert dl.make_playable(video) is False
|
|
215
|
+
assert video.read_bytes() == b"vp9 data"
|
|
216
|
+
assert list(tmp_path.iterdir()) == [video]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_hardware_encoder_failure_retries_in_software(monkeypatch, tmp_path):
|
|
220
|
+
video = tmp_path / "clip.mp4"
|
|
221
|
+
video.write_bytes(b"vp9 data")
|
|
222
|
+
monkeypatch.setattr(dl, "probe_media", lambda path: {"vcodec": "vp9", "acodec": "aac", "vbitrate": 1})
|
|
223
|
+
monkeypatch.setattr(dl, "find_ffmpeg", lambda: "ffmpeg")
|
|
224
|
+
monkeypatch.setattr(dl, "h264_encoder", lambda: "h264_videotoolbox")
|
|
225
|
+
attempts = []
|
|
226
|
+
|
|
227
|
+
def flaky_ffmpeg(cmd, check=True):
|
|
228
|
+
encoder = cmd[cmd.index("-c:v") + 1]
|
|
229
|
+
attempts.append(encoder)
|
|
230
|
+
if encoder == "h264_videotoolbox":
|
|
231
|
+
return subprocess.CompletedProcess(cmd, 1)
|
|
232
|
+
Path(cmd[-1]).write_bytes(b"h264 data")
|
|
233
|
+
return subprocess.CompletedProcess(cmd, 0)
|
|
234
|
+
|
|
235
|
+
monkeypatch.setattr(dl, "run_command", flaky_ffmpeg)
|
|
236
|
+
assert dl.make_playable(video) is True
|
|
237
|
+
assert attempts == ["h264_videotoolbox", "libx264"]
|
|
238
|
+
|
|
239
|
+
|
|
115
240
|
def test_build_args_default(opts):
|
|
116
241
|
args = dl.build_ytdlp_args(opts, has_ffmpeg=True)
|
|
117
242
|
assert "--no-playlist" in args
|
evo_cli-0.20.2/evo_cli/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.20.2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|