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 ADDED
@@ -0,0 +1,6 @@
1
+ """core - logic inti Lumenfetch."""
2
+
3
+ from core.constants import VERSION
4
+
5
+ __version__ = VERSION
6
+ __all__ = ["__version__"]
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})