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/__init__.py
ADDED
core/constants.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""constants.py - Single source of truth untuk nilai-nilai shared."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
VERSION = "2.0.0"
|
|
6
|
+
|
|
7
|
+
# --- Config ---
|
|
8
|
+
HISTORY_LIMIT = 20
|
|
9
|
+
CONFIG_VERSION = 2
|
|
10
|
+
UPDATE_CHECK_TIMEOUT_SECONDS = 2
|
|
11
|
+
DOWNLOAD_TIMEOUT_SECONDS = 30
|
|
12
|
+
FFMPEG_TIMEOUT_SECONDS = 120
|
|
13
|
+
APP_NAME = "lumenfetch"
|
|
14
|
+
|
|
15
|
+
# --- Retry (backoff eksponensial) ---
|
|
16
|
+
RETRY_BASE_DELAY_SECONDS = 2.0
|
|
17
|
+
RETRY_MAX_DELAY_SECONDS = 30.0
|
|
18
|
+
RETRY_JITTER_SECONDS = 1.0
|
|
19
|
+
|
|
20
|
+
# --- Playlist ---
|
|
21
|
+
PLAYLIST_MODE_SINGLE = "single"
|
|
22
|
+
PLAYLIST_MODE_ALL = "all"
|
|
23
|
+
PLAYLIST_MODES = [PLAYLIST_MODE_SINGLE, PLAYLIST_MODE_ALL]
|
|
24
|
+
|
|
25
|
+
# --- Subtitle ---
|
|
26
|
+
SUBTITLE_LANGS_DEFAULT = ["en", "id"]
|
|
27
|
+
|
|
28
|
+
# --- Batch ---
|
|
29
|
+
BATCH_DEFAULT_JOBS = 1
|
|
30
|
+
BATCH_MAX_JOBS = 4
|
|
31
|
+
|
|
32
|
+
# --- YouTube ---
|
|
33
|
+
YOUTUBE_HOSTS = ("youtube.com", "youtu.be", "music.youtube.com", "youtube-nocookie.com")
|
|
34
|
+
VIDEO_QUALITIES = ["Best", "1080p", "720p", "480p", "360p", "Worst"]
|
|
35
|
+
VIDEO_FORMAT = "mp4"
|
|
36
|
+
AUDIO_FORMAT = "mp3"
|
|
37
|
+
AUDIO_QUALITIES = ["Best", "320kbps", "192kbps", "128kbps"]
|
|
38
|
+
|
|
39
|
+
# Container video yang didukung EmbedThumbnailPP milik yt-dlp.
|
|
40
|
+
EMBEDDABLE_VIDEO_FORMATS = frozenset({"mp4"})
|
|
41
|
+
|
|
42
|
+
# --- Download ---
|
|
43
|
+
CONNECTION_ERROR_HINTS = (
|
|
44
|
+
"timed out",
|
|
45
|
+
"timeout",
|
|
46
|
+
"connection",
|
|
47
|
+
"network",
|
|
48
|
+
"temporary failure",
|
|
49
|
+
"reset by peer",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Batas tinggi per label quality (None = tanpa batas / ambil terbaik).
|
|
53
|
+
HEIGHT_LIMITS = {
|
|
54
|
+
"1080p": 1080,
|
|
55
|
+
"720p": 720,
|
|
56
|
+
"480p": 480,
|
|
57
|
+
"360p": 360,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Target bitrate (kbps) untuk opsi audio lossy.
|
|
61
|
+
AUDIO_TARGET_KBPS = {
|
|
62
|
+
"320kbps": 320,
|
|
63
|
+
"192kbps": 192,
|
|
64
|
+
"128kbps": 128,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Browser yang didukung yt-dlp untuk cookiesfrombrowser.
|
|
68
|
+
SUPPORTED_COOKIE_BROWSERS = ["none", "chrome", "firefox", "edge", "brave", "opera", "vivaldi", "safari"]
|
|
69
|
+
|
|
70
|
+
# --- Filenames ---
|
|
71
|
+
ILLEGAL_CHARS = r'[\\/:*?"<>|]'
|
|
72
|
+
MAX_TITLE_LEN = 80
|
|
73
|
+
# Nama reserved Windows (case-insensitive) yang tidak boleh jadi nama file.
|
|
74
|
+
WINDOWS_RESERVED_NAMES = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"CON",
|
|
77
|
+
"PRN",
|
|
78
|
+
"AUX",
|
|
79
|
+
"NUL",
|
|
80
|
+
"COM1",
|
|
81
|
+
"COM2",
|
|
82
|
+
"COM3",
|
|
83
|
+
"COM4",
|
|
84
|
+
"COM5",
|
|
85
|
+
"COM6",
|
|
86
|
+
"COM7",
|
|
87
|
+
"COM8",
|
|
88
|
+
"COM9",
|
|
89
|
+
"LPT1",
|
|
90
|
+
"LPT2",
|
|
91
|
+
"LPT3",
|
|
92
|
+
"LPT4",
|
|
93
|
+
"LPT5",
|
|
94
|
+
"LPT6",
|
|
95
|
+
"LPT7",
|
|
96
|
+
"LPT8",
|
|
97
|
+
"LPT9",
|
|
98
|
+
}
|
|
99
|
+
)
|
core/detector.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
detector.py
|
|
3
|
+
Deteksi info video YouTube (judul, durasi, playlist) dari URL memakai yt-dlp,
|
|
4
|
+
tanpa mendownload.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
import yt_dlp
|
|
14
|
+
|
|
15
|
+
from core.constants import YOUTUBE_HOSTS
|
|
16
|
+
from core.errors import DetectionError, classify_error, detection_message
|
|
17
|
+
from core.log import get_ydl_logger
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DetectionError",
|
|
21
|
+
"DetectedContent",
|
|
22
|
+
"is_youtube_url",
|
|
23
|
+
"is_valid_url",
|
|
24
|
+
"strip_playlist_context",
|
|
25
|
+
"detect",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
URL_PATTERN = re.compile(r"^https?://[^\s]+$", re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DetectedContent:
|
|
33
|
+
url: str
|
|
34
|
+
title: str
|
|
35
|
+
content_type: str = "VIDEO" # "VIDEO" | "AUDIO" (audio = stream audio YouTube)
|
|
36
|
+
duration: float | None = None
|
|
37
|
+
entries: list = field(default_factory=list) # untuk playlist
|
|
38
|
+
raw_info: dict = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def platform(self) -> str:
|
|
42
|
+
return "YouTube"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _host(url: str) -> str:
|
|
46
|
+
try:
|
|
47
|
+
return urlsplit(url).netloc.lower().split(":")[0]
|
|
48
|
+
except ValueError:
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_youtube_url(url: str) -> bool:
|
|
53
|
+
"""True bila URL http(s) dengan host domain YouTube yang dikenal."""
|
|
54
|
+
if not isinstance(url, str):
|
|
55
|
+
return False
|
|
56
|
+
url = url.strip()
|
|
57
|
+
if not url.lower().startswith(("http://", "https://")):
|
|
58
|
+
return False
|
|
59
|
+
host = _host(url)
|
|
60
|
+
return any(host == h or host.endswith("." + h) for h in YOUTUBE_HOSTS)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_valid_url(text: str) -> bool:
|
|
64
|
+
"""URL YouTube yang valid (http/https + host YouTube)."""
|
|
65
|
+
if not isinstance(text, str):
|
|
66
|
+
return False
|
|
67
|
+
text = text.strip()
|
|
68
|
+
return bool(URL_PATTERN.match(text)) and is_youtube_url(text)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def strip_playlist_context(url: str) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Buang parameter "list" (dan "index") dari URL kalau URL itu jelas
|
|
74
|
+
menunjuk ke SATU video spesifik (ada "v=", /shorts/, /embed/, youtu.be),
|
|
75
|
+
bukan link playlist murni. Tanpa ini, video biasa yang kebawa "list="
|
|
76
|
+
salah narik metadata playlist-nya (judul salah, lambat, rawan 429).
|
|
77
|
+
"""
|
|
78
|
+
parts = urlsplit(url)
|
|
79
|
+
pairs = parse_qsl(parts.query, keep_blank_values=True)
|
|
80
|
+
|
|
81
|
+
if not any(k == "list" for k, _ in pairs):
|
|
82
|
+
return url
|
|
83
|
+
|
|
84
|
+
query_dict = dict(pairs)
|
|
85
|
+
has_specific_video = (
|
|
86
|
+
"v" in query_dict
|
|
87
|
+
or "/shorts/" in parts.path
|
|
88
|
+
or "/embed/" in parts.path
|
|
89
|
+
or ((parts.netloc == "youtu.be" or parts.netloc.endswith(".youtu.be")) and parts.path.strip("/") != "")
|
|
90
|
+
)
|
|
91
|
+
if not has_specific_video:
|
|
92
|
+
return url
|
|
93
|
+
|
|
94
|
+
filtered = [(k, v) for k, v in pairs if k not in ("list", "index")]
|
|
95
|
+
new_query = urlencode(filtered)
|
|
96
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _base_ydl_opts() -> dict:
|
|
100
|
+
return {
|
|
101
|
+
"quiet": True,
|
|
102
|
+
"no_warnings": True,
|
|
103
|
+
# Alihkan noise mentah yt-dlp (baris ERROR:/WARNING:) ke file log,
|
|
104
|
+
# terminal hanya menampilkan pesan ramah via rich.
|
|
105
|
+
"logger": get_ydl_logger(),
|
|
106
|
+
"extractor_args": {"youtube": {"player_client": ["android", "web"]}},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _with_cookies_note(text: str) -> str:
|
|
111
|
+
from core.errors import friendly_message # noqa: PLC0415
|
|
112
|
+
|
|
113
|
+
note = friendly_message("cookies").lstrip("❌ ").strip()
|
|
114
|
+
return f"{note}. {text}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def detect(url: str, cookies_browser: str | None = None, cookies_file: str | None = None) -> DetectedContent:
|
|
118
|
+
"""Ekstrak info video YouTube dari URL tanpa mendownload."""
|
|
119
|
+
from core.utils import apply_cookie_opts # noqa: PLC0415 - hindari circular import
|
|
120
|
+
|
|
121
|
+
if not is_valid_url(url):
|
|
122
|
+
raise DetectionError("URL bukan link YouTube yang valid")
|
|
123
|
+
|
|
124
|
+
url = strip_playlist_context(url)
|
|
125
|
+
|
|
126
|
+
ydl_opts = _base_ydl_opts()
|
|
127
|
+
ydl_opts.update({"skip_download": True, "noplaylist": False})
|
|
128
|
+
|
|
129
|
+
has_cookies = apply_cookie_opts(ydl_opts, cookies_browser, cookies_file)
|
|
130
|
+
|
|
131
|
+
def _extract(opts: dict):
|
|
132
|
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
133
|
+
return ydl.extract_info(url, download=False)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
info = _extract(ydl_opts)
|
|
137
|
+
except yt_dlp.utils.DownloadError as e:
|
|
138
|
+
if has_cookies and classify_error(e) == "cookies":
|
|
139
|
+
# Cookies gagal dibaca (browser terkunci/DPAPI): coba tanpa cookies.
|
|
140
|
+
ydl_opts.pop("cookiesfrombrowser", None)
|
|
141
|
+
ydl_opts.pop("cookiefile", None)
|
|
142
|
+
try:
|
|
143
|
+
info = _extract(ydl_opts)
|
|
144
|
+
except yt_dlp.utils.DownloadError as e2:
|
|
145
|
+
raise DetectionError(_with_cookies_note(detection_message(e2))) from e2
|
|
146
|
+
except Exception as e2: # noqa: BLE001
|
|
147
|
+
raise DetectionError("URL bukan link YouTube yang valid") from e2
|
|
148
|
+
else:
|
|
149
|
+
raise DetectionError(detection_message(e)) from e
|
|
150
|
+
except Exception as e: # noqa: BLE001
|
|
151
|
+
raise DetectionError("URL bukan link YouTube yang valid") from e
|
|
152
|
+
|
|
153
|
+
if info is None:
|
|
154
|
+
raise DetectionError("URL bukan link YouTube yang valid")
|
|
155
|
+
|
|
156
|
+
raw_entries = info.get("entries") or []
|
|
157
|
+
entries = raw_entries if isinstance(raw_entries, list) else list(raw_entries)
|
|
158
|
+
vcodec = info.get("vcodec")
|
|
159
|
+
|
|
160
|
+
return DetectedContent(
|
|
161
|
+
url=url,
|
|
162
|
+
title=info.get("title") or info.get("id") or "untitled",
|
|
163
|
+
content_type="AUDIO" if vcodec == "none" else "VIDEO",
|
|
164
|
+
duration=info.get("duration"),
|
|
165
|
+
entries=entries,
|
|
166
|
+
raw_info=info,
|
|
167
|
+
)
|
core/downloader.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""
|
|
2
|
+
downloader.py
|
|
3
|
+
Download video YouTube (MP4) / ekstrak audio (MP3) memakai yt-dlp,
|
|
4
|
+
dengan progress hook Rich, resume otomatis, dan retry backoff eksponensial.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yt_dlp
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.progress import (
|
|
16
|
+
BarColumn,
|
|
17
|
+
DownloadColumn,
|
|
18
|
+
Progress,
|
|
19
|
+
TextColumn,
|
|
20
|
+
TimeRemainingColumn,
|
|
21
|
+
TransferSpeedColumn,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from core.constants import (
|
|
25
|
+
EMBEDDABLE_VIDEO_FORMATS,
|
|
26
|
+
PLAYLIST_MODE_ALL,
|
|
27
|
+
RETRY_BASE_DELAY_SECONDS,
|
|
28
|
+
RETRY_JITTER_SECONDS,
|
|
29
|
+
RETRY_MAX_DELAY_SECONDS,
|
|
30
|
+
SUBTITLE_LANGS_DEFAULT,
|
|
31
|
+
)
|
|
32
|
+
from core.detector import DetectedContent
|
|
33
|
+
from core.errors import (
|
|
34
|
+
DownloadCancelled,
|
|
35
|
+
DownloadFailed,
|
|
36
|
+
classify_error,
|
|
37
|
+
friendly_message,
|
|
38
|
+
)
|
|
39
|
+
from core.log import get_ydl_logger
|
|
40
|
+
from core.options import DownloadChoice
|
|
41
|
+
from core.utils import (
|
|
42
|
+
_iter_dir_files,
|
|
43
|
+
apply_cookie_opts,
|
|
44
|
+
build_filename,
|
|
45
|
+
find_indexed_files,
|
|
46
|
+
resolve_duplicate,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"DownloadCancelled",
|
|
51
|
+
"DownloadFailed",
|
|
52
|
+
"DownloadResult",
|
|
53
|
+
"CONNECTION_ERROR_HINTS",
|
|
54
|
+
"EMBEDDABLE_VIDEO_FORMATS",
|
|
55
|
+
"backoff_delay",
|
|
56
|
+
"download",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
console = Console()
|
|
60
|
+
|
|
61
|
+
CONNECTION_ERROR_HINTS = tuple(__import__("core.constants", fromlist=["CONNECTION_ERROR_HINTS"]).CONNECTION_ERROR_HINTS)
|
|
62
|
+
|
|
63
|
+
_HEIGHT_FILTERS = {
|
|
64
|
+
"Best": "",
|
|
65
|
+
"1080p": "[height<=1080]",
|
|
66
|
+
"720p": "[height<=720]",
|
|
67
|
+
"480p": "[height<=480]",
|
|
68
|
+
"360p": "[height<=360]",
|
|
69
|
+
"Worst": "",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_AUDIO_KBPS = {"Best": "0", "320kbps": "320", "192kbps": "192", "128kbps": "128"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class DownloadResult:
|
|
77
|
+
filepath: Path
|
|
78
|
+
size_bytes: int
|
|
79
|
+
elapsed_seconds: float
|
|
80
|
+
file_count: int = 1 # >1 kalau --playlist all
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _quality_to_format_selector(quality: str, ext: str = "mp4") -> str:
|
|
84
|
+
"""
|
|
85
|
+
Selector video: prioritaskan kombinasi video+audio yang codec-nya sudah
|
|
86
|
+
kompatibel dengan MP4 (h264+aac) biar ffmpeg tinggal remux tanpa re-encode.
|
|
87
|
+
"""
|
|
88
|
+
hf = _HEIGHT_FILTERS.get(quality, "")
|
|
89
|
+
|
|
90
|
+
if quality == "Worst":
|
|
91
|
+
return f"worstvideo*{hf}+worstaudio/worst{hf}"
|
|
92
|
+
|
|
93
|
+
if ext == "mp4":
|
|
94
|
+
return f"bestvideo[ext=mp4]{hf}+bestaudio[ext=m4a]/bestvideo*{hf}+bestaudio/best{hf}"
|
|
95
|
+
return f"bestvideo*{hf}+bestaudio/best{hf}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _audio_quality_to_kbps(quality: str) -> str:
|
|
99
|
+
return _AUDIO_KBPS.get(quality, "0")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _make_progress_hook(progress: Progress, task_id):
|
|
103
|
+
def hook(d: dict):
|
|
104
|
+
if d.get("status") == "downloading":
|
|
105
|
+
total = d.get("total_bytes") or d.get("total_bytes_estimate")
|
|
106
|
+
downloaded = d.get("downloaded_bytes", 0)
|
|
107
|
+
if total:
|
|
108
|
+
progress.update(task_id, total=total, completed=downloaded)
|
|
109
|
+
filename = d.get("filename") or "Downloading"
|
|
110
|
+
progress.update(task_id, description=filename[:40])
|
|
111
|
+
elif d.get("status") == "finished":
|
|
112
|
+
try:
|
|
113
|
+
total = progress.tasks[task_id].total
|
|
114
|
+
except (IndexError, AttributeError):
|
|
115
|
+
total = None
|
|
116
|
+
if total:
|
|
117
|
+
progress.update(task_id, completed=total)
|
|
118
|
+
|
|
119
|
+
return hook
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _classify_error(e: Exception) -> str:
|
|
123
|
+
"""Wrapper kompatibel - logika kanonis di core.errors.classify_error."""
|
|
124
|
+
return classify_error(e)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _friendly_message(kind: str) -> str:
|
|
128
|
+
return friendly_message(kind)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def backoff_delay(attempt: int) -> float:
|
|
132
|
+
"""Delay eksponensial untuk retry ke-`attempt` (1-indexed), dibatasi max + jitter."""
|
|
133
|
+
import random
|
|
134
|
+
|
|
135
|
+
base = RETRY_BASE_DELAY_SECONDS * (2.0 ** max(0, attempt - 1))
|
|
136
|
+
capped = min(base, RETRY_MAX_DELAY_SECONDS)
|
|
137
|
+
return capped + random.uniform(0, RETRY_JITTER_SECONDS)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def download(
|
|
141
|
+
content: DetectedContent,
|
|
142
|
+
choice: DownloadChoice,
|
|
143
|
+
output_folder: Path,
|
|
144
|
+
naming_template: str,
|
|
145
|
+
max_retry: int = 3,
|
|
146
|
+
cookies_browser: str | None = None,
|
|
147
|
+
cookies_file: str | None = None,
|
|
148
|
+
) -> DownloadResult:
|
|
149
|
+
"""Download video/audio YouTube sesuai pilihan, retry backoff untuk error koneksi."""
|
|
150
|
+
output_folder.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
ext = choice.fmt
|
|
153
|
+
filename = build_filename(content.platform, content.title, ext, naming_template)
|
|
154
|
+
|
|
155
|
+
entries_count = len(getattr(content, "entries", None) or [])
|
|
156
|
+
playlist_all = getattr(choice, "playlist_mode", "single") == PLAYLIST_MODE_ALL and entries_count > 1
|
|
157
|
+
# "noplaylist" tidak berefek untuk URL playlist murni (yt-dlp #5215):
|
|
158
|
+
# paksa entry pertama bila mode single.
|
|
159
|
+
force_single_item = not playlist_all and entries_count > 1
|
|
160
|
+
|
|
161
|
+
dest = resolve_duplicate(output_folder / filename, multi_item=playlist_all)
|
|
162
|
+
|
|
163
|
+
ydl_opts = _build_ydl_opts(
|
|
164
|
+
choice,
|
|
165
|
+
dest,
|
|
166
|
+
cookies_browser,
|
|
167
|
+
cookies_file,
|
|
168
|
+
force_single_item=force_single_item,
|
|
169
|
+
playlist_all=playlist_all,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
attempt = 0
|
|
173
|
+
start_time = time.time()
|
|
174
|
+
|
|
175
|
+
with Progress(
|
|
176
|
+
TextColumn("[progress.description]{task.description}"),
|
|
177
|
+
BarColumn(),
|
|
178
|
+
DownloadColumn(),
|
|
179
|
+
TransferSpeedColumn(),
|
|
180
|
+
TimeRemainingColumn(),
|
|
181
|
+
console=console,
|
|
182
|
+
) as progress:
|
|
183
|
+
task_id = progress.add_task(f"📥 {content.title[:40]}", total=None)
|
|
184
|
+
ydl_opts["progress_hooks"] = [_make_progress_hook(progress, task_id)]
|
|
185
|
+
|
|
186
|
+
while True:
|
|
187
|
+
try:
|
|
188
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
189
|
+
ydl.download([content.url])
|
|
190
|
+
break
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
raise DownloadCancelled("Download dibatalkan oleh user") from None
|
|
193
|
+
except yt_dlp.utils.DownloadError as e:
|
|
194
|
+
kind = _classify_error(e)
|
|
195
|
+
if kind == "cookies" and ("cookiesfrombrowser" in ydl_opts or "cookiefile" in ydl_opts):
|
|
196
|
+
ydl_opts.pop("cookiesfrombrowser", None)
|
|
197
|
+
ydl_opts.pop("cookiefile", None)
|
|
198
|
+
console.print(
|
|
199
|
+
"[yellow]⚠️ Cookies browser gagal dibaca (tutup browser-nya) - lanjut tanpa cookies...[/yellow]"
|
|
200
|
+
)
|
|
201
|
+
continue
|
|
202
|
+
if kind == "connection" and attempt < max_retry:
|
|
203
|
+
attempt += 1
|
|
204
|
+
delay = backoff_delay(attempt)
|
|
205
|
+
console.print(f"🔄 Retry {attempt}/{max_retry} dalam {delay:.0f}s...")
|
|
206
|
+
time.sleep(delay)
|
|
207
|
+
continue
|
|
208
|
+
raise DownloadFailed(_friendly_message(kind)) from e
|
|
209
|
+
except Exception as e: # noqa: BLE001
|
|
210
|
+
raise DownloadFailed(_friendly_message(_classify_error(e))) from e
|
|
211
|
+
|
|
212
|
+
elapsed = time.time() - start_time
|
|
213
|
+
final_path, total_size, file_count = _resolve_final_path(dest, multi_item=playlist_all)
|
|
214
|
+
|
|
215
|
+
return DownloadResult(filepath=final_path, size_bytes=total_size, elapsed_seconds=elapsed, file_count=file_count)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _find_result_files(dest: Path, multi_item: bool = False) -> list[Path]:
|
|
219
|
+
"""Cari file hasil download yang cocok dengan `dest` (aman dari karakter glob di judul)."""
|
|
220
|
+
if multi_item:
|
|
221
|
+
return find_indexed_files(dest)
|
|
222
|
+
|
|
223
|
+
if dest.exists():
|
|
224
|
+
return [dest]
|
|
225
|
+
|
|
226
|
+
image_exts = {".jpg", ".jpeg", ".png", ".webp"}
|
|
227
|
+
stem = dest.stem
|
|
228
|
+
matches: list[Path] = []
|
|
229
|
+
non_image_matches: list[Path] = []
|
|
230
|
+
for p in _iter_dir_files(dest.parent):
|
|
231
|
+
if not p.is_file() or p.stem != stem:
|
|
232
|
+
continue
|
|
233
|
+
matches.append(p)
|
|
234
|
+
if p.suffix.lower() not in image_exts:
|
|
235
|
+
non_image_matches.append(p)
|
|
236
|
+
|
|
237
|
+
if non_image_matches:
|
|
238
|
+
return [sorted(non_image_matches, key=lambda p: p.name)[0]]
|
|
239
|
+
if matches:
|
|
240
|
+
return [sorted(matches, key=lambda p: p.name)[0]]
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _resolve_final_path(dest: Path, multi_item: bool = False) -> tuple[Path, int, int]:
|
|
245
|
+
"""Return (path_representatif, total_size_bytes, jumlah_file)."""
|
|
246
|
+
matches = _find_result_files(dest, multi_item=multi_item)
|
|
247
|
+
if not matches:
|
|
248
|
+
return dest, 0, 0
|
|
249
|
+
total_size = sum(m.stat().st_size for m in matches if m.exists())
|
|
250
|
+
return matches[0], total_size, len(matches)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _build_ydl_opts(
|
|
254
|
+
choice: DownloadChoice,
|
|
255
|
+
dest: Path,
|
|
256
|
+
cookies_browser: str | None = None,
|
|
257
|
+
cookies_file: str | None = None,
|
|
258
|
+
force_single_item: bool = False,
|
|
259
|
+
playlist_all: bool = False,
|
|
260
|
+
) -> dict:
|
|
261
|
+
if playlist_all:
|
|
262
|
+
outtmpl = f"{dest.with_suffix('')}-%(playlist_index,autonumber)d"
|
|
263
|
+
else:
|
|
264
|
+
outtmpl = str(dest.with_suffix("")) # ekstensi diserahkan ke yt-dlp/postprocessor
|
|
265
|
+
|
|
266
|
+
opts = {
|
|
267
|
+
"outtmpl": outtmpl + ".%(ext)s",
|
|
268
|
+
"quiet": True,
|
|
269
|
+
"no_warnings": True,
|
|
270
|
+
"noprogress": True,
|
|
271
|
+
# Lihat detector._base_ydl_opts: noise mentah ke file log saja.
|
|
272
|
+
"logger": get_ydl_logger(),
|
|
273
|
+
"retries": 0, # retry koneksi ditangani manual (backoff) di download()
|
|
274
|
+
"fragment_retries": 3,
|
|
275
|
+
"continuedl": True, # resume file parsial (.part)
|
|
276
|
+
"skip_unavailable_fragments": False,
|
|
277
|
+
"noplaylist": not playlist_all,
|
|
278
|
+
"extractor_args": {"youtube": {"player_client": ["android", "web"]}},
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if force_single_item:
|
|
282
|
+
opts["playlist_items"] = "1"
|
|
283
|
+
|
|
284
|
+
apply_cookie_opts(opts, cookies_browser, cookies_file)
|
|
285
|
+
|
|
286
|
+
if choice.output_kind == "video":
|
|
287
|
+
opts["format"] = _quality_to_format_selector(choice.quality, choice.fmt)
|
|
288
|
+
opts["merge_output_format"] = choice.fmt
|
|
289
|
+
postprocessors: list[dict] = []
|
|
290
|
+
if choice.fmt in EMBEDDABLE_VIDEO_FORMATS:
|
|
291
|
+
postprocessors.append({"key": "EmbedThumbnail"})
|
|
292
|
+
opts["writethumbnail"] = True
|
|
293
|
+
else:
|
|
294
|
+
opts["writethumbnail"] = False
|
|
295
|
+
_apply_subs_metadata_opts(opts, postprocessors, choice)
|
|
296
|
+
opts["postprocessors"] = postprocessors
|
|
297
|
+
|
|
298
|
+
else: # audio -> MP3
|
|
299
|
+
opts["format"] = "bestaudio/best"
|
|
300
|
+
postprocessors = [
|
|
301
|
+
{
|
|
302
|
+
"key": "FFmpegExtractAudio",
|
|
303
|
+
"preferredcodec": "mp3",
|
|
304
|
+
"preferredquality": _audio_quality_to_kbps(choice.quality),
|
|
305
|
+
}
|
|
306
|
+
]
|
|
307
|
+
_apply_subs_metadata_opts(opts, postprocessors, choice, audio=True)
|
|
308
|
+
opts["postprocessors"] = postprocessors
|
|
309
|
+
|
|
310
|
+
return opts
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _apply_subs_metadata_opts(opts: dict, postprocessors: list[dict], choice, audio: bool = False) -> None:
|
|
314
|
+
"""Tambahkan opsi subtitle & metadata ke ydl opts (default mati)."""
|
|
315
|
+
write_subs = bool(getattr(choice, "write_subs", False))
|
|
316
|
+
if write_subs:
|
|
317
|
+
langs = getattr(choice, "sub_langs", None) or list(SUBTITLE_LANGS_DEFAULT)
|
|
318
|
+
opts["writesubtitles"] = True
|
|
319
|
+
opts["subtitleslangs"] = langs
|
|
320
|
+
if bool(getattr(choice, "embed_subs", False)) and not audio:
|
|
321
|
+
opts["embedsubtitles"] = True
|
|
322
|
+
if bool(getattr(choice, "embed_metadata", False)):
|
|
323
|
+
postprocessors.append({"key": "FFmpegMetadata", "add_metadata": True})
|