Spaces:
Running
Running
deploy: main@5d35a5b (batched narration read-path: clip offsets + clipRef headers so the reader can play ONE sentence out of a shared blob; + research agent)
3b86006 verified | """DTOs for the audiobook endpoints (BE_AUDIOBOOK_SPEC Β§5.5). | |
| Field names match the FE ``contract.ts`` 1:1 (camelCase via :class:`ApiModel`). | |
| ``AudiobookStateEnum`` is a real ``str, Enum`` so OpenAPI codegen yields a TS | |
| union (a ``Literal`` would collapse to ``string``). | |
| """ | |
| from __future__ import annotations | |
| from enum import Enum | |
| from .common import ApiModel | |
| class AudiobookStateEnum(str, Enum): | |
| """Mirrors the FE ``AudiobookState`` union.""" | |
| idle = "idle" | |
| generating = "generating" | |
| ready = "ready" | |
| partial = "partial" | |
| error = "error" | |
| class AudioManifestSpan(ApiModel): | |
| """One span entry in the manifest (matches a reader highlight span). | |
| BATCHED NARRATION (additive, v10): when one TTS request voiced several | |
| consecutive sentences, the returned audio is stored ONCE and never cut, and | |
| each sentence carries a play window into that shared blob. | |
| * ``clipStartMs`` / ``clipEndMs`` null β this span owns its whole clip file | |
| (one request per sentence β the original behaviour, and what every span | |
| recorded before this feature reports). Clients that ignore these fields | |
| keep working unchanged. | |
| * both set β play the fetched audio from ``clipStartMs`` and stop at | |
| ``clipEndMs``. | |
| ``durationMs`` is ALWAYS this sentence's own length (``clipEndMs - | |
| clipStartMs`` when windowed), never the shared blob's, so every existing | |
| consumer of it stays correct. | |
| ``clipRef`` is an opaque, stable id for the underlying audio object. Spans | |
| that share a blob share a ``clipRef``, which is how a client fetches and | |
| stores it ONCE instead of once per sentence. It is a hash, not a storage | |
| path β it exposes no bucket layout. | |
| """ | |
| span_id: str | |
| page: int | |
| paragraph: int | |
| duration_ms: int | |
| has_word_marks: bool | |
| clip_start_ms: int | None = None | |
| clip_end_ms: int | None = None | |
| clip_ref: str | None = None | |
| class AudioManifest(ApiModel): | |
| """``GET /books/{id}/audio/manifest`` 200 body.""" | |
| engine: str | |
| voice: str | |
| lang: str | |
| generated_at: str | None = None | |
| spans: list[AudioManifestSpan] | |
| total_spans: int | |
| done_spans: int | |
| state: AudiobookStateEnum | |
| class AudioStatus(ApiModel): | |
| """``GET /books/{id}/audio/status`` 200 body (manifest numbers, no spans).""" | |
| state: AudiobookStateEnum | |
| done_spans: int | |
| total_spans: int | |
| engine: str | |
| error: str | None = None | |
| # Actual accumulated cost for this (book_id, engine, voice) run, summed from | |
| # tts_usage_ledger. null when no usage rows exist (e.g. free MMS runs). | |
| actual_cost_usd: float | None = None | |
| class GenerateAudiobookRequest(ApiModel): | |
| """``POST /books/{id}/audio:generate`` body. | |
| ``engine`` is required (validated against the TTS registry). The rest are | |
| optional selection params that default from ``config.yaml > audiobook`` when | |
| omitted, so the wizard can pre-fill them transparently: | |
| * ``model`` β engine model id (e.g. ``gemini-2.5-flash-preview-tts``). | |
| * ``voice`` β engine voice id (e.g. ``Pulcherrima`` / ``ar-XA-Wavenet-D``). | |
| * ``lang`` β BCP-47 narration language (defaults from the book's language). | |
| * ``style`` β optional natural-language delivery instruction (Gemini only). | |
| * ``tier`` β ``'free'`` (throttle hard, $0) or ``'paid'`` (full speed). | |
| * ``force`` β re-generate spans already rendered for this (engine, voice). | |
| """ | |
| engine: str | |
| model: str | None = None | |
| voice: str | None = None | |
| lang: str | None = None | |
| style: str | None = None | |
| tier: str | None = None | |
| force: bool = False | |
| class AudioRunSummaryDTO(ApiModel): | |
| """One (engine, voice) run summary for the admin overview.""" | |
| engine: str | |
| voice: str | |
| model_version: str | None = None | |
| state: str | |
| done_clips: int | |
| skipped_clips: int | |
| error_clips: int | |
| # total_spans from the status row (planned at generation start). | |
| total_spans: int | |
| # Percentage of planned spans that are done (done_clips / total_spans * 100). | |
| coverage_pct: float | |
| # Sum of duration_ms over done clips (ms). | |
| total_duration_ms: int | |
| # ISO-8601 string of the last finished_at timestamp (may be null if still running). | |
| last_run_at: str | None = None | |
| # Actual accumulated cost for this (engine) for the current calendar month. | |
| # null = free engine (MMS) or no ledger rows yet. | |
| actual_cost_usd: float | None = None | |
| class AudioBookAdminSummary(ApiModel): | |
| """One book entry in the admin overview.""" | |
| book_id: str | |
| title: str | |
| language: str | None = None | |
| # Planned spans (from the most-recent status row, or 0 when no runs). | |
| total_spans: int | |
| runs: list[AudioRunSummaryDTO] | |
| class AudioAdminOverview(ApiModel): | |
| """``GET /books/audio/admin/overview`` 200 body.""" | |
| books: list[AudioBookAdminSummary] | |
| class AudioVoiceStateEnum(str, Enum): | |
| """Per-voice recording status for a book (drives the Reading Room picker). | |
| Distinct from :class:`AudiobookStateEnum` (a generation-run lifecycle): this | |
| is the READER-facing answer to "can I pick this voice for THIS book?". | |
| * ``ready`` β every planned span is recorded (selectable, full playback). | |
| * ``partial`` β some spans recorded, some missing (selectable; missing | |
| spans fall through per-span on the client). | |
| * ``none`` β no clips for this (engine, voice) on this book; the voice | |
| COULD be generated (it's in the config catalog) but isn't | |
| recorded yet β shown but DISABLED ("Not recorded yet"). | |
| """ | |
| ready = "ready" | |
| partial = "partial" | |
| none = "none" | |
| class AudioVoice(ApiModel): | |
| """One selectable/visible voice in ``GET /books/{id}/audio/voices``. | |
| Recorded voices (``ready``/``partial``) carry real ``spansDone``/``spansTotal`` | |
| and play their own clips when selected. ``none`` voices come from the config | |
| ``audiobook.providers`` catalog (merged in) so the picker can show every voice | |
| that exists, disabled, with its Arabic rating. | |
| """ | |
| engine: str | |
| voice: str | |
| label: str | |
| state: AudioVoiceStateEnum | |
| spans_done: int = 0 | |
| spans_total: int = 0 | |
| # PAGE coverage β the human-meaningful count the picker shows ("180 / 400 | |
| # pages"). pages_done = DISTINCT book pages with β₯1 'done' clip for this voice; | |
| # pages_total = the book's real page count (book metadata). The span counts | |
| # above (sentence/chunk granularity, ~20Γ larger) are kept for any internal | |
| # use but are NOT what the reader sees. | |
| pages_done: int = 0 | |
| pages_total: int = 0 | |
| # Arabic quality /10 from config (Mario's lived score), null when unrated. | |
| arabic_rating: float | None = None | |
| # One-line human insight (config ``insight``), shown as picker subtext. | |
| insight: str | None = None | |
| # Pinned model id for a recorded voice (transparency), null for catalog-only. | |
| model_version: str | None = None | |
| class AudioVoiceList(ApiModel): | |
| """``GET /books/{id}/audio/voices`` 200 body. | |
| ``voices`` is ordered most-complete-recorded first, then catalog-only | |
| (``none``) voices. ``default`` is the engine/voice the FE should pre-select | |
| (the most-complete recorded voice, or null when nothing is recorded yet). | |
| """ | |
| voices: list[AudioVoice] | |
| default_engine: str | None = None | |
| default_voice: str | None = None | |
| class AudioCostEstimate(ApiModel): | |
| """``GET /books/{id}/audio/estimate`` 200 body β pre-flight consent step. | |
| The legacy CostGuard fields (cost_usd .. is_free) are preserved for backward | |
| compatibility. The richer fields below are ADDITIVE (Task 2 / Β§13 estimate). | |
| """ | |
| engine: str | |
| chars: int | |
| cost_usd: float | |
| free_chars_remaining: int | |
| budget_remaining_usd: float | |
| within_budget: bool | |
| warn: bool | |
| would_exceed: bool | |
| is_free: bool | |
| # --- ADDITIVE: richer Β§13 estimate fields (may be null for simple engines) --- | |
| # Number of narratable spans (non-empty speech_text; excludes page-number artifacts) | |
| narratable_spans: int | None = None | |
| # Estimated audio duration in minutes (chars / 10.5 ar-chars/sec) | |
| est_audio_minutes: float | None = None | |
| # Gemini token breakdown (null for char-billed engines) | |
| est_tokens: dict | None = None | |
| # More precise USD estimate from Β§13 method (supersedes cost_usd for Gemini) | |
| est_usd: float | None = None | |
| # ISO-4217 currency code | |
| currency: str = "USD" | |
| # Chars or tokens remaining in the free monthly quota for this provider | |
| # (null when not applicable, e.g. MMS=unlimited, Gemini=rate-limited not char-limited) | |
| free_quota_remaining: int | None = None | |
| # True when the book's chars fit within this month's free quota | |
| within_free_tier: bool | None = None | |
| # Whether the price figures are approximate (always true for Gemini; false for MMS) | |
| price_estimated: bool | None = None | |
| # Human-readable one-liner (chars β estimate β cost) | |
| breakdown: str | None = None | |
| # Model id actually used for the estimate | |
| model: str | None = None | |
| # Voice id actually used for the estimate | |
| voice: str | None = None | |