"""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