ytp-dl 0.6.9__tar.gz → 0.7.1__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.9 → ytp_dl-0.7.1}/PKG-INFO +5 -5
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/README.md +4 -4
- ytp_dl-0.7.1/scripts/api.py +328 -0
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/scripts/downloader.py +174 -1
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/setup.py +1 -1
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/ytp_dl.egg-info/PKG-INFO +5 -5
- ytp_dl-0.6.9/scripts/api.py +0 -162
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/scripts/__init__.py +0 -0
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/setup.cfg +0 -0
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/ytp_dl.egg-info/SOURCES.txt +0 -0
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/ytp_dl.egg-info/dependency_links.txt +0 -0
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/ytp_dl.egg-info/entry_points.txt +0 -0
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/ytp_dl.egg-info/requires.txt +0 -0
- {ytp_dl-0.6.9 → ytp_dl-0.7.1}/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.1
|
|
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.1 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.1` + `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.1 + 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.1" "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.1 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.1` + `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.1 + 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.1" "yt-dlp[default]" gunicorn
|
|
365
365
|
deactivate
|
|
366
366
|
|
|
367
367
|
echo "==> 3) API environment file (/etc/default/ytp-dl-api)"
|
|
@@ -0,0 +1,328 @@
|
|
|
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, 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 _safe_job_id(raw: str) -> str:
|
|
71
|
+
"""Keep job ids filesystem-safe and predictable."""
|
|
72
|
+
s = (raw or "").strip()
|
|
73
|
+
if not s:
|
|
74
|
+
return str(int(time.time() * 1000))
|
|
75
|
+
# Only allow basic chars; everything else becomes "_"
|
|
76
|
+
out = []
|
|
77
|
+
for ch in s:
|
|
78
|
+
if ch.isalnum() or ch in ("-", "_"):
|
|
79
|
+
out.append(ch)
|
|
80
|
+
else:
|
|
81
|
+
out.append("_")
|
|
82
|
+
return "".join(out)[:120] or str(int(time.time() * 1000))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _write_result(job_dir: str, final_path: str) -> None:
|
|
86
|
+
"""Persist the final file path so /api/fetch can retrieve it."""
|
|
87
|
+
meta = {
|
|
88
|
+
"path": final_path,
|
|
89
|
+
"filename": os.path.basename(final_path),
|
|
90
|
+
"ts": time.time(),
|
|
91
|
+
}
|
|
92
|
+
with open(os.path.join(job_dir, "result.json"), "w", encoding="utf-8") as f:
|
|
93
|
+
json.dump(meta, f, ensure_ascii=False)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_result(job_dir: str) -> Optional[dict]:
|
|
97
|
+
"""Load persisted result metadata."""
|
|
98
|
+
p = os.path.join(job_dir, "result.json")
|
|
99
|
+
if not os.path.exists(p):
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
103
|
+
return json.load(f)
|
|
104
|
+
except Exception:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.route("/api/download", methods=["POST"])
|
|
109
|
+
def handle_download():
|
|
110
|
+
"""
|
|
111
|
+
Legacy endpoint (unchanged):
|
|
112
|
+
- Runs yt-dlp to completion
|
|
113
|
+
- Returns the file as the HTTP response body
|
|
114
|
+
"""
|
|
115
|
+
_cleanup_stale_jobs()
|
|
116
|
+
|
|
117
|
+
if not _try_acquire_job_slot():
|
|
118
|
+
return jsonify(error="Server busy, try again later"), 503
|
|
119
|
+
|
|
120
|
+
job_dir: str | None = None
|
|
121
|
+
released = False
|
|
122
|
+
|
|
123
|
+
def _release_once() -> None:
|
|
124
|
+
nonlocal released
|
|
125
|
+
if not released:
|
|
126
|
+
released = True
|
|
127
|
+
_release_job_slot()
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
data = request.get_json(force=True)
|
|
131
|
+
url = (data.get("url") or "").strip()
|
|
132
|
+
resolution = data.get("resolution")
|
|
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
|
+
|
|
147
|
+
# yt-dlp work (guarded by semaphore)
|
|
148
|
+
filename = download_video(
|
|
149
|
+
url=url,
|
|
150
|
+
resolution=resolution,
|
|
151
|
+
extension=extension,
|
|
152
|
+
out_dir=job_dir,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if not (filename and os.path.exists(filename)):
|
|
156
|
+
raise RuntimeError("Download failed")
|
|
157
|
+
|
|
158
|
+
# Release slot as soon as yt-dlp is done.
|
|
159
|
+
_release_once()
|
|
160
|
+
|
|
161
|
+
response = send_file(filename, as_attachment=True)
|
|
162
|
+
|
|
163
|
+
# Cleanup directory after client finishes consuming the response.
|
|
164
|
+
def _cleanup() -> None:
|
|
165
|
+
try:
|
|
166
|
+
if job_dir:
|
|
167
|
+
shutil.rmtree(job_dir, ignore_errors=True)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
response.call_on_close(_cleanup)
|
|
172
|
+
return response
|
|
173
|
+
|
|
174
|
+
except RuntimeError as e:
|
|
175
|
+
if job_dir:
|
|
176
|
+
shutil.rmtree(job_dir, ignore_errors=True)
|
|
177
|
+
_release_once()
|
|
178
|
+
|
|
179
|
+
msg = str(e)
|
|
180
|
+
if "Mullvad not logged in" in msg:
|
|
181
|
+
return jsonify(error=msg), 503
|
|
182
|
+
return jsonify(error=f"Download failed: {msg}"), 500
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
if job_dir:
|
|
186
|
+
shutil.rmtree(job_dir, ignore_errors=True)
|
|
187
|
+
_release_once()
|
|
188
|
+
return jsonify(error=f"Download failed: {str(e)}"), 500
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@app.route("/api/download_sse", methods=["POST"])
|
|
192
|
+
def handle_download_sse():
|
|
193
|
+
"""
|
|
194
|
+
NEW:
|
|
195
|
+
- Streams raw yt-dlp stdout lines as SSE in real time.
|
|
196
|
+
- Persists the final path to job_dir/result.json.
|
|
197
|
+
- Does NOT stream the file in this response.
|
|
198
|
+
"""
|
|
199
|
+
_cleanup_stale_jobs()
|
|
200
|
+
|
|
201
|
+
if not _try_acquire_job_slot():
|
|
202
|
+
return jsonify(error="Server busy, try again later"), 503
|
|
203
|
+
|
|
204
|
+
released = False
|
|
205
|
+
|
|
206
|
+
def _release_once() -> None:
|
|
207
|
+
nonlocal released
|
|
208
|
+
if not released:
|
|
209
|
+
released = True
|
|
210
|
+
_release_job_slot()
|
|
211
|
+
|
|
212
|
+
data = request.get_json(force=True) or {}
|
|
213
|
+
url = (data.get("url") or "").strip()
|
|
214
|
+
resolution = data.get("resolution")
|
|
215
|
+
extension = (data.get("extension") or "mp4").strip().lower()
|
|
216
|
+
job_id = _safe_job_id(str(data.get("job_id") or ""))
|
|
217
|
+
|
|
218
|
+
if not url:
|
|
219
|
+
_release_once()
|
|
220
|
+
return jsonify(error="Missing 'url'"), 400
|
|
221
|
+
if extension not in _ALLOWED_EXTENSIONS:
|
|
222
|
+
_release_once()
|
|
223
|
+
return jsonify(error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"), 400
|
|
224
|
+
|
|
225
|
+
job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
|
|
226
|
+
os.makedirs(job_dir, exist_ok=True)
|
|
227
|
+
|
|
228
|
+
def gen():
|
|
229
|
+
try:
|
|
230
|
+
# download_video_stream is a generator that yields yt-dlp lines and
|
|
231
|
+
# returns final_path via StopIteration.value
|
|
232
|
+
g = download_video_stream(
|
|
233
|
+
url=url,
|
|
234
|
+
resolution=resolution,
|
|
235
|
+
extension=extension,
|
|
236
|
+
out_dir=job_dir,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
final_path: Optional[str] = None
|
|
240
|
+
|
|
241
|
+
while True:
|
|
242
|
+
try:
|
|
243
|
+
line = next(g)
|
|
244
|
+
except StopIteration as si:
|
|
245
|
+
final_path = si.value # type: ignore[assignment]
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
line = (line or "").strip()
|
|
249
|
+
if not line:
|
|
250
|
+
continue
|
|
251
|
+
yield f"data: {line}\n\n"
|
|
252
|
+
|
|
253
|
+
if not final_path or not os.path.exists(final_path):
|
|
254
|
+
raise RuntimeError("Download completed but output file not found")
|
|
255
|
+
|
|
256
|
+
_write_result(job_dir, final_path)
|
|
257
|
+
|
|
258
|
+
# Marker consumed by your Render app (not forwarded to the browser).
|
|
259
|
+
yield f"data: [vps_ready] {os.path.basename(final_path)}\n\n"
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
msg = str(e)
|
|
263
|
+
yield f"data: VPS error: {msg}\n\n"
|
|
264
|
+
finally:
|
|
265
|
+
_release_once()
|
|
266
|
+
|
|
267
|
+
resp = Response(stream_with_context(gen()), content_type="text/event-stream")
|
|
268
|
+
resp.headers["Cache-Control"] = "no-cache"
|
|
269
|
+
resp.headers["X-Accel-Buffering"] = "no"
|
|
270
|
+
return resp
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@app.route("/api/fetch/<job_id>", methods=["GET"])
|
|
274
|
+
def fetch_job(job_id: str):
|
|
275
|
+
"""
|
|
276
|
+
NEW:
|
|
277
|
+
- Returns the completed file for a prior /api/download_sse job.
|
|
278
|
+
- Deletes the job dir after the client finishes consuming the response.
|
|
279
|
+
"""
|
|
280
|
+
job_id = _safe_job_id(job_id)
|
|
281
|
+
job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
|
|
282
|
+
meta = _read_result(job_dir)
|
|
283
|
+
if not meta:
|
|
284
|
+
return jsonify(error="Job not found or not ready"), 404
|
|
285
|
+
|
|
286
|
+
path = (meta.get("path") or "").strip()
|
|
287
|
+
if not path:
|
|
288
|
+
return jsonify(error="Job result missing path"), 500
|
|
289
|
+
|
|
290
|
+
# Prevent path traversal: ensure file lives inside job_dir
|
|
291
|
+
try:
|
|
292
|
+
job_dir_abs = os.path.abspath(job_dir)
|
|
293
|
+
path_abs = os.path.abspath(path)
|
|
294
|
+
if os.path.commonpath([job_dir_abs, path_abs]) != job_dir_abs:
|
|
295
|
+
return jsonify(error="Invalid job result path"), 500
|
|
296
|
+
except Exception:
|
|
297
|
+
return jsonify(error="Invalid job result path"), 500
|
|
298
|
+
|
|
299
|
+
if not os.path.exists(path):
|
|
300
|
+
return jsonify(error="File not found"), 404
|
|
301
|
+
|
|
302
|
+
response = send_file(path, as_attachment=True)
|
|
303
|
+
|
|
304
|
+
def _cleanup() -> None:
|
|
305
|
+
try:
|
|
306
|
+
shutil.rmtree(job_dir, ignore_errors=True)
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
response.call_on_close(_cleanup)
|
|
311
|
+
return response
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.route("/healthz", methods=["GET"])
|
|
315
|
+
def healthz():
|
|
316
|
+
with _in_use_lock:
|
|
317
|
+
in_use = _in_use
|
|
318
|
+
return jsonify(ok=True, in_use=in_use, capacity=MAX_CONCURRENT), 200
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def main():
|
|
322
|
+
validate_environment()
|
|
323
|
+
print("Starting ytp-dl API server...")
|
|
324
|
+
app.run(host="0.0.0.0", port=5000)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
main()
|
|
@@ -6,7 +6,8 @@ import shlex
|
|
|
6
6
|
import shutil
|
|
7
7
|
import subprocess
|
|
8
8
|
import time
|
|
9
|
-
from
|
|
9
|
+
from collections import deque
|
|
10
|
+
from typing import Optional, List, Tuple, Deque, Generator
|
|
10
11
|
|
|
11
12
|
# =========================
|
|
12
13
|
# Config / constants
|
|
@@ -278,6 +279,100 @@ def _download_with_format(
|
|
|
278
279
|
raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{tail}")
|
|
279
280
|
|
|
280
281
|
|
|
282
|
+
def _download_with_format_stream(
|
|
283
|
+
url: str,
|
|
284
|
+
out_dir: str,
|
|
285
|
+
fmt: str,
|
|
286
|
+
merge_output_format: Optional[str] = None,
|
|
287
|
+
extract_mp3: bool = False,
|
|
288
|
+
) -> Generator[str, None, str]:
|
|
289
|
+
"""
|
|
290
|
+
Stream yt-dlp output line-by-line (stdout+stderr merged),
|
|
291
|
+
and return the final output path via StopIteration.value.
|
|
292
|
+
"""
|
|
293
|
+
out_dir = os.path.abspath(out_dir)
|
|
294
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
295
|
+
|
|
296
|
+
out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
|
|
297
|
+
|
|
298
|
+
argv = [
|
|
299
|
+
YTDLP_BIN,
|
|
300
|
+
"-f", fmt,
|
|
301
|
+
*(_common_flags()),
|
|
302
|
+
"--output", out_tpl,
|
|
303
|
+
|
|
304
|
+
# Make progress lines flush as newline-terminated output
|
|
305
|
+
"--progress",
|
|
306
|
+
"--newline",
|
|
307
|
+
|
|
308
|
+
# Ensure we can reliably obtain the post-processed final path
|
|
309
|
+
"--print", "after_move:filepath",
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
if extract_mp3:
|
|
313
|
+
argv.extend(["--extract-audio", "--audio-format", "mp3"])
|
|
314
|
+
|
|
315
|
+
if merge_output_format:
|
|
316
|
+
argv.extend(["--merge-output-format", merge_output_format])
|
|
317
|
+
|
|
318
|
+
argv.append(url)
|
|
319
|
+
|
|
320
|
+
proc = subprocess.Popen(
|
|
321
|
+
argv,
|
|
322
|
+
stdout=subprocess.PIPE,
|
|
323
|
+
stderr=subprocess.STDOUT,
|
|
324
|
+
text=True,
|
|
325
|
+
bufsize=1,
|
|
326
|
+
universal_newlines=True,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
assert proc.stdout is not None
|
|
330
|
+
|
|
331
|
+
tail_buf: Deque[str] = deque(maxlen=_MAX_ERR_LINES)
|
|
332
|
+
final_path: Optional[str] = None
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
for raw in iter(proc.stdout.readline, ""):
|
|
336
|
+
line = (raw or "").rstrip("\n")
|
|
337
|
+
if not line:
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
tail_buf.append(line)
|
|
341
|
+
|
|
342
|
+
# Capture after_move:filepath lines (absolute paths under out_dir)
|
|
343
|
+
cand = line.strip().strip("'\"")
|
|
344
|
+
try:
|
|
345
|
+
if os.path.isabs(cand):
|
|
346
|
+
cand_abs = os.path.abspath(cand)
|
|
347
|
+
if os.path.commonpath([out_dir, cand_abs]) == out_dir:
|
|
348
|
+
final_path = cand_abs
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
yield line
|
|
353
|
+
|
|
354
|
+
finally:
|
|
355
|
+
try:
|
|
356
|
+
proc.stdout.close()
|
|
357
|
+
except Exception:
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
rc = proc.wait()
|
|
361
|
+
|
|
362
|
+
if rc != 0:
|
|
363
|
+
raise RuntimeError(f"yt-dlp failed (format: {fmt})\n{_tail('\n'.join(tail_buf))}")
|
|
364
|
+
|
|
365
|
+
if final_path and os.path.exists(final_path):
|
|
366
|
+
return os.path.abspath(final_path)
|
|
367
|
+
|
|
368
|
+
# Fallback: newest non-temp file in out_dir
|
|
369
|
+
fallback = _extract_final_path("\n".join(tail_buf), out_dir)
|
|
370
|
+
if fallback and os.path.exists(fallback):
|
|
371
|
+
return os.path.abspath(fallback)
|
|
372
|
+
|
|
373
|
+
raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{_tail('\n'.join(tail_buf))}")
|
|
374
|
+
|
|
375
|
+
|
|
281
376
|
def _fmt_mp4_apple_safe(cap: int) -> str:
|
|
282
377
|
# Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
|
|
283
378
|
return (
|
|
@@ -361,3 +456,81 @@ def download_video(
|
|
|
361
456
|
finally:
|
|
362
457
|
if _mullvad_present():
|
|
363
458
|
_run_argv(["mullvad", "disconnect"], check=False)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def download_video_stream(
|
|
462
|
+
url: str,
|
|
463
|
+
resolution: int | None = 1080,
|
|
464
|
+
extension: Optional[str] = None,
|
|
465
|
+
out_dir: str = DEFAULT_OUT_DIR,
|
|
466
|
+
) -> Generator[str, None, str]:
|
|
467
|
+
"""
|
|
468
|
+
Stream raw yt-dlp output lines and return the final file path.
|
|
469
|
+
Mirrors download_video(), but line-streaming instead of capture.
|
|
470
|
+
"""
|
|
471
|
+
if not url:
|
|
472
|
+
raise RuntimeError("Missing URL")
|
|
473
|
+
|
|
474
|
+
out_dir = os.path.abspath(out_dir)
|
|
475
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
476
|
+
|
|
477
|
+
validate_environment()
|
|
478
|
+
|
|
479
|
+
require_mullvad_login()
|
|
480
|
+
mullvad_connect(MULLVAD_LOCATION)
|
|
481
|
+
if not mullvad_wait_connected():
|
|
482
|
+
raise RuntimeError("Mullvad connection failed")
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
mode = (extension or "mp4").lower().strip()
|
|
486
|
+
|
|
487
|
+
if mode == "mp3":
|
|
488
|
+
g = _download_with_format_stream(
|
|
489
|
+
url=url,
|
|
490
|
+
out_dir=out_dir,
|
|
491
|
+
fmt="bestaudio",
|
|
492
|
+
merge_output_format=None,
|
|
493
|
+
extract_mp3=True,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
else:
|
|
497
|
+
cap = int(resolution or 1080)
|
|
498
|
+
|
|
499
|
+
if mode == "best":
|
|
500
|
+
# Try best; on failure fall back to Apple-safe mp4 (same logic as download_video)
|
|
501
|
+
try:
|
|
502
|
+
g = _download_with_format_stream(
|
|
503
|
+
url=url,
|
|
504
|
+
out_dir=out_dir,
|
|
505
|
+
fmt=_fmt_best(cap),
|
|
506
|
+
merge_output_format=None,
|
|
507
|
+
extract_mp3=False,
|
|
508
|
+
)
|
|
509
|
+
except Exception:
|
|
510
|
+
g = _download_with_format_stream(
|
|
511
|
+
url=url,
|
|
512
|
+
out_dir=out_dir,
|
|
513
|
+
fmt=_fmt_mp4_apple_safe(cap),
|
|
514
|
+
merge_output_format="mp4",
|
|
515
|
+
extract_mp3=False,
|
|
516
|
+
)
|
|
517
|
+
else:
|
|
518
|
+
g = _download_with_format_stream(
|
|
519
|
+
url=url,
|
|
520
|
+
out_dir=out_dir,
|
|
521
|
+
fmt=_fmt_mp4_apple_safe(cap),
|
|
522
|
+
merge_output_format="mp4",
|
|
523
|
+
extract_mp3=False,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Forward all lines; return final path via StopIteration.value
|
|
527
|
+
while True:
|
|
528
|
+
try:
|
|
529
|
+
line = next(g)
|
|
530
|
+
except StopIteration as si:
|
|
531
|
+
return si.value # final path
|
|
532
|
+
yield line
|
|
533
|
+
|
|
534
|
+
finally:
|
|
535
|
+
if _mullvad_present():
|
|
536
|
+
_run_argv(["mullvad", "disconnect"], check=False)
|
|
@@ -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.
|
|
11
|
+
version="0.7.1",
|
|
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.
|
|
3
|
+
Version: 0.7.1
|
|
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.1 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.1` + `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.1 + 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.1" "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.9/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
|