Fix exporter
Browse files- app.py +4 -2
- frontend/app.js +23 -12
- tests/test_frontend_export_controls.py +7 -0
- tests/test_render_api.py +27 -0
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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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())}">
|
| 871 |
</div>
|
| 872 |
</div>
|
| 873 |
|
|
@@ -1330,14 +1335,20 @@ function exportFilename() {
|
|
| 1330 |
return `${base}.${state.exportFormat}`;
|
| 1331 |
}
|
| 1332 |
|
| 1333 |
-
function
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]
|