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/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
|