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.
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/PKG-INFO +5 -5
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/README.md +4 -4
- ytp_dl-0.6.8/scripts/api.py +286 -0
- ytp_dl-0.6.8/scripts/downloader.py +369 -0
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/setup.py +1 -1
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/ytp_dl.egg-info/PKG-INFO +5 -5
- ytp_dl-0.6.7/scripts/api.py +0 -346
- ytp_dl-0.6.7/scripts/downloader.py +0 -311
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/scripts/__init__.py +0 -0
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/setup.cfg +0 -0
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/ytp_dl.egg-info/SOURCES.txt +0 -0
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/ytp_dl.egg-info/dependency_links.txt +0 -0
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/ytp_dl.egg-info/entry_points.txt +0 -0
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/ytp_dl.egg-info/requires.txt +0 -0
- {ytp_dl-0.6.7 → ytp_dl-0.6.8}/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.6.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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()
|