fetchforge 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.
fetchforge/server.py ADDED
@@ -0,0 +1,2198 @@
1
+ import asyncio
2
+ import datetime
3
+ import json
4
+ import os
5
+ import random
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+ from urllib.parse import urlparse
14
+
15
+ IS_WINDOWS = sys.platform.startswith("win")
16
+ if IS_WINDOWS:
17
+ import ctypes
18
+
19
+ # Power management — prevent sleep during long encodes.
20
+ # Windows: SetThreadExecutionState, re-asserted on a timer.
21
+ # Linux: hold a `systemd-inhibit` process for the duration.
22
+ # The public interface (_keep_awake / _allow_sleep, refcounted) is identical on
23
+ # both; only the backend differs.
24
+ _ES_CONTINUOUS = 0x80000000
25
+ _ES_SYSTEM_REQUIRED = 0x00000001
26
+ _ES_DISPLAY_REQUIRED = 0x00000002
27
+ _AWAKE_FLAGS = _ES_CONTINUOUS | _ES_SYSTEM_REQUIRED | _ES_DISPLAY_REQUIRED
28
+
29
+ _awake_task: Optional[asyncio.Task] = None # Windows: re-assertion loop
30
+ _awake_inhibitor: Optional[subprocess.Popen] = None # Linux: systemd-inhibit handle
31
+ _awake_refcount = 0
32
+
33
+ def _set_awake():
34
+ """Single-shot assertion — called repeatedly by the background task (Windows)."""
35
+ ctypes.windll.kernel32.SetThreadExecutionState(_AWAKE_FLAGS)
36
+
37
+ async def _awake_loop():
38
+ """Re-assert wakefulness every 30s so Windows never overrides it."""
39
+ try:
40
+ while True:
41
+ _set_awake()
42
+ await asyncio.sleep(30)
43
+ except asyncio.CancelledError:
44
+ pass
45
+ finally:
46
+ ctypes.windll.kernel32.SetThreadExecutionState(_ES_CONTINUOUS)
47
+
48
+ def _keep_awake():
49
+ global _awake_task, _awake_inhibitor, _awake_refcount
50
+ _awake_refcount += 1
51
+ if IS_WINDOWS:
52
+ if _awake_task is None or _awake_task.done():
53
+ _awake_task = asyncio.get_event_loop().create_task(_awake_loop())
54
+ else:
55
+ if _awake_inhibitor is None or _awake_inhibitor.poll() is not None:
56
+ inhibit = shutil.which("systemd-inhibit")
57
+ if inhibit:
58
+ # Block idle/sleep until this process is killed in _allow_sleep.
59
+ _awake_inhibitor = subprocess.Popen(
60
+ [inhibit, "--what=idle:sleep", "--who=FetchForge",
61
+ "--why=Video encoding in progress", "sleep", "infinity"]
62
+ )
63
+ # No systemd-inhibit (rare) → no-op; an active encode keeps the box busy anyway.
64
+
65
+ def _allow_sleep():
66
+ global _awake_task, _awake_inhibitor, _awake_refcount
67
+ _awake_refcount = max(0, _awake_refcount - 1)
68
+ if _awake_refcount != 0:
69
+ return
70
+ if _awake_task is not None and not _awake_task.done():
71
+ _awake_task.cancel()
72
+ _awake_task = None
73
+ if _awake_inhibitor is not None and _awake_inhibitor.poll() is None:
74
+ _awake_inhibitor.terminate()
75
+ _awake_inhibitor = None
76
+
77
+
78
+ # Heartbeat watchdog — auto-quit when the browser tab goes away.
79
+ # The page pings GET /heartbeat every few seconds; if no ping arrives within
80
+ # _HEARTBEAT_TIMEOUT, the server self-exits. A grace window (rather than firing
81
+ # on tab-close directly) tolerates page refreshes and multiple open tabs.
82
+ #
83
+ # IMPORTANT: heartbeat silence alone must NOT kill the server while real work is
84
+ # in flight. Browsers throttle setInterval in backgrounded/hidden tabs (clamped
85
+ # to ~once/min, and frozen entirely after a few minutes), so a long queue run in
86
+ # a non-focused window would otherwise breach the 30s timeout and self-exit
87
+ # mid-encode. Two guards prevent that:
88
+ # 1. _active_jobs > 0 — a /download or /convert-local SSE stream is actively
89
+ # running (download/encode in progress). Definitive proof the user is here;
90
+ # never exit. Covers ~all of a queue's wall-clock (incl. pipeline mode,
91
+ # where one request spans the whole playlist).
92
+ # 2. _INTER_ITEM_GRACE after the last job ends — rides out the gaps between
93
+ # queue items, the client's retry backoffs (up to 45s), and the client-side
94
+ # 60s shutdown countdown, so a throttled tab can't kill the server in those
95
+ # windows. Bounded so a truly-dead tab still auto-quits ~2 min after work.
96
+ _HEARTBEAT_TIMEOUT = 30 # seconds of silence before shutting down
97
+ _INTER_ITEM_GRACE = 120 # seconds to stay alive after a job ends (> 45s backoff + 60s shutdown countdown)
98
+ _last_heartbeat: float = 0.0 # monotonic timestamp of the last ping
99
+ _active_jobs = 0 # count of /download + /convert-local SSE streams currently running
100
+ _last_job_end: float = 0.0 # monotonic timestamp when the last job stream finished
101
+ _uvicorn_server = None # set in run_server(); lets the watchdog stop the server
102
+
103
+ async def _heartbeat_watchdog():
104
+ """Exit the process once no tab has pinged for _HEARTBEAT_TIMEOUT seconds,
105
+ UNLESS a job is running or just finished (see guards above)."""
106
+ global _last_heartbeat
107
+ _last_heartbeat = time.monotonic() # start the clock at launch
108
+ while True:
109
+ await asyncio.sleep(5)
110
+ now = time.monotonic()
111
+ # A download/encode is actively streaming — the user is here even if the
112
+ # backgrounded tab has throttled its heartbeat. Never exit.
113
+ if _active_jobs > 0:
114
+ continue
115
+ # Just finished a job: ride out inter-item gaps, retry backoffs, and the
116
+ # client's 60s shutdown countdown before trusting heartbeat silence.
117
+ if now - _last_job_end < _INTER_ITEM_GRACE:
118
+ continue
119
+ if now - _last_heartbeat > _HEARTBEAT_TIMEOUT:
120
+ if _uvicorn_server is not None:
121
+ _uvicorn_server.should_exit = True
122
+ return
123
+
124
+
125
+ async def _tracked(agen):
126
+ """Wrap an SSE generator so the heartbeat watchdog knows a job is in flight.
127
+ Increments _active_jobs for the life of the stream and stamps _last_job_end
128
+ on completion/disconnect (the finally runs when Starlette closes the gen)."""
129
+ global _active_jobs, _last_job_end
130
+ _active_jobs += 1
131
+ try:
132
+ async for chunk in agen:
133
+ yield chunk
134
+ finally:
135
+ _active_jobs -= 1
136
+ _last_job_end = time.monotonic()
137
+
138
+ import secrets
139
+ from contextlib import asynccontextmanager
140
+ from fastapi import FastAPI, UploadFile, File, Form, Request
141
+ from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
142
+ from fastapi.middleware.cors import CORSMiddleware
143
+ import uvicorn
144
+
145
+ # The app binds to 127.0.0.1 only, but any web page the user visits can still
146
+ # issue cross-origin requests to it. Without an origin check those drive-by
147
+ # requests succeed (CSRF) — e.g. powering off the box via /shutdown-now or
148
+ # triggering downloads. These two origins are the only ones the real UI uses.
149
+ ALLOWED_ORIGINS = ["http://localhost:8765", "http://127.0.0.1:8765"]
150
+
151
+ # Per-process secret, injected into the served HTML and required as a header on
152
+ # the most dangerous endpoint (/shutdown-now). Regenerated every launch.
153
+ _SESSION_TOKEN = secrets.token_urlsafe(32)
154
+
155
+ @asynccontextmanager
156
+ async def lifespan(app: FastAPI):
157
+ await _probe_nvenc_tune()
158
+ watchdog = asyncio.create_task(_heartbeat_watchdog())
159
+ yield
160
+ watchdog.cancel()
161
+
162
+ # Hide the interactive API surface — this is a single-user local tool, not an API.
163
+ app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
164
+
165
+
166
+ @app.middleware("http")
167
+ async def _origin_guard(request: Request, call_next):
168
+ """Reject cross-origin state-changing requests. A browser always sends an
169
+ Origin header on cross-origin POST/DELETE/PUT/PATCH; a drive-by page on
170
+ evil.com cannot forge it to our allowlisted value, so this defangs CSRF.
171
+ Same-origin UI requests carry an allowlisted Origin and pass through.
172
+ Requests with no Origin at all (curl, the page's own EventSource GET) are
173
+ not browser cross-origin attacks and are allowed."""
174
+ if request.method in ("POST", "PUT", "PATCH", "DELETE"):
175
+ origin = request.headers.get("origin")
176
+ if origin is not None and origin not in ALLOWED_ORIGINS:
177
+ return JSONResponse({"error": "cross-origin request rejected"}, status_code=403)
178
+ return await call_next(request)
179
+
180
+
181
+ app.add_middleware(
182
+ CORSMiddleware,
183
+ allow_origins=ALLOWED_ORIGINS,
184
+ allow_methods=["*"],
185
+ allow_headers=["*"],
186
+ )
187
+
188
+ PKG_DIR = Path(__file__).parent # code + index.html template (read-only)
189
+ STATE_DIR = Path.cwd() # all writable runtime state
190
+
191
+
192
+ def _resolve_tool(name: str, win_bundled: Path) -> str:
193
+ """Locate an external binary.
194
+
195
+ Windows keeps the bundled `_internal/*.exe` / `yt-dlp.exe` it always shipped.
196
+ Linux/macOS resolve from PATH (system package or the project .venv), matching
197
+ the repo's "binaries are per-machine, do not track" convention.
198
+ """
199
+ if IS_WINDOWS and win_bundled.exists():
200
+ return str(win_bundled)
201
+ found = shutil.which(name)
202
+ if found:
203
+ return found
204
+ # Console scripts installed into this interpreter's venv (e.g. yt-dlp) live
205
+ # in sys.prefix/bin and resolve even when the venv isn't on PATH.
206
+ bindir = "Scripts" if IS_WINDOWS else "bin"
207
+ for exe in (name, name + ".exe"):
208
+ cand = Path(sys.prefix) / bindir / exe
209
+ if cand.exists():
210
+ return str(cand)
211
+ if win_bundled.exists(): # last resort (e.g. a manually-dropped binary)
212
+ return str(win_bundled)
213
+ raise RuntimeError(
214
+ f"Required tool '{name}' not found on PATH. "
215
+ f"On Linux: activate the project .venv (pip installs yt-dlp) and install ffmpeg."
216
+ )
217
+
218
+
219
+ import functools
220
+
221
+ @functools.cache
222
+ def get_ffmpeg() -> str:
223
+ return _resolve_tool("ffmpeg", PKG_DIR / "_internal" / "ffmpeg.exe")
224
+
225
+ @functools.cache
226
+ def get_ffprobe() -> str:
227
+ return _resolve_tool("ffprobe", PKG_DIR / "_internal" / "ffprobe.exe")
228
+
229
+ @functools.cache
230
+ def get_ytdlp() -> str:
231
+ return _resolve_tool("yt-dlp", PKG_DIR / "yt-dlp.exe")
232
+
233
+
234
+ def _resolve_node_args() -> list:
235
+ """yt-dlp's JS runtime for solving challenges. Resolve node from PATH;
236
+ fall back to the canonical Windows install location it always used."""
237
+ node = shutil.which("node")
238
+ if not node and IS_WINDOWS:
239
+ node = r"C:\Program Files\nodejs\node.exe"
240
+ return ["--js-runtimes", f"node:{node}"] if node else []
241
+
242
+
243
+ NODE_ARGS = _resolve_node_args()
244
+ COOKIES_PATH = STATE_DIR / "cookies.txt"
245
+
246
+ # Intermediate webm/mkv — cleared weekly. Override with BOP_CACHE_DIR.
247
+ if os.getenv("BOP_CACHE_DIR"):
248
+ CACHE_DIR = Path(os.environ["BOP_CACHE_DIR"])
249
+ elif IS_WINDOWS:
250
+ CACHE_DIR = Path(r"E:/Cache/ytdlp") if Path(r"E:/").exists() else Path(r"C:/cache/ytdlp")
251
+ else:
252
+ CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "bop-convert" / "ytdlp"
253
+ OUTPUT_DIR = STATE_DIR / "downloads" # final converted mp4s
254
+ CONVERTED_DIR = OUTPUT_DIR / "converted"
255
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
256
+ OUTPUT_DIR.mkdir(exist_ok=True)
257
+ CONVERTED_DIR.mkdir(exist_ok=True)
258
+ HISTORY_PATH = STATE_DIR / "history.json"
259
+ LOGS_DIR = STATE_DIR / "logs"
260
+ LOGS_DIR.mkdir(exist_ok=True)
261
+ from fetchforge import __version__ as APP_VERSION
262
+
263
+
264
+ import logging
265
+ from logging.handlers import RotatingFileHandler
266
+
267
+ logger = logging.getLogger("fetchforge")
268
+
269
+
270
+ def _setup_logging() -> None:
271
+ """Route our own output and uvicorn's loggers into a rotating logs/server.log
272
+ (5MB × 3) so headless launches (launch-hidden.vbs / launch.sh with no console)
273
+ still leave a debuggable trail — without it, a crash before the browser
274
+ connects is silent. The visible-console launchers keep their stdout too."""
275
+ fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
276
+ file_handler = RotatingFileHandler(
277
+ LOGS_DIR / "server.log", maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
278
+ )
279
+ file_handler.setFormatter(fmt)
280
+ stream_handler = logging.StreamHandler(sys.stdout)
281
+ stream_handler.setFormatter(fmt)
282
+ logger.setLevel(logging.INFO)
283
+ logger.handlers = [file_handler, stream_handler]
284
+ logger.propagate = False
285
+ # Tee uvicorn's loggers into the same file (they keep their own console handlers).
286
+ for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
287
+ logging.getLogger(name).addHandler(file_handler)
288
+
289
+
290
+ _setup_logging()
291
+
292
+
293
+ def _poweroff():
294
+ """Shut the machine down — the UI's optional 'power off when done'.
295
+ Windows: `shutdown /s /t 0`. Linux: `systemctl poweroff` (logind allows the
296
+ active-session user without a password), falling back to `shutdown -h now`."""
297
+ if IS_WINDOWS:
298
+ subprocess.run(["shutdown", "/s", "/t", "0"])
299
+ return
300
+ if shutil.which("systemctl"):
301
+ subprocess.run(["systemctl", "poweroff"])
302
+ else:
303
+ subprocess.run(["shutdown", "-h", "now"])
304
+
305
+
306
+ _history_lock = asyncio.Lock()
307
+
308
+
309
+ def _atomic_write_text(path: Path, text: str) -> None:
310
+ """Write text via a temp file + os.replace so a crash mid-write can't truncate
311
+ the real file (os.replace is atomic on the same filesystem)."""
312
+ tmp = path.with_name(path.name + ".tmp")
313
+ tmp.write_text(text, encoding="utf-8")
314
+ os.replace(tmp, path)
315
+
316
+
317
+ def _read_history_sync() -> list:
318
+ if HISTORY_PATH.exists():
319
+ try:
320
+ return json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
321
+ except Exception:
322
+ return []
323
+ return []
324
+
325
+
326
+ async def _load_history() -> list:
327
+ # Off the event loop — a synchronous read here jitters any in-flight SSE stream.
328
+ return await asyncio.to_thread(_read_history_sync)
329
+
330
+
331
+ async def _save_history_entry(entry: dict):
332
+ # Lock the whole read-modify-write so two concurrent /history POSTs can't lose
333
+ # each other's entry; write atomically so a crash can't truncate the file.
334
+ async with _history_lock:
335
+ history = await asyncio.to_thread(_read_history_sync)
336
+ history = [h for h in history if h.get("url") != entry["url"]]
337
+ history.insert(0, entry)
338
+ payload = json.dumps(history[:50], ensure_ascii=False, indent=2)
339
+ await asyncio.to_thread(_atomic_write_text, HISTORY_PATH, payload)
340
+
341
+
342
+ # Windows illegal chars + tilde + control chars
343
+ ILLEGAL_RE = re.compile(r'[<>:"/\\|?*~\x00-\x1f]')
344
+
345
+ def cookie_args() -> list:
346
+ return ["--cookies", str(COOKIES_PATH)] if COOKIES_PATH.exists() else []
347
+
348
+
349
+ def is_http_url(url: str) -> bool:
350
+ """True only for http/https URLs. yt-dlp treats any argv beginning with '-'
351
+ as a flag (e.g. --exec=…, --config-location=…), so a crafted URL is remote
352
+ code execution. Callers reject non-http(s) here AND append '--' before the
353
+ URL so it can never be parsed as an option."""
354
+ try:
355
+ return urlparse((url or "").strip()).scheme in ("http", "https")
356
+ except Exception:
357
+ return False
358
+
359
+
360
+ def sanitize(name: str) -> str:
361
+ name = ILLEGAL_RE.sub("_", name)
362
+ name = re.sub(r"_+", "_", name)
363
+ return name.strip("_. ")[:200]
364
+
365
+
366
+ try:
367
+ # The authoritative function yt-dlp itself uses for --restrict-filenames.
368
+ # Available when yt-dlp is pip-installed (Linux/macOS, project .venv). On a
369
+ # Windows bundled yt-dlp.exe the package may be absent — fall back below.
370
+ from yt_dlp.utils import sanitize_filename as _ytdlp_sanitize_filename
371
+ except Exception:
372
+ _ytdlp_sanitize_filename = None
373
+
374
+
375
+ def _restrict_filename(title: str) -> str:
376
+ """Mirror yt-dlp's --restrict-filenames transform on a title. Uses yt-dlp's
377
+ own sanitizer when importable (exact: handles accents like café→cafe, emoji,
378
+ consecutive separators); otherwise a close ASCII approximation."""
379
+ if _ytdlp_sanitize_filename is not None:
380
+ try:
381
+ return _ytdlp_sanitize_filename(title, restricted=True)
382
+ except Exception:
383
+ pass
384
+ return re.sub(r'[^A-Za-z0-9_.\-]', '_', title)
385
+
386
+
387
+ def _predict_output_stem(title: str) -> str:
388
+ """Predict the output file stem for a video title: yt-dlp's --restrict-filenames
389
+ transform followed by our sanitize() — exactly what the post-download path
390
+ derives from the real downloaded filename. Used to skip already-encoded videos
391
+ without diverging from yt-dlp's actual output (H-8)."""
392
+ return sanitize(_restrict_filename(title))
393
+
394
+
395
+ def _resolve_batch_items(items_json: str):
396
+ """Parse the client `items` payload into the structures the pipeline
397
+ consumes. Returns (video_urls, video_titles, video_durations, per_item)
398
+ where per_item[i] carries the per-video format/output-dir/tune. Raises
399
+ ValueError on a non-http url (mirrors the single-url guard)."""
400
+ items = json.loads(items_json)
401
+ video_urls, video_titles, video_durations, per_item = [], {}, {}, []
402
+ for it in items:
403
+ u = it["url"]
404
+ if not is_http_url(u):
405
+ raise ValueError("URL must be http or https: {}".format(u))
406
+ video_urls.append(u)
407
+ video_titles[u] = it.get("title", "")
408
+ video_durations[u] = it.get("duration") or 0
409
+ per_item.append({
410
+ "video_format": it.get("video_format", ""),
411
+ "audio_format": it["audio_format"],
412
+ "expected_size": int(it.get("expected_size") or 0),
413
+ "output_dir": it.get("output_dir", "") or "",
414
+ "tune_mode": it.get("tune_mode", "uhq"),
415
+ })
416
+ return video_urls, video_titles, video_durations, per_item
417
+
418
+
419
+ # ── SSE event builders (single source of truth for the wire format) ───────────
420
+ # Every event is `data: {json}\n\n`. Centralizing this kills the f-string-vs-.format
421
+ # split that the file used to carry (the f-string-backslash gotcha is avoided here
422
+ # once, in _sse) and gives each event type one definition. The UI splits `phase`
423
+ # msgs on ": " to separate header from filename — sse_phase enforces that contract.
424
+ def _sse(obj: dict) -> str:
425
+ return "data: {}\n\n".format(json.dumps(obj))
426
+
427
+ def sse_log(msg: str) -> str:
428
+ return _sse({"type": "log", "msg": msg})
429
+
430
+ def sse_error(msg: str) -> str:
431
+ return _sse({"type": "error", "msg": msg})
432
+
433
+ def sse_cancelled(msg: str = "Cancelled by user.") -> str:
434
+ return _sse({"type": "cancelled", "msg": msg})
435
+
436
+ def sse_done(msg: str = "All done!") -> str:
437
+ return _sse({"type": "done", "msg": msg})
438
+
439
+ def sse_shutdown(msg: str = "Shutting down in 60 seconds...") -> str:
440
+ return _sse({"type": "shutdown", "msg": msg})
441
+
442
+ def sse_phase(header: str, filename: str = "") -> str:
443
+ # UI contract: "header: filename" — split on the first ": " in index.html.
444
+ msg = "{}: {}".format(header, filename) if filename else header
445
+ return _sse({"type": "phase", "msg": msg})
446
+
447
+ def sse_video_start(current: int, total: int) -> str:
448
+ return _sse({"type": "video_start", "current": current, "total": total})
449
+
450
+ def sse_source_size(size_bytes: int) -> str:
451
+ return _sse({"type": "source_size", "bytes": size_bytes})
452
+
453
+ def sse_progress(pct: float, overall: float, speed: str, eta: str, size: str, output_bytes: int = 0) -> str:
454
+ return _sse({"type": "progress", "pct": pct, "overall": overall,
455
+ "speed": speed, "eta": eta, "size": size, "output_bytes": output_bytes})
456
+
457
+ def sse_dl_progress(pct: float, size: str, speed: str, eta: str, idx: int, total: int) -> str:
458
+ return _sse({"type": "dl_progress", "pct": pct, "size": size,
459
+ "speed": speed, "eta": eta, "idx": idx, "total": total})
460
+
461
+ def sse_item_done(idx: int, total: int) -> str:
462
+ return _sse({"type": "item_done", "idx": idx, "total": total})
463
+
464
+ def sse_item_failed(idx: int, total: int, msg: str) -> str:
465
+ return _sse({"type": "item_failed", "idx": idx, "total": total, "msg": msg})
466
+
467
+ def sse_size_info(source_bytes: int, output_bytes: int) -> str:
468
+ bloat = (output_bytes - source_bytes) / source_bytes * 100 if source_bytes > 0 else 0.0
469
+ return _sse({"type": "size_info",
470
+ "source_mb": round(source_bytes / 1024**2, 1),
471
+ "output_mb": round(output_bytes / 1024**2, 1),
472
+ "bloat_pct": round(bloat, 1)})
473
+
474
+
475
+ _CUVID_DECODERS = {
476
+ "vp9": "vp9_cuvid",
477
+ "h264": "h264_cuvid",
478
+ "hevc": "hevc_cuvid",
479
+ "av1": "av1_cuvid",
480
+ "vp8": "vp8_cuvid",
481
+ }
482
+
483
+
484
+ def _ffmpeg_has_filter(name: str) -> bool:
485
+ """Whether this ffmpeg build provides a given filter."""
486
+ try:
487
+ out = subprocess.run(
488
+ [get_ffmpeg(), "-hide_banner", "-filters"],
489
+ capture_output=True, text=True, timeout=15,
490
+ ).stdout
491
+ except Exception:
492
+ return False
493
+ return any(line.split()[1:2] == [name] for line in out.splitlines() if line.strip())
494
+
495
+
496
+ # GPU-resident pixel-format conversion needs the scale_cuda filter (libnpp).
497
+ # The bundled Windows ffmpeg (gyan.dev) has it; Fedora/Nobara builds ship
498
+ # --disable-libnpp, so it's absent there even though hevc_nvenc encoding works.
499
+ HAS_SCALE_CUDA = _ffmpeg_has_filter("scale_cuda")
500
+
501
+
502
+ def _decode_filter_args(input_file: Path, pix_fmt: str, cuvid: Optional[str]) -> list:
503
+ """Decode + pixel-format args feeding the hevc_nvenc encode.
504
+
505
+ Fast path (scale_cuda present): NVDEC decode into CUDA memory and convert
506
+ the pixel format on the GPU — zero CPU roundtrip. This is what Windows used.
507
+ Fallback (no scale_cuda): NVDEC decode but let ffmpeg download frames so a
508
+ CPU `format` filter can run; hevc_nvenc re-uploads and encodes. Slower by a
509
+ PCIe copy, but works on stock Linux ffmpeg. hevc_nvenc does the heavy lifting
510
+ either way."""
511
+ if HAS_SCALE_CUDA:
512
+ return [
513
+ "-hwaccel", "cuda", "-hwaccel_output_format", "cuda",
514
+ *(["-c:v", cuvid] if cuvid else []),
515
+ "-i", str(input_file),
516
+ "-vf", f"scale_cuda=format={pix_fmt}",
517
+ ]
518
+ return [
519
+ "-hwaccel", "cuda",
520
+ "-i", str(input_file),
521
+ "-vf", f"format={pix_fmt}",
522
+ ]
523
+
524
+ # Audio-stream containers yt-dlp may produce when downloading audio-only formats.
525
+ _AUDIO_EXTS = {".webm", ".m4a", ".opus", ".ogg", ".mp3", ".aac", ".wav", ".flac"}
526
+
527
+
528
+ def audio_output_ext(preset: str) -> str:
529
+ return "wav" if preset == "wav" else "mp3"
530
+
531
+
532
+ def build_audio_ffmpeg(input_file: Path, output_file: Path, preset: str) -> list:
533
+ """ffmpeg args for audio extraction. -vn drops video; -progress pipe:1
534
+ matches the video encode flow so the UI parser stays unified."""
535
+ if preset == "wav":
536
+ codec_args = ["-c:a", "pcm_s16le"]
537
+ else:
538
+ codec_args = ["-c:a", "libmp3lame", "-b:a", "320k"]
539
+ return [
540
+ get_ffmpeg(), "-y",
541
+ "-loglevel", "info",
542
+ "-i", str(input_file),
543
+ "-vn",
544
+ *codec_args,
545
+ "-progress", "pipe:1",
546
+ "-nostats",
547
+ str(output_file),
548
+ ]
549
+
550
+
551
+ async def _download_is_complete(path: Path, expected_bytes: int,
552
+ expected_duration: Optional[float] = None,
553
+ min_ratio: float = 0.85) -> bool:
554
+ """Decide whether a downloaded media file is actually complete after yt-dlp
555
+ exited non-zero. With a known size, use the byte-ratio check. Without one
556
+ (live streams, premieres, age-restricted — YouTube reports no filesize),
557
+ do NOT trust the old existence+1MB heuristic: a 50MB slice of a 4GB video
558
+ would pass. Instead verify the container's duration against the duration we
559
+ learned at resolve time (±2s). If neither size nor duration is available we
560
+ can't prove completeness, so treat it as incomplete (H-9)."""
561
+ if not path.exists():
562
+ return False
563
+ if expected_bytes > 0:
564
+ return path.stat().st_size >= expected_bytes * min_ratio
565
+ if expected_duration and expected_duration > 0:
566
+ info = await probe_video(path)
567
+ actual = info.get("duration") or 0
568
+ return actual > 0 and abs(actual - expected_duration) <= 2.0
569
+ return False
570
+
571
+
572
+ async def probe_video(path: Path) -> dict:
573
+ """Deep-probe a video file and return source metadata."""
574
+ proc = await asyncio.create_subprocess_exec(
575
+ get_ffprobe(), "-v", "quiet",
576
+ "-print_format", "json",
577
+ "-show_streams", "-show_format",
578
+ str(path),
579
+ stdout=asyncio.subprocess.PIPE,
580
+ stderr=asyncio.subprocess.PIPE,
581
+ )
582
+ out, _ = await proc.communicate()
583
+ try:
584
+ info = json.loads(out)
585
+ except Exception:
586
+ return {}
587
+
588
+ fmt = info.get("format", {})
589
+ streams = info.get("streams", [])
590
+ vs = next((s for s in streams if s.get("codec_type") == "video"), {})
591
+
592
+ duration = float(fmt.get("duration", 0) or 0)
593
+ height = int(vs.get("height", 1080) or 1080)
594
+ try:
595
+ num, den = vs.get("r_frame_rate", "30/1").split("/")
596
+ fps = int(num) / max(int(den), 1)
597
+ except Exception:
598
+ fps = 30.0
599
+
600
+ # Per-stream bitrate is often absent from MKV; fall back to format total minus ~192k audio
601
+ video_bps = int(vs.get("bit_rate", 0) or 0)
602
+ if not video_bps:
603
+ total_bps = int(fmt.get("bit_rate", 0) or 0)
604
+ video_bps = max(total_bps - 192_000, 0)
605
+
606
+ return {
607
+ "duration": duration,
608
+ "height": height,
609
+ "fps": fps,
610
+ "codec": vs.get("codec_name", "h264"),
611
+ "pix_fmt": vs.get("pix_fmt", "yuv420p"),
612
+ "color_transfer": vs.get("color_transfer", ""),
613
+ "video_bitrate_kbps": video_bps // 1000,
614
+ }
615
+
616
+
617
+ def calc_encode_params(
618
+ height: int,
619
+ fps: float,
620
+ video_bitrate_kbps: int,
621
+ codec: str,
622
+ pix_fmt: str,
623
+ color_transfer: str,
624
+ cq_override: int = 0,
625
+ maxrate_override: str = "",
626
+ nvenc_tune: str = "uhq",
627
+ ) -> dict:
628
+ """
629
+ Derive NVENC encode parameters from source analysis.
630
+ cq_override=0 → auto-select by resolution.
631
+ maxrate_override="" → auto-calculate from source bitrate + codec efficiency.
632
+ """
633
+ # Quality target: lower = better quality / larger file
634
+ if cq_override > 0:
635
+ cq = cq_override
636
+ elif height >= 2160:
637
+ cq = 22
638
+ elif height >= 1440:
639
+ cq = 24
640
+ elif height >= 1080:
641
+ cq = 26
642
+ else:
643
+ cq = 28
644
+
645
+ # HQ mode gets a 2-point quality boost (source bitrate cap prevents file bloat).
646
+ if nvenc_tune == "hq" and cq_override == 0:
647
+ cq = max(cq - 2, 16)
648
+
649
+ # How much smaller H.265 NVENC should be vs the source codec at equal perceptual quality.
650
+ # Fast motion (game footage) narrows the gap — temporal prediction helps both codecs equally.
651
+ efficiency = {
652
+ "h264": 0.55, # H.265 ≈ 45% smaller than H.264
653
+ "vp9": 0.80, # H.265 ≈ 20% smaller than VP9 (closer parity, especially at 60fps)
654
+ "av1": 0.92, # AV1 and H.265 are nearly equivalent
655
+ "hevc": 0.88, # Re-encoding H.265 → diminishing returns
656
+ }.get(codec.lower(), 0.70)
657
+
658
+ # Absolute resolution+fps ceiling (hard cap regardless of source)
659
+ res_ceil = next(
660
+ (v for k, v in [(2160, 20), (1440, 12), (1080, 7), (720, 4)] if height >= k), 4
661
+ )
662
+ if fps > 35 and height < 2160: # 60fps headroom for sub-4K only; 4K stays at 20M ceiling
663
+ res_ceil = round(res_ceil * 1.40)
664
+
665
+ # Per-resolution minimum — ensures CQ always has enough headroom to hit its target
666
+ res_min = next(
667
+ (v for k, v in [(2160, 8), (1440, 5), (1080, 3), (720, 2)] if height >= k), 2
668
+ )
669
+
670
+ # Source-derived average bitrate target and bloom-capped ceiling.
671
+ # Using a real b_v average + maxrate ceiling lets NVENC vary around the average
672
+ # and burst above it for complex frames — proper VBR instead of de-facto CBR.
673
+ derived_mbps = round(video_bitrate_kbps * efficiency / 1000, 1) if video_bitrate_kbps > 0 else None
674
+
675
+ if maxrate_override and maxrate_override not in ("", "0"):
676
+ # User-specified ceiling: pure CQ mode, no average target
677
+ maxrate_mbps = int(maxrate_override.replace("M", ""))
678
+ elif derived_mbps:
679
+ # Burst headroom keyed off derived (expected output) bitrate, not source.
680
+ # source * 1.875 was too generous for H.264 (0.55 efficiency) — it allowed
681
+ # burst headroom of 3.4× the expected output, causing H.264 sources to produce
682
+ # outputs larger than the source. 2× derived gives reasonable burst room while
683
+ # keeping average bitrate close to the expected output.
684
+ bloom_cap_mbps = max(round(derived_mbps) + 1, round(derived_mbps * 2))
685
+ maxrate_mbps = min(bloom_cap_mbps, res_ceil)
686
+ else:
687
+ # Bitrate unknown: fall back to half the resolution ceiling
688
+ maxrate_mbps = max(res_min, res_ceil // 2)
689
+
690
+ # HQ mode: cap at source bitrate — re-encoding a lossy source can't recover quality
691
+ # beyond what the source contains, so exceeding source bitrate only bloats the file.
692
+ if nvenc_tune == "hq" and video_bitrate_kbps > 0 and not (maxrate_override and maxrate_override not in ("", "0")):
693
+ source_cap_mbps = video_bitrate_kbps / 1000
694
+ maxrate_mbps = max(min(maxrate_mbps, source_cap_mbps), res_min)
695
+
696
+ # HDR / 10-bit detection
697
+ hdr_transfers = {"smpte2084", "arib-std-b67", "smpte428", "bt2020-10", "bt2020-12"}
698
+ hdr = color_transfer in hdr_transfers
699
+ ten_bit = "10le" in pix_fmt or "10be" in pix_fmt or hdr
700
+ pix_out = "yuv420p10le" if ten_bit else "yuv420p"
701
+
702
+ return {
703
+ "cq": cq,
704
+ # hq: p2 freely for all 60fps (no uhq restriction). uhq: p2 rejected by uhq at 4K;
705
+ # p3 for 4K 60fps, p2 for sub-4K 60fps (handled at encode time for p2→p4 guard).
706
+ "preset": ("p2" if fps > 35 else "p4") if nvenc_tune == "hq" else (
707
+ "p2" if (fps > 35 and height < 2160) else ("p3" if fps > 35 else "p4")
708
+ ),
709
+ "maxrate": f"{maxrate_mbps}M",
710
+ "bufsize": f"{maxrate_mbps * 2}M",
711
+ "pix_fmt": pix_out,
712
+ "profile": "main10" if ten_bit else "main",
713
+ "hdr": hdr,
714
+ "ten_bit": ten_bit,
715
+ "bf": 3,
716
+ "b_ref_mode": "middle",
717
+ }
718
+
719
+ current_process: Optional[asyncio.subprocess.Process] = None
720
+ current_dl_process: Optional[asyncio.subprocess.Process] = None
721
+ # Sticky cancel flag. /cancel sets it; every download/encode loop checks it at the
722
+ # top of each iteration and inside the subprocess read loops, so a cancel stops the
723
+ # WHOLE job (no next item, no retry) — not just the one process it happened to catch.
724
+ # Cleared at the start of every new /download and /convert-local stream.
725
+ _cancel_requested = asyncio.Event()
726
+ _nvenc_tune: str = "uhq" # Verified at startup; falls back to "hq" on older drivers
727
+ _MAX_CONSECUTIVE_ENCODE_FAILURES = 3 # pipeline mode: bail out if the encoder is fundamentally broken
728
+
729
+
730
+ async def _probe_nvenc_tune():
731
+ """Test whether hevc_nvenc accepts -tune uhq.
732
+ Note: -bf at any value (even 0) is incompatible with uhq on all tested NVENC GPUs
733
+ (RTX 3060, RTX 4080). uhq manages B-frames internally; passing -bf overrides this
734
+ and causes InitializeEncoder failed: invalid param (8). The probe tests uhq without
735
+ -bf, which is how it's actually used in encodes."""
736
+ global _nvenc_tune
737
+ proc = await asyncio.create_subprocess_exec(
738
+ get_ffmpeg(), "-f", "lavfi", "-i", "nullsrc=s=1920x1080:d=0.1",
739
+ "-vframes", "3",
740
+ "-c:v", "hevc_nvenc",
741
+ "-preset", "p4",
742
+ "-tune", "uhq",
743
+ "-rc", "vbr",
744
+ "-cq", "26",
745
+ "-b:v", "0",
746
+ "-maxrate", "7M",
747
+ "-bufsize", "14M",
748
+ "-rc-lookahead", "16",
749
+ "-f", "null", "-",
750
+ stdout=asyncio.subprocess.PIPE,
751
+ stderr=asyncio.subprocess.PIPE,
752
+ )
753
+ _, stderr = await proc.communicate()
754
+ err = stderr.decode(errors="replace")
755
+ if "InitializeEncoder failed" in err or "Unable to parse option value" in err or "invalid param" in err:
756
+ _nvenc_tune = "hq"
757
+ logger.info("hevc_nvenc: 'uhq' not supported on this driver — using 'hq' + AQ")
758
+ else:
759
+ _nvenc_tune = "uhq"
760
+ logger.info("hevc_nvenc: 'uhq' supported")
761
+
762
+
763
+ def build_video_ffmpeg_args(input_file: Path, output_file: Path, params: dict,
764
+ effective_tune: str, codec: str) -> list:
765
+ """Construct the hevc_nvenc ffmpeg argv. Pure (no I/O beyond the module-level
766
+ capability probes) so it can be unit-tested with a fixture params dict. Single
767
+ source of truth for the three former copies (H-2)."""
768
+ # uhq requires p4+ minimum; p2 rejected — guard only needed for the uhq path.
769
+ effective_preset = "p4" if (effective_tune == "uhq" and params["preset"] == "p2") else params["preset"]
770
+ cuvid = _CUVID_DECODERS.get((codec or "").lower())
771
+ return [
772
+ get_ffmpeg(), "-y",
773
+ "-loglevel", "info",
774
+ *_decode_filter_args(input_file, params["pix_fmt"], cuvid),
775
+ "-c:v", "hevc_nvenc",
776
+ "-preset", effective_preset,
777
+ "-profile:v", params["profile"],
778
+ "-tune", effective_tune,
779
+ "-rc", "vbr",
780
+ "-cq", str(params["cq"]),
781
+ "-b:v", "0",
782
+ "-maxrate", params["maxrate"],
783
+ "-bufsize", params["bufsize"],
784
+ *(["-bf", str(params["bf"]), "-b_ref_mode", params["b_ref_mode"]] if effective_tune == "hq" else []),
785
+ "-rc-lookahead", "16",
786
+ # uhq enables AQ internally; explicit flags cause invalid param on SDK 12+.
787
+ *(["-spatial_aq", "1", "-aq-strength", "8", "-temporal_aq", "1"] if effective_tune != "uhq" else []),
788
+ "-c:a", "aac",
789
+ "-b:a", "192k",
790
+ "-progress", "pipe:1",
791
+ "-nostats",
792
+ str(output_file),
793
+ ]
794
+
795
+
796
+ async def run_encode(ffmpeg_args: list, *, duration_secs, idx: int, total: int,
797
+ result: dict):
798
+ """Run one ffmpeg encode/extract, yielding SSE strings for progress + log.
799
+
800
+ Drains stdout (the -progress key=value stream) and stderr (ffmpeg's human
801
+ log) CONCURRENTLY via a single queue, so stderr lines reach the UI within
802
+ ~100ms even when no stdout arrives during a long codec init (H-4) — no more
803
+ O(n) list.pop(0) gated on stdout. Sets current_process so /cancel can
804
+ terminate it, and honours the sticky _cancel_requested flag. The ffmpeg
805
+ return code is written to result["returncode"]; this is the single encode
806
+ loop the three call sites share (H-2)."""
807
+ global current_process
808
+ proc = await asyncio.create_subprocess_exec(
809
+ *ffmpeg_args,
810
+ stdout=asyncio.subprocess.PIPE,
811
+ stderr=asyncio.subprocess.PIPE,
812
+ )
813
+ current_process = proc
814
+
815
+ q: asyncio.Queue = asyncio.Queue()
816
+
817
+ async def _pump(stream, tag):
818
+ async for raw in stream:
819
+ await q.put((tag, raw))
820
+ await q.put((tag + "_eof", None))
821
+
822
+ t_out = asyncio.create_task(_pump(proc.stdout, "out"))
823
+ t_err = asyncio.create_task(_pump(proc.stderr, "err"))
824
+
825
+ progress_buf: dict = {}
826
+ eofs = 0
827
+ terminated = False
828
+ try:
829
+ while eofs < 2:
830
+ tag, raw = await q.get()
831
+ if tag in ("out_eof", "err_eof"):
832
+ eofs += 1
833
+ continue
834
+ if _cancel_requested.is_set() and not terminated:
835
+ proc.terminate()
836
+ terminated = True
837
+ # keep draining both pipes to EOF so the pump tasks finish cleanly
838
+ continue
839
+ line = raw.decode(errors="replace").rstrip()
840
+ if tag == "err":
841
+ if line:
842
+ yield sse_log(line)
843
+ continue
844
+ # tag == "out": ffmpeg -progress key=value lines
845
+ if "=" in line:
846
+ k, _, v = line.partition("=")
847
+ progress_buf[k.strip()] = v.strip()
848
+ if k.strip() == "progress":
849
+ try:
850
+ out_time_us = int(progress_buf.get("out_time_us", 0) or 0)
851
+ except (ValueError, TypeError):
852
+ out_time_us = 0
853
+ current_secs = out_time_us / 1_000_000
854
+ speed_str = progress_buf.get("speed", "?")
855
+ fps_str = progress_buf.get("fps", "?")
856
+ if duration_secs and duration_secs > 0:
857
+ file_pct = min(100, (current_secs / duration_secs) * 100)
858
+ overall = ((idx - 1) / total * 100) + (file_pct / total)
859
+ else:
860
+ file_pct = 0.0
861
+ overall = ((idx - 1) / total * 100)
862
+ live_bytes = int(progress_buf.get("total_size", 0) or 0)
863
+ live_size = "{:.0f} MB".format(live_bytes / 1024**2) if live_bytes > 0 else ""
864
+ yield sse_progress(file_pct, overall, speed_str, fps_str + " fps", live_size, live_bytes)
865
+ progress_buf = {}
866
+ elif line:
867
+ yield sse_log(line)
868
+ finally:
869
+ await t_out
870
+ await t_err
871
+ await proc.wait()
872
+ current_process = None
873
+ result["returncode"] = proc.returncode
874
+
875
+
876
+ @app.get("/", response_class=HTMLResponse)
877
+ async def index():
878
+ html_path = PKG_DIR / "index.html"
879
+ html = await asyncio.to_thread(html_path.read_text, encoding="utf-8")
880
+ # Hand the page this launch's session token (consumed by /shutdown-now).
881
+ html = html.replace("__DLPR_TOKEN__", _SESSION_TOKEN)
882
+ return HTMLResponse(html)
883
+
884
+
885
+ @app.post("/upload-cookies")
886
+ async def upload_cookies(file: UploadFile = File(...)):
887
+ content = await file.read()
888
+ await asyncio.to_thread(COOKIES_PATH.write_bytes, content)
889
+ return {"status": "ok", "message": "cookies.txt saved"}
890
+
891
+
892
+ def _validate_cookies_text(text: str) -> dict:
893
+ """Parse a Netscape cookie file and report counts. Does not save."""
894
+ text = (text or "").strip()
895
+ if not text:
896
+ return {"valid": False, "reason": "empty input"}
897
+
898
+ lines = text.splitlines()
899
+ cookie_lines = [l for l in lines if l.strip() and not l.lstrip().startswith("#")]
900
+ if not cookie_lines:
901
+ return {"valid": False, "reason": "no cookie entries found"}
902
+
903
+ valid_cookies = 0
904
+ youtube_cookies = 0
905
+ for l in cookie_lines:
906
+ parts = l.split("\t")
907
+ if len(parts) >= 7:
908
+ valid_cookies += 1
909
+ domain = parts[0].lower()
910
+ if "youtube" in domain or "google" in domain:
911
+ youtube_cookies += 1
912
+
913
+ if valid_cookies == 0:
914
+ return {"valid": False, "reason": "lines are not tab-separated Netscape cookies"}
915
+
916
+ return {"valid": True, "cookies": valid_cookies, "youtube_cookies": youtube_cookies}
917
+
918
+
919
+ @app.post("/paste-cookies")
920
+ async def paste_cookies(text: str = Form(...)):
921
+ result = _validate_cookies_text(text)
922
+ if not result.get("valid"):
923
+ return result
924
+
925
+ body = text.strip()
926
+ if not any("netscape http cookie file" in l.lower() for l in body.splitlines()[:5]):
927
+ body = "# Netscape HTTP Cookie File\n" + body
928
+ await asyncio.to_thread(COOKIES_PATH.write_text, body, encoding="utf-8")
929
+ return result
930
+
931
+
932
+ @app.get("/check-cookies")
933
+ async def check_cookies():
934
+ if not COOKIES_PATH.exists():
935
+ return {"exists": False}
936
+ try:
937
+ text = await asyncio.to_thread(COOKIES_PATH.read_text, encoding="utf-8", errors="replace")
938
+ info = _validate_cookies_text(text)
939
+ return {"exists": True, **info}
940
+ except Exception as e:
941
+ return {"exists": True, "valid": False, "reason": str(e)}
942
+
943
+
944
+ @app.get("/history")
945
+ async def get_history():
946
+ return await _load_history()
947
+
948
+
949
+ @app.post("/history")
950
+ async def add_history(
951
+ url: str = Form(...),
952
+ title: str = Form(""),
953
+ thumbnail: str = Form(""),
954
+ is_playlist: str = Form("false"),
955
+ count: str = Form("1"),
956
+ ):
957
+ entry = {
958
+ "url": url,
959
+ "title": title,
960
+ "thumbnail": thumbnail,
961
+ "is_playlist": is_playlist == "true",
962
+ "count": int(count) if count.isdigit() else 1,
963
+ "added": datetime.datetime.now().isoformat(timespec="seconds"),
964
+ }
965
+ await _save_history_entry(entry)
966
+ return {"status": "ok"}
967
+
968
+
969
+ @app.delete("/history")
970
+ async def clear_history():
971
+ async with _history_lock:
972
+ if HISTORY_PATH.exists():
973
+ await asyncio.to_thread(HISTORY_PATH.unlink)
974
+ return {"status": "ok"}
975
+
976
+
977
+ @app.get("/ytdlp-version")
978
+ async def ytdlp_version():
979
+ proc = await asyncio.create_subprocess_exec(
980
+ str(get_ytdlp()), "--version",
981
+ stdout=asyncio.subprocess.PIPE,
982
+ stderr=asyncio.subprocess.PIPE,
983
+ )
984
+ stdout, _ = await proc.communicate()
985
+ return {"version": stdout.decode().strip()}
986
+
987
+
988
+ @app.post("/update-ytdlp")
989
+ async def update_ytdlp():
990
+ proc = await asyncio.create_subprocess_exec(
991
+ str(get_ytdlp()), "-U",
992
+ stdout=asyncio.subprocess.PIPE,
993
+ stderr=asyncio.subprocess.STDOUT,
994
+ )
995
+ stdout, _ = await proc.communicate()
996
+ return {"output": stdout.decode(errors="replace").strip()}
997
+
998
+
999
+ @app.get("/video-info")
1000
+ async def video_info(url: str):
1001
+ if not is_http_url(url):
1002
+ return JSONResponse({"error": "URL must be http or https"}, status_code=400)
1003
+ # Use --flat-playlist first to detect playlist vs single
1004
+ args = [
1005
+ str(get_ytdlp()),
1006
+ "--flat-playlist",
1007
+ "-J",
1008
+ "--no-download",
1009
+ *NODE_ARGS,
1010
+ ]
1011
+ args += cookie_args()
1012
+ args += ["--", url]
1013
+
1014
+ proc = await asyncio.create_subprocess_exec(
1015
+ *args,
1016
+ stdout=asyncio.subprocess.PIPE,
1017
+ stderr=asyncio.subprocess.PIPE,
1018
+ )
1019
+ stdout, stderr = await proc.communicate()
1020
+ if proc.returncode != 0:
1021
+ err = stderr.decode(errors="replace")
1022
+ return JSONResponse({"error": err}, status_code=400)
1023
+
1024
+ info = json.loads(stdout)
1025
+ is_playlist = info.get("_type") == "playlist"
1026
+
1027
+ if is_playlist:
1028
+ entries = info.get("entries", [])
1029
+ # For playlists, fetch format info from the first entry
1030
+ first_url = entries[0].get("url") or entries[0].get("webpage_url") if entries else url
1031
+ format_info = await _get_formats(first_url)
1032
+ return {
1033
+ "is_playlist": True,
1034
+ "title": info.get("title", "Playlist"),
1035
+ "uploader": info.get("uploader") or info.get("channel"),
1036
+ "count": len(entries),
1037
+ "thumbnail": entries[0].get("thumbnails", [{}])[-1].get("url") if entries else None,
1038
+ **format_info,
1039
+ }
1040
+ else:
1041
+ format_info = await _get_formats(url)
1042
+ return {
1043
+ "is_playlist": False,
1044
+ "title": info.get("title", "Unknown"),
1045
+ "duration": info.get("duration"),
1046
+ "thumbnail": info.get("thumbnail"),
1047
+ "uploader": info.get("uploader"),
1048
+ **format_info,
1049
+ }
1050
+
1051
+
1052
+ async def _get_formats(url: str) -> dict:
1053
+ args = [
1054
+ str(get_ytdlp()),
1055
+ "-J",
1056
+ "--no-download",
1057
+ "--no-playlist",
1058
+ *NODE_ARGS,
1059
+ ]
1060
+ args += cookie_args()
1061
+ args += ["--", url]
1062
+
1063
+ proc = await asyncio.create_subprocess_exec(
1064
+ *args,
1065
+ stdout=asyncio.subprocess.PIPE,
1066
+ stderr=asyncio.subprocess.PIPE,
1067
+ )
1068
+ stdout, stderr = await proc.communicate()
1069
+ if proc.returncode != 0:
1070
+ return {"video_formats": [], "audio_formats": []}
1071
+
1072
+ info = json.loads(stdout)
1073
+ formats = info.get("formats", [])
1074
+ video_formats = []
1075
+ audio_formats = []
1076
+
1077
+ for f in formats:
1078
+ fid = f.get("format_id", "")
1079
+ ext = f.get("ext", "")
1080
+ vcodec = f.get("vcodec", "none")
1081
+ acodec = f.get("acodec", "none")
1082
+ height = f.get("height")
1083
+ fps = f.get("fps")
1084
+ tbr = f.get("tbr")
1085
+ filesize = f.get("filesize") or f.get("filesize_approx")
1086
+ note = f.get("format_note", "")
1087
+
1088
+ if vcodec != "none" and acodec == "none":
1089
+ label = f"{height}p" if height else ""
1090
+ if fps and fps > 30:
1091
+ label += f"{int(fps)}"
1092
+ if tbr:
1093
+ label += f" ~{int(tbr)}k"
1094
+ if filesize:
1095
+ label += f" ({filesize / 1024**3:.1f}GB)"
1096
+ video_formats.append({
1097
+ "id": fid, "ext": ext, "label": label,
1098
+ "height": height or 0, "fps": fps or 0,
1099
+ "vcodec": vcodec, "note": note,
1100
+ "filesize": filesize or 0,
1101
+ })
1102
+ elif acodec != "none" and vcodec == "none":
1103
+ abr = f.get("abr", 0)
1104
+ label = f"{acodec} {int(abr)}k" if abr else acodec
1105
+ if filesize:
1106
+ label += f" ({filesize / 1024**2:.0f}MB)"
1107
+ audio_formats.append({
1108
+ "id": fid, "ext": ext, "label": label,
1109
+ "abr": abr or 0, "acodec": acodec, "note": note,
1110
+ "filesize": filesize or 0,
1111
+ })
1112
+
1113
+ video_formats.sort(key=lambda x: (x["height"], x["fps"]), reverse=True)
1114
+ audio_formats.sort(key=lambda x: x["abr"], reverse=True)
1115
+ return {"video_formats": video_formats, "audio_formats": audio_formats}
1116
+
1117
+
1118
+ @app.post("/download")
1119
+ async def download(
1120
+ url: str = Form(...),
1121
+ video_format: str = Form(""),
1122
+ audio_format: str = Form(...),
1123
+ convert: str = Form("true"),
1124
+ cq: str = Form("0"),
1125
+ maxrate: str = Form(""),
1126
+ pipeline: str = Form("false"),
1127
+ delete_cache: str = Form("true"),
1128
+ expected_size: str = Form("0"), # expected MKV size in bytes (video+audio from YouTube metadata)
1129
+ shutdown: str = Form("false"),
1130
+ output_dir: str = Form(""),
1131
+ tune_mode: str = Form("uhq"),
1132
+ mode: str = Form("video"), # "video" → H.265 MP4; "audio" → WAV/MP3 extract
1133
+ audio_preset: str = Form("mp3"), # "wav" or "mp3"; only used when mode == "audio"
1134
+ items: str = Form(""), # JSON array of per-item {url,video_format,audio_format,expected_size,output_dir,tune_mode,title,duration}
1135
+ ):
1136
+ async def stream():
1137
+ global current_process
1138
+ _cancel_requested.clear() # fresh job — drop any stale cancel
1139
+ _keep_awake()
1140
+ _job_start = datetime.datetime.now()
1141
+ _job_lines = []
1142
+
1143
+ def _jlog(level, msg):
1144
+ ts = datetime.datetime.now().strftime("%H:%M:%S")
1145
+ _job_lines.append("[{}] [{}] {}".format(ts, level, msg))
1146
+
1147
+ try:
1148
+ async for chunk in _download_stream():
1149
+ if chunk.startswith("data: "):
1150
+ try:
1151
+ evt = json.loads(chunk[6:].strip())
1152
+ t = evt.get("type", "")
1153
+ if t == "phase":
1154
+ _jlog("PHASE", evt.get("msg", ""))
1155
+ elif t == "log":
1156
+ _jlog("LOG", evt.get("msg", ""))
1157
+ elif t == "error":
1158
+ _jlog("ERROR", evt.get("msg", ""))
1159
+ elif t == "done":
1160
+ _jlog("DONE", evt.get("msg", ""))
1161
+ elif t == "video_start":
1162
+ _jlog("VIDEO", "Video {current}/{total}".format(**evt))
1163
+ elif t == "size_info":
1164
+ _jlog("SIZE", "source={source_mb}MB output={output_mb}MB bloat={bloat_pct:+.1f}%".format(**evt))
1165
+ except Exception:
1166
+ pass
1167
+ yield chunk
1168
+ finally:
1169
+ elapsed = datetime.datetime.now() - _job_start
1170
+ status = "CANCELLED"
1171
+ for ln in reversed(_job_lines):
1172
+ if "[ERROR]" in ln:
1173
+ status = "FAILED"
1174
+ break
1175
+ if "[DONE]" in ln:
1176
+ status = "SUCCESS"
1177
+ break
1178
+ ts_file = _job_start.strftime("%Y-%m-%d_%H-%M-%S")
1179
+ log_path = LOGS_DIR / "{}_download.txt".format(ts_file)
1180
+ header_lines = [
1181
+ "=== FetchForge Job Log ===",
1182
+ "Started : {}".format(_job_start.strftime("%Y-%m-%d %H:%M:%S")),
1183
+ "Type : YouTube Download",
1184
+ "URL : {}".format(url),
1185
+ "Elapsed : {}".format(str(elapsed).split(".")[0]),
1186
+ "Status : {}".format(status),
1187
+ "",
1188
+ "--- Events ---",
1189
+ "",
1190
+ ]
1191
+ try:
1192
+ await asyncio.to_thread(
1193
+ log_path.write_text,
1194
+ "\n".join(header_lines + _job_lines + [""]),
1195
+ encoding="utf-8",
1196
+ )
1197
+ except Exception as exc:
1198
+ logger.warning("Log write failed: %s", exc)
1199
+ _allow_sleep()
1200
+
1201
+ async def _download_stream():
1202
+ global current_process
1203
+
1204
+ dest_dir = Path(output_dir) if output_dir else CONVERTED_DIR
1205
+ dest_dir.mkdir(parents=True, exist_ok=True)
1206
+
1207
+ is_audio = mode == "audio"
1208
+ out_ext = audio_output_ext(audio_preset) if is_audio else "mp4"
1209
+ out_suffix = "" if is_audio else "_h265" # video keeps the _h265 marker
1210
+
1211
+ def _predicted_output(stem: str) -> Path:
1212
+ return dest_dir / "{}{}.{}".format(stem, out_suffix, out_ext)
1213
+
1214
+ # In audio mode the user-selected audio_format is the only stream we pull;
1215
+ # no merge to MKV. Output filename detection swaps from [Merger] to [download] Destination.
1216
+ def _yt_format_args() -> list:
1217
+ if is_audio:
1218
+ return ["-f", audio_format]
1219
+ return ["-f", "{}+{}".format(video_format, audio_format)]
1220
+
1221
+ def _yt_merge_args() -> list:
1222
+ return [] if is_audio else ["--merge-output-format", "mkv"]
1223
+
1224
+ def _glob_downloaded(cache: Path) -> list:
1225
+ if is_audio:
1226
+ return sorted(
1227
+ [f for f in cache.iterdir() if f.is_file() and f.suffix.lower() in _AUDIO_EXTS],
1228
+ key=os.path.getmtime,
1229
+ )
1230
+ return sorted(cache.glob("*.mkv"), key=os.path.getmtime)
1231
+
1232
+ # ── Step 1: resolve batch / playlist / single video ───────────────────
1233
+ # Batch mode: the client handed us a ready list of independent items.
1234
+ # Skip the playlist resolve entirely — metadata came from /video-info.
1235
+ batch = items.strip() != ""
1236
+ per_item = []
1237
+ if batch:
1238
+ try:
1239
+ video_urls, video_titles, video_durations, per_item = _resolve_batch_items(items)
1240
+ except (ValueError, KeyError, json.JSONDecodeError) as exc:
1241
+ yield sse_error("Bad batch payload: {}".format(exc))
1242
+ return
1243
+ total_videos = len(video_urls)
1244
+ if total_videos == 0:
1245
+ yield sse_error("Batch had no items.")
1246
+ return
1247
+ yield sse_log("Batch: {} video(s) queued — pipelined.".format(total_videos))
1248
+ else:
1249
+ if not is_http_url(url):
1250
+ yield sse_error('URL must be http or https.')
1251
+ return
1252
+
1253
+ yield sse_phase('Resolving...')
1254
+
1255
+ flat_args = [str(get_ytdlp()), "--flat-playlist", "-J", "--no-download", *NODE_ARGS]
1256
+ flat_args += cookie_args()
1257
+ flat_args += ["--", url]
1258
+
1259
+ flat_proc = await asyncio.create_subprocess_exec(
1260
+ *flat_args,
1261
+ stdout=asyncio.subprocess.PIPE,
1262
+ stderr=asyncio.subprocess.PIPE,
1263
+ )
1264
+ flat_out, flat_err = await flat_proc.communicate()
1265
+
1266
+ if flat_proc.returncode != 0:
1267
+ yield sse_error('Could not fetch video info: ' + flat_err.decode(errors='replace')[:300])
1268
+ return
1269
+
1270
+ try:
1271
+ info = json.loads(flat_out)
1272
+ except Exception:
1273
+ yield sse_error('Failed to parse video info.')
1274
+ return
1275
+
1276
+ if info.get("_type") == "playlist":
1277
+ entries = [e for e in info.get("entries", []) if e]
1278
+ video_urls = []
1279
+ video_titles = {} # url → title, used to skip already-encoded videos
1280
+ video_durations = {} # url → duration (s), used to verify download completeness
1281
+ for e in entries:
1282
+ vid_url = e.get("webpage_url") or e.get("url") or ""
1283
+ if vid_url and not vid_url.startswith("http"):
1284
+ vid_url = f"https://www.youtube.com/watch?v={vid_url}"
1285
+ if vid_url:
1286
+ video_urls.append(vid_url)
1287
+ video_titles[vid_url] = e.get("title", "")
1288
+ video_durations[vid_url] = e.get("duration") or 0
1289
+ total_videos = len(video_urls)
1290
+ yield sse_log(f'Playlist: {total_videos} video(s) queued — processing one at a time.')
1291
+ else:
1292
+ video_urls = [url]
1293
+ video_titles = {url: info.get("title") or ""}
1294
+ video_durations = {url: info.get("duration") or 0}
1295
+ total_videos = 1
1296
+
1297
+ # ── Step 2 ────────────────────────────────────────────────────────────
1298
+ if (pipeline == "true" or batch) and total_videos >= 1 and convert == "true" and not is_audio and (batch or total_videos > 1):
1299
+ # ── Pipeline mode: downloader and encoder run concurrently ─────────
1300
+ yield sse_log('Pipeline mode: next download starts while current video encodes.')
1301
+
1302
+ file_q: asyncio.Queue = asyncio.Queue(maxsize=1)
1303
+ msg_q: asyncio.Queue = asyncio.Queue()
1304
+ abort = asyncio.Event() # set on hard failure (stops the pipeline)
1305
+ enc_gone = asyncio.Event() # set when enc_worker exits (unblocks dl_worker)
1306
+
1307
+ def _stopping() -> bool:
1308
+ return abort.is_set() or _cancel_requested.is_set() or enc_gone.is_set()
1309
+
1310
+ async def _put_file(item) -> bool:
1311
+ """Hand a downloaded MKV to the encoder over the maxsize=1 queue,
1312
+ but never block forever if the encoder has stopped consuming
1313
+ (abort, cancel, or encoder exited). Returns False if we gave up
1314
+ without enqueuing. Fixes the dl_worker↔enc_worker deadlock (H-7)."""
1315
+ while not _stopping():
1316
+ try:
1317
+ file_q.put_nowait(item)
1318
+ return True
1319
+ except asyncio.QueueFull:
1320
+ await asyncio.sleep(0.1)
1321
+ return False
1322
+
1323
+ async def _send_sentinel():
1324
+ """Deliver the None stop-sentinel to enc_worker so it can break out
1325
+ of file_q.get(). Keep trying while the encoder is alive (even on
1326
+ cancel/abort — it still needs the sentinel to exit), but give up the
1327
+ moment it has already exited so we never block on a full queue."""
1328
+ while not enc_gone.is_set():
1329
+ try:
1330
+ file_q.put_nowait(None)
1331
+ return
1332
+ except asyncio.QueueFull:
1333
+ await asyncio.sleep(0.1)
1334
+
1335
+ async def dl_worker():
1336
+ global current_dl_process
1337
+ try:
1338
+ for vid_idx, vid_url in enumerate(video_urls, 1):
1339
+ if _stopping():
1340
+ break
1341
+ dl_label = "[DL {}/{}] ".format(vid_idx, total_videos)
1342
+
1343
+ # Per-item lookups (batch mode) vs request-level scalars.
1344
+ cfg = per_item[vid_idx - 1] if batch else None
1345
+ vf = cfg["video_format"] if cfg else video_format
1346
+ af = cfg["audio_format"] if cfg else audio_format
1347
+ item_dest = Path(cfg["output_dir"]) if (cfg and cfg["output_dir"]) else dest_dir
1348
+ item_dest.mkdir(parents=True, exist_ok=True)
1349
+ exp_bytes = cfg["expected_size"] if cfg else (int(expected_size) if expected_size else 0)
1350
+
1351
+ # Skip download if output MP4 already exists and looks complete
1352
+ min_output = max(1024 * 1024, int(exp_bytes * 0.30))
1353
+ vid_title = video_titles.get(vid_url, "")
1354
+ if vid_title:
1355
+ predicted = item_dest / "{}_h265.mp4".format(_predict_output_stem(vid_title))
1356
+ if predicted.exists() and predicted.stat().st_size >= min_output:
1357
+ await msg_q.put(sse_log("{}Skipping \u2014 already encoded: {}".format(dl_label, predicted.name)))
1358
+ if batch:
1359
+ await msg_q.put(sse_item_done(vid_idx, total_videos))
1360
+ continue
1361
+
1362
+ # NOTE: pipeline mode only — do NOT emit a `phase` event here.
1363
+ # The main progress bar tracks the concurrent ENCODE (sse_progress);
1364
+ # pipeline download progress is shown separately via sse_dl_progress
1365
+ # (the dl-status-row). A `phase` event would flip the main bar to
1366
+ # indeterminate/scrolling with nothing to clear it during the download.
1367
+ # The title still surfaces here in the log and in the dl-status-row.
1368
+ if vid_title:
1369
+ await msg_q.put(sse_log("{}Downloading: {}".format(dl_label, vid_title)))
1370
+ else:
1371
+ await msg_q.put(sse_log("{}Downloading...".format(dl_label)))
1372
+
1373
+ dl_args = [
1374
+ str(get_ytdlp()),
1375
+ "-f", "{}+{}".format(vf, af),
1376
+ *NODE_ARGS,
1377
+ "--no-playlist",
1378
+ "--no-overwrites",
1379
+ "--merge-output-format", "mkv",
1380
+ "--concurrent-fragments", "2",
1381
+ "--retries", "25",
1382
+ "--fragment-retries", "25",
1383
+ "--newline",
1384
+ "--restrict-filenames",
1385
+ "--paths", str(CACHE_DIR),
1386
+ "-o", "%(title)s.%(ext)s",
1387
+ ]
1388
+ dl_args += cookie_args()
1389
+ dl_args += ["--", vid_url]
1390
+
1391
+ proc = await asyncio.create_subprocess_exec(
1392
+ *dl_args,
1393
+ stdout=asyncio.subprocess.PIPE,
1394
+ stderr=asyncio.subprocess.STDOUT,
1395
+ )
1396
+ current_dl_process = proc
1397
+ output_paths = set()
1398
+
1399
+ async for raw in proc.stdout:
1400
+ if _stopping():
1401
+ proc.terminate()
1402
+ break
1403
+ line = raw.decode(errors="replace").rstrip()
1404
+ m = re.search(
1405
+ r"\[download\]\s+([\d.]+)%\s+of\s+(\S+)\s+at\s+(.+?)\s+ETA\s+(\S+)",
1406
+ line
1407
+ )
1408
+ if m:
1409
+ dl_pct, dl_size, dl_speed, dl_eta = m.groups()
1410
+ await msg_q.put(sse_dl_progress(float(dl_pct), dl_size, dl_speed, dl_eta, vid_idx, total_videos))
1411
+ else:
1412
+ merger = re.search(r'\[Merger\] Merging formats into "(.+?)"', line)
1413
+ already = re.search(r'\[download\] (.+?) has already been downloaded', line)
1414
+ if merger:
1415
+ output_paths.add(merger.group(1))
1416
+ elif already:
1417
+ output_paths.add(already.group(1))
1418
+ await msg_q.put(sse_log(line))
1419
+
1420
+ await proc.wait()
1421
+ current_dl_process = None
1422
+
1423
+ if _cancel_requested.is_set():
1424
+ break # user cancelled — not a download failure
1425
+
1426
+ mkv_files = []
1427
+ if output_paths:
1428
+ mkv_files = sorted(
1429
+ [Path(p) for p in output_paths if Path(p).exists() and p.endswith(".mkv")],
1430
+ key=os.path.getmtime
1431
+ )
1432
+ if not mkv_files:
1433
+ for p in output_paths:
1434
+ candidate = Path(p) if Path(p).suffix else Path(p).with_suffix(".mkv")
1435
+ if candidate.exists():
1436
+ mkv_files = [candidate]
1437
+ break
1438
+ if not mkv_files:
1439
+ mkv_files = sorted(CACHE_DIR.glob("*.mkv"), key=os.path.getmtime)
1440
+
1441
+ expected_dur = video_durations.get(vid_url) or 0
1442
+
1443
+ if proc.returncode != 0:
1444
+ if mkv_files and await _download_is_complete(mkv_files[0], exp_bytes, expected_dur):
1445
+ await msg_q.put(sse_log("{}yt-dlp exited with errors but output file verified complete \u2014 continuing.".format(dl_label)))
1446
+ else:
1447
+ # Batch: skip this bad video and keep going; single/playlist: abort.
1448
+ await msg_q.put(sse_item_failed(vid_idx, total_videos, "download failed"))
1449
+ size_note = ""
1450
+ if mkv_files and exp_bytes > 0:
1451
+ actual = mkv_files[0].stat().st_size
1452
+ size_note = " (got {:.0f}MB of expected {:.0f}MB)".format(actual / 1024**2, exp_bytes / 1024**2)
1453
+ if not batch:
1454
+ abort.set()
1455
+ await msg_q.put(sse_error("{}Download failed{}.".format(dl_label, size_note)))
1456
+ break
1457
+ await msg_q.put(sse_error("{}Download failed{} \u2014 skipping.".format(dl_label, size_note)))
1458
+ continue
1459
+
1460
+ if not mkv_files:
1461
+ await msg_q.put(sse_item_failed(vid_idx, total_videos, "no mkv"))
1462
+ if not batch:
1463
+ abort.set()
1464
+ await msg_q.put(sse_error("{}No .mkv found after download.".format(dl_label)))
1465
+ break
1466
+ await msg_q.put(sse_error("{}No .mkv found after download \u2014 skipping.".format(dl_label)))
1467
+ continue
1468
+
1469
+ await msg_q.put(sse_log("{}Queued for encoding.".format(dl_label)))
1470
+ if not await _put_file((vid_idx, mkv_files[0])):
1471
+ break # encoder stopped consuming (abort/cancel) — don't hang
1472
+
1473
+ if vid_idx < total_videos and not _stopping():
1474
+ sleep_s = random.uniform(3, 8)
1475
+ await msg_q.put(sse_log("Next download in {:.1f}s...".format(sleep_s)))
1476
+ await asyncio.sleep(sleep_s)
1477
+
1478
+ finally:
1479
+ # Always make sure the encoder receives its stop-sentinel (even on
1480
+ # cancel/abort it may be idle on file_q.get()), but without blocking
1481
+ # if it has already exited. Its own finally sends the msg_q sentinel
1482
+ # the main loop counts.
1483
+ await _send_sentinel()
1484
+ await msg_q.put(None)
1485
+
1486
+ async def enc_worker():
1487
+ global current_process
1488
+ consecutive_failures = 0
1489
+ try:
1490
+ while True:
1491
+ item = await file_q.get()
1492
+ if item is None:
1493
+ break
1494
+ if _cancel_requested.is_set():
1495
+ break
1496
+ vid_idx, input_file = item
1497
+ await msg_q.put(sse_video_start(vid_idx, total_videos))
1498
+ enc_label = "[ENC {}/{}] ".format(vid_idx, total_videos)
1499
+
1500
+ # Per-item output-dir + tune (batch mode) vs request-level.
1501
+ cfg = per_item[vid_idx - 1] if batch else None
1502
+ item_dest = Path(cfg["output_dir"]) if (cfg and cfg["output_dir"]) else dest_dir
1503
+ item_tune = cfg["tune_mode"] if cfg else tune_mode
1504
+ item_dest.mkdir(parents=True, exist_ok=True)
1505
+
1506
+ safe_stem = sanitize(input_file.stem)
1507
+ output_file = item_dest / "{}_h265.mp4".format(safe_stem)
1508
+ source_size = input_file.stat().st_size if input_file.exists() else 0
1509
+ min_output = max(1024 * 1024, int(source_size * 0.30))
1510
+
1511
+ if output_file.exists():
1512
+ out_size = output_file.stat().st_size
1513
+ if out_size < min_output:
1514
+ await msg_q.put(sse_log("{}Removing incomplete output ({:.0f} MB, expected >={:.0f} MB) \u2014 re-encoding.".format(enc_label, out_size / 1024**2, min_output / 1024**2)))
1515
+ output_file.unlink()
1516
+ else:
1517
+ await msg_q.put(sse_log("{}Skipping \u2014 output already exists: {} ({:.0f} MB)".format(enc_label, output_file.name, out_size / 1024**2)))
1518
+ if delete_cache == "true":
1519
+ input_file.unlink(missing_ok=True)
1520
+ if batch:
1521
+ await msg_q.put(sse_item_done(vid_idx, total_videos))
1522
+ continue
1523
+
1524
+ await msg_q.put(sse_phase("{}Converting: {}".format(enc_label, input_file.name)))
1525
+ await msg_q.put(sse_source_size(source_size))
1526
+
1527
+ src = await probe_video(input_file)
1528
+ duration_secs = src.get("duration") or None
1529
+ if duration_secs:
1530
+ await msg_q.put(sse_log("Duration: {:.1f}s".format(duration_secs)))
1531
+ else:
1532
+ await msg_q.put(sse_log("Could not read duration \u2014 progress bar will be indeterminate"))
1533
+
1534
+ effective_tune = "hq" if item_tune == "hq" else _nvenc_tune
1535
+ params = calc_encode_params(
1536
+ height=src.get("height", 1080),
1537
+ fps=src.get("fps", 30.0),
1538
+ video_bitrate_kbps=src.get("video_bitrate_kbps", 0),
1539
+ codec=src.get("codec", "h264"),
1540
+ pix_fmt=src.get("pix_fmt", "yuv420p"),
1541
+ color_transfer=src.get("color_transfer", ""),
1542
+ cq_override=int(cq) if cq.isdigit() else 0,
1543
+ maxrate_override=maxrate,
1544
+ nvenc_tune=effective_tune,
1545
+ )
1546
+ enc_log = "Encode params: CQ={} maxrate={} preset={} tune={} pix={}".format(
1547
+ params["cq"], params["maxrate"], params["preset"], effective_tune, params["pix_fmt"]
1548
+ )
1549
+ await msg_q.put(sse_log(enc_log))
1550
+
1551
+ enc_args = build_video_ffmpeg_args(
1552
+ input_file, output_file, params, effective_tune, src.get("codec", "")
1553
+ )
1554
+ enc_res: dict = {}
1555
+ async for _s in run_encode(enc_args, duration_secs=duration_secs,
1556
+ idx=vid_idx, total=total_videos, result=enc_res):
1557
+ await msg_q.put(_s)
1558
+
1559
+ if _cancel_requested.is_set():
1560
+ # User cancelled mid-encode — discard the partial output,
1561
+ # don't treat it as a failure or retry.
1562
+ if output_file.exists():
1563
+ output_file.unlink(missing_ok=True)
1564
+ break
1565
+
1566
+ if enc_res["returncode"] != 0:
1567
+ # Remove partial output so retries don't falsely skip.
1568
+ if output_file.exists():
1569
+ output_file.unlink(missing_ok=True)
1570
+ consecutive_failures += 1
1571
+ # H-6: a single encode failure must NOT abort the pipeline —
1572
+ # that would kill dl_worker's in-flight download. Surface the
1573
+ # error and move on to the next queued MKV. Only give up once
1574
+ # the encoder looks fundamentally broken (N in a row).
1575
+ await msg_q.put(sse_error("{}Conversion failed: {}".format(enc_label, input_file.name)))
1576
+ await msg_q.put(sse_item_failed(vid_idx, total_videos, "encode failed"))
1577
+ if consecutive_failures >= _MAX_CONSECUTIVE_ENCODE_FAILURES:
1578
+ abort.set()
1579
+ await msg_q.put(sse_error("Aborting pipeline: {} consecutive encode failures.".format(consecutive_failures)))
1580
+ break
1581
+ continue
1582
+
1583
+ consecutive_failures = 0
1584
+ source_bytes = input_file.stat().st_size if input_file.exists() else 0
1585
+ if delete_cache == "true":
1586
+ input_file.unlink(missing_ok=True)
1587
+ output_bytes = output_file.stat().st_size
1588
+ size_mb = output_bytes / 1024 ** 2
1589
+ cache_note = "cache cleared" if delete_cache == "true" else "cache kept"
1590
+ await msg_q.put(sse_log("Done: {} \u2014 {:.0f} MB ({})".format(output_file.name, size_mb, cache_note)))
1591
+ if source_bytes > 0:
1592
+ await msg_q.put(sse_size_info(source_bytes, output_bytes))
1593
+ await msg_q.put(sse_item_done(vid_idx, total_videos))
1594
+
1595
+ finally:
1596
+ # Unblock any pending _put_file so dl_worker can't hang on the
1597
+ # maxsize=1 queue now that we're gone (H-7). Distinct from `abort`
1598
+ # so a clean finish still reports success.
1599
+ enc_gone.set()
1600
+ # Drain queue without deleting — queued MKVs may still be valid.
1601
+ while True:
1602
+ try:
1603
+ file_q.get_nowait()
1604
+ except asyncio.QueueEmpty:
1605
+ break
1606
+ await msg_q.put(None)
1607
+
1608
+ dl_task = asyncio.create_task(dl_worker())
1609
+ enc_task = asyncio.create_task(enc_worker())
1610
+
1611
+ done_count = 0
1612
+ while done_count < 2:
1613
+ msg = await msg_q.get()
1614
+ if msg is None:
1615
+ done_count += 1
1616
+ continue
1617
+ yield msg
1618
+
1619
+ # return_exceptions: a worker dying on an unexpected error must surface as
1620
+ # a clean error event, not crash the SSE stream (both workers already sent
1621
+ # their msg_q sentinel via finally, so the loop above has exited).
1622
+ for _r in await asyncio.gather(dl_task, enc_task, return_exceptions=True):
1623
+ if isinstance(_r, Exception):
1624
+ abort.set()
1625
+ yield sse_error("Pipeline worker error: {}".format(_r))
1626
+
1627
+ if _cancel_requested.is_set():
1628
+ yield sse_cancelled('Cancelled by user.')
1629
+ elif not abort.is_set():
1630
+ yield sse_done('All done!')
1631
+ if shutdown == "true":
1632
+ # Shutdown is client-driven: emit the event, the UI runs the
1633
+ # visible countdown and calls POST /shutdown-now itself.
1634
+ yield sse_shutdown('Shutting down in 60 seconds...')
1635
+
1636
+ else:
1637
+ # ── Sequential: download → convert → delete, one at a time ─────────
1638
+ for vid_idx, vid_url in enumerate(video_urls, 1):
1639
+ if _cancel_requested.is_set():
1640
+ break # cancel stops the whole queue, not just the current proc
1641
+ label = f"[{vid_idx}/{total_videos}] " if total_videos > 1 else ""
1642
+ yield sse_video_start(vid_idx, total_videos)
1643
+
1644
+ # Skip download if output already exists and looks complete.
1645
+ # Video: H.265 is 40-100% of source — 30% floor. Audio: WAV/MP3 size
1646
+ # is unrelated to source bytes (WAV often >> source), so existence + 1MB suffices.
1647
+ expected_bytes = int(expected_size) if expected_size else 0
1648
+ min_output = 1024 * 1024 if is_audio else max(1024 * 1024, int(expected_bytes * 0.30))
1649
+ vid_title = video_titles.get(vid_url, "")
1650
+ if vid_title:
1651
+ predicted = _predicted_output(_predict_output_stem(vid_title))
1652
+ if predicted.exists() and predicted.stat().st_size >= min_output:
1653
+ yield sse_log(f'{label}Skipping \u2014 already encoded: {predicted.name}')
1654
+ continue
1655
+
1656
+ yield sse_phase("{}Downloading".format(label), vid_title)
1657
+
1658
+ dl_args = [
1659
+ str(get_ytdlp()),
1660
+ *_yt_format_args(),
1661
+ *NODE_ARGS,
1662
+ "--no-playlist",
1663
+ "--no-overwrites",
1664
+ *_yt_merge_args(),
1665
+ "--concurrent-fragments", "2",
1666
+ "--retries", "25",
1667
+ "--fragment-retries", "25",
1668
+ "--newline",
1669
+ "--restrict-filenames",
1670
+ "--paths", str(CACHE_DIR),
1671
+ "-o", "%(title)s.%(ext)s",
1672
+ ]
1673
+ dl_args += cookie_args()
1674
+ dl_args += ["--", vid_url]
1675
+
1676
+ proc = await asyncio.create_subprocess_exec(
1677
+ *dl_args,
1678
+ stdout=asyncio.subprocess.PIPE,
1679
+ stderr=asyncio.subprocess.STDOUT,
1680
+ )
1681
+ current_process = proc
1682
+ output_paths = set()
1683
+
1684
+ async for raw in proc.stdout:
1685
+ if _cancel_requested.is_set():
1686
+ proc.terminate()
1687
+ break
1688
+ line = raw.decode(errors="replace").rstrip()
1689
+ m = re.search(
1690
+ r"\[download\]\s+([\d.]+)%\s+of\s+([\S]+)\s+at\s+([\S]+)\s+ETA\s+([\S]+)",
1691
+ line
1692
+ )
1693
+ if m:
1694
+ pct, size, speed, eta = m.groups()
1695
+ yield _sse({"type": "progress", "pct": float(pct), "size": size, "speed": speed, "eta": eta})
1696
+ else:
1697
+ merger = re.search(r'\[Merger\] Merging formats into "(.+?)"', line)
1698
+ # Audio mode has no merger — capture the destination line instead.
1699
+ dest = re.search(r'\[download\] Destination: (.+)', line) if is_audio else None
1700
+ already = re.search(r'\[download\] (.+?) has already been downloaded', line)
1701
+ if merger:
1702
+ output_paths.add(merger.group(1))
1703
+ elif dest:
1704
+ output_paths.add(dest.group(1).strip())
1705
+ elif already:
1706
+ output_paths.add(already.group(1))
1707
+ yield sse_log(line)
1708
+
1709
+ await proc.wait()
1710
+ current_process = None
1711
+
1712
+ if _cancel_requested.is_set():
1713
+ break # user cancelled — not a download failure
1714
+
1715
+ # Resolve downloaded file(s) before checking return code — yt-dlp can
1716
+ # exit non-zero from transient fragment errors even when the file was
1717
+ # fully downloaded.
1718
+ allowed_suffixes = _AUDIO_EXTS if is_audio else {".mkv"}
1719
+ mkv_files = []
1720
+ if output_paths:
1721
+ mkv_files = sorted(
1722
+ [Path(p) for p in output_paths if Path(p).exists() and Path(p).suffix.lower() in allowed_suffixes],
1723
+ key=os.path.getmtime
1724
+ )
1725
+ if not mkv_files and not is_audio:
1726
+ for p in output_paths:
1727
+ candidate = Path(p) if Path(p).suffix else Path(p).with_suffix('.mkv')
1728
+ if candidate.exists():
1729
+ mkv_files = [candidate]
1730
+ break
1731
+ if not mkv_files:
1732
+ mkv_files = _glob_downloaded(CACHE_DIR)
1733
+
1734
+ expected_bytes = int(expected_size) if expected_size else 0
1735
+ expected_dur = video_durations.get(vid_url) or 0
1736
+
1737
+ if proc.returncode != 0:
1738
+ if mkv_files and await _download_is_complete(mkv_files[0], expected_bytes, expected_dur):
1739
+ yield sse_log(f'{label}yt-dlp exited with errors but output file verified complete — continuing.')
1740
+ else:
1741
+ size_note = ""
1742
+ if mkv_files and expected_bytes > 0:
1743
+ actual = mkv_files[0].stat().st_size
1744
+ size_note = " (got {:.0f}MB of expected {:.0f}MB)".format(actual / 1024**2, expected_bytes / 1024**2)
1745
+ yield sse_error("{}Download failed{}.".format(label, size_note))
1746
+ return
1747
+
1748
+ if not mkv_files:
1749
+ yield sse_error(f'{label}No .mkv found after download.')
1750
+ return
1751
+
1752
+ if convert == "true" or is_audio:
1753
+ input_file = mkv_files[0]
1754
+ source_size = input_file.stat().st_size
1755
+ safe_stem = sanitize(input_file.stem)
1756
+ output_file = _predicted_output(safe_stem)
1757
+
1758
+ # Video: H.265 should be at least 30% of source. Audio: WAV/MP3 size
1759
+ # is unrelated to source bytes, so just require existence + 1 MB.
1760
+ min_output = 1024 * 1024 if is_audio else max(1024 * 1024, int(source_size * 0.30))
1761
+
1762
+ if output_file.exists():
1763
+ out_size = output_file.stat().st_size
1764
+ if out_size < min_output:
1765
+ msg = "{}Removing incomplete output ({:.0f} MB, expected >={:.0f} MB) — re-encoding.".format(
1766
+ label, out_size / 1024**2, min_output / 1024**2)
1767
+ yield sse_log(msg)
1768
+ output_file.unlink()
1769
+ else:
1770
+ msg = "{}Skipping \u2014 output already exists: {} ({:.0f} MB)".format(label, output_file.name, out_size / 1024**2)
1771
+ yield sse_log(msg)
1772
+ if delete_cache == "true":
1773
+ input_file.unlink(missing_ok=True)
1774
+ continue
1775
+
1776
+ phase_verb = "Extracting audio" if is_audio else "Converting"
1777
+ yield sse_phase(f'{label}{phase_verb}: {input_file.name}')
1778
+ yield sse_source_size(source_size)
1779
+
1780
+ src = await probe_video(input_file)
1781
+ duration_secs = src.get("duration") or None
1782
+ if duration_secs:
1783
+ yield sse_log(f'Duration: {duration_secs:.1f}s')
1784
+ else:
1785
+ yield sse_log('Could not read duration — progress bar will be indeterminate')
1786
+
1787
+ if is_audio:
1788
+ ffmpeg_args = build_audio_ffmpeg(input_file, output_file, audio_preset)
1789
+ enc_log = "Audio extract: {} → {}".format(audio_preset.upper(), output_file.name)
1790
+ yield sse_log(enc_log)
1791
+ else:
1792
+ effective_tune = "hq" if tune_mode == "hq" else _nvenc_tune
1793
+ params = calc_encode_params(
1794
+ height=src.get("height", 1080),
1795
+ fps=src.get("fps", 30.0),
1796
+ video_bitrate_kbps=src.get("video_bitrate_kbps", 0),
1797
+ codec=src.get("codec", "h264"),
1798
+ pix_fmt=src.get("pix_fmt", "yuv420p"),
1799
+ color_transfer=src.get("color_transfer", ""),
1800
+ cq_override=int(cq) if cq.isdigit() else 0,
1801
+ maxrate_override=maxrate,
1802
+ nvenc_tune=effective_tune,
1803
+ )
1804
+ enc_log = "Encode params: CQ={} maxrate={} preset={} tune={} pix={}".format(
1805
+ params["cq"], params["maxrate"], params["preset"], effective_tune, params["pix_fmt"]
1806
+ )
1807
+ yield sse_log(enc_log)
1808
+
1809
+ ffmpeg_args = build_video_ffmpeg_args(
1810
+ input_file, output_file, params, effective_tune, src.get("codec", "")
1811
+ )
1812
+
1813
+ enc_res: dict = {}
1814
+ async for _s in run_encode(ffmpeg_args, duration_secs=duration_secs,
1815
+ idx=vid_idx, total=total_videos, result=enc_res):
1816
+ yield _s
1817
+
1818
+ if _cancel_requested.is_set():
1819
+ if output_file.exists():
1820
+ output_file.unlink(missing_ok=True)
1821
+ break # user cancelled mid-encode — not a failure
1822
+ if enc_res["returncode"] != 0:
1823
+ # Remove partial output so retries don't falsely skip
1824
+ if output_file.exists():
1825
+ output_file.unlink(missing_ok=True)
1826
+ fail_verb = "Audio extract" if is_audio else "Conversion"
1827
+ yield sse_error(f'{label}{fail_verb} failed: {input_file.name}')
1828
+ return
1829
+
1830
+ source_bytes = input_file.stat().st_size
1831
+ if delete_cache == "true":
1832
+ input_file.unlink(missing_ok=True)
1833
+ output_bytes = output_file.stat().st_size
1834
+ size_mb = output_bytes / 1024 ** 2
1835
+ cache_note = "cache cleared" if delete_cache == "true" else "cache kept"
1836
+ yield sse_log("Done: {} \u2014 {:.0f} MB ({})".format(output_file.name, size_mb, cache_note))
1837
+ if source_bytes > 0:
1838
+ yield sse_size_info(source_bytes, output_bytes)
1839
+
1840
+ else:
1841
+ for f in mkv_files:
1842
+ yield sse_log(f'Saved (no conversion): {f}')
1843
+
1844
+ if _cancel_requested.is_set():
1845
+ yield sse_cancelled('Cancelled by user.')
1846
+ else:
1847
+ yield sse_done('All done!')
1848
+ if shutdown == "true":
1849
+ # Client-driven: UI runs the visible countdown and calls /shutdown-now.
1850
+ yield sse_shutdown('Shutting down in 60 seconds...')
1851
+
1852
+ return StreamingResponse(_tracked(stream()), media_type="text/event-stream")
1853
+
1854
+
1855
+ @app.post("/cancel")
1856
+ async def cancel():
1857
+ global current_process, current_dl_process
1858
+ # Sticky flag first: the running job loops poll this to stop launching the
1859
+ # next item / retry, even for processes not captured in the globals yet.
1860
+ _cancel_requested.set()
1861
+ cancelled = False
1862
+ for proc in [current_process, current_dl_process]:
1863
+ if proc:
1864
+ try:
1865
+ proc.terminate()
1866
+ except Exception:
1867
+ pass
1868
+ cancelled = True
1869
+ current_process = None
1870
+ current_dl_process = None
1871
+ return {"status": "cancelled" if cancelled else "nothing running"}
1872
+
1873
+
1874
+ @app.post("/probe-file")
1875
+ async def probe_file(path: str = Form(...)):
1876
+ p = Path(path)
1877
+ if not p.exists():
1878
+ return JSONResponse({"error": "File not found"}, status_code=400)
1879
+ proc = await asyncio.create_subprocess_exec(
1880
+ get_ffprobe(), "-v", "quiet",
1881
+ "-print_format", "json",
1882
+ "-show_streams", "-select_streams", "v:0",
1883
+ str(p),
1884
+ stdout=asyncio.subprocess.PIPE,
1885
+ stderr=asyncio.subprocess.PIPE,
1886
+ )
1887
+ out, _ = await proc.communicate()
1888
+ try:
1889
+ stream = json.loads(out)["streams"][0]
1890
+ height = stream.get("height", 0)
1891
+ num, den = stream.get("r_frame_rate", "30/1").split("/")
1892
+ fps = int(num) / max(int(den), 1)
1893
+ return {"height": height, "fps": round(fps, 3)}
1894
+ except Exception:
1895
+ return JSONResponse({"error": "Could not probe file"}, status_code=400)
1896
+
1897
+
1898
+ @app.post("/scan-folder")
1899
+ async def scan_folder(folder: str = Form(...)):
1900
+ p = Path(folder)
1901
+ if not p.exists() or not p.is_dir():
1902
+ return JSONResponse({"error": f"Folder not found: {folder}"}, status_code=400)
1903
+ extensions = {".mkv", ".mp4", ".webm", ".avi", ".mov", ".m4v"}
1904
+ files = [
1905
+ {"name": f.name, "path": str(f), "size_gb": round(f.stat().st_size / 1024**3, 2)}
1906
+ for f in sorted(p.iterdir())
1907
+ if f.suffix.lower() in extensions
1908
+ ]
1909
+ return {"files": files, "folder": str(p)}
1910
+
1911
+
1912
+ @app.post("/convert-local")
1913
+ async def convert_local(
1914
+ files: str = Form(...), # JSON array of absolute file paths
1915
+ cq: str = Form("0"),
1916
+ maxrate: str = Form(""),
1917
+ shutdown: str = Form("false"),
1918
+ output_dir: str = Form(""),
1919
+ tune_mode: str = Form("uhq"),
1920
+ mode: str = Form("video"), # "video" → H.265 MP4; "audio" → WAV/MP3 extract
1921
+ audio_preset: str = Form("mp3"), # "wav" or "mp3"; only used when mode == "audio"
1922
+ ):
1923
+ async def stream():
1924
+ global current_process
1925
+ _cancel_requested.clear() # fresh job — drop any stale cancel
1926
+ _keep_awake()
1927
+ _job_start = datetime.datetime.now()
1928
+ _job_lines = []
1929
+
1930
+ def _jlog(level, msg):
1931
+ ts = datetime.datetime.now().strftime("%H:%M:%S")
1932
+ _job_lines.append("[{}] [{}] {}".format(ts, level, msg))
1933
+
1934
+ try:
1935
+ async for chunk in _convert_local_stream():
1936
+ if chunk.startswith("data: "):
1937
+ try:
1938
+ evt = json.loads(chunk[6:].strip())
1939
+ t = evt.get("type", "")
1940
+ if t == "phase":
1941
+ _jlog("PHASE", evt.get("msg", ""))
1942
+ elif t == "log":
1943
+ _jlog("LOG", evt.get("msg", ""))
1944
+ elif t == "error":
1945
+ _jlog("ERROR", evt.get("msg", ""))
1946
+ elif t == "done":
1947
+ _jlog("DONE", evt.get("msg", ""))
1948
+ elif t == "video_start":
1949
+ _jlog("VIDEO", "Video {current}/{total}".format(**evt))
1950
+ elif t == "size_info":
1951
+ _jlog("SIZE", "source={source_mb}MB output={output_mb}MB bloat={bloat_pct:+.1f}%".format(**evt))
1952
+ except Exception:
1953
+ pass
1954
+ yield chunk
1955
+ finally:
1956
+ elapsed = datetime.datetime.now() - _job_start
1957
+ status = "CANCELLED"
1958
+ for ln in reversed(_job_lines):
1959
+ if "[ERROR]" in ln:
1960
+ status = "FAILED"
1961
+ break
1962
+ if "[DONE]" in ln:
1963
+ status = "SUCCESS"
1964
+ break
1965
+ ts_file = _job_start.strftime("%Y-%m-%d_%H-%M-%S")
1966
+ log_path = LOGS_DIR / "{}_local.txt".format(ts_file)
1967
+ try:
1968
+ file_list = json.loads(files)
1969
+ except Exception:
1970
+ file_list = [files]
1971
+ header_lines = [
1972
+ "=== FetchForge Job Log ===",
1973
+ "Started : {}".format(_job_start.strftime("%Y-%m-%d %H:%M:%S")),
1974
+ "Type : Local Convert",
1975
+ "Files : {}".format(", ".join(str(f) for f in file_list)),
1976
+ "Elapsed : {}".format(str(elapsed).split(".")[0]),
1977
+ "Status : {}".format(status),
1978
+ "",
1979
+ "--- Events ---",
1980
+ "",
1981
+ ]
1982
+ try:
1983
+ await asyncio.to_thread(
1984
+ log_path.write_text,
1985
+ "\n".join(header_lines + _job_lines + [""]),
1986
+ encoding="utf-8",
1987
+ )
1988
+ except Exception as exc:
1989
+ logger.warning("Log write failed: %s", exc)
1990
+ _allow_sleep()
1991
+
1992
+ async def _convert_local_stream():
1993
+ global current_process
1994
+
1995
+ is_audio = mode == "audio"
1996
+ out_ext = audio_output_ext(audio_preset) if is_audio else "mp4"
1997
+ out_suffix = "" if is_audio else "_h265"
1998
+
1999
+ file_paths = [Path(f) for f in json.loads(files)]
2000
+ file_paths = [f for f in file_paths if f.exists()]
2001
+ total = len(file_paths)
2002
+
2003
+ if not file_paths:
2004
+ yield sse_error('No valid files found.')
2005
+ return
2006
+
2007
+ custom_dest = Path(output_dir) if output_dir else None
2008
+ if custom_dest:
2009
+ custom_dest.mkdir(parents=True, exist_ok=True)
2010
+
2011
+ action_label = "audio extraction" if is_audio else "conversion"
2012
+ yield sse_log(f'{total} file(s) queued for {action_label}.')
2013
+
2014
+ for idx, input_file in enumerate(file_paths, 1):
2015
+ if _cancel_requested.is_set():
2016
+ break # cancel stops the whole queue
2017
+ yield sse_video_start(idx, total)
2018
+ dest_dir = custom_dest or (input_file.parent / "converted")
2019
+ dest_dir.mkdir(parents=True, exist_ok=True)
2020
+ safe_stem = sanitize(input_file.stem)
2021
+ output_file = dest_dir / "{}{}.{}".format(safe_stem, out_suffix, out_ext)
2022
+
2023
+ if output_file.exists():
2024
+ size_mb = output_file.stat().st_size / 1024 ** 2
2025
+ if size_mb < 1:
2026
+ yield sse_log(f'Removing incomplete output ({size_mb * 1024:.0f} KB) — re-encoding.')
2027
+ output_file.unlink()
2028
+ else:
2029
+ yield sse_log(f'Skipping — output already exists: {output_file.name} ({size_mb:.0f} MB)')
2030
+ continue
2031
+
2032
+ phase_verb = "Extracting audio" if is_audio else "Converting"
2033
+ yield sse_phase(f'{phase_verb} {idx}/{total}: {input_file.name}')
2034
+ _src_bytes = input_file.stat().st_size if input_file.exists() else 0
2035
+ yield sse_source_size(_src_bytes)
2036
+
2037
+ # Deep-probe source for smart encode parameters
2038
+ src = await probe_video(input_file)
2039
+ duration_secs = src.get("duration") or None
2040
+ if duration_secs:
2041
+ yield sse_log(f'Duration: {duration_secs:.1f}s')
2042
+
2043
+ if is_audio:
2044
+ ffmpeg_args = build_audio_ffmpeg(input_file, output_file, audio_preset)
2045
+ enc_log = "Audio extract: {} → {}".format(audio_preset.upper(), output_file.name)
2046
+ yield sse_log(enc_log)
2047
+ else:
2048
+ effective_tune = "hq" if tune_mode == "hq" else _nvenc_tune
2049
+ params = calc_encode_params(
2050
+ height=src.get("height", 1080),
2051
+ fps=src.get("fps", 30.0),
2052
+ video_bitrate_kbps=src.get("video_bitrate_kbps", 0),
2053
+ codec=src.get("codec", "h264"),
2054
+ pix_fmt=src.get("pix_fmt", "yuv420p"),
2055
+ color_transfer=src.get("color_transfer", ""),
2056
+ cq_override=int(cq) if cq.isdigit() else 0,
2057
+ maxrate_override=maxrate,
2058
+ nvenc_tune=effective_tune,
2059
+ )
2060
+ enc_log = "Encode params: CQ={} maxrate={} preset={} tune={} pix={}".format(
2061
+ params["cq"], params["maxrate"], params["preset"], effective_tune, params["pix_fmt"]
2062
+ )
2063
+ yield sse_log(enc_log)
2064
+
2065
+ ffmpeg_args = build_video_ffmpeg_args(
2066
+ input_file, output_file, params, effective_tune, src.get("codec", "")
2067
+ )
2068
+
2069
+ enc_res: dict = {}
2070
+ async for _s in run_encode(ffmpeg_args, duration_secs=duration_secs,
2071
+ idx=idx, total=total, result=enc_res):
2072
+ yield _s
2073
+
2074
+ if _cancel_requested.is_set():
2075
+ if output_file.exists():
2076
+ output_file.unlink(missing_ok=True)
2077
+ break # user cancelled mid-encode — not a failure
2078
+ if enc_res["returncode"] != 0:
2079
+ # Remove partial output so retries don't falsely skip
2080
+ if output_file.exists():
2081
+ output_file.unlink(missing_ok=True)
2082
+ fail_verb = "Audio extract" if is_audio else "Conversion"
2083
+ yield sse_error(f'{fail_verb} failed: {input_file.name}')
2084
+ return
2085
+
2086
+ source_bytes = input_file.stat().st_size
2087
+ output_bytes = output_file.stat().st_size
2088
+ yield sse_log(f'Done: {output_file.name}')
2089
+ if source_bytes > 0:
2090
+ yield sse_size_info(source_bytes, output_bytes)
2091
+
2092
+ if _cancel_requested.is_set():
2093
+ yield sse_cancelled('Cancelled by user.')
2094
+ else:
2095
+ yield sse_done('All done!')
2096
+ if shutdown == "true":
2097
+ # Client-driven: UI runs the visible countdown and calls /shutdown-now.
2098
+ yield sse_shutdown('Shutting down in 60 seconds...')
2099
+
2100
+ return StreamingResponse(_tracked(stream()), media_type="text/event-stream")
2101
+
2102
+
2103
+ def _open_folder_dialog(title: str = "Select Output Folder") -> str:
2104
+ import tkinter as tk
2105
+ from tkinter import filedialog
2106
+ root = tk.Tk()
2107
+ root.withdraw()
2108
+ root.attributes("-topmost", True)
2109
+ path = filedialog.askdirectory(title=title)
2110
+ root.destroy()
2111
+ return path or ""
2112
+
2113
+
2114
+ def _open_file_dialog() -> list:
2115
+ import tkinter as tk
2116
+ from tkinter import filedialog
2117
+ root = tk.Tk()
2118
+ root.withdraw()
2119
+ root.attributes("-topmost", True)
2120
+ extensions = [".mkv", ".mp4", ".webm", ".avi", ".mov", ".m4v"]
2121
+ filetypes = [("Video files", " ".join("*" + e for e in extensions)), ("All files", "*.*")]
2122
+ paths = filedialog.askopenfilenames(title="Select Video Files", filetypes=filetypes)
2123
+ root.destroy()
2124
+ return list(paths) if paths else []
2125
+
2126
+
2127
+ @app.get("/version")
2128
+ async def get_version():
2129
+ return {"version": APP_VERSION}
2130
+
2131
+
2132
+ @app.get("/heartbeat")
2133
+ async def heartbeat():
2134
+ """Keep-alive ping from the open browser tab. When pings stop (tab closed),
2135
+ the watchdog self-exits the server after _HEARTBEAT_TIMEOUT seconds."""
2136
+ global _last_heartbeat
2137
+ _last_heartbeat = time.monotonic()
2138
+ return {"ok": True, "timeout": _HEARTBEAT_TIMEOUT}
2139
+
2140
+
2141
+ @app.get("/browse-folder")
2142
+ async def browse_folder():
2143
+ loop = asyncio.get_event_loop()
2144
+ path = await loop.run_in_executor(None, _open_folder_dialog)
2145
+ return {"path": path}
2146
+
2147
+
2148
+ @app.get("/browse-files")
2149
+ async def browse_files():
2150
+ loop = asyncio.get_event_loop()
2151
+ paths = await loop.run_in_executor(None, _open_file_dialog)
2152
+ files = []
2153
+ for p in paths:
2154
+ fp = Path(p)
2155
+ if fp.exists():
2156
+ files.append({"name": fp.name, "path": str(fp), "size_gb": round(fp.stat().st_size / 1024**3, 2)})
2157
+ return {"files": files}
2158
+
2159
+
2160
+ @app.get("/browse-source-folder")
2161
+ async def browse_source_folder():
2162
+ loop = asyncio.get_event_loop()
2163
+ path = await loop.run_in_executor(None, lambda: _open_folder_dialog("Select Folder to Scan"))
2164
+ return {"path": path}
2165
+
2166
+
2167
+ @app.post("/shutdown-now")
2168
+ async def shutdown_now(request: Request):
2169
+ """Immediate system shutdown — called by the UI after its 60s countdown.
2170
+ Requires the per-session token (in addition to the global origin guard) so
2171
+ that even a same-origin mishap can't power off the machine accidentally."""
2172
+ if request.headers.get("x-dlpr-token") != _SESSION_TOKEN:
2173
+ return JSONResponse({"error": "forbidden"}, status_code=403)
2174
+ _poweroff()
2175
+ return {"ok": True}
2176
+
2177
+
2178
+ def run_server(open_browser: bool = True) -> None:
2179
+ global _uvicorn_server
2180
+ logger.info("Starting FetchForge at http://localhost:8765")
2181
+ if open_browser:
2182
+ import threading, webbrowser
2183
+ threading.Timer(1.5, lambda: webbrowser.open("http://localhost:8765")).start()
2184
+ config = uvicorn.Config(app, host="127.0.0.1", port=8765)
2185
+ _uvicorn_server = uvicorn.Server(config)
2186
+ try:
2187
+ _uvicorn_server.run()
2188
+ except Exception:
2189
+ # On a headless (no-console) launch this is the only place the failure is
2190
+ # visible — make sure it lands in logs/server.log before we die.
2191
+ logger.exception("Server exited with an unhandled exception")
2192
+ raise
2193
+
2194
+
2195
+ if __name__ == "__main__":
2196
+ import sys
2197
+ from fetchforge.cli import main
2198
+ sys.exit(main())