ytp-dl 0.7.0__py3-none-any.whl → 0.7.1__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 +121 -139
- scripts/downloader.py +101 -114
- {ytp_dl-0.7.0.dist-info → ytp_dl-0.7.1.dist-info}/METADATA +5 -5
- ytp_dl-0.7.1.dist-info/RECORD +8 -0
- ytp_dl-0.7.0.dist-info/RECORD +0 -8
- {ytp_dl-0.7.0.dist-info → ytp_dl-0.7.1.dist-info}/WHEEL +0 -0
- {ytp_dl-0.7.0.dist-info → ytp_dl-0.7.1.dist-info}/entry_points.txt +0 -0
- {ytp_dl-0.7.0.dist-info → ytp_dl-0.7.1.dist-info}/top_level.txt +0 -0
scripts/api.py
CHANGED
|
@@ -3,19 +3,15 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
-
import re
|
|
7
6
|
import shutil
|
|
8
7
|
import tempfile
|
|
9
8
|
import time
|
|
10
9
|
from threading import BoundedSemaphore, Lock
|
|
10
|
+
from typing import Optional
|
|
11
11
|
|
|
12
|
-
from flask import Flask,
|
|
12
|
+
from flask import Flask, request, send_file, jsonify, Response, stream_with_context
|
|
13
13
|
|
|
14
|
-
from .downloader import
|
|
15
|
-
download_video,
|
|
16
|
-
download_video_stream,
|
|
17
|
-
validate_environment,
|
|
18
|
-
)
|
|
14
|
+
from .downloader import validate_environment, download_video, download_video_stream
|
|
19
15
|
|
|
20
16
|
app = Flask(__name__)
|
|
21
17
|
|
|
@@ -36,8 +32,6 @@ STALE_JOB_TTL_S = int(os.environ.get("YTPDL_STALE_JOB_TTL_S", "3600"))
|
|
|
36
32
|
|
|
37
33
|
_ALLOWED_EXTENSIONS = {"mp3", "mp4", "best"}
|
|
38
34
|
|
|
39
|
-
_JOB_ID_RX = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
40
|
-
|
|
41
35
|
|
|
42
36
|
def _cleanup_stale_jobs() -> None:
|
|
43
37
|
now = time.time()
|
|
@@ -56,14 +50,6 @@ def _cleanup_stale_jobs() -> None:
|
|
|
56
50
|
pass
|
|
57
51
|
|
|
58
52
|
|
|
59
|
-
def _safe_job_id(raw: str | None) -> str:
|
|
60
|
-
s = (raw or "").strip()
|
|
61
|
-
if _JOB_ID_RX.match(s):
|
|
62
|
-
return s
|
|
63
|
-
# fallback to timestamp-based id (stable & filesystem-safe)
|
|
64
|
-
return str(int(time.time() * 1000))
|
|
65
|
-
|
|
66
|
-
|
|
67
53
|
def _try_acquire_job_slot() -> bool:
|
|
68
54
|
global _in_use
|
|
69
55
|
if not _sem.acquire(blocking=False):
|
|
@@ -81,12 +67,50 @@ def _release_job_slot() -> None:
|
|
|
81
67
|
_sem.release()
|
|
82
68
|
|
|
83
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
|
+
|
|
84
108
|
@app.route("/api/download", methods=["POST"])
|
|
85
109
|
def handle_download():
|
|
86
110
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
111
|
+
Legacy endpoint (unchanged):
|
|
112
|
+
- Runs yt-dlp to completion
|
|
113
|
+
- Returns the file as the HTTP response body
|
|
90
114
|
"""
|
|
91
115
|
_cleanup_stale_jobs()
|
|
92
116
|
|
|
@@ -106,8 +130,6 @@ def handle_download():
|
|
|
106
130
|
data = request.get_json(force=True)
|
|
107
131
|
url = (data.get("url") or "").strip()
|
|
108
132
|
resolution = data.get("resolution")
|
|
109
|
-
|
|
110
|
-
# extension is now a "mode": mp3 | mp4 | best
|
|
111
133
|
extension = (data.get("extension") or "mp4").strip().lower()
|
|
112
134
|
|
|
113
135
|
if not url:
|
|
@@ -169,11 +191,10 @@ def handle_download():
|
|
|
169
191
|
@app.route("/api/download_sse", methods=["POST"])
|
|
170
192
|
def handle_download_sse():
|
|
171
193
|
"""
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
- client fetches the final file via /api/fetch/<job_id>
|
|
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.
|
|
177
198
|
"""
|
|
178
199
|
_cleanup_stale_jobs()
|
|
179
200
|
|
|
@@ -188,136 +209,97 @@ def handle_download_sse():
|
|
|
188
209
|
released = True
|
|
189
210
|
_release_job_slot()
|
|
190
211
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 ""))
|
|
196
217
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
200
224
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return jsonify(
|
|
204
|
-
error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"
|
|
205
|
-
), 400
|
|
225
|
+
job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
|
|
226
|
+
os.makedirs(job_dir, exist_ok=True)
|
|
206
227
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
211
247
|
|
|
212
|
-
|
|
213
|
-
|
|
248
|
+
line = (line or "").strip()
|
|
249
|
+
if not line:
|
|
250
|
+
continue
|
|
251
|
+
yield f"data: {line}\n\n"
|
|
214
252
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
try:
|
|
218
|
-
# optional "ready" marker
|
|
219
|
-
yield f"data: [vps_ready] {job_id}\n\n"
|
|
220
|
-
|
|
221
|
-
dl = download_video_stream(
|
|
222
|
-
url=url,
|
|
223
|
-
resolution=resolution,
|
|
224
|
-
extension=extension,
|
|
225
|
-
out_dir=job_dir,
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
# manually iterate so we can capture StopIteration.value (the final path)
|
|
229
|
-
while True:
|
|
230
|
-
try:
|
|
231
|
-
line = next(dl)
|
|
232
|
-
except StopIteration as si:
|
|
233
|
-
file_path = si.value
|
|
234
|
-
break
|
|
235
|
-
|
|
236
|
-
if line:
|
|
237
|
-
yield f"data: {line}\n\n"
|
|
238
|
-
|
|
239
|
-
if not (file_path and os.path.exists(file_path)):
|
|
240
|
-
raise RuntimeError("Download completed but output file not found")
|
|
241
|
-
|
|
242
|
-
# Persist result for /api/fetch/<job_id>
|
|
243
|
-
try:
|
|
244
|
-
with open(result_path, "w", encoding="utf-8") as f:
|
|
245
|
-
json.dump(
|
|
246
|
-
{
|
|
247
|
-
"job_id": job_id,
|
|
248
|
-
"file_path": os.path.abspath(file_path),
|
|
249
|
-
"basename": os.path.basename(file_path),
|
|
250
|
-
"created_ts": time.time(),
|
|
251
|
-
},
|
|
252
|
-
f,
|
|
253
|
-
ensure_ascii=False,
|
|
254
|
-
)
|
|
255
|
-
except Exception:
|
|
256
|
-
pass
|
|
257
|
-
|
|
258
|
-
# Release slot as soon as yt-dlp is done (do NOT hold slot for fetch transfer)
|
|
259
|
-
_release_once()
|
|
260
|
-
|
|
261
|
-
yield f"data: [vps_done] {os.path.basename(file_path)}\n\n"
|
|
262
|
-
yield "data: [vps_end]\n\n"
|
|
263
|
-
return
|
|
264
|
-
|
|
265
|
-
except Exception as e:
|
|
266
|
-
try:
|
|
267
|
-
with open(err_path, "w", encoding="utf-8") as f:
|
|
268
|
-
f.write(str(e))
|
|
269
|
-
except Exception:
|
|
270
|
-
pass
|
|
271
|
-
|
|
272
|
-
_release_once()
|
|
273
|
-
msg = str(e)
|
|
274
|
-
# keep it as a single SSE line
|
|
275
|
-
yield f"data: [vps_error] {msg}\n\n"
|
|
276
|
-
yield "data: [vps_end]\n\n"
|
|
277
|
-
return
|
|
278
|
-
|
|
279
|
-
finally:
|
|
280
|
-
# ensure slot is released even if client disconnects mid-stream
|
|
281
|
-
_release_once()
|
|
282
|
-
|
|
283
|
-
resp = Response(stream_with_context(gen()), content_type="text/event-stream")
|
|
284
|
-
resp.headers["Cache-Control"] = "no-cache"
|
|
285
|
-
resp.headers["X-Accel-Buffering"] = "no"
|
|
286
|
-
return resp
|
|
253
|
+
if not final_path or not os.path.exists(final_path):
|
|
254
|
+
raise RuntimeError("Download completed but output file not found")
|
|
287
255
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
291
271
|
|
|
292
272
|
|
|
293
273
|
@app.route("/api/fetch/<job_id>", methods=["GET"])
|
|
294
|
-
def
|
|
274
|
+
def fetch_job(job_id: str):
|
|
295
275
|
"""
|
|
296
|
-
|
|
297
|
-
|
|
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.
|
|
298
279
|
"""
|
|
299
280
|
job_id = _safe_job_id(job_id)
|
|
300
281
|
job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
|
|
301
|
-
|
|
282
|
+
meta = _read_result(job_dir)
|
|
283
|
+
if not meta:
|
|
284
|
+
return jsonify(error="Job not found or not ready"), 404
|
|
302
285
|
|
|
303
|
-
|
|
304
|
-
|
|
286
|
+
path = (meta.get("path") or "").strip()
|
|
287
|
+
if not path:
|
|
288
|
+
return jsonify(error="Job result missing path"), 500
|
|
305
289
|
|
|
290
|
+
# Prevent path traversal: ensure file lives inside job_dir
|
|
306
291
|
try:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
310
296
|
except Exception:
|
|
311
|
-
return jsonify(error="
|
|
312
|
-
|
|
313
|
-
file_path = os.path.abspath(file_path)
|
|
314
|
-
if not file_path.startswith(os.path.abspath(job_dir) + os.sep):
|
|
315
|
-
return jsonify(error="Invalid job file path"), 500
|
|
297
|
+
return jsonify(error="Invalid job result path"), 500
|
|
316
298
|
|
|
317
|
-
if not os.path.exists(
|
|
318
|
-
return jsonify(error="
|
|
299
|
+
if not os.path.exists(path):
|
|
300
|
+
return jsonify(error="File not found"), 404
|
|
319
301
|
|
|
320
|
-
response = send_file(
|
|
302
|
+
response = send_file(path, as_attachment=True)
|
|
321
303
|
|
|
322
304
|
def _cleanup() -> None:
|
|
323
305
|
try:
|
|
@@ -343,4 +325,4 @@ def main():
|
|
|
343
325
|
|
|
344
326
|
|
|
345
327
|
if __name__ == "__main__":
|
|
346
|
-
main()
|
|
328
|
+
main()
|
scripts/downloader.py
CHANGED
|
@@ -7,7 +7,7 @@ import shutil
|
|
|
7
7
|
import subprocess
|
|
8
8
|
import time
|
|
9
9
|
from collections import deque
|
|
10
|
-
from typing import
|
|
10
|
+
from typing import Optional, List, Tuple, Deque, Generator
|
|
11
11
|
|
|
12
12
|
# =========================
|
|
13
13
|
# Config / constants
|
|
@@ -61,13 +61,6 @@ def _tail(out: str) -> str:
|
|
|
61
61
|
return txt.strip()
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
def _tail_deque(lines: Deque[str]) -> str:
|
|
65
|
-
txt = "\n".join(list(lines)[-_MAX_ERR_LINES:])
|
|
66
|
-
if len(txt) > _MAX_ERR_CHARS:
|
|
67
|
-
txt = txt[-_MAX_ERR_CHARS:]
|
|
68
|
-
return txt.strip()
|
|
69
|
-
|
|
70
|
-
|
|
71
64
|
def _is_youtube_url(url: str) -> bool:
|
|
72
65
|
u = (url or "").lower()
|
|
73
66
|
return any(h in u for h in ("youtube.com", "youtu.be", "youtube-nocookie.com"))
|
|
@@ -137,19 +130,14 @@ def _common_flags() -> List[str]:
|
|
|
137
130
|
# --no-playlist prevents accidental channel/playlist pulls (and disk blowups)
|
|
138
131
|
return [
|
|
139
132
|
"--no-playlist",
|
|
140
|
-
"--retries",
|
|
141
|
-
"10",
|
|
142
|
-
"--
|
|
143
|
-
"
|
|
144
|
-
"--retry-sleep",
|
|
145
|
-
"exp=1:30",
|
|
146
|
-
"--user-agent",
|
|
147
|
-
MODERN_UA,
|
|
133
|
+
"--retries", "10",
|
|
134
|
+
"--fragment-retries", "10",
|
|
135
|
+
"--retry-sleep", "exp=1:30",
|
|
136
|
+
"--user-agent", MODERN_UA,
|
|
148
137
|
"--no-cache-dir",
|
|
149
138
|
"--ignore-config",
|
|
150
139
|
"--embed-metadata",
|
|
151
|
-
"--sleep-interval",
|
|
152
|
-
"1",
|
|
140
|
+
"--sleep-interval", "1",
|
|
153
141
|
]
|
|
154
142
|
|
|
155
143
|
|
|
@@ -262,14 +250,11 @@ def _download_with_format(
|
|
|
262
250
|
|
|
263
251
|
argv = [
|
|
264
252
|
YTDLP_BIN,
|
|
265
|
-
"-f",
|
|
266
|
-
fmt,
|
|
253
|
+
"-f", fmt,
|
|
267
254
|
*(_common_flags()),
|
|
268
|
-
"--output",
|
|
269
|
-
out_tpl,
|
|
255
|
+
"--output", out_tpl,
|
|
270
256
|
# Ensure we can reliably pick the final output path.
|
|
271
|
-
"--print",
|
|
272
|
-
"after_move:filepath",
|
|
257
|
+
"--print", "after_move:filepath",
|
|
273
258
|
]
|
|
274
259
|
|
|
275
260
|
if extract_mp3:
|
|
@@ -294,20 +279,6 @@ def _download_with_format(
|
|
|
294
279
|
raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{tail}")
|
|
295
280
|
|
|
296
281
|
|
|
297
|
-
def _fmt_mp4_apple_safe(cap: int) -> str:
|
|
298
|
-
# Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
|
|
299
|
-
return (
|
|
300
|
-
f"bv*[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)']"
|
|
301
|
-
f"+ba[ext=m4a][acodec~='^mp4a']"
|
|
302
|
-
f"/b[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)'][acodec~='^mp4a']"
|
|
303
|
-
)
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
def _fmt_best(cap: int) -> str:
|
|
307
|
-
# Best overall up to cap (can yield webm/mkv/etc).
|
|
308
|
-
return f"bv*[height<={cap}]+ba/b[height<={cap}]"
|
|
309
|
-
|
|
310
|
-
|
|
311
282
|
def _download_with_format_stream(
|
|
312
283
|
url: str,
|
|
313
284
|
out_dir: str,
|
|
@@ -316,26 +287,26 @@ def _download_with_format_stream(
|
|
|
316
287
|
extract_mp3: bool = False,
|
|
317
288
|
) -> Generator[str, None, str]:
|
|
318
289
|
"""
|
|
319
|
-
Stream yt-dlp
|
|
320
|
-
|
|
321
|
-
Returns (StopIteration.value): absolute final output path
|
|
290
|
+
Stream yt-dlp output line-by-line (stdout+stderr merged),
|
|
291
|
+
and return the final output path via StopIteration.value.
|
|
322
292
|
"""
|
|
323
293
|
out_dir = os.path.abspath(out_dir)
|
|
324
294
|
os.makedirs(out_dir, exist_ok=True)
|
|
295
|
+
|
|
325
296
|
out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
|
|
326
297
|
|
|
327
|
-
argv
|
|
298
|
+
argv = [
|
|
328
299
|
YTDLP_BIN,
|
|
329
|
-
"-f",
|
|
330
|
-
fmt,
|
|
300
|
+
"-f", fmt,
|
|
331
301
|
*(_common_flags()),
|
|
332
|
-
|
|
302
|
+
"--output", out_tpl,
|
|
303
|
+
|
|
304
|
+
# Make progress lines flush as newline-terminated output
|
|
333
305
|
"--progress",
|
|
334
306
|
"--newline",
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
"--print",
|
|
338
|
-
"after_move:filepath",
|
|
307
|
+
|
|
308
|
+
# Ensure we can reliably obtain the post-processed final path
|
|
309
|
+
"--print", "after_move:filepath",
|
|
339
310
|
]
|
|
340
311
|
|
|
341
312
|
if extract_mp3:
|
|
@@ -346,10 +317,6 @@ def _download_with_format_stream(
|
|
|
346
317
|
|
|
347
318
|
argv.append(url)
|
|
348
319
|
|
|
349
|
-
tail_lines: Deque[str] = deque(maxlen=_MAX_ERR_LINES)
|
|
350
|
-
# Keep only a bounded capture for path extraction.
|
|
351
|
-
cap_lines: Deque[str] = deque(maxlen=2000)
|
|
352
|
-
|
|
353
320
|
proc = subprocess.Popen(
|
|
354
321
|
argv,
|
|
355
322
|
stdout=subprocess.PIPE,
|
|
@@ -359,55 +326,65 @@ def _download_with_format_stream(
|
|
|
359
326
|
universal_newlines=True,
|
|
360
327
|
)
|
|
361
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
|
+
|
|
362
334
|
try:
|
|
363
|
-
assert proc.stdout is not None
|
|
364
335
|
for raw in iter(proc.stdout.readline, ""):
|
|
365
336
|
line = (raw or "").rstrip("\n")
|
|
366
337
|
if not line:
|
|
367
338
|
continue
|
|
368
339
|
|
|
369
|
-
|
|
370
|
-
cap_lines.append(line)
|
|
371
|
-
yield line
|
|
340
|
+
tail_buf.append(line)
|
|
372
341
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
378
351
|
|
|
379
|
-
|
|
380
|
-
return os.path.abspath(path)
|
|
352
|
+
yield line
|
|
381
353
|
|
|
382
|
-
|
|
354
|
+
finally:
|
|
383
355
|
try:
|
|
384
|
-
|
|
385
|
-
best_mtime = -1.0
|
|
386
|
-
for name in os.listdir(out_dir):
|
|
387
|
-
if name.endswith((".part", ".ytdl", ".tmp")):
|
|
388
|
-
continue
|
|
389
|
-
full = os.path.join(out_dir, name)
|
|
390
|
-
if not os.path.isfile(full):
|
|
391
|
-
continue
|
|
392
|
-
mt = os.path.getmtime(full)
|
|
393
|
-
if mt > best_mtime:
|
|
394
|
-
best_mtime = mt
|
|
395
|
-
best_path = full
|
|
396
|
-
if best_path and os.path.exists(best_path):
|
|
397
|
-
return os.path.abspath(best_path)
|
|
356
|
+
proc.stdout.close()
|
|
398
357
|
except Exception:
|
|
399
358
|
pass
|
|
400
359
|
|
|
401
|
-
|
|
402
|
-
raise RuntimeError(f"yt-dlp failed (format: {fmt})\n{_tail_deque(tail_lines)}")
|
|
403
|
-
raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{_tail_deque(tail_lines)}")
|
|
360
|
+
rc = proc.wait()
|
|
404
361
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
+
|
|
376
|
+
def _fmt_mp4_apple_safe(cap: int) -> str:
|
|
377
|
+
# Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
|
|
378
|
+
return (
|
|
379
|
+
f"bv*[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)']"
|
|
380
|
+
f"+ba[ext=m4a][acodec~='^mp4a']"
|
|
381
|
+
f"/b[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)'][acodec~='^mp4a']"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _fmt_best(cap: int) -> str:
|
|
386
|
+
# Best overall up to cap (can yield webm/mkv/etc).
|
|
387
|
+
return f"bv*[height<={cap}]+ba/b[height<={cap}]"
|
|
411
388
|
|
|
412
389
|
|
|
413
390
|
# =========================
|
|
@@ -488,8 +465,8 @@ def download_video_stream(
|
|
|
488
465
|
out_dir: str = DEFAULT_OUT_DIR,
|
|
489
466
|
) -> Generator[str, None, str]:
|
|
490
467
|
"""
|
|
491
|
-
|
|
492
|
-
|
|
468
|
+
Stream raw yt-dlp output lines and return the final file path.
|
|
469
|
+
Mirrors download_video(), but line-streaming instead of capture.
|
|
493
470
|
"""
|
|
494
471
|
if not url:
|
|
495
472
|
raise RuntimeError("Missing URL")
|
|
@@ -508,42 +485,52 @@ def download_video_stream(
|
|
|
508
485
|
mode = (extension or "mp4").lower().strip()
|
|
509
486
|
|
|
510
487
|
if mode == "mp3":
|
|
511
|
-
|
|
488
|
+
g = _download_with_format_stream(
|
|
512
489
|
url=url,
|
|
513
490
|
out_dir=out_dir,
|
|
514
491
|
fmt="bestaudio",
|
|
515
492
|
merge_output_format=None,
|
|
516
493
|
extract_mp3=True,
|
|
517
|
-
)
|
|
518
|
-
|
|
519
|
-
cap = int(resolution or 1080)
|
|
494
|
+
)
|
|
520
495
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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(
|
|
532
519
|
url=url,
|
|
533
520
|
out_dir=out_dir,
|
|
534
521
|
fmt=_fmt_mp4_apple_safe(cap),
|
|
535
522
|
merge_output_format="mp4",
|
|
536
523
|
extract_mp3=False,
|
|
537
|
-
)
|
|
524
|
+
)
|
|
538
525
|
|
|
539
|
-
return
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
546
533
|
|
|
547
534
|
finally:
|
|
548
535
|
if _mullvad_present():
|
|
549
|
-
_run_argv(["mullvad", "disconnect"], check=False)
|
|
536
|
+
_run_argv(["mullvad", "disconnect"], check=False)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ytp-dl
|
|
3
|
-
Version: 0.7.
|
|
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.7.
|
|
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.7.
|
|
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.7.
|
|
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.7.
|
|
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)"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
scripts/__init__.py,sha256=EbAplfCcyLD3Q_9sxemm6owCc5_UJv53vmlxy810p2s,152
|
|
2
|
+
scripts/api.py,sha256=dqVcS1niL2ziv3w3WipiDH8T0peNlSzRCBfm1xxR4N0,9562
|
|
3
|
+
scripts/downloader.py,sha256=Fo8srnATM9Eee0yWvGsr5LKp6Zg8KTpsW7TFk24YZlg,15916
|
|
4
|
+
ytp_dl-0.7.1.dist-info/METADATA,sha256=8WsJ-TM4orFucsTAEck6S95dAuh5P2eRLVpurKlFhDk,14547
|
|
5
|
+
ytp_dl-0.7.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
ytp_dl-0.7.1.dist-info/entry_points.txt,sha256=QqjqZZAEt3Y7RGrleqZ312sjjboUpbMLdo7qFxuCH30,48
|
|
7
|
+
ytp_dl-0.7.1.dist-info/top_level.txt,sha256=rmzd5mewlrJy4sT608KPib7sM7edoY75AeqJeY3SPB4,8
|
|
8
|
+
ytp_dl-0.7.1.dist-info/RECORD,,
|
ytp_dl-0.7.0.dist-info/RECORD
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
scripts/__init__.py,sha256=EbAplfCcyLD3Q_9sxemm6owCc5_UJv53vmlxy810p2s,152
|
|
2
|
-
scripts/api.py,sha256=Lwu-Bb1fg6fjKBECyNOtvt3Q6Kna5qwmYUT50X51vUk,10356
|
|
3
|
-
scripts/downloader.py,sha256=Zpjoxcm8HJ2_j7xMXQysfEKXanOHKtbLAFwuYkzqc4w,16049
|
|
4
|
-
ytp_dl-0.7.0.dist-info/METADATA,sha256=KG6IndrPKUI5xOzA3Ldx0iKTqBbwjoqAYDEIxVtXKdU,14547
|
|
5
|
-
ytp_dl-0.7.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
-
ytp_dl-0.7.0.dist-info/entry_points.txt,sha256=QqjqZZAEt3Y7RGrleqZ312sjjboUpbMLdo7qFxuCH30,48
|
|
7
|
-
ytp_dl-0.7.0.dist-info/top_level.txt,sha256=rmzd5mewlrJy4sT608KPib7sM7edoY75AeqJeY3SPB4,8
|
|
8
|
-
ytp_dl-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|