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 +178 -118
- scripts/downloader.py +283 -103
- {ytp_dl-0.6.8.dist-info → ytp_dl-0.7.0.dist-info}/METADATA +5 -5
- ytp_dl-0.7.0.dist-info/RECORD +8 -0
- ytp_dl-0.6.8.dist-info/RECORD +0 -8
- {ytp_dl-0.6.8.dist-info → ytp_dl-0.7.0.dist-info}/WHEEL +0 -0
- {ytp_dl-0.6.8.dist-info → ytp_dl-0.7.0.dist-info}/entry_points.txt +0 -0
- {ytp_dl-0.6.8.dist-info → ytp_dl-0.7.0.dist-info}/top_level.txt +0 -0
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,
|
|
12
|
+
from flask import Flask, Response, jsonify, request, send_file, stream_with_context
|
|
13
13
|
|
|
14
|
-
from .downloader import
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
#
|
|
201
|
-
yield
|
|
202
|
-
|
|
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
|
-
|
|
208
|
-
#
|
|
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
|
-
|
|
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/
|
|
237
|
-
def
|
|
293
|
+
@app.route("/api/fetch/<job_id>", methods=["GET"])
|
|
294
|
+
def fetch_job_file(job_id: str):
|
|
238
295
|
"""
|
|
239
|
-
|
|
240
|
-
the
|
|
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
|
-
|
|
243
|
-
|
|
299
|
+
job_id = _safe_job_id(job_id)
|
|
244
300
|
job_dir = os.path.join(BASE_DOWNLOAD_DIR, job_id)
|
|
245
|
-
|
|
246
|
-
return jsonify(error="Job not found"), 404
|
|
301
|
+
result_path = os.path.join(job_dir, "result.json")
|
|
247
302
|
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
return jsonify(error="
|
|
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
|
|
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
|
|
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",
|
|
128
|
-
"
|
|
129
|
-
"--
|
|
130
|
-
"
|
|
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
|
-
"--
|
|
150
|
+
"--embed-metadata",
|
|
151
|
+
"--sleep-interval",
|
|
152
|
+
"1",
|
|
134
153
|
]
|
|
135
154
|
|
|
136
155
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
f"bv*[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)']"
|
|
141
|
-
f"+ba[ext=m4a][acodec~='^mp4a']"
|
|
142
|
-
f"/b[height<={cap}][ext=mp4][vcodec~='^(avc1|h264)'][acodec~='^mp4a']"
|
|
143
|
-
)
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
+
if best_path:
|
|
244
|
+
return best_path
|
|
166
245
|
except Exception:
|
|
167
|
-
|
|
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
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
"--
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
"
|
|
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")
|
|
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
|
-
|
|
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.
|
|
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.
|
|
254
|
-
proc.
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,,
|
ytp_dl-0.6.8.dist-info/RECORD
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|