ytp-dl 0.6.5__py3-none-any.whl → 0.6.7__py3-none-any.whl
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.
- scripts/api.py +209 -25
- scripts/downloader.py +141 -193
- {ytp_dl-0.6.5.dist-info → ytp_dl-0.6.7.dist-info}/METADATA +6 -6
- ytp_dl-0.6.7.dist-info/RECORD +8 -0
- {ytp_dl-0.6.5.dist-info → ytp_dl-0.6.7.dist-info}/WHEEL +1 -1
- ytp_dl-0.6.5.dist-info/RECORD +0 -8
- {ytp_dl-0.6.5.dist-info → ytp_dl-0.6.7.dist-info}/entry_points.txt +0 -0
- {ytp_dl-0.6.5.dist-info → ytp_dl-0.6.7.dist-info}/top_level.txt +0 -0
scripts/api.py
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import json
|
|
4
5
|
import os
|
|
6
|
+
import secrets
|
|
5
7
|
import shutil
|
|
6
8
|
import tempfile
|
|
7
9
|
import time
|
|
8
10
|
from threading import BoundedSemaphore, Lock
|
|
11
|
+
from typing import Optional, Dict, Any, Iterable
|
|
9
12
|
|
|
10
|
-
from flask import Flask, request,
|
|
13
|
+
from flask import Flask, request, jsonify, Response, send_file
|
|
11
14
|
|
|
12
|
-
from .downloader import
|
|
15
|
+
from .downloader import (
|
|
16
|
+
validate_environment,
|
|
17
|
+
prepare_network,
|
|
18
|
+
cleanup_network,
|
|
19
|
+
build_download_plan,
|
|
20
|
+
)
|
|
13
21
|
|
|
14
22
|
app = Flask(__name__)
|
|
15
23
|
|
|
@@ -65,8 +73,49 @@ def _release_job_slot() -> None:
|
|
|
65
73
|
_sem.release()
|
|
66
74
|
|
|
67
75
|
|
|
76
|
+
def _job_meta_path(job_dir: str) -> str:
|
|
77
|
+
return os.path.join(job_dir, "job.json")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _write_job_meta(job_dir: str, meta: Dict[str, Any]) -> None:
|
|
81
|
+
try:
|
|
82
|
+
with open(_job_meta_path(job_dir), "w", encoding="utf-8") as f:
|
|
83
|
+
json.dump(meta, f, ensure_ascii=False)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_job_meta(job_dir: str) -> Optional[Dict[str, Any]]:
|
|
89
|
+
p = _job_meta_path(job_dir)
|
|
90
|
+
if not os.path.exists(p):
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
94
|
+
return json.load(f)
|
|
95
|
+
except Exception:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _touch(path: str) -> None:
|
|
100
|
+
try:
|
|
101
|
+
os.utime(path, None)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _sse(data: str) -> str:
|
|
107
|
+
# Minimal SSE formatting
|
|
108
|
+
return f"data: {data}\n\n"
|
|
109
|
+
|
|
110
|
+
|
|
68
111
|
@app.route("/api/download", methods=["POST"])
|
|
69
112
|
def handle_download():
|
|
113
|
+
"""
|
|
114
|
+
VPS-side downloader:
|
|
115
|
+
- returns SSE logs (real yt-dlp output) to caller
|
|
116
|
+
- stores completed file in a job dir
|
|
117
|
+
- caller then fetches the file via /api/fetch/<job_id>?token=...
|
|
118
|
+
"""
|
|
70
119
|
_cleanup_stale_jobs()
|
|
71
120
|
|
|
72
121
|
if not _try_acquire_job_slot():
|
|
@@ -100,33 +149,126 @@ def handle_download():
|
|
|
100
149
|
), 400
|
|
101
150
|
|
|
102
151
|
job_dir = tempfile.mkdtemp(prefix="ytpdl_", dir=BASE_DOWNLOAD_DIR)
|
|
152
|
+
job_id = os.path.basename(job_dir)
|
|
153
|
+
token = secrets.token_urlsafe(24)
|
|
103
154
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
resolution=resolution,
|
|
108
|
-
extension=extension,
|
|
109
|
-
out_dir=job_dir,
|
|
110
|
-
)
|
|
155
|
+
def stream() -> Iterable[str]:
|
|
156
|
+
nonlocal job_dir
|
|
157
|
+
assert job_dir is not None
|
|
111
158
|
|
|
112
|
-
|
|
113
|
-
|
|
159
|
+
# Internal control info (caller should NOT forward to browser).
|
|
160
|
+
yield _sse(f"[internal] job={job_id} token={token}")
|
|
114
161
|
|
|
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
162
|
try:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
163
|
+
validate_environment()
|
|
164
|
+
prepare_network()
|
|
165
|
+
|
|
166
|
+
out_dir = job_dir
|
|
167
|
+
out_dir_abs = os.path.abspath(out_dir)
|
|
168
|
+
|
|
169
|
+
# Track final file path without printing it to the client.
|
|
170
|
+
final_path: Optional[str] = None
|
|
171
|
+
# Track "pretty" filename from [download_complete] marker.
|
|
172
|
+
final_name: Optional[str] = None
|
|
173
|
+
|
|
174
|
+
plan = build_download_plan(
|
|
175
|
+
url=url,
|
|
176
|
+
resolution=resolution,
|
|
177
|
+
extension=extension,
|
|
178
|
+
out_dir=out_dir,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
for step_idx, argv in enumerate(plan, start=1):
|
|
182
|
+
# Let caller know about fallback attempt (still safe to forward).
|
|
183
|
+
if step_idx > 1:
|
|
184
|
+
yield _sse("Retrying with fallback format...")
|
|
185
|
+
|
|
186
|
+
import subprocess
|
|
187
|
+
|
|
188
|
+
proc = subprocess.Popen(
|
|
189
|
+
argv,
|
|
190
|
+
stdout=subprocess.PIPE,
|
|
191
|
+
stderr=subprocess.STDOUT,
|
|
192
|
+
text=True,
|
|
193
|
+
bufsize=1,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
assert proc.stdout is not None
|
|
197
|
+
|
|
198
|
+
for raw in iter(proc.stdout.readline, ""):
|
|
199
|
+
line = (raw or "").strip()
|
|
200
|
+
if not line:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
# yt-dlp --print after_move:filepath outputs an absolute path.
|
|
204
|
+
# Keep it internal so browser logs match local.
|
|
205
|
+
if os.path.isabs(line) and line.startswith(out_dir_abs):
|
|
206
|
+
final_path = line.strip("'\"")
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
# Capture local-style marker for filename.
|
|
210
|
+
if line.startswith("[download_complete]"):
|
|
211
|
+
# same semantics as your local parser
|
|
212
|
+
try:
|
|
213
|
+
final_name = line.split("[download_complete]", 1)[1].strip()
|
|
214
|
+
except Exception:
|
|
215
|
+
final_name = None
|
|
216
|
+
|
|
217
|
+
yield _sse(line)
|
|
218
|
+
|
|
219
|
+
proc.stdout.close()
|
|
220
|
+
rc = proc.wait()
|
|
221
|
+
|
|
222
|
+
if rc == 0:
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
# Validate output
|
|
226
|
+
if not final_path:
|
|
227
|
+
# Try to discover any output file in job dir if filepath line wasn't produced.
|
|
228
|
+
try:
|
|
229
|
+
for name in os.listdir(job_dir):
|
|
230
|
+
if name.endswith((".part", ".ytdl", ".tmp")):
|
|
231
|
+
continue
|
|
232
|
+
p = os.path.join(job_dir, name)
|
|
233
|
+
if os.path.isfile(p):
|
|
234
|
+
final_path = os.path.abspath(p)
|
|
235
|
+
break
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
if not (final_path and os.path.exists(final_path)):
|
|
240
|
+
yield _sse("ERROR: Download completed but output file not found")
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
# Persist meta for /api/fetch
|
|
244
|
+
meta = {
|
|
245
|
+
"job_id": job_id,
|
|
246
|
+
"token": token,
|
|
247
|
+
"file_path": os.path.abspath(final_path),
|
|
248
|
+
"file_name": final_name or os.path.basename(final_path),
|
|
249
|
+
"created_at": time.time(),
|
|
250
|
+
}
|
|
251
|
+
_write_job_meta(job_dir, meta)
|
|
252
|
+
_touch(job_dir)
|
|
253
|
+
|
|
254
|
+
# Release slot as soon as yt-dlp is done (do NOT hold during fetch).
|
|
255
|
+
_release_once()
|
|
256
|
+
|
|
257
|
+
# Signal ready to caller (do NOT forward to browser).
|
|
258
|
+
yield _sse("[internal] ready")
|
|
259
|
+
|
|
260
|
+
finally:
|
|
261
|
+
cleanup_network()
|
|
262
|
+
|
|
263
|
+
return Response(
|
|
264
|
+
stream(),
|
|
265
|
+
content_type="text/event-stream",
|
|
266
|
+
headers={
|
|
267
|
+
"Cache-Control": "no-cache",
|
|
268
|
+
"X-Accel-Buffering": "no",
|
|
269
|
+
"Connection": "close",
|
|
270
|
+
},
|
|
271
|
+
)
|
|
130
272
|
|
|
131
273
|
except RuntimeError as e:
|
|
132
274
|
if job_dir:
|
|
@@ -145,6 +287,48 @@ def handle_download():
|
|
|
145
287
|
return jsonify(error=f"Download failed: {str(e)}"), 500
|
|
146
288
|
|
|
147
289
|
|
|
290
|
+
@app.route("/api/fetch/<job_id>", methods=["GET"])
|
|
291
|
+
def fetch(job_id: str):
|
|
292
|
+
"""
|
|
293
|
+
Fetch the finished file after /api/download completes.
|
|
294
|
+
|
|
295
|
+
Requires:
|
|
296
|
+
/api/fetch/<job_id>?token=...
|
|
297
|
+
"""
|
|
298
|
+
token = (request.args.get("token") or "").strip()
|
|
299
|
+
if not token:
|
|
300
|
+
return jsonify(error="Missing token"), 400
|
|
301
|
+
|
|
302
|
+
job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
|
|
303
|
+
if not os.path.isdir(job_dir):
|
|
304
|
+
return jsonify(error="Job not found"), 404
|
|
305
|
+
|
|
306
|
+
meta = _read_job_meta(job_dir)
|
|
307
|
+
if not meta:
|
|
308
|
+
return jsonify(error="Job metadata missing"), 404
|
|
309
|
+
|
|
310
|
+
if meta.get("token") != token:
|
|
311
|
+
return jsonify(error="Invalid token"), 403
|
|
312
|
+
|
|
313
|
+
file_path = meta.get("file_path")
|
|
314
|
+
if not (file_path and os.path.exists(file_path)):
|
|
315
|
+
return jsonify(error="File not found"), 404
|
|
316
|
+
|
|
317
|
+
download_name = meta.get("file_name") or os.path.basename(file_path)
|
|
318
|
+
|
|
319
|
+
resp = send_file(file_path, as_attachment=True, download_name=download_name)
|
|
320
|
+
|
|
321
|
+
# Cleanup after caller finishes consuming the response.
|
|
322
|
+
def _cleanup() -> None:
|
|
323
|
+
try:
|
|
324
|
+
shutil.rmtree(job_dir, ignore_errors=True)
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
resp.call_on_close(_cleanup)
|
|
329
|
+
return resp
|
|
330
|
+
|
|
331
|
+
|
|
148
332
|
@app.route("/healthz", methods=["GET"])
|
|
149
333
|
def healthz():
|
|
150
334
|
with _in_use_lock:
|
scripts/downloader.py
CHANGED
|
@@ -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, Any
|
|
10
10
|
|
|
11
11
|
# =========================
|
|
12
12
|
# Config / constants
|
|
@@ -29,6 +29,8 @@ DEFAULT_OUT_DIR = os.environ.get("YTPDL_DOWNLOAD_DIR", "/root")
|
|
|
29
29
|
_MAX_ERR_LINES = 80
|
|
30
30
|
_MAX_ERR_CHARS = 4000
|
|
31
31
|
|
|
32
|
+
_ALLOWED_EXTENSIONS = {"mp3", "mp4", "best"}
|
|
33
|
+
|
|
32
34
|
|
|
33
35
|
# =========================
|
|
34
36
|
# Shell helpers
|
|
@@ -60,11 +62,6 @@ def _tail(out: str) -> str:
|
|
|
60
62
|
return txt.strip()
|
|
61
63
|
|
|
62
64
|
|
|
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
65
|
# =========================
|
|
69
66
|
# Environment / Mullvad
|
|
70
67
|
# =========================
|
|
@@ -122,6 +119,25 @@ def mullvad_wait_connected(timeout: int = 20) -> bool:
|
|
|
122
119
|
return False
|
|
123
120
|
|
|
124
121
|
|
|
122
|
+
def prepare_network() -> None:
|
|
123
|
+
"""
|
|
124
|
+
Called by VPS API server before launching yt-dlp.
|
|
125
|
+
"""
|
|
126
|
+
validate_environment()
|
|
127
|
+
require_mullvad_login()
|
|
128
|
+
mullvad_connect(MULLVAD_LOCATION)
|
|
129
|
+
if not mullvad_wait_connected():
|
|
130
|
+
raise RuntimeError("Mullvad connection failed")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cleanup_network() -> None:
|
|
134
|
+
"""
|
|
135
|
+
Called by VPS API server after yt-dlp finishes.
|
|
136
|
+
"""
|
|
137
|
+
if _mullvad_present():
|
|
138
|
+
_run_argv(["mullvad", "disconnect"], check=False)
|
|
139
|
+
|
|
140
|
+
|
|
125
141
|
# =========================
|
|
126
142
|
# yt-dlp helpers
|
|
127
143
|
# =========================
|
|
@@ -135,148 +151,28 @@ def _common_flags() -> List[str]:
|
|
|
135
151
|
"--user-agent", MODERN_UA,
|
|
136
152
|
"--no-cache-dir",
|
|
137
153
|
"--ignore-config",
|
|
138
|
-
"--embed-metadata",
|
|
139
154
|
"--sleep-interval", "1",
|
|
140
155
|
]
|
|
141
156
|
|
|
142
157
|
|
|
143
|
-
def
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
165
|
-
|
|
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
|
|
217
|
-
try:
|
|
218
|
-
best_path = None
|
|
219
|
-
best_mtime = -1.0
|
|
220
|
-
for name in os.listdir(out_dir):
|
|
221
|
-
if name.endswith((".part", ".ytdl", ".tmp")):
|
|
222
|
-
continue
|
|
223
|
-
full = os.path.join(out_dir, name)
|
|
224
|
-
if not os.path.isfile(full):
|
|
225
|
-
continue
|
|
226
|
-
mt = os.path.getmtime(full)
|
|
227
|
-
if mt > best_mtime:
|
|
228
|
-
best_mtime = mt
|
|
229
|
-
best_path = full
|
|
230
|
-
if best_path:
|
|
231
|
-
return best_path
|
|
232
|
-
except Exception:
|
|
233
|
-
pass
|
|
234
|
-
|
|
235
|
-
return None
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def _download_with_format(
|
|
239
|
-
url: str,
|
|
240
|
-
out_dir: str,
|
|
241
|
-
fmt: str,
|
|
242
|
-
merge_output_format: Optional[str] = None,
|
|
243
|
-
extract_mp3: bool = False,
|
|
244
|
-
) -> str:
|
|
245
|
-
out_dir = os.path.abspath(out_dir)
|
|
246
|
-
os.makedirs(out_dir, exist_ok=True)
|
|
247
|
-
|
|
248
|
-
out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
|
|
249
|
-
|
|
250
|
-
argv = [
|
|
251
|
-
YTDLP_BIN,
|
|
252
|
-
"-f", fmt,
|
|
253
|
-
*(_common_flags()),
|
|
254
|
-
"--output", out_tpl,
|
|
255
|
-
# Ensure we can reliably pick the final output path.
|
|
256
|
-
"--print", "after_move:filepath",
|
|
158
|
+
def _sanitize_title_flags() -> List[str]:
|
|
159
|
+
# Mirrors your local downloader's title sanitization intent.
|
|
160
|
+
return [
|
|
161
|
+
"--replace-in-metadata",
|
|
162
|
+
"title",
|
|
163
|
+
"[\\U0001F600-\\U0001F64F\\U0001F300-\\U0001F5FF"
|
|
164
|
+
"\\U0001F680-\\U0001F6FF\\U0001F700-\\U0001F77F"
|
|
165
|
+
"\\U0001F780-\\U0001F7FF\\U0001F800-\\U0001F8FF"
|
|
166
|
+
"\\U0001F900-\\U0001F9FF\\U0001FA00-\\U0001FA6F"
|
|
167
|
+
"\\U0001FA70-\\U0001FAFF\\U00002702-\\U000027B0"
|
|
168
|
+
"\\U000024C2-\\U0001F251]",
|
|
169
|
+
"",
|
|
170
|
+
"--replace-in-metadata",
|
|
171
|
+
"title",
|
|
172
|
+
r"[\\\/:*?\"<>|]|[\s.]+$",
|
|
173
|
+
"",
|
|
257
174
|
]
|
|
258
175
|
|
|
259
|
-
if extract_mp3:
|
|
260
|
-
# Force audio extraction to MP3 (requires ffmpeg)
|
|
261
|
-
argv.extend(["--extract-audio", "--audio-format", "mp3"])
|
|
262
|
-
|
|
263
|
-
# Only force merge container when we actually want MP4 output.
|
|
264
|
-
if merge_output_format:
|
|
265
|
-
argv.extend(["--merge-output-format", merge_output_format])
|
|
266
|
-
|
|
267
|
-
argv.append(url)
|
|
268
|
-
|
|
269
|
-
rc, out = _run_argv_capture(argv)
|
|
270
|
-
path = _extract_final_path(out, out_dir)
|
|
271
|
-
|
|
272
|
-
if path and os.path.exists(path):
|
|
273
|
-
return os.path.abspath(path)
|
|
274
|
-
|
|
275
|
-
tail = _tail(out)
|
|
276
|
-
if rc != 0:
|
|
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}")
|
|
279
|
-
|
|
280
176
|
|
|
281
177
|
def _fmt_mp4_apple_safe(cap: int) -> str:
|
|
282
178
|
# Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
|
|
@@ -292,8 +188,85 @@ def _fmt_best(cap: int) -> str:
|
|
|
292
188
|
return f"bv*[height<={cap}]+ba/b[height<={cap}]"
|
|
293
189
|
|
|
294
190
|
|
|
191
|
+
def _base_argv(out_dir: str) -> List[str]:
|
|
192
|
+
out_dir = os.path.abspath(out_dir)
|
|
193
|
+
out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
|
|
194
|
+
|
|
195
|
+
return [
|
|
196
|
+
YTDLP_BIN,
|
|
197
|
+
"--progress",
|
|
198
|
+
"--newline",
|
|
199
|
+
"--continue",
|
|
200
|
+
*_common_flags(),
|
|
201
|
+
*_sanitize_title_flags(),
|
|
202
|
+
"--output", out_tpl,
|
|
203
|
+
# Internal: absolute final path (API server filters this from browser).
|
|
204
|
+
"--print", "after_move:filepath",
|
|
205
|
+
# Visible: local-style completion marker.
|
|
206
|
+
"--print", "after_move:[download_complete] %(title)s.%(ext)s",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def build_download_plan(
|
|
211
|
+
url: str,
|
|
212
|
+
resolution: Any | None = 1080,
|
|
213
|
+
extension: Optional[str] = None,
|
|
214
|
+
out_dir: str = DEFAULT_OUT_DIR,
|
|
215
|
+
) -> List[List[str]]:
|
|
216
|
+
"""
|
|
217
|
+
Returns a list of argv arrays to try in order.
|
|
218
|
+
The VPS API server will run them with Popen and stream stdout.
|
|
219
|
+
"""
|
|
220
|
+
if not url:
|
|
221
|
+
raise RuntimeError("Missing URL")
|
|
222
|
+
|
|
223
|
+
mode = (extension or "mp4").lower().strip()
|
|
224
|
+
if mode not in _ALLOWED_EXTENSIONS:
|
|
225
|
+
raise RuntimeError(f"Invalid extension/mode: {mode}")
|
|
226
|
+
|
|
227
|
+
cap = int(resolution or 1080)
|
|
228
|
+
base = _base_argv(out_dir)
|
|
229
|
+
|
|
230
|
+
if mode == "mp3":
|
|
231
|
+
return [[
|
|
232
|
+
*base,
|
|
233
|
+
"-f", "bestaudio",
|
|
234
|
+
"--extract-audio",
|
|
235
|
+
"--audio-format", "mp3",
|
|
236
|
+
"--audio-quality", "0",
|
|
237
|
+
"--embed-thumbnail",
|
|
238
|
+
"--add-metadata",
|
|
239
|
+
"--metadata-from-title", "%(artist)s - %(title)s",
|
|
240
|
+
url,
|
|
241
|
+
]]
|
|
242
|
+
|
|
243
|
+
if mode == "best":
|
|
244
|
+
# Try best first, then fallback to Apple-safe MP4.
|
|
245
|
+
return [
|
|
246
|
+
[
|
|
247
|
+
*base,
|
|
248
|
+
"-f", _fmt_best(cap),
|
|
249
|
+
url,
|
|
250
|
+
],
|
|
251
|
+
[
|
|
252
|
+
*base,
|
|
253
|
+
"-f", _fmt_mp4_apple_safe(cap),
|
|
254
|
+
"--merge-output-format", "mp4",
|
|
255
|
+
url,
|
|
256
|
+
],
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
# mode == "mp4" (default)
|
|
260
|
+
return [[
|
|
261
|
+
*base,
|
|
262
|
+
"-f", _fmt_mp4_apple_safe(cap),
|
|
263
|
+
"--merge-output-format", "mp4",
|
|
264
|
+
url,
|
|
265
|
+
]]
|
|
266
|
+
|
|
267
|
+
|
|
295
268
|
# =========================
|
|
296
|
-
#
|
|
269
|
+
# Legacy (keep for compatibility)
|
|
297
270
|
# =========================
|
|
298
271
|
def download_video(
|
|
299
272
|
url: str,
|
|
@@ -301,63 +274,38 @@ def download_video(
|
|
|
301
274
|
extension: Optional[str] = None,
|
|
302
275
|
out_dir: str = DEFAULT_OUT_DIR,
|
|
303
276
|
) -> str:
|
|
277
|
+
"""
|
|
278
|
+
Original blocking API (kept so existing imports don't break).
|
|
279
|
+
The VPS API server now uses build_download_plan + Popen streaming instead.
|
|
280
|
+
"""
|
|
281
|
+
import os
|
|
282
|
+
|
|
304
283
|
if not url:
|
|
305
284
|
raise RuntimeError("Missing URL")
|
|
306
285
|
|
|
307
286
|
out_dir = os.path.abspath(out_dir)
|
|
308
287
|
os.makedirs(out_dir, exist_ok=True)
|
|
309
288
|
|
|
310
|
-
|
|
289
|
+
prepare_network()
|
|
290
|
+
try:
|
|
291
|
+
plan = build_download_plan(url=url, resolution=resolution, extension=extension, out_dir=out_dir)
|
|
311
292
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
293
|
+
last_out = ""
|
|
294
|
+
for argv in plan:
|
|
295
|
+
rc, out = _run_argv_capture(argv)
|
|
296
|
+
last_out = out or ""
|
|
297
|
+
# Find the printed after_move:filepath absolute path (first matching line).
|
|
298
|
+
for raw in (last_out or "").splitlines():
|
|
299
|
+
line = (raw or "").strip()
|
|
300
|
+
if os.path.isabs(line) and line.startswith(out_dir):
|
|
301
|
+
if os.path.exists(line):
|
|
302
|
+
return os.path.abspath(line)
|
|
316
303
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return _download_with_format(
|
|
323
|
-
url=url,
|
|
324
|
-
out_dir=out_dir,
|
|
325
|
-
fmt="bestaudio",
|
|
326
|
-
merge_output_format=None,
|
|
327
|
-
extract_mp3=True,
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
cap = int(resolution or 1080)
|
|
331
|
-
|
|
332
|
-
if mode == "best":
|
|
333
|
-
# Try best first (may produce webm/mkv/etc).
|
|
334
|
-
try:
|
|
335
|
-
return _download_with_format(
|
|
336
|
-
url=url,
|
|
337
|
-
out_dir=out_dir,
|
|
338
|
-
fmt=_fmt_best(cap),
|
|
339
|
-
merge_output_format=None,
|
|
340
|
-
extract_mp3=False,
|
|
341
|
-
)
|
|
342
|
-
except Exception:
|
|
343
|
-
# If best fails for any reason, fall back to Apple-safe MP4.
|
|
344
|
-
return _download_with_format(
|
|
345
|
-
url=url,
|
|
346
|
-
out_dir=out_dir,
|
|
347
|
-
fmt=_fmt_mp4_apple_safe(cap),
|
|
348
|
-
merge_output_format="mp4",
|
|
349
|
-
extract_mp3=False,
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
# Default / "mp4" mode: force Apple-safe MP4 up to cap.
|
|
353
|
-
return _download_with_format(
|
|
354
|
-
url=url,
|
|
355
|
-
out_dir=out_dir,
|
|
356
|
-
fmt=_fmt_mp4_apple_safe(cap),
|
|
357
|
-
merge_output_format="mp4",
|
|
358
|
-
extract_mp3=False,
|
|
359
|
-
)
|
|
304
|
+
if rc == 0:
|
|
305
|
+
break
|
|
306
|
+
|
|
307
|
+
tail = _tail(last_out)
|
|
308
|
+
raise RuntimeError(f"Download completed but output file not found\n{tail}")
|
|
360
309
|
|
|
361
310
|
finally:
|
|
362
|
-
|
|
363
|
-
_run_argv(["mullvad", "disconnect"], check=False)
|
|
311
|
+
cleanup_network()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: ytp-dl
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.7
|
|
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.7 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.7` + `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.7 + 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.7" "yt-dlp[default]" gunicorn
|
|
398
398
|
deactivate
|
|
399
399
|
|
|
400
400
|
echo "==> 3) API environment file (/etc/default/ytp-dl-api)"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
scripts/__init__.py,sha256=EbAplfCcyLD3Q_9sxemm6owCc5_UJv53vmlxy810p2s,152
|
|
2
|
+
scripts/api.py,sha256=gHVQgAJE2x0V1UpE0UTuBr1HU5yleYv7uhLmjN-2HXA,10454
|
|
3
|
+
scripts/downloader.py,sha256=NWPJPP2WcXUt4X2yr4EPEqHG5e6N2GMdNufDgzhwDDg,8768
|
|
4
|
+
ytp_dl-0.6.7.dist-info/METADATA,sha256=JvG6aK5Z4VEshRBy0FWpDmh3Dcr3ro5c49kOsnOT3do,14547
|
|
5
|
+
ytp_dl-0.6.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
ytp_dl-0.6.7.dist-info/entry_points.txt,sha256=QqjqZZAEt3Y7RGrleqZ312sjjboUpbMLdo7qFxuCH30,48
|
|
7
|
+
ytp_dl-0.6.7.dist-info/top_level.txt,sha256=rmzd5mewlrJy4sT608KPib7sM7edoY75AeqJeY3SPB4,8
|
|
8
|
+
ytp_dl-0.6.7.dist-info/RECORD,,
|
ytp_dl-0.6.5.dist-info/RECORD
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
scripts/__init__.py,sha256=EbAplfCcyLD3Q_9sxemm6owCc5_UJv53vmlxy810p2s,152
|
|
2
|
-
scripts/api.py,sha256=cyLjHmelLwzh8-GOjqXsQdhm6wLX8bOkADZ_qU1naRQ,4331
|
|
3
|
-
scripts/downloader.py,sha256=vvHasu-41DGPDUzOTA4kz52tijTkaii1NnuU4cHQxg8,10825
|
|
4
|
-
ytp_dl-0.6.5.dist-info/METADATA,sha256=XVL2522dMVIjIPh8cHZXSkkJM-7GRb-KyS_lfAwcOBI,14547
|
|
5
|
-
ytp_dl-0.6.5.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
6
|
-
ytp_dl-0.6.5.dist-info/entry_points.txt,sha256=QqjqZZAEt3Y7RGrleqZ312sjjboUpbMLdo7qFxuCH30,48
|
|
7
|
-
ytp_dl-0.6.5.dist-info/top_level.txt,sha256=rmzd5mewlrJy4sT608KPib7sM7edoY75AeqJeY3SPB4,8
|
|
8
|
-
ytp_dl-0.6.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|