Merge remote-tracking branch 'upstream/dev' into feat/unified-model-connections

This commit is contained in:
Anish Sarkar 2026-06-13 19:04:49 +05:30
commit ab5423d2d2
45 changed files with 775 additions and 272 deletions

View file

@ -1 +1 @@
0.0.27 0.0.28

View file

@ -6,6 +6,8 @@ Revises: 157
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op from alembic import op
revision: str = "158" revision: str = "158"
@ -14,47 +16,128 @@ branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
PUBLICATION_NAME = "zero_publication" PUBLICATION_NAME = "zero_publication"
TARGET_STATUS_LABELS = (
"pending",
"awaiting_brief",
"drafting",
"awaiting_review",
"rendering",
"ready",
"failed",
"cancelled",
)
LEGACY_STATUS_LABELS = ("pending", "generating", "ready", "failed")
def _drop_podcasts_from_zero_publication() -> None: def _drop_podcasts_from_publication() -> None:
"""Temporarily unpublish podcasts while changing published columns.""" """Detach podcasts from zero_publication so status can be retyped.
Postgres refuses ``ALTER COLUMN ... TYPE`` on a column a publication
depends on. Some databases reach this migration with podcasts already
published (an interim apply_publication ran during 156); drop it here and
let migration 159 reconcile the publication to the canonical shape.
"""
conn = op.get_bind()
published = conn.execute(
sa.text(
"SELECT 1 FROM pg_publication_tables "
"WHERE pubname = :publication "
"AND schemaname = current_schema() AND tablename = 'podcasts'"
),
{"publication": PUBLICATION_NAME},
).fetchone()
if published:
op.execute(f'ALTER PUBLICATION "{PUBLICATION_NAME}" DROP TABLE "podcasts";')
def _enum_labels(type_name: str) -> list[str] | None:
rows = (
op.get_bind()
.execute(
sa.text(
"SELECT e.enumlabel "
"FROM pg_type t "
"JOIN pg_namespace n ON n.oid = t.typnamespace "
"JOIN pg_enum e ON e.enumtypid = t.oid "
"WHERE n.nspname = current_schema() AND t.typname = :type_name "
"ORDER BY e.enumsortorder"
),
{"type_name": type_name},
)
.fetchall()
)
if not rows:
return None
return [str(row[0]) for row in rows]
def _column_type_name(table: str, column: str) -> str | None:
row = (
op.get_bind()
.execute(
sa.text(
"SELECT udt_name "
"FROM information_schema.columns "
"WHERE table_schema = current_schema() "
"AND table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
.fetchone()
)
return str(row[0]) if row else None
def _ensure_status_enum(
*,
desired_labels: tuple[str, ...],
temporary_type: str,
create_sql: str,
alter_sql: str,
default_value: str,
) -> None:
current_labels = _enum_labels("podcast_status")
desired = list(desired_labels)
if current_labels != desired:
if current_labels is None:
if _enum_labels(temporary_type) is None:
raise RuntimeError("podcast_status enum is missing")
elif _enum_labels(temporary_type) is None:
op.execute(f"ALTER TYPE podcast_status RENAME TO {temporary_type};")
else:
raise RuntimeError(
"podcast_status and its temporary replacement both exist"
)
if _enum_labels("podcast_status") is None:
op.execute(create_sql)
if _enum_labels("podcast_status") != desired:
raise RuntimeError("podcast_status enum is not in the expected shape")
op.execute("ALTER TABLE podcasts ALTER COLUMN status DROP DEFAULT;")
if _column_type_name("podcasts", "status") != "podcast_status":
op.execute(alter_sql)
op.execute( op.execute(
f""" f"ALTER TABLE podcasts ALTER COLUMN status SET DEFAULT '{default_value}';"
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_publication_tables
WHERE pubname = '{PUBLICATION_NAME}'
AND schemaname = current_schema()
AND tablename = 'podcasts'
) THEN
ALTER PUBLICATION "{PUBLICATION_NAME}" DROP TABLE "podcasts";
END IF;
END
$$;
"""
) )
if _enum_labels(temporary_type) is not None:
op.execute(f"DROP TYPE {temporary_type};")
def upgrade() -> None:
_drop_podcasts_from_zero_publication()
# Retype the status enum by swapping in a fresh type and casting existing def _upgrade_status_enum() -> None:
# rows. The legacy transient value 'generating' maps onto 'rendering'. _ensure_status_enum(
op.execute("ALTER TYPE podcast_status RENAME TO podcast_status_old;") desired_labels=TARGET_STATUS_LABELS,
op.execute( temporary_type="podcast_status_old",
""" create_sql="""
CREATE TYPE podcast_status AS ENUM ( CREATE TYPE podcast_status AS ENUM (
'pending', 'awaiting_brief', 'drafting', 'awaiting_review', 'pending', 'awaiting_brief', 'drafting', 'awaiting_review',
'rendering', 'ready', 'failed', 'cancelled' 'rendering', 'ready', 'failed', 'cancelled'
); );
""" """,
) alter_sql="""
op.execute("ALTER TABLE podcasts ALTER COLUMN status DROP DEFAULT;")
op.execute(
"""
ALTER TABLE podcasts ALTER TABLE podcasts
ALTER COLUMN status TYPE podcast_status ALTER COLUMN status TYPE podcast_status
USING ( USING (
@ -63,45 +146,20 @@ def upgrade() -> None:
ELSE status::text ELSE status::text
END END
)::podcast_status; )::podcast_status;
""" """,
default_value="pending",
) )
op.execute("ALTER TABLE podcasts ALTER COLUMN status SET DEFAULT 'pending';")
op.execute("DROP TYPE podcast_status_old;")
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS source_content TEXT;")
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS spec JSONB;")
op.execute(
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS spec_version "
"INTEGER NOT NULL DEFAULT 1;"
)
op.execute(
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_backend VARCHAR(32);"
)
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_key TEXT;")
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;")
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS error TEXT;")
def downgrade() -> None: def _downgrade_status_enum() -> None:
_drop_podcasts_from_zero_publication() _ensure_status_enum(
desired_labels=LEGACY_STATUS_LABELS,
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS error;") temporary_type="podcast_status_new",
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS duration_seconds;") create_sql=(
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS storage_key;") "CREATE TYPE podcast_status AS ENUM "
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS storage_backend;") "('pending', 'generating', 'ready', 'failed');"
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS spec_version;") ),
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS spec;") alter_sql="""
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS source_content;")
# Collapse the expanded lifecycle back onto the original four values.
op.execute("ALTER TYPE podcast_status RENAME TO podcast_status_new;")
op.execute(
"CREATE TYPE podcast_status AS ENUM "
"('pending', 'generating', 'ready', 'failed');"
)
op.execute("ALTER TABLE podcasts ALTER COLUMN status DROP DEFAULT;")
op.execute(
"""
ALTER TABLE podcasts ALTER TABLE podcasts
ALTER COLUMN status TYPE podcast_status ALTER COLUMN status TYPE podcast_status
USING ( USING (
@ -114,7 +172,44 @@ def downgrade() -> None:
ELSE status::text ELSE status::text
END END
)::podcast_status; )::podcast_status;
""" """,
default_value="ready",
) )
op.execute("ALTER TABLE podcasts ALTER COLUMN status SET DEFAULT 'ready';")
op.execute("DROP TYPE podcast_status_new;")
def upgrade() -> None:
_drop_podcasts_from_publication()
# Retype the status enum by swapping in a fresh type and casting existing
# rows. The legacy transient value 'generating' maps onto 'rendering'.
_upgrade_status_enum()
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS source_content TEXT;")
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS spec JSONB;")
op.execute(
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS spec_version "
"INTEGER NOT NULL DEFAULT 1;"
)
op.execute(
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_backend VARCHAR(32);"
)
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_key TEXT;")
op.execute(
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;"
)
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS error TEXT;")
def downgrade() -> None:
_drop_podcasts_from_publication()
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS error;")
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS duration_seconds;")
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS storage_key;")
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS storage_backend;")
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS spec_version;")
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS spec;")
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS source_content;")
# Collapse the expanded lifecycle back onto the original four values.
_downgrade_status_enum()

View file

@ -9,6 +9,8 @@ then enqueues the matching Celery task; lifecycle errors map to 409/422.
from __future__ import annotations from __future__ import annotations
import os import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
@ -27,10 +29,10 @@ from app.db import (
from app.podcasts.generation.brief import propose_brief from app.podcasts.generation.brief import propose_brief
from app.podcasts.persistence import Podcast, PodcastRepository from app.podcasts.persistence import Podcast, PodcastRepository
from app.podcasts.service import ( from app.podcasts.service import (
InvalidTransition, InvalidTransitionError,
PodcastService, PodcastService,
PreconditionFailed, PreconditionFailedError,
SpecConflict, SpecConflictError,
) )
from app.podcasts.storage import open_audio_stream, purge_audio from app.podcasts.storage import open_audio_stream, purge_audio
from app.podcasts.tasks import draft_transcript_task from app.podcasts.tasks import draft_transcript_task
@ -45,6 +47,7 @@ from app.utils.rbac import check_permission
from .schemas import ( from .schemas import (
CreatePodcastRequest, CreatePodcastRequest,
LanguageOptions,
PodcastDetail, PodcastDetail,
PodcastSummary, PodcastSummary,
UpdateSpecRequest, UpdateSpecRequest,
@ -112,6 +115,20 @@ async def list_voices(language: str | None = None):
] ]
@router.get("/podcasts/languages", response_model=LanguageOptions)
async def list_languages():
"""Languages the active TTS provider can offer the brief editor."""
if not app_config.TTS_SERVICE:
raise HTTPException(status_code=503, detail="No TTS provider configured")
provider = provider_from_service(app_config.TTS_SERVICE)
offering = get_voice_catalog().offerable_languages(provider)
return LanguageOptions(
languages=offering.languages,
allows_custom=offering.allows_custom,
)
@router.get("/podcasts/voices/{voice_id}/preview") @router.get("/podcasts/voices/{voice_id}/preview")
async def preview_voice( async def preview_voice(
voice_id: str, voice_id: str,
@ -324,19 +341,12 @@ async def _load(
return podcast return podcast
class _lifecycle_errors: @asynccontextmanager
async def _lifecycle_errors() -> AsyncIterator[None]:
"""Map service lifecycle errors onto HTTP responses.""" """Map service lifecycle errors onto HTTP responses."""
try:
async def __aenter__(self) -> None: yield
return None except (SpecConflictError, InvalidTransitionError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
async def __aexit__(self, exc_type, exc, tb) -> bool: except PreconditionFailedError as exc:
if exc is None: raise HTTPException(status_code=422, detail=str(exc)) from exc
return False
if isinstance(exc, SpecConflict):
raise HTTPException(status_code=409, detail=str(exc)) from exc
if isinstance(exc, InvalidTransition):
raise HTTPException(status_code=409, detail=str(exc)) from exc
if isinstance(exc, PreconditionFailed):
raise HTTPException(status_code=422, detail=str(exc)) from exc
return False

View file

@ -51,6 +51,17 @@ class VoiceOption(BaseModel):
gender: str gender: str
class LanguageOptions(BaseModel):
"""The languages the brief editor may offer for the active provider.
When ``allows_custom`` is true the list is a curated starting point and
the editor accepts any BCP-47 tag beyond it.
"""
languages: list[str]
allows_custom: bool
class PodcastSummary(BaseModel): class PodcastSummary(BaseModel):
"""Lightweight list item.""" """Lightweight list item."""

View file

@ -23,7 +23,9 @@ class StructuredOutputError(RuntimeError):
"""The model reply could not be parsed into the expected shape.""" """The model reply could not be parsed into the expected shape."""
async def invoke_json(llm, messages: list[BaseMessage], model: type[T]) -> T: async def invoke_json[T: BaseModel](
llm, messages: list[BaseMessage], model: type[T]
) -> T:
"""Invoke ``llm`` and validate its reply as ``model``.""" """Invoke ``llm`` and validate its reply as ``model``."""
response = await llm.ainvoke(messages) response = await llm.ainvoke(messages)
content = strip_markdown_fences(extract_text_content(response.content)) content = strip_markdown_fences(extract_text_content(response.content))

View file

@ -18,7 +18,7 @@ from app.services.llm_service import get_agent_llm
from ..prompts import draft_segment_prompt, plan_outline_prompt from ..prompts import draft_segment_prompt, plan_outline_prompt
from ..structured import invoke_json from ..structured import invoke_json
from .config import TranscriptConfig from .config import TranscriptConfig
from .planning import Outline, OutlineSegment, SegmentDraft from .planning import Outline, SegmentDraft
from .state import TranscriptState from .state import TranscriptState
# Average speaking rate; converts target minutes to a target word count. # Average speaking rate; converts target minutes to a target word count.

View file

@ -32,7 +32,7 @@ async def concat_to_mp3(segment_paths: list[Path], output_path: Path) -> None:
.output(str(output_path), {"c:a": "libmp3lame"}) .output(str(output_path), {"c:a": "libmp3lame"})
) )
await ffmpeg.execute() await ffmpeg.execute()
except Exception as exc: # noqa: BLE001 - normalise ffmpeg failures except Exception as exc:
raise RenderError(f"audio merge failed: {exc}") from exc raise RenderError(f"audio merge failed: {exc}") from exc
finally: finally:
list_file.unlink(missing_ok=True) list_file.unlink(missing_ok=True)

View file

@ -77,9 +77,7 @@ class PodcastRenderer:
await concat_to_mp3(list(segment_paths), output_path) await concat_to_mp3(list(segment_paths), output_path)
return RenderedPodcast(data=output_path.read_bytes(), container="mp3") return RenderedPodcast(data=output_path.read_bytes(), container="mp3")
def _request_for( def _request_for(self, spec: PodcastSpec, turn: TranscriptTurn) -> SynthesisRequest:
self, spec: PodcastSpec, turn: TranscriptTurn
) -> SynthesisRequest:
try: try:
speaker = spec.speaker_for(turn.speaker) speaker = spec.speaker_for(turn.speaker)
except KeyError as exc: except KeyError as exc:
@ -132,7 +130,7 @@ class _SegmentSynthesizer:
if owner: if owner:
try: try:
path = await self._synthesize(request, key) path = await self._synthesize(request, key)
except BaseException as exc: # noqa: BLE001 - relayed to all waiters except BaseException as exc:
future.set_exception(exc) future.set_exception(exc)
else: else:
future.set_result(path) future.set_result(path)

View file

@ -70,7 +70,9 @@ class SpeakerSpec(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
slot: int = Field(..., ge=0, description="Stable index a transcript turn references") slot: int = Field(
..., ge=0, description="Stable index a transcript turn references"
)
name: str = Field(..., min_length=1, max_length=120) name: str = Field(..., min_length=1, max_length=120)
role: SpeakerRole role: SpeakerRole
voice_id: str = Field( voice_id: str = Field(

View file

@ -59,11 +59,11 @@ class PodcastError(RuntimeError):
"""Base class for lifecycle errors.""" """Base class for lifecycle errors."""
class InvalidTransition(PodcastError): class InvalidTransitionError(PodcastError):
"""A requested status change is not permitted from the current state.""" """A requested status change is not permitted from the current state."""
class SpecConflict(PodcastError): class SpecConflictError(PodcastError):
"""A spec edit raced another: the expected version is stale.""" """A spec edit raced another: the expected version is stale."""
def __init__(self, expected: int, actual: int) -> None: def __init__(self, expected: int, actual: int) -> None:
@ -74,7 +74,7 @@ class SpecConflict(PodcastError):
self.actual = actual self.actual = actual
class PreconditionFailed(PodcastError): class PreconditionFailedError(PodcastError):
"""A transition's data precondition (brief/transcript present) is unmet.""" """A transition's data precondition (brief/transcript present) is unmet."""
@ -110,12 +110,12 @@ class PodcastService:
) -> Podcast: ) -> Podcast:
"""Edit the brief at the gate, guarded by optimistic concurrency.""" """Edit the brief at the gate, guarded by optimistic concurrency."""
if _status(podcast) is not PodcastStatus.AWAITING_BRIEF: if _status(podcast) is not PodcastStatus.AWAITING_BRIEF:
raise InvalidTransition( raise InvalidTransitionError(
f"the brief can only be edited while awaiting_brief, " f"the brief can only be edited while awaiting_brief, "
f"not {_status(podcast).value}" f"not {_status(podcast).value}"
) )
if expected_version != podcast.spec_version: if expected_version != podcast.spec_version:
raise SpecConflict(expected_version, podcast.spec_version) raise SpecConflictError(expected_version, podcast.spec_version)
podcast.spec = spec.model_dump(mode="json") podcast.spec = spec.model_dump(mode="json")
podcast.spec_version += 1 podcast.spec_version += 1
await self._session.flush() await self._session.flush()
@ -124,7 +124,7 @@ class PodcastService:
async def begin_drafting(self, podcast: Podcast) -> Podcast: async def begin_drafting(self, podcast: Podcast) -> Podcast:
"""Approve the brief and start transcript drafting.""" """Approve the brief and start transcript drafting."""
if podcast.spec is None: if podcast.spec is None:
raise PreconditionFailed("cannot draft without a brief") raise PreconditionFailedError("cannot draft without a brief")
self._transition(podcast, PodcastStatus.DRAFTING) self._transition(podcast, PodcastStatus.DRAFTING)
await self._session.flush() await self._session.flush()
return podcast return podcast
@ -145,13 +145,13 @@ class PodcastService:
async def regenerate(self, podcast: Podcast) -> Podcast: async def regenerate(self, podcast: Podcast) -> Podcast:
"""Reopen the brief gate; the saved spec becomes the new starting point.""" """Reopen the brief gate; the saved spec becomes the new starting point."""
if _status(podcast) not in self._REGENERABLE: if _status(podcast) not in self._REGENERABLE:
raise InvalidTransition( raise InvalidTransitionError(
f"nothing to regenerate from {_status(podcast).value}" f"nothing to regenerate from {_status(podcast).value}"
) )
# Legacy episodes finished before briefs existed; a gate with nothing # Legacy episodes finished before briefs existed; a gate with nothing
# to review would strand them. # to review would strand them.
if podcast.spec is None: if podcast.spec is None:
raise PreconditionFailed("cannot regenerate without a brief") raise PreconditionFailedError("cannot regenerate without a brief")
self._transition(podcast, PodcastStatus.AWAITING_BRIEF) self._transition(podcast, PodcastStatus.AWAITING_BRIEF)
await self._session.flush() await self._session.flush()
return podcast return podcast
@ -164,7 +164,7 @@ class PodcastService:
has no regeneration to revert and is rejected. has no regeneration to revert and is rejected.
""" """
if not has_stored_episode(podcast): if not has_stored_episode(podcast):
raise InvalidTransition("no finished episode to fall back to") raise InvalidTransitionError("no finished episode to fall back to")
self._transition(podcast, PodcastStatus.READY) self._transition(podcast, PodcastStatus.READY)
await self._session.flush() await self._session.flush()
return podcast return podcast
@ -200,7 +200,7 @@ class PodcastService:
backing out goes through revert_regeneration instead. backing out goes through revert_regeneration instead.
""" """
if has_stored_episode(podcast): if has_stored_episode(podcast):
raise InvalidTransition( raise InvalidTransitionError(
"a finished episode exists; revert the regeneration instead" "a finished episode exists; revert the regeneration instead"
) )
self._transition(podcast, PodcastStatus.CANCELLED) self._transition(podcast, PodcastStatus.CANCELLED)
@ -210,7 +210,7 @@ class PodcastService:
def _transition(self, podcast: Podcast, target: PodcastStatus) -> None: def _transition(self, podcast: Podcast, target: PodcastStatus) -> None:
current = _status(podcast) current = _status(podcast)
if target not in _ALLOWED[current]: if target not in _ALLOWED[current]:
raise InvalidTransition( raise InvalidTransitionError(
f"{current.value} -> {target.value} is not allowed" f"{current.value} -> {target.value} is not allowed"
) )
podcast.status = target podcast.status = target

View file

@ -36,9 +36,10 @@ def draft_transcript_task(self, podcast_id: int, search_space_id: int) -> dict:
return run_async_celery_task( return run_async_celery_task(
lambda: _draft_transcript(podcast_id, search_space_id) lambda: _draft_transcript(podcast_id, search_space_id)
) )
except Exception as exc: # noqa: BLE001 - record and report, never crash worker except Exception as exc:
logger.error("Podcast %s drafting failed: %s", podcast_id, exc) logger.error("Podcast %s drafting failed: %s", podcast_id, exc)
run_async_celery_task(lambda: mark_failed(podcast_id, str(exc))) message = str(exc)
run_async_celery_task(lambda: mark_failed(podcast_id, message))
return {"status": "failed", "podcast_id": podcast_id} return {"status": "failed", "podcast_id": podcast_id}

View file

@ -15,7 +15,7 @@ from app.celery_app import celery_app
from app.podcasts.persistence import PodcastRepository from app.podcasts.persistence import PodcastRepository
from app.podcasts.rendering import PodcastRenderer from app.podcasts.rendering import PodcastRenderer
from app.podcasts.service import ( from app.podcasts.service import (
InvalidTransition, InvalidTransitionError,
PodcastService, PodcastService,
read_spec, read_spec,
read_transcript, read_transcript,
@ -36,9 +36,10 @@ _WORKDIR_BASE = Path(tempfile.gettempdir()) / "surfsense_podcasts"
def render_audio_task(self, podcast_id: int) -> dict: def render_audio_task(self, podcast_id: int) -> dict:
try: try:
return run_async_celery_task(lambda: _render_audio(podcast_id)) return run_async_celery_task(lambda: _render_audio(podcast_id))
except Exception as exc: # noqa: BLE001 - record and report, never crash worker except Exception as exc:
logger.error("Podcast %s render failed: %s", podcast_id, exc) logger.error("Podcast %s render failed: %s", podcast_id, exc)
run_async_celery_task(lambda: mark_failed(podcast_id, str(exc))) message = str(exc)
run_async_celery_task(lambda: mark_failed(podcast_id, message))
return {"status": "failed", "podcast_id": podcast_id} return {"status": "failed", "podcast_id": podcast_id}
@ -75,7 +76,7 @@ async def _render_audio(podcast_id: int) -> dict:
podcast, storage_backend=backend_name, storage_key=key podcast, storage_backend=backend_name, storage_key=key
) )
await session.commit() await session.commit()
except InvalidTransition: except InvalidTransitionError:
# A user back-out won the race (e.g. the regeneration was # A user back-out won the race (e.g. the regeneration was
# reverted): drop the stale render and leave the row alone. # reverted): drop the stale render and leave the row alone.
await purge_audio_object(key) await purge_audio_object(key)

View file

@ -50,9 +50,7 @@ class KokoroTextToSpeech(TextToSpeech):
async def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: async def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if not isinstance(request.voice, str): if not isinstance(request.voice, str):
raise TextToSpeechError( raise TextToSpeechError("Kokoro voices are named by string, not a mapping")
"Kokoro voices are named by string, not a mapping"
)
pipeline = self._pipeline_for(request.language) pipeline = self._pipeline_for(request.language)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@ -67,7 +65,7 @@ class KokoroTextToSpeech(TextToSpeech):
), ),
) )
segments = [audio for _gs, _ps, audio in generator] segments = [audio for _gs, _ps, audio in generator]
except Exception as exc: # noqa: BLE001 - normalise provider errors except Exception as exc:
raise TextToSpeechError(f"Kokoro synthesis failed: {exc}") from exc raise TextToSpeechError(f"Kokoro synthesis failed: {exc}") from exc
if not segments: if not segments:

View file

@ -57,10 +57,8 @@ class LiteLlmTextToSpeech(TextToSpeech):
try: try:
response = await aspeech(**kwargs) response = await aspeech(**kwargs)
except Exception as exc: # noqa: BLE001 - normalise provider errors except Exception as exc:
raise TextToSpeechError( raise TextToSpeechError(f"{self._model} synthesis failed: {exc}") from exc
f"{self._model} synthesis failed: {exc}"
) from exc
data = getattr(response, "content", None) data = getattr(response, "content", None)
if not data: if not data:

View file

@ -6,7 +6,7 @@ configured provider via :func:`provider_from_service`.
from __future__ import annotations from __future__ import annotations
from .catalog import VoiceCatalog, get_voice_catalog from .catalog import LanguageOffering, VoiceCatalog, get_voice_catalog
from .preview import render_voice_preview from .preview import render_voice_preview
from .provider import TtsProvider, provider_from_service from .provider import TtsProvider, provider_from_service
from .voice import ANY_LANGUAGE, CatalogVoice, VoiceGender from .voice import ANY_LANGUAGE, CatalogVoice, VoiceGender
@ -14,6 +14,7 @@ from .voice import ANY_LANGUAGE, CatalogVoice, VoiceGender
__all__ = [ __all__ = [
"ANY_LANGUAGE", "ANY_LANGUAGE",
"CatalogVoice", "CatalogVoice",
"LanguageOffering",
"TtsProvider", "TtsProvider",
"VoiceCatalog", "VoiceCatalog",
"VoiceGender", "VoiceGender",

View file

@ -9,11 +9,26 @@ provider-native reference.
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from .data import AZURE_VOICES, KOKORO_VOICES, OPENAI_VOICES, VERTEX_VOICES from .data import AZURE_VOICES, KOKORO_VOICES, OPENAI_VOICES, VERTEX_VOICES
from .data.languages import COMMON_LANGUAGES
from .provider import TtsProvider from .provider import TtsProvider
from .voice import CatalogVoice from .voice import ANY_LANGUAGE, CatalogVoice
@dataclass(frozen=True, slots=True)
class LanguageOffering:
"""The languages a provider's roster can offer the brief form.
``allows_custom`` is true when the roster has wildcard voices: the listed
languages are then a curated starting point, not a limit, and any BCP-47
tag may be entered.
"""
languages: list[str]
allows_custom: bool
class VoiceCatalog: class VoiceCatalog:
@ -36,9 +51,7 @@ class VoiceCatalog:
"""All voices offered by ``provider``, in catalog order.""" """All voices offered by ``provider``, in catalog order."""
return list(self._by_provider.get(provider, ())) return list(self._by_provider.get(provider, ()))
def for_language( def for_language(self, provider: TtsProvider, language: str) -> list[CatalogVoice]:
self, provider: TtsProvider, language: str
) -> list[CatalogVoice]:
"""``provider`` voices that can render ``language``, in catalog order.""" """``provider`` voices that can render ``language``, in catalog order."""
return [v for v in self.for_provider(provider) if v.speaks(language)] return [v for v in self.for_provider(provider) if v.speaks(language)]
@ -46,10 +59,22 @@ class VoiceCatalog:
"""Whether ``provider`` has at least one voice for ``language``.""" """Whether ``provider`` has at least one voice for ``language``."""
return any(v.speaks(language) for v in self.for_provider(provider)) return any(v.speaks(language) for v in self.for_provider(provider))
def offerable_languages(self, provider: TtsProvider) -> LanguageOffering:
"""The languages ``provider`` can offer up front.
Language-bound voices contribute their concrete tags; wildcard voices
cannot enumerate languages, so their presence merges in the curated
common list and opens free entry.
"""
voices = self.for_provider(provider)
tags = {v.language for v in voices if v.language != ANY_LANGUAGE}
has_wildcard = any(v.language == ANY_LANGUAGE for v in voices)
if has_wildcard:
tags.update(COMMON_LANGUAGES)
return LanguageOffering(languages=sorted(tags), allows_custom=has_wildcard)
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_voice_catalog() -> VoiceCatalog: def get_voice_catalog() -> VoiceCatalog:
"""The process-wide catalog assembled from every provider's roster.""" """The process-wide catalog assembled from every provider's roster."""
return VoiceCatalog( return VoiceCatalog((*KOKORO_VOICES, *OPENAI_VOICES, *AZURE_VOICES, *VERTEX_VOICES))
(*KOKORO_VOICES, *OPENAI_VOICES, *AZURE_VOICES, *VERTEX_VOICES)
)

View file

@ -0,0 +1,33 @@
"""Curated languages offered when a roster has wildcard (any-language) voices.
OpenAI-style multilingual voices speak whatever language the text is in, so
there is no provider list to enumerate. This is the set the brief form offers
up front for such providers; it is an offering, not a limit the API flags
``allows_custom`` so users can enter any BCP-47 tag beyond it.
"""
from __future__ import annotations
COMMON_LANGUAGES: tuple[str, ...] = (
"ar",
"bn",
"de",
"en",
"es",
"fr",
"hi",
"id",
"it",
"ja",
"ko",
"nl",
"pl",
"pt",
"ru",
"sw",
"th",
"tr",
"uk",
"vi",
"zh",
)

View file

@ -30,10 +30,52 @@ def _voice(
VERTEX_VOICES: tuple[CatalogVoice, ...] = ( VERTEX_VOICES: tuple[CatalogVoice, ...] = (
_voice("en-US-Studio-O", "en-US", "en-US", "en-US-Studio-O", "Studio O (US)", VoiceGender.FEMALE), _voice(
_voice("en-US-Studio-M", "en-US", "en-US", "en-US-Studio-M", "Studio M (US)", VoiceGender.MALE), "en-US-Studio-O",
_voice("en-GB-Studio-A", "en-GB", "en-UK", "en-UK-Studio-A", "Studio A (UK)", VoiceGender.FEMALE), "en-US",
_voice("en-GB-Studio-B", "en-GB", "en-UK", "en-UK-Studio-B", "Studio B (UK)", VoiceGender.MALE), "en-US",
_voice("en-AU-Studio-A", "en-AU", "en-AU", "en-AU-Studio-A", "Studio A (AU)", VoiceGender.FEMALE), "en-US-Studio-O",
_voice("en-AU-Studio-B", "en-AU", "en-AU", "en-AU-Studio-B", "Studio B (AU)", VoiceGender.MALE), "Studio O (US)",
VoiceGender.FEMALE,
),
_voice(
"en-US-Studio-M",
"en-US",
"en-US",
"en-US-Studio-M",
"Studio M (US)",
VoiceGender.MALE,
),
_voice(
"en-GB-Studio-A",
"en-GB",
"en-UK",
"en-UK-Studio-A",
"Studio A (UK)",
VoiceGender.FEMALE,
),
_voice(
"en-GB-Studio-B",
"en-GB",
"en-UK",
"en-UK-Studio-B",
"Studio B (UK)",
VoiceGender.MALE,
),
_voice(
"en-AU-Studio-A",
"en-AU",
"en-AU",
"en-AU-Studio-A",
"Studio A (AU)",
VoiceGender.FEMALE,
),
_voice(
"en-AU-Studio-B",
"en-AU",
"en-AU",
"en-AU-Studio-B",
"Studio B (AU)",
VoiceGender.MALE,
),
) )

View file

@ -30,7 +30,7 @@ _SAMPLE_TEXTS = {
"it": "Ciao! Questa è la mia voce quando racconto il tuo podcast.", "it": "Ciao! Questa è la mia voce quando racconto il tuo podcast.",
"ja": "こんにちは。ポッドキャストをお届けするときの私の声です。", "ja": "こんにちは。ポッドキャストをお届けするときの私の声です。",
"pt": "Olá! É assim que eu soo ao narrar o seu podcast.", "pt": "Olá! É assim que eu soo ao narrar o seu podcast.",
"zh": "你好!这就是我为你播报播客时的声音。", "zh": "你好!这就是我为你播报播客时的声音。", # noqa: RUF001
} }
_CONTENT_TYPES = {"mp3": "audio/mpeg", "wav": "audio/wav"} _CONTENT_TYPES = {"mp3": "audio/mpeg", "wav": "audio/wav"}
@ -40,9 +40,7 @@ async def render_voice_preview(
voice: CatalogVoice, tts: TextToSpeech voice: CatalogVoice, tts: TextToSpeech
) -> tuple[bytes, str]: ) -> tuple[bytes, str]:
"""Return ``(audio_bytes, content_type)`` for a sample spoken by ``voice``.""" """Return ``(audio_bytes, content_type)`` for a sample spoken by ``voice``."""
language = ( language = _FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language
_FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language
)
request = SynthesisRequest( request = SynthesisRequest(
text=_sample_text(language), voice=voice.native_ref, language=language text=_sample_text(language), voice=voice.native_ref, language=language
) )

View file

@ -59,7 +59,7 @@ def _card_error_payment_intent_id(exc: CardError) -> str | None:
@celery_app.task(name="auto_reload_credits") @celery_app.task(name="auto_reload_credits")
def auto_reload_credits_task(user_id: str): def auto_reload_credits_task(user_id: str):
"""Charge the user's saved card to top up credits when below threshold.""" """Charge the user's saved card to top up credits when below threshold."""
return run_async_celery_task(_auto_reload_credits, user_id) return run_async_celery_task(lambda: _auto_reload_credits(user_id))
async def _auto_reload_credits(user_id: str) -> None: async def _auto_reload_credits(user_id: str) -> None:

View file

@ -36,7 +36,11 @@ def iter_completion_emission_frames(
"success", "success",
) )
elif status in ("failed", "error"): elif status in ("failed", "error"):
error_msg = out.get("error", "Unknown error") if isinstance(out, dict) else "Unknown error" error_msg = (
out.get("error", "Unknown error")
if isinstance(out, dict)
else "Unknown error"
)
yield ctx.streaming_service.format_terminal_info( yield ctx.streaming_service.format_terminal_info(
f"Podcast generation failed: {error_msg}", f"Podcast generation failed: {error_msg}",
"error", "error",

View file

@ -86,66 +86,54 @@ def _quote_identifier(identifier: str) -> str:
return '"' + identifier.replace('"', '""') + '"' return '"' + identifier.replace('"', '""') + '"'
def _column_exists(conn: Connection, table: str, column: str) -> bool: def _table_columns(conn: Connection, table: str) -> set[str]:
return ( rows = conn.execute(
conn.execute( text(
text( "SELECT column_name FROM information_schema.columns "
"SELECT 1 FROM information_schema.columns " "WHERE table_schema = current_schema() AND table_name = :table"
"WHERE table_schema = current_schema() " ),
"AND table_name = :table AND column_name = :column" {"table": table},
), ).fetchall()
{"table": table, "column": column}, return {row[0] for row in rows}
).fetchone()
is not None
)
def _table_exists(conn: Connection, table: str) -> bool: def _expected_columns(conn: Connection, table: str) -> list[str] | None:
return (
conn.execute(
text(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = current_schema() "
"AND table_name = :table"
),
{"table": table},
).fetchone()
is not None
)
def _expected_columns(
conn: Connection, table: str, *, include_missing_columns: bool = True
) -> list[str] | None:
columns = ZERO_PUBLICATION[table] columns = ZERO_PUBLICATION[table]
if columns is None: if columns is None:
return None return None
if include_missing_columns: expected = list(columns)
expected = list(columns) if table in {"documents", "user", "podcasts"} and "_0_version" in _table_columns(
else: conn, table
expected = [column for column in columns if _column_exists(conn, table, column)]
if table in {"documents", "user", "podcasts"} and _column_exists(
conn, table, "_0_version"
): ):
expected.append("_0_version") expected.append("_0_version")
return expected return expected
def _format_table_entry( def _format_table_entry(conn: Connection, table: str) -> str | None:
conn: Connection, table: str, *, include_missing_columns: bool = True """Render one SET TABLE entry, or ``None`` if the table isn't ready.
) -> str | None:
if not include_missing_columns and not _table_exists(conn, table): Historical migrations (e.g. 155/156) call ``apply_publication`` while the
schema is still mid-history, before later migrations add columns that the
canonical shape references. A table is only published once it exists AND
every canonical column exists; otherwise it is omitted entirely and a later
reconcile migration (e.g. 159) picks it up once its columns land. Partial
column lists are deliberately avoided: publishing a column early would
block later ``ALTER COLUMN ... TYPE`` migrations on it (Postgres forbids
retyping columns a publication depends on). ``verify_publication`` remains
strict against the unfiltered canonical shape.
"""
actual = _table_columns(conn, table)
if not actual:
return None return None
columns = _expected_columns(
conn, table, include_missing_columns=include_missing_columns
)
table_sql = _quote_identifier(table) table_sql = _quote_identifier(table)
columns = _expected_columns(conn, table)
if columns is None: if columns is None:
return table_sql return table_sql
if not include_missing_columns and not columns:
if any(column not in actual for column in columns):
return None return None
column_sql = ", ".join(_quote_identifier(column) for column in columns) column_sql = ", ".join(_quote_identifier(column) for column in columns)
@ -155,17 +143,8 @@ def _format_table_entry(
def build_set_table_sql(conn: Connection) -> str: def build_set_table_sql(conn: Connection) -> str:
"""Build the canonical plain SET TABLE statement for Zero's event triggers.""" """Build the canonical plain SET TABLE statement for Zero's event triggers."""
table_entries = [ entries = [_format_table_entry(conn, table) for table in ZERO_PUBLICATION]
entry table_list = ", ".join(entry for entry in entries if entry is not None)
for table in ZERO_PUBLICATION
if (
entry := _format_table_entry(
conn, table, include_missing_columns=False
)
)
is not None
]
table_list = ", ".join(table_entries)
return f"ALTER PUBLICATION {_quote_identifier(PUBLICATION_NAME)} SET TABLE {table_list}" return f"ALTER PUBLICATION {_quote_identifier(PUBLICATION_NAME)} SET TABLE {table_list}"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "surf-new-backend" name = "surf-new-backend"
version = "0.0.27" version = "0.0.28"
description = "SurfSense Backend" description = "SurfSense Backend"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [

View file

@ -26,7 +26,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.app import app, limiter from app.app import app, limiter
from app.config import config as app_config from app.config import config as app_config
from app.db import SearchSpace, User, get_async_session from app.db import SearchSpace, User, get_async_session
from app.routes.search_spaces_routes import create_default_roles_and_membership
from app.podcasts.persistence import Podcast, PodcastStatus from app.podcasts.persistence import Podcast, PodcastStatus
from app.podcasts.schemas import ( from app.podcasts.schemas import (
DurationTarget, DurationTarget,
@ -39,6 +38,7 @@ from app.podcasts.schemas import (
) )
from app.podcasts.service import PodcastService from app.podcasts.service import PodcastService
from app.podcasts.tts import SynthesisRequest, SynthesizedAudio, TextToSpeech from app.podcasts.tts import SynthesisRequest, SynthesizedAudio, TextToSpeech
from app.routes.search_spaces_routes import create_default_roles_and_membership
from app.users import current_active_user from app.users import current_active_user
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@ -128,12 +128,8 @@ class FakeStorageBackend:
def fake_storage(monkeypatch) -> FakeStorageBackend: def fake_storage(monkeypatch) -> FakeStorageBackend:
"""Route audio storage to an in-memory backend for the stream routes.""" """Route audio storage to an in-memory backend for the stream routes."""
backend = FakeStorageBackend() backend = FakeStorageBackend()
monkeypatch.setattr( monkeypatch.setattr("app.podcasts.storage.get_storage_backend", lambda: backend)
"app.podcasts.storage.get_storage_backend", lambda: backend monkeypatch.setattr("app.file_storage.factory.get_storage_backend", lambda: backend)
)
monkeypatch.setattr(
"app.file_storage.factory.get_storage_backend", lambda: backend
)
return backend return backend
@ -159,9 +155,7 @@ def bind_task_session(db_session: AsyncSession, monkeypatch) -> AsyncSession:
"app.podcasts.tasks.render", "app.podcasts.tasks.render",
"app.podcasts.tasks.runtime", "app.podcasts.tasks.runtime",
): ):
monkeypatch.setattr( monkeypatch.setattr(f"{module}.get_celery_session_maker", lambda: _make_session)
f"{module}.get_celery_session_maker", lambda: _make_session
)
return db_session return db_session
@ -213,8 +207,12 @@ def build_spec(
language=language, language=language,
style=PodcastStyle.CONVERSATIONAL, style=PodcastStyle.CONVERSATIONAL,
speakers=[ speakers=[
SpeakerSpec(slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_ids[0]), SpeakerSpec(
SpeakerSpec(slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]), slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_ids[0]
),
SpeakerSpec(
slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]
),
], ],
duration=DurationTarget(min_minutes=10, max_minutes=20), duration=DurationTarget(min_minutes=10, max_minutes=20),
) )
@ -237,7 +235,7 @@ def make_podcast(db_session: AsyncSession):
session, so the endpoint under test reads a realistically-built row. session, so the endpoint under test reads a realistically-built row.
""" """
_LADDER = [ ladder = [
PodcastStatus.AWAITING_BRIEF, PodcastStatus.AWAITING_BRIEF,
PodcastStatus.DRAFTING, PodcastStatus.DRAFTING,
PodcastStatus.RENDERING, PodcastStatus.RENDERING,
@ -259,7 +257,7 @@ def make_podcast(db_session: AsyncSession):
await db_session.flush() await db_session.flush()
return podcast return podcast
targets = _LADDER[: _LADDER.index(status) + 1] targets = ladder[: ladder.index(status) + 1]
for target in targets: for target in targets:
if target is PodcastStatus.AWAITING_BRIEF: if target is PodcastStatus.AWAITING_BRIEF:
await service.attach_brief(podcast, build_spec()) await service.attach_brief(podcast, build_spec())

View file

@ -15,9 +15,7 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts" BASE = "/api/v1/podcasts"
async def test_cancel_from_a_live_state_succeeds( async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_podcast):
client, db_search_space, make_podcast
):
podcast = await make_podcast( podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
) )

View file

@ -23,18 +23,12 @@ BASE = "/api/v1/podcasts"
def preview_tts(monkeypatch, tmp_path) -> FakeTextToSpeech: def preview_tts(monkeypatch, tmp_path) -> FakeTextToSpeech:
"""Route preview synthesis to the fake provider and an isolated cache.""" """Route preview synthesis to the fake provider and an isolated cache."""
provider = FakeTextToSpeech() provider = FakeTextToSpeech()
monkeypatch.setattr( monkeypatch.setattr("app.podcasts.api.routes.get_text_to_speech", lambda: provider)
"app.podcasts.api.routes.get_text_to_speech", lambda: provider monkeypatch.setattr("app.podcasts.voices.preview.PREVIEW_CACHE_ROOT", tmp_path)
)
monkeypatch.setattr(
"app.podcasts.voices.preview.PREVIEW_CACHE_ROOT", tmp_path
)
return provider return provider
async def test_preview_returns_playable_audio_for_a_catalog_voice( async def test_preview_returns_playable_audio_for_a_catalog_voice(client, preview_tts):
client, preview_tts
):
resp = await client.get(f"{BASE}/voices/openai:alloy/preview") resp = await client.get(f"{BASE}/voices/openai:alloy/preview")
assert resp.status_code == 200 assert resp.status_code == 200
@ -42,9 +36,7 @@ async def test_preview_returns_playable_audio_for_a_catalog_voice(
assert resp.content == b"segment-audio" assert resp.content == b"segment-audio"
async def test_preview_is_synthesised_once_then_served_from_cache( async def test_preview_is_synthesised_once_then_served_from_cache(client, preview_tts):
client, preview_tts
):
first = await client.get(f"{BASE}/voices/openai:alloy/preview") first = await client.get(f"{BASE}/voices/openai:alloy/preview")
second = await client.get(f"{BASE}/voices/openai:alloy/preview") second = await client.get(f"{BASE}/voices/openai:alloy/preview")
@ -69,9 +61,7 @@ async def test_preview_voice_of_inactive_provider_is_404(client, preview_tts):
assert preview_tts.requests == [] assert preview_tts.requests == []
async def test_preview_without_tts_provider_is_503( async def test_preview_without_tts_provider_is_503(client, preview_tts, monkeypatch):
client, preview_tts, monkeypatch
):
monkeypatch.setattr(app_config, "TTS_SERVICE", None) monkeypatch.setattr(app_config, "TTS_SERVICE", None)
resp = await client.get(f"{BASE}/voices/openai:alloy/preview") resp = await client.get(f"{BASE}/voices/openai:alloy/preview")

View file

@ -29,3 +29,23 @@ async def test_voices_503_when_no_tts_configured(client, monkeypatch):
resp = await client.get(f"{BASE}/voices") resp = await client.get(f"{BASE}/voices")
assert resp.status_code == 503 assert resp.status_code == 503
async def test_languages_returns_the_active_providers_offering(client):
"""The brief form renders exactly what the backend offers — for a wildcard
provider (openai/tts-1) that is the curated list plus free entry."""
resp = await client.get(f"{BASE}/languages")
assert resp.status_code == 200
offering = resp.json()
assert "en" in offering["languages"]
assert "fr" in offering["languages"]
assert offering["allows_custom"] is True
async def test_languages_503_when_no_tts_configured(client, monkeypatch):
monkeypatch.setattr(app_config, "TTS_SERVICE", "")
resp = await client.get(f"{BASE}/languages")
assert resp.status_code == 503

View file

@ -38,7 +38,10 @@ def make_spec():
if speakers is None: if speakers is None:
speakers = [ speakers = [
SpeakerSpec( SpeakerSpec(
slot=0, name="Host", role=SpeakerRole.HOST, voice_id="kokoro:am_adam" slot=0,
name="Host",
role=SpeakerRole.HOST,
voice_id="kokoro:am_adam",
), ),
SpeakerSpec( SpeakerSpec(
slot=1, slot=1,

View file

@ -71,7 +71,9 @@ def _spec(voice_id: str) -> PodcastSpec:
async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path): async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path):
renderer = PodcastRenderer(tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")) renderer = PodcastRenderer(
tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")
)
transcript = Transcript(turns=[TranscriptTurn(speaker=5, text="Who am I?")]) transcript = Transcript(turns=[TranscriptTurn(speaker=5, text="Who am I?")])
with pytest.raises(RenderError): with pytest.raises(RenderError):
@ -81,7 +83,9 @@ async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path):
async def test_render_rejects_a_speaker_whose_voice_is_not_in_the_catalog(tmp_path): async def test_render_rejects_a_speaker_whose_voice_is_not_in_the_catalog(tmp_path):
renderer = PodcastRenderer(tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")) renderer = PodcastRenderer(
tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")
)
transcript = Transcript(turns=[TranscriptTurn(speaker=0, text="Hello.")]) transcript = Transcript(turns=[TranscriptTurn(speaker=0, text="Hello.")])
with pytest.raises(RenderError): with pytest.raises(RenderError):

View file

@ -64,7 +64,9 @@ def test_a_preferred_voice_invalid_for_the_language_is_replaced():
speaker_count=1, speaker_count=1,
preferred=["kokoro:does-not-exist"], preferred=["kokoro:does-not-exist"],
) )
assert voices[0].voice_id in {v.voice_id for v in catalog.for_provider(TtsProvider.KOKORO)} assert voices[0].voice_id in {
v.voice_id for v in catalog.for_provider(TtsProvider.KOKORO)
}
def test_resolution_fails_when_no_voice_speaks_the_language(): def test_resolution_fails_when_no_voice_speaks_the_language():

View file

@ -52,7 +52,9 @@ def test_for_provider_returns_only_that_providers_voices():
def test_for_language_matches_on_the_primary_subtag(): def test_for_language_matches_on_the_primary_subtag():
"""A request for 'en' should match an 'en-US' voice (region-insensitive).""" """A request for 'en' should match an 'en-US' voice (region-insensitive)."""
catalog = VoiceCatalog([_voice("k1", language="en-US")]) catalog = VoiceCatalog([_voice("k1", language="en-US")])
assert [v.voice_id for v in catalog.for_language(TtsProvider.KOKORO, "en")] == ["k1"] assert [v.voice_id for v in catalog.for_language(TtsProvider.KOKORO, "en")] == [
"k1"
]
def test_for_language_excludes_other_languages(): def test_for_language_excludes_other_languages():
@ -73,6 +75,59 @@ def test_supports_language_reports_availability():
assert not catalog.supports_language(TtsProvider.KOKORO, "de") assert not catalog.supports_language(TtsProvider.KOKORO, "de")
def test_offerable_languages_for_a_concrete_roster_are_its_tags_only():
"""A provider whose voices are language-bound offers exactly those tags."""
catalog = VoiceCatalog(
[
_voice("k1", language="en-US"),
_voice("k2", language="fr"),
_voice("k3", language="fr"),
]
)
offering = catalog.offerable_languages(TtsProvider.KOKORO)
assert offering.languages == ["en-US", "fr"]
assert offering.allows_custom is False
def test_a_wildcard_roster_offers_the_curated_languages_and_custom_entry():
"""Voices that speak anything can't enumerate languages themselves, so the
catalog offers the curated common list and invites free entry."""
catalog = VoiceCatalog(
[_voice("o1", provider=TtsProvider.OPENAI, language=ANY_LANGUAGE)]
)
offering = catalog.offerable_languages(TtsProvider.OPENAI)
assert {"en", "fr", "sw", "hi", "zh"} <= set(offering.languages)
assert offering.allows_custom is True
def test_a_mixed_roster_offers_the_union_of_concrete_and_curated():
catalog = VoiceCatalog(
[
_voice("v1", provider=TtsProvider.VERTEX_AI, language="en-GB"),
_voice("v2", provider=TtsProvider.VERTEX_AI, language=ANY_LANGUAGE),
]
)
offering = catalog.offerable_languages(TtsProvider.VERTEX_AI)
assert "en-GB" in offering.languages
assert "fr" in offering.languages
assert offering.allows_custom is True
def test_a_provider_with_no_voices_offers_nothing():
catalog = VoiceCatalog([_voice("k1")])
offering = catalog.offerable_languages(TtsProvider.OPENAI)
assert offering.languages == []
assert offering.allows_custom is False
def test_get_raises_for_an_unknown_voice(): def test_get_raises_for_an_unknown_voice():
catalog = VoiceCatalog([_voice("k1")]) catalog = VoiceCatalog([_voice("k1")])
with pytest.raises(KeyError): with pytest.raises(KeyError):

View file

@ -15,6 +15,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -28,6 +31,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -41,6 +47,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -54,6 +63,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -67,6 +79,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -80,6 +95,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -93,6 +111,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -106,6 +127,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -119,6 +143,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -132,6 +159,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -149,6 +179,10 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -162,6 +196,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -175,6 +212,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -188,6 +228,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -201,6 +244,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -214,6 +260,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -227,6 +276,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -240,6 +292,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -253,6 +308,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -266,6 +324,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -279,6 +340,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -296,6 +360,10 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -309,6 +377,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -322,6 +393,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -335,6 +409,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -348,6 +425,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -361,6 +441,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -374,6 +457,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -387,6 +473,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -400,6 +489,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -413,6 +505,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -426,6 +521,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -443,6 +541,10 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -456,6 +558,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -481,6 +586,12 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -494,6 +605,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -507,6 +621,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -532,6 +649,12 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -557,6 +680,12 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -570,6 +699,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
] ]
conflicts = [[ conflicts = [[
@ -9482,7 +9614,7 @@ wheels = [
[[package]] [[package]]
name = "surf-new-backend" name = "surf-new-backend"
version = "0.0.27" version = "0.0.28"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },

View file

@ -1,7 +1,7 @@
{ {
"name": "surfsense_browser_extension", "name": "surfsense_browser_extension",
"displayName": "Surfsense Browser Extension", "displayName": "Surfsense Browser Extension",
"version": "0.0.27", "version": "0.0.28",
"description": "Extension to collect Browsing History for SurfSense.", "description": "Extension to collect Browsing History for SurfSense.",
"author": "https://github.com/MODSetter", "author": "https://github.com/MODSetter",
"engines": { "engines": {

View file

@ -1,7 +1,7 @@
{ {
"name": "surfsense-desktop", "name": "surfsense-desktop",
"productName": "SurfSense", "productName": "SurfSense",
"version": "0.0.27", "version": "0.0.28",
"description": "SurfSense Desktop App", "description": "SurfSense Desktop App",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {

View file

@ -3,10 +3,7 @@ import type { MDXComponents } from "mdx/types";
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { changelog } from "@/.source/server"; import { changelog } from "@/.source/server";
import { import { ChangelogTimeline, type ChangelogTimelineEntry } from "@/components/ui/changelog-timeline";
ChangelogTimeline,
type ChangelogTimelineEntry,
} from "@/components/ui/changelog-timeline";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components"; import { getMDXComponents } from "@/mdx-components";

View file

@ -13,11 +13,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { import type { CreditPurchase, PagePurchase, PurchaseStatus } from "@/contracts/types/stripe.types";
CreditPurchase,
PagePurchase,
PurchaseStatus,
} from "@/contracts/types/stripe.types";
import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View file

@ -48,8 +48,8 @@ import {
isCommentReplyMetadata, isCommentReplyMetadata,
isConnectorIndexingMetadata, isConnectorIndexingMetadata,
isDocumentProcessingMetadata, isDocumentProcessingMetadata,
isNewMentionMetadata,
isInsufficientCreditsMetadata, isInsufficientCreditsMetadata,
isNewMentionMetadata,
} from "@/contracts/types/inbox.types"; } from "@/contracts/types/inbox.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import type { InboxItem } from "@/hooks/use-inbox"; import type { InboxItem } from "@/hooks/use-inbox";

View file

@ -1,9 +1,9 @@
export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar"; export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar";
export { ChatListItem } from "./ChatListItem"; export { ChatListItem } from "./ChatListItem";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { DocumentsSidebar } from "./DocumentsSidebar"; export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar"; export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { NavSection } from "./NavSection"; export { NavSection } from "./NavSection";
export { Sidebar } from "./Sidebar"; export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton"; export { SidebarCollapseButton } from "./SidebarCollapseButton";

View file

@ -3,9 +3,9 @@
import { import {
AlarmClock, AlarmClock,
FilePlus2, FilePlus2,
type LucideIcon,
Search, Search,
Settings2, Settings2,
type LucideIcon,
WandSparkles, WandSparkles,
X, X,
} from "lucide-react"; } from "lucide-react";

View file

@ -286,8 +286,8 @@ function PricingFAQ() {
Frequently Asked Questions Frequently Asked Questions
</h2> </h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground"> <p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
Everything you need to know about SurfSense credits and billing. Everything you need to know about SurfSense credits and billing. Can&apos;t find what you
Can&apos;t find what you need? Reach out at{" "} need? Reach out at{" "}
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline"> <a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
rohan@surfsense.com rohan@surfsense.com
</a> </a>

View file

@ -77,9 +77,7 @@ export function EarnCreditsContent() {
<div className="w-full space-y-5"> <div className="w-full space-y-5">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-bold tracking-tight">Earn Credits</h2> <h2 className="text-xl font-bold tracking-tight">Earn Credits</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">Earn bonus credits by completing tasks</p>
Earn bonus credits by completing tasks
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View file

@ -1,11 +1,20 @@
"use client"; "use client";
import { Loader2, Plus, Trash2 } from "lucide-react"; import { Check, ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -15,6 +24,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
type LanguageOptions,
MAX_SPEAKERS, MAX_SPEAKERS,
type PodcastSpec, type PodcastSpec,
type PodcastStyle, type PodcastStyle,
@ -56,6 +66,7 @@ interface BriefReviewProps {
export function BriefReview({ podcast, spec }: BriefReviewProps) { export function BriefReview({ podcast, spec }: BriefReviewProps) {
const [draft, setDraft] = useState<PodcastSpec>(spec); const [draft, setDraft] = useState<PodcastSpec>(spec);
const [voices, setVoices] = useState<VoiceOption[] | null>(null); const [voices, setVoices] = useState<VoiceOption[] | null>(null);
const [offering, setOffering] = useState<LanguageOptions | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// A pushed spec change (saved edit or concurrent editor) resets the form to // A pushed spec change (saved edit or concurrent editor) resets the form to
@ -75,19 +86,26 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
.catch(() => { .catch(() => {
if (!cancelled) setVoices([]); if (!cancelled) setVoices([]);
}); });
podcastsApiService
.listLanguages()
.then((options) => {
if (!cancelled) setOffering(options);
})
.catch(() => {
if (!cancelled) setOffering({ languages: [], allows_custom: false });
});
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, []);
// The backend owns the offering; the draft's language stays listed even
// when it falls outside it (e.g. a custom tag entered earlier).
const languages = useMemo(() => { const languages = useMemo(() => {
const tags = new Set<string>(); const tags = new Set(offering?.languages ?? []);
for (const voice of voices ?? []) {
if (voice.language !== ANY_LANGUAGE) tags.add(voice.language);
}
tags.add(draft.language); tags.add(draft.language);
return [...tags].sort(); return [...tags].sort();
}, [voices, draft.language]); }, [offering, draft.language]);
const voicesForLanguage = useMemo( const voicesForLanguage = useMemo(
() => (voices ?? []).filter((voice) => speaks(voice, draft.language)), () => (voices ?? []).filter((voice) => speaks(voice, draft.language)),
@ -193,18 +211,22 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="podcast-language">Language</Label> <Label htmlFor="podcast-language">Language</Label>
<Select value={draft.language} onValueChange={setLanguage}> {offering?.allows_custom ? (
<SelectTrigger id="podcast-language"> <LanguageCombobox value={draft.language} languages={languages} onSelect={setLanguage} />
<SelectValue placeholder="Language" /> ) : (
</SelectTrigger> <Select value={draft.language} onValueChange={setLanguage}>
<SelectContent> <SelectTrigger id="podcast-language">
{languages.map((tag) => ( <SelectValue placeholder="Language" />
<SelectItem key={tag} value={tag}> </SelectTrigger>
{languageLabel(tag)} <SelectContent>
</SelectItem> {languages.map((tag) => (
))} <SelectItem key={tag} value={tag}>
</SelectContent> {languageLabel(tag)}
</Select> </SelectItem>
))}
</SelectContent>
</Select>
)}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="podcast-style">Style</Label> <Label htmlFor="podcast-style">Style</Label>
@ -375,6 +397,80 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
); );
} }
/** A searchable language picker for providers whose voices speak anything:
* the offered list comes from the backend, and any BCP-47 tag may be typed
* when none of them fits. */
function LanguageCombobox({
value,
languages,
onSelect,
}: {
value: string;
languages: string[];
onSelect: (language: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const pick = (tag: string) => {
onSelect(tag);
setOpen(false);
setQuery("");
};
const customTag = query.trim();
const isNewTag =
customTag.length > 0 && !languages.some((tag) => tag.toLowerCase() === customTag.toLowerCase());
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
id="podcast-language"
className="border-popover-border flex h-9 w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="line-clamp-1 text-left">{languageLabel(value)}</span>
<ChevronDown className="size-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput
placeholder="Search or type a language tag…"
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No matching language.</CommandEmpty>
<CommandGroup>
{languages.map((tag) => (
<CommandItem
key={tag}
value={tag}
keywords={[languageLabel(tag)]}
onSelect={() => pick(tag)}
>
<Check className={tag === value ? "size-4" : "size-4 opacity-0"} />
{languageLabel(tag)}
</CommandItem>
))}
{isNewTag ? (
<CommandItem value={customTag} onSelect={() => pick(customTag)}>
<Plus className="size-4" />
Use {customTag}
</CommandItem>
) : null}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
/** The current selection stays listed even when it no longer matches the /** The current selection stays listed even when it no longer matches the
* language filter, so the Select never renders an orphaned value. */ * language filter, so the Select never renders an orphaned value. */
function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] { function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {

View file

@ -103,6 +103,15 @@ export const voiceOption = z.object({
}); });
export type VoiceOption = z.infer<typeof voiceOption>; export type VoiceOption = z.infer<typeof voiceOption>;
// The languages the backend offers for the active TTS provider. When
// `allows_custom` is true the list is a starting point and any BCP-47 tag
// may be entered.
export const languageOptions = z.object({
languages: z.array(z.string()),
allows_custom: z.boolean(),
});
export type LanguageOptions = z.infer<typeof languageOptions>;
export const updateSpecRequest = z.object({ export const updateSpecRequest = z.object({
spec: podcastSpec, spec: podcastSpec,
expected_version: z.number().int().min(1), expected_version: z.number().int().min(1),

View file

@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { import {
languageOptions,
type PodcastSpec, type PodcastSpec,
podcastDetail, podcastDetail,
updateSpecRequest, updateSpecRequest,
@ -60,6 +61,12 @@ class PodcastsApiService {
return baseApiService.get(`${BASE}/voices${qs}`, voiceOptionList); return baseApiService.get(`${BASE}/voices${qs}`, voiceOptionList);
}; };
// The languages the active provider can offer; the brief form renders
// exactly this list and only opens free entry when the backend allows it.
listLanguages = async () => {
return baseApiService.get(`${BASE}/languages`, languageOptions);
};
// A short audio sample of a voice, cached server-side per voice. // A short audio sample of a voice, cached server-side per voice.
previewVoice = async (voiceId: string) => { previewVoice = async (voiceId: string) => {
return baseApiService.getBlob(`${BASE}/voices/${encodeURIComponent(voiceId)}/preview`); return baseApiService.getBlob(`${BASE}/voices/${encodeURIComponent(voiceId)}/preview`);

View file

@ -1,6 +1,6 @@
{ {
"name": "surfsense_web", "name": "surfsense_web",
"version": "0.0.27", "version": "0.0.28",
"private": true, "private": true,
"packageManager": "pnpm@10.26.0", "packageManager": "pnpm@10.26.0",
"description": "SurfSense Frontend", "description": "SurfSense Frontend",