ytp-dl 0.6.8__py3-none-any.whl → 0.7.0__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 CHANGED
@@ -3,15 +3,19 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import os
6
+ import re
6
7
  import shutil
7
8
  import tempfile
8
9
  import time
9
10
  from threading import BoundedSemaphore, Lock
10
- from typing import Optional
11
11
 
12
- from flask import Flask, request, send_file, jsonify, Response, stream_with_context
12
+ from flask import Flask, Response, jsonify, request, send_file, stream_with_context
13
13
 
14
- from .downloader import validate_environment, download_video_stream
14
+ from .downloader import (
15
+ download_video,
16
+ download_video_stream,
17
+ validate_environment,
18
+ )
15
19
 
16
20
  app = Flask(__name__)
17
21
 
@@ -32,6 +36,8 @@ STALE_JOB_TTL_S = int(os.environ.get("YTPDL_STALE_JOB_TTL_S", "3600"))
32
36
 
33
37
  _ALLOWED_EXTENSIONS = {"mp3", "mp4", "best"}
34
38
 
39
+ _JOB_ID_RX = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
40
+
35
41
 
36
42
  def _cleanup_stale_jobs() -> None:
37
43
  now = time.time()
@@ -50,6 +56,14 @@ def _cleanup_stale_jobs() -> None:
50
56
  pass
51
57
 
52
58
 
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
+
53
67
  def _try_acquire_job_slot() -> bool:
54
68
  global _in_use
55
69
  if not _sem.acquire(blocking=False):
@@ -67,55 +81,105 @@ def _release_job_slot() -> None:
67
81
  _sem.release()
68
82
 
69
83
 
70
- def _job_meta_path(job_dir: str) -> str:
71
- return os.path.join(job_dir, "job.json")
84
+ @app.route("/api/download", methods=["POST"])
85
+ def handle_download():
86
+ """
87
+ Backwards-compatible direct download endpoint:
88
+ - runs yt-dlp
89
+ - returns the file body directly
90
+ """
91
+ _cleanup_stale_jobs()
72
92
 
93
+ if not _try_acquire_job_slot():
94
+ return jsonify(error="Server busy, try again later"), 503
73
95
 
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
96
+ job_dir: str | None = None
97
+ released = False
80
98
 
99
+ def _release_once() -> None:
100
+ nonlocal released
101
+ if not released:
102
+ released = True
103
+ _release_job_slot()
81
104
 
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
105
  try:
87
- with open(path, "r", encoding="utf-8") as f:
88
- return json.load(f)
89
- except Exception:
90
- return None
106
+ data = request.get_json(force=True)
107
+ url = (data.get("url") or "").strip()
108
+ resolution = data.get("resolution")
91
109
 
110
+ # extension is now a "mode": mp3 | mp4 | best
111
+ extension = (data.get("extension") or "mp4").strip().lower()
92
112
 
93
- def _sse_message(data: str) -> str:
94
- # one "message" event
95
- return f"data: {data}\n\n"
113
+ if not url:
114
+ _release_once()
115
+ return jsonify(error="Missing 'url'"), 400
96
116
 
117
+ if extension not in _ALLOWED_EXTENSIONS:
118
+ _release_once()
119
+ return jsonify(
120
+ error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"
121
+ ), 400
97
122
 
98
- def _sse_event(event_name: str, data: str) -> str:
99
- # custom event type
100
- return f"event: {event_name}\ndata: {data}\n\n"
123
+ job_dir = tempfile.mkdtemp(prefix="ytpdl_", dir=BASE_DOWNLOAD_DIR)
101
124
 
125
+ # yt-dlp work (guarded by semaphore)
126
+ filename = download_video(
127
+ url=url,
128
+ resolution=resolution,
129
+ extension=extension,
130
+ out_dir=job_dir,
131
+ )
102
132
 
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"}
133
+ if not (filename and os.path.exists(filename)):
134
+ raise RuntimeError("Download failed")
135
+
136
+ # Release slot as soon as yt-dlp is done.
137
+ _release_once()
138
+
139
+ response = send_file(filename, as_attachment=True)
140
+
141
+ # Cleanup directory after client finishes consuming the response.
142
+ def _cleanup() -> None:
143
+ try:
144
+ if job_dir:
145
+ shutil.rmtree(job_dir, ignore_errors=True)
146
+ except Exception:
147
+ pass
148
+
149
+ response.call_on_close(_cleanup)
150
+ return response
109
151
 
110
- The finished file is retrieved separately via:
111
- GET /api/file/<job_id>
152
+ except RuntimeError as e:
153
+ if job_dir:
154
+ shutil.rmtree(job_dir, ignore_errors=True)
155
+ _release_once()
156
+
157
+ msg = str(e)
158
+ if "Mullvad not logged in" in msg:
159
+ return jsonify(error=msg), 503
160
+ return jsonify(error=f"Download failed: {msg}"), 500
161
+
162
+ except Exception as e:
163
+ if job_dir:
164
+ shutil.rmtree(job_dir, ignore_errors=True)
165
+ _release_once()
166
+ return jsonify(error=f"Download failed: {str(e)}"), 500
167
+
168
+
169
+ @app.route("/api/download_sse", methods=["POST"])
170
+ def handle_download_sse():
171
+ """
172
+ New SSE endpoint:
173
+ - streams raw yt-dlp stdout lines in real-time (SSE)
174
+ - stores result.json with the final file path in a stable job dir
175
+ - releases concurrency slot immediately when yt-dlp completes
176
+ - client fetches the final file via /api/fetch/<job_id>
112
177
  """
113
178
  _cleanup_stale_jobs()
114
179
 
115
180
  if not _try_acquire_job_slot():
116
181
  return jsonify(error="Server busy, try again later"), 503
117
182
 
118
- job_dir: str | None = None
119
183
  released = False
120
184
 
121
185
  def _release_once() -> None:
@@ -128,8 +192,6 @@ def handle_download():
128
192
  data = request.get_json(force=True)
129
193
  url = (data.get("url") or "").strip()
130
194
  resolution = data.get("resolution")
131
-
132
- # extension is now a "mode": mp3 | mp4 | best
133
195
  extension = (data.get("extension") or "mp4").strip().lower()
134
196
 
135
197
  if not url:
@@ -142,123 +204,121 @@ def handle_download():
142
204
  error=f"Invalid 'extension'. Allowed: {sorted(_ALLOWED_EXTENSIONS)}"
143
205
  ), 400
144
206
 
145
- job_dir = tempfile.mkdtemp(prefix="ytpdl_", dir=BASE_DOWNLOAD_DIR)
146
- job_id = os.path.basename(job_dir)
207
+ job_id = _safe_job_id(data.get("job_id"))
208
+ job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
209
+ shutil.rmtree(job_dir, ignore_errors=True)
210
+ os.makedirs(job_dir, exist_ok=True)
147
211
 
148
- def stream():
149
- nonlocal job_dir
212
+ result_path = os.path.join(job_dir, "result.json")
213
+ err_path = os.path.join(job_dir, "error.txt")
214
+
215
+ def gen():
216
+ file_path: str | None = None
150
217
  try:
151
- # ---- yt-dlp streamed logs ----
152
- # download_video_stream yields yt-dlp stdout lines.
153
- filename_path = yield from download_video_stream(
218
+ # optional "ready" marker
219
+ yield f"data: [vps_ready] {job_id}\n\n"
220
+
221
+ dl = download_video_stream(
154
222
  url=url,
155
223
  resolution=resolution,
156
224
  extension=extension,
157
225
  out_dir=job_dir,
158
226
  )
159
227
 
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.
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)
169
259
  _release_once()
170
260
 
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.")
261
+ yield f"data: [vps_done] {os.path.basename(file_path)}\n\n"
262
+ yield "data: [vps_end]\n\n"
192
263
  return
193
264
 
194
- except RuntimeError as e:
195
- if job_dir:
196
- shutil.rmtree(job_dir, ignore_errors=True)
197
- _release_once()
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
198
271
 
272
+ _release_once()
199
273
  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}))
274
+ # keep it as a single SSE line
275
+ yield f"data: [vps_error] {msg}\n\n"
276
+ yield "data: [vps_end]\n\n"
205
277
  return
206
278
 
207
- except GeneratorExit:
208
- # Client disconnected mid-stream; best-effort cleanup.
209
- if job_dir:
210
- shutil.rmtree(job_dir, ignore_errors=True)
279
+ finally:
280
+ # ensure slot is released even if client disconnects mid-stream
211
281
  _release_once()
212
- raise
213
282
 
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")
283
+ resp = Response(stream_with_context(gen()), content_type="text/event-stream")
225
284
  resp.headers["Cache-Control"] = "no-cache"
226
285
  resp.headers["X-Accel-Buffering"] = "no"
227
286
  return resp
228
287
 
229
288
  except Exception as e:
230
- if job_dir:
231
- shutil.rmtree(job_dir, ignore_errors=True)
232
289
  _release_once()
233
290
  return jsonify(error=f"Download failed: {str(e)}"), 500
234
291
 
235
292
 
236
- @app.route("/api/file/<job_id>", methods=["GET"])
237
- def fetch_file(job_id: str):
293
+ @app.route("/api/fetch/<job_id>", methods=["GET"])
294
+ def fetch_job_file(job_id: str):
238
295
  """
239
- After /api/download SSE completes with `event: result`,
240
- the caller fetches the finished file here.
296
+ Fetch the finished file for a job_id created by /api/download_sse.
297
+ Cleans up the job dir after response is fully consumed.
241
298
  """
242
- _cleanup_stale_jobs()
243
-
299
+ job_id = _safe_job_id(job_id)
244
300
  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
301
+ result_path = os.path.join(job_dir, "result.json")
247
302
 
248
- meta = _read_job_meta(job_dir)
249
- if not meta:
250
- return jsonify(error="Job metadata missing"), 404
303
+ if not os.path.exists(result_path):
304
+ return jsonify(error="Job not found or not complete"), 404
251
305
 
252
- file_path = meta.get("file_path")
253
- filename = meta.get("filename") or (os.path.basename(file_path) if file_path else None)
306
+ try:
307
+ with open(result_path, "r", encoding="utf-8") as f:
308
+ meta = json.load(f)
309
+ file_path = meta.get("file_path") or ""
310
+ except Exception:
311
+ return jsonify(error="Job metadata unreadable"), 500
254
312
 
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
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
316
+
317
+ if not os.path.exists(file_path):
318
+ return jsonify(error="Job file missing"), 404
258
319
 
259
- response = send_file(file_path, as_attachment=True, download_name=filename)
320
+ response = send_file(file_path, as_attachment=True)
260
321
 
261
- # Cleanup directory after client finishes consuming the response.
262
322
  def _cleanup() -> None:
263
323
  try:
264
324
  shutil.rmtree(job_dir, ignore_errors=True)
scripts/downloader.py CHANGED
@@ -6,7 +6,8 @@ import shlex
6
6
  import shutil
7
7
  import subprocess
8
8
  import time
9
- from typing import Optional, List, Tuple, Generator
9
+ from collections import deque
10
+ from typing import Deque, Generator, List, Optional, Tuple
10
11
 
11
12
  # =========================
12
13
  # Config / constants
@@ -60,6 +61,18 @@ def _tail(out: str) -> str:
60
61
  return txt.strip()
61
62
 
62
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
+ def _is_youtube_url(url: str) -> bool:
72
+ u = (url or "").lower()
73
+ return any(h in u for h in ("youtube.com", "youtu.be", "youtube-nocookie.com"))
74
+
75
+
63
76
  # =========================
64
77
  # Environment / Mullvad
65
78
  # =========================
@@ -124,31 +137,96 @@ def _common_flags() -> List[str]:
124
137
  # --no-playlist prevents accidental channel/playlist pulls (and disk blowups)
125
138
  return [
126
139
  "--no-playlist",
127
- "--retries", "10",
128
- "--fragment-retries", "10",
129
- "--retry-sleep", "exp=1:30",
130
- "--user-agent", MODERN_UA,
140
+ "--retries",
141
+ "10",
142
+ "--fragment-retries",
143
+ "10",
144
+ "--retry-sleep",
145
+ "exp=1:30",
146
+ "--user-agent",
147
+ MODERN_UA,
131
148
  "--no-cache-dir",
132
149
  "--ignore-config",
133
- "--sleep-interval", "1",
150
+ "--embed-metadata",
151
+ "--sleep-interval",
152
+ "1",
134
153
  ]
135
154
 
136
155
 
137
- def _fmt_mp4_apple_safe(cap: int) -> str:
138
- # Always pick the best Apple-safe MP4/H.264 + M4A/AAC up to cap.
139
- return (
140
- f"bv*[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)']"
141
- f"+ba[ext=m4a][acodec~='^mp4a']"
142
- f"/b[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)'][acodec~='^mp4a']"
143
- )
156
+ def _extract_final_path(stdout: str, out_dir: str) -> Optional[str]:
157
+ """
158
+ Robustly derive the final output file path from yt-dlp output.
144
159
 
160
+ Priority:
161
+ 1) --print after_move:filepath lines (absolute paths)
162
+ 2) [Merger] Merging formats into "..."
163
+ 3) Any Destination: lines that still exist
164
+ 4) Newest non-temp file in out_dir
165
+ """
166
+ candidates: List[str] = []
167
+ out_dir = os.path.abspath(out_dir)
145
168
 
146
- def _fmt_best(cap: int) -> str:
147
- # Best overall up to cap (can yield webm/mkv/etc).
148
- return f"bv*[height<={cap}]+ba/b[height<={cap}]"
169
+ for raw in (stdout or "").splitlines():
170
+ line = (raw or "").strip()
171
+ if not line:
172
+ continue
149
173
 
174
+ # 1) --print after_move:filepath (usually an absolute path)
175
+ if os.path.isabs(line) and line.startswith(out_dir):
176
+ candidates.append(line.strip("'\""))
177
+ continue
150
178
 
151
- def _newest_non_temp_file(out_dir: str) -> Optional[str]:
179
+ # 2) Merger line: ... into "path"
180
+ if "Merging formats into" in line and "\"" in line:
181
+ try:
182
+ merged = line.split("Merging formats into", 1)[1].strip()
183
+ if merged.startswith("\"") and merged.endswith("\""):
184
+ merged = merged[1:-1]
185
+ else:
186
+ if merged.startswith("\""):
187
+ merged = merged.split("\"", 2)[1]
188
+ if merged:
189
+ if not os.path.isabs(merged):
190
+ merged = os.path.join(out_dir, merged)
191
+ candidates.append(merged.strip("'\""))
192
+ except Exception:
193
+ pass
194
+ continue
195
+
196
+ # 3) Destination lines (download/extractaudio)
197
+ if "Destination:" in line:
198
+ try:
199
+ p = line.split("Destination:", 1)[1].strip().strip("'\"")
200
+ if p and not os.path.isabs(p):
201
+ p = os.path.join(out_dir, p)
202
+ if p:
203
+ candidates.append(p)
204
+ except Exception:
205
+ pass
206
+ continue
207
+
208
+ # already downloaded
209
+ if "] " in line and " has already been downloaded" in line:
210
+ try:
211
+ p = (
212
+ line.split("] ", 1)[1]
213
+ .split(" has already been downloaded", 1)[0]
214
+ .strip()
215
+ .strip("'\"")
216
+ )
217
+ if p and not os.path.isabs(p):
218
+ p = os.path.join(out_dir, p)
219
+ if p:
220
+ candidates.append(p)
221
+ except Exception:
222
+ pass
223
+
224
+ # Prefer existing, newest candidate (reverse traversal)
225
+ for p in reversed(candidates):
226
+ if p and os.path.exists(p):
227
+ return p
228
+
229
+ # 4) Fallback: newest non-temp file in out_dir
152
230
  try:
153
231
  best_path = None
154
232
  best_mtime = -1.0
@@ -162,13 +240,75 @@ def _newest_non_temp_file(out_dir: str) -> Optional[str]:
162
240
  if mt > best_mtime:
163
241
  best_mtime = mt
164
242
  best_path = full
165
- return best_path
243
+ if best_path:
244
+ return best_path
166
245
  except Exception:
167
- return None
246
+ pass
247
+
248
+ return None
249
+
250
+
251
+ def _download_with_format(
252
+ url: str,
253
+ out_dir: str,
254
+ fmt: str,
255
+ merge_output_format: Optional[str] = None,
256
+ extract_mp3: bool = False,
257
+ ) -> str:
258
+ out_dir = os.path.abspath(out_dir)
259
+ os.makedirs(out_dir, exist_ok=True)
260
+
261
+ out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
262
+
263
+ argv = [
264
+ YTDLP_BIN,
265
+ "-f",
266
+ fmt,
267
+ *(_common_flags()),
268
+ "--output",
269
+ out_tpl,
270
+ # Ensure we can reliably pick the final output path.
271
+ "--print",
272
+ "after_move:filepath",
273
+ ]
274
+
275
+ if extract_mp3:
276
+ # Force audio extraction to MP3 (requires ffmpeg)
277
+ argv.extend(["--extract-audio", "--audio-format", "mp3"])
278
+
279
+ # Only force merge container when we actually want MP4 output.
280
+ if merge_output_format:
281
+ argv.extend(["--merge-output-format", merge_output_format])
282
+
283
+ argv.append(url)
284
+
285
+ rc, out = _run_argv_capture(argv)
286
+ path = _extract_final_path(out, out_dir)
287
+
288
+ if path and os.path.exists(path):
289
+ return os.path.abspath(path)
290
+
291
+ tail = _tail(out)
292
+ if rc != 0:
293
+ raise RuntimeError(f"yt-dlp failed (format: {fmt})\n{tail}")
294
+ raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{tail}")
295
+
296
+
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}]"
168
309
 
169
310
 
170
311
  def _download_with_format_stream(
171
- *,
172
312
  url: str,
173
313
  out_dir: str,
174
314
  fmt: str,
@@ -176,45 +316,40 @@ def _download_with_format_stream(
176
316
  extract_mp3: bool = False,
177
317
  ) -> Generator[str, None, str]:
178
318
  """
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).
319
+ Stream yt-dlp stdout lines in real-time.
320
+ Yields: raw stdout lines
321
+ Returns (StopIteration.value): absolute final output path
183
322
  """
184
323
  out_dir = os.path.abspath(out_dir)
185
324
  os.makedirs(out_dir, exist_ok=True)
186
-
187
325
  out_tpl = os.path.join(out_dir, "%(title)s.%(ext)s")
188
326
 
189
327
  argv: List[str] = [
190
328
  YTDLP_BIN,
191
- url,
329
+ "-f",
330
+ fmt,
331
+ *(_common_flags()),
332
+ # ensure progress is emitted as real lines (no carriage-return overwrites)
192
333
  "--progress",
193
334
  "--newline",
194
- "--continue",
195
- "-f", fmt,
196
- *(_common_flags()),
197
- "--output", out_tpl,
198
- # Absolute final path for internal capture (we do NOT emit this line).
199
- "--print", "after_move:filepath",
200
- # Local parity signal:
201
- "--print", "after_move:[download_complete] %(title)s.%(ext)s",
335
+ "--output",
336
+ out_tpl,
337
+ "--print",
338
+ "after_move:filepath",
202
339
  ]
203
340
 
204
341
  if extract_mp3:
205
- argv.extend(
206
- [
207
- "--extract-audio",
208
- "--audio-format", "mp3",
209
- "--audio-quality", "0",
210
- "--embed-thumbnail",
211
- "--add-metadata",
212
- ]
213
- )
342
+ argv.extend(["--extract-audio", "--audio-format", "mp3"])
214
343
 
215
344
  if merge_output_format:
216
345
  argv.extend(["--merge-output-format", merge_output_format])
217
346
 
347
+ argv.append(url)
348
+
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
+
218
353
  proc = subprocess.Popen(
219
354
  argv,
220
355
  stdout=subprocess.PIPE,
@@ -224,58 +359,128 @@ def _download_with_format_stream(
224
359
  universal_newlines=True,
225
360
  )
226
361
 
227
- final_path: Optional[str] = None
228
- tail_lines: List[str] = []
229
-
230
362
  try:
231
363
  assert proc.stdout is not None
232
364
  for raw in iter(proc.stdout.readline, ""):
233
- line = (raw or "").rstrip("\n").rstrip("\r")
365
+ line = (raw or "").rstrip("\n")
234
366
  if not line:
235
367
  continue
236
368
 
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
369
  tail_lines.append(line)
244
- if len(tail_lines) > _MAX_ERR_LINES:
245
- tail_lines.pop(0)
246
-
247
- # Emit everything else (yt-dlp progress + [download_complete] line)
370
+ cap_lines.append(line)
248
371
  yield line
249
372
 
250
- proc.wait()
373
+ proc.stdout.close()
374
+ rc = proc.wait()
375
+
376
+ out_joined = "\n".join(cap_lines)
377
+ path = _extract_final_path(out_joined, out_dir)
378
+
379
+ if path and os.path.exists(path):
380
+ return os.path.abspath(path)
381
+
382
+ # fallback: newest non-temp file in out_dir
383
+ try:
384
+ best_path = None
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)
398
+ except Exception:
399
+ pass
400
+
401
+ if rc != 0:
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)}")
404
+
251
405
  finally:
252
406
  try:
253
- if proc.stdout:
254
- proc.stdout.close()
407
+ if proc.poll() is None:
408
+ proc.kill()
255
409
  except Exception:
256
410
  pass
257
411
 
258
- if proc.returncode != 0:
259
- tail = "\n".join(tail_lines)
260
- tail = _tail(tail)
261
- raise RuntimeError(f"yt-dlp failed (format: {fmt})\n{tail}")
262
412
 
263
- # Resolve final path
264
- if final_path and os.path.exists(final_path):
265
- return os.path.abspath(final_path)
413
+ # =========================
414
+ # Public API
415
+ # =========================
416
+ def download_video(
417
+ url: str,
418
+ resolution: int | None = 1080,
419
+ extension: Optional[str] = None,
420
+ out_dir: str = DEFAULT_OUT_DIR,
421
+ ) -> str:
422
+ if not url:
423
+ raise RuntimeError("Missing URL")
266
424
 
267
- # Fallback: newest output in out_dir
268
- newest = _newest_non_temp_file(out_dir)
269
- if newest and os.path.exists(newest):
270
- return os.path.abspath(newest)
425
+ out_dir = os.path.abspath(out_dir)
426
+ os.makedirs(out_dir, exist_ok=True)
271
427
 
272
- tail = _tail("\n".join(tail_lines))
273
- raise RuntimeError(f"Download completed but output file not found (format: {fmt})\n{tail}")
428
+ validate_environment()
429
+
430
+ require_mullvad_login()
431
+ mullvad_connect(MULLVAD_LOCATION)
432
+ if not mullvad_wait_connected():
433
+ raise RuntimeError("Mullvad connection failed")
434
+
435
+ try:
436
+ mode = (extension or "mp4").lower().strip()
437
+
438
+ if mode == "mp3":
439
+ # bestaudio -> ffmpeg -> mp3 (post-processed by yt-dlp)
440
+ return _download_with_format(
441
+ url=url,
442
+ out_dir=out_dir,
443
+ fmt="bestaudio",
444
+ merge_output_format=None,
445
+ extract_mp3=True,
446
+ )
447
+
448
+ cap = int(resolution or 1080)
449
+
450
+ if mode == "best":
451
+ # Try best first (may produce webm/mkv/etc).
452
+ try:
453
+ return _download_with_format(
454
+ url=url,
455
+ out_dir=out_dir,
456
+ fmt=_fmt_best(cap),
457
+ merge_output_format=None,
458
+ extract_mp3=False,
459
+ )
460
+ except Exception:
461
+ # If best fails for any reason, fall back to Apple-safe MP4.
462
+ return _download_with_format(
463
+ url=url,
464
+ out_dir=out_dir,
465
+ fmt=_fmt_mp4_apple_safe(cap),
466
+ merge_output_format="mp4",
467
+ extract_mp3=False,
468
+ )
469
+
470
+ # Default / "mp4" mode: force Apple-safe MP4 up to cap.
471
+ return _download_with_format(
472
+ url=url,
473
+ out_dir=out_dir,
474
+ fmt=_fmt_mp4_apple_safe(cap),
475
+ merge_output_format="mp4",
476
+ extract_mp3=False,
477
+ )
478
+
479
+ finally:
480
+ if _mullvad_present():
481
+ _run_argv(["mullvad", "disconnect"], check=False)
274
482
 
275
483
 
276
- # =========================
277
- # Public APIs
278
- # =========================
279
484
  def download_video_stream(
280
485
  url: str,
281
486
  resolution: int | None = 1080,
@@ -283,7 +488,8 @@ def download_video_stream(
283
488
  out_dir: str = DEFAULT_OUT_DIR,
284
489
  ) -> Generator[str, None, str]:
285
490
  """
286
- Streams yt-dlp logs and returns final file path (generator return value).
491
+ Streaming variant for SSE usage.
492
+ Yields raw yt-dlp stdout lines; returns final file path as StopIteration.value.
287
493
  """
288
494
  if not url:
289
495
  raise RuntimeError("Missing URL")
@@ -313,7 +519,6 @@ def download_video_stream(
313
519
  cap = int(resolution or 1080)
314
520
 
315
521
  if mode == "best":
316
- # Try best first (may produce webm/mkv/etc). If it fails, fall back to Apple-safe MP4.
317
522
  try:
318
523
  return (yield from _download_with_format_stream(
319
524
  url=url,
@@ -331,7 +536,6 @@ def download_video_stream(
331
536
  extract_mp3=False,
332
537
  ))
333
538
 
334
- # Default / "mp4" mode
335
539
  return (yield from _download_with_format_stream(
336
540
  url=url,
337
541
  out_dir=out_dir,
@@ -343,27 +547,3 @@ def download_video_stream(
343
547
  finally:
344
548
  if _mullvad_present():
345
549
  _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)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ytp-dl
3
- Version: 0.6.8
3
+ Version: 0.7.0
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.8 yt-dlp[default]
60
+ pip install ytp-dl==0.7.0 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.8` + `yt-dlp[default]` + `gunicorn`
245
+ * Installs `ytp-dl==0.7.0` + `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.8 + yt-dlp[default] + gunicorn in that venv
261
+ # - Installs ytp-dl==0.7.0 + 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.8" "yt-dlp[default]" gunicorn
397
+ pip install "ytp-dl==0.7.0" "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=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,,
@@ -1,8 +0,0 @@
1
- scripts/__init__.py,sha256=EbAplfCcyLD3Q_9sxemm6owCc5_UJv53vmlxy810p2s,152
2
- scripts/api.py,sha256=EMpD_vRX5FZQ-ICIxLuRqJxitvlbi1VnUflWMC4yvmw,8560
3
- scripts/downloader.py,sha256=LT7ANnpf7DRgVuRdNynqIXMKfKaeohy508OCXmltLtA,10529
4
- ytp_dl-0.6.8.dist-info/METADATA,sha256=g5Q33WgF9ZBJYLdFYSt2cAiyl9QPSD1YpOeXcFUw628,14547
5
- ytp_dl-0.6.8.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
- ytp_dl-0.6.8.dist-info/entry_points.txt,sha256=QqjqZZAEt3Y7RGrleqZ312sjjboUpbMLdo7qFxuCH30,48
7
- ytp_dl-0.6.8.dist-info/top_level.txt,sha256=rmzd5mewlrJy4sT608KPib7sM7edoY75AeqJeY3SPB4,8
8
- ytp_dl-0.6.8.dist-info/RECORD,,
File without changes