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/errors.py ADDED
@@ -0,0 +1,118 @@
1
+ """errors.py - Taxonomy error terpusat.
2
+
3
+ Sebelumnya logic klasifikasi error duplikat di detector.py dan
4
+ downloader.py dengan pola string yang bisa drift (mis. satu modul
5
+ kenal "429", satunya tidak). Sekarang satu fungsi klasifikasi dipakai
6
+ bersama, dengan pesan ramah yang konsisten.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from core.constants import CONNECTION_ERROR_HINTS
12
+
13
+
14
+ class DetectionError(Exception):
15
+ """Error ramah untuk ditampilkan ke user (bukan traceback mentah)."""
16
+
17
+
18
+ class DownloadCancelled(Exception):
19
+ """User cancel download (Ctrl+C)."""
20
+
21
+
22
+ class DownloadFailed(Exception):
23
+ """Error fatal yang gak perlu di-retry (pesan sudah ramah)."""
24
+
25
+
26
+ # Kind yang dikenal classify_error().
27
+ ERROR_KIND_CONNECTION = "connection"
28
+ ERROR_KIND_PRIVATE = "private"
29
+ ERROR_KIND_FORMAT = "format"
30
+ ERROR_KIND_DISK = "disk"
31
+ ERROR_KIND_RATE_LIMIT = "rate_limit"
32
+ ERROR_KIND_REMOVED = "removed"
33
+ ERROR_KIND_UNSUPPORTED = "unsupported"
34
+ ERROR_KIND_BLOCKED = "blocked"
35
+ ERROR_KIND_COOKIES = "cookies"
36
+ ERROR_KIND_UNKNOWN = "unknown"
37
+
38
+ _FRIENDLY_MESSAGES = {
39
+ ERROR_KIND_CONNECTION: "❌ Koneksi internet bermasalah, coba lagi",
40
+ ERROR_KIND_PRIVATE: "❌ Konten ini private / tidak bisa diakses - coba aktifkan Cookies dari Browser di settings",
41
+ ERROR_KIND_FORMAT: "❌ Format tidak tersedia untuk konten ini",
42
+ ERROR_KIND_DISK: "❌ Tidak cukup ruang disk",
43
+ ERROR_KIND_RATE_LIMIT: "❌ Terlalu banyak request ke platform ini - tunggu sebentar lalu coba lagi",
44
+ ERROR_KIND_REMOVED: "❌ Konten ini sudah dihapus / tidak lagi tersedia",
45
+ ERROR_KIND_UNSUPPORTED: "❌ URL tidak valid atau tidak didukung",
46
+ ERROR_KIND_BLOCKED: (
47
+ "❌ Platform memblokir request otomatis (bot-check) - update yt-dlp "
48
+ "(pip install --upgrade yt-dlp) lalu aktifkan Cookies dari Browser di settings"
49
+ ),
50
+ ERROR_KIND_COOKIES: (
51
+ "❌ Gagal membaca cookies dari browser - tutup browser-nya sampai "
52
+ "benar-benar tertutup (cek Task Manager), lalu coba lagi"
53
+ ),
54
+ ERROR_KIND_UNKNOWN: "❌ Terjadi kesalahan saat download",
55
+ }
56
+
57
+
58
+ def classify_error(exc: Exception) -> str:
59
+ """Klasifikasikan exception (terutama dari yt-dlp) jadi kind string."""
60
+ msg = str(exc).lower()
61
+ if any(hint in msg for hint in CONNECTION_ERROR_HINTS):
62
+ return ERROR_KIND_CONNECTION
63
+ if "could not copy" in msg and "cookie" in msg:
64
+ # File cookies dikunci browser yang masih terbuka (yt-dlp #7271).
65
+ # Bukan URL rusak - harus ditangani khusus (coba tanpa cookies).
66
+ return ERROR_KIND_COOKIES
67
+ if "failed to decrypt" in msg or "dpapi" in msg or "failed to load cookies" in msg:
68
+ # Cookies terenkripsi tak bisa didekripsi (yt-dlp #10927, umum di
69
+ # Edge/Chrome Windows) - sama: bukan URL rusak, fallback tanpa cookies.
70
+ return ERROR_KIND_COOKIES
71
+ if "unexpected response from webpage request" in msg or "confirm you are on the latest version" in msg:
72
+ # Challenge bot-check yang cuma lolos pakai impersonation + cookies,
73
+ # bukan URL rusak.
74
+ return ERROR_KIND_BLOCKED
75
+ if "private" in msg or "login required" in msg or "login" in msg or "unavailable" in msg:
76
+ return ERROR_KIND_PRIVATE
77
+ if "unsupported url" in msg:
78
+ return ERROR_KIND_UNSUPPORTED
79
+ if "requested format is not available" in msg or "no video formats" in msg:
80
+ return ERROR_KIND_FORMAT
81
+ if "no space left" in msg:
82
+ return ERROR_KIND_DISK
83
+ if "429" in msg or "too many requests" in msg or "rate-limit" in msg or "rate limit" in msg:
84
+ return ERROR_KIND_RATE_LIMIT
85
+ if "404" in msg or "not found" in msg or "removed" in msg or "deleted" in msg or "no longer available" in msg:
86
+ return ERROR_KIND_REMOVED
87
+ return ERROR_KIND_UNKNOWN
88
+
89
+
90
+ def _curl_cffi_available() -> bool:
91
+ import importlib.util
92
+
93
+ return importlib.util.find_spec("curl_cffi") is not None
94
+
95
+
96
+ def blocked_message() -> str:
97
+ """Pesan blocked yang adaptif: sebut curl_cffi hanya bila belum terinstall."""
98
+ base = _FRIENDLY_MESSAGES[ERROR_KIND_BLOCKED]
99
+ if not _curl_cffi_available():
100
+ base += " + install paket 'curl_cffi' (pip install curl_cffi)"
101
+ return base
102
+
103
+
104
+ def friendly_message(kind: str) -> str:
105
+ """Pesan ramah untuk kind hasil classify_error()."""
106
+ if kind == ERROR_KIND_BLOCKED:
107
+ return blocked_message()
108
+ return _FRIENDLY_MESSAGES.get(kind, _FRIENDLY_MESSAGES[ERROR_KIND_UNKNOWN])
109
+
110
+
111
+ def detection_message(exc: Exception) -> str:
112
+ """Pesan deteksi: sama dengan friendly, tapi unknown -> URL YouTube tidak valid."""
113
+ kind = classify_error(exc)
114
+ if kind == ERROR_KIND_FORMAT:
115
+ return "Konten ini tidak berisi video yang bisa didownload"
116
+ if kind == ERROR_KIND_UNKNOWN:
117
+ return "URL bukan link YouTube yang valid atau tidak didukung"
118
+ return friendly_message(kind).lstrip("❌ ").strip()
core/ffmpeg.py ADDED
@@ -0,0 +1,74 @@
1
+ """ffmpeg.py - Deteksi & penyiapan ffmpeg (sistem dulu, bundled fallback)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+
8
+ from core.constants import FFMPEG_TIMEOUT_SECONDS
9
+
10
+
11
+ def find_system_ffmpeg() -> str | None:
12
+ """Return path ffmpeg sistem kalau ada di PATH, else None."""
13
+ return shutil.which("ffmpeg")
14
+
15
+
16
+ def is_available() -> bool:
17
+ return find_system_ffmpeg() is not None
18
+
19
+
20
+ def exe() -> str:
21
+ """Nama/path executable ffmpeg yang dipakai yt-dlp (merge MP4 / ekstrak MP3)."""
22
+ return find_system_ffmpeg() or "ffmpeg"
23
+
24
+
25
+ def ensure() -> bool:
26
+ """Pastikan ffmpeg bisa dipanggil.
27
+
28
+ 1. Kalau ffmpeg sistem ada -> langsung True (tanpa download apa pun).
29
+ 2. Kalau tidak ada -> coba static_ffmpeg bundled (lazy import).
30
+ 3. Verifikasi dengan `ffmpeg -version` (cepat, timeout 10s).
31
+ """
32
+ if is_available():
33
+ return _verify(exe())
34
+ try:
35
+ import static_ffmpeg # noqa: PLC0415
36
+ except ImportError:
37
+ return False
38
+ try:
39
+ static_ffmpeg.add_paths()
40
+ except Exception: # noqa: BLE001
41
+ return False
42
+ return _verify(exe())
43
+
44
+
45
+ def _verify(ffmpeg_exe: str, timeout: int = 10) -> bool:
46
+ try:
47
+ subprocess.run(
48
+ [ffmpeg_exe, "-version"],
49
+ check=True,
50
+ stdout=subprocess.DEVNULL,
51
+ stderr=subprocess.DEVNULL,
52
+ timeout=timeout,
53
+ )
54
+ return True
55
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError, subprocess.TimeoutExpired):
56
+ return False
57
+
58
+
59
+ def version() -> str | None:
60
+ """Versi ffmpeg (baris pertama `ffmpeg -version`), None kalau tak tersedia."""
61
+ try:
62
+ out = subprocess.run(
63
+ [exe(), "-version"],
64
+ check=True,
65
+ capture_output=True,
66
+ text=True,
67
+ timeout=10,
68
+ ).stdout
69
+ return out.splitlines()[0].strip() if out else None
70
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError, subprocess.TimeoutExpired):
71
+ return None
72
+
73
+
74
+ __all__ = ["find_system_ffmpeg", "is_available", "exe", "ensure", "version", "FFMPEG_TIMEOUT_SECONDS"]
core/log.py ADDED
@@ -0,0 +1,75 @@
1
+ """log.py - Logging file terpusat (console tetap ramah via rich)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ _LOGGER_NAME = "lumenfetch"
10
+ _configured = False
11
+
12
+
13
+ def log_file_path() -> Path:
14
+ """Lokasi file log (sefolder dengan config user)."""
15
+ from core.utils import _user_data_dir # noqa: PLC0415 - hindari circular import
16
+
17
+ d = _user_data_dir()
18
+ try:
19
+ d.mkdir(parents=True, exist_ok=True)
20
+ except OSError:
21
+ pass
22
+ return d / "lumenfetch.log"
23
+
24
+
25
+ def setup(verbose: bool = False) -> logging.Logger:
26
+ """Setup handler file + stderr. Aman dipanggil berkali-kali."""
27
+ global _configured
28
+ logger = logging.getLogger(_LOGGER_NAME)
29
+ level = logging.DEBUG if verbose else logging.INFO
30
+ logger.setLevel(level)
31
+ if _configured:
32
+ return logger
33
+ fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
34
+ try:
35
+ fh = logging.FileHandler(str(log_file_path()), encoding="utf-8")
36
+ fh.setLevel(logging.DEBUG)
37
+ fh.setFormatter(fmt)
38
+ logger.addHandler(fh)
39
+ except OSError:
40
+ pass
41
+ # Error ke stderr agar tidak hilang saat rich console di-redirect.
42
+ sh = logging.StreamHandler(sys.stderr)
43
+ sh.setLevel(logging.WARNING)
44
+ sh.setFormatter(fmt)
45
+ logger.addHandler(sh)
46
+ _configured = True
47
+ return logger
48
+
49
+
50
+ def get_logger(name: str = _LOGGER_NAME) -> logging.Logger:
51
+ return logging.getLogger(name)
52
+
53
+
54
+ def get_ydl_logger() -> logging.Logger:
55
+ """Logger khusus untuk yt-dlp: semua output mentahnya (termasuk baris
56
+ `ERROR:` yang tidak bisa dimatikan via `quiet`) dialihkan ke file log,
57
+ TIDAK ke terminal. Terminal hanya menampilkan pesan ramah via rich."""
58
+ name = f"{_LOGGER_NAME}.ydl"
59
+ logger = logging.getLogger(name)
60
+ if getattr(logger, "_lumenfetch_configured", False):
61
+ return logger
62
+ logger.setLevel(logging.DEBUG)
63
+ logger.propagate = False
64
+ try:
65
+ fh = logging.FileHandler(str(log_file_path()), encoding="utf-8")
66
+ fh.setLevel(logging.DEBUG)
67
+ fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
68
+ logger.addHandler(fh)
69
+ except OSError:
70
+ logger.addHandler(logging.NullHandler())
71
+ logger._lumenfetch_configured = True # type: ignore[attr-defined]
72
+ return logger
73
+
74
+
75
+ __all__ = ["setup", "get_logger", "get_ydl_logger", "log_file_path"]
core/options.py ADDED
@@ -0,0 +1,292 @@
1
+ """
2
+ options.py
3
+ Semua prompt/menu interaktif (pilih output, quality, dll) memakai Rich.
4
+ Tidak ada logic download di sini.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.prompt import Confirm, Prompt
14
+ from rich.table import Table
15
+
16
+ from core.constants import (
17
+ AUDIO_FORMAT,
18
+ AUDIO_QUALITIES,
19
+ PLAYLIST_MODES,
20
+ SUBTITLE_LANGS_DEFAULT,
21
+ VERSION,
22
+ VIDEO_FORMAT,
23
+ VIDEO_QUALITIES,
24
+ )
25
+ from core.detector import DetectedContent
26
+ from core.utils import estimate_audio_size_bytes, estimate_video_size_bytes, format_duration, format_eta, format_size
27
+
28
+ console = Console()
29
+
30
+ __all__ = [
31
+ "DownloadChoice",
32
+ "VIDEO_QUALITIES",
33
+ "AUDIO_QUALITIES",
34
+ "PLAYLIST_MODES",
35
+ ]
36
+
37
+
38
+ @dataclass
39
+ class DownloadChoice:
40
+ output_kind: str # "video" | "audio"
41
+ quality: str = "Best"
42
+ fmt: str = "" # "mp4" untuk video, "mp3" untuk audio
43
+ playlist_mode: str = "single" # "single" | "all"
44
+ write_subs: bool = False
45
+ sub_langs: list[str] | None = None
46
+ embed_subs: bool = False
47
+ embed_metadata: bool = False
48
+
49
+
50
+ def show_header(version: str = VERSION) -> None:
51
+ console.print(
52
+ Panel.fit(
53
+ f"[bold cyan]Lumenfetch[/bold cyan] v{version}\n[dim]YouTube downloader (MP4/MP3)[/dim]",
54
+ border_style="cyan",
55
+ )
56
+ )
57
+
58
+
59
+ def show_update_notice(installed_version: str, latest_version: str) -> None:
60
+ console.print(
61
+ Panel(
62
+ f"yt-dlp kamu versi [bold]{installed_version}[/bold], "
63
+ f"versi terbaru [bold green]{latest_version}[/bold green] sudah tersedia.\n"
64
+ "[dim]YouTube sering ubah sistemnya, jadi update disarankan biar tetap kompatibel:[/dim]\n"
65
+ "[bold cyan]pip install --upgrade yt-dlp[/bold cyan]",
66
+ title="🔔 Update yt-dlp tersedia",
67
+ border_style="yellow",
68
+ )
69
+ )
70
+
71
+
72
+ def show_content_panel(content: DetectedContent) -> None:
73
+ count_text = f" ({len(content.entries)} video)" if content.entries else ""
74
+ lines = [
75
+ f"[bold]Judul[/bold] : {content.title}",
76
+ f"[bold]Tipe[/bold] : {content.content_type}{count_text}",
77
+ ]
78
+ if content.duration:
79
+ lines.append(f"[bold]Durasi[/bold] : {format_duration(content.duration)}")
80
+ console.print(Panel("\n".join(lines), title="✅ Video Terdeteksi", border_style="green"))
81
+
82
+
83
+ def _select_from_list(
84
+ prompt_text: str,
85
+ choices: list[str],
86
+ default_index: int = 0,
87
+ extra: list[str] | None = None,
88
+ ) -> str:
89
+ console.print(f"\n{prompt_text}")
90
+ for i, c in enumerate(choices):
91
+ line = f" [{i + 1}] {c}"
92
+ if i == default_index:
93
+ line += " (default)"
94
+ if extra and extra[i]:
95
+ line += f" [dim]{extra[i]}[/dim]"
96
+ console.print(line)
97
+
98
+ raw = Prompt.ask("→", default=str(default_index + 1))
99
+ try:
100
+ idx = int(str(raw).strip()) - 1
101
+ if 0 <= idx < len(choices):
102
+ return choices[idx]
103
+ except (ValueError, TypeError):
104
+ pass
105
+ return choices[default_index]
106
+
107
+
108
+ def ask_output_type() -> str:
109
+ console.print("\nPilih tipe output:")
110
+ console.print(" [1] Video (MP4)")
111
+ console.print(" [2] Audio (MP3)")
112
+ raw = Prompt.ask("→", default="1")
113
+ return "audio" if str(raw).strip() == "2" else "video"
114
+
115
+
116
+ def _build_size_labels(
117
+ formats: list[dict],
118
+ qualities: list[str],
119
+ duration: float | None,
120
+ avg_speed_bps: float | None,
121
+ estimator,
122
+ ) -> list[str]:
123
+ labels = []
124
+ for quality in qualities:
125
+ size_bytes = estimator(formats, quality, duration)
126
+ if size_bytes is None:
127
+ labels.append("")
128
+ continue
129
+ size_text = f"~{format_size(size_bytes)}"
130
+ eta_text = format_eta(size_bytes, avg_speed_bps)
131
+ labels.append(f"({size_text}, {eta_text})" if eta_text else f"({size_text})")
132
+ return labels
133
+
134
+
135
+ def ask_video_choice(content: DetectedContent | None = None, avg_speed_bps: float | None = None) -> DownloadChoice:
136
+ extra = None
137
+ if content is not None:
138
+ formats = content.raw_info.get("formats") or []
139
+ extra = _build_size_labels(formats, VIDEO_QUALITIES, content.duration, avg_speed_bps, estimate_video_size_bytes)
140
+
141
+ quality = _select_from_list("Pilih Quality:", VIDEO_QUALITIES, default_index=0, extra=extra)
142
+ return DownloadChoice(output_kind="video", quality=quality, fmt=VIDEO_FORMAT)
143
+
144
+
145
+ def ask_audio_choice(content: DetectedContent | None = None, avg_speed_bps: float | None = None) -> DownloadChoice:
146
+ extra = None
147
+ if content is not None:
148
+ formats = content.raw_info.get("formats") or []
149
+ extra = _build_size_labels(formats, AUDIO_QUALITIES, content.duration, avg_speed_bps, estimate_audio_size_bytes)
150
+
151
+ quality = _select_from_list("Pilih Quality (bitrate MP3):", AUDIO_QUALITIES, default_index=0, extra=extra)
152
+ return DownloadChoice(output_kind="audio", quality=quality, fmt=AUDIO_FORMAT)
153
+
154
+
155
+ def ask_playlist_mode(total: int) -> str:
156
+ """Tanya mode playlist: 1 video pertama (default) atau semua."""
157
+ console.print(f"\nURL ini playlist berisi {total} video:")
158
+ console.print(" [1] Download video pertama saja (default)")
159
+ console.print(f" [2] Download semua ({total} video)")
160
+ raw = Prompt.ask("→", default="1")
161
+ return "all" if str(raw).strip() == "2" else "single"
162
+
163
+
164
+ def ask_subtitle_options() -> tuple[bool, list[str] | None, bool]:
165
+ """Return (write_subs, sub_langs, embed_subs). Default mati."""
166
+ console.print("\nDownload subtitle?")
167
+ console.print(" [1] Tidak (default)")
168
+ console.print(" [2] Ya, download file subtitle")
169
+ raw = Prompt.ask("→", default="1")
170
+ if str(raw).strip() != "2":
171
+ return False, None, False
172
+ langs_raw = Prompt.ask("Bahasa subtitle (koma, mis. en,id)", default=",".join(SUBTITLE_LANGS_DEFAULT))
173
+ langs = [p.strip().lower() for p in str(langs_raw).replace(",", " ").split() if p.strip()]
174
+ embed = Confirm.ask("Embed subtitle ke file MP4?", default=False)
175
+ return True, langs or list(SUBTITLE_LANGS_DEFAULT), bool(embed)
176
+
177
+
178
+ def ask_embed_metadata() -> bool:
179
+ return Confirm.ask("Embed metadata (judul/artis) ke file hasil?", default=False)
180
+
181
+
182
+ def resolve_choice(content: DetectedContent, avg_speed_bps: float | None = None) -> DownloadChoice:
183
+ """Alur pilihan dinamis: video YouTube -> tanya output, lalu quality."""
184
+ if content.content_type == "AUDIO":
185
+ return ask_audio_choice(content, avg_speed_bps)
186
+ kind = ask_output_type()
187
+ if kind == "audio":
188
+ return ask_audio_choice(content, avg_speed_bps)
189
+ return ask_video_choice(content, avg_speed_bps)
190
+
191
+
192
+ def confirm_filename(filename: str) -> bool:
193
+ console.print(f"\n📄 Nama file: [bold]{filename}[/bold]")
194
+ return Confirm.ask("Lanjutkan?", default=True)
195
+
196
+
197
+ def ask_again_or_url() -> bool | str:
198
+ """Prompt lanjutan yang toleran terhadap paste URL langsung."""
199
+ from core.detector import is_valid_url # noqa: PLC0415 - hindari circular import
200
+
201
+ while True:
202
+ raw = Prompt.ask("\nDownload lagi? [y/n] [dim](atau paste URL langsung)[/dim]", default="y").strip()
203
+ if is_valid_url(raw):
204
+ return raw
205
+ low = raw.lower()
206
+ if low in ("y", "yes") or low.startswith("y"):
207
+ return True
208
+ if low in ("n", "no", "q", "quit") or low.startswith("n"):
209
+ return False
210
+ console.print("[red]Jawab y/n, atau paste URL langsung.[/red]")
211
+
212
+
213
+ def ask_again() -> bool:
214
+ return Confirm.ask("\nDownload lagi?", default=True)
215
+
216
+
217
+ def show_history_table(history: list[dict]) -> None:
218
+ if not history:
219
+ console.print("[dim]Belum ada riwayat download.[/dim]")
220
+ return
221
+
222
+ table = Table(title="Riwayat Download (20 terakhir)")
223
+ for col in ("No", "Tanggal", "Judul", "Format", "Ukuran", "Status"):
224
+ table.add_column(col)
225
+
226
+ for i, entry in enumerate(history, start=1):
227
+ if not isinstance(entry, dict):
228
+ continue
229
+ status = "✅" if entry.get("success") else "❌"
230
+ table.add_row(
231
+ str(i),
232
+ str(entry.get("date") or "-"),
233
+ str(entry.get("title") or "-")[:40],
234
+ str(entry.get("format") or "-"),
235
+ str(entry.get("size") or "-"),
236
+ status,
237
+ )
238
+ console.print(table)
239
+
240
+
241
+ def show_help() -> None:
242
+ console.print(
243
+ Panel(
244
+ "[bold]COMMAND TERSEDIA[/bold]\n"
245
+ "history Lihat riwayat download\n"
246
+ "settings Buka pengaturan\n"
247
+ "help Tampilkan halaman ini\n"
248
+ "q/quit Keluar dari aplikasi\n\n"
249
+ "[bold]CARA PAKAI[/bold]\n"
250
+ "1. Paste link video YouTube\n"
251
+ "2. Pilih output Video (MP4) / Audio (MP3)\n"
252
+ "3. Pilih quality\n"
253
+ "4. Tunggu download selesai\n\n"
254
+ "[bold]CANCEL DOWNLOAD[/bold]\n"
255
+ "Tekan Ctrl+C saat download berjalan",
256
+ title="📖 Lumenfetch Help",
257
+ border_style="blue",
258
+ )
259
+ )
260
+
261
+
262
+ def show_settings_menu(config: dict) -> str:
263
+ cookies_status = config.get("cookies_browser") or config.get("cookies_file") or "none"
264
+ console.print("\n[bold]Settings[/bold]")
265
+ console.print(f" [1] Ganti folder download default [dim](saat ini: {config['download_folder']})[/dim]")
266
+ console.print(f" [2] Ganti max retry [dim](saat ini: {config['max_retry']})[/dim]")
267
+ console.print(f" [3] Toggle auto-paste clipboard [dim](saat ini: {'ON' if config['auto_paste'] else 'OFF'})[/dim]")
268
+ console.print(f" [4] Ganti naming template [dim](saat ini: {config['naming_template']})[/dim]")
269
+ console.print(f" [5] Pakai cookies dari browser/file [dim](saat ini: {cookies_status})[/dim]")
270
+ console.print(" [6] Reset ke default")
271
+ console.print(" [7] Kembali")
272
+ return Prompt.ask("→", default="7")
273
+
274
+
275
+ def ask_cookies_browser() -> str | None:
276
+ """Minta user pilih browser buat ambil cookies (konten age-restricted/private)."""
277
+ console.print(
278
+ "\n[dim]Dipakai buat video yang minta login / age-restricted.[/dim]\n"
279
+ "[dim]Pastikan kamu sudah login ke YouTube di browser pilihanmu.[/dim]"
280
+ )
281
+ labels = ["Nonaktifkan", "Chrome", "Firefox", "Edge", "Brave", "Opera", "Vivaldi", "Safari"]
282
+ values: list[str | None] = [None, "chrome", "firefox", "edge", "brave", "opera", "vivaldi", "safari"]
283
+ for i, label in enumerate(labels):
284
+ console.print(f" [{i + 1}] {label}")
285
+ raw = Prompt.ask("→", default="1")
286
+ try:
287
+ idx = int(str(raw).strip()) - 1
288
+ if 0 <= idx < len(values):
289
+ return values[idx]
290
+ except (ValueError, TypeError):
291
+ pass
292
+ return None
core/update_checker.py ADDED
@@ -0,0 +1,63 @@
1
+ """
2
+ update_checker.py
3
+ Cek versi terbaru yt-dlp di PyPI, buat kasih notice kalau versi lokal ketinggalan.
4
+ Gagal diam-diam kalau tidak ada internet / PyPI tidak bisa diakses - proses
5
+ pengecekan TIDAK BOLEH menghalangi atau memperlambat user pakai aplikasi.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import urllib.request
12
+ from urllib.error import URLError
13
+
14
+ import yt_dlp
15
+
16
+ from core.constants import UPDATE_CHECK_TIMEOUT_SECONDS
17
+
18
+ PYPI_URL = "https://pypi.org/pypi/yt-dlp/json"
19
+ TIMEOUT_SECONDS = UPDATE_CHECK_TIMEOUT_SECONDS
20
+
21
+
22
+ def get_installed_version() -> str:
23
+ return yt_dlp.version.__version__
24
+
25
+
26
+ def get_latest_version(timeout: float = TIMEOUT_SECONDS) -> str | None:
27
+ """Return versi terbaru yt-dlp di PyPI, atau None kalau gagal cek (mis. tidak ada internet)."""
28
+ try:
29
+ with urllib.request.urlopen(PYPI_URL, timeout=timeout) as response: # noqa: S310
30
+ data = json.loads(response.read().decode("utf-8"))
31
+ version = data.get("info", {}).get("version")
32
+ return str(version) if version else None
33
+ except (URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError):
34
+ return None
35
+
36
+
37
+ def _version_tuple(version: str) -> tuple[int, ...]:
38
+ """yt-dlp pakai format versi YYYY.MM.DD[.rev] - urutkan sebagai angka biar perbandingan benar."""
39
+ parts = []
40
+ for part in str(version).split("."):
41
+ try:
42
+ parts.append(int(part))
43
+ except ValueError:
44
+ parts.append(0)
45
+ return tuple(parts)
46
+
47
+
48
+ def check_for_update() -> tuple[bool, str, str | None]:
49
+ """
50
+ Cek apakah ada versi yt-dlp yang lebih baru dari yang terinstall.
51
+
52
+ Return (ada_update, versi_terinstall, versi_terbaru_atau_None).
53
+ ada_update selalu False kalau sudah versi terbaru ATAU kalau pengecekan
54
+ gagal (fail-safe - tidak ada internet bukan berarti "ada update").
55
+ """
56
+ installed = get_installed_version()
57
+ latest = get_latest_version()
58
+
59
+ if latest is None:
60
+ return False, installed, None
61
+
62
+ has_update = _version_tuple(latest) > _version_tuple(installed)
63
+ return has_update, installed, latest