quranic-universal-aligner / src /api /preload_api.py
hetchyy's picture
deploy
7814c84 verified
Raw
History Blame Contribute Delete
15.1 kB
"""Preload-mode API: the dropdown catalog + pre-aligned segment fetch.
Mirrors the Preload UI for programmatic consumers (e.g. the QuranCaption app):
- ``preload_recitations`` — every released recitation as a
``(reciter, recitation-specifics)`` tuple plus its available chapters. One
call backs the recitation + surah dropdowns.
- ``preload_segments`` — pre-aligned segments (+ optional word timestamps) for a
recitation / chapter / verse range. Output is byte-identical to the Preload
segment-mode JSON download (shared ``build_segment_export`` path), plus a
single directly-fetchable ``audio_url``.
These endpoints are **public / ungated** — Preload data is read-only catalog
content served from the inspector bucket, so they intentionally do NOT call
``enforce_beta_access`` (the Caption app and scripted callers have no OAuth
profile). Everything is a bucket read; no GPU, no session.
"""
from __future__ import annotations
import os
import threading
import time
import gradio as gr
from config import PRELOAD_AUDIO_CATALOG_TTL_S, PRELOAD_CLIP_PAD_S
from src.preload.clip_audio import plan_clip_window
from src.core.usage_logger import (
get_user_id,
log_error,
mark_endpoint_entry,
set_stage,
)
from src.pipeline.exports import build_segment_export
from src.preload import manifest_client, recitation_labels, repo_loader
# ---------------------------------------------------------------------------
# Audio URL — hand back a single directly-fetchable absolute URL.
# ---------------------------------------------------------------------------
def _space_base_url(request: gr.Request | None) -> str:
"""Public base URL of this Space (``https://<host>``), or ``""``.
Prefers the HF-set ``SPACE_HOST`` env var (the canonical ``*.hf.space``
host), falling back to the forwarded/host headers of the incoming request.
"""
host = os.environ.get("SPACE_HOST")
if host:
return f"https://{host.rstrip('/')}"
if request is not None:
try:
headers = request.headers
h = headers.get("x-forwarded-host") or headers.get("host")
proto = headers.get("x-forwarded-proto") or "https"
if h:
return f"{proto}://{h}"
except Exception: # noqa: BLE001 — header access is best-effort
pass
return ""
def _absolute_audio_url(audio_url: str, request: gr.Request | None) -> str:
"""Make a resolved chapter audio URL directly fetchable by external callers.
``repo_loader.resolve_chapter_audio_url`` returns the in-Space bucket-route
path (``/preload-audio/<slug>/<ch>.mp3`` — a public, Range-capable endpoint)
that every released delivery's audio is served through. Relative paths are
absolutized against the Space host; an already-absolute URL is returned
unchanged. There is no separate CDN path — audio is always one route.
"""
if not audio_url:
return ""
if audio_url.startswith(("http://", "https://")):
return audio_url
base = _space_base_url(request)
return f"{base}{audio_url}" if base else audio_url
def _rezero_to_clip(segments, audio_url: str, request: gr.Request | None):
"""Point audio at a range-cut clip and re-zero segment times to its start.
The verse window is derived from the segments' own ``time_from`` /
``time_to`` — the only absolute fields in a segment-mode export (per-word
``start`` / ``end`` are already segment-relative, so they are left alone).
The window is padded by ``PRELOAD_CLIP_PAD_S`` on each side, ``audio_url``
gains ``?start_ms&end_ms`` for the route to cut against, and every segment's
``time_from`` / ``time_to`` is shifted to be relative to the clip start.
Returns ``(clip_audio_url, clip_start_s)``. Falls back to the full chapter
URL with ``clip_start = 0.0`` when there is no audio or no usable timing —
callers should treat ``clip_start`` only as informational metadata.
"""
if not audio_url or not segments:
return _absolute_audio_url(audio_url, request), 0.0
window = plan_clip_window(segments, PRELOAD_CLIP_PAD_S)
if window is None:
return _absolute_audio_url(audio_url, request), 0.0
start_ms, end_ms, clip_start = window
sep = "&" if "?" in audio_url else "?"
clip_url = _absolute_audio_url(
f"{audio_url}{sep}start_ms={start_ms}&end_ms={end_ms}", request,
)
return clip_url, clip_start
# ---------------------------------------------------------------------------
# Endpoint 1 — recitation catalog (drop-downs)
# ---------------------------------------------------------------------------
def preload_recitations(request: gr.Request = None):
"""Return every released recitation with its available chapters.
Shape: ``{"recitations": [{slug, label, reciter, riwayah, style, channel,
source, chapters: [int, ...]}, ...]}``. On catalog failure returns
``{"recitations": [], "error": "..."}``.
"""
mark_endpoint_entry()
set_stage("catalog")
try:
catalog = manifest_client.load_catalog()
details = recitation_labels.build_recitation_details(catalog)
except Exception as e: # noqa: BLE001 — surface as an error payload
log_error(
error_code="catalog_load_failed",
endpoint="preload_recitations",
stage="catalog",
exception=e,
user_id=get_user_id(request) if request else "unknown",
message=f"Preload catalog load failed: {e}",
)
return {"recitations": [], "error": f"Catalog load failed: {e}"}
recitations = []
for row in details:
try:
chapters = repo_loader.chapters_for_delivery(row["slug"])
except Exception: # noqa: BLE001 — a single bad delivery shouldn't 500 the list
chapters = []
recitations.append({**row, "chapters": chapters})
return {"recitations": recitations}
# ---------------------------------------------------------------------------
# Endpoint 2 — pre-aligned segments for a recitation / chapter / verse range
# ---------------------------------------------------------------------------
def _coerce_verse(value, default: int) -> int:
"""Parse a verse bound; non-positive / unparseable falls back to ``default``."""
try:
iv = int(value)
except (TypeError, ValueError):
return default
return iv if iv > 0 else default
def preload_segments(recitation, chapter, verse_from, verse_to,
include_timestamps=True, request: gr.Request = None):
"""Pre-aligned segments for a recitation chapter + verse range.
Verse range defaults to the full chapter (1 → last verse) when
``verse_from`` / ``verse_to`` are missing or ≤ 0; bounds are clamped to the
chapter and ordered. ``include_timestamps`` (default True) controls whether
per-word timestamps are emitted. Segments mirror the Preload segment-mode
download byte-for-byte; the envelope adds ``recitation/chapter/verse_from/
verse_to/audio_url``. Errors return ``{"error", "segments": []}``.
"""
mark_endpoint_entry()
set_stage("validate")
slug = (recitation or "").strip() if isinstance(recitation, str) else ""
if not slug:
log_error(
error_code="invalid_recitation", endpoint="preload_segments",
stage="validate", user_id=get_user_id(request) if request else "unknown",
message="recitation (delivery slug) is required",
)
return {"error": "recitation is required", "segments": []}
try:
chapter = int(chapter)
except (TypeError, ValueError):
return {"error": "chapter must be an integer", "segments": []}
surah_info = repo_loader.load_surah_info_local()
meta = surah_info.get(str(chapter))
if not meta:
return {"error": f"Unknown chapter {chapter}", "segments": []}
num_verses = int(meta.get("num_verses") or 1)
lo = _coerce_verse(verse_from, default=1)
hi = _coerce_verse(verse_to, default=num_verses)
lo = max(1, min(lo, num_verses))
hi = max(1, min(hi, num_verses))
if lo > hi:
lo, hi = hi, lo
include_words = True if include_timestamps is None else bool(include_timestamps)
set_stage("build")
try:
pc = repo_loader.build_segment_infos(slug, chapter, lo, hi)
except Exception as e: # noqa: BLE001 — surface as an error payload
log_error(
error_code="preload_build_failed", endpoint="preload_segments",
stage="build", exception=e,
user_id=get_user_id(request) if request else "unknown",
message=f"Preload segment build failed: {e}",
context={"recitation": slug, "chapter": chapter,
"verse_from": lo, "verse_to": hi},
)
return {"error": f"Failed to load segments: {e}", "segments": []}
payload = build_segment_export(
pc.all_segments, include_words=include_words, source="preload",
)
segments = payload.get("segments", []) if payload else []
meta_block = (
payload.get("_meta") if payload
else {"view_mode": "segment", "source": "preload"}
)
# Range-scoped clip: audio_url points at the cut for [verse_from, verse_to]
# and segment times are re-zeroed to the clip start, so the caller downloads
# only the requested verses with timings that already begin at 0.
audio_url, clip_start = _rezero_to_clip(segments, pc.audio_url, request)
return {
"_meta": meta_block,
"recitation": slug,
"chapter": chapter,
"verse_from": lo,
"verse_to": hi,
"audio_url": audio_url,
"clip_start": round(clip_start, 3),
"segments": segments,
}
# ---------------------------------------------------------------------------
# Endpoint 3 — audio-only catalog (full audio set, NON-published reciters)
# ---------------------------------------------------------------------------
# Assembled audio-only payload is TTL-cached: the catalog is ~2k+ deliveries and
# each chapter list is a separate sidecar read, so we build it once per window.
_AUDIO_RECITATIONS_LOCK = threading.Lock()
_AUDIO_RECITATIONS_CACHE: dict | None = None
_AUDIO_RECITATIONS_AT: float = 0.0
def _build_audio_recitations() -> dict:
"""Assemble the audio-only catalog (rows + chapters), reading sidecars in
parallel. Raises on catalog-load failure; the caller maps it to an error."""
catalog = manifest_client.load_catalog()
details = recitation_labels.build_recitation_details(
catalog,
reciters_fn=repo_loader.audio_reciters,
deliveries_fn=repo_loader.audio_deliveries_for_reciter,
)
chapters_by_slug = repo_loader.chapters_for_audio_deliveries(
[row["slug"] for row in details]
)
recitations = [
{**row, "chapters": chapters_by_slug.get(row["slug"], [])} for row in details
]
return {"recitations": recitations}
def preload_audio_recitations(request: gr.Request = None):
"""Return the audio-only recitation catalog with available chapters.
The audio-only set is every ``by_surah`` delivery that has audio on the
bucket but is **not** in the released/segments catalog — so it never
duplicates ``preload_recitations``. These reciters offer downloadable
chapter audio (no reviewed segments). Same row shape as
``preload_recitations``; chapters come from the audio manifest sidecar (not
``segments.json``, which WIP reciters don't have). The assembled payload is
TTL-cached (``PRELOAD_AUDIO_CATALOG_TTL_S``). On catalog failure returns
``{"recitations": [], "error": "..."}``.
"""
global _AUDIO_RECITATIONS_CACHE, _AUDIO_RECITATIONS_AT
mark_endpoint_entry()
set_stage("catalog")
now = time.monotonic()
with _AUDIO_RECITATIONS_LOCK:
if (
_AUDIO_RECITATIONS_CACHE is not None
and (now - _AUDIO_RECITATIONS_AT) < PRELOAD_AUDIO_CATALOG_TTL_S
):
return _AUDIO_RECITATIONS_CACHE
try:
payload = _build_audio_recitations()
except Exception as e: # noqa: BLE001 — surface as an error payload
log_error(
error_code="catalog_load_failed",
endpoint="preload_audio_recitations",
stage="catalog",
exception=e,
user_id=get_user_id(request) if request else "unknown",
message=f"Preload audio catalog load failed: {e}",
)
return {"recitations": [], "error": f"Catalog load failed: {e}"}
with _AUDIO_RECITATIONS_LOCK:
_AUDIO_RECITATIONS_CACHE = payload
_AUDIO_RECITATIONS_AT = time.monotonic()
return payload
# ---------------------------------------------------------------------------
# Endpoint 4 — chapter audio URL for an audio-only recitation (no segments)
# ---------------------------------------------------------------------------
def preload_audio(recitation, chapter, request: gr.Request = None):
"""Directly-fetchable full-chapter audio URL for a recitation / chapter.
Audio-only counterpart of ``preload_segments``: no segments, no clipping —
just the whole-chapter ``audio_url`` (bucket Space-route when the mp3 is on
the mount, else the manifest's CDN URL). Accepts any audio-eligible delivery
(released or audio-only). Errors return ``{"error", "audio_url": ""}``.
"""
mark_endpoint_entry()
set_stage("validate")
slug = (recitation or "").strip() if isinstance(recitation, str) else ""
if not slug:
log_error(
error_code="invalid_recitation", endpoint="preload_audio",
stage="validate", user_id=get_user_id(request) if request else "unknown",
message="recitation (delivery slug) is required",
)
return {"error": "recitation is required", "audio_url": ""}
try:
chapter = int(chapter)
except (TypeError, ValueError):
return {"error": "chapter must be an integer", "audio_url": ""}
if slug not in repo_loader._list_audio_delivery_slugs():
return {"error": f"Unknown or unavailable recitation {slug}", "audio_url": ""}
set_stage("build")
try:
url = repo_loader.resolve_chapter_audio_url(slug, chapter)
except Exception as e: # noqa: BLE001 — surface as an error payload
log_error(
error_code="preload_audio_failed", endpoint="preload_audio",
stage="build", exception=e,
user_id=get_user_id(request) if request else "unknown",
message=f"Preload audio resolve failed: {e}",
context={"recitation": slug, "chapter": chapter},
)
return {"error": f"Failed to resolve audio: {e}", "audio_url": ""}
audio_url = _absolute_audio_url(url, request)
if not audio_url:
return {"error": "No audio available for this recitation/chapter", "audio_url": ""}
return {"recitation": slug, "chapter": chapter, "audio_url": audio_url}