ytp-dl 0.6.7__tar.gz → 0.6.9__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ytp-dl
3
- Version: 0.6.7
3
+ Version: 0.6.9
4
4
  Summary: YouTube video downloader with Mullvad VPN integration and Flask API
5
5
  Home-page: https://github.com/yourusername/ytp-dl
6
6
  Author: dumgum82
@@ -57,7 +57,7 @@ A lightweight YouTube downloader with Mullvad VPN integration and an HTTP API.
57
57
  ## Installation
58
58
 
59
59
  ```bash
60
- pip install ytp-dl==0.6.7 yt-dlp[default]
60
+ pip install ytp-dl==0.6.9 yt-dlp[default]
61
61
  ```
62
62
 
63
63
  Requirements:
@@ -242,7 +242,7 @@ When Mullvad connects/disconnects, Linux routing can change in a way that breaks
242
242
 
243
243
  * Installs Python, FFmpeg, Mullvad CLI, and Deno
244
244
  * Creates a virtualenv at `/opt/yt-dlp-mullvad/venv`
245
- * Installs `ytp-dl==0.6.7` + `yt-dlp[default]` + `gunicorn`
245
+ * Installs `ytp-dl==0.6.9` + `yt-dlp[default]` + `gunicorn`
246
246
  * Installs a policy-routing oneshot service to keep the public API reachable
247
247
  * Sets up a systemd service on port 5000
248
248
  * Runs Gunicorn with `gthread` (threaded) workers
@@ -258,7 +258,7 @@ Note: `gthread` is a built-in Gunicorn worker class (no extra Python dependency)
258
258
  # - Installs Deno system-wide (JS runtime required for modern YouTube extraction via yt-dlp)
259
259
  # - Configures policy routing so the public API stays reachable while Mullvad toggles
260
260
  # - Creates a virtualenv at /opt/yt-dlp-mullvad/venv
261
- # - Installs ytp-dl==0.6.7 + yt-dlp[default] + gunicorn in that venv
261
+ # - Installs ytp-dl==0.6.9 + yt-dlp[default] + gunicorn in that venv
262
262
  # - Creates a systemd service ytp-dl-api.service on port 5000
263
263
  #
264
264
  # Mullvad connect/disconnect is handled per-job by downloader.py.
@@ -394,7 +394,7 @@ mkdir -p "${APP_DIR}"
394
394
  python3 -m venv "${VENV_DIR}"
395
395
  source "${VENV_DIR}/bin/activate"
396
396
  pip install --upgrade pip
397
- pip install "ytp-dl==0.6.7" "yt-dlp[default]" gunicorn
397
+ pip install "ytp-dl==0.6.9" "yt-dlp[default]" gunicorn
398
398
  deactivate
399
399
 
400
400
  echo "==> 3) API environment file (/etc/default/ytp-dl-api)"
@@ -24,7 +24,7 @@ A lightweight YouTube downloader with Mullvad VPN integration and an HTTP API.
24
24
  ## Installation
25
25
 
26
26
  ```bash
27
- pip install ytp-dl==0.6.7 yt-dlp[default]
27
+ pip install ytp-dl==0.6.9 yt-dlp[default]
28
28
  ```
29
29
 
30
30
  Requirements:
@@ -209,7 +209,7 @@ When Mullvad connects/disconnects, Linux routing can change in a way that breaks
209
209
 
210
210
  * Installs Python, FFmpeg, Mullvad CLI, and Deno
211
211
  * Creates a virtualenv at `/opt/yt-dlp-mullvad/venv`
212
- * Installs `ytp-dl==0.6.7` + `yt-dlp[default]` + `gunicorn`
212
+ * Installs `ytp-dl==0.6.9` + `yt-dlp[default]` + `gunicorn`
213
213
  * Installs a policy-routing oneshot service to keep the public API reachable
214
214
  * Sets up a systemd service on port 5000
215
215
  * Runs Gunicorn with `gthread` (threaded) workers
@@ -225,7 +225,7 @@ Note: `gthread` is a built-in Gunicorn worker class (no extra Python dependency)
225
225
  # - Installs Deno system-wide (JS runtime required for modern YouTube extraction via yt-dlp)
226
226
  # - Configures policy routing so the public API stays reachable while Mullvad toggles
227
227
  # - Creates a virtualenv at /opt/yt-dlp-mullvad/venv
228
- # - Installs ytp-dl==0.6.7 + yt-dlp[default] + gunicorn in that venv
228
+ # - Installs ytp-dl==0.6.9 + yt-dlp[default] + gunicorn in that venv
229
229
  # - Creates a systemd service ytp-dl-api.service on port 5000
230
230
  #
231
231
  # Mullvad connect/disconnect is handled per-job by downloader.py.
@@ -361,7 +361,7 @@ mkdir -p "${APP_DIR}"
361
361
  python3 -m venv "${VENV_DIR}"
362
362
  source "${VENV_DIR}/bin/activate"
363
363
  pip install --upgrade pip
364
- pip install "ytp-dl==0.6.7" "yt-dlp[default]" gunicorn
364
+ pip install "ytp-dl==0.6.9" "yt-dlp[default]" gunicorn
365
365
  deactivate
366
366
 
367
367
  echo "==> 3) API environment file (/etc/default/ytp-dl-api)"
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ import time
8
+ from threading import BoundedSemaphore, Lock
9
+
10
+ from flask import Flask, request, send_file, jsonify
11
+
12
+ from .downloader import validate_environment, download_video
13
+
14
+ app = Flask(__name__)
15
+
16
+ BASE_DOWNLOAD_DIR = os.environ.get("YTPDL_JOB_BASE_DIR", "/root/ytpdl_jobs")
17
+ os.makedirs(BASE_DOWNLOAD_DIR, exist_ok=True)
18
+
19
+ MAX_CONCURRENT = int(os.environ.get("YTPDL_MAX_CONCURRENT", "1"))
20
+
21
+ # Thread-safe concurrency gate (caps actual download jobs).
22
+ _sem = BoundedSemaphore(MAX_CONCURRENT)
23
+
24
+ # Track in-flight jobs for /healthz reporting.
25
+ _in_use = 0
26
+ _in_use_lock = Lock()
27
+
28
+ # Failsafe: delete abandoned job dirs older than this many seconds.
29
+ STALE_JOB_TTL_S = int(os.environ.get("YTPDL_STALE_JOB_TTL_S", "3600"))
30
+
31
+ _ALLOWED_EXTENSIONS = {"mp3", "mp4", "best"}
32
+
33
+
34
+ def _cleanup_stale_jobs() -> None:
35
+ now = time.time()
36
+ try:
37
+ for name in os.listdir(BASE_DOWNLOAD_DIR):
38
+ p = os.path.join(BASE_DOWNLOAD_DIR, name)
39
+ if not os.path.isdir(p):
40
+ continue
41
+ try:
42
+ age = now - os.path.getmtime(p)
43
+ except Exception:
44
+ continue
45
+ if age > STALE_JOB_TTL_S:
46
+ shutil.rmtree(p, ignore_errors=True)
47
+ except Exception:
48
+ pass
49
+
50
+
51
+ def _try_acquire_job_slot() -> bool:
52
+ global _in_use
53
+ if not _sem.acquire(blocking=False):
54
+ return False
55
+ with _in_use_lock:
56
+ _in_use += 1
57
+ return True
58
+
59
+
60
+ def _release_job_slot() -> None:
61
+ global _in_use
62
+ with _in_use_lock:
63
+ if _in_use > 0:
64
+ _in_use -= 1
65
+ _sem.release()
66
+
67
+
68
+ @app.route("/api/download", methods=["POST"])
69
+ def handle_download():
70
+ _cleanup_stale_jobs()
71
+
72
+ if not _try_acquire_job_slot():
73
+ return jsonify(error="Server busy, try again later"), 503
74
+
75
+ job_dir: str | None = None
76
+ released = False
77
+
78
+ def _release_once() -> None:
79
+ nonlocal released
80
+ if not released:
81
+ released = True
82
+ _release_job_slot()
83
+
84
+ try:
85
+ data = request.get_json(force=True)
86
+ url = (data.get("url") or "").strip()
87
+ resolution = data.get("resolution")
88
+
89
+ # extension is now a "mode": mp3 | mp4 | best
90
+ extension = (data.get("extension") or "mp4").strip().lower()
91
+
92
+ if not url:
93
+ _release_once()
94
+ return jsonify(error="Missing 'url'"), 400
95
+
96
+ if extension not in _ALLOWED_EXTENSIONS:
97
+ _release_once()
98
+ return jsonify(
99
+ error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"
100
+ ), 400
101
+
102
+ job_dir = tempfile.mkdtemp(prefix="ytpdl_", dir=BASE_DOWNLOAD_DIR)
103
+
104
+ # yt-dlp work (guarded by semaphore)
105
+ filename = download_video(
106
+ url=url,
107
+ resolution=resolution,
108
+ extension=extension,
109
+ out_dir=job_dir,
110
+ )
111
+
112
+ if not (filename and os.path.exists(filename)):
113
+ raise RuntimeError("Download failed")
114
+
115
+ # Release slot as soon as yt-dlp is done.
116
+ _release_once()
117
+
118
+ response = send_file(filename, as_attachment=True)
119
+
120
+ # Cleanup directory after client finishes consuming the response.
121
+ def _cleanup() -> None:
122
+ try:
123
+ if job_dir:
124
+ shutil.rmtree(job_dir, ignore_errors=True)
125
+ except Exception:
126
+ pass
127
+
128
+ response.call_on_close(_cleanup)
129
+ return response
130
+
131
+ except RuntimeError as e:
132
+ if job_dir:
133
+ shutil.rmtree(job_dir, ignore_errors=True)
134
+ _release_once()
135
+
136
+ msg = str(e)
137
+ if "Mullvad not logged in" in msg:
138
+ return jsonify(error=msg), 503
139
+ return jsonify(error=f"Download failed: {msg}"), 500
140
+
141
+ except Exception as e:
142
+ if job_dir:
143
+ shutil.rmtree(job_dir, ignore_errors=True)
144
+ _release_once()
145
+ return jsonify(error=f"Download failed: {str(e)}"), 500
146
+
147
+
148
+ @app.route("/healthz", methods=["GET"])
149
+ def healthz():
150
+ with _in_use_lock:
151
+ in_use = _in_use
152
+ return jsonify(ok=True, in_use=in_use, capacity=MAX_CONCURRENT), 200
153
+
154
+
155
+ def main():
156
+ validate_environment()
157
+ print("Starting ytp-dl API server...")
158
+ app.run(host="0.0.0.0", port=5000)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shlex
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+ from typing import Optional, List, Tuple
10
+
11
+ # =========================
12
+ # Config / constants
13
+ # =========================
14
+ VENV_PATH = os.environ.get("YTPDL_VENV", "/opt/yt-dlp-mullvad/venv")
15
+ YTDLP_BIN = os.path.join(VENV_PATH, "bin", "yt-dlp")
16
+ MULLVAD_LOCATION = os.environ.get("YTPDL_MULLVAD_LOCATION", "us")
17
+
18
+ MODERN_UA = os.environ.get(
19
+ "YTPDL_USER_AGENT",
20
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
21
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
22
+ "Chrome/124.0.0.0 Safari/537.36",
23
+ )
24
+
25
+ FFMPEG_BIN = shutil.which("ffmpeg") or "ffmpeg"
26
+ DEFAULT_OUT_DIR = os.environ.get("YTPDL_DOWNLOAD_DIR", "/root")
27
+
28
+ # Keep error payloads readable (your web UI prints these).
29
+ _MAX_ERR_LINES = 80
30
+ _MAX_ERR_CHARS = 4000
31
+
32
+
33
+ # =========================
34
+ # Shell helpers
35
+ # =========================
36
+ def _run_argv_capture(argv: List[str]) -> Tuple[int, str]:
37
+ res = subprocess.run(
38
+ argv,
39
+ stdout=subprocess.PIPE,
40
+ stderr=subprocess.STDOUT,
41
+ text=True,
42
+ )
43
+ return res.returncode, (res.stdout or "")
44
+
45
+
46
+ def _run_argv(argv: List[str], check: bool = True) -> str:
47
+ rc, out = _run_argv_capture(argv)
48
+ if check and rc != 0:
49
+ cmd = " ".join(shlex.quote(p) for p in argv)
50
+ raise RuntimeError(f"Command failed: {cmd}\n{out}")
51
+ return out
52
+
53
+
54
+ def _tail(out: str) -> str:
55
+ lines = (out or "").splitlines()
56
+ tail_lines = lines[-_MAX_ERR_LINES:]
57
+ txt = "\n".join(tail_lines)
58
+ if len(txt) > _MAX_ERR_CHARS:
59
+ txt = txt[-_MAX_ERR_CHARS:]
60
+ return txt.strip()
61
+
62
+
63
+ def _is_youtube_url(url: str) -> bool:
64
+ u = (url or "").lower()
65
+ return any(h in u for h in ("youtube.com", "youtu.be", "youtube-nocookie.com"))
66
+
67
+
68
+ # =========================
69
+ # Environment / Mullvad
70
+ # =========================
71
+ def validate_environment() -> None:
72
+ if not os.path.exists(YTDLP_BIN):
73
+ raise RuntimeError(f"yt-dlp not found at {YTDLP_BIN}")
74
+ if shutil.which(FFMPEG_BIN) is None:
75
+ raise RuntimeError("ffmpeg not found on PATH")
76
+
77
+
78
+ def _mullvad_present() -> bool:
79
+ return shutil.which("mullvad") is not None
80
+
81
+
82
+ def mullvad_logged_in() -> bool:
83
+ if not _mullvad_present():
84
+ return False
85
+ res = subprocess.run(
86
+ ["mullvad", "account", "get"],
87
+ stdout=subprocess.PIPE,
88
+ stderr=subprocess.STDOUT,
89
+ text=True,
90
+ )
91
+ return "not logged in" not in (res.stdout or "").lower()
92
+
93
+
94
+ def require_mullvad_login() -> None:
95
+ if _mullvad_present() and not mullvad_logged_in():
96
+ raise RuntimeError("Mullvad not logged in. Run: mullvad account login <ACCOUNT>")
97
+
98
+
99
+ def mullvad_connect(location: Optional[str] = None) -> None:
100
+ if not _mullvad_present():
101
+ return
102
+ loc = (location or MULLVAD_LOCATION).strip()
103
+ _run_argv(["mullvad", "disconnect"], check=False)
104
+ if loc:
105
+ _run_argv(["mullvad", "relay", "set", "location", loc], check=False)
106
+ _run_argv(["mullvad", "connect"], check=False)
107
+
108
+
109
+ def mullvad_wait_connected(timeout: int = 20) -> bool:
110
+ if not _mullvad_present():
111
+ return True
112
+ for _ in range(timeout):
113
+ res = subprocess.run(
114
+ ["mullvad", "status"],
115
+ stdout=subprocess.PIPE,
116
+ stderr=subprocess.STDOUT,
117
+ text=True,
118
+ )
119
+ if "Connected" in (res.stdout or ""):
120
+ return True
121
+ time.sleep(1)
122
+ return False
123
+
124
+
125
+ # =========================
126
+ # yt-dlp helpers
127
+ # =========================
128
+ def _common_flags() -> List[str]:
129
+ # --no-playlist prevents accidental channel/playlist pulls (and disk blowups)
130
+ return [
131
+ "--no-playlist",
132
+ "--retries", "10",
133
+ "--fragment-retries", "10",
134
+ "--retry-sleep", "exp=1:30",
135
+ "--user-agent", MODERN_UA,
136
+ "--no-cache-dir",
137
+ "--ignore-config",
138
+ "--embed-metadata",
139
+ "--sleep-interval", "1",
140
+ ]
141
+
142
+
143
+ def _extract_final_path(stdout: str, out_dir: str) -> Optional[str]:
144
+ """
145
+ Robustly derive the final output file path from yt-dlp output.
146
+
147
+ Priority:
148
+ 1) --print after_move:filepath lines (absolute paths)
149
+ 2) [Merger] Merging formats into "..."
150
+ 3) Any Destination: lines that still exist
151
+ 4) Newest non-temp file in out_dir
152
+ """
153
+ candidates: List[str] = []
154
+ out_dir = os.path.abspath(out_dir)
155
+
156
+ for raw in (stdout or "").splitlines():
157
+ line = (raw or "").strip()
158
+ if not line:
159
+ continue
160
+
161
+ # 1) --print after_move:filepath (usually an absolute path)
162
+ if os.path.isabs(line) and line.startswith(out_dir):
163
+ candidates.append(line.strip("'\""))
164
+ continue
165
+
166
+ # 2) Merger line: ... into "path"
167
+ if "Merging formats into" in line and "\"" in line:
168
+ try:
169
+ merged = line.split("Merging formats into", 1)[1].strip()
170
+ if merged.startswith("\"") and merged.endswith("\""):
171
+ merged = merged[1:-1]
172
+ else:
173
+ if merged.startswith("\""):
174
+ merged = merged.split("\"", 2)[1]
175
+ if merged:
176
+ if not os.path.isabs(merged):
177
+ merged = os.path.join(out_dir, merged)
178
+ candidates.append(merged.strip("'\""))
179
+ except Exception:
180
+ pass
181
+ continue
182
+
183
+ # 3) Destination lines (download/extractaudio)
184
+ if "Destination:" in line:
185
+ try:
186
+ p = line.split("Destination:", 1)[1].strip().strip("'\"")
187
+ if p and not os.path.isabs(p):
188
+ p = os.path.join(out_dir, p)
189
+ if p:
190
+ candidates.append(p)
191
+ except Exception:
192
+ pass
193
+ continue
194
+
195
+ # already downloaded
196
+ if "] " in line and " has already been downloaded" in line:
197
+ try:
198
+ p = (
199
+ line.split("] ", 1)[1]
200
+ .split(" has already been downloaded", 1)[0]
201
+ .strip()
202
+ .strip("'\"")
203
+ )
204
+ if p and not os.path.isabs(p):
205
+ p = os.path.join(out_dir, p)
206
+ if p:
207
+ candidates.append(p)
208
+ except Exception:
209
+ pass
210
+
211
+ # Prefer existing, newest candidate (reverse traversal)
212
+ for p in reversed(candidates):
213
+ if p and os.path.exists(p):
214
+ return p
215
+
216
+ # 4) Fallback: newest non-temp file in out_dir
217
+ try:
218
+ best_path = None
219
+ best_mtime = -1.0
220
+ for name in os.listdir(out_dir):
221
+ if name.endswith((".part", ".ytdl", ".tmp")):
222
+ continue
223
+ full = os.path.join(out_dir, name)
224
+ if not os.path.isfile(full):
225
+ continue
226
+ mt = os.path.getmtime(full)
227
+ if mt > best_mtime:
228
+ best_mtime = mt
229
+ best_path = full
230
+ if best_path:
231
+ return best_path
232
+ except Exception:
233
+ pass
234
+
235
+ return None
236
+
237
+
238
+ def _download_with_format(
239
+ url: str,
240
+ out_dir: str,
241
+ fmt: str,
242
+ merge_output_format: Optional[str] = None,
243
+ extract_mp3: bool = False,
244
+ ) -> str:
245
+ out_dir = os.path.abspath(out_dir)
246
+ os.makedirs(out_dir, exist_ok=True)
247
+
248
+ out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
249
+
250
+ argv = [
251
+ YTDLP_BIN,
252
+ "-f", fmt,
253
+ *(_common_flags()),
254
+ "--output", out_tpl,
255
+ # Ensure we can reliably pick the final output path.
256
+ "--print", "after_move:filepath",
257
+ ]
258
+
259
+ if extract_mp3:
260
+ # Force audio extraction to MP3 (requires ffmpeg)
261
+ argv.extend(["--extract-audio", "--audio-format", "mp3"])
262
+
263
+ # Only force merge container when we actually want MP4 output.
264
+ if merge_output_format:
265
+ argv.extend(["--merge-output-format", merge_output_format])
266
+
267
+ argv.append(url)
268
+
269
+ rc, out = _run_argv_capture(argv)
270
+ path = _extract_final_path(out, out_dir)
271
+
272
+ if path and os.path.exists(path):
273
+ return os.path.abspath(path)
274
+
275
+ tail = _tail(out)
276
+ if rc != 0:
277
+ raise RuntimeError(f"yt-dlp failed (format: {fmt})\n{tail}")
278
+ raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{tail}")
279
+
280
+
281
+ def _fmt_mp4_apple_safe(cap: int) -> str:
282
+ # Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
283
+ return (
284
+ f"bv*[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)']"
285
+ f"+ba[ext=m4a][acodec~='^mp4a']"
286
+ f"/b[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)'][acodec~='^mp4a']"
287
+ )
288
+
289
+
290
+ def _fmt_best(cap: int) -> str:
291
+ # Best overall up to cap (can yield webm/mkv/etc).
292
+ return f"bv*[height<={cap}]+ba/b[height<={cap}]"
293
+
294
+
295
+ # =========================
296
+ # Public API
297
+ # =========================
298
+ def download_video(
299
+ url: str,
300
+ resolution: int | None = 1080,
301
+ extension: Optional[str] = None,
302
+ out_dir: str = DEFAULT_OUT_DIR,
303
+ ) -> str:
304
+ if not url:
305
+ raise RuntimeError("Missing URL")
306
+
307
+ out_dir = os.path.abspath(out_dir)
308
+ os.makedirs(out_dir, exist_ok=True)
309
+
310
+ validate_environment()
311
+
312
+ require_mullvad_login()
313
+ mullvad_connect(MULLVAD_LOCATION)
314
+ if not mullvad_wait_connected():
315
+ raise RuntimeError("Mullvad connection failed")
316
+
317
+ try:
318
+ mode = (extension or "mp4").lower().strip()
319
+
320
+ if mode == "mp3":
321
+ # bestaudio -> ffmpeg -> mp3 (post-processed by yt-dlp)
322
+ return _download_with_format(
323
+ url=url,
324
+ out_dir=out_dir,
325
+ fmt="bestaudio",
326
+ merge_output_format=None,
327
+ extract_mp3=True,
328
+ )
329
+
330
+ cap = int(resolution or 1080)
331
+
332
+ if mode == "best":
333
+ # Try best first (may produce webm/mkv/etc).
334
+ try:
335
+ return _download_with_format(
336
+ url=url,
337
+ out_dir=out_dir,
338
+ fmt=_fmt_best(cap),
339
+ merge_output_format=None,
340
+ extract_mp3=False,
341
+ )
342
+ except Exception:
343
+ # If best fails for any reason, fall back to Apple-safe MP4.
344
+ return _download_with_format(
345
+ url=url,
346
+ out_dir=out_dir,
347
+ fmt=_fmt_mp4_apple_safe(cap),
348
+ merge_output_format="mp4",
349
+ extract_mp3=False,
350
+ )
351
+
352
+ # Default / "mp4" mode: force Apple-safe MP4 up to cap.
353
+ return _download_with_format(
354
+ url=url,
355
+ out_dir=out_dir,
356
+ fmt=_fmt_mp4_apple_safe(cap),
357
+ merge_output_format="mp4",
358
+ extract_mp3=False,
359
+ )
360
+
361
+ finally:
362
+ if _mullvad_present():
363
+ _run_argv(["mullvad", "disconnect"], check=False)
@@ -8,7 +8,7 @@ with open("requirements.txt", "r", encoding="utf-8") as fh:
8
8
 
9
9
  setup(
10
10
  name="ytp-dl",
11
- version="0.6.7",
11
+ version="0.6.9",
12
12
  author="dumgum82",
13
13
  author_email="dumgum42@gmail.com",
14
14
  description="YouTube video downloader with Mullvad VPN integration and Flask API",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ytp-dl
3
- Version: 0.6.7
3
+ Version: 0.6.9
4
4
  Summary: YouTube video downloader with Mullvad VPN integration and Flask API
5
5
  Home-page: https://github.com/yourusername/ytp-dl
6
6
  Author: dumgum82
@@ -57,7 +57,7 @@ A lightweight YouTube downloader with Mullvad VPN integration and an HTTP API.
57
57
  ## Installation
58
58
 
59
59
  ```bash
60
- pip install ytp-dl==0.6.7 yt-dlp[default]
60
+ pip install ytp-dl==0.6.9 yt-dlp[default]
61
61
  ```
62
62
 
63
63
  Requirements:
@@ -242,7 +242,7 @@ When Mullvad connects/disconnects, Linux routing can change in a way that breaks
242
242
 
243
243
  * Installs Python, FFmpeg, Mullvad CLI, and Deno
244
244
  * Creates a virtualenv at `/opt/yt-dlp-mullvad/venv`
245
- * Installs `ytp-dl==0.6.7` + `yt-dlp[default]` + `gunicorn`
245
+ * Installs `ytp-dl==0.6.9` + `yt-dlp[default]` + `gunicorn`
246
246
  * Installs a policy-routing oneshot service to keep the public API reachable
247
247
  * Sets up a systemd service on port 5000
248
248
  * Runs Gunicorn with `gthread` (threaded) workers
@@ -258,7 +258,7 @@ Note: `gthread` is a built-in Gunicorn worker class (no extra Python dependency)
258
258
  # - Installs Deno system-wide (JS runtime required for modern YouTube extraction via yt-dlp)
259
259
  # - Configures policy routing so the public API stays reachable while Mullvad toggles
260
260
  # - Creates a virtualenv at /opt/yt-dlp-mullvad/venv
261
- # - Installs ytp-dl==0.6.7 + yt-dlp[default] + gunicorn in that venv
261
+ # - Installs ytp-dl==0.6.9 + yt-dlp[default] + gunicorn in that venv
262
262
  # - Creates a systemd service ytp-dl-api.service on port 5000
263
263
  #
264
264
  # Mullvad connect/disconnect is handled per-job by downloader.py.
@@ -394,7 +394,7 @@ mkdir -p "${APP_DIR}"
394
394
  python3 -m venv "${VENV_DIR}"
395
395
  source "${VENV_DIR}/bin/activate"
396
396
  pip install --upgrade pip
397
- pip install "ytp-dl==0.6.7" "yt-dlp[default]" gunicorn
397
+ pip install "ytp-dl==0.6.9" "yt-dlp[default]" gunicorn
398
398
  deactivate
399
399
 
400
400
  echo "==> 3) API environment file (/etc/default/ytp-dl-api)"
@@ -1,346 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import json
5
- import os
6
- import secrets
7
- import shutil
8
- import tempfile
9
- import time
10
- from threading import BoundedSemaphore, Lock
11
- from typing import Optional, Dict, Any, Iterable
12
-
13
- from flask import Flask, request, jsonify, Response, send_file
14
-
15
- from .downloader import (
16
- validate_environment,
17
- prepare_network,
18
- cleanup_network,
19
- build_download_plan,
20
- )
21
-
22
- app = Flask(__name__)
23
-
24
- BASE_DOWNLOAD_DIR = os.environ.get("YTPDL_JOB_BASE_DIR", "/root/ytpdl_jobs")
25
- os.makedirs(BASE_DOWNLOAD_DIR, exist_ok=True)
26
-
27
- MAX_CONCURRENT = int(os.environ.get("YTPDL_MAX_CONCURRENT", "1"))
28
-
29
- # Thread-safe concurrency gate (caps actual download jobs).
30
- _sem = BoundedSemaphore(MAX_CONCURRENT)
31
-
32
- # Track in-flight jobs for /healthz reporting.
33
- _in_use = 0
34
- _in_use_lock = Lock()
35
-
36
- # Failsafe: delete abandoned job dirs older than this many seconds.
37
- STALE_JOB_TTL_S = int(os.environ.get("YTPDL_STALE_JOB_TTL_S", "3600"))
38
-
39
- _ALLOWED_EXTENSIONS = {"mp3", "mp4", "best"}
40
-
41
-
42
- def _cleanup_stale_jobs() -> None:
43
- now = time.time()
44
- try:
45
- for name in os.listdir(BASE_DOWNLOAD_DIR):
46
- p = os.path.join(BASE_DOWNLOAD_DIR, name)
47
- if not os.path.isdir(p):
48
- continue
49
- try:
50
- age = now - os.path.getmtime(p)
51
- except Exception:
52
- continue
53
- if age > STALE_JOB_TTL_S:
54
- shutil.rmtree(p, ignore_errors=True)
55
- except Exception:
56
- pass
57
-
58
-
59
- def _try_acquire_job_slot() -> bool:
60
- global _in_use
61
- if not _sem.acquire(blocking=False):
62
- return False
63
- with _in_use_lock:
64
- _in_use += 1
65
- return True
66
-
67
-
68
- def _release_job_slot() -> None:
69
- global _in_use
70
- with _in_use_lock:
71
- if _in_use > 0:
72
- _in_use -= 1
73
- _sem.release()
74
-
75
-
76
- def _job_meta_path(job_dir: str) -> str:
77
- return os.path.join(job_dir, "job.json")
78
-
79
-
80
- def _write_job_meta(job_dir: str, meta: Dict[str, Any]) -> None:
81
- try:
82
- with open(_job_meta_path(job_dir), "w", encoding="utf-8") as f:
83
- json.dump(meta, f, ensure_ascii=False)
84
- except Exception:
85
- pass
86
-
87
-
88
- def _read_job_meta(job_dir: str) -> Optional[Dict[str, Any]]:
89
- p = _job_meta_path(job_dir)
90
- if not os.path.exists(p):
91
- return None
92
- try:
93
- with open(p, "r", encoding="utf-8") as f:
94
- return json.load(f)
95
- except Exception:
96
- return None
97
-
98
-
99
- def _touch(path: str) -> None:
100
- try:
101
- os.utime(path, None)
102
- except Exception:
103
- pass
104
-
105
-
106
- def _sse(data: str) -> str:
107
- # Minimal SSE formatting
108
- return f"data: {data}\n\n"
109
-
110
-
111
- @app.route("/api/download", methods=["POST"])
112
- def handle_download():
113
- """
114
- VPS-side downloader:
115
- - returns SSE logs (real yt-dlp output) to caller
116
- - stores completed file in a job dir
117
- - caller then fetches the file via /api/fetch/<job_id>?token=...
118
- """
119
- _cleanup_stale_jobs()
120
-
121
- if not _try_acquire_job_slot():
122
- return jsonify(error="Server busy, try again later"), 503
123
-
124
- job_dir: str | None = None
125
- released = False
126
-
127
- def _release_once() -> None:
128
- nonlocal released
129
- if not released:
130
- released = True
131
- _release_job_slot()
132
-
133
- try:
134
- data = request.get_json(force=True)
135
- url = (data.get("url") or "").strip()
136
- resolution = data.get("resolution")
137
-
138
- # extension is now a "mode": mp3 | mp4 | best
139
- extension = (data.get("extension") or "mp4").strip().lower()
140
-
141
- if not url:
142
- _release_once()
143
- return jsonify(error="Missing 'url'"), 400
144
-
145
- if extension not in _ALLOWED_EXTENSIONS:
146
- _release_once()
147
- return jsonify(
148
- error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"
149
- ), 400
150
-
151
- job_dir = tempfile.mkdtemp(prefix="ytpdl_", dir=BASE_DOWNLOAD_DIR)
152
- job_id = os.path.basename(job_dir)
153
- token = secrets.token_urlsafe(24)
154
-
155
- def stream() -> Iterable[str]:
156
- nonlocal job_dir
157
- assert job_dir is not None
158
-
159
- # Internal control info (caller should NOT forward to browser).
160
- yield _sse(f"[internal] job={job_id} token={token}")
161
-
162
- try:
163
- validate_environment()
164
- prepare_network()
165
-
166
- out_dir = job_dir
167
- out_dir_abs = os.path.abspath(out_dir)
168
-
169
- # Track final file path without printing it to the client.
170
- final_path: Optional[str] = None
171
- # Track "pretty" filename from [download_complete] marker.
172
- final_name: Optional[str] = None
173
-
174
- plan = build_download_plan(
175
- url=url,
176
- resolution=resolution,
177
- extension=extension,
178
- out_dir=out_dir,
179
- )
180
-
181
- for step_idx, argv in enumerate(plan, start=1):
182
- # Let caller know about fallback attempt (still safe to forward).
183
- if step_idx > 1:
184
- yield _sse("Retrying with fallback format...")
185
-
186
- import subprocess
187
-
188
- proc = subprocess.Popen(
189
- argv,
190
- stdout=subprocess.PIPE,
191
- stderr=subprocess.STDOUT,
192
- text=True,
193
- bufsize=1,
194
- )
195
-
196
- assert proc.stdout is not None
197
-
198
- for raw in iter(proc.stdout.readline, ""):
199
- line = (raw or "").strip()
200
- if not line:
201
- continue
202
-
203
- # yt-dlp --print after_move:filepath outputs an absolute path.
204
- # Keep it internal so browser logs match local.
205
- if os.path.isabs(line) and line.startswith(out_dir_abs):
206
- final_path = line.strip("'\"")
207
- continue
208
-
209
- # Capture local-style marker for filename.
210
- if line.startswith("[download_complete]"):
211
- # same semantics as your local parser
212
- try:
213
- final_name = line.split("[download_complete]", 1)[1].strip()
214
- except Exception:
215
- final_name = None
216
-
217
- yield _sse(line)
218
-
219
- proc.stdout.close()
220
- rc = proc.wait()
221
-
222
- if rc == 0:
223
- break
224
-
225
- # Validate output
226
- if not final_path:
227
- # Try to discover any output file in job dir if filepath line wasn't produced.
228
- try:
229
- for name in os.listdir(job_dir):
230
- if name.endswith((".part", ".ytdl", ".tmp")):
231
- continue
232
- p = os.path.join(job_dir, name)
233
- if os.path.isfile(p):
234
- final_path = os.path.abspath(p)
235
- break
236
- except Exception:
237
- pass
238
-
239
- if not (final_path and os.path.exists(final_path)):
240
- yield _sse("ERROR: Download completed but output file not found")
241
- return
242
-
243
- # Persist meta for /api/fetch
244
- meta = {
245
- "job_id": job_id,
246
- "token": token,
247
- "file_path": os.path.abspath(final_path),
248
- "file_name": final_name or os.path.basename(final_path),
249
- "created_at": time.time(),
250
- }
251
- _write_job_meta(job_dir, meta)
252
- _touch(job_dir)
253
-
254
- # Release slot as soon as yt-dlp is done (do NOT hold during fetch).
255
- _release_once()
256
-
257
- # Signal ready to caller (do NOT forward to browser).
258
- yield _sse("[internal] ready")
259
-
260
- finally:
261
- cleanup_network()
262
-
263
- return Response(
264
- stream(),
265
- content_type="text/event-stream",
266
- headers={
267
- "Cache-Control": "no-cache",
268
- "X-Accel-Buffering": "no",
269
- "Connection": "close",
270
- },
271
- )
272
-
273
- except RuntimeError as e:
274
- if job_dir:
275
- shutil.rmtree(job_dir, ignore_errors=True)
276
- _release_once()
277
-
278
- msg = str(e)
279
- if "Mullvad not logged in" in msg:
280
- return jsonify(error=msg), 503
281
- return jsonify(error=f"Download failed: {msg}"), 500
282
-
283
- except Exception as e:
284
- if job_dir:
285
- shutil.rmtree(job_dir, ignore_errors=True)
286
- _release_once()
287
- return jsonify(error=f"Download failed: {str(e)}"), 500
288
-
289
-
290
- @app.route("/api/fetch/<job_id>", methods=["GET"])
291
- def fetch(job_id: str):
292
- """
293
- Fetch the finished file after /api/download completes.
294
-
295
- Requires:
296
- /api/fetch/<job_id>?token=...
297
- """
298
- token = (request.args.get("token") or "").strip()
299
- if not token:
300
- return jsonify(error="Missing token"), 400
301
-
302
- job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
303
- if not os.path.isdir(job_dir):
304
- return jsonify(error="Job not found"), 404
305
-
306
- meta = _read_job_meta(job_dir)
307
- if not meta:
308
- return jsonify(error="Job metadata missing"), 404
309
-
310
- if meta.get("token") != token:
311
- return jsonify(error="Invalid token"), 403
312
-
313
- file_path = meta.get("file_path")
314
- if not (file_path and os.path.exists(file_path)):
315
- return jsonify(error="File not found"), 404
316
-
317
- download_name = meta.get("file_name") or os.path.basename(file_path)
318
-
319
- resp = send_file(file_path, as_attachment=True, download_name=download_name)
320
-
321
- # Cleanup after caller finishes consuming the response.
322
- def _cleanup() -> None:
323
- try:
324
- shutil.rmtree(job_dir, ignore_errors=True)
325
- except Exception:
326
- pass
327
-
328
- resp.call_on_close(_cleanup)
329
- return resp
330
-
331
-
332
- @app.route("/healthz", methods=["GET"])
333
- def healthz():
334
- with _in_use_lock:
335
- in_use = _in_use
336
- return jsonify(ok=True, in_use=in_use, capacity=MAX_CONCURRENT), 200
337
-
338
-
339
- def main():
340
- validate_environment()
341
- print("Starting ytp-dl API server...")
342
- app.run(host="0.0.0.0", port=5000)
343
-
344
-
345
- if __name__ == "__main__":
346
- main()
@@ -1,311 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import os
5
- import shlex
6
- import shutil
7
- import subprocess
8
- import time
9
- from typing import Optional, List, Tuple, Any
10
-
11
- # =========================
12
- # Config / constants
13
- # =========================
14
- VENV_PATH = os.environ.get("YTPDL_VENV", "/opt/yt-dlp-mullvad/venv")
15
- YTDLP_BIN = os.path.join(VENV_PATH, "bin", "yt-dlp")
16
- MULLVAD_LOCATION = os.environ.get("YTPDL_MULLVAD_LOCATION", "us")
17
-
18
- MODERN_UA = os.environ.get(
19
- "YTPDL_USER_AGENT",
20
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
21
- "AppleWebKit/537.36 (KHTML, like Gecko) "
22
- "Chrome/124.0.0.0 Safari/537.36",
23
- )
24
-
25
- FFMPEG_BIN = shutil.which("ffmpeg") or "ffmpeg"
26
- DEFAULT_OUT_DIR = os.environ.get("YTPDL_DOWNLOAD_DIR", "/root")
27
-
28
- # Keep error payloads readable (your web UI prints these).
29
- _MAX_ERR_LINES = 80
30
- _MAX_ERR_CHARS = 4000
31
-
32
- _ALLOWED_EXTENSIONS = {"mp3", "mp4", "best"}
33
-
34
-
35
- # =========================
36
- # Shell helpers
37
- # =========================
38
- def _run_argv_capture(argv: List[str]) -> Tuple[int, str]:
39
- res = subprocess.run(
40
- argv,
41
- stdout=subprocess.PIPE,
42
- stderr=subprocess.STDOUT,
43
- text=True,
44
- )
45
- return res.returncode, (res.stdout or "")
46
-
47
-
48
- def _run_argv(argv: List[str], check: bool = True) -> str:
49
- rc, out = _run_argv_capture(argv)
50
- if check and rc != 0:
51
- cmd = " ".join(shlex.quote(p) for p in argv)
52
- raise RuntimeError(f"Command failed: {cmd}\n{out}")
53
- return out
54
-
55
-
56
- def _tail(out: str) -> str:
57
- lines = (out or "").splitlines()
58
- tail_lines = lines[-_MAX_ERR_LINES:]
59
- txt = "\n".join(tail_lines)
60
- if len(txt) > _MAX_ERR_CHARS:
61
- txt = txt[-_MAX_ERR_CHARS:]
62
- return txt.strip()
63
-
64
-
65
- # =========================
66
- # Environment / Mullvad
67
- # =========================
68
- def validate_environment() -> None:
69
- if not os.path.exists(YTDLP_BIN):
70
- raise RuntimeError(f"yt-dlp not found at {YTDLP_BIN}")
71
- if shutil.which(FFMPEG_BIN) is None:
72
- raise RuntimeError("ffmpeg not found on PATH")
73
-
74
-
75
- def _mullvad_present() -> bool:
76
- return shutil.which("mullvad") is not None
77
-
78
-
79
- def mullvad_logged_in() -> bool:
80
- if not _mullvad_present():
81
- return False
82
- res = subprocess.run(
83
- ["mullvad", "account", "get"],
84
- stdout=subprocess.PIPE,
85
- stderr=subprocess.STDOUT,
86
- text=True,
87
- )
88
- return "not logged in" not in (res.stdout or "").lower()
89
-
90
-
91
- def require_mullvad_login() -> None:
92
- if _mullvad_present() and not mullvad_logged_in():
93
- raise RuntimeError("Mullvad not logged in. Run: mullvad account login <ACCOUNT>")
94
-
95
-
96
- def mullvad_connect(location: Optional[str] = None) -> None:
97
- if not _mullvad_present():
98
- return
99
- loc = (location or MULLVAD_LOCATION).strip()
100
- _run_argv(["mullvad", "disconnect"], check=False)
101
- if loc:
102
- _run_argv(["mullvad", "relay", "set", "location", loc], check=False)
103
- _run_argv(["mullvad", "connect"], check=False)
104
-
105
-
106
- def mullvad_wait_connected(timeout: int = 20) -> bool:
107
- if not _mullvad_present():
108
- return True
109
- for _ in range(timeout):
110
- res = subprocess.run(
111
- ["mullvad", "status"],
112
- stdout=subprocess.PIPE,
113
- stderr=subprocess.STDOUT,
114
- text=True,
115
- )
116
- if "Connected" in (res.stdout or ""):
117
- return True
118
- time.sleep(1)
119
- return False
120
-
121
-
122
- def prepare_network() -> None:
123
- """
124
- Called by VPS API server before launching yt-dlp.
125
- """
126
- validate_environment()
127
- require_mullvad_login()
128
- mullvad_connect(MULLVAD_LOCATION)
129
- if not mullvad_wait_connected():
130
- raise RuntimeError("Mullvad connection failed")
131
-
132
-
133
- def cleanup_network() -> None:
134
- """
135
- Called by VPS API server after yt-dlp finishes.
136
- """
137
- if _mullvad_present():
138
- _run_argv(["mullvad", "disconnect"], check=False)
139
-
140
-
141
- # =========================
142
- # yt-dlp helpers
143
- # =========================
144
- def _common_flags() -> List[str]:
145
- # --no-playlist prevents accidental channel/playlist pulls (and disk blowups)
146
- return [
147
- "--no-playlist",
148
- "--retries", "10",
149
- "--fragment-retries", "10",
150
- "--retry-sleep", "exp=1:30",
151
- "--user-agent", MODERN_UA,
152
- "--no-cache-dir",
153
- "--ignore-config",
154
- "--sleep-interval", "1",
155
- ]
156
-
157
-
158
- def _sanitize_title_flags() -> List[str]:
159
- # Mirrors your local downloader's title sanitization intent.
160
- return [
161
- "--replace-in-metadata",
162
- "title",
163
- "[\\U0001F600-\\U0001F64F\\U0001F300-\\U0001F5FF"
164
- "\\U0001F680-\\U0001F6FF\\U0001F700-\\U0001F77F"
165
- "\\U0001F780-\\U0001F7FF\\U0001F800-\\U0001F8FF"
166
- "\\U0001F900-\\U0001F9FF\\U0001FA00-\\U0001FA6F"
167
- "\\U0001FA70-\\U0001FAFF\\U00002702-\\U000027B0"
168
- "\\U000024C2-\\U0001F251]",
169
- "",
170
- "--replace-in-metadata",
171
- "title",
172
- r"[\\\/:*?\"<>|]|[\s.]+$",
173
- "",
174
- ]
175
-
176
-
177
- def _fmt_mp4_apple_safe(cap: int) -> str:
178
- # Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
179
- return (
180
- f"bv*[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)']"
181
- f"+ba[ext=m4a][acodec~='^mp4a']"
182
- f"/b[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)'][acodec~='^mp4a']"
183
- )
184
-
185
-
186
- def _fmt_best(cap: int) -> str:
187
- # Best overall up to cap (can yield webm/mkv/etc).
188
- return f"bv*[height<={cap}]+ba/b[height<={cap}]"
189
-
190
-
191
- def _base_argv(out_dir: str) -> List[str]:
192
- out_dir = os.path.abspath(out_dir)
193
- out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
194
-
195
- return [
196
- YTDLP_BIN,
197
- "--progress",
198
- "--newline",
199
- "--continue",
200
- *_common_flags(),
201
- *_sanitize_title_flags(),
202
- "--output", out_tpl,
203
- # Internal: absolute final path (API server filters this from browser).
204
- "--print", "after_move:filepath",
205
- # Visible: local-style completion marker.
206
- "--print", "after_move:[download_complete] %(title)s.%(ext)s",
207
- ]
208
-
209
-
210
- def build_download_plan(
211
- url: str,
212
- resolution: Any | None = 1080,
213
- extension: Optional[str] = None,
214
- out_dir: str = DEFAULT_OUT_DIR,
215
- ) -> List[List[str]]:
216
- """
217
- Returns a list of argv arrays to try in order.
218
- The VPS API server will run them with Popen and stream stdout.
219
- """
220
- if not url:
221
- raise RuntimeError("Missing URL")
222
-
223
- mode = (extension or "mp4").lower().strip()
224
- if mode not in _ALLOWED_EXTENSIONS:
225
- raise RuntimeError(f"Invalid extension/mode: {mode}")
226
-
227
- cap = int(resolution or 1080)
228
- base = _base_argv(out_dir)
229
-
230
- if mode == "mp3":
231
- return [[
232
- *base,
233
- "-f", "bestaudio",
234
- "--extract-audio",
235
- "--audio-format", "mp3",
236
- "--audio-quality", "0",
237
- "--embed-thumbnail",
238
- "--add-metadata",
239
- "--metadata-from-title", "%(artist)s - %(title)s",
240
- url,
241
- ]]
242
-
243
- if mode == "best":
244
- # Try best first, then fallback to Apple-safe MP4.
245
- return [
246
- [
247
- *base,
248
- "-f", _fmt_best(cap),
249
- url,
250
- ],
251
- [
252
- *base,
253
- "-f", _fmt_mp4_apple_safe(cap),
254
- "--merge-output-format", "mp4",
255
- url,
256
- ],
257
- ]
258
-
259
- # mode == "mp4" (default)
260
- return [[
261
- *base,
262
- "-f", _fmt_mp4_apple_safe(cap),
263
- "--merge-output-format", "mp4",
264
- url,
265
- ]]
266
-
267
-
268
- # =========================
269
- # Legacy (keep for compatibility)
270
- # =========================
271
- def download_video(
272
- url: str,
273
- resolution: int | None = 1080,
274
- extension: Optional[str] = None,
275
- out_dir: str = DEFAULT_OUT_DIR,
276
- ) -> str:
277
- """
278
- Original blocking API (kept so existing imports don't break).
279
- The VPS API server now uses build_download_plan + Popen streaming instead.
280
- """
281
- import os
282
-
283
- if not url:
284
- raise RuntimeError("Missing URL")
285
-
286
- out_dir = os.path.abspath(out_dir)
287
- os.makedirs(out_dir, exist_ok=True)
288
-
289
- prepare_network()
290
- try:
291
- plan = build_download_plan(url=url, resolution=resolution, extension=extension, out_dir=out_dir)
292
-
293
- last_out = ""
294
- for argv in plan:
295
- rc, out = _run_argv_capture(argv)
296
- last_out = out or ""
297
- # Find the printed after_move:filepath absolute path (first matching line).
298
- for raw in (last_out or "").splitlines():
299
- line = (raw or "").strip()
300
- if os.path.isabs(line) and line.startswith(out_dir):
301
- if os.path.exists(line):
302
- return os.path.abspath(line)
303
-
304
- if rc == 0:
305
- break
306
-
307
- tail = _tail(last_out)
308
- raise RuntimeError(f"Download completed but output file not found\n{tail}")
309
-
310
- finally:
311
- cleanup_network()
File without changes
File without changes