mattkevan commited on
Commit
186f4a4
·
1 Parent(s): 3921761

Fix exporter

Browse files
app.py CHANGED
@@ -240,11 +240,13 @@ async def health() -> Dict[str, str]:
240
 
241
 
242
  @app.get("/files/{session_id}/{kind}/{filename}")
243
- async def serve_generated_file(session_id: str, kind: str, filename: str):
244
  path = _session_root(session_id) / kind / filename
245
  if not path.exists():
246
  return JSONResponse({"error": "file not found"}, status_code=404)
247
- return FileResponse(path)
 
 
248
 
249
 
250
  app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="frontend-static")
 
240
 
241
 
242
  @app.get("/files/{session_id}/{kind}/{filename}")
243
+ async def serve_generated_file(session_id: str, kind: str, filename: str, download: bool = False):
244
  path = _session_root(session_id) / kind / filename
245
  if not path.exists():
246
  return JSONResponse({"error": "file not found"}, status_code=404)
247
+ if download:
248
+ return FileResponse(path, filename=filename)
249
+ return FileResponse(path, content_disposition_type="inline")
250
 
251
 
252
  app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="frontend-static")
frontend/app.js CHANGED
@@ -251,7 +251,7 @@ const actionHandlers = {
251
  downloadCurrentExport();
252
  return;
253
  }
254
- await requestExport();
255
  },
256
  goConfigure() {
257
  state.step = "configure";
@@ -427,7 +427,7 @@ function updateRenderState(payload) {
427
  }
428
  }
429
 
430
- async function requestExport() {
431
  if (!state.client) return;
432
  state.errorMessage = "";
433
  state.statusMessage = `Preparing ${state.exportFormat.toUpperCase()} export…`;
@@ -447,7 +447,12 @@ async function requestExport() {
447
  state.exportFile = payload;
448
  state.exportPlayerTrackIndex = clampTrackIndex(state.exportPlayerTrackIndex, Math.max(1, includedChapters().length));
449
  state.previewUrl = payload.url || state.previewUrl;
450
- state.statusMessage = `${state.exportFormat.toUpperCase()} export ready.`;
 
 
 
 
 
451
  } catch (error) {
452
  state.errorMessage = error.message;
453
  }
@@ -867,7 +872,7 @@ function renderExport() {
867
  </div>
868
  <div>
869
  <button class="btn-primary" data-action="exportBook">${state.exportFile ? "Download again" : "Download audiobook"}</button>
870
- ${state.exportFile ? `<div style="margin-top:10px;"><a href="${state.exportFile.url}" target="_blank" rel="noopener" download="${escapeAttr(exportFilename())}">Open exported file</a></div>` : `<div style="margin-top:10px; color:var(--faint); font-size:12.5px;">with embedded chapter markers</div>`}
871
  </div>
872
  </div>
873
 
@@ -1330,14 +1335,20 @@ function exportFilename() {
1330
  return `${base}.${state.exportFormat}`;
1331
  }
1332
 
1333
- function downloadCurrentExport() {
1334
- if (!state.exportFile?.url) return;
1335
- const link = document.createElement("a");
1336
- link.href = state.exportFile.url;
1337
- link.download = exportFilename();
1338
- document.body.appendChild(link);
1339
- link.click();
1340
- link.remove();
 
 
 
 
 
 
1341
  }
1342
 
1343
  function canStartRender() {
 
251
  downloadCurrentExport();
252
  return;
253
  }
254
+ await requestExport({ autoDownload: true });
255
  },
256
  goConfigure() {
257
  state.step = "configure";
 
427
  }
428
  }
429
 
430
+ async function requestExport(options = {}) {
431
  if (!state.client) return;
432
  state.errorMessage = "";
433
  state.statusMessage = `Preparing ${state.exportFormat.toUpperCase()} export…`;
 
447
  state.exportFile = payload;
448
  state.exportPlayerTrackIndex = clampTrackIndex(state.exportPlayerTrackIndex, Math.max(1, includedChapters().length));
449
  state.previewUrl = payload.url || state.previewUrl;
450
+ if (options.autoDownload && payload.url) {
451
+ downloadCurrentExport(payload);
452
+ state.statusMessage = `${state.exportFormat.toUpperCase()} export ready. Download starting…`;
453
+ } else {
454
+ state.statusMessage = `${state.exportFormat.toUpperCase()} export ready.`;
455
+ }
456
  } catch (error) {
457
  state.errorMessage = error.message;
458
  }
 
872
  </div>
873
  <div>
874
  <button class="btn-primary" data-action="exportBook">${state.exportFile ? "Download again" : "Download audiobook"}</button>
875
+ ${state.exportFile ? `<div style="margin-top:10px;"><a href="${escapeAttr(buildDownloadUrl(state.exportFile.url))}" target="_blank" rel="noopener" download="${escapeAttr(exportFilename())}">Download exported file</a></div>` : `<div style="margin-top:10px; color:var(--faint); font-size:12.5px;">with embedded chapter markers</div>`}
876
  </div>
877
  </div>
878
 
 
1335
  return `${base}.${state.exportFormat}`;
1336
  }
1337
 
1338
+ function buildDownloadUrl(url) {
1339
+ const downloadUrl = new URL(url, window.location.origin);
1340
+ downloadUrl.searchParams.set("download", "1");
1341
+ return downloadUrl.toString();
1342
+ }
1343
+
1344
+ function downloadCurrentExport(exportFile = state.exportFile) {
1345
+ if (!exportFile?.url) return;
1346
+ document.getElementById("download-frame")?.remove();
1347
+ const frame = document.createElement("iframe");
1348
+ frame.id = "download-frame";
1349
+ frame.hidden = true;
1350
+ frame.src = buildDownloadUrl(exportFile.url);
1351
+ document.body.appendChild(frame);
1352
  }
1353
 
1354
  function canStartRender() {
tests/test_frontend_export_controls.py CHANGED
@@ -17,3 +17,10 @@ def test_export_screen_uses_downloadable_export_links() -> None:
17
 
18
  assert "downloadCurrentExport()" in source
19
  assert 'download="${escapeAttr(exportFilename())}"' in source
 
 
 
 
 
 
 
 
17
 
18
  assert "downloadCurrentExport()" in source
19
  assert 'download="${escapeAttr(exportFilename())}"' in source
20
+
21
+
22
+ def test_export_screen_prepares_attachment_downloads() -> None:
23
+ source = Path("frontend/app.js").read_text(encoding="utf-8")
24
+
25
+ assert "await requestExport({ autoDownload: true });" in source
26
+ assert 'searchParams.set("download", "1")' in source
tests/test_render_api.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import importlib
2
  import sys
3
  from types import SimpleNamespace
@@ -5,6 +6,7 @@ from types import SimpleNamespace
5
  import pytest
6
 
7
  import gradio as gr
 
8
 
9
 
10
  def test_start_render_api_registers_one_streamed_output() -> None:
@@ -102,3 +104,28 @@ def test_generate_preview_api_exposes_model_and_backend(monkeypatch) -> None:
102
  assert payload["url"] == "/files/session-a/previews/c1.wav"
103
  assert payload["backend"] == "modal"
104
  assert payload["model"] == "magpie"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
  import importlib
3
  import sys
4
  from types import SimpleNamespace
 
6
  import pytest
7
 
8
  import gradio as gr
9
+ from fastapi.responses import FileResponse
10
 
11
 
12
  def test_start_render_api_registers_one_streamed_output() -> None:
 
104
  assert payload["url"] == "/files/session-a/previews/c1.wav"
105
  assert payload["backend"] == "modal"
106
  assert payload["model"] == "magpie"
107
+
108
+
109
+ def test_serve_generated_file_supports_attachment_downloads(tmp_path, monkeypatch) -> None:
110
+ sys.modules.pop("app", None)
111
+ module = importlib.import_module("app")
112
+
113
+ session_root = tmp_path / "session-a"
114
+ export_path = session_root / "exports" / "reading-room.m4a"
115
+ export_path.parent.mkdir(parents=True, exist_ok=True)
116
+ export_path.write_bytes(b"m4a")
117
+
118
+ monkeypatch.setattr(module, "_session_root", lambda _session_id: session_root)
119
+
120
+ response = asyncio.run(
121
+ module.serve_generated_file(
122
+ session_id="session-a",
123
+ kind="exports",
124
+ filename="reading-room.m4a",
125
+ download=True,
126
+ )
127
+ )
128
+
129
+ assert isinstance(response, FileResponse)
130
+ assert "attachment" in response.headers["content-disposition"]
131
+ assert "reading-room.m4a" in response.headers["content-disposition"]