lumenfetch 2.0.0__py3-none-any.whl

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.
core/utils.py ADDED
@@ -0,0 +1,631 @@
1
+ """
2
+ utils.py
3
+ Kumpulan fungsi bantu: sanitasi nama file, format ukuran,
4
+ dan baca/tulis config.json.
5
+
6
+ Fasad kompatibel: nilai kanonis tinggal di core.constants,
7
+ tapi tetap di-re-export dari sini agar import lama
8
+ (`from core.utils import MAX_TITLE_LEN`, dll) tidak rusak.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ import json
15
+ import re
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+
19
+ from core.constants import (
20
+ AUDIO_TARGET_KBPS,
21
+ CONFIG_VERSION,
22
+ HEIGHT_LIMITS,
23
+ HISTORY_LIMIT,
24
+ ILLEGAL_CHARS,
25
+ MAX_TITLE_LEN,
26
+ SUPPORTED_COOKIE_BROWSERS,
27
+ WINDOWS_RESERVED_NAMES,
28
+ )
29
+
30
+ __all__ = [
31
+ "CONFIG_PATH",
32
+ "DEFAULT_CONFIG",
33
+ "SUPPORTED_COOKIE_BROWSERS",
34
+ "ILLEGAL_CHARS",
35
+ "MAX_TITLE_LEN",
36
+ "get_default_download_folder",
37
+ "get_default_config",
38
+ "load_config",
39
+ "save_config",
40
+ "add_history_entry",
41
+ "clear_history",
42
+ "sanitize_filename",
43
+ "is_valid_naming_template",
44
+ "build_filename",
45
+ "find_indexed_files",
46
+ "resolve_duplicate",
47
+ "build_cookies_from_browser",
48
+ "resolve_cookie_file",
49
+ "apply_cookie_opts",
50
+ "format_size",
51
+ "format_duration",
52
+ "estimate_video_size_bytes",
53
+ "estimate_audio_size_bytes",
54
+ "estimate_average_speed_bps",
55
+ "format_eta",
56
+ ]
57
+
58
+ # Re-export agar `utils.MAX_TITLE_LEN` dkk tetap tersedia.
59
+ # (Nilai kanonisnya di core.constants)
60
+
61
+
62
+ def _project_root() -> Path:
63
+ return Path(__file__).resolve().parent.parent
64
+
65
+
66
+ def _user_data_dir() -> Path:
67
+ """Direktori data user per-OS (tanpa dependency platformdirs)."""
68
+ import os
69
+ import sys
70
+
71
+ home = Path.home()
72
+ if sys.platform.startswith("win"):
73
+ base = os.environ.get("APPDATA")
74
+ if base:
75
+ return Path(base) / "lumenfetch"
76
+ return home / "AppData" / "Roaming" / "lumenfetch"
77
+ if sys.platform == "darwin":
78
+ return home / "Library" / "Application Support" / "lumenfetch"
79
+ xdg = os.environ.get("XDG_CONFIG_HOME")
80
+ if xdg:
81
+ return Path(xdg) / "lumenfetch"
82
+ return home / ".config" / "lumenfetch"
83
+
84
+
85
+ def _legacy_config_path() -> Path:
86
+ return _project_root() / "config.json"
87
+
88
+
89
+ def _resolve_config_path() -> Path:
90
+ """Prioritas: CONFIG_PATH yang di-monkeypatch test > user dir.
91
+
92
+ Fungsi ini dipanggil dinamis (bukan sekali di import) agar
93
+ `monkeypatch.setattr(utils, "CONFIG_PATH", ...)` tetap dihormati.
94
+ """
95
+ return CONFIG_PATH
96
+
97
+
98
+ # Lokasi kanonis: user dir. File lama di project root dimigrasi otomatis
99
+ # sekali saat load pertama (lihat _migrate_legacy_config).
100
+ CONFIG_PATH = _user_data_dir() / "config.json"
101
+
102
+
103
+ def get_default_download_folder() -> str:
104
+ """
105
+ Folder download default: folder "Downloads" asli sistem operasi user
106
+ (sama seperti browser lain - Chrome, Firefox, Edge, dll - defaultnya
107
+ selalu ke situ), BUKAN folder relatif "downloads/" di dalam folder
108
+ project. User tetap bisa ganti ke folder lain kapan saja lewat command
109
+ settings > [1] Ganti folder download default.
110
+ """
111
+ return str(Path.home() / "Downloads")
112
+
113
+
114
+ DEFAULT_CONFIG = {
115
+ "version": CONFIG_VERSION,
116
+ "download_folder": get_default_download_folder(),
117
+ "auto_paste": True,
118
+ "naming_template": "%(platform)s_%(title)s_%(year)s",
119
+ "max_retry": 3,
120
+ "cookies_browser": None,
121
+ "cookies_file": None,
122
+ "history": [],
123
+ }
124
+
125
+
126
+ def get_default_config() -> dict:
127
+ """
128
+ Return salinan BENAR-BENAR terpisah dari DEFAULT_CONFIG (deep copy).
129
+
130
+ Wajib pakai ini (bukan DEFAULT_CONFIG.copy()) di manapun butuh config default,
131
+ karena "history" adalah list - shallow copy cuma menyalin referensinya,
132
+ bukan isinya. Kalau nanti list itu dimutasi (mis. lewat add_history_entry),
133
+ DEFAULT_CONFIG module-level ikut "tercemar" dan bocor ke semua config baru
134
+ yang dibuat sesudahnya, termasuk pas user reset ke default.
135
+ """
136
+ return copy.deepcopy(DEFAULT_CONFIG)
137
+
138
+
139
+ def _coerce_config(data: dict) -> dict:
140
+ """Validasi ringan untuk config hasil load / edit manual."""
141
+ merged = get_default_config()
142
+ if not isinstance(data, dict):
143
+ return merged
144
+ merged.update(data)
145
+ # max_retry harus int >= 0, kalau rusak fallback ke default.
146
+ try:
147
+ merged["max_retry"] = max(0, int(merged.get("max_retry", 3)))
148
+ except (TypeError, ValueError):
149
+ merged["max_retry"] = 3
150
+ if not isinstance(merged.get("history"), list):
151
+ merged["history"] = []
152
+ # Normalisasi cookies_browser: "none"/"" dianggap None (nonaktif).
153
+ cb = merged.get("cookies_browser")
154
+ if cb is None:
155
+ pass
156
+ elif isinstance(cb, str) and cb.strip().lower() in ("", "none"):
157
+ merged["cookies_browser"] = None
158
+ elif isinstance(cb, str):
159
+ merged["cookies_browser"] = cb.strip().lower()
160
+ else:
161
+ merged["cookies_browser"] = None
162
+ if not isinstance(merged.get("download_folder"), str) or not merged["download_folder"].strip():
163
+ merged["download_folder"] = get_default_download_folder()
164
+ # cookies_file: path ke file cookies Netscape (opsional). Normalisasi "" -> None.
165
+ cf = merged.get("cookies_file")
166
+ if isinstance(cf, str):
167
+ cf = cf.strip()
168
+ merged["cookies_file"] = cf or None
169
+ else:
170
+ merged["cookies_file"] = None
171
+ return merged
172
+
173
+
174
+ def _canonical_config_path() -> Path:
175
+ return _user_data_dir() / "config.json"
176
+
177
+
178
+ def _migrate_legacy_config() -> None:
179
+ """Salin config lama (project root) ke user dir bila user dir belum ada.
180
+
181
+ Dilewati bila CONFIG_PATH sedang di-monkeypatch test ke lokasi lain.
182
+ """
183
+ try:
184
+ target = _resolve_config_path()
185
+ if target.resolve() != _canonical_config_path().resolve():
186
+ return
187
+ if target.exists():
188
+ return
189
+ legacy = _legacy_config_path()
190
+ # Jangan migrasi file yang cuma berisi default/Contoh history lokal dev.
191
+ if not legacy.exists() or legacy.resolve() == target.resolve():
192
+ return
193
+ data = json.loads(legacy.read_text(encoding="utf-8"))
194
+ if not isinstance(data, dict):
195
+ return
196
+ target.parent.mkdir(parents=True, exist_ok=True)
197
+ target.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
198
+ except (OSError, ValueError):
199
+ pass
200
+
201
+
202
+ def load_config() -> dict:
203
+ """Baca config.json, buat default kalau belum ada / rusak."""
204
+ _migrate_legacy_config()
205
+ path = _resolve_config_path()
206
+ if not path.exists():
207
+ fresh = get_default_config()
208
+ save_config(fresh)
209
+ return fresh
210
+
211
+ try:
212
+ with path.open("r", encoding="utf-8") as f:
213
+ data = json.load(f)
214
+ return _coerce_config(data)
215
+ except (json.JSONDecodeError, OSError, ValueError, AttributeError):
216
+ fresh = get_default_config()
217
+ save_config(fresh)
218
+ return fresh
219
+
220
+
221
+ def save_config(config: dict) -> None:
222
+ """
223
+ Simpan config ke config.json. Gagal simpan (mis. folder tempat app
224
+ dijalankan read-only) TIDAK raise - aplikasi tetap jalan pakai config
225
+ in-memory, cuma perubahannya gak ke-persist ke disk. Fail-safe di sini
226
+ otomatis melindungi SEMUA pemanggilnya (load_config, handle_settings di
227
+ main.py, add_history_entry) tanpa perlu tiap caller bungkus try/except
228
+ masing-masing.
229
+
230
+ utils.py sengaja tidak nge-print apa pun ke user (module ini "helper
231
+ murni" - lihat CONTRIBUTING.md, tidak boleh tahu soal CLI/rich) - kalau
232
+ perlu notifikasi ke user, itu tanggung jawab caller.
233
+
234
+ Thread-safe untuk batch paralel via lock proses-lokal.
235
+ """
236
+ lock = _history_lock()
237
+ with lock:
238
+ try:
239
+ path = _resolve_config_path()
240
+ path.parent.mkdir(parents=True, exist_ok=True)
241
+ with path.open("w", encoding="utf-8") as f:
242
+ json.dump(config, f, indent=2, ensure_ascii=False)
243
+ except OSError:
244
+ pass
245
+
246
+
247
+ _history_lock_instance = None
248
+
249
+
250
+ def _history_lock():
251
+ import threading
252
+
253
+ global _history_lock_instance
254
+ if _history_lock_instance is None:
255
+ _history_lock_instance = threading.Lock()
256
+ return _history_lock_instance
257
+
258
+
259
+ def add_history_entry(config: dict, entry: dict) -> None:
260
+ """Tambah entri riwayat baru, maksimal simpan HISTORY_LIMIT entri terakhir."""
261
+ history = config.get("history")
262
+ if not isinstance(history, list):
263
+ history = []
264
+ config["history"] = history
265
+ history.insert(0, entry)
266
+ config["history"] = history[:HISTORY_LIMIT]
267
+ save_config(config)
268
+
269
+
270
+ def clear_history(config: dict) -> None:
271
+ config["history"] = []
272
+ save_config(config)
273
+
274
+
275
+ def sanitize_filename(name: str | None) -> str:
276
+ """Bersihkan nama file dari karakter ilegal & spasi, truncate max 80 char."""
277
+ if not name:
278
+ name = "untitled"
279
+ name = re.sub(ILLEGAL_CHARS, "_", name)
280
+ # Ganti SEMUA whitespace (spasi, tab, newline) jadi "-", bukan cuma " ".
281
+ name = re.sub(r"\s+", "-", name.strip())
282
+ name = re.sub(r"-{2,}", "-", name)
283
+ # Windows melarang nama diakhiri titik/spasi.
284
+ name = name.strip(" .")
285
+ if not name:
286
+ name = "untitled"
287
+ # Nama reserved Windows (CON, PRN, ...) tidak boleh dipakai polos.
288
+ if name.upper() in WINDOWS_RESERVED_NAMES:
289
+ name = f"_{name}"
290
+ return name[:MAX_TITLE_LEN]
291
+
292
+
293
+ def is_valid_naming_template(template: str) -> bool:
294
+ """
295
+ Cek apakah naming template valid sebelum disimpan ke config - dipakai di
296
+ handle_settings() supaya user tidak bisa nyimpen template yang bakal
297
+ bikin build_filename() crash (mis. placeholder salah ketik seperti
298
+ %(titel)s bukannya %(title)s).
299
+ """
300
+ try:
301
+ template % {"platform": "x", "title": "x", "year": 2026}
302
+ except (KeyError, ValueError, TypeError):
303
+ return False
304
+ return True
305
+
306
+
307
+ def build_filename(platform: str, title: str, ext: str, template: str) -> str:
308
+ """
309
+ Susun nama file berdasarkan naming template dari config.
310
+
311
+ Fail-safe: kalau template ternyata tidak valid (mis. lolos tervalidasi
312
+ saat disimpan tapi rusak lewat edit manual config.json), otomatis
313
+ fallback ke template default - supaya kesalahan konfigurasi TIDAK PERNAH
314
+ bikin seluruh aplikasi crash pas lagi proses download.
315
+ """
316
+ year = datetime.now().year
317
+ values = {
318
+ "platform": sanitize_filename(platform or "Unknown"),
319
+ "title": sanitize_filename(title or "untitled"),
320
+ "year": year,
321
+ }
322
+ try:
323
+ base = template % values
324
+ except (KeyError, ValueError, TypeError):
325
+ base = DEFAULT_CONFIG["naming_template"] % values
326
+ base = sanitize_filename(base) if base else "untitled"
327
+ ext = (ext or "").lower().lstrip(".")
328
+ return f"{base}.{ext}" if ext else base
329
+
330
+
331
+ def _iter_dir_files(folder: Path):
332
+ try:
333
+ yield from folder.iterdir()
334
+ except OSError:
335
+ return
336
+
337
+
338
+ def find_indexed_files(dest: Path) -> list[Path]:
339
+ """
340
+ Cari file hasil outtmpl multi-item: pola "{stem}-<angka>.<ext>" persis
341
+ (index-nya dari %(playlist_index,autonumber)d di yt-dlp, jadi selalu
342
+ angka murni).
343
+
344
+ SENGAJA pakai regex yang di-anchor ke akhir nama file, BUKAN glob
345
+ "{stem}-*.*" biasa - glob polos itu false-positive kalau ada konten lain
346
+ yang judulnya kebetulan numpuk sebagai prefix. Misal stem "Sunset" bakal
347
+ salah kena "Sunset-Beach-1.jpg" (padahal itu punya konten LAIN yang
348
+ judulnya "Sunset-Beach"), karena "Beach-1.jpg" tetap cocok pola glob
349
+ "*.*" longgar. Dengan regex `-\\d+\\.` di akhir, "Sunset-Beach-1.jpg"
350
+ tidak match untuk stem "Sunset" (karena setelah "Sunset-" karakternya
351
+ "B", bukan digit), tapi tetap match buat stem "Sunset-Beach" itu sendiri.
352
+
353
+ Hasilnya diurutkan NUMERIK berdasarkan angka index-nya (bukan alfabetis
354
+ dari nama file) - kalau pakai sorted() string biasa, "Galeri-10.jpg"
355
+ bakal muncul SEBELUM "Galeri-2.jpg" (perbandingan karakter '1' < '2'),
356
+ padahal urutan aslinya seharusnya 2 dulu baru 10.
357
+
358
+ FIX: tidak pakai `glob(f"{stem}-*.*")` mentah karena stem bisa mengandung
359
+ karakter glob (`[`, `*`, `?`) dari judul asli (mis. "Video [Official]").
360
+ Pakai iterdir + regex agar aman untuk semua judul.
361
+ """
362
+ pattern = re.compile(rf"^{re.escape(dest.stem)}-(\d+)\.[^.]+$")
363
+ indexed_matches = []
364
+ for p in _iter_dir_files(dest.parent):
365
+ if not p.is_file():
366
+ continue
367
+ m = pattern.match(p.name)
368
+ if m:
369
+ indexed_matches.append((int(m.group(1)), p))
370
+
371
+ indexed_matches.sort(key=lambda pair: pair[0])
372
+ return [p for _, p in indexed_matches]
373
+
374
+
375
+ def resolve_duplicate(path: Path, multi_item: bool = False) -> Path:
376
+ """
377
+ Kalau nama file sudah ada, tambahin suffix (1), (2), dst ke base filename.
378
+
379
+ multi_item=True dipakai khusus buat galeri/carousel (outtmpl-nya nanti
380
+ dikasih suffix index seperti "-1.jpg", "-2.jpg" - lihat _build_ydl_opts
381
+ di downloader.py). File persis "{stem}.{ext}" TIDAK PERNAH benar-benar
382
+ dibuat untuk kasus ini, jadi mengecek exists() pada path itu sendiri
383
+ salah - selalu balik False meski sudah pernah didownload sebelumnya.
384
+ Yang perlu dicek adalah apakah ADA file terindeks dengan stem itu di
385
+ folder tujuan (lihat find_indexed_files). Tanpa ini, download galeri
386
+ yang sama dua kali diam-diam menimpa hasil download pertama, alih-alih
387
+ dapat suffix (1) seperti perilaku normal untuk konten single-item.
388
+ """
389
+
390
+ def _already_exists(p: Path) -> bool:
391
+ if multi_item:
392
+ return bool(find_indexed_files(p))
393
+ return p.exists()
394
+
395
+ if not _already_exists(path):
396
+ return path
397
+
398
+ stem, suffix = path.stem, path.suffix
399
+ counter = 1
400
+ while True:
401
+ candidate = path.with_name(f"{stem}({counter}){suffix}")
402
+ if not _already_exists(candidate):
403
+ return candidate
404
+ counter += 1
405
+
406
+
407
+ def build_cookies_from_browser(browser: str | None):
408
+ """
409
+ Ubah nama browser dari config jadi format yang dipahami yt-dlp
410
+ (opsi 'cookiesfrombrowser'). Return None kalau fitur nonaktif.
411
+ """
412
+ if not browser:
413
+ return None
414
+ normalized = str(browser).strip().lower()
415
+ if normalized in ("", "none"):
416
+ return None
417
+ if normalized not in SUPPORTED_COOKIE_BROWSERS:
418
+ return None
419
+ return (normalized, None, None, None)
420
+
421
+
422
+ def resolve_cookie_file(path: str | None) -> str | None:
423
+ """Validasi path file cookies (format Netscape). Return path absolut atau None."""
424
+ if not path:
425
+ return None
426
+ p = Path(str(path)).expanduser()
427
+ if p.is_file():
428
+ return str(p)
429
+ return None
430
+
431
+
432
+ def apply_cookie_opts(ydl_opts: dict, cookies_browser: str | None, cookies_file: str | None = None) -> bool:
433
+ """Terapkan opsi cookies ke ydl_opts. Return True bila ada cookies yang dipakai.
434
+
435
+ Urutan: file cookies dulu (paling reliable, works walau browser terbuka),
436
+ lalu cookies browser.
437
+ """
438
+ cookie_file = resolve_cookie_file(cookies_file)
439
+ if cookie_file:
440
+ ydl_opts["cookiefile"] = cookie_file
441
+ return True
442
+ cookies = build_cookies_from_browser(cookies_browser)
443
+ if cookies:
444
+ ydl_opts["cookiesfrombrowser"] = cookies
445
+ return True
446
+ return False
447
+
448
+
449
+ def format_size(num_bytes: float | None) -> str:
450
+ """Format bytes jadi human-readable (KB/MB/GB)."""
451
+ if num_bytes is None:
452
+ return "?"
453
+ try:
454
+ num = float(num_bytes)
455
+ except (TypeError, ValueError):
456
+ return "?"
457
+ if num < 0:
458
+ num = 0
459
+ for unit in ("B", "KB", "MB", "GB"):
460
+ if num < 1024:
461
+ return f"{num:.1f} {unit}"
462
+ num /= 1024
463
+ return f"{num:.1f} TB"
464
+
465
+
466
+ def format_duration(seconds) -> str:
467
+ """Format detik jadi H:MM:SS atau M:SS."""
468
+ if not seconds:
469
+ return "-"
470
+ try:
471
+ seconds = int(seconds)
472
+ except (TypeError, ValueError):
473
+ return "-"
474
+ if seconds <= 0:
475
+ return "-"
476
+ h, rem = divmod(seconds, 3600)
477
+ m, s = divmod(rem, 60)
478
+ if h:
479
+ return f"{h}:{m:02d}:{s:02d}"
480
+ return f"{m}:{s:02d}"
481
+
482
+
483
+ def _format_size_bytes_from_info(fmt: dict, duration: float | None) -> int | None:
484
+ """
485
+ Ambil ukuran (bytes) dari satu format yt-dlp. "filesize" itu ukuran
486
+ exact yang dilaporkan platform, "filesize_approx" perkiraan yt-dlp
487
+ sendiri. Kalau dua-duanya gak ada (cukup umum, banyak platform gak
488
+ ngasih tau ukuran di metadata), fallback hitung dari bitrate (tbr,
489
+ satuan kbit/s) dikali durasi video.
490
+ """
491
+ if not isinstance(fmt, dict):
492
+ return None
493
+ if fmt.get("filesize"):
494
+ try:
495
+ return int(fmt["filesize"])
496
+ except (TypeError, ValueError):
497
+ pass
498
+ if fmt.get("filesize_approx"):
499
+ try:
500
+ return int(fmt["filesize_approx"])
501
+ except (TypeError, ValueError):
502
+ pass
503
+ try:
504
+ tbr = float(fmt.get("tbr") or 0)
505
+ except (TypeError, ValueError):
506
+ tbr = 0
507
+ if tbr and duration:
508
+ try:
509
+ return int(tbr * 1000 / 8 * float(duration))
510
+ except (TypeError, ValueError):
511
+ return None
512
+ return None
513
+
514
+
515
+ def estimate_video_size_bytes(formats: list[dict], quality: str, duration: float | None) -> int | None:
516
+ """
517
+ Estimasi ukuran total (video + audio yang bakal digabung) buat satu
518
+ opsi quality, berdasarkan daftar format yang sudah didapat yt-dlp pas
519
+ deteksi (tanpa perlu request tambahan ke platform). Return None kalau
520
+ datanya gak cukup buat ngasih estimasi (mis. platform gak expose
521
+ filesize/bitrate sama sekali) - lebih baik gak nampilin estimasi
522
+ daripada nampilin angka ngasal.
523
+ """
524
+ if not formats or not duration:
525
+ return None
526
+
527
+ limit = HEIGHT_LIMITS.get(quality)
528
+
529
+ video_formats = [f for f in formats if isinstance(f, dict) and f.get("vcodec") not in (None, "none") and f.get("height")]
530
+ if not video_formats:
531
+ return None
532
+
533
+ if quality == "Worst":
534
+ video_formats.sort(key=lambda f: f["height"])
535
+ else:
536
+ if limit:
537
+ video_formats = [f for f in video_formats if f["height"] <= limit]
538
+ if not video_formats:
539
+ # Tier ini kemungkinan besar gak tersedia buat konten ini
540
+ # (mis. sumbernya cuma sampai 720p, gak ada opsi 360p) -
541
+ # mending gak nampilin estimasi sama sekali daripada nebak
542
+ # pakai ukuran tier LAIN yang bisa menyesatkan (kelihatan
543
+ # kayak "360p ukurannya segede 1080p").
544
+ return None
545
+ video_formats.sort(key=lambda f: f["height"], reverse=True)
546
+
547
+ video_size = _format_size_bytes_from_info(video_formats[0], duration)
548
+ if video_size is None:
549
+ return None
550
+
551
+ audio_formats = [
552
+ f for f in formats if isinstance(f, dict) and f.get("acodec") not in (None, "none") and f.get("vcodec") in (None, "none")
553
+ ]
554
+ audio_size = 0
555
+ if audio_formats:
556
+ audio_formats.sort(key=lambda f: f.get("abr") or 0, reverse=(quality != "Worst"))
557
+ audio_size = _format_size_bytes_from_info(audio_formats[0], duration) or 0
558
+
559
+ return video_size + audio_size
560
+
561
+
562
+ def estimate_audio_size_bytes(formats: list[dict], quality: str, duration: float | None) -> int | None:
563
+ """
564
+ Estimasi ukuran hasil ekstraksi audio. Untuk quality dengan bitrate
565
+ tetap (320/192/128 kbps), dihitung langsung dari bitrate target itu
566
+ sendiri (lebih akurat, karena itu memang bitrate output-nya setelah
567
+ ffmpeg convert - bukan bitrate sumbernya). Untuk "Best", dipakai
568
+ estimasi dari stream audio sumber terbaik yang ada.
569
+ """
570
+ if not duration:
571
+ return None
572
+
573
+ try:
574
+ dur = float(duration)
575
+ except (TypeError, ValueError):
576
+ return None
577
+ if dur <= 0:
578
+ return None
579
+
580
+ target_kbps = AUDIO_TARGET_KBPS.get(quality)
581
+ if target_kbps:
582
+ return int(target_kbps * 1000 / 8 * dur)
583
+
584
+ audio_formats = [f for f in (formats or []) if isinstance(f, dict) and f.get("acodec") not in (None, "none")]
585
+ if not audio_formats:
586
+ return None
587
+ audio_formats.sort(key=lambda f: f.get("abr") or 0, reverse=True)
588
+ return _format_size_bytes_from_info(audio_formats[0], dur)
589
+
590
+
591
+ def estimate_average_speed_bps(history: list[dict], sample_size: int = 10) -> float | None:
592
+ """
593
+ Kecepatan download rata-rata (bytes/detik) dari beberapa download
594
+ terakhir yang sukses di riwayat - dipakai buat estimasi waktu download,
595
+ lebih jujur daripada nebak angka kecepatan generik yang belum tentu
596
+ sesuai koneksi user. Return None kalau belum ada riwayat yang punya
597
+ data lengkap (mis. baru pertama kali pakai aplikasi).
598
+ """
599
+ speeds = []
600
+ for entry in (history or [])[:sample_size]:
601
+ if not isinstance(entry, dict) or not entry.get("success"):
602
+ continue
603
+ size_bytes = entry.get("size_bytes")
604
+ elapsed = entry.get("elapsed_seconds")
605
+ try:
606
+ if size_bytes and elapsed and float(elapsed) > 0:
607
+ speeds.append(float(size_bytes) / float(elapsed))
608
+ except (TypeError, ValueError):
609
+ continue
610
+
611
+ if not speeds:
612
+ return None
613
+ return sum(speeds) / len(speeds)
614
+
615
+
616
+ def format_eta(size_bytes: int | None, speed_bps: float | None) -> str | None:
617
+ """Ubah estimasi ukuran + kecepatan jadi teks perkiraan waktu ("~14 detik", "~2 menit"). None kalau datanya kurang."""
618
+ try:
619
+ if not size_bytes or not speed_bps or float(speed_bps) <= 0:
620
+ return None
621
+ seconds = float(size_bytes) / float(speed_bps)
622
+ except (TypeError, ValueError):
623
+ return None
624
+
625
+ if seconds < 60:
626
+ return f"~{seconds:.0f} detik"
627
+ minutes = seconds / 60
628
+ if minutes < 60:
629
+ return f"~{minutes:.0f} menit"
630
+ hours = minutes / 60
631
+ return f"~{hours:.1f} jam"