ytp-dl 0.6.7__tar.gz → 0.6.8__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.8
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.8 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.8` + `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.8 + 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.8" "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.8 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.8` + `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.8 + 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.8" "yt-dlp[default]" gunicorn
365
365
  deactivate
366
366
 
367
367
  echo "==> 3) API environment file (/etc/default/ytp-dl-api)"
@@ -0,0 +1,286 @@
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_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 _job_meta_path(job_dir: str) -> str:
71
+ return os.path.join(job_dir, "job.json")
72
+
73
+
74
+ def _write_job_meta(job_dir: str, meta: dict) -> None:
75
+ try:
76
+ with open(_job_meta_path(job_dir), "w", encoding="utf-8") as f:
77
+ json.dump(meta, f, ensure_ascii=False)
78
+ except Exception:
79
+ pass
80
+
81
+
82
+ def _read_job_meta(job_dir: str) -> Optional[dict]:
83
+ path = _job_meta_path(job_dir)
84
+ if not os.path.exists(path):
85
+ return None
86
+ try:
87
+ with open(path, "r", encoding="utf-8") as f:
88
+ return json.load(f)
89
+ except Exception:
90
+ return None
91
+
92
+
93
+ def _sse_message(data: str) -> str:
94
+ # one "message" event
95
+ return f"data: {data}\n\n"
96
+
97
+
98
+ def _sse_event(event_name: str, data: str) -> str:
99
+ # custom event type
100
+ return f"event: {event_name}\ndata: {data}\n\n"
101
+
102
+
103
+ @app.route("/api/download", methods=["POST"])
104
+ def handle_download():
105
+ """
106
+ Streams yt-dlp logs via SSE (real-time), then emits a final custom `result` event:
107
+ event: result
108
+ data: {"job_id":"ytpdl_xxx","filename":"file.mp4"}
109
+
110
+ The finished file is retrieved separately via:
111
+ GET /api/file/<job_id>
112
+ """
113
+ _cleanup_stale_jobs()
114
+
115
+ if not _try_acquire_job_slot():
116
+ return jsonify(error="Server busy, try again later"), 503
117
+
118
+ job_dir: str | None = None
119
+ released = False
120
+
121
+ def _release_once() -> None:
122
+ nonlocal released
123
+ if not released:
124
+ released = True
125
+ _release_job_slot()
126
+
127
+ try:
128
+ data = request.get_json(force=True)
129
+ url = (data.get("url") or "").strip()
130
+ resolution = data.get("resolution")
131
+
132
+ # extension is now a "mode": mp3 | mp4 | best
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
+ job_id = os.path.basename(job_dir)
147
+
148
+ def stream():
149
+ nonlocal job_dir
150
+ try:
151
+ # ---- yt-dlp streamed logs ----
152
+ # download_video_stream yields yt-dlp stdout lines.
153
+ filename_path = yield from download_video_stream(
154
+ url=url,
155
+ resolution=resolution,
156
+ extension=extension,
157
+ out_dir=job_dir,
158
+ )
159
+
160
+ if not (filename_path and os.path.exists(filename_path)):
161
+ yield _sse_message("ERROR: Download failed (file missing).")
162
+ yield _sse_event(
163
+ "error",
164
+ json.dumps({"error": "Download failed (file missing)"}),
165
+ )
166
+ return
167
+
168
+ # Release slot as soon as yt-dlp is done.
169
+ _release_once()
170
+
171
+ out_name = os.path.basename(filename_path)
172
+
173
+ # Persist meta for /api/file/<job_id>
174
+ _write_job_meta(
175
+ job_dir,
176
+ {
177
+ "job_id": job_id,
178
+ "filename": out_name,
179
+ "file_path": filename_path,
180
+ "created_at": time.time(),
181
+ },
182
+ )
183
+
184
+ # Result event (custom type) so your browser log UI won't display it
185
+ yield _sse_event(
186
+ "result",
187
+ json.dumps({"job_id": job_id, "filename": out_name}),
188
+ )
189
+
190
+ # keep-alive tail
191
+ yield _sse_message("All downloads complete.")
192
+ return
193
+
194
+ except RuntimeError as e:
195
+ if job_dir:
196
+ shutil.rmtree(job_dir, ignore_errors=True)
197
+ _release_once()
198
+
199
+ msg = str(e)
200
+ # make it visible in logs
201
+ yield _sse_message(f"ERROR: {msg}")
202
+ # also machine-readable
203
+ code = 503 if "Mullvad not logged in" in msg else 500
204
+ yield _sse_event("error", json.dumps({"error": msg, "code": code}))
205
+ return
206
+
207
+ except GeneratorExit:
208
+ # Client disconnected mid-stream; best-effort cleanup.
209
+ if job_dir:
210
+ shutil.rmtree(job_dir, ignore_errors=True)
211
+ _release_once()
212
+ raise
213
+
214
+ except Exception as e:
215
+ if job_dir:
216
+ shutil.rmtree(job_dir, ignore_errors=True)
217
+ _release_once()
218
+
219
+ msg = f"Download failed: {str(e)}"
220
+ yield _sse_message(f"ERROR: {msg}")
221
+ yield _sse_event("error", json.dumps({"error": msg, "code": 500}))
222
+ return
223
+
224
+ resp = Response(stream_with_context(stream()), content_type="text/event-stream")
225
+ resp.headers["Cache-Control"] = "no-cache"
226
+ resp.headers["X-Accel-Buffering"] = "no"
227
+ return resp
228
+
229
+ except Exception as e:
230
+ if job_dir:
231
+ shutil.rmtree(job_dir, ignore_errors=True)
232
+ _release_once()
233
+ return jsonify(error=f"Download failed: {str(e)}"), 500
234
+
235
+
236
+ @app.route("/api/file/<job_id>", methods=["GET"])
237
+ def fetch_file(job_id: str):
238
+ """
239
+ After /api/download SSE completes with `event: result`,
240
+ the caller fetches the finished file here.
241
+ """
242
+ _cleanup_stale_jobs()
243
+
244
+ job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
245
+ if not os.path.isdir(job_dir):
246
+ return jsonify(error="Job not found"), 404
247
+
248
+ meta = _read_job_meta(job_dir)
249
+ if not meta:
250
+ return jsonify(error="Job metadata missing"), 404
251
+
252
+ file_path = meta.get("file_path")
253
+ filename = meta.get("filename") or (os.path.basename(file_path) if file_path else None)
254
+
255
+ if not file_path or not os.path.exists(file_path):
256
+ shutil.rmtree(job_dir, ignore_errors=True)
257
+ return jsonify(error="File not found"), 404
258
+
259
+ response = send_file(file_path, as_attachment=True, download_name=filename)
260
+
261
+ # Cleanup directory after client finishes consuming the response.
262
+ def _cleanup() -> None:
263
+ try:
264
+ shutil.rmtree(job_dir, ignore_errors=True)
265
+ except Exception:
266
+ pass
267
+
268
+ response.call_on_close(_cleanup)
269
+ return response
270
+
271
+
272
+ @app.route("/healthz", methods=["GET"])
273
+ def healthz():
274
+ with _in_use_lock:
275
+ in_use = _in_use
276
+ return jsonify(ok=True, in_use=in_use, capacity=MAX_CONCURRENT), 200
277
+
278
+
279
+ def main():
280
+ validate_environment()
281
+ print("Starting ytp-dl API server...")
282
+ app.run(host="0.0.0.0", port=5000)
283
+
284
+
285
+ if __name__ == "__main__":
286
+ main()