ytp-dl 0.6.6__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.6 → ytp_dl-0.6.8}/PKG-INFO +5 -5
- {ytp_dl-0.6.6 → ytp_dl-0.6.8}/README.md +4 -4
- ytp_dl-0.6.8/scripts/api.py +286 -0
- {ytp_dl-0.6.6 → ytp_dl-0.6.8}/scripts/downloader.py +129 -123
- {ytp_dl-0.6.6 → ytp_dl-0.6.8}/setup.py +1 -1
- {ytp_dl-0.6.6 → ytp_dl-0.6.8}/ytp_dl.egg-info/PKG-INFO +5 -5
- ytp_dl-0.6.6/scripts/api.py +0 -162
- {ytp_dl-0.6.6 → ytp_dl-0.6.8}/scripts/__init__.py +0 -0
- {ytp_dl-0.6.6 → ytp_dl-0.6.8}/setup.cfg +0 -0
- {ytp_dl-0.6.6 → ytp_dl-0.6.8}/ytp_dl.egg-info/SOURCES.txt +0 -0
- {ytp_dl-0.6.6 → ytp_dl-0.6.8}/ytp_dl.egg-info/dependency_links.txt +0 -0
- {ytp_dl-0.6.6 → ytp_dl-0.6.8}/ytp_dl.egg-info/entry_points.txt +0 -0
- {ytp_dl-0.6.6 → ytp_dl-0.6.8}/ytp_dl.egg-info/requires.txt +0 -0
- {ytp_dl-0.6.6 → 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()
|
|
@@ -6,7 +6,7 @@ import shlex
|
|
|
6
6
|
import shutil
|
|
7
7
|
import subprocess
|
|
8
8
|
import time
|
|
9
|
-
from typing import Optional, List, Tuple
|
|
9
|
+
from typing import Optional, List, Tuple, Generator
|
|
10
10
|
|
|
11
11
|
# =========================
|
|
12
12
|
# Config / constants
|
|
@@ -60,11 +60,6 @@ def _tail(out: str) -> str:
|
|
|
60
60
|
return txt.strip()
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
def _is_youtube_url(url: str) -> bool:
|
|
64
|
-
u = (url or "").lower()
|
|
65
|
-
return any(h in u for h in ("youtube.com", "youtu.be", "youtube-nocookie.com"))
|
|
66
|
-
|
|
67
|
-
|
|
68
63
|
# =========================
|
|
69
64
|
# Environment / Mullvad
|
|
70
65
|
# =========================
|
|
@@ -135,85 +130,25 @@ def _common_flags() -> List[str]:
|
|
|
135
130
|
"--user-agent", MODERN_UA,
|
|
136
131
|
"--no-cache-dir",
|
|
137
132
|
"--ignore-config",
|
|
138
|
-
"--embed-metadata",
|
|
139
133
|
"--sleep-interval", "1",
|
|
140
134
|
]
|
|
141
135
|
|
|
142
136
|
|
|
143
|
-
def
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
3) Any Destination: lines that still exist
|
|
151
|
-
4) Newest non-temp file in out_dir
|
|
152
|
-
"""
|
|
153
|
-
candidates: List[str] = []
|
|
154
|
-
out_dir = os.path.abspath(out_dir)
|
|
155
|
-
|
|
156
|
-
for raw in (stdout or "").splitlines():
|
|
157
|
-
line = (raw or "").strip()
|
|
158
|
-
if not line:
|
|
159
|
-
continue
|
|
160
|
-
|
|
161
|
-
# 1) --print after_move:filepath (usually an absolute path)
|
|
162
|
-
if os.path.isabs(line) and line.startswith(out_dir):
|
|
163
|
-
candidates.append(line.strip("'\""))
|
|
164
|
-
continue
|
|
137
|
+
def _fmt_mp4_apple_safe(cap: int) -> str:
|
|
138
|
+
# Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
|
|
139
|
+
return (
|
|
140
|
+
f"bv*[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)']"
|
|
141
|
+
f"+ba[ext=m4a][acodec~='^mp4a']"
|
|
142
|
+
f"/b[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)'][acodec~='^mp4a']"
|
|
143
|
+
)
|
|
165
144
|
|
|
166
|
-
# 2) Merger line: ... into "path"
|
|
167
|
-
if "Merging formats into" in line and "\"" in line:
|
|
168
|
-
try:
|
|
169
|
-
merged = line.split("Merging formats into", 1)[1].strip()
|
|
170
|
-
if merged.startswith("\"") and merged.endswith("\""):
|
|
171
|
-
merged = merged[1:-1]
|
|
172
|
-
else:
|
|
173
|
-
if merged.startswith("\""):
|
|
174
|
-
merged = merged.split("\"", 2)[1]
|
|
175
|
-
if merged:
|
|
176
|
-
if not os.path.isabs(merged):
|
|
177
|
-
merged = os.path.join(out_dir, merged)
|
|
178
|
-
candidates.append(merged.strip("'\""))
|
|
179
|
-
except Exception:
|
|
180
|
-
pass
|
|
181
|
-
continue
|
|
182
145
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
p = line.split("Destination:", 1)[1].strip().strip("'\"")
|
|
187
|
-
if p and not os.path.isabs(p):
|
|
188
|
-
p = os.path.join(out_dir, p)
|
|
189
|
-
if p:
|
|
190
|
-
candidates.append(p)
|
|
191
|
-
except Exception:
|
|
192
|
-
pass
|
|
193
|
-
continue
|
|
194
|
-
|
|
195
|
-
# already downloaded
|
|
196
|
-
if "] " in line and " has already been downloaded" in line:
|
|
197
|
-
try:
|
|
198
|
-
p = (
|
|
199
|
-
line.split("] ", 1)[1]
|
|
200
|
-
.split(" has already been downloaded", 1)[0]
|
|
201
|
-
.strip()
|
|
202
|
-
.strip("'\"")
|
|
203
|
-
)
|
|
204
|
-
if p and not os.path.isabs(p):
|
|
205
|
-
p = os.path.join(out_dir, p)
|
|
206
|
-
if p:
|
|
207
|
-
candidates.append(p)
|
|
208
|
-
except Exception:
|
|
209
|
-
pass
|
|
146
|
+
def _fmt_best(cap: int) -> str:
|
|
147
|
+
# Best overall up to cap (can yield webm/mkv/etc).
|
|
148
|
+
return f"bv*[height<={cap}]+ba/b[height<={cap}]"
|
|
210
149
|
|
|
211
|
-
# Prefer existing, newest candidate (reverse traversal)
|
|
212
|
-
for p in reversed(candidates):
|
|
213
|
-
if p and os.path.exists(p):
|
|
214
|
-
return p
|
|
215
150
|
|
|
216
|
-
|
|
151
|
+
def _newest_non_temp_file(out_dir: str) -> Optional[str]:
|
|
217
152
|
try:
|
|
218
153
|
best_path = None
|
|
219
154
|
best_mtime = -1.0
|
|
@@ -227,80 +162,129 @@ def _extract_final_path(stdout: str, out_dir: str) -> Optional[str]:
|
|
|
227
162
|
if mt > best_mtime:
|
|
228
163
|
best_mtime = mt
|
|
229
164
|
best_path = full
|
|
230
|
-
|
|
231
|
-
return best_path
|
|
165
|
+
return best_path
|
|
232
166
|
except Exception:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return None
|
|
167
|
+
return None
|
|
236
168
|
|
|
237
169
|
|
|
238
|
-
def
|
|
170
|
+
def _download_with_format_stream(
|
|
171
|
+
*,
|
|
239
172
|
url: str,
|
|
240
173
|
out_dir: str,
|
|
241
174
|
fmt: str,
|
|
242
175
|
merge_output_format: Optional[str] = None,
|
|
243
176
|
extract_mp3: bool = False,
|
|
244
|
-
) -> str:
|
|
177
|
+
) -> Generator[str, None, str]:
|
|
178
|
+
"""
|
|
179
|
+
Stream yt-dlp stdout lines (same style as local: --progress --newline),
|
|
180
|
+
while capturing final output path reliably.
|
|
181
|
+
|
|
182
|
+
Returns absolute file path (generator return value).
|
|
183
|
+
"""
|
|
245
184
|
out_dir = os.path.abspath(out_dir)
|
|
246
185
|
os.makedirs(out_dir, exist_ok=True)
|
|
247
186
|
|
|
248
187
|
out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
|
|
249
188
|
|
|
250
|
-
argv = [
|
|
189
|
+
argv: List[str] = [
|
|
251
190
|
YTDLP_BIN,
|
|
191
|
+
url,
|
|
192
|
+
"--progress",
|
|
193
|
+
"--newline",
|
|
194
|
+
"--continue",
|
|
252
195
|
"-f", fmt,
|
|
253
196
|
*(_common_flags()),
|
|
254
197
|
"--output", out_tpl,
|
|
255
|
-
#
|
|
198
|
+
# Absolute final path for internal capture (we do NOT emit this line).
|
|
256
199
|
"--print", "after_move:filepath",
|
|
200
|
+
# Local parity signal:
|
|
201
|
+
"--print", "after_move:[download_complete] %(title)s.%(ext)s",
|
|
257
202
|
]
|
|
258
203
|
|
|
259
204
|
if extract_mp3:
|
|
260
|
-
|
|
261
|
-
|
|
205
|
+
argv.extend(
|
|
206
|
+
[
|
|
207
|
+
"--extract-audio",
|
|
208
|
+
"--audio-format", "mp3",
|
|
209
|
+
"--audio-quality", "0",
|
|
210
|
+
"--embed-thumbnail",
|
|
211
|
+
"--add-metadata",
|
|
212
|
+
]
|
|
213
|
+
)
|
|
262
214
|
|
|
263
|
-
# Only force merge container when we actually want MP4 output.
|
|
264
215
|
if merge_output_format:
|
|
265
216
|
argv.extend(["--merge-output-format", merge_output_format])
|
|
266
217
|
|
|
267
|
-
|
|
218
|
+
proc = subprocess.Popen(
|
|
219
|
+
argv,
|
|
220
|
+
stdout=subprocess.PIPE,
|
|
221
|
+
stderr=subprocess.STDOUT,
|
|
222
|
+
text=True,
|
|
223
|
+
bufsize=1,
|
|
224
|
+
universal_newlines=True,
|
|
225
|
+
)
|
|
268
226
|
|
|
269
|
-
|
|
270
|
-
|
|
227
|
+
final_path: Optional[str] = None
|
|
228
|
+
tail_lines: List[str] = []
|
|
271
229
|
|
|
272
|
-
|
|
273
|
-
|
|
230
|
+
try:
|
|
231
|
+
assert proc.stdout is not None
|
|
232
|
+
for raw in iter(proc.stdout.readline, ""):
|
|
233
|
+
line = (raw or "").rstrip("\n").rstrip("\r")
|
|
234
|
+
if not line:
|
|
235
|
+
continue
|
|
274
236
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
237
|
+
# Capture absolute final path from after_move:filepath (do not emit to logs)
|
|
238
|
+
if os.path.isabs(line) and line.startswith(out_dir):
|
|
239
|
+
final_path = line.strip("'\"")
|
|
240
|
+
continue
|
|
279
241
|
|
|
242
|
+
# Keep a small tail buffer for error reporting
|
|
243
|
+
tail_lines.append(line)
|
|
244
|
+
if len(tail_lines) > _MAX_ERR_LINES:
|
|
245
|
+
tail_lines.pop(0)
|
|
280
246
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
return (
|
|
284
|
-
f"bv*[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)']"
|
|
285
|
-
f"+ba[ext=m4a][acodec~='^mp4a']"
|
|
286
|
-
f"/b[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)'][acodec~='^mp4a']"
|
|
287
|
-
)
|
|
247
|
+
# Emit everything else (yt-dlp progress + [download_complete] line)
|
|
248
|
+
yield line
|
|
288
249
|
|
|
250
|
+
proc.wait()
|
|
251
|
+
finally:
|
|
252
|
+
try:
|
|
253
|
+
if proc.stdout:
|
|
254
|
+
proc.stdout.close()
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
if proc.returncode != 0:
|
|
259
|
+
tail = "\n".join(tail_lines)
|
|
260
|
+
tail = _tail(tail)
|
|
261
|
+
raise RuntimeError(f"yt-dlp failed (format: {fmt})\n{tail}")
|
|
289
262
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
263
|
+
# Resolve final path
|
|
264
|
+
if final_path and os.path.exists(final_path):
|
|
265
|
+
return os.path.abspath(final_path)
|
|
266
|
+
|
|
267
|
+
# Fallback: newest output in out_dir
|
|
268
|
+
newest = _newest_non_temp_file(out_dir)
|
|
269
|
+
if newest and os.path.exists(newest):
|
|
270
|
+
return os.path.abspath(newest)
|
|
271
|
+
|
|
272
|
+
tail = _tail("\n".join(tail_lines))
|
|
273
|
+
raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{tail}")
|
|
293
274
|
|
|
294
275
|
|
|
295
276
|
# =========================
|
|
296
|
-
# Public
|
|
277
|
+
# Public APIs
|
|
297
278
|
# =========================
|
|
298
|
-
def
|
|
279
|
+
def download_video_stream(
|
|
299
280
|
url: str,
|
|
300
281
|
resolution: int | None = 1080,
|
|
301
282
|
extension: Optional[str] = None,
|
|
302
283
|
out_dir: str = DEFAULT_OUT_DIR,
|
|
303
|
-
) -> str:
|
|
284
|
+
) -> Generator[str, None, str]:
|
|
285
|
+
"""
|
|
286
|
+
Streams yt-dlp logs and returns final file path (generator return value).
|
|
287
|
+
"""
|
|
304
288
|
if not url:
|
|
305
289
|
raise RuntimeError("Missing URL")
|
|
306
290
|
|
|
@@ -318,46 +302,68 @@ def download_video(
|
|
|
318
302
|
mode = (extension or "mp4").lower().strip()
|
|
319
303
|
|
|
320
304
|
if mode == "mp3":
|
|
321
|
-
|
|
322
|
-
return _download_with_format(
|
|
305
|
+
return (yield from _download_with_format_stream(
|
|
323
306
|
url=url,
|
|
324
307
|
out_dir=out_dir,
|
|
325
308
|
fmt="bestaudio",
|
|
326
309
|
merge_output_format=None,
|
|
327
310
|
extract_mp3=True,
|
|
328
|
-
)
|
|
311
|
+
))
|
|
329
312
|
|
|
330
313
|
cap = int(resolution or 1080)
|
|
331
314
|
|
|
332
315
|
if mode == "best":
|
|
333
|
-
# Try best first (may produce webm/mkv/etc).
|
|
316
|
+
# Try best first (may produce webm/mkv/etc). If it fails, fall back to Apple-safe MP4.
|
|
334
317
|
try:
|
|
335
|
-
return
|
|
318
|
+
return (yield from _download_with_format_stream(
|
|
336
319
|
url=url,
|
|
337
320
|
out_dir=out_dir,
|
|
338
321
|
fmt=_fmt_best(cap),
|
|
339
322
|
merge_output_format=None,
|
|
340
323
|
extract_mp3=False,
|
|
341
|
-
)
|
|
324
|
+
))
|
|
342
325
|
except Exception:
|
|
343
|
-
|
|
344
|
-
return _download_with_format(
|
|
326
|
+
return (yield from _download_with_format_stream(
|
|
345
327
|
url=url,
|
|
346
328
|
out_dir=out_dir,
|
|
347
329
|
fmt=_fmt_mp4_apple_safe(cap),
|
|
348
330
|
merge_output_format="mp4",
|
|
349
331
|
extract_mp3=False,
|
|
350
|
-
)
|
|
332
|
+
))
|
|
351
333
|
|
|
352
|
-
# Default / "mp4" mode
|
|
353
|
-
return
|
|
334
|
+
# Default / "mp4" mode
|
|
335
|
+
return (yield from _download_with_format_stream(
|
|
354
336
|
url=url,
|
|
355
337
|
out_dir=out_dir,
|
|
356
338
|
fmt=_fmt_mp4_apple_safe(cap),
|
|
357
339
|
merge_output_format="mp4",
|
|
358
340
|
extract_mp3=False,
|
|
359
|
-
)
|
|
341
|
+
))
|
|
360
342
|
|
|
361
343
|
finally:
|
|
362
344
|
if _mullvad_present():
|
|
363
345
|
_run_argv(["mullvad", "disconnect"], check=False)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def download_video(
|
|
349
|
+
url: str,
|
|
350
|
+
resolution: int | None = 1080,
|
|
351
|
+
extension: Optional[str] = None,
|
|
352
|
+
out_dir: str = DEFAULT_OUT_DIR,
|
|
353
|
+
) -> str:
|
|
354
|
+
"""
|
|
355
|
+
Backward-compatible non-streaming wrapper.
|
|
356
|
+
"""
|
|
357
|
+
gen = download_video_stream(
|
|
358
|
+
url=url,
|
|
359
|
+
resolution=resolution,
|
|
360
|
+
extension=extension,
|
|
361
|
+
out_dir=out_dir,
|
|
362
|
+
)
|
|
363
|
+
try:
|
|
364
|
+
for _ in gen:
|
|
365
|
+
pass
|
|
366
|
+
except StopIteration as si:
|
|
367
|
+
return si.value # type: ignore[attr-defined]
|
|
368
|
+
# Should never happen
|
|
369
|
+
raise RuntimeError("Download failed (no result)")
|
|
@@ -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.6.
|
|
11
|
+
version="0.6.8",
|
|
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.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)"
|
ytp_dl-0.6.6/scripts/api.py
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import os
|
|
5
|
-
import shutil
|
|
6
|
-
import tempfile
|
|
7
|
-
import time
|
|
8
|
-
from threading import BoundedSemaphore, Lock
|
|
9
|
-
|
|
10
|
-
from flask import Flask, request, send_file, jsonify
|
|
11
|
-
|
|
12
|
-
from .downloader import validate_environment, download_video
|
|
13
|
-
|
|
14
|
-
app = Flask(__name__)
|
|
15
|
-
|
|
16
|
-
BASE_DOWNLOAD_DIR = os.environ.get("YTPDL_JOB_BASE_DIR", "/root/ytpdl_jobs")
|
|
17
|
-
os.makedirs(BASE_DOWNLOAD_DIR, exist_ok=True)
|
|
18
|
-
|
|
19
|
-
MAX_CONCURRENT = int(os.environ.get("YTPDL_MAX_CONCURRENT", "1"))
|
|
20
|
-
|
|
21
|
-
# Thread-safe concurrency gate (caps actual download jobs).
|
|
22
|
-
_sem = BoundedSemaphore(MAX_CONCURRENT)
|
|
23
|
-
|
|
24
|
-
# Track in-flight jobs for /healthz reporting.
|
|
25
|
-
_in_use = 0
|
|
26
|
-
_in_use_lock = Lock()
|
|
27
|
-
|
|
28
|
-
# Failsafe: delete abandoned job dirs older than this many seconds.
|
|
29
|
-
STALE_JOB_TTL_S = int(os.environ.get("YTPDL_STALE_JOB_TTL_S", "3600"))
|
|
30
|
-
|
|
31
|
-
_ALLOWED_EXTENSIONS = {"mp3", "mp4", "best"}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _cleanup_stale_jobs() -> None:
|
|
35
|
-
now = time.time()
|
|
36
|
-
try:
|
|
37
|
-
for name in os.listdir(BASE_DOWNLOAD_DIR):
|
|
38
|
-
p = os.path.join(BASE_DOWNLOAD_DIR, name)
|
|
39
|
-
if not os.path.isdir(p):
|
|
40
|
-
continue
|
|
41
|
-
try:
|
|
42
|
-
age = now - os.path.getmtime(p)
|
|
43
|
-
except Exception:
|
|
44
|
-
continue
|
|
45
|
-
if age > STALE_JOB_TTL_S:
|
|
46
|
-
shutil.rmtree(p, ignore_errors=True)
|
|
47
|
-
except Exception:
|
|
48
|
-
pass
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _try_acquire_job_slot() -> bool:
|
|
52
|
-
global _in_use
|
|
53
|
-
if not _sem.acquire(blocking=False):
|
|
54
|
-
return False
|
|
55
|
-
with _in_use_lock:
|
|
56
|
-
_in_use += 1
|
|
57
|
-
return True
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _release_job_slot() -> None:
|
|
61
|
-
global _in_use
|
|
62
|
-
with _in_use_lock:
|
|
63
|
-
if _in_use > 0:
|
|
64
|
-
_in_use -= 1
|
|
65
|
-
_sem.release()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
@app.route("/api/download", methods=["POST"])
|
|
69
|
-
def handle_download():
|
|
70
|
-
_cleanup_stale_jobs()
|
|
71
|
-
|
|
72
|
-
if not _try_acquire_job_slot():
|
|
73
|
-
return jsonify(error="Server busy, try again later"), 503
|
|
74
|
-
|
|
75
|
-
job_dir: str | None = None
|
|
76
|
-
released = False
|
|
77
|
-
|
|
78
|
-
def _release_once() -> None:
|
|
79
|
-
nonlocal released
|
|
80
|
-
if not released:
|
|
81
|
-
released = True
|
|
82
|
-
_release_job_slot()
|
|
83
|
-
|
|
84
|
-
try:
|
|
85
|
-
data = request.get_json(force=True)
|
|
86
|
-
url = (data.get("url") or "").strip()
|
|
87
|
-
resolution = data.get("resolution")
|
|
88
|
-
|
|
89
|
-
# extension is now a "mode": mp3 | mp4 | best
|
|
90
|
-
extension = (data.get("extension") or "mp4").strip().lower()
|
|
91
|
-
|
|
92
|
-
if not url:
|
|
93
|
-
_release_once()
|
|
94
|
-
return jsonify(error="Missing 'url'"), 400
|
|
95
|
-
|
|
96
|
-
if extension not in _ALLOWED_EXTENSIONS:
|
|
97
|
-
_release_once()
|
|
98
|
-
return jsonify(
|
|
99
|
-
error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"
|
|
100
|
-
), 400
|
|
101
|
-
|
|
102
|
-
job_dir = tempfile.mkdtemp(prefix="ytpdl_", dir=BASE_DOWNLOAD_DIR)
|
|
103
|
-
|
|
104
|
-
# yt-dlp work (guarded by semaphore)
|
|
105
|
-
filename = download_video(
|
|
106
|
-
url=url,
|
|
107
|
-
resolution=resolution,
|
|
108
|
-
extension=extension,
|
|
109
|
-
out_dir=job_dir,
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
if not (filename and os.path.exists(filename)):
|
|
113
|
-
raise RuntimeError("Download failed")
|
|
114
|
-
|
|
115
|
-
# Release slot as soon as yt-dlp is done.
|
|
116
|
-
_release_once()
|
|
117
|
-
|
|
118
|
-
response = send_file(filename, as_attachment=True)
|
|
119
|
-
|
|
120
|
-
# Cleanup directory after client finishes consuming the response.
|
|
121
|
-
def _cleanup() -> None:
|
|
122
|
-
try:
|
|
123
|
-
if job_dir:
|
|
124
|
-
shutil.rmtree(job_dir, ignore_errors=True)
|
|
125
|
-
except Exception:
|
|
126
|
-
pass
|
|
127
|
-
|
|
128
|
-
response.call_on_close(_cleanup)
|
|
129
|
-
return response
|
|
130
|
-
|
|
131
|
-
except RuntimeError as e:
|
|
132
|
-
if job_dir:
|
|
133
|
-
shutil.rmtree(job_dir, ignore_errors=True)
|
|
134
|
-
_release_once()
|
|
135
|
-
|
|
136
|
-
msg = str(e)
|
|
137
|
-
if "Mullvad not logged in" in msg:
|
|
138
|
-
return jsonify(error=msg), 503
|
|
139
|
-
return jsonify(error=f"Download failed: {msg}"), 500
|
|
140
|
-
|
|
141
|
-
except Exception as e:
|
|
142
|
-
if job_dir:
|
|
143
|
-
shutil.rmtree(job_dir, ignore_errors=True)
|
|
144
|
-
_release_once()
|
|
145
|
-
return jsonify(error=f"Download failed: {str(e)}"), 500
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
@app.route("/healthz", methods=["GET"])
|
|
149
|
-
def healthz():
|
|
150
|
-
with _in_use_lock:
|
|
151
|
-
in_use = _in_use
|
|
152
|
-
return jsonify(ok=True, in_use=in_use, capacity=MAX_CONCURRENT), 200
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def main():
|
|
156
|
-
validate_environment()
|
|
157
|
-
print("Starting ytp-dl API server...")
|
|
158
|
-
app.run(host="0.0.0.0", port=5000)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if __name__ == "__main__":
|
|
162
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|