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/__init__.py +6 -0
- core/constants.py +99 -0
- core/detector.py +167 -0
- core/downloader.py +323 -0
- core/errors.py +118 -0
- core/ffmpeg.py +74 -0
- core/log.py +75 -0
- core/options.py +292 -0
- core/update_checker.py +63 -0
- core/utils.py +631 -0
- lumenfetch-2.0.0.dist-info/METADATA +387 -0
- lumenfetch-2.0.0.dist-info/RECORD +16 -0
- lumenfetch-2.0.0.dist-info/WHEEL +4 -0
- lumenfetch-2.0.0.dist-info/entry_points.txt +2 -0
- lumenfetch-2.0.0.dist-info/licenses/LICENSE +21 -0
- main.py +557 -0
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"
|