ytp-dl 0.6.9__py3-none-any.whl → 0.7.1__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.
scripts/api.py CHANGED
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env python3
2
2
  from __future__ import annotations
3
3
 
4
+ import json
4
5
  import os
5
6
  import shutil
6
7
  import tempfile
7
8
  import time
8
9
  from threading import BoundedSemaphore, Lock
10
+ from typing import Optional
9
11
 
10
- from flask import Flask, request, send_file, jsonify
12
+ from flask import Flask, request, send_file, jsonify, Response, stream_with_context
11
13
 
12
- from .downloader import validate_environment, download_video
14
+ from .downloader import validate_environment, download_video, download_video_stream
13
15
 
14
16
  app = Flask(__name__)
15
17
 
@@ -65,8 +67,51 @@ def _release_job_slot() -> None:
65
67
  _sem.release()
66
68
 
67
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
+
68
108
  @app.route("/api/download", methods=["POST"])
69
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
+ """
70
115
  _cleanup_stale_jobs()
71
116
 
72
117
  if not _try_acquire_job_slot():
@@ -85,8 +130,6 @@ def handle_download():
85
130
  data = request.get_json(force=True)
86
131
  url = (data.get("url") or "").strip()
87
132
  resolution = data.get("resolution")
88
-
89
- # extension is now a "mode": mp3 | mp4 | best
90
133
  extension = (data.get("extension") or "mp4").strip().lower()
91
134
 
92
135
  if not url:
@@ -145,6 +188,129 @@ def handle_download():
145
188
  return jsonify(error=f"Download failed: {str(e)}"), 500
146
189
 
147
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
+
148
314
  @app.route("/healthz", methods=["GET"])
149
315
  def healthz():
150
316
  with _in_use_lock:
@@ -159,4 +325,4 @@ def main():
159
325
 
160
326
 
161
327
  if __name__ == "__main__":
162
- main()
328
+ main()
scripts/downloader.py CHANGED
@@ -6,7 +6,8 @@ import shlex
6
6
  import shutil
7
7
  import subprocess
8
8
  import time
9
- from typing import Optional, List, Tuple
9
+ from collections import deque
10
+ from typing import Optional, List, Tuple, Deque, Generator
10
11
 
11
12
  # =========================
12
13
  # Config / constants
@@ -278,6 +279,100 @@ def _download_with_format(
278
279
  raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{tail}")
279
280
 
280
281
 
282
+ def _download_with_format_stream(
283
+ url: str,
284
+ out_dir: str,
285
+ fmt: str,
286
+ merge_output_format: Optional[str] = None,
287
+ extract_mp3: bool = False,
288
+ ) -> Generator[str, None, str]:
289
+ """
290
+ Stream yt-dlp output line-by-line (stdout+stderr merged),
291
+ and return the final output path via StopIteration.value.
292
+ """
293
+ out_dir = os.path.abspath(out_dir)
294
+ os.makedirs(out_dir, exist_ok=True)
295
+
296
+ out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
297
+
298
+ argv = [
299
+ YTDLP_BIN,
300
+ "-f", fmt,
301
+ *(_common_flags()),
302
+ "--output", out_tpl,
303
+
304
+ # Make progress lines flush as newline-terminated output
305
+ "--progress",
306
+ "--newline",
307
+
308
+ # Ensure we can reliably obtain the post-processed final path
309
+ "--print", "after_move:filepath",
310
+ ]
311
+
312
+ if extract_mp3:
313
+ argv.extend(["--extract-audio", "--audio-format", "mp3"])
314
+
315
+ if merge_output_format:
316
+ argv.extend(["--merge-output-format", merge_output_format])
317
+
318
+ argv.append(url)
319
+
320
+ proc = subprocess.Popen(
321
+ argv,
322
+ stdout=subprocess.PIPE,
323
+ stderr=subprocess.STDOUT,
324
+ text=True,
325
+ bufsize=1,
326
+ universal_newlines=True,
327
+ )
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
+
334
+ try:
335
+ for raw in iter(proc.stdout.readline, ""):
336
+ line = (raw or "").rstrip("\n")
337
+ if not line:
338
+ continue
339
+
340
+ tail_buf.append(line)
341
+
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
351
+
352
+ yield line
353
+
354
+ finally:
355
+ try:
356
+ proc.stdout.close()
357
+ except Exception:
358
+ pass
359
+
360
+ rc = proc.wait()
361
+
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
+
281
376
  def _fmt_mp4_apple_safe(cap: int) -> str:
282
377
  # Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
283
378
  return (
@@ -361,3 +456,81 @@ def download_video(
361
456
  finally:
362
457
  if _mullvad_present():
363
458
  _run_argv(["mullvad", "disconnect"], check=False)
459
+
460
+
461
+ def download_video_stream(
462
+ url: str,
463
+ resolution: int | None = 1080,
464
+ extension: Optional[str] = None,
465
+ out_dir: str = DEFAULT_OUT_DIR,
466
+ ) -> Generator[str, None, str]:
467
+ """
468
+ Stream raw yt-dlp output lines and return the final file path.
469
+ Mirrors download_video(), but line-streaming instead of capture.
470
+ """
471
+ if not url:
472
+ raise RuntimeError("Missing URL")
473
+
474
+ out_dir = os.path.abspath(out_dir)
475
+ os.makedirs(out_dir, exist_ok=True)
476
+
477
+ validate_environment()
478
+
479
+ require_mullvad_login()
480
+ mullvad_connect(MULLVAD_LOCATION)
481
+ if not mullvad_wait_connected():
482
+ raise RuntimeError("Mullvad connection failed")
483
+
484
+ try:
485
+ mode = (extension or "mp4").lower().strip()
486
+
487
+ if mode == "mp3":
488
+ g = _download_with_format_stream(
489
+ url=url,
490
+ out_dir=out_dir,
491
+ fmt="bestaudio",
492
+ merge_output_format=None,
493
+ extract_mp3=True,
494
+ )
495
+
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(
519
+ url=url,
520
+ out_dir=out_dir,
521
+ fmt=_fmt_mp4_apple_safe(cap),
522
+ merge_output_format="mp4",
523
+ extract_mp3=False,
524
+ )
525
+
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
533
+
534
+ finally:
535
+ if _mullvad_present():
536
+ _run_argv(["mullvad", "disconnect"], check=False)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ytp-dl
3
- Version: 0.6.9
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.6.9 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.6.9` + `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.6.9 + 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.6.9" "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)"
@@ -0,0 +1,8 @@
1
+ scripts/__init__.py,sha256=EbAplfCcyLD3Q_9sxemm6owCc5_UJv53vmlxy810p2s,152
2
+ scripts/api.py,sha256=dqVcS1niL2ziv3w3WipiDH8T0peNlSzRCBfm1xxR4N0,9562
3
+ scripts/downloader.py,sha256=Fo8srnATM9Eee0yWvGsr5LKp6Zg8KTpsW7TFk24YZlg,15916
4
+ ytp_dl-0.7.1.dist-info/METADATA,sha256=8WsJ-TM4orFucsTAEck6S95dAuh5P2eRLVpurKlFhDk,14547
5
+ ytp_dl-0.7.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
+ ytp_dl-0.7.1.dist-info/entry_points.txt,sha256=QqjqZZAEt3Y7RGrleqZ312sjjboUpbMLdo7qFxuCH30,48
7
+ ytp_dl-0.7.1.dist-info/top_level.txt,sha256=rmzd5mewlrJy4sT608KPib7sM7edoY75AeqJeY3SPB4,8
8
+ ytp_dl-0.7.1.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- scripts/__init__.py,sha256=EbAplfCcyLD3Q_9sxemm6owCc5_UJv53vmlxy810p2s,152
2
- scripts/api.py,sha256=cyLjHmelLwzh8-GOjqXsQdhm6wLX8bOkADZ_qU1naRQ,4331
3
- scripts/downloader.py,sha256=vvHasu-41DGPDUzOTA4kz52tijTkaii1NnuU4cHQxg8,10825
4
- ytp_dl-0.6.9.dist-info/METADATA,sha256=Z4H5XVsQIQgU3hFXdF-Lp2Yf6-sSzTjwpHAptrGeMYg,14547
5
- ytp_dl-0.6.9.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
- ytp_dl-0.6.9.dist-info/entry_points.txt,sha256=QqjqZZAEt3Y7RGrleqZ312sjjboUpbMLdo7qFxuCH30,48
7
- ytp_dl-0.6.9.dist-info/top_level.txt,sha256=rmzd5mewlrJy4sT608KPib7sM7edoY75AeqJeY3SPB4,8
8
- ytp_dl-0.6.9.dist-info/RECORD,,
File without changes