wuhp commited on
Commit
4c1d09c
·
verified ·
1 Parent(s): bd0b355

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +123 -56
app.py CHANGED
@@ -8,7 +8,10 @@ import requests
8
  import gradio as gr
9
  import bencodepy
10
  import py7zr
11
- from py7zr.exceptions import CrcError # for granular handling
 
 
 
12
 
13
  # =========================
14
  # Helpers
@@ -31,15 +34,6 @@ def fetch_bytes(url: str, timeout: int = 45) -> bytes:
31
  return r.content
32
 
33
  def parse_torrent(raw: bytes) -> Dict:
34
- """
35
- Return:
36
- {
37
- "infohash": str,
38
- "name": str,
39
- "files": [{"path": str, "length": int}, ...],
40
- "web_seeds": [str, ...]
41
- }
42
- """
43
  data = bencodepy.decode(raw)
44
  if not isinstance(data, dict) or b"info" not in data:
45
  raise ValueError("Invalid .torrent (missing 'info').")
@@ -89,7 +83,6 @@ def join_url(base: str, *segs: str) -> str:
89
  return "/".join(parts)
90
 
91
  def _head_or_peek(url: str, timeout: int = 20) -> Tuple[bool, Optional[int]]:
92
- # HEAD
93
  try:
94
  r = requests.head(url, timeout=timeout, allow_redirects=True)
95
  if r.status_code < 400:
@@ -97,7 +90,6 @@ def _head_or_peek(url: str, timeout: int = 20) -> Tuple[bool, Optional[int]]:
97
  return True, (int(size) if size and size.isdigit() else None)
98
  except Exception:
99
  pass
100
- # Tiny GET
101
  try:
102
  r = requests.get(url, stream=True, timeout=timeout, allow_redirects=True)
103
  if r.status_code < 400:
@@ -137,10 +129,6 @@ def supports_range_and_size(url: str, timeout: int = 30) -> Tuple[bool, Optional
137
 
138
  def download_file_exact(url: str, dest_path: pathlib.Path, expected_size: Optional[int],
139
  timeout: int = 120, max_attempts: int = 2):
140
- """
141
- Download to dest_path. If expected_size is known, enforce it.
142
- Attempt resume first; if mismatch, retry once with full GET.
143
- """
144
  dest_path.parent.mkdir(parents=True, exist_ok=True)
145
 
146
  def _resume_once():
@@ -180,7 +168,7 @@ def download_file_exact(url: str, dest_path: pathlib.Path, expected_size: Option
180
  _fresh_once()
181
 
182
  if expected_size is None:
183
- return # no verification possible
184
  if dest_path.exists() and dest_path.stat().st_size == expected_size:
185
  return
186
 
@@ -200,7 +188,7 @@ def preview_path(path_str: str, max_bytes: int = 250_000) -> Tuple[str, Optional
200
  p = pathlib.Path(path_str)
201
  suffix = p.suffix.lower()
202
  try:
203
- if suffix in [".csv", ".tsv", ".json", ".ndjson", ".txt", ".log", ".md", ".eml"]:
204
  raw = open(p, "rb").read(max_bytes)
205
  text = raw.decode("utf-8", errors="replace")
206
  return f"Previewing {p.name} (truncated):\n\n```\n{text}\n```", None
@@ -211,12 +199,6 @@ def preview_path(path_str: str, max_bytes: int = 250_000) -> Tuple[str, Optional
211
  return f"Error previewing file: {type(e).__name__}: {e}", None
212
 
213
  def infer_bases_from_torrent_url(torrent_url: str) -> List[str]:
214
- """
215
- For URLs like:
216
- https://data.ddosecrets.com/Collection/Collection.torrent
217
- return:
218
- ["https://data.ddosecrets.com/Collection"]
219
- """
220
  u = torrent_url.strip()
221
  if "/" not in u:
222
  return []
@@ -224,12 +206,6 @@ def infer_bases_from_torrent_url(torrent_url: str) -> List[str]:
224
  return [base]
225
 
226
  def resolve_download_url(bases: List[str], root_name: str, rel_path: str) -> Optional[str]:
227
- """
228
- Try both:
229
- base/root_name/rel_path
230
- base/rel_path
231
- Return the first that responds.
232
- """
233
  candidates = []
234
  for b in bases:
235
  candidates.append(join_url(b, root_name, rel_path))
@@ -243,37 +219,28 @@ def resolve_download_url(bases: List[str], root_name: str, rel_path: str) -> Opt
243
  def test_7z_integrity(archive_path: str) -> bool:
244
  try:
245
  with py7zr.SevenZipFile(archive_path, mode="r") as z:
246
- z.test() # raises on CRC or structure errors
247
  return True
248
  except Exception:
249
  return False
250
 
251
  def safe_extract_7z(archive_path: str, dest_dir: str) -> Tuple[int, List[str]]:
252
- """
253
- Extract an archive. If a CRC error occurs, fall back to per-member extraction,
254
- skipping only the bad members. Returns (#extracted, skipped_list).
255
- """
256
  extracted_count = 0
257
  skipped: List[str] = []
258
  dest = pathlib.Path(dest_dir)
259
  dest.mkdir(parents=True, exist_ok=True)
260
 
261
- # First try normal extraction (fast path).
262
  try:
263
  with py7zr.SevenZipFile(archive_path, mode="r") as z:
264
  z.extract(path=str(dest))
265
- # We don't know exact count from here; return -1 to mean "unknown but success"
266
  return -1, skipped
267
  except CrcError:
268
- # Fall back to per-member extraction, skipping corrupted ones.
269
  pass
270
 
271
- # Per-member pass
272
  with py7zr.SevenZipFile(archive_path, mode="r") as z:
273
  members = [info.filename for info in z.list() if not info.is_directory]
274
  for name in members:
275
  try:
276
- # Extract only this member; py7zr streams it to disk
277
  z.extract(targets=[name], path=str(dest))
278
  extracted_count += 1
279
  except CrcError:
@@ -283,6 +250,92 @@ def safe_extract_7z(archive_path: str, dest_dir: str) -> Tuple[int, List[str]]:
283
 
284
  return extracted_count, skipped
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  # =========================
287
  # Pipeline
288
  # =========================
@@ -291,17 +344,14 @@ def run_pipeline(torrent_url: str):
291
  if not torrent_url.strip().lower().endswith(".torrent"):
292
  raise gr.Error("Please provide a direct .torrent URL.")
293
 
294
- # Parse torrent metadata
295
  raw = fetch_bytes(torrent_url.strip())
296
  meta = parse_torrent(raw)
297
 
298
- # Seeds: prefer BEP-19 web seeds, else infer from torrent URL folder (DDoSecrets-friendly)
299
  seeds = list(meta["web_seeds"]) or infer_bases_from_torrent_url(torrent_url)
300
 
301
  infohash = meta["infohash"]
302
  root_name = meta["name"]
303
 
304
- # Expect .7z payloads
305
  sevenz_files = [f for f in meta["files"] if f["path"].lower().endswith(".7z")]
306
  if not sevenz_files:
307
  raise gr.Error("No .7z files listed in the torrent.")
@@ -310,7 +360,6 @@ def run_pipeline(torrent_url: str):
310
  raise gr.Error("No HTTP source found to fetch files. "
311
  "If this is DDoSecrets, ensure the .torrent sits with the files over HTTPS.")
312
 
313
- # Work dirs
314
  base_dir = pathlib.Path("/mnt/data/work") / infohash
315
  dl_dir = base_dir / "downloads"
316
  ex_dir = base_dir / "extracted"
@@ -320,10 +369,8 @@ def run_pipeline(torrent_url: str):
320
  logs = []
321
  saved_archives = []
322
 
323
- # Expected sizes from torrent metadata
324
  expected_map = {f["path"]: int(f.get("length", 0)) for f in meta["files"]}
325
 
326
- # Download each .7z over HTTP with verification and retry
327
  for f in sevenz_files:
328
  rel = f["path"]
329
  final_url = None
@@ -343,7 +390,6 @@ def run_pipeline(torrent_url: str):
343
  raise gr.Error(f"Download failed: {final_url}")
344
  logs.append(f"Saved: {dest} ({human_bytes(dest.stat().st_size)})")
345
 
346
- # Integrity test; if fails, re-fetch once fresh (handled inside download_file_exact via attempts)
347
  if not test_7z_integrity(str(dest)):
348
  logs.append(f"CRC test failed for {dest.name}, retrying download fresh…")
349
  download_file_exact(final_url, dest, expected_size, max_attempts=2)
@@ -351,7 +397,6 @@ def run_pipeline(torrent_url: str):
351
  logs.append(f"Archive still reports CRC problems: {dest.name}. Will try per-file extraction and skip corrupt members.")
352
  saved_archives.append(str(dest))
353
 
354
- # Extract all .7z archives (with resilient per-member fallback)
355
  for apath in saved_archives:
356
  logs.append(f"Extracting: {apath}")
357
  count, skipped = safe_extract_7z(apath, str(ex_dir))
@@ -361,13 +406,11 @@ def run_pipeline(torrent_url: str):
361
  logs.append(f"Extracted {count} members to {ex_dir}")
362
  if skipped:
363
  logs.append(f"Skipped {len(skipped)} corrupted member(s):")
364
- # show up to a few to keep log readable
365
  show = skipped[:10]
366
  logs += [f" - {s}" for s in show]
367
  if len(skipped) > 10:
368
  logs.append(f" … and {len(skipped) - 10} more")
369
 
370
- # List extracted files
371
  extracted = list_files_recursive(ex_dir)
372
  if not extracted:
373
  logs.append("No files extracted (archive may be empty).")
@@ -375,12 +418,22 @@ def run_pipeline(torrent_url: str):
375
  logs.append(f"Extracted files: {len(extracted)}")
376
 
377
  log_md = "### Run log\n" + "\n".join(f"- {l}" for l in logs)
378
- return log_md, extracted, (extracted[0] if extracted else "")
 
379
 
380
  def do_preview(path: str):
381
  md, _ = preview_path(path)
382
  return md
383
 
 
 
 
 
 
 
 
 
 
384
  # =========================
385
  # UI
386
  # =========================
@@ -390,7 +443,7 @@ with gr.Blocks(title="Torrent → 7z → View (HTTP only)") as demo:
390
  """
391
  # Torrent → 7z → View (HTTP only)
392
  Paste a **.torrent URL** (with web seeds or DDoSecrets-style layout).
393
- The app downloads `.7z` file(s), verifies size & CRC, extracts them, and lets you preview text/csv/json.
394
  """
395
  )
396
  url_in = gr.Textbox(label=".torrent URL", placeholder="https://data.ddosecrets.com/Collection/Collection.torrent")
@@ -400,20 +453,34 @@ The app downloads `.7z` file(s), verifies size & CRC, extracts them, and lets yo
400
  preview_btn = gr.Button("Preview selected")
401
  preview_md = gr.Markdown()
402
 
 
 
 
 
 
 
 
 
 
 
403
  def _go(url):
404
- log, files, first = run_pipeline(url)
405
  return (
406
  log,
407
  gr.update(choices=files, value=(first if first else None)),
408
- (first if first else "")
 
 
409
  )
410
 
411
- go_btn.click(fn=_go, inputs=[url_in], outputs=[log_out, files_dd, files_dd])
412
  preview_btn.click(fn=do_preview, inputs=[files_dd], outputs=[preview_md])
413
 
 
 
414
  if __name__ == "__main__":
415
  demo.launch(
416
  server_name="0.0.0.0",
417
  server_port=int(os.environ.get("PORT", 7860)),
418
- allowed_paths=["/mnt/data"] # allow returning files from /mnt/data if needed
419
  )
 
8
  import gradio as gr
9
  import bencodepy
10
  import py7zr
11
+ from py7zr.exceptions import CrcError
12
+
13
+ # NEW: HTML parsing
14
+ from bs4 import BeautifulSoup
15
 
16
  # =========================
17
  # Helpers
 
34
  return r.content
35
 
36
  def parse_torrent(raw: bytes) -> Dict:
 
 
 
 
 
 
 
 
 
37
  data = bencodepy.decode(raw)
38
  if not isinstance(data, dict) or b"info" not in data:
39
  raise ValueError("Invalid .torrent (missing 'info').")
 
83
  return "/".join(parts)
84
 
85
  def _head_or_peek(url: str, timeout: int = 20) -> Tuple[bool, Optional[int]]:
 
86
  try:
87
  r = requests.head(url, timeout=timeout, allow_redirects=True)
88
  if r.status_code < 400:
 
90
  return True, (int(size) if size and size.isdigit() else None)
91
  except Exception:
92
  pass
 
93
  try:
94
  r = requests.get(url, stream=True, timeout=timeout, allow_redirects=True)
95
  if r.status_code < 400:
 
129
 
130
  def download_file_exact(url: str, dest_path: pathlib.Path, expected_size: Optional[int],
131
  timeout: int = 120, max_attempts: int = 2):
 
 
 
 
132
  dest_path.parent.mkdir(parents=True, exist_ok=True)
133
 
134
  def _resume_once():
 
168
  _fresh_once()
169
 
170
  if expected_size is None:
171
+ return
172
  if dest_path.exists() and dest_path.stat().st_size == expected_size:
173
  return
174
 
 
188
  p = pathlib.Path(path_str)
189
  suffix = p.suffix.lower()
190
  try:
191
+ if suffix in [".csv", ".tsv", ".json", ".ndjson", ".txt", ".log", ".md", ".eml", ".html", ".htm", ".meta"]:
192
  raw = open(p, "rb").read(max_bytes)
193
  text = raw.decode("utf-8", errors="replace")
194
  return f"Previewing {p.name} (truncated):\n\n```\n{text}\n```", None
 
199
  return f"Error previewing file: {type(e).__name__}: {e}", None
200
 
201
  def infer_bases_from_torrent_url(torrent_url: str) -> List[str]:
 
 
 
 
 
 
202
  u = torrent_url.strip()
203
  if "/" not in u:
204
  return []
 
206
  return [base]
207
 
208
  def resolve_download_url(bases: List[str], root_name: str, rel_path: str) -> Optional[str]:
 
 
 
 
 
 
209
  candidates = []
210
  for b in bases:
211
  candidates.append(join_url(b, root_name, rel_path))
 
219
  def test_7z_integrity(archive_path: str) -> bool:
220
  try:
221
  with py7zr.SevenZipFile(archive_path, mode="r") as z:
222
+ z.test()
223
  return True
224
  except Exception:
225
  return False
226
 
227
  def safe_extract_7z(archive_path: str, dest_dir: str) -> Tuple[int, List[str]]:
 
 
 
 
228
  extracted_count = 0
229
  skipped: List[str] = []
230
  dest = pathlib.Path(dest_dir)
231
  dest.mkdir(parents=True, exist_ok=True)
232
 
 
233
  try:
234
  with py7zr.SevenZipFile(archive_path, mode="r") as z:
235
  z.extract(path=str(dest))
 
236
  return -1, skipped
237
  except CrcError:
 
238
  pass
239
 
 
240
  with py7zr.SevenZipFile(archive_path, mode="r") as z:
241
  members = [info.filename for info in z.list() if not info.is_directory]
242
  for name in members:
243
  try:
 
244
  z.extract(targets=[name], path=str(dest))
245
  extracted_count += 1
246
  except CrcError:
 
250
 
251
  return extracted_count, skipped
252
 
253
+ # =========================
254
+ # NEW: HTML/.meta → JSONL exporter
255
+ # =========================
256
+
257
+ def _parse_meta_file(path: pathlib.Path) -> Dict:
258
+ """
259
+ Try JSON parse; else parse simple 'key: value' lines; else return raw text.
260
+ """
261
+ raw = path.read_text(encoding="utf-8", errors="replace")
262
+ # try JSON
263
+ try:
264
+ obj = json.loads(raw)
265
+ return {"type": "meta", "path": str(path), "content": obj}
266
+ except Exception:
267
+ pass
268
+ # key: value lines
269
+ data: Dict[str, str] = {}
270
+ lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
271
+ for ln in lines:
272
+ if ":" in ln:
273
+ k, v = ln.split(":", 1)
274
+ data[k.strip()] = v.strip()
275
+ if data:
276
+ return {"type": "meta", "path": str(path), "content": data}
277
+ # fallback raw
278
+ return {"type": "meta", "path": str(path), "content_raw": raw}
279
+
280
+ def _parse_html_file(path: pathlib.Path) -> Dict:
281
+ """
282
+ Extract title, meta[name/content], and plain text.
283
+ """
284
+ raw = path.read_text(encoding="utf-8", errors="replace")
285
+ # Prefer lxml if present; fallback to built-in parser
286
+ try:
287
+ soup = BeautifulSoup(raw, "lxml")
288
+ except Exception:
289
+ soup = BeautifulSoup(raw, "html.parser")
290
+ title = (soup.title.string.strip() if soup.title and soup.title.string else "")
291
+ meta = {}
292
+ for tag in soup.find_all("meta"):
293
+ name = tag.get("name") or tag.get("property")
294
+ content = tag.get("content")
295
+ if name and content:
296
+ meta[str(name)] = str(content)
297
+ text = soup.get_text(separator="\n", strip=True)
298
+ return {"type": "html", "path": str(path), "title": title, "meta": meta, "text": text}
299
+
300
+ def build_jsonl_from_extracted(ex_dir: str, out_dir: str, max_records: Optional[int] = None) -> Tuple[str, int, int]:
301
+ """
302
+ Walk extracted dir, convert all .html/.htm and .meta files to JSONL.
303
+ Returns (output_path, html_count, meta_count).
304
+ """
305
+ ex_root = pathlib.Path(ex_dir)
306
+ out_root = pathlib.Path(out_dir)
307
+ out_root.mkdir(parents=True, exist_ok=True)
308
+ out_path = out_root / "converted.jsonl"
309
+
310
+ html_count = 0
311
+ meta_count = 0
312
+ written = 0
313
+
314
+ with open(out_path, "w", encoding="utf-8") as fout:
315
+ for p in ex_root.rglob("*"):
316
+ if not p.is_file():
317
+ continue
318
+ suf = p.suffix.lower()
319
+ try:
320
+ if suf in (".html", ".htm"):
321
+ rec = _parse_html_file(p)
322
+ html_count += 1
323
+ elif suf == ".meta":
324
+ rec = _parse_meta_file(p)
325
+ meta_count += 1
326
+ else:
327
+ continue
328
+ fout.write(json.dumps(rec, ensure_ascii=False) + "\n")
329
+ written += 1
330
+ if max_records and written >= max_records:
331
+ break
332
+ except Exception as e:
333
+ # Skip unreadable files but carry on
334
+ err = {"type": "error", "path": str(p), "error": f"{type(e).__name__}: {e}"}
335
+ fout.write(json.dumps(err, ensure_ascii=False) + "\n")
336
+
337
+ return str(out_path), html_count, meta_count
338
+
339
  # =========================
340
  # Pipeline
341
  # =========================
 
344
  if not torrent_url.strip().lower().endswith(".torrent"):
345
  raise gr.Error("Please provide a direct .torrent URL.")
346
 
 
347
  raw = fetch_bytes(torrent_url.strip())
348
  meta = parse_torrent(raw)
349
 
 
350
  seeds = list(meta["web_seeds"]) or infer_bases_from_torrent_url(torrent_url)
351
 
352
  infohash = meta["infohash"]
353
  root_name = meta["name"]
354
 
 
355
  sevenz_files = [f for f in meta["files"] if f["path"].lower().endswith(".7z")]
356
  if not sevenz_files:
357
  raise gr.Error("No .7z files listed in the torrent.")
 
360
  raise gr.Error("No HTTP source found to fetch files. "
361
  "If this is DDoSecrets, ensure the .torrent sits with the files over HTTPS.")
362
 
 
363
  base_dir = pathlib.Path("/mnt/data/work") / infohash
364
  dl_dir = base_dir / "downloads"
365
  ex_dir = base_dir / "extracted"
 
369
  logs = []
370
  saved_archives = []
371
 
 
372
  expected_map = {f["path"]: int(f.get("length", 0)) for f in meta["files"]}
373
 
 
374
  for f in sevenz_files:
375
  rel = f["path"]
376
  final_url = None
 
390
  raise gr.Error(f"Download failed: {final_url}")
391
  logs.append(f"Saved: {dest} ({human_bytes(dest.stat().st_size)})")
392
 
 
393
  if not test_7z_integrity(str(dest)):
394
  logs.append(f"CRC test failed for {dest.name}, retrying download fresh…")
395
  download_file_exact(final_url, dest, expected_size, max_attempts=2)
 
397
  logs.append(f"Archive still reports CRC problems: {dest.name}. Will try per-file extraction and skip corrupt members.")
398
  saved_archives.append(str(dest))
399
 
 
400
  for apath in saved_archives:
401
  logs.append(f"Extracting: {apath}")
402
  count, skipped = safe_extract_7z(apath, str(ex_dir))
 
406
  logs.append(f"Extracted {count} members to {ex_dir}")
407
  if skipped:
408
  logs.append(f"Skipped {len(skipped)} corrupted member(s):")
 
409
  show = skipped[:10]
410
  logs += [f" - {s}" for s in show]
411
  if len(skipped) > 10:
412
  logs.append(f" … and {len(skipped) - 10} more")
413
 
 
414
  extracted = list_files_recursive(ex_dir)
415
  if not extracted:
416
  logs.append("No files extracted (archive may be empty).")
 
418
  logs.append(f"Extracted files: {len(extracted)}")
419
 
420
  log_md = "### Run log\n" + "\n".join(f"- {l}" for l in logs)
421
+ # RETURN the extracted dir so we can build JSON later
422
+ return log_md, extracted, (extracted[0] if extracted else ""), str(ex_dir), str(base_dir)
423
 
424
  def do_preview(path: str):
425
  md, _ = preview_path(path)
426
  return md
427
 
428
+ # NEW: hook to build JSONL and return a downloadable file
429
+ def do_build_jsonl(ex_dir: str, base_dir: str):
430
+ if not ex_dir or not os.path.isdir(ex_dir):
431
+ raise gr.Error("Extraction folder not found. Run the download/extract step first.")
432
+ out_dir = str(pathlib.Path(base_dir) / "exports")
433
+ out_path, html_count, meta_count = build_jsonl_from_extracted(ex_dir, out_dir)
434
+ summary = f"Built JSONL at: `{out_path}`\n- HTML files: {html_count}\n- META files: {meta_count}\n"
435
+ return summary, out_path
436
+
437
  # =========================
438
  # UI
439
  # =========================
 
443
  """
444
  # Torrent → 7z → View (HTTP only)
445
  Paste a **.torrent URL** (with web seeds or DDoSecrets-style layout).
446
+ The app downloads `.7z` file(s), verifies size & CRC, extracts them, lets you preview text/csv/json, **and exports all `.html` + `.meta` to a single JSONL**.
447
  """
448
  )
449
  url_in = gr.Textbox(label=".torrent URL", placeholder="https://data.ddosecrets.com/Collection/Collection.torrent")
 
453
  preview_btn = gr.Button("Preview selected")
454
  preview_md = gr.Markdown()
455
 
456
+ # NEW: export controls
457
+ gr.Markdown("### Export `.html` and `.meta` → combined JSONL")
458
+ build_btn = gr.Button("Build JSONL from extracted")
459
+ build_log = gr.Markdown()
460
+ dl_file = gr.File(label="Download combined JSONL", interactive=False)
461
+
462
+ # internal state: extracted dir & base dir for exports
463
+ ex_dir_state = gr.State()
464
+ base_dir_state = gr.State()
465
+
466
  def _go(url):
467
+ log, files, first, ex_dir, base_dir = run_pipeline(url)
468
  return (
469
  log,
470
  gr.update(choices=files, value=(first if first else None)),
471
+ (first if first else ""),
472
+ ex_dir,
473
+ base_dir
474
  )
475
 
476
+ go_btn.click(fn=_go, inputs=[url_in], outputs=[log_out, files_dd, files_dd, ex_dir_state, base_dir_state])
477
  preview_btn.click(fn=do_preview, inputs=[files_dd], outputs=[preview_md])
478
 
479
+ build_btn.click(fn=do_build_jsonl, inputs=[ex_dir_state, base_dir_state], outputs=[build_log, dl_file])
480
+
481
  if __name__ == "__main__":
482
  demo.launch(
483
  server_name="0.0.0.0",
484
  server_port=int(os.environ.get("PORT", 7860)),
485
+ allowed_paths=["/mnt/data"] # allow returning files from /mnt/data
486
  )