ytp-dl 0.6.8__tar.gz → 0.7.0__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.
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/PKG-INFO +5 -5
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/README.md +4 -4
- ytp_dl-0.7.0/scripts/api.py +346 -0
- ytp_dl-0.7.0/scripts/downloader.py +549 -0
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/setup.py +1 -1
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/ytp_dl.egg-info/PKG-INFO +5 -5
- ytp_dl-0.6.8/scripts/api.py +0 -286
- ytp_dl-0.6.8/scripts/downloader.py +0 -369
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/scripts/__init__.py +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/setup.cfg +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/ytp_dl.egg-info/SOURCES.txt +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/ytp_dl.egg-info/dependency_links.txt +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/ytp_dl.egg-info/entry_points.txt +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/ytp_dl.egg-info/requires.txt +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.7.0}/ytp_dl.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ytp-dl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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.
|
|
60
|
+
pip install ytp-dl==0.7.0 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.
|
|
245
|
+
* Installs `ytp-dl==0.7.0` + `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.
|
|
261
|
+
# - Installs ytp-dl==0.7.0 + 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.
|
|
397
|
+
pip install "ytp-dl==0.7.0" "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.
|
|
27
|
+
pip install ytp-dl==0.7.0 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.
|
|
212
|
+
* Installs `ytp-dl==0.7.0` + `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.
|
|
228
|
+
# - Installs ytp-dl==0.7.0 + 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.
|
|
364
|
+
pip install "ytp-dl==0.7.0" "yt-dlp[default]" gunicorn
|
|
365
365
|
deactivate
|
|
366
366
|
|
|
367
367
|
echo "==> 3) API environment file (/etc/default/ytp-dl-api)"
|
|
@@ -0,0 +1,346 @@
|
|
|
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()
|