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/__init__.py +3 -0
- fetchforge/__main__.py +4 -0
- fetchforge/cli.py +27 -0
- fetchforge/index.html +2978 -0
- fetchforge/provision.py +116 -0
- fetchforge/server.py +2198 -0
- fetchforge-2.0.0.dist-info/METADATA +131 -0
- fetchforge-2.0.0.dist-info/RECORD +12 -0
- fetchforge-2.0.0.dist-info/WHEEL +5 -0
- fetchforge-2.0.0.dist-info/entry_points.txt +2 -0
- fetchforge-2.0.0.dist-info/licenses/LICENSE +21 -0
- fetchforge-2.0.0.dist-info/top_level.txt +1 -0
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())
|