lumenfetch 2.1.0__tar.gz → 2.1.2__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.
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/PKG-INFO +3 -2
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/README.id.md +1 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/README.md +1 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/constants.py +1 -1
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/detector.py +8 -1
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/doctor.py +11 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/downloader.py +82 -5
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/errors.py +6 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/i18n.py +18 -2
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/pyproject.toml +2 -2
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_downloader.py +130 -3
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_features.py +31 -1
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/.github/workflows/publish.yml +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/.github/workflows/tests.yml +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/.gitignore +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/CONTRIBUTING.id.md +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/CONTRIBUTING.md +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/LICENSE +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/__init__.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/cli.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/ffmpeg.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/log.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/options.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/update_checker.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/utils.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/downloads/.gitkeep +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/lumenfetch.spec +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/main.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/requirements-dev.txt +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/requirements.txt +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/__init__.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/conftest.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_detector.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_main.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_options.py +0 -0
- {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_utils.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lumenfetch
|
|
3
|
-
Version: 2.1.
|
|
4
|
-
Summary: YouTube downloader
|
|
3
|
+
Version: 2.1.2
|
|
4
|
+
Summary: YouTube downloader for the terminal (MP4 & MP3).
|
|
5
5
|
Project-URL: Homepage, https://github.com/nekonaru/lumenfetch
|
|
6
6
|
Author: Nicolas Dwi Dharma
|
|
7
7
|
License: MIT
|
|
@@ -376,6 +376,7 @@ Change it anytime via the `settings` command (`[1]`), absolute paths and `~` are
|
|
|
376
376
|
| Naming template rejected in `settings` | Only `%(platform)s`, `%(title)s`, `%(year)s` are allowed - rejected automatically on typos so filenames never break |
|
|
377
377
|
| `⚠️ Playlist with N videos` | Only the first video by default. Use `--playlist all` for all of them as indexed files |
|
|
378
378
|
| `ffmpeg not found` | Install system ffmpeg first (lighter), bundled fallback is automatic. Check with `ffmpeg -version` |
|
|
379
|
+
| `❌ YouTube needs a JavaScript runtime` | Install Deno (`winget install DenoLand.Deno`) or Node.js - required to solve YouTube signature challenges |
|
|
379
380
|
|
|
380
381
|
## 📝 Important Notes
|
|
381
382
|
|
|
@@ -347,6 +347,7 @@ Ganti kapan saja lewat command `settings` (`[1]`), path absolut maupun `~` sama-
|
|
|
347
347
|
| Batch download | Buat file teks (satu URL per baris, `#` = komentar), lalu `lumenfetch --batch daftar.txt --jobs 2` |
|
|
348
348
|
| Resume download | Didukung via `continuedl` yt-dlp + retry backoff eksponensial (2s, 4s, 8s... max 30s). File parsial `.part` dilanjutkan, bukan diulang dari 0 |
|
|
349
349
|
| `ffmpeg tidak ditemukan` | Install ffmpeg sistem dulu (lebih ringan), fallback bundled otomatis. Cek dengan `ffmpeg -version` |
|
|
350
|
+
| `❌ YouTube butuh JavaScript runtime` | Install Deno (`winget install DenoLand.Deno`) atau Node.js - dibutuhkan untuk signature challenge YouTube |
|
|
350
351
|
|
|
351
352
|
## 📝 Catatan Penting
|
|
352
353
|
|
|
@@ -343,6 +343,7 @@ Change it anytime via the `settings` command (`[1]`), absolute paths and `~` are
|
|
|
343
343
|
| Naming template rejected in `settings` | Only `%(platform)s`, `%(title)s`, `%(year)s` are allowed - rejected automatically on typos so filenames never break |
|
|
344
344
|
| `⚠️ Playlist with N videos` | Only the first video by default. Use `--playlist all` for all of them as indexed files |
|
|
345
345
|
| `ffmpeg not found` | Install system ffmpeg first (lighter), bundled fallback is automatic. Check with `ffmpeg -version` |
|
|
346
|
+
| `❌ YouTube needs a JavaScript runtime` | Install Deno (`winget install DenoLand.Deno`) or Node.js - required to solve YouTube signature challenges |
|
|
346
347
|
|
|
347
348
|
## 📝 Important Notes
|
|
348
349
|
|
|
@@ -104,7 +104,14 @@ def _base_ydl_opts() -> dict:
|
|
|
104
104
|
# Redirect raw yt-dlp noise (ERROR:/WARNING: lines) to the log file;
|
|
105
105
|
# the terminal only shows friendly messages via rich.
|
|
106
106
|
"logger": get_ydl_logger(),
|
|
107
|
-
|
|
107
|
+
# NOTE: do NOT force player_client here. An old override
|
|
108
|
+
# (["android", "web"]) dodged "Sign in to confirm you're not a bot"
|
|
109
|
+
# but now triggers YouTube's SABR-only experiment: only 1 progressive
|
|
110
|
+
# format served instead of ~46, so quality selection fails.
|
|
111
|
+
# yt-dlp's maintained defaults handle clients + fallbacks better.
|
|
112
|
+
# Signature challenges still need a JS runtime (deno is yt-dlp's
|
|
113
|
+
# default; also allow node/bun/quickjs if already installed).
|
|
114
|
+
"js_runtimes": {"node": {}, "deno": {}, "bun": {}, "quickjs": {}},
|
|
108
115
|
}
|
|
109
116
|
|
|
110
117
|
|
|
@@ -26,6 +26,7 @@ def run_all(config: dict | None = None) -> list[DoctorCheck]:
|
|
|
26
26
|
checks: list[DoctorCheck] = [
|
|
27
27
|
_check_python(),
|
|
28
28
|
_check_ffmpeg(),
|
|
29
|
+
_check_js_runtime(),
|
|
29
30
|
_check_curl_cffi(),
|
|
30
31
|
_check_yt_dlp(),
|
|
31
32
|
_check_self(),
|
|
@@ -61,6 +62,16 @@ def _check_ffmpeg() -> DoctorCheck:
|
|
|
61
62
|
return DoctorCheck(name=t("doctor.ffmpeg"), ok=False, detail="-", hint=t("doctor.ffmpeg_hint"))
|
|
62
63
|
|
|
63
64
|
|
|
65
|
+
def _check_js_runtime() -> DoctorCheck:
|
|
66
|
+
"""At least one JS runtime (Deno/Node/Bun) must exist for YouTube challenges."""
|
|
67
|
+
import shutil
|
|
68
|
+
|
|
69
|
+
found = next((r for r in ("deno", "node", "bun", "quickjs") if shutil.which(r)), None)
|
|
70
|
+
if found:
|
|
71
|
+
return DoctorCheck(name=t("doctor.js"), ok=True, detail=found)
|
|
72
|
+
return DoctorCheck(name=t("doctor.js"), ok=False, detail="-", hint=t("doctor.js_hint"))
|
|
73
|
+
|
|
74
|
+
|
|
64
75
|
def _check_curl_cffi() -> DoctorCheck:
|
|
65
76
|
import importlib.util
|
|
66
77
|
|
|
@@ -238,17 +238,41 @@ def download(
|
|
|
238
238
|
console.print(t("dl.retry", a=attempt, m=max_retry, d=delay))
|
|
239
239
|
time.sleep(delay)
|
|
240
240
|
continue
|
|
241
|
+
if kind == "format" and attempt < max_retry:
|
|
242
|
+
# SABR-only sessions come and go per request: a fresh
|
|
243
|
+
# extraction may get full formats again. Same backoff.
|
|
244
|
+
attempt += 1
|
|
245
|
+
delay = backoff_delay(attempt)
|
|
246
|
+
console.print(t("dl.retry", a=attempt, m=max_retry, d=delay))
|
|
247
|
+
time.sleep(delay)
|
|
248
|
+
continue
|
|
241
249
|
raise DownloadFailed(_friendly_message(kind)) from e
|
|
242
250
|
except Exception as e: # noqa: BLE001
|
|
243
251
|
raise DownloadFailed(_friendly_message(_classify_error(e))) from e
|
|
244
|
-
except (DownloadCancelled, DownloadFailed):
|
|
245
|
-
|
|
252
|
+
except (DownloadCancelled, DownloadFailed) as e:
|
|
253
|
+
if isinstance(e, DownloadFailed):
|
|
254
|
+
salvaged = _salvage_media(dest, playlist_all)
|
|
255
|
+
if salvaged is not None:
|
|
256
|
+
path, size, count = salvaged
|
|
257
|
+
console.print(f"[yellow]{t('dl.pp_warning')}[/yellow]")
|
|
258
|
+
_cleanup_failed_dest(dest, reserved, playlist_all, keep={path})
|
|
259
|
+
return DownloadResult(
|
|
260
|
+
filepath=path,
|
|
261
|
+
size_bytes=size,
|
|
262
|
+
elapsed_seconds=time.time() - start_time,
|
|
263
|
+
file_count=count,
|
|
264
|
+
)
|
|
265
|
+
_cleanup_failed_dest(dest, reserved, playlist_all)
|
|
246
266
|
raise
|
|
247
267
|
|
|
248
268
|
elapsed = time.time() - start_time
|
|
249
269
|
final_path, total_size, file_count = _resolve_final_path(dest, multi_item=playlist_all)
|
|
250
|
-
if
|
|
251
|
-
|
|
270
|
+
if total_size == 0:
|
|
271
|
+
# yt-dlp reported success but left nothing (or an empty file):
|
|
272
|
+
# expired stream URLs typically end like this. Never report these
|
|
273
|
+
# as success, and don't leave 0-byte litter behind.
|
|
274
|
+
_cleanup_failed_dest(dest, reserved, playlist_all)
|
|
275
|
+
raise DownloadFailed(t("dl.empty_result"))
|
|
252
276
|
|
|
253
277
|
if choice.output_kind == "audio" and playlist_all and file_count > 1:
|
|
254
278
|
_write_m3u_playlist(dest)
|
|
@@ -256,6 +280,56 @@ def download(
|
|
|
256
280
|
return DownloadResult(filepath=final_path, size_bytes=total_size, elapsed_seconds=elapsed, file_count=file_count)
|
|
257
281
|
|
|
258
282
|
|
|
283
|
+
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _salvage_media(dest: Path, multi_item: bool = False) -> tuple[Path, int, int] | None:
|
|
287
|
+
"""Return (path, size, count) if a NON-EMPTY media file exists despite the failure.
|
|
288
|
+
|
|
289
|
+
Post-processing (e.g. thumbnail embed) must never nuke an otherwise good
|
|
290
|
+
download: a failed PP step on top of an intact video still counts.
|
|
291
|
+
"""
|
|
292
|
+
matches = _find_result_files(dest, multi_item=multi_item)
|
|
293
|
+
good = [p for p in matches if p.exists() and p.suffix.lower() not in _IMAGE_EXTS]
|
|
294
|
+
good = [p for p in good if _safe_size(p) > 0]
|
|
295
|
+
if not good:
|
|
296
|
+
return None
|
|
297
|
+
total = sum(_safe_size(p) for p in good)
|
|
298
|
+
return good[0], total, len(good)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _safe_size(p: Path) -> int:
|
|
302
|
+
try:
|
|
303
|
+
return p.stat().st_size
|
|
304
|
+
except OSError:
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _cleanup_failed_dest(dest: Path, reserved: bool, multi_item: bool = False, keep: frozenset | set = frozenset()) -> None:
|
|
309
|
+
"""Remove our placeholder, 0-byte results, and thumbnail leftovers of a failed download.
|
|
310
|
+
|
|
311
|
+
Never touches non-empty media files outside `keep`... in fact never
|
|
312
|
+
touches non-empty media at all: only our own empty placeholder, 0-byte
|
|
313
|
+
stem files, and stem thumbnail litter from crashed runs.
|
|
314
|
+
"""
|
|
315
|
+
_release_placeholder(dest, reserved)
|
|
316
|
+
try:
|
|
317
|
+
if multi_item:
|
|
318
|
+
candidates = find_indexed_files(dest)
|
|
319
|
+
else:
|
|
320
|
+
candidates = [p for p in _iter_dir_files(dest.parent) if p.is_file() and p.stem == dest.stem]
|
|
321
|
+
for p in candidates:
|
|
322
|
+
if p in keep:
|
|
323
|
+
continue
|
|
324
|
+
try:
|
|
325
|
+
if p.suffix.lower() in _IMAGE_EXTS or _safe_size(p) == 0:
|
|
326
|
+
p.unlink()
|
|
327
|
+
except OSError:
|
|
328
|
+
pass
|
|
329
|
+
except OSError:
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
|
|
259
333
|
def _write_m3u_playlist(dest: Path) -> Path | None:
|
|
260
334
|
"""Write a `.m3u` playlist next to audio `--playlist all` results. Best effort."""
|
|
261
335
|
try:
|
|
@@ -331,8 +405,11 @@ def _build_ydl_opts(
|
|
|
331
405
|
"fragment_retries": 3,
|
|
332
406
|
"continuedl": True, # resume partial files (.part)
|
|
333
407
|
"skip_unavailable_fragments": False,
|
|
408
|
+
# Same as detector._base_ydl_opts: a JS runtime is needed for signature challenges.
|
|
409
|
+
"js_runtimes": {"node": {}, "deno": {}, "bun": {}, "quickjs": {}},
|
|
334
410
|
"noplaylist": not playlist_all,
|
|
335
|
-
|
|
411
|
+
# No forced player_client (see detector._base_ydl_opts): yt-dlp
|
|
412
|
+
# defaults survive YouTube's SABR-only sessions much better.
|
|
336
413
|
}
|
|
337
414
|
|
|
338
415
|
if force_single_item:
|
|
@@ -34,6 +34,7 @@ ERROR_KIND_REMOVED = "removed"
|
|
|
34
34
|
ERROR_KIND_UNSUPPORTED = "unsupported"
|
|
35
35
|
ERROR_KIND_BLOCKED = "blocked"
|
|
36
36
|
ERROR_KIND_COOKIES = "cookies"
|
|
37
|
+
ERROR_KIND_JSRUNTIME = "jsruntime"
|
|
37
38
|
ERROR_KIND_UNKNOWN = "unknown"
|
|
38
39
|
|
|
39
40
|
_KIND_TO_KEY = {
|
|
@@ -46,6 +47,7 @@ _KIND_TO_KEY = {
|
|
|
46
47
|
ERROR_KIND_UNSUPPORTED: "err.unsupported",
|
|
47
48
|
ERROR_KIND_BLOCKED: "err.blocked",
|
|
48
49
|
ERROR_KIND_COOKIES: "err.cookies",
|
|
50
|
+
ERROR_KIND_JSRUNTIME: "err.jsruntime",
|
|
49
51
|
ERROR_KIND_UNKNOWN: "err.unknown",
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -63,6 +65,10 @@ def classify_error(exc: Exception) -> str:
|
|
|
63
65
|
# Encrypted cookies cannot be decrypted (yt-dlp #10927, common on
|
|
64
66
|
# Edge/Chrome for Windows) - likewise: not a broken URL, fall back without cookies.
|
|
65
67
|
return ERROR_KIND_COOKIES
|
|
68
|
+
if "javascript runtime" in msg or "js runtime" in msg:
|
|
69
|
+
# Modern yt-dlp needs Deno/Node/Bun to solve YouTube signature
|
|
70
|
+
# challenges - without one, videos fail as "unavailable".
|
|
71
|
+
return ERROR_KIND_JSRUNTIME
|
|
66
72
|
if "unexpected response from webpage request" in msg or "confirm you are on the latest version" in msg:
|
|
67
73
|
# Bot-check challenge that only passes with impersonation + cookies,
|
|
68
74
|
# not a broken URL.
|
|
@@ -126,7 +126,7 @@ STRINGS: dict[str, dict[str, str]] = {
|
|
|
126
126
|
"eta.hour": "~{n} hours",
|
|
127
127
|
"err.connection": "❌ Connection trouble, try again",
|
|
128
128
|
"err.private": "❌ This content is private / unavailable - try enabling Browser Cookies in settings",
|
|
129
|
-
"err.format": "❌
|
|
129
|
+
"err.format": "❌ No matching format available right now - please retry",
|
|
130
130
|
"err.disk": "❌ Not enough disk space",
|
|
131
131
|
"err.rate_limit": "❌ Too many requests to this platform - wait a bit then retry",
|
|
132
132
|
"err.removed": "❌ This content was deleted / is no longer available",
|
|
@@ -140,10 +140,16 @@ STRINGS: dict[str, dict[str, str]] = {
|
|
|
140
140
|
"❌ Failed to read browser cookies - close the browser completely "
|
|
141
141
|
"(check Task Manager), then try again"
|
|
142
142
|
),
|
|
143
|
+
"err.jsruntime": (
|
|
144
|
+
"❌ YouTube needs a JavaScript runtime (Deno or Node.js) - install one, "
|
|
145
|
+
"e.g. winget install DenoLand.Deno, then retry"
|
|
146
|
+
),
|
|
143
147
|
"err.unknown": "❌ Download error occurred",
|
|
144
148
|
"err.unknown_detect": "Not a valid YouTube URL or unsupported",
|
|
145
149
|
"dl.cookie_retry": "continuing without cookies...",
|
|
146
150
|
"dl.retry": "🔄 Retry {a}/{m} in {d:.0f}s...",
|
|
151
|
+
"dl.pp_warning": "⚠️ Post-processing failed (e.g. thumbnail embed) - but the video file itself is fine and kept.",
|
|
152
|
+
"dl.empty_result": "❌ Download produced no data (stream links may have expired) - please retry",
|
|
147
153
|
"doctor.title": "🩺 Lumenfetch Doctor",
|
|
148
154
|
"doctor.col_check": "Check",
|
|
149
155
|
"doctor.col_result": "Result",
|
|
@@ -155,6 +161,8 @@ STRINGS: dict[str, dict[str, str]] = {
|
|
|
155
161
|
"doctor.ffmpeg_bundled": "bundled (static-ffmpeg)",
|
|
156
162
|
"doctor.ffmpeg_hint": "Install system ffmpeg or pip install static-ffmpeg",
|
|
157
163
|
"doctor.curl": "curl_cffi (bot-check bypass)",
|
|
164
|
+
"doctor.js": "JS runtime (Deno/Node for challenges)",
|
|
165
|
+
"doctor.js_hint": "install Deno (winget install DenoLand.Deno) or Node.js",
|
|
158
166
|
"doctor.installed": "installed",
|
|
159
167
|
"doctor.missing": "missing",
|
|
160
168
|
"doctor.curl_hint": "pip install curl_cffi",
|
|
@@ -288,7 +296,7 @@ STRINGS: dict[str, dict[str, str]] = {
|
|
|
288
296
|
"eta.hour": "~{n} jam",
|
|
289
297
|
"err.connection": "❌ Koneksi internet bermasalah, coba lagi",
|
|
290
298
|
"err.private": "❌ Konten ini private / tidak bisa diakses - coba aktifkan Cookies dari Browser di settings",
|
|
291
|
-
"err.format": "❌ Format tidak tersedia
|
|
299
|
+
"err.format": "❌ Format tidak tersedia saat ini - coba lagi",
|
|
292
300
|
"err.disk": "❌ Tidak cukup ruang disk",
|
|
293
301
|
"err.rate_limit": "❌ Terlalu banyak request ke platform ini - tunggu sebentar lalu coba lagi",
|
|
294
302
|
"err.removed": "❌ Konten ini sudah dihapus / tidak lagi tersedia",
|
|
@@ -302,10 +310,16 @@ STRINGS: dict[str, dict[str, str]] = {
|
|
|
302
310
|
"❌ Gagal membaca cookies dari browser - tutup browser-nya sampai "
|
|
303
311
|
"benar-benar tertutup (cek Task Manager), lalu coba lagi"
|
|
304
312
|
),
|
|
313
|
+
"err.jsruntime": (
|
|
314
|
+
"❌ YouTube butuh JavaScript runtime (Deno atau Node.js) - install salah satu, "
|
|
315
|
+
"mis. winget install DenoLand.Deno, lalu coba lagi"
|
|
316
|
+
),
|
|
305
317
|
"err.unknown": "❌ Terjadi kesalahan saat download",
|
|
306
318
|
"err.unknown_detect": "URL bukan link YouTube yang valid atau tidak didukung",
|
|
307
319
|
"dl.cookie_retry": "lanjut tanpa cookies...",
|
|
308
320
|
"dl.retry": "🔄 Retry {a}/{m} dalam {d:.0f}s...",
|
|
321
|
+
"dl.pp_warning": "⚠️ Post-processing gagal (mis. embed thumbnail) - tapi file videonya utuh dan disimpan.",
|
|
322
|
+
"dl.empty_result": "❌ Download tidak menghasilkan data (link stream mungkin kedaluwarsa) - coba lagi",
|
|
309
323
|
"doctor.title": "🩺 Lumenfetch Doctor",
|
|
310
324
|
"doctor.col_check": "Cek",
|
|
311
325
|
"doctor.col_result": "Hasil",
|
|
@@ -317,6 +331,8 @@ STRINGS: dict[str, dict[str, str]] = {
|
|
|
317
331
|
"doctor.ffmpeg_bundled": "bundled (static-ffmpeg)",
|
|
318
332
|
"doctor.ffmpeg_hint": "Install ffmpeg sistem atau pip install static-ffmpeg",
|
|
319
333
|
"doctor.curl": "curl_cffi (lolos bot-check)",
|
|
334
|
+
"doctor.js": "JS runtime (Deno/Node untuk challenge)",
|
|
335
|
+
"doctor.js_hint": "install Deno (winget install DenoLand.Deno) atau Node.js",
|
|
320
336
|
"doctor.installed": "terinstall",
|
|
321
337
|
"doctor.missing": "hilang",
|
|
322
338
|
"doctor.curl_hint": "pip install curl_cffi",
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lumenfetch"
|
|
7
|
-
version = "2.1.
|
|
8
|
-
description = "YouTube downloader
|
|
7
|
+
version = "2.1.2"
|
|
8
|
+
description = "YouTube downloader for the terminal (MP4 & MP3)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = { text = "MIT" }
|
|
@@ -153,6 +153,7 @@ def test_download_forces_single_item_for_pure_playlist_video(tmp_path, monkeypat
|
|
|
153
153
|
class FakeYDL:
|
|
154
154
|
def __init__(self, opts):
|
|
155
155
|
captured_opts.update(opts)
|
|
156
|
+
self.opts = opts
|
|
156
157
|
|
|
157
158
|
def __enter__(self):
|
|
158
159
|
return self
|
|
@@ -161,7 +162,10 @@ def test_download_forces_single_item_for_pure_playlist_video(tmp_path, monkeypat
|
|
|
161
162
|
return False
|
|
162
163
|
|
|
163
164
|
def download(self, urls):
|
|
164
|
-
|
|
165
|
+
from pathlib import Path as _P
|
|
166
|
+
|
|
167
|
+
base = self.opts["outtmpl"].rsplit(".", 1)[0]
|
|
168
|
+
_P(f"{base}.mp4").write_bytes(b"video pertama doang")
|
|
165
169
|
|
|
166
170
|
monkeypatch.setattr(downloader.yt_dlp, "YoutubeDL", FakeYDL)
|
|
167
171
|
|
|
@@ -212,7 +216,6 @@ def test_download_playlist_all_finds_indexed_files(tmp_path, monkeypatch):
|
|
|
212
216
|
|
|
213
217
|
def test_download_retries_connection_with_backoff(tmp_path, monkeypatch):
|
|
214
218
|
attempts = []
|
|
215
|
-
|
|
216
219
|
class FakeYDL:
|
|
217
220
|
def __init__(self, opts):
|
|
218
221
|
self.opts = opts
|
|
@@ -264,7 +267,10 @@ def test_download_retries_without_cookies_when_cookie_copy_fails(tmp_path, monke
|
|
|
264
267
|
calls.append("cookiesfrombrowser" in self.opts)
|
|
265
268
|
if "cookiesfrombrowser" in self.opts:
|
|
266
269
|
raise downloader.yt_dlp.utils.DownloadError("Could not copy Chrome cookie database")
|
|
267
|
-
|
|
270
|
+
from pathlib import Path as _P
|
|
271
|
+
|
|
272
|
+
base = self.opts["outtmpl"].rsplit(".", 1)[0]
|
|
273
|
+
_P(f"{base}.mp4").write_bytes(b"x")
|
|
268
274
|
|
|
269
275
|
monkeypatch.setattr(downloader.yt_dlp, "YoutubeDL", FakeYDL)
|
|
270
276
|
|
|
@@ -431,3 +437,124 @@ def test_failed_download_cleans_own_placeholder(tmp_path, monkeypatch):
|
|
|
431
437
|
|
|
432
438
|
leftovers = [p for p in tmp_path.iterdir() if p.is_file()]
|
|
433
439
|
assert leftovers == []
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def test_empty_result_is_failure_not_success(tmp_path, monkeypatch):
|
|
443
|
+
"""yt-dlp success with a 0-byte file (expired stream URLs) must fail loudly."""
|
|
444
|
+
|
|
445
|
+
class FakeYDL:
|
|
446
|
+
def __init__(self, opts):
|
|
447
|
+
self.opts = opts
|
|
448
|
+
|
|
449
|
+
def __enter__(self):
|
|
450
|
+
return self
|
|
451
|
+
|
|
452
|
+
def __exit__(self, *args):
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
def download(self, urls):
|
|
456
|
+
base = self.opts["outtmpl"].rsplit(".", 1)[0]
|
|
457
|
+
from pathlib import Path as _P
|
|
458
|
+
|
|
459
|
+
_P(f"{base}.mp4").write_bytes(b"") # 0 bytes, like expired-URL runs
|
|
460
|
+
|
|
461
|
+
monkeypatch.setattr(downloader.yt_dlp, "YoutubeDL", FakeYDL)
|
|
462
|
+
|
|
463
|
+
class FakeContent:
|
|
464
|
+
url = "https://www.youtube.com/watch?v=abc"
|
|
465
|
+
platform = "YouTube"
|
|
466
|
+
title = "Judul Kosong"
|
|
467
|
+
entries = []
|
|
468
|
+
|
|
469
|
+
choice = DownloadChoice(output_kind="video", quality="Best", fmt="mp4")
|
|
470
|
+
with pytest.raises(downloader.DownloadFailed):
|
|
471
|
+
downloader.download(FakeContent(), choice, tmp_path, "%(title)s")
|
|
472
|
+
|
|
473
|
+
assert [p for p in tmp_path.iterdir() if p.is_file()] == []
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def test_postprocessor_crash_salvages_good_video(tmp_path, monkeypatch, capsys):
|
|
477
|
+
"""EmbedThumbnail-style crash on top of an intact video still counts as success."""
|
|
478
|
+
|
|
479
|
+
class FakeYDL:
|
|
480
|
+
def __init__(self, opts):
|
|
481
|
+
self.opts = opts
|
|
482
|
+
|
|
483
|
+
def __enter__(self):
|
|
484
|
+
return self
|
|
485
|
+
|
|
486
|
+
def __exit__(self, *args):
|
|
487
|
+
return False
|
|
488
|
+
|
|
489
|
+
def download(self, urls):
|
|
490
|
+
base = self.opts["outtmpl"].rsplit(".", 1)[0]
|
|
491
|
+
from pathlib import Path as _P
|
|
492
|
+
|
|
493
|
+
_P(f"{base}.mp4").write_bytes(b"good-video-bytes")
|
|
494
|
+
_P(f"{base}.png").write_bytes(b"thumbnail-litter")
|
|
495
|
+
raise downloader.yt_dlp.utils.DownloadError("EmbedThumbnailPP: KeyError streams")
|
|
496
|
+
|
|
497
|
+
monkeypatch.setattr(downloader.yt_dlp, "YoutubeDL", FakeYDL)
|
|
498
|
+
|
|
499
|
+
class FakeContent:
|
|
500
|
+
url = "https://www.youtube.com/watch?v=abc"
|
|
501
|
+
platform = "YouTube"
|
|
502
|
+
title = "Judul Bagus"
|
|
503
|
+
entries = []
|
|
504
|
+
|
|
505
|
+
choice = DownloadChoice(output_kind="video", quality="Best", fmt="mp4")
|
|
506
|
+
result = downloader.download(FakeContent(), choice, tmp_path, "%(title)s")
|
|
507
|
+
|
|
508
|
+
assert result.file_count == 1
|
|
509
|
+
assert result.size_bytes == len(b"good-video-bytes")
|
|
510
|
+
assert result.filepath.exists()
|
|
511
|
+
# thumbnail litter cleaned, video kept
|
|
512
|
+
assert not list(tmp_path.glob("*.png"))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def test_no_forced_player_client(tmp_path):
|
|
516
|
+
from core import detector
|
|
517
|
+
from core.options import DownloadChoice as _DC
|
|
518
|
+
|
|
519
|
+
assert "extractor_args" not in detector._base_ydl_opts()
|
|
520
|
+
choice = _DC(output_kind="video", quality="Best", fmt="mp4")
|
|
521
|
+
assert "extractor_args" not in downloader._build_ydl_opts(choice, tmp_path / "v.mp4")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def test_download_retries_transient_format_errors(tmp_path, monkeypatch):
|
|
525
|
+
"""SABR-only sessions are transient: a fresh extraction may get full formats."""
|
|
526
|
+
attempts = []
|
|
527
|
+
|
|
528
|
+
class FakeYDL:
|
|
529
|
+
def __init__(self, opts):
|
|
530
|
+
self.opts = opts
|
|
531
|
+
|
|
532
|
+
def __enter__(self):
|
|
533
|
+
return self
|
|
534
|
+
|
|
535
|
+
def __exit__(self, *args):
|
|
536
|
+
return False
|
|
537
|
+
|
|
538
|
+
def download(self, urls):
|
|
539
|
+
attempts.append(1)
|
|
540
|
+
if len(attempts) == 1:
|
|
541
|
+
raise downloader.yt_dlp.utils.DownloadError("Requested format is not available")
|
|
542
|
+
base = self.opts["outtmpl"].rsplit(".", 1)[0]
|
|
543
|
+
from pathlib import Path as _P
|
|
544
|
+
|
|
545
|
+
_P(f"{base}.mp4").write_bytes(b"recovered")
|
|
546
|
+
|
|
547
|
+
monkeypatch.setattr(downloader.yt_dlp, "YoutubeDL", FakeYDL)
|
|
548
|
+
|
|
549
|
+
class FakeContent:
|
|
550
|
+
url = "https://www.youtube.com/watch?v=abc"
|
|
551
|
+
platform = "YouTube"
|
|
552
|
+
title = "Judul"
|
|
553
|
+
entries = []
|
|
554
|
+
|
|
555
|
+
choice = DownloadChoice(output_kind="video", quality="Best", fmt="mp4")
|
|
556
|
+
result = downloader.download(FakeContent(), choice, tmp_path, "%(title)s", max_retry=2)
|
|
557
|
+
|
|
558
|
+
assert len(attempts) == 2
|
|
559
|
+
assert result.file_count == 1
|
|
560
|
+
assert result.size_bytes == len(b"recovered")
|
|
@@ -198,6 +198,36 @@ def test_classify_dpapi_failure_as_cookies():
|
|
|
198
198
|
assert classify_error(Exception("failed to load cookies")) == ERROR_KIND_COOKIES
|
|
199
199
|
|
|
200
200
|
|
|
201
|
+
def test_classify_missing_js_runtime():
|
|
202
|
+
from core.errors import ERROR_KIND_JSRUNTIME, classify_error, friendly_message
|
|
203
|
+
|
|
204
|
+
err = Exception("No supported JavaScript runtime could be found. Only deno is enabled by default")
|
|
205
|
+
assert classify_error(err) == ERROR_KIND_JSRUNTIME
|
|
206
|
+
assert "deno" in friendly_message(ERROR_KIND_JSRUNTIME).lower()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_doctor_js_runtime_check(monkeypatch):
|
|
210
|
+
import shutil
|
|
211
|
+
|
|
212
|
+
from core import doctor
|
|
213
|
+
|
|
214
|
+
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None)
|
|
215
|
+
check = doctor._check_js_runtime()
|
|
216
|
+
assert check.ok is True
|
|
217
|
+
assert check.detail == "node"
|
|
218
|
+
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
219
|
+
assert doctor._check_js_runtime().ok is False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_build_opts_enable_js_runtimes(tmp_path):
|
|
223
|
+
from core import downloader
|
|
224
|
+
from core.options import DownloadChoice
|
|
225
|
+
|
|
226
|
+
choice = DownloadChoice(output_kind="video", quality="Best", fmt="mp4")
|
|
227
|
+
opts = downloader._build_ydl_opts(choice, tmp_path / "v.mp4")
|
|
228
|
+
assert "node" in opts["js_runtimes"]
|
|
229
|
+
|
|
230
|
+
|
|
201
231
|
def test_resolve_cookie_file(tmp_path):
|
|
202
232
|
assert utils.resolve_cookie_file(None) is None
|
|
203
233
|
assert utils.resolve_cookie_file("") is None
|
|
@@ -327,7 +357,7 @@ def test_doctor_all_checks_structure():
|
|
|
327
357
|
|
|
328
358
|
checks = doctor.run_all({})
|
|
329
359
|
names = [c.name for c in checks]
|
|
330
|
-
assert len(checks) ==
|
|
360
|
+
assert len(checks) == 9
|
|
331
361
|
assert all(isinstance(c.ok, bool) for c in checks)
|
|
332
362
|
assert any("ffmpeg" in n.lower() for n in names)
|
|
333
363
|
|
|
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
|