From 957cd0b701f6f74ad094107a40fe9bc1c779ae51 Mon Sep 17 00:00:00 2001 From: jqueguiner Date: Mon, 20 Jul 2026 18:28:44 +0000 Subject: [PATCH 01/11] feat(datasets): resolve reference-dataset audio by storage connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference datasets (e.g. the speech catalog) index audio that physically lives in a different bucket than the dataset's own connection, stored per row as an s3://bucket/key uri. The generic /files/{path}/download route presigns against the dataset's own connection and requires a DatasetFile manifest, so these s3:// audio refs 404. Add GET /datasets/{id}/audio?uri=s3://... which matches the uri's bucket to one of the org's storage connections and returns a presigned GET url — played with server-held credentials, never a raw/public s3 url. Scoped: the bucket must be an org connection and the key must sit under the dataset's declared audio_prefix (dataset_metadata). Frontend: detect s3:// audio values (any container extension) and route them to the new endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/app/api/v1/datasets.py | 63 +++++++++++++++++++++++++++++ src/web/src/pages/DatasetDetail.tsx | 18 +++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/api/app/api/v1/datasets.py b/src/api/app/api/v1/datasets.py index c13bf971..d81e3b53 100644 --- a/src/api/app/api/v1/datasets.py +++ b/src/api/app/api/v1/datasets.py @@ -621,6 +621,69 @@ def _presign() -> str: return {"url": url, "dataset": ds.name, "path": rel, "content_type": ctype} +@router.get("/datasets/{dataset_id}/audio") +async def dataset_audio_by_connection( + dataset_id: uuid.UUID, + uri: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Presign an audio object that lives in a DIFFERENT org storage connection. + + A reference dataset (e.g. the speech catalog) indexes audio that physically + lives in another bucket than the dataset's own connection, referenced per row + by an ``s3:///`` uri. This resolves that bucket to one of the + org's storage connections and returns a presigned GET url — the browser plays + it with server-held credentials, never a raw/public s3 url. Scoped: the bucket + must match an org storage connection and the key must sit under the dataset's + declared ``audio_prefix`` (dataset_metadata) when one is set. + """ + import mimetypes as _mt + from urllib.parse import urlparse + + from starlette.concurrency import run_in_threadpool + + from app.services.storage_connection import create_backend_from_connection + + ds = await _get_dataset(db, dataset_id) + await _verify_org_membership(db, ds.org_id, current_user.id) + if not uri.startswith("s3://"): + raise HTTPException(status_code=400, detail="uri must be s3://bucket/key") + p = urlparse(uri) + bucket, key = p.netloc, p.path.lstrip("/") + if not bucket or not key: + raise HTTPException(status_code=400, detail="bad s3 uri") + # Scope: the key must live under the dataset's declared audio prefix (if any), + # so this route can't be used to presign arbitrary objects in the bucket. + meta = ds.dataset_metadata or {} + prefix = (meta.get("audio_prefix") or "").strip("/") + if prefix and not (key == prefix or key.startswith(prefix + "/")): + raise HTTPException(status_code=403, detail="key outside dataset audio prefix") + conn = ( + await db.execute( + select(StorageConnection).where( + StorageConnection.org_id == ds.org_id, + StorageConnection.bucket == bucket, + ) + ) + ).scalars().first() + if conn is None: + raise HTTPException(status_code=404, detail=f"no org storage connection for bucket '{bucket}'") + ctype = _mt.guess_type(key)[0] or "audio/mpeg" + + def _sign() -> str: + backend = create_backend_from_connection(conn) + return backend._client.generate_presigned_url( + "get_object", + Params={"Bucket": bucket, "Key": key, "ResponseContentType": ctype, + "ResponseContentDisposition": "inline"}, + ExpiresIn=3600, + ) + + url = await run_in_threadpool(_sign) + return {"url": url, "bucket": bucket, "key": key, "connection": conn.name, "content_type": ctype} + + @router.get("/orgs/{org_id}/datasets", response_model=list[DatasetRead]) async def list_org_datasets( org_id: uuid.UUID, diff --git a/src/web/src/pages/DatasetDetail.tsx b/src/web/src/pages/DatasetDetail.tsx index d463d11a..6a025902 100644 --- a/src/web/src/pages/DatasetDetail.tsx +++ b/src/web/src/pages/DatasetDetail.tsx @@ -507,8 +507,12 @@ function DeleteDatasetModal({ name, managed, runCount, derivedCount, childCount, ); } -const AUDIO_RE = /\.(wav|mp3|flac|ogg|m4a|opus|aac)$/i; -const isAudioPath = (v: unknown): v is string => typeof v === "string" && AUDIO_RE.test(v); +const AUDIO_RE = /\.(wav|mp3|flac|ogg|m4a|opus|aac|mp4|webm|mkv|isom|amr)$/i; +// A row value is playable audio if it ends in an audio/container extension, OR is +// an s3:// uri (reference datasets store audio as s3://bucket/key resolved by +// connection, regardless of the original container extension). +const isAudioPath = (v: unknown): v is string => + typeof v === "string" && (AUDIO_RE.test(v) || v.startsWith("s3://")); // Rows may carry a parent-qualified audio ref like // "gladia/aligner-2026-05::audio/af/clip__a_b.wav". Strip the "::" @@ -516,6 +520,7 @@ const isAudioPath = (v: unknown): v is string => typeof v === "string" && AUDIO_ const QUALIFIER_RE = /^[^:]+::/; function audioRefOf(v: unknown): string | null { if (typeof v !== "string") return null; + if (v.startsWith("s3://")) return v; // Keep the qualifier — AudioCell uses it to resolve cross-dataset refs. return AUDIO_RE.test(v.replace(QUALIFIER_RE, "")) ? v : null; } @@ -547,7 +552,14 @@ function AudioCell({ datasetId, path, sharded, label }: { datasetId: string; pat setSt("loading"); try { let url: string; - if (path.includes("::")) { + if (path.startsWith("s3://")) { + // Audio lives in another org connection (reference dataset). The API + // matches the bucket to a storage connection and presigns server-side. + const res = await apiFetch(`/datasets/${datasetId}/audio?uri=${encodeURIComponent(path)}`); + if (res.status === 404) { setSt("missing"); return; } + if (!res.ok) throw new Error(String(res.status)); + url = ((await res.json()) as { url: string }).url; + } else if (path.includes("::")) { // Parent-qualified ref ("::path") — the file lives in another // dataset. Resolve against the owning dataset → presigned URL (plays on //