File size: 15,057 Bytes
a642146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7814c84
 
a642146
 
 
7814c84
530e49b
a642146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530e49b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a642146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530e49b
 
 
 
 
a642146
 
 
 
 
 
530e49b
 
a642146
 
530e49b
 
 
 
 
 
 
7814c84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530e49b
 
 
 
 
 
 
 
7814c84
 
530e49b
 
7814c84
530e49b
 
7814c84
 
 
 
 
 
 
 
 
530e49b
7814c84
530e49b
 
 
 
 
 
 
 
 
 
 
7814c84
 
 
 
530e49b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
"""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}