From 05190da0a9f710d290d0b1fd90145f407e20bb5d Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 11 Jun 2026 15:31:43 -0700 Subject: [PATCH 01/15] chore: linting --- .../versions/158_evolve_podcasts_lifecycle.py | 4 +- surfsense_backend/app/podcasts/api/routes.py | 31 +++++------ .../app/podcasts/generation/structured.py | 4 +- .../podcasts/generation/transcript/nodes.py | 2 +- .../app/podcasts/rendering/merge.py | 2 +- .../app/podcasts/rendering/renderer.py | 6 +-- .../app/podcasts/schemas/spec.py | 4 +- surfsense_backend/app/podcasts/service.py | 22 ++++---- surfsense_backend/app/podcasts/tasks/draft.py | 5 +- .../app/podcasts/tasks/render.py | 9 ++-- .../app/podcasts/tts/adapters/kokoro.py | 6 +-- .../app/podcasts/tts/adapters/litellm.py | 6 +-- .../app/podcasts/voices/catalog.py | 8 +-- .../app/podcasts/voices/data/vertex.py | 54 ++++++++++++++++--- .../app/podcasts/voices/preview.py | 6 +-- .../deliverables/generate_podcast/emission.py | 6 ++- .../tests/integration/podcasts/conftest.py | 26 +++++---- .../tests/integration/podcasts/test_cancel.py | 4 +- .../podcasts/test_voice_preview.py | 20 ++----- .../tests/unit/podcasts/conftest.py | 5 +- .../tests/unit/podcasts/test_renderer.py | 8 ++- .../tests/unit/podcasts/test_resolution.py | 4 +- .../tests/unit/podcasts/test_voice_catalog.py | 4 +- surfsense_web/app/(home)/changelog/page.tsx | 5 +- .../components/PurchaseHistoryContent.tsx | 6 +-- .../layout/ui/sidebar/InboxSidebar.tsx | 2 +- .../components/layout/ui/sidebar/index.ts | 2 +- .../new-chat/chat-example-prompts.tsx | 2 +- .../components/pricing/pricing-section.tsx | 4 +- .../settings/earn-credits-content.tsx | 4 +- 30 files changed, 148 insertions(+), 123 deletions(-) diff --git a/surfsense_backend/alembic/versions/158_evolve_podcasts_lifecycle.py b/surfsense_backend/alembic/versions/158_evolve_podcasts_lifecycle.py index 15cf04f9d..ae595cc1b 100644 --- a/surfsense_backend/alembic/versions/158_evolve_podcasts_lifecycle.py +++ b/surfsense_backend/alembic/versions/158_evolve_podcasts_lifecycle.py @@ -52,7 +52,9 @@ def upgrade() -> None: "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 duration_seconds INTEGER;" + ) op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS error TEXT;") diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py index 0a9a8e659..80e5e1c64 100644 --- a/surfsense_backend/app/podcasts/api/routes.py +++ b/surfsense_backend/app/podcasts/api/routes.py @@ -9,6 +9,8 @@ then enqueues the matching Celery task; lifecycle errors map to 409/422. from __future__ import annotations import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from pathlib import Path 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.persistence import Podcast, PodcastRepository from app.podcasts.service import ( - InvalidTransition, + InvalidTransitionError, PodcastService, - PreconditionFailed, - SpecConflict, + PreconditionFailedError, + SpecConflictError, ) from app.podcasts.storage import open_audio_stream, purge_audio from app.podcasts.tasks import draft_transcript_task @@ -324,19 +326,12 @@ async def _load( return podcast -class _lifecycle_errors: +@asynccontextmanager +async def _lifecycle_errors() -> AsyncIterator[None]: """Map service lifecycle errors onto HTTP responses.""" - - async def __aenter__(self) -> None: - return None - - async def __aexit__(self, exc_type, exc, tb) -> bool: - if exc is None: - 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 + try: + yield + except (SpecConflictError, InvalidTransitionError) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except PreconditionFailedError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc diff --git a/surfsense_backend/app/podcasts/generation/structured.py b/surfsense_backend/app/podcasts/generation/structured.py index bcc03a6c7..08132e776 100644 --- a/surfsense_backend/app/podcasts/generation/structured.py +++ b/surfsense_backend/app/podcasts/generation/structured.py @@ -23,7 +23,9 @@ class StructuredOutputError(RuntimeError): """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``.""" response = await llm.ainvoke(messages) content = strip_markdown_fences(extract_text_content(response.content)) diff --git a/surfsense_backend/app/podcasts/generation/transcript/nodes.py b/surfsense_backend/app/podcasts/generation/transcript/nodes.py index b4a3e6541..44d6b219d 100644 --- a/surfsense_backend/app/podcasts/generation/transcript/nodes.py +++ b/surfsense_backend/app/podcasts/generation/transcript/nodes.py @@ -18,7 +18,7 @@ from app.services.llm_service import get_agent_llm from ..prompts import draft_segment_prompt, plan_outline_prompt from ..structured import invoke_json from .config import TranscriptConfig -from .planning import Outline, OutlineSegment, SegmentDraft +from .planning import Outline, SegmentDraft from .state import TranscriptState # Average speaking rate; converts target minutes to a target word count. diff --git a/surfsense_backend/app/podcasts/rendering/merge.py b/surfsense_backend/app/podcasts/rendering/merge.py index 48771d17c..223295349 100644 --- a/surfsense_backend/app/podcasts/rendering/merge.py +++ b/surfsense_backend/app/podcasts/rendering/merge.py @@ -32,7 +32,7 @@ async def concat_to_mp3(segment_paths: list[Path], output_path: Path) -> None: .output(str(output_path), {"c:a": "libmp3lame"}) ) 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 finally: list_file.unlink(missing_ok=True) diff --git a/surfsense_backend/app/podcasts/rendering/renderer.py b/surfsense_backend/app/podcasts/rendering/renderer.py index 89a4e6b7d..44071c060 100644 --- a/surfsense_backend/app/podcasts/rendering/renderer.py +++ b/surfsense_backend/app/podcasts/rendering/renderer.py @@ -77,9 +77,7 @@ class PodcastRenderer: await concat_to_mp3(list(segment_paths), output_path) return RenderedPodcast(data=output_path.read_bytes(), container="mp3") - def _request_for( - self, spec: PodcastSpec, turn: TranscriptTurn - ) -> SynthesisRequest: + def _request_for(self, spec: PodcastSpec, turn: TranscriptTurn) -> SynthesisRequest: try: speaker = spec.speaker_for(turn.speaker) except KeyError as exc: @@ -132,7 +130,7 @@ class _SegmentSynthesizer: if owner: try: path = await self._synthesize(request, key) - except BaseException as exc: # noqa: BLE001 - relayed to all waiters + except BaseException as exc: future.set_exception(exc) else: future.set_result(path) diff --git a/surfsense_backend/app/podcasts/schemas/spec.py b/surfsense_backend/app/podcasts/schemas/spec.py index a2c68e359..1ef3dcfff 100644 --- a/surfsense_backend/app/podcasts/schemas/spec.py +++ b/surfsense_backend/app/podcasts/schemas/spec.py @@ -70,7 +70,9 @@ class SpeakerSpec(BaseModel): 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) role: SpeakerRole voice_id: str = Field( diff --git a/surfsense_backend/app/podcasts/service.py b/surfsense_backend/app/podcasts/service.py index 1a2f3677b..165bc77a4 100644 --- a/surfsense_backend/app/podcasts/service.py +++ b/surfsense_backend/app/podcasts/service.py @@ -59,11 +59,11 @@ class PodcastError(RuntimeError): """Base class for lifecycle errors.""" -class InvalidTransition(PodcastError): +class InvalidTransitionError(PodcastError): """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.""" def __init__(self, expected: int, actual: int) -> None: @@ -74,7 +74,7 @@ class SpecConflict(PodcastError): self.actual = actual -class PreconditionFailed(PodcastError): +class PreconditionFailedError(PodcastError): """A transition's data precondition (brief/transcript present) is unmet.""" @@ -110,12 +110,12 @@ class PodcastService: ) -> Podcast: """Edit the brief at the gate, guarded by optimistic concurrency.""" if _status(podcast) is not PodcastStatus.AWAITING_BRIEF: - raise InvalidTransition( + raise InvalidTransitionError( f"the brief can only be edited while awaiting_brief, " f"not {_status(podcast).value}" ) 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_version += 1 await self._session.flush() @@ -124,7 +124,7 @@ class PodcastService: async def begin_drafting(self, podcast: Podcast) -> Podcast: """Approve the brief and start transcript drafting.""" if podcast.spec is None: - raise PreconditionFailed("cannot draft without a brief") + raise PreconditionFailedError("cannot draft without a brief") self._transition(podcast, PodcastStatus.DRAFTING) await self._session.flush() return podcast @@ -145,13 +145,13 @@ class PodcastService: async def regenerate(self, podcast: Podcast) -> Podcast: """Reopen the brief gate; the saved spec becomes the new starting point.""" if _status(podcast) not in self._REGENERABLE: - raise InvalidTransition( + raise InvalidTransitionError( f"nothing to regenerate from {_status(podcast).value}" ) # Legacy episodes finished before briefs existed; a gate with nothing # to review would strand them. 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) await self._session.flush() return podcast @@ -164,7 +164,7 @@ class PodcastService: has no regeneration to revert and is rejected. """ 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) await self._session.flush() return podcast @@ -200,7 +200,7 @@ class PodcastService: backing out goes through revert_regeneration instead. """ if has_stored_episode(podcast): - raise InvalidTransition( + raise InvalidTransitionError( "a finished episode exists; revert the regeneration instead" ) self._transition(podcast, PodcastStatus.CANCELLED) @@ -210,7 +210,7 @@ class PodcastService: def _transition(self, podcast: Podcast, target: PodcastStatus) -> None: current = _status(podcast) if target not in _ALLOWED[current]: - raise InvalidTransition( + raise InvalidTransitionError( f"{current.value} -> {target.value} is not allowed" ) podcast.status = target diff --git a/surfsense_backend/app/podcasts/tasks/draft.py b/surfsense_backend/app/podcasts/tasks/draft.py index 8779f6ce1..c5b489571 100644 --- a/surfsense_backend/app/podcasts/tasks/draft.py +++ b/surfsense_backend/app/podcasts/tasks/draft.py @@ -36,9 +36,10 @@ def draft_transcript_task(self, podcast_id: int, search_space_id: int) -> dict: return run_async_celery_task( 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) - 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} diff --git a/surfsense_backend/app/podcasts/tasks/render.py b/surfsense_backend/app/podcasts/tasks/render.py index 8afe53b83..2e550a868 100644 --- a/surfsense_backend/app/podcasts/tasks/render.py +++ b/surfsense_backend/app/podcasts/tasks/render.py @@ -15,7 +15,7 @@ from app.celery_app import celery_app from app.podcasts.persistence import PodcastRepository from app.podcasts.rendering import PodcastRenderer from app.podcasts.service import ( - InvalidTransition, + InvalidTransitionError, PodcastService, read_spec, read_transcript, @@ -36,9 +36,10 @@ _WORKDIR_BASE = Path(tempfile.gettempdir()) / "surfsense_podcasts" def render_audio_task(self, podcast_id: int) -> dict: try: 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) - 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} @@ -75,7 +76,7 @@ async def _render_audio(podcast_id: int) -> dict: podcast, storage_backend=backend_name, storage_key=key ) await session.commit() - except InvalidTransition: + except InvalidTransitionError: # A user back-out won the race (e.g. the regeneration was # reverted): drop the stale render and leave the row alone. await purge_audio_object(key) diff --git a/surfsense_backend/app/podcasts/tts/adapters/kokoro.py b/surfsense_backend/app/podcasts/tts/adapters/kokoro.py index 031b48e86..2ef0069c5 100644 --- a/surfsense_backend/app/podcasts/tts/adapters/kokoro.py +++ b/surfsense_backend/app/podcasts/tts/adapters/kokoro.py @@ -50,9 +50,7 @@ class KokoroTextToSpeech(TextToSpeech): async def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: if not isinstance(request.voice, str): - raise TextToSpeechError( - "Kokoro voices are named by string, not a mapping" - ) + raise TextToSpeechError("Kokoro voices are named by string, not a mapping") pipeline = self._pipeline_for(request.language) loop = asyncio.get_event_loop() @@ -67,7 +65,7 @@ class KokoroTextToSpeech(TextToSpeech): ), ) 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 if not segments: diff --git a/surfsense_backend/app/podcasts/tts/adapters/litellm.py b/surfsense_backend/app/podcasts/tts/adapters/litellm.py index 181973b47..d0014c5cd 100644 --- a/surfsense_backend/app/podcasts/tts/adapters/litellm.py +++ b/surfsense_backend/app/podcasts/tts/adapters/litellm.py @@ -57,10 +57,8 @@ class LiteLlmTextToSpeech(TextToSpeech): try: response = await aspeech(**kwargs) - except Exception as exc: # noqa: BLE001 - normalise provider errors - raise TextToSpeechError( - f"{self._model} synthesis failed: {exc}" - ) from exc + except Exception as exc: + raise TextToSpeechError(f"{self._model} synthesis failed: {exc}") from exc data = getattr(response, "content", None) if not data: diff --git a/surfsense_backend/app/podcasts/voices/catalog.py b/surfsense_backend/app/podcasts/voices/catalog.py index 28914e742..c36313a0c 100644 --- a/surfsense_backend/app/podcasts/voices/catalog.py +++ b/surfsense_backend/app/podcasts/voices/catalog.py @@ -36,9 +36,7 @@ class VoiceCatalog: """All voices offered by ``provider``, in catalog order.""" return list(self._by_provider.get(provider, ())) - def for_language( - self, provider: TtsProvider, language: str - ) -> list[CatalogVoice]: + def for_language(self, provider: TtsProvider, language: str) -> list[CatalogVoice]: """``provider`` voices that can render ``language``, in catalog order.""" return [v for v in self.for_provider(provider) if v.speaks(language)] @@ -50,6 +48,4 @@ class VoiceCatalog: @lru_cache(maxsize=1) def get_voice_catalog() -> VoiceCatalog: """The process-wide catalog assembled from every provider's roster.""" - return VoiceCatalog( - (*KOKORO_VOICES, *OPENAI_VOICES, *AZURE_VOICES, *VERTEX_VOICES) - ) + return VoiceCatalog((*KOKORO_VOICES, *OPENAI_VOICES, *AZURE_VOICES, *VERTEX_VOICES)) diff --git a/surfsense_backend/app/podcasts/voices/data/vertex.py b/surfsense_backend/app/podcasts/voices/data/vertex.py index 8452a00ff..99477eb21 100644 --- a/surfsense_backend/app/podcasts/voices/data/vertex.py +++ b/surfsense_backend/app/podcasts/voices/data/vertex.py @@ -30,10 +30,52 @@ def _voice( VERTEX_VOICES: tuple[CatalogVoice, ...] = ( - _voice("en-US-Studio-O", "en-US", "en-US", "en-US-Studio-O", "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), + _voice( + "en-US-Studio-O", + "en-US", + "en-US", + "en-US-Studio-O", + "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, + ), ) diff --git a/surfsense_backend/app/podcasts/voices/preview.py b/surfsense_backend/app/podcasts/voices/preview.py index cb70a9f0b..868504a91 100644 --- a/surfsense_backend/app/podcasts/voices/preview.py +++ b/surfsense_backend/app/podcasts/voices/preview.py @@ -30,7 +30,7 @@ _SAMPLE_TEXTS = { "it": "Ciao! Questa è la mia voce quando racconto il tuo podcast.", "ja": "こんにちは。ポッドキャストをお届けするときの私の声です。", "pt": "Olá! É assim que eu soo ao narrar o seu podcast.", - "zh": "你好!这就是我为你播报播客时的声音。", + "zh": "你好!这就是我为你播报播客时的声音。", # noqa: RUF001 } _CONTENT_TYPES = {"mp3": "audio/mpeg", "wav": "audio/wav"} @@ -40,9 +40,7 @@ async def render_voice_preview( voice: CatalogVoice, tts: TextToSpeech ) -> tuple[bytes, str]: """Return ``(audio_bytes, content_type)`` for a sample spoken by ``voice``.""" - language = ( - _FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language - ) + language = _FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language request = SynthesisRequest( text=_sample_text(language), voice=voice.native_ref, language=language ) diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/emission.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/emission.py index 86f453bc7..b21357b50 100644 --- a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/emission.py +++ b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/emission.py @@ -36,7 +36,11 @@ def iter_completion_emission_frames( "success", ) 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( f"Podcast generation failed: {error_msg}", "error", diff --git a/surfsense_backend/tests/integration/podcasts/conftest.py b/surfsense_backend/tests/integration/podcasts/conftest.py index 330bcbef0..f244c17d2 100644 --- a/surfsense_backend/tests/integration/podcasts/conftest.py +++ b/surfsense_backend/tests/integration/podcasts/conftest.py @@ -26,7 +26,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.app import app, limiter from app.config import config as app_config 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.schemas import ( DurationTarget, @@ -39,6 +38,7 @@ from app.podcasts.schemas import ( ) from app.podcasts.service import PodcastService 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 pytestmark = pytest.mark.integration @@ -128,12 +128,8 @@ class FakeStorageBackend: def fake_storage(monkeypatch) -> FakeStorageBackend: """Route audio storage to an in-memory backend for the stream routes.""" backend = FakeStorageBackend() - monkeypatch.setattr( - "app.podcasts.storage.get_storage_backend", lambda: backend - ) - monkeypatch.setattr( - "app.file_storage.factory.get_storage_backend", lambda: backend - ) + monkeypatch.setattr("app.podcasts.storage.get_storage_backend", lambda: backend) + monkeypatch.setattr("app.file_storage.factory.get_storage_backend", lambda: backend) return backend @@ -159,9 +155,7 @@ def bind_task_session(db_session: AsyncSession, monkeypatch) -> AsyncSession: "app.podcasts.tasks.render", "app.podcasts.tasks.runtime", ): - monkeypatch.setattr( - f"{module}.get_celery_session_maker", lambda: _make_session - ) + monkeypatch.setattr(f"{module}.get_celery_session_maker", lambda: _make_session) return db_session @@ -213,8 +207,12 @@ def build_spec( language=language, style=PodcastStyle.CONVERSATIONAL, speakers=[ - SpeakerSpec(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]), + SpeakerSpec( + 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), ) @@ -237,7 +235,7 @@ def make_podcast(db_session: AsyncSession): session, so the endpoint under test reads a realistically-built row. """ - _LADDER = [ + ladder = [ PodcastStatus.AWAITING_BRIEF, PodcastStatus.DRAFTING, PodcastStatus.RENDERING, @@ -259,7 +257,7 @@ def make_podcast(db_session: AsyncSession): await db_session.flush() return podcast - targets = _LADDER[: _LADDER.index(status) + 1] + targets = ladder[: ladder.index(status) + 1] for target in targets: if target is PodcastStatus.AWAITING_BRIEF: await service.attach_brief(podcast, build_spec()) diff --git a/surfsense_backend/tests/integration/podcasts/test_cancel.py b/surfsense_backend/tests/integration/podcasts/test_cancel.py index e4d78031d..4fe4cfc55 100644 --- a/surfsense_backend/tests/integration/podcasts/test_cancel.py +++ b/surfsense_backend/tests/integration/podcasts/test_cancel.py @@ -15,9 +15,7 @@ pytestmark = pytest.mark.integration BASE = "/api/v1/podcasts" -async def test_cancel_from_a_live_state_succeeds( - client, db_search_space, make_podcast -): +async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_podcast): podcast = await make_podcast( search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF ) diff --git a/surfsense_backend/tests/integration/podcasts/test_voice_preview.py b/surfsense_backend/tests/integration/podcasts/test_voice_preview.py index 729b77be4..113172bee 100644 --- a/surfsense_backend/tests/integration/podcasts/test_voice_preview.py +++ b/surfsense_backend/tests/integration/podcasts/test_voice_preview.py @@ -23,18 +23,12 @@ BASE = "/api/v1/podcasts" def preview_tts(monkeypatch, tmp_path) -> FakeTextToSpeech: """Route preview synthesis to the fake provider and an isolated cache.""" provider = FakeTextToSpeech() - monkeypatch.setattr( - "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.api.routes.get_text_to_speech", lambda: provider) + monkeypatch.setattr("app.podcasts.voices.preview.PREVIEW_CACHE_ROOT", tmp_path) return provider -async def test_preview_returns_playable_audio_for_a_catalog_voice( - client, preview_tts -): +async def test_preview_returns_playable_audio_for_a_catalog_voice(client, preview_tts): resp = await client.get(f"{BASE}/voices/openai:alloy/preview") 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" -async def test_preview_is_synthesised_once_then_served_from_cache( - client, preview_tts -): +async def test_preview_is_synthesised_once_then_served_from_cache(client, preview_tts): first = 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 == [] -async def test_preview_without_tts_provider_is_503( - client, preview_tts, monkeypatch -): +async def test_preview_without_tts_provider_is_503(client, preview_tts, monkeypatch): monkeypatch.setattr(app_config, "TTS_SERVICE", None) resp = await client.get(f"{BASE}/voices/openai:alloy/preview") diff --git a/surfsense_backend/tests/unit/podcasts/conftest.py b/surfsense_backend/tests/unit/podcasts/conftest.py index a3836f689..5eb4d8457 100644 --- a/surfsense_backend/tests/unit/podcasts/conftest.py +++ b/surfsense_backend/tests/unit/podcasts/conftest.py @@ -38,7 +38,10 @@ def make_spec(): if speakers is None: speakers = [ 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( slot=1, diff --git a/surfsense_backend/tests/unit/podcasts/test_renderer.py b/surfsense_backend/tests/unit/podcasts/test_renderer.py index f80e2a4c4..2bcdff967 100644 --- a/surfsense_backend/tests/unit/podcasts/test_renderer.py +++ b/surfsense_backend/tests/unit/podcasts/test_renderer.py @@ -71,7 +71,9 @@ def _spec(voice_id: str) -> PodcastSpec: 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?")]) 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): - 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.")]) with pytest.raises(RenderError): diff --git a/surfsense_backend/tests/unit/podcasts/test_resolution.py b/surfsense_backend/tests/unit/podcasts/test_resolution.py index 48e834096..aab44f8fb 100644 --- a/surfsense_backend/tests/unit/podcasts/test_resolution.py +++ b/surfsense_backend/tests/unit/podcasts/test_resolution.py @@ -64,7 +64,9 @@ def test_a_preferred_voice_invalid_for_the_language_is_replaced(): speaker_count=1, 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(): diff --git a/surfsense_backend/tests/unit/podcasts/test_voice_catalog.py b/surfsense_backend/tests/unit/podcasts/test_voice_catalog.py index d94c85922..861d8768c 100644 --- a/surfsense_backend/tests/unit/podcasts/test_voice_catalog.py +++ b/surfsense_backend/tests/unit/podcasts/test_voice_catalog.py @@ -52,7 +52,9 @@ def test_for_provider_returns_only_that_providers_voices(): def test_for_language_matches_on_the_primary_subtag(): """A request for 'en' should match an 'en-US' voice (region-insensitive).""" 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(): diff --git a/surfsense_web/app/(home)/changelog/page.tsx b/surfsense_web/app/(home)/changelog/page.tsx index 42bac512a..b7aa14d20 100644 --- a/surfsense_web/app/(home)/changelog/page.tsx +++ b/surfsense_web/app/(home)/changelog/page.tsx @@ -3,10 +3,7 @@ import type { MDXComponents } from "mdx/types"; import type { Metadata } from "next"; import type { ComponentType } from "react"; import { changelog } from "@/.source/server"; -import { - ChangelogTimeline, - type ChangelogTimelineEntry, -} from "@/components/ui/changelog-timeline"; +import { ChangelogTimeline, type ChangelogTimelineEntry } from "@/components/ui/changelog-timeline"; import { formatDate } from "@/lib/utils"; import { getMDXComponents } from "@/mdx-components"; diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx index 081ce358e..263a286c1 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx @@ -13,11 +13,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { - CreditPurchase, - PagePurchase, - PurchaseStatus, -} from "@/contracts/types/stripe.types"; +import type { CreditPurchase, PagePurchase, PurchaseStatus } from "@/contracts/types/stripe.types"; import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { cn } from "@/lib/utils"; diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index f6c7f3e15..3785dc649 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -48,8 +48,8 @@ import { isCommentReplyMetadata, isConnectorIndexingMetadata, isDocumentProcessingMetadata, - isNewMentionMetadata, isInsufficientCreditsMetadata, + isNewMentionMetadata, } from "@/contracts/types/inbox.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import type { InboxItem } from "@/hooks/use-inbox"; diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index e9256cc3a..fcfe2252d 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -1,9 +1,9 @@ export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar"; export { ChatListItem } from "./ChatListItem"; +export { CreditBalanceDisplay } from "./CreditBalanceDisplay"; export { DocumentsSidebar } from "./DocumentsSidebar"; export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; -export { CreditBalanceDisplay } from "./CreditBalanceDisplay"; export { NavSection } from "./NavSection"; export { Sidebar } from "./Sidebar"; export { SidebarCollapseButton } from "./SidebarCollapseButton"; diff --git a/surfsense_web/components/new-chat/chat-example-prompts.tsx b/surfsense_web/components/new-chat/chat-example-prompts.tsx index 98d95b98b..61041cc29 100644 --- a/surfsense_web/components/new-chat/chat-example-prompts.tsx +++ b/surfsense_web/components/new-chat/chat-example-prompts.tsx @@ -2,9 +2,9 @@ import { FilePlus2, + type LucideIcon, Search, Settings2, - type LucideIcon, WandSparkles, Workflow, X, diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index bf4067336..1e11e95d5 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -286,8 +286,8 @@ function PricingFAQ() { Frequently Asked Questions

- Everything you need to know about SurfSense credits and billing. - Can't find what you need? Reach out at{" "} + Everything you need to know about SurfSense credits and billing. Can't find what you + need? Reach out at{" "} rohan@surfsense.com diff --git a/surfsense_web/components/settings/earn-credits-content.tsx b/surfsense_web/components/settings/earn-credits-content.tsx index 21b7f8a5b..731ea7726 100644 --- a/surfsense_web/components/settings/earn-credits-content.tsx +++ b/surfsense_web/components/settings/earn-credits-content.tsx @@ -77,9 +77,7 @@ export function EarnCreditsContent() {

Earn Credits

-

- Earn bonus credits by completing tasks -

+

Earn bonus credits by completing tasks

From f166a532bd4a4bf2ee44a802ffa5c29c937ff755 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 11 Jun 2026 15:59:44 -0700 Subject: [PATCH 02/15] feat(version): bumped to 0.0.28 --- VERSION | 2 +- surfsense_backend/pyproject.toml | 2 +- surfsense_backend/uv.lock | 134 ++++++++++++++++++++++- surfsense_browser_extension/package.json | 2 +- surfsense_desktop/package.json | 2 +- surfsense_web/package.json | 2 +- 6 files changed, 138 insertions(+), 6 deletions(-) diff --git a/VERSION b/VERSION index 24ff85581..1fe695856 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.27 +0.0.28 diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index 16d46445c..ff43f6a97 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "surf-new-backend" -version = "0.0.27" +version = "0.0.28" description = "SurfSense Backend" requires-python = ">=3.12" dependencies = [ diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index a927a928d..182b9679f 100644 --- a/surfsense_backend/uv.lock +++ b/surfsense_backend/uv.lock @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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'", @@ -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_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 = [[ @@ -9482,7 +9614,7 @@ wheels = [ [[package]] name = "surf-new-backend" -version = "0.0.27" +version = "0.0.28" source = { editable = "." } dependencies = [ { name = "alembic" }, diff --git a/surfsense_browser_extension/package.json b/surfsense_browser_extension/package.json index 959e0b395..e7f0f082c 100644 --- a/surfsense_browser_extension/package.json +++ b/surfsense_browser_extension/package.json @@ -1,7 +1,7 @@ { "name": "surfsense_browser_extension", "displayName": "Surfsense Browser Extension", - "version": "0.0.27", + "version": "0.0.28", "description": "Extension to collect Browsing History for SurfSense.", "author": "https://github.com/MODSetter", "engines": { diff --git a/surfsense_desktop/package.json b/surfsense_desktop/package.json index 433e33315..f4cc9586d 100644 --- a/surfsense_desktop/package.json +++ b/surfsense_desktop/package.json @@ -1,7 +1,7 @@ { "name": "surfsense-desktop", "productName": "SurfSense", - "version": "0.0.27", + "version": "0.0.28", "description": "SurfSense Desktop App", "main": "dist/main.js", "scripts": { diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 0a12fa02c..ea8425be5 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -1,6 +1,6 @@ { "name": "surfsense_web", - "version": "0.0.27", + "version": "0.0.28", "private": true, "packageManager": "pnpm@10.26.0", "description": "SurfSense Frontend", From cff721aa422827b689f66e8317c7b74e10549283 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 11 Jun 2026 16:17:14 -0700 Subject: [PATCH 03/15] feat(migration): evolve podcast lifecycle by detaching from zero_publication and updating column handling --- .../versions/158_evolve_podcasts_lifecycle.py | 24 +++++++++ surfsense_backend/app/zero_publication.py | 54 ++++++++++++------- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/surfsense_backend/alembic/versions/158_evolve_podcasts_lifecycle.py b/surfsense_backend/alembic/versions/158_evolve_podcasts_lifecycle.py index ae595cc1b..f3b194cbd 100644 --- a/surfsense_backend/alembic/versions/158_evolve_podcasts_lifecycle.py +++ b/surfsense_backend/alembic/versions/158_evolve_podcasts_lifecycle.py @@ -6,6 +6,8 @@ Revises: 157 from collections.abc import Sequence +import sqlalchemy as sa + from alembic import op revision: str = "158" @@ -14,7 +16,29 @@ branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None +def _drop_podcasts_from_publication() -> None: + """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 = 'zero_publication' " + "AND schemaname = current_schema() AND tablename = 'podcasts'" + ) + ).fetchone() + if published: + op.execute('ALTER PUBLICATION "zero_publication" DROP TABLE "podcasts";') + + 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'. op.execute("ALTER TYPE podcast_status RENAME TO podcast_status_old;") diff --git a/surfsense_backend/app/zero_publication.py b/surfsense_backend/app/zero_publication.py index 139286ee6..b14ee14d1 100644 --- a/surfsense_backend/app/zero_publication.py +++ b/surfsense_backend/app/zero_publication.py @@ -86,18 +86,15 @@ def _quote_identifier(identifier: str) -> str: return '"' + identifier.replace('"', '""') + '"' -def _column_exists(conn: Connection, table: str, column: str) -> bool: - return ( - conn.execute( - text( - "SELECT 1 FROM information_schema.columns " - "WHERE table_schema = current_schema() " - "AND table_name = :table AND column_name = :column" - ), - {"table": table, "column": column}, - ).fetchone() - is not None - ) +def _table_columns(conn: Connection, table: str) -> set[str]: + rows = conn.execute( + text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_schema = current_schema() AND table_name = :table" + ), + {"table": table}, + ).fetchall() + return {row[0] for row in rows} def _expected_columns(conn: Connection, table: str) -> list[str] | None: @@ -106,19 +103,39 @@ def _expected_columns(conn: Connection, table: str) -> list[str] | None: return None expected = list(columns) - if table in {"documents", "user", "podcasts"} and _column_exists( - conn, table, "_0_version" + if table in {"documents", "user", "podcasts"} and "_0_version" in _table_columns( + conn, table ): expected.append("_0_version") return expected -def _format_table_entry(conn: Connection, table: str) -> str: - columns = _expected_columns(conn, table) +def _format_table_entry(conn: Connection, table: str) -> str | None: + """Render one SET TABLE entry, or ``None`` if the table isn't ready. + + 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 + table_sql = _quote_identifier(table) + columns = _expected_columns(conn, table) if columns is None: return table_sql + if any(column not in actual for column in columns): + return None + column_sql = ", ".join(_quote_identifier(column) for column in columns) return f"{table_sql} ({column_sql})" @@ -126,9 +143,8 @@ def _format_table_entry(conn: Connection, table: str) -> str: def build_set_table_sql(conn: Connection) -> str: """Build the canonical plain SET TABLE statement for Zero's event triggers.""" - table_list = ", ".join( - _format_table_entry(conn, table) for table in ZERO_PUBLICATION - ) + entries = [_format_table_entry(conn, table) for table in ZERO_PUBLICATION] + table_list = ", ".join(entry for entry in entries if entry is not None) return f"ALTER PUBLICATION {_quote_identifier(PUBLICATION_NAME)} SET TABLE {table_list}" From c855be8ccdebc074f837c9523eeed370f3389945 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 11 Jun 2026 16:51:18 -0700 Subject: [PATCH 04/15] fix(auto_reload): update task to use a lambda for user_id in async call --- surfsense_backend/app/tasks/celery_tasks/auto_reload_task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_backend/app/tasks/celery_tasks/auto_reload_task.py b/surfsense_backend/app/tasks/celery_tasks/auto_reload_task.py index 41a6f0b70..385cdde88 100644 --- a/surfsense_backend/app/tasks/celery_tasks/auto_reload_task.py +++ b/surfsense_backend/app/tasks/celery_tasks/auto_reload_task.py @@ -59,7 +59,7 @@ def _card_error_payment_intent_id(exc: CardError) -> str | None: @celery_app.task(name="auto_reload_credits") def auto_reload_credits_task(user_id: str): """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: From 87be162d78446211cc5511dbdc424583f4507357 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 05/15] feat(podcast): curated common languages data --- .../app/podcasts/voices/data/languages.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 surfsense_backend/app/podcasts/voices/data/languages.py diff --git a/surfsense_backend/app/podcasts/voices/data/languages.py b/surfsense_backend/app/podcasts/voices/data/languages.py new file mode 100644 index 000000000..c00fd7f05 --- /dev/null +++ b/surfsense_backend/app/podcasts/voices/data/languages.py @@ -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", +) From c8ee74b123a0a925f17b19320f7a60b3153b98ae Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 06/15] feat(podcast): offerable languages on the voice catalog --- .../app/podcasts/voices/catalog.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/podcasts/voices/catalog.py b/surfsense_backend/app/podcasts/voices/catalog.py index 28914e742..101f8f9d0 100644 --- a/surfsense_backend/app/podcasts/voices/catalog.py +++ b/surfsense_backend/app/podcasts/voices/catalog.py @@ -9,11 +9,26 @@ provider-native reference. from __future__ import annotations from collections.abc import Iterable +from dataclasses import dataclass from functools import lru_cache from .data import AZURE_VOICES, KOKORO_VOICES, OPENAI_VOICES, VERTEX_VOICES +from .data.languages import COMMON_LANGUAGES 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: @@ -46,6 +61,20 @@ class VoiceCatalog: """Whether ``provider`` has at least one voice for ``language``.""" 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) def get_voice_catalog() -> VoiceCatalog: From f3d253ae7773e7b077734ac6940ff9d0cdcfbf88 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 07/15] feat(podcast): export LanguageOffering --- surfsense_backend/app/podcasts/voices/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/podcasts/voices/__init__.py b/surfsense_backend/app/podcasts/voices/__init__.py index ab1f8bbbf..97874a655 100644 --- a/surfsense_backend/app/podcasts/voices/__init__.py +++ b/surfsense_backend/app/podcasts/voices/__init__.py @@ -6,7 +6,7 @@ configured provider via :func:`provider_from_service`. 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 .provider import TtsProvider, provider_from_service from .voice import ANY_LANGUAGE, CatalogVoice, VoiceGender @@ -14,6 +14,7 @@ from .voice import ANY_LANGUAGE, CatalogVoice, VoiceGender __all__ = [ "ANY_LANGUAGE", "CatalogVoice", + "LanguageOffering", "TtsProvider", "VoiceCatalog", "VoiceGender", From fe4d69f478e407c4b2faf6d4c0851a02af73d5fa Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 08/15] feat(podcast): LanguageOptions read model --- surfsense_backend/app/podcasts/api/schemas.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/surfsense_backend/app/podcasts/api/schemas.py b/surfsense_backend/app/podcasts/api/schemas.py index 7f1f8cc7c..c412e372f 100644 --- a/surfsense_backend/app/podcasts/api/schemas.py +++ b/surfsense_backend/app/podcasts/api/schemas.py @@ -51,6 +51,17 @@ class VoiceOption(BaseModel): 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): """Lightweight list item.""" From 1ee38fc9ab930d71806afa49d04b77ff601c9ccb Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 09/15] feat(podcast): GET /podcasts/languages --- surfsense_backend/app/podcasts/api/routes.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py index 0a9a8e659..593ec6990 100644 --- a/surfsense_backend/app/podcasts/api/routes.py +++ b/surfsense_backend/app/podcasts/api/routes.py @@ -45,6 +45,7 @@ from app.utils.rbac import check_permission from .schemas import ( CreatePodcastRequest, + LanguageOptions, PodcastDetail, PodcastSummary, UpdateSpecRequest, @@ -112,6 +113,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") async def preview_voice( voice_id: str, From a19b7dd8e0ca3324e9dee974d650be3856bb96a0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 10/15] test(podcast): offerable languages catalog rules --- .../tests/unit/podcasts/test_voice_catalog.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/surfsense_backend/tests/unit/podcasts/test_voice_catalog.py b/surfsense_backend/tests/unit/podcasts/test_voice_catalog.py index d94c85922..e7d4c8d2b 100644 --- a/surfsense_backend/tests/unit/podcasts/test_voice_catalog.py +++ b/surfsense_backend/tests/unit/podcasts/test_voice_catalog.py @@ -73,6 +73,59 @@ def test_supports_language_reports_availability(): 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(): catalog = VoiceCatalog([_voice("k1")]) with pytest.raises(KeyError): From 402ae6befec50a7cd5c0d323d61f6b2749bf33a1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 11/15] test(podcast): languages endpoint --- .../tests/integration/podcasts/test_voices.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/surfsense_backend/tests/integration/podcasts/test_voices.py b/surfsense_backend/tests/integration/podcasts/test_voices.py index 688ddad56..fd41bfd4e 100644 --- a/surfsense_backend/tests/integration/podcasts/test_voices.py +++ b/surfsense_backend/tests/integration/podcasts/test_voices.py @@ -29,3 +29,23 @@ async def test_voices_503_when_no_tts_configured(client, monkeypatch): resp = await client.get(f"{BASE}/voices") 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 From 0c7e5dee8bd98e814ed944638924e504dca92696 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 12/15] test(podcast): align quota error kwargs with wallet refactor --- .../tests/integration/podcasts/test_draft_task.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/surfsense_backend/tests/integration/podcasts/test_draft_task.py b/surfsense_backend/tests/integration/podcasts/test_draft_task.py index 7dadfc2f5..e9c9e4a9c 100644 --- a/surfsense_backend/tests/integration/podcasts/test_draft_task.py +++ b/surfsense_backend/tests/integration/podcasts/test_draft_task.py @@ -76,8 +76,7 @@ async def test_quota_denial_fails_the_podcast_without_a_transcript( async def _deny(**_kwargs): raise QuotaInsufficientError( usage_type="podcast_generation", - used_micros=5_000_000, - limit_micros=5_000_000, + balance_micros=0, remaining_micros=0, ) yield # pragma: no cover - unreachable, satisfies the CM protocol From 3cf76e82951de8bdf74d5e877bf96bb409add3cc Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 13/15] feat(podcast): languageOptions contract --- surfsense_web/contracts/types/podcast.types.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts index e6332d5b2..627cc6f58 100644 --- a/surfsense_web/contracts/types/podcast.types.ts +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -103,6 +103,15 @@ export const voiceOption = z.object({ }); export type VoiceOption = z.infer; +// 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; + export const updateSpecRequest = z.object({ spec: podcastSpec, expected_version: z.number().int().min(1), From 90cae46b5f2a129fc12c230e6f095a5c957da198 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 14/15] feat(podcast): listLanguages API call --- surfsense_web/lib/apis/podcasts-api.service.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_web/lib/apis/podcasts-api.service.ts b/surfsense_web/lib/apis/podcasts-api.service.ts index bd7bb784e..2e13d63cc 100644 --- a/surfsense_web/lib/apis/podcasts-api.service.ts +++ b/surfsense_web/lib/apis/podcasts-api.service.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { + languageOptions, type PodcastSpec, podcastDetail, updateSpecRequest, @@ -60,6 +61,12 @@ class PodcastsApiService { 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. previewVoice = async (voiceId: string) => { return baseApiService.getBlob(`${BASE}/voices/${encodeURIComponent(voiceId)}/preview`); From 8dd174d304d5125aad95509bee6e036230dc24a1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 12 Jun 2026 07:38:38 +0200 Subject: [PATCH 15/15] feat(podcast): backend-driven language picker with custom entry --- .../tool-ui/podcast/brief-review.tsx | 132 +++++++++++++++--- 1 file changed, 114 insertions(+), 18 deletions(-) diff --git a/surfsense_web/components/tool-ui/podcast/brief-review.tsx b/surfsense_web/components/tool-ui/podcast/brief-review.tsx index 3473b64d6..d662aebc2 100644 --- a/surfsense_web/components/tool-ui/podcast/brief-review.tsx +++ b/surfsense_web/components/tool-ui/podcast/brief-review.tsx @@ -1,11 +1,20 @@ "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 { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -15,6 +24,7 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { + type LanguageOptions, MAX_SPEAKERS, type PodcastSpec, type PodcastStyle, @@ -56,6 +66,7 @@ interface BriefReviewProps { export function BriefReview({ podcast, spec }: BriefReviewProps) { const [draft, setDraft] = useState(spec); const [voices, setVoices] = useState(null); + const [offering, setOffering] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); // A pushed spec change (saved edit or concurrent editor) resets the form to @@ -75,19 +86,26 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) { .catch(() => { if (!cancelled) setVoices([]); }); + podcastsApiService + .listLanguages() + .then((options) => { + if (!cancelled) setOffering(options); + }) + .catch(() => { + if (!cancelled) setOffering({ languages: [], allows_custom: false }); + }); return () => { 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 tags = new Set(); - for (const voice of voices ?? []) { - if (voice.language !== ANY_LANGUAGE) tags.add(voice.language); - } + const tags = new Set(offering?.languages ?? []); tags.add(draft.language); return [...tags].sort(); - }, [voices, draft.language]); + }, [offering, draft.language]); const voicesForLanguage = useMemo( () => (voices ?? []).filter((voice) => speaks(voice, draft.language)), @@ -193,18 +211,22 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
- + {offering?.allows_custom ? ( + + ) : ( + + )}
@@ -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 ( + + + + + + + + + No matching language. + + {languages.map((tag) => ( + pick(tag)} + > + + {languageLabel(tag)} + + ))} + {isNewTag ? ( + pick(customTag)}> + + Use “{customTag}” + + ) : null} + + + + + + ); +} + /** The current selection stays listed even when it no longer matches the * language filter, so the Select never renders an orphaned value. */ function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {