ytp-dl 0.6.8__tar.gz → 0.6.9__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.6.9}/PKG-INFO +5 -5
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/README.md +4 -4
- ytp_dl-0.6.9/scripts/api.py +162 -0
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/scripts/downloader.py +123 -129
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/setup.py +1 -1
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/ytp_dl.egg-info/PKG-INFO +5 -5
- ytp_dl-0.6.8/scripts/api.py +0 -286
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/scripts/__init__.py +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/setup.cfg +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/ytp_dl.egg-info/SOURCES.txt +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/ytp_dl.egg-info/dependency_links.txt +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/ytp_dl.egg-info/entry_points.txt +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/ytp_dl.egg-info/requires.txt +0 -0
- {ytp_dl-0.6.8 → ytp_dl-0.6.9}/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.9
|
|
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.9 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.9` + `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.9 + 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.9" "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.9 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.9` + `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.9 + 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.9" "yt-dlp[default]" gunicorn
|
|
365
365
|
deactivate
|
|
366
366
|
|
|
367
367
|
echo "==> 3) API environment file (/etc/default/ytp-dl-api)"
|
|
@@ -0,0 +1,162 @@
|
|
|
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()
|
|
@@ -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
|
|
10
10
|
|
|
11
11
|
# =========================
|
|
12
12
|
# Config / constants
|
|
@@ -60,6 +60,11 @@ 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
|
+
|
|
63
68
|
# =========================
|
|
64
69
|
# Environment / Mullvad
|
|
65
70
|
# =========================
|
|
@@ -130,25 +135,85 @@ def _common_flags() -> List[str]:
|
|
|
130
135
|
"--user-agent", MODERN_UA,
|
|
131
136
|
"--no-cache-dir",
|
|
132
137
|
"--ignore-config",
|
|
138
|
+
"--embed-metadata",
|
|
133
139
|
"--sleep-interval", "1",
|
|
134
140
|
]
|
|
135
141
|
|
|
136
142
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
)
|
|
143
|
+
def _extract_final_path(stdout: str, out_dir: str) -> Optional[str]:
|
|
144
|
+
"""
|
|
145
|
+
Robustly derive the final output file path from yt-dlp output.
|
|
144
146
|
|
|
147
|
+
Priority:
|
|
148
|
+
1) --print after_move:filepath lines (absolute paths)
|
|
149
|
+
2) [Merger] Merging formats into "..."
|
|
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)
|
|
145
155
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
156
|
+
for raw in (stdout or "").splitlines():
|
|
157
|
+
line = (raw or "").strip()
|
|
158
|
+
if not line:
|
|
159
|
+
continue
|
|
149
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
|
|
150
165
|
|
|
151
|
-
|
|
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
|
+
|
|
183
|
+
# 3) Destination lines (download/extractaudio)
|
|
184
|
+
if "Destination:" in line:
|
|
185
|
+
try:
|
|
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
|
|
210
|
+
|
|
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
|
+
|
|
216
|
+
# 4) Fallback: newest non-temp file in out_dir
|
|
152
217
|
try:
|
|
153
218
|
best_path = None
|
|
154
219
|
best_mtime = -1.0
|
|
@@ -162,129 +227,80 @@ def _newest_non_temp_file(out_dir: str) -> Optional[str]:
|
|
|
162
227
|
if mt > best_mtime:
|
|
163
228
|
best_mtime = mt
|
|
164
229
|
best_path = full
|
|
165
|
-
|
|
230
|
+
if best_path:
|
|
231
|
+
return best_path
|
|
166
232
|
except Exception:
|
|
167
|
-
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
return None
|
|
168
236
|
|
|
169
237
|
|
|
170
|
-
def
|
|
171
|
-
*,
|
|
238
|
+
def _download_with_format(
|
|
172
239
|
url: str,
|
|
173
240
|
out_dir: str,
|
|
174
241
|
fmt: str,
|
|
175
242
|
merge_output_format: Optional[str] = None,
|
|
176
243
|
extract_mp3: bool = False,
|
|
177
|
-
) ->
|
|
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
|
-
"""
|
|
244
|
+
) -> str:
|
|
184
245
|
out_dir = os.path.abspath(out_dir)
|
|
185
246
|
os.makedirs(out_dir, exist_ok=True)
|
|
186
247
|
|
|
187
248
|
out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
|
|
188
249
|
|
|
189
|
-
argv
|
|
250
|
+
argv = [
|
|
190
251
|
YTDLP_BIN,
|
|
191
|
-
url,
|
|
192
|
-
"--progress",
|
|
193
|
-
"--newline",
|
|
194
|
-
"--continue",
|
|
195
252
|
"-f", fmt,
|
|
196
253
|
*(_common_flags()),
|
|
197
254
|
"--output", out_tpl,
|
|
198
|
-
#
|
|
255
|
+
# Ensure we can reliably pick the final output path.
|
|
199
256
|
"--print", "after_move:filepath",
|
|
200
|
-
# Local parity signal:
|
|
201
|
-
"--print", "after_move:[download_complete] %(title)s.%(ext)s",
|
|
202
257
|
]
|
|
203
258
|
|
|
204
259
|
if extract_mp3:
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
"--extract-audio",
|
|
208
|
-
"--audio-format", "mp3",
|
|
209
|
-
"--audio-quality", "0",
|
|
210
|
-
"--embed-thumbnail",
|
|
211
|
-
"--add-metadata",
|
|
212
|
-
]
|
|
213
|
-
)
|
|
260
|
+
# Force audio extraction to MP3 (requires ffmpeg)
|
|
261
|
+
argv.extend(["--extract-audio", "--audio-format", "mp3"])
|
|
214
262
|
|
|
263
|
+
# Only force merge container when we actually want MP4 output.
|
|
215
264
|
if merge_output_format:
|
|
216
265
|
argv.extend(["--merge-output-format", merge_output_format])
|
|
217
266
|
|
|
218
|
-
|
|
219
|
-
argv,
|
|
220
|
-
stdout=subprocess.PIPE,
|
|
221
|
-
stderr=subprocess.STDOUT,
|
|
222
|
-
text=True,
|
|
223
|
-
bufsize=1,
|
|
224
|
-
universal_newlines=True,
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
final_path: Optional[str] = None
|
|
228
|
-
tail_lines: List[str] = []
|
|
267
|
+
argv.append(url)
|
|
229
268
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
for raw in iter(proc.stdout.readline, ""):
|
|
233
|
-
line = (raw or "").rstrip("\n").rstrip("\r")
|
|
234
|
-
if not line:
|
|
235
|
-
continue
|
|
236
|
-
|
|
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
|
|
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)
|
|
269
|
+
rc, out = _run_argv_capture(argv)
|
|
270
|
+
path = _extract_final_path(out, out_dir)
|
|
246
271
|
|
|
247
|
-
|
|
248
|
-
|
|
272
|
+
if path and os.path.exists(path):
|
|
273
|
+
return os.path.abspath(path)
|
|
249
274
|
|
|
250
|
-
|
|
251
|
-
|
|
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)
|
|
275
|
+
tail = _tail(out)
|
|
276
|
+
if rc != 0:
|
|
261
277
|
raise RuntimeError(f"yt-dlp failed (format: {fmt})\n{tail}")
|
|
278
|
+
raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{tail}")
|
|
262
279
|
|
|
263
|
-
# Resolve final path
|
|
264
|
-
if final_path and os.path.exists(final_path):
|
|
265
|
-
return os.path.abspath(final_path)
|
|
266
280
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
281
|
+
def _fmt_mp4_apple_safe(cap: int) -> str:
|
|
282
|
+
# Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
|
|
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
|
+
)
|
|
271
288
|
|
|
272
|
-
|
|
273
|
-
|
|
289
|
+
|
|
290
|
+
def _fmt_best(cap: int) -> str:
|
|
291
|
+
# Best overall up to cap (can yield webm/mkv/etc).
|
|
292
|
+
return f"bv*[height<={cap}]+ba/b[height<={cap}]"
|
|
274
293
|
|
|
275
294
|
|
|
276
295
|
# =========================
|
|
277
|
-
# Public
|
|
296
|
+
# Public API
|
|
278
297
|
# =========================
|
|
279
|
-
def
|
|
298
|
+
def download_video(
|
|
280
299
|
url: str,
|
|
281
300
|
resolution: int | None = 1080,
|
|
282
301
|
extension: Optional[str] = None,
|
|
283
302
|
out_dir: str = DEFAULT_OUT_DIR,
|
|
284
|
-
) ->
|
|
285
|
-
"""
|
|
286
|
-
Streams yt-dlp logs and returns final file path (generator return value).
|
|
287
|
-
"""
|
|
303
|
+
) -> str:
|
|
288
304
|
if not url:
|
|
289
305
|
raise RuntimeError("Missing URL")
|
|
290
306
|
|
|
@@ -302,68 +318,46 @@ def download_video_stream(
|
|
|
302
318
|
mode = (extension or "mp4").lower().strip()
|
|
303
319
|
|
|
304
320
|
if mode == "mp3":
|
|
305
|
-
|
|
321
|
+
# bestaudio -> ffmpeg -> mp3 (post-processed by yt-dlp)
|
|
322
|
+
return _download_with_format(
|
|
306
323
|
url=url,
|
|
307
324
|
out_dir=out_dir,
|
|
308
325
|
fmt="bestaudio",
|
|
309
326
|
merge_output_format=None,
|
|
310
327
|
extract_mp3=True,
|
|
311
|
-
)
|
|
328
|
+
)
|
|
312
329
|
|
|
313
330
|
cap = int(resolution or 1080)
|
|
314
331
|
|
|
315
332
|
if mode == "best":
|
|
316
|
-
# Try best first (may produce webm/mkv/etc).
|
|
333
|
+
# Try best first (may produce webm/mkv/etc).
|
|
317
334
|
try:
|
|
318
|
-
return (
|
|
335
|
+
return _download_with_format(
|
|
319
336
|
url=url,
|
|
320
337
|
out_dir=out_dir,
|
|
321
338
|
fmt=_fmt_best(cap),
|
|
322
339
|
merge_output_format=None,
|
|
323
340
|
extract_mp3=False,
|
|
324
|
-
)
|
|
341
|
+
)
|
|
325
342
|
except Exception:
|
|
326
|
-
|
|
343
|
+
# If best fails for any reason, fall back to Apple-safe MP4.
|
|
344
|
+
return _download_with_format(
|
|
327
345
|
url=url,
|
|
328
346
|
out_dir=out_dir,
|
|
329
347
|
fmt=_fmt_mp4_apple_safe(cap),
|
|
330
348
|
merge_output_format="mp4",
|
|
331
349
|
extract_mp3=False,
|
|
332
|
-
)
|
|
350
|
+
)
|
|
333
351
|
|
|
334
|
-
# Default / "mp4" mode
|
|
335
|
-
return (
|
|
352
|
+
# Default / "mp4" mode: force Apple-safe MP4 up to cap.
|
|
353
|
+
return _download_with_format(
|
|
336
354
|
url=url,
|
|
337
355
|
out_dir=out_dir,
|
|
338
356
|
fmt=_fmt_mp4_apple_safe(cap),
|
|
339
357
|
merge_output_format="mp4",
|
|
340
358
|
extract_mp3=False,
|
|
341
|
-
)
|
|
359
|
+
)
|
|
342
360
|
|
|
343
361
|
finally:
|
|
344
362
|
if _mullvad_present():
|
|
345
363
|
_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.9",
|
|
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.9
|
|
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.9 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.9` + `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.9 + 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.9" "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.8/scripts/api.py
DELETED
|
@@ -1,286 +0,0 @@
|
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|