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.
Files changed (36) hide show
  1. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/PKG-INFO +3 -2
  2. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/README.id.md +1 -0
  3. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/README.md +1 -0
  4. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/constants.py +1 -1
  5. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/detector.py +8 -1
  6. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/doctor.py +11 -0
  7. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/downloader.py +82 -5
  8. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/errors.py +6 -0
  9. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/i18n.py +18 -2
  10. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/pyproject.toml +2 -2
  11. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_downloader.py +130 -3
  12. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_features.py +31 -1
  13. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/.github/workflows/publish.yml +0 -0
  14. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/.github/workflows/tests.yml +0 -0
  15. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/.gitignore +0 -0
  16. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/CONTRIBUTING.id.md +0 -0
  17. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/CONTRIBUTING.md +0 -0
  18. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/LICENSE +0 -0
  19. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/__init__.py +0 -0
  20. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/cli.py +0 -0
  21. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/ffmpeg.py +0 -0
  22. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/log.py +0 -0
  23. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/options.py +0 -0
  24. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/update_checker.py +0 -0
  25. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/core/utils.py +0 -0
  26. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/downloads/.gitkeep +0 -0
  27. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/lumenfetch.spec +0 -0
  28. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/main.py +0 -0
  29. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/requirements-dev.txt +0 -0
  30. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/requirements.txt +0 -0
  31. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/__init__.py +0 -0
  32. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/conftest.py +0 -0
  33. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_detector.py +0 -0
  34. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_main.py +0 -0
  35. {lumenfetch-2.1.0 → lumenfetch-2.1.2}/tests/test_options.py +0 -0
  36. {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.0
4
- Summary: YouTube downloader buat terminal - MP4 & MP3.
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
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- VERSION = "2.1.0"
5
+ VERSION = "2.1.2"
6
6
 
7
7
  # --- Config ---
8
8
  HISTORY_LIMIT = 20
@@ -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
- "extractor_args": {"youtube": {"player_client": ["android", "web"]}},
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
- _release_placeholder(dest, reserved)
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 file_count == 0:
251
- _release_placeholder(dest, reserved)
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
- "extractor_args": {"youtube": {"player_client": ["android", "web"]}},
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": "❌ Format not available for this content",
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 untuk konten ini",
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.0"
8
- description = "YouTube downloader buat terminal - MP4 & MP3."
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
- (tmp_path / "Judul-Playlist_2026.mp4").write_bytes(b"video pertama doang")
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
- (tmp_path / "Judul_2026.mp4").write_bytes(b"x")
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) == 8
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