ytp-dl 0.7.0__tar.gz → 0.7.1__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.7.0
3
+ Version: 0.7.1
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.7.0 yt-dlp[default]
60
+ pip install ytp-dl==0.7.1 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.7.0` + `yt-dlp[default]` + `gunicorn`
245
+ * Installs `ytp-dl==0.7.1` + `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.7.0 + yt-dlp[default] + gunicorn in that venv
261
+ # - Installs ytp-dl==0.7.1 + 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.7.0" "yt-dlp[default]" gunicorn
397
+ pip install "ytp-dl==0.7.1" "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.7.0 yt-dlp[default]
27
+ pip install ytp-dl==0.7.1 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.7.0` + `yt-dlp[default]` + `gunicorn`
212
+ * Installs `ytp-dl==0.7.1` + `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.7.0 + yt-dlp[default] + gunicorn in that venv
228
+ # - Installs ytp-dl==0.7.1 + 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.7.0" "yt-dlp[default]" gunicorn
364
+ pip install "ytp-dl==0.7.1" "yt-dlp[default]" gunicorn
365
365
  deactivate
366
366
 
367
367
  echo "==> 3) API environment file (/etc/default/ytp-dl-api)"
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ import time
9
+ from threading import BoundedSemaphore, Lock
10
+ from typing import Optional
11
+
12
+ from flask import Flask, request, send_file, jsonify, Response, stream_with_context
13
+
14
+ from .downloader import validate_environment, download_video, download_video_stream
15
+
16
+ app = Flask(__name__)
17
+
18
+ BASE_DOWNLOAD_DIR = os.environ.get("YTPDL_JOB_BASE_DIR", "/root/ytpdl_jobs")
19
+ os.makedirs(BASE_DOWNLOAD_DIR, exist_ok=True)
20
+
21
+ MAX_CONCURRENT = int(os.environ.get("YTPDL_MAX_CONCURRENT", "1"))
22
+
23
+ # Thread-safe concurrency gate (caps actual download jobs).
24
+ _sem = BoundedSemaphore(MAX_CONCURRENT)
25
+
26
+ # Track in-flight jobs for /healthz reporting.
27
+ _in_use = 0
28
+ _in_use_lock = Lock()
29
+
30
+ # Failsafe: delete abandoned job dirs older than this many seconds.
31
+ STALE_JOB_TTL_S = int(os.environ.get("YTPDL_STALE_JOB_TTL_S", "3600"))
32
+
33
+ _ALLOWED_EXTENSIONS = {"mp3", "mp4", "best"}
34
+
35
+
36
+ def _cleanup_stale_jobs() -> None:
37
+ now = time.time()
38
+ try:
39
+ for name in os.listdir(BASE_DOWNLOAD_DIR):
40
+ p = os.path.join(BASE_DOWNLOAD_DIR, name)
41
+ if not os.path.isdir(p):
42
+ continue
43
+ try:
44
+ age = now - os.path.getmtime(p)
45
+ except Exception:
46
+ continue
47
+ if age > STALE_JOB_TTL_S:
48
+ shutil.rmtree(p, ignore_errors=True)
49
+ except Exception:
50
+ pass
51
+
52
+
53
+ def _try_acquire_job_slot() -> bool:
54
+ global _in_use
55
+ if not _sem.acquire(blocking=False):
56
+ return False
57
+ with _in_use_lock:
58
+ _in_use += 1
59
+ return True
60
+
61
+
62
+ def _release_job_slot() -> None:
63
+ global _in_use
64
+ with _in_use_lock:
65
+ if _in_use > 0:
66
+ _in_use -= 1
67
+ _sem.release()
68
+
69
+
70
+ def _safe_job_id(raw: str) -> str:
71
+ """Keep job ids filesystem-safe and predictable."""
72
+ s = (raw or "").strip()
73
+ if not s:
74
+ return str(int(time.time() * 1000))
75
+ # Only allow basic chars; everything else becomes "_"
76
+ out = []
77
+ for ch in s:
78
+ if ch.isalnum() or ch in ("-", "_"):
79
+ out.append(ch)
80
+ else:
81
+ out.append("_")
82
+ return "".join(out)[:120] or str(int(time.time() * 1000))
83
+
84
+
85
+ def _write_result(job_dir: str, final_path: str) -> None:
86
+ """Persist the final file path so /api/fetch can retrieve it."""
87
+ meta = {
88
+ "path": final_path,
89
+ "filename": os.path.basename(final_path),
90
+ "ts": time.time(),
91
+ }
92
+ with open(os.path.join(job_dir, "result.json"), "w", encoding="utf-8") as f:
93
+ json.dump(meta, f, ensure_ascii=False)
94
+
95
+
96
+ def _read_result(job_dir: str) -> Optional[dict]:
97
+ """Load persisted result metadata."""
98
+ p = os.path.join(job_dir, "result.json")
99
+ if not os.path.exists(p):
100
+ return None
101
+ try:
102
+ with open(p, "r", encoding="utf-8") as f:
103
+ return json.load(f)
104
+ except Exception:
105
+ return None
106
+
107
+
108
+ @app.route("/api/download", methods=["POST"])
109
+ def handle_download():
110
+ """
111
+ Legacy endpoint (unchanged):
112
+ - Runs yt-dlp to completion
113
+ - Returns the file as the HTTP response body
114
+ """
115
+ _cleanup_stale_jobs()
116
+
117
+ if not _try_acquire_job_slot():
118
+ return jsonify(error="Server busy, try again later"), 503
119
+
120
+ job_dir: str | None = None
121
+ released = False
122
+
123
+ def _release_once() -> None:
124
+ nonlocal released
125
+ if not released:
126
+ released = True
127
+ _release_job_slot()
128
+
129
+ try:
130
+ data = request.get_json(force=True)
131
+ url = (data.get("url") or "").strip()
132
+ resolution = data.get("resolution")
133
+ extension = (data.get("extension") or "mp4").strip().lower()
134
+
135
+ if not url:
136
+ _release_once()
137
+ return jsonify(error="Missing 'url'"), 400
138
+
139
+ if extension not in _ALLOWED_EXTENSIONS:
140
+ _release_once()
141
+ return jsonify(
142
+ error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"
143
+ ), 400
144
+
145
+ job_dir = tempfile.mkdtemp(prefix="ytpdl_", dir=BASE_DOWNLOAD_DIR)
146
+
147
+ # yt-dlp work (guarded by semaphore)
148
+ filename = download_video(
149
+ url=url,
150
+ resolution=resolution,
151
+ extension=extension,
152
+ out_dir=job_dir,
153
+ )
154
+
155
+ if not (filename and os.path.exists(filename)):
156
+ raise RuntimeError("Download failed")
157
+
158
+ # Release slot as soon as yt-dlp is done.
159
+ _release_once()
160
+
161
+ response = send_file(filename, as_attachment=True)
162
+
163
+ # Cleanup directory after client finishes consuming the response.
164
+ def _cleanup() -> None:
165
+ try:
166
+ if job_dir:
167
+ shutil.rmtree(job_dir, ignore_errors=True)
168
+ except Exception:
169
+ pass
170
+
171
+ response.call_on_close(_cleanup)
172
+ return response
173
+
174
+ except RuntimeError as e:
175
+ if job_dir:
176
+ shutil.rmtree(job_dir, ignore_errors=True)
177
+ _release_once()
178
+
179
+ msg = str(e)
180
+ if "Mullvad not logged in" in msg:
181
+ return jsonify(error=msg), 503
182
+ return jsonify(error=f"Download failed: {msg}"), 500
183
+
184
+ except Exception as e:
185
+ if job_dir:
186
+ shutil.rmtree(job_dir, ignore_errors=True)
187
+ _release_once()
188
+ return jsonify(error=f"Download failed: {str(e)}"), 500
189
+
190
+
191
+ @app.route("/api/download_sse", methods=["POST"])
192
+ def handle_download_sse():
193
+ """
194
+ NEW:
195
+ - Streams raw yt-dlp stdout lines as SSE in real time.
196
+ - Persists the final path to job_dir/result.json.
197
+ - Does NOT stream the file in this response.
198
+ """
199
+ _cleanup_stale_jobs()
200
+
201
+ if not _try_acquire_job_slot():
202
+ return jsonify(error="Server busy, try again later"), 503
203
+
204
+ released = False
205
+
206
+ def _release_once() -> None:
207
+ nonlocal released
208
+ if not released:
209
+ released = True
210
+ _release_job_slot()
211
+
212
+ data = request.get_json(force=True) or {}
213
+ url = (data.get("url") or "").strip()
214
+ resolution = data.get("resolution")
215
+ extension = (data.get("extension") or "mp4").strip().lower()
216
+ job_id = _safe_job_id(str(data.get("job_id") or ""))
217
+
218
+ if not url:
219
+ _release_once()
220
+ return jsonify(error="Missing 'url'"), 400
221
+ if extension not in _ALLOWED_EXTENSIONS:
222
+ _release_once()
223
+ return jsonify(error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"), 400
224
+
225
+ job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
226
+ os.makedirs(job_dir, exist_ok=True)
227
+
228
+ def gen():
229
+ try:
230
+ # download_video_stream is a generator that yields yt-dlp lines and
231
+ # returns final_path via StopIteration.value
232
+ g = download_video_stream(
233
+ url=url,
234
+ resolution=resolution,
235
+ extension=extension,
236
+ out_dir=job_dir,
237
+ )
238
+
239
+ final_path: Optional[str] = None
240
+
241
+ while True:
242
+ try:
243
+ line = next(g)
244
+ except StopIteration as si:
245
+ final_path = si.value # type: ignore[assignment]
246
+ break
247
+
248
+ line = (line or "").strip()
249
+ if not line:
250
+ continue
251
+ yield f"data: {line}\n\n"
252
+
253
+ if not final_path or not os.path.exists(final_path):
254
+ raise RuntimeError("Download completed but output file not found")
255
+
256
+ _write_result(job_dir, final_path)
257
+
258
+ # Marker consumed by your Render app (not forwarded to the browser).
259
+ yield f"data: [vps_ready] {os.path.basename(final_path)}\n\n"
260
+
261
+ except Exception as e:
262
+ msg = str(e)
263
+ yield f"data: VPS error: {msg}\n\n"
264
+ finally:
265
+ _release_once()
266
+
267
+ resp = Response(stream_with_context(gen()), content_type="text/event-stream")
268
+ resp.headers["Cache-Control"] = "no-cache"
269
+ resp.headers["X-Accel-Buffering"] = "no"
270
+ return resp
271
+
272
+
273
+ @app.route("/api/fetch/<job_id>", methods=["GET"])
274
+ def fetch_job(job_id: str):
275
+ """
276
+ NEW:
277
+ - Returns the completed file for a prior /api/download_sse job.
278
+ - Deletes the job dir after the client finishes consuming the response.
279
+ """
280
+ job_id = _safe_job_id(job_id)
281
+ job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
282
+ meta = _read_result(job_dir)
283
+ if not meta:
284
+ return jsonify(error="Job not found or not ready"), 404
285
+
286
+ path = (meta.get("path") or "").strip()
287
+ if not path:
288
+ return jsonify(error="Job result missing path"), 500
289
+
290
+ # Prevent path traversal: ensure file lives inside job_dir
291
+ try:
292
+ job_dir_abs = os.path.abspath(job_dir)
293
+ path_abs = os.path.abspath(path)
294
+ if os.path.commonpath([job_dir_abs, path_abs]) != job_dir_abs:
295
+ return jsonify(error="Invalid job result path"), 500
296
+ except Exception:
297
+ return jsonify(error="Invalid job result path"), 500
298
+
299
+ if not os.path.exists(path):
300
+ return jsonify(error="File not found"), 404
301
+
302
+ response = send_file(path, as_attachment=True)
303
+
304
+ def _cleanup() -> None:
305
+ try:
306
+ shutil.rmtree(job_dir, ignore_errors=True)
307
+ except Exception:
308
+ pass
309
+
310
+ response.call_on_close(_cleanup)
311
+ return response
312
+
313
+
314
+ @app.route("/healthz", methods=["GET"])
315
+ def healthz():
316
+ with _in_use_lock:
317
+ in_use = _in_use
318
+ return jsonify(ok=True, in_use=in_use, capacity=MAX_CONCURRENT), 200
319
+
320
+
321
+ def main():
322
+ validate_environment()
323
+ print("Starting ytp-dl API server...")
324
+ app.run(host="0.0.0.0", port=5000)
325
+
326
+
327
+ if __name__ == "__main__":
328
+ main()
@@ -7,7 +7,7 @@ import shutil
7
7
  import subprocess
8
8
  import time
9
9
  from collections import deque
10
- from typing import Deque, Generator, List, Optional, Tuple
10
+ from typing import Optional, List, Tuple, Deque, Generator
11
11
 
12
12
  # =========================
13
13
  # Config / constants
@@ -61,13 +61,6 @@ def _tail(out: str) -> str:
61
61
  return txt.strip()
62
62
 
63
63
 
64
- def _tail_deque(lines: Deque[str]) -> str:
65
- txt = "\n".join(list(lines)[-_MAX_ERR_LINES:])
66
- if len(txt) > _MAX_ERR_CHARS:
67
- txt = txt[-_MAX_ERR_CHARS:]
68
- return txt.strip()
69
-
70
-
71
64
  def _is_youtube_url(url: str) -> bool:
72
65
  u = (url or "").lower()
73
66
  return any(h in u for h in ("youtube.com", "youtu.be", "youtube-nocookie.com"))
@@ -137,19 +130,14 @@ def _common_flags() -> List[str]:
137
130
  # --no-playlist prevents accidental channel/playlist pulls (and disk blowups)
138
131
  return [
139
132
  "--no-playlist",
140
- "--retries",
141
- "10",
142
- "--fragment-retries",
143
- "10",
144
- "--retry-sleep",
145
- "exp=1:30",
146
- "--user-agent",
147
- MODERN_UA,
133
+ "--retries", "10",
134
+ "--fragment-retries", "10",
135
+ "--retry-sleep", "exp=1:30",
136
+ "--user-agent", MODERN_UA,
148
137
  "--no-cache-dir",
149
138
  "--ignore-config",
150
139
  "--embed-metadata",
151
- "--sleep-interval",
152
- "1",
140
+ "--sleep-interval", "1",
153
141
  ]
154
142
 
155
143
 
@@ -262,14 +250,11 @@ def _download_with_format(
262
250
 
263
251
  argv = [
264
252
  YTDLP_BIN,
265
- "-f",
266
- fmt,
253
+ "-f", fmt,
267
254
  *(_common_flags()),
268
- "--output",
269
- out_tpl,
255
+ "--output", out_tpl,
270
256
  # Ensure we can reliably pick the final output path.
271
- "--print",
272
- "after_move:filepath",
257
+ "--print", "after_move:filepath",
273
258
  ]
274
259
 
275
260
  if extract_mp3:
@@ -294,20 +279,6 @@ def _download_with_format(
294
279
  raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{tail}")
295
280
 
296
281
 
297
- def _fmt_mp4_apple_safe(cap: int) -> str:
298
- # Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
299
- return (
300
- f"bv*[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)']"
301
- f"+ba[ext=m4a][acodec~='^mp4a']"
302
- f"/b[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)'][acodec~='^mp4a']"
303
- )
304
-
305
-
306
- def _fmt_best(cap: int) -> str:
307
- # Best overall up to cap (can yield webm/mkv/etc).
308
- return f"bv*[height<={cap}]+ba/b[height<={cap}]"
309
-
310
-
311
282
  def _download_with_format_stream(
312
283
  url: str,
313
284
  out_dir: str,
@@ -316,26 +287,26 @@ def _download_with_format_stream(
316
287
  extract_mp3: bool = False,
317
288
  ) -> Generator[str, None, str]:
318
289
  """
319
- Stream yt-dlp stdout lines in real-time.
320
- Yields: raw stdout lines
321
- Returns (StopIteration.value): absolute final output path
290
+ Stream yt-dlp output line-by-line (stdout+stderr merged),
291
+ and return the final output path via StopIteration.value.
322
292
  """
323
293
  out_dir = os.path.abspath(out_dir)
324
294
  os.makedirs(out_dir, exist_ok=True)
295
+
325
296
  out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
326
297
 
327
- argv: List[str] = [
298
+ argv = [
328
299
  YTDLP_BIN,
329
- "-f",
330
- fmt,
300
+ "-f", fmt,
331
301
  *(_common_flags()),
332
- # ensure progress is emitted as real lines (no carriage-return overwrites)
302
+ "--output", out_tpl,
303
+
304
+ # Make progress lines flush as newline-terminated output
333
305
  "--progress",
334
306
  "--newline",
335
- "--output",
336
- out_tpl,
337
- "--print",
338
- "after_move:filepath",
307
+
308
+ # Ensure we can reliably obtain the post-processed final path
309
+ "--print", "after_move:filepath",
339
310
  ]
340
311
 
341
312
  if extract_mp3:
@@ -346,10 +317,6 @@ def _download_with_format_stream(
346
317
 
347
318
  argv.append(url)
348
319
 
349
- tail_lines: Deque[str] = deque(maxlen=_MAX_ERR_LINES)
350
- # Keep only a bounded capture for path extraction.
351
- cap_lines: Deque[str] = deque(maxlen=2000)
352
-
353
320
  proc = subprocess.Popen(
354
321
  argv,
355
322
  stdout=subprocess.PIPE,
@@ -359,55 +326,65 @@ def _download_with_format_stream(
359
326
  universal_newlines=True,
360
327
  )
361
328
 
329
+ assert proc.stdout is not None
330
+
331
+ tail_buf: Deque[str] = deque(maxlen=_MAX_ERR_LINES)
332
+ final_path: Optional[str] = None
333
+
362
334
  try:
363
- assert proc.stdout is not None
364
335
  for raw in iter(proc.stdout.readline, ""):
365
336
  line = (raw or "").rstrip("\n")
366
337
  if not line:
367
338
  continue
368
339
 
369
- tail_lines.append(line)
370
- cap_lines.append(line)
371
- yield line
340
+ tail_buf.append(line)
372
341
 
373
- proc.stdout.close()
374
- rc = proc.wait()
375
-
376
- out_joined = "\n".join(cap_lines)
377
- path = _extract_final_path(out_joined, out_dir)
342
+ # Capture after_move:filepath lines (absolute paths under out_dir)
343
+ cand = line.strip().strip("'\"")
344
+ try:
345
+ if os.path.isabs(cand):
346
+ cand_abs = os.path.abspath(cand)
347
+ if os.path.commonpath([out_dir, cand_abs]) == out_dir:
348
+ final_path = cand_abs
349
+ except Exception:
350
+ pass
378
351
 
379
- if path and os.path.exists(path):
380
- return os.path.abspath(path)
352
+ yield line
381
353
 
382
- # fallback: newest non-temp file in out_dir
354
+ finally:
383
355
  try:
384
- best_path = None
385
- best_mtime = -1.0
386
- for name in os.listdir(out_dir):
387
- if name.endswith((".part", ".ytdl", ".tmp")):
388
- continue
389
- full = os.path.join(out_dir, name)
390
- if not os.path.isfile(full):
391
- continue
392
- mt = os.path.getmtime(full)
393
- if mt > best_mtime:
394
- best_mtime = mt
395
- best_path = full
396
- if best_path and os.path.exists(best_path):
397
- return os.path.abspath(best_path)
356
+ proc.stdout.close()
398
357
  except Exception:
399
358
  pass
400
359
 
401
- if rc != 0:
402
- raise RuntimeError(f"yt-dlp failed (format: {fmt})\n{_tail_deque(tail_lines)}")
403
- raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{_tail_deque(tail_lines)}")
360
+ rc = proc.wait()
404
361
 
405
- finally:
406
- try:
407
- if proc.poll() is None:
408
- proc.kill()
409
- except Exception:
410
- pass
362
+ if rc != 0:
363
+ raise RuntimeError(f"yt-dlp failed (format: {fmt})\n{_tail('\n'.join(tail_buf))}")
364
+
365
+ if final_path and os.path.exists(final_path):
366
+ return os.path.abspath(final_path)
367
+
368
+ # Fallback: newest non-temp file in out_dir
369
+ fallback = _extract_final_path("\n".join(tail_buf), out_dir)
370
+ if fallback and os.path.exists(fallback):
371
+ return os.path.abspath(fallback)
372
+
373
+ raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{_tail('\n'.join(tail_buf))}")
374
+
375
+
376
+ def _fmt_mp4_apple_safe(cap: int) -> str:
377
+ # Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
378
+ return (
379
+ f"bv*[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)']"
380
+ f"+ba[ext=m4a][acodec~='^mp4a']"
381
+ f"/b[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)'][acodec~='^mp4a']"
382
+ )
383
+
384
+
385
+ def _fmt_best(cap: int) -> str:
386
+ # Best overall up to cap (can yield webm/mkv/etc).
387
+ return f"bv*[height<={cap}]+ba/b[height<={cap}]"
411
388
 
412
389
 
413
390
  # =========================
@@ -488,8 +465,8 @@ def download_video_stream(
488
465
  out_dir: str = DEFAULT_OUT_DIR,
489
466
  ) -> Generator[str, None, str]:
490
467
  """
491
- Streaming variant for SSE usage.
492
- Yields raw yt-dlp stdout lines; returns final file path as StopIteration.value.
468
+ Stream raw yt-dlp output lines and return the final file path.
469
+ Mirrors download_video(), but line-streaming instead of capture.
493
470
  """
494
471
  if not url:
495
472
  raise RuntimeError("Missing URL")
@@ -508,42 +485,52 @@ def download_video_stream(
508
485
  mode = (extension or "mp4").lower().strip()
509
486
 
510
487
  if mode == "mp3":
511
- return (yield from _download_with_format_stream(
488
+ g = _download_with_format_stream(
512
489
  url=url,
513
490
  out_dir=out_dir,
514
491
  fmt="bestaudio",
515
492
  merge_output_format=None,
516
493
  extract_mp3=True,
517
- ))
518
-
519
- cap = int(resolution or 1080)
494
+ )
520
495
 
521
- if mode == "best":
522
- try:
523
- return (yield from _download_with_format_stream(
524
- url=url,
525
- out_dir=out_dir,
526
- fmt=_fmt_best(cap),
527
- merge_output_format=None,
528
- extract_mp3=False,
529
- ))
530
- except Exception:
531
- return (yield from _download_with_format_stream(
496
+ else:
497
+ cap = int(resolution or 1080)
498
+
499
+ if mode == "best":
500
+ # Try best; on failure fall back to Apple-safe mp4 (same logic as download_video)
501
+ try:
502
+ g = _download_with_format_stream(
503
+ url=url,
504
+ out_dir=out_dir,
505
+ fmt=_fmt_best(cap),
506
+ merge_output_format=None,
507
+ extract_mp3=False,
508
+ )
509
+ except Exception:
510
+ g = _download_with_format_stream(
511
+ url=url,
512
+ out_dir=out_dir,
513
+ fmt=_fmt_mp4_apple_safe(cap),
514
+ merge_output_format="mp4",
515
+ extract_mp3=False,
516
+ )
517
+ else:
518
+ g = _download_with_format_stream(
532
519
  url=url,
533
520
  out_dir=out_dir,
534
521
  fmt=_fmt_mp4_apple_safe(cap),
535
522
  merge_output_format="mp4",
536
523
  extract_mp3=False,
537
- ))
524
+ )
538
525
 
539
- return (yield from _download_with_format_stream(
540
- url=url,
541
- out_dir=out_dir,
542
- fmt=_fmt_mp4_apple_safe(cap),
543
- merge_output_format="mp4",
544
- extract_mp3=False,
545
- ))
526
+ # Forward all lines; return final path via StopIteration.value
527
+ while True:
528
+ try:
529
+ line = next(g)
530
+ except StopIteration as si:
531
+ return si.value # final path
532
+ yield line
546
533
 
547
534
  finally:
548
535
  if _mullvad_present():
549
- _run_argv(["mullvad", "disconnect"], check=False)
536
+ _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.7.0",
11
+ version="0.7.1",
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.7.0
3
+ Version: 0.7.1
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.7.0 yt-dlp[default]
60
+ pip install ytp-dl==0.7.1 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.7.0` + `yt-dlp[default]` + `gunicorn`
245
+ * Installs `ytp-dl==0.7.1` + `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.7.0 + yt-dlp[default] + gunicorn in that venv
261
+ # - Installs ytp-dl==0.7.1 + 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.7.0" "yt-dlp[default]" gunicorn
397
+ pip install "ytp-dl==0.7.1" "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 re
7
- import shutil
8
- import tempfile
9
- import time
10
- from threading import BoundedSemaphore, Lock
11
-
12
- from flask import Flask, Response, jsonify, request, send_file, stream_with_context
13
-
14
- from .downloader import (
15
- download_video,
16
- download_video_stream,
17
- validate_environment,
18
- )
19
-
20
- app = Flask(__name__)
21
-
22
- BASE_DOWNLOAD_DIR = os.environ.get("YTPDL_JOB_BASE_DIR", "/root/ytpdl_jobs")
23
- os.makedirs(BASE_DOWNLOAD_DIR, exist_ok=True)
24
-
25
- MAX_CONCURRENT = int(os.environ.get("YTPDL_MAX_CONCURRENT", "1"))
26
-
27
- # Thread-safe concurrency gate (caps actual download jobs).
28
- _sem = BoundedSemaphore(MAX_CONCURRENT)
29
-
30
- # Track in-flight jobs for /healthz reporting.
31
- _in_use = 0
32
- _in_use_lock = Lock()
33
-
34
- # Failsafe: delete abandoned job dirs older than this many seconds.
35
- STALE_JOB_TTL_S = int(os.environ.get("YTPDL_STALE_JOB_TTL_S", "3600"))
36
-
37
- _ALLOWED_EXTENSIONS = {"mp3", "mp4", "best"}
38
-
39
- _JOB_ID_RX = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
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 _safe_job_id(raw: str | None) -> str:
60
- s = (raw or "").strip()
61
- if _JOB_ID_RX.match(s):
62
- return s
63
- # fallback to timestamp-based id (stable & filesystem-safe)
64
- return str(int(time.time() * 1000))
65
-
66
-
67
- def _try_acquire_job_slot() -> bool:
68
- global _in_use
69
- if not _sem.acquire(blocking=False):
70
- return False
71
- with _in_use_lock:
72
- _in_use += 1
73
- return True
74
-
75
-
76
- def _release_job_slot() -> None:
77
- global _in_use
78
- with _in_use_lock:
79
- if _in_use > 0:
80
- _in_use -= 1
81
- _sem.release()
82
-
83
-
84
- @app.route("/api/download", methods=["POST"])
85
- def handle_download():
86
- """
87
- Backwards-compatible direct download endpoint:
88
- - runs yt-dlp
89
- - returns the file body directly
90
- """
91
- _cleanup_stale_jobs()
92
-
93
- if not _try_acquire_job_slot():
94
- return jsonify(error="Server busy, try again later"), 503
95
-
96
- job_dir: str | None = None
97
- released = False
98
-
99
- def _release_once() -> None:
100
- nonlocal released
101
- if not released:
102
- released = True
103
- _release_job_slot()
104
-
105
- try:
106
- data = request.get_json(force=True)
107
- url = (data.get("url") or "").strip()
108
- resolution = data.get("resolution")
109
-
110
- # extension is now a "mode": mp3 | mp4 | best
111
- extension = (data.get("extension") or "mp4").strip().lower()
112
-
113
- if not url:
114
- _release_once()
115
- return jsonify(error="Missing 'url'"), 400
116
-
117
- if extension not in _ALLOWED_EXTENSIONS:
118
- _release_once()
119
- return jsonify(
120
- error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"
121
- ), 400
122
-
123
- job_dir = tempfile.mkdtemp(prefix="ytpdl_", dir=BASE_DOWNLOAD_DIR)
124
-
125
- # yt-dlp work (guarded by semaphore)
126
- filename = download_video(
127
- url=url,
128
- resolution=resolution,
129
- extension=extension,
130
- out_dir=job_dir,
131
- )
132
-
133
- if not (filename and os.path.exists(filename)):
134
- raise RuntimeError("Download failed")
135
-
136
- # Release slot as soon as yt-dlp is done.
137
- _release_once()
138
-
139
- response = send_file(filename, as_attachment=True)
140
-
141
- # Cleanup directory after client finishes consuming the response.
142
- def _cleanup() -> None:
143
- try:
144
- if job_dir:
145
- shutil.rmtree(job_dir, ignore_errors=True)
146
- except Exception:
147
- pass
148
-
149
- response.call_on_close(_cleanup)
150
- return response
151
-
152
- except RuntimeError as e:
153
- if job_dir:
154
- shutil.rmtree(job_dir, ignore_errors=True)
155
- _release_once()
156
-
157
- msg = str(e)
158
- if "Mullvad not logged in" in msg:
159
- return jsonify(error=msg), 503
160
- return jsonify(error=f"Download failed: {msg}"), 500
161
-
162
- except Exception as e:
163
- if job_dir:
164
- shutil.rmtree(job_dir, ignore_errors=True)
165
- _release_once()
166
- return jsonify(error=f"Download failed: {str(e)}"), 500
167
-
168
-
169
- @app.route("/api/download_sse", methods=["POST"])
170
- def handle_download_sse():
171
- """
172
- New SSE endpoint:
173
- - streams raw yt-dlp stdout lines in real-time (SSE)
174
- - stores result.json with the final file path in a stable job dir
175
- - releases concurrency slot immediately when yt-dlp completes
176
- - client fetches the final file via /api/fetch/<job_id>
177
- """
178
- _cleanup_stale_jobs()
179
-
180
- if not _try_acquire_job_slot():
181
- return jsonify(error="Server busy, try again later"), 503
182
-
183
- released = False
184
-
185
- def _release_once() -> None:
186
- nonlocal released
187
- if not released:
188
- released = True
189
- _release_job_slot()
190
-
191
- try:
192
- data = request.get_json(force=True)
193
- url = (data.get("url") or "").strip()
194
- resolution = data.get("resolution")
195
- extension = (data.get("extension") or "mp4").strip().lower()
196
-
197
- if not url:
198
- _release_once()
199
- return jsonify(error="Missing 'url'"), 400
200
-
201
- if extension not in _ALLOWED_EXTENSIONS:
202
- _release_once()
203
- return jsonify(
204
- error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"
205
- ), 400
206
-
207
- job_id = _safe_job_id(data.get("job_id"))
208
- job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
209
- shutil.rmtree(job_dir, ignore_errors=True)
210
- os.makedirs(job_dir, exist_ok=True)
211
-
212
- result_path = os.path.join(job_dir, "result.json")
213
- err_path = os.path.join(job_dir, "error.txt")
214
-
215
- def gen():
216
- file_path: str | None = None
217
- try:
218
- # optional "ready" marker
219
- yield f"data: [vps_ready] {job_id}\n\n"
220
-
221
- dl = download_video_stream(
222
- url=url,
223
- resolution=resolution,
224
- extension=extension,
225
- out_dir=job_dir,
226
- )
227
-
228
- # manually iterate so we can capture StopIteration.value (the final path)
229
- while True:
230
- try:
231
- line = next(dl)
232
- except StopIteration as si:
233
- file_path = si.value
234
- break
235
-
236
- if line:
237
- yield f"data: {line}\n\n"
238
-
239
- if not (file_path and os.path.exists(file_path)):
240
- raise RuntimeError("Download completed but output file not found")
241
-
242
- # Persist result for /api/fetch/<job_id>
243
- try:
244
- with open(result_path, "w", encoding="utf-8") as f:
245
- json.dump(
246
- {
247
- "job_id": job_id,
248
- "file_path": os.path.abspath(file_path),
249
- "basename": os.path.basename(file_path),
250
- "created_ts": time.time(),
251
- },
252
- f,
253
- ensure_ascii=False,
254
- )
255
- except Exception:
256
- pass
257
-
258
- # Release slot as soon as yt-dlp is done (do NOT hold slot for fetch transfer)
259
- _release_once()
260
-
261
- yield f"data: [vps_done] {os.path.basename(file_path)}\n\n"
262
- yield "data: [vps_end]\n\n"
263
- return
264
-
265
- except Exception as e:
266
- try:
267
- with open(err_path, "w", encoding="utf-8") as f:
268
- f.write(str(e))
269
- except Exception:
270
- pass
271
-
272
- _release_once()
273
- msg = str(e)
274
- # keep it as a single SSE line
275
- yield f"data: [vps_error] {msg}\n\n"
276
- yield "data: [vps_end]\n\n"
277
- return
278
-
279
- finally:
280
- # ensure slot is released even if client disconnects mid-stream
281
- _release_once()
282
-
283
- resp = Response(stream_with_context(gen()), content_type="text/event-stream")
284
- resp.headers["Cache-Control"] = "no-cache"
285
- resp.headers["X-Accel-Buffering"] = "no"
286
- return resp
287
-
288
- except Exception as e:
289
- _release_once()
290
- return jsonify(error=f"Download failed: {str(e)}"), 500
291
-
292
-
293
- @app.route("/api/fetch/<job_id>", methods=["GET"])
294
- def fetch_job_file(job_id: str):
295
- """
296
- Fetch the finished file for a job_id created by /api/download_sse.
297
- Cleans up the job dir after response is fully consumed.
298
- """
299
- job_id = _safe_job_id(job_id)
300
- job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
301
- result_path = os.path.join(job_dir, "result.json")
302
-
303
- if not os.path.exists(result_path):
304
- return jsonify(error="Job not found or not complete"), 404
305
-
306
- try:
307
- with open(result_path, "r", encoding="utf-8") as f:
308
- meta = json.load(f)
309
- file_path = meta.get("file_path") or ""
310
- except Exception:
311
- return jsonify(error="Job metadata unreadable"), 500
312
-
313
- file_path = os.path.abspath(file_path)
314
- if not file_path.startswith(os.path.abspath(job_dir) + os.sep):
315
- return jsonify(error="Invalid job file path"), 500
316
-
317
- if not os.path.exists(file_path):
318
- return jsonify(error="Job file missing"), 404
319
-
320
- response = send_file(file_path, as_attachment=True)
321
-
322
- def _cleanup() -> None:
323
- try:
324
- shutil.rmtree(job_dir, ignore_errors=True)
325
- except Exception:
326
- pass
327
-
328
- response.call_on_close(_cleanup)
329
- return response
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()
File without changes
File without changes