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/alembic/versions/158_evolve_podcasts_lifecycle.py b/surfsense_backend/alembic/versions/158_evolve_podcasts_lifecycle.py index 7c51158a9..f1d231f9e 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,47 +16,128 @@ branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None 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: - """Temporarily unpublish podcasts while changing published columns.""" +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 = :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( - f""" - 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 - $$; - """ + f"ALTER TABLE podcasts ALTER COLUMN status SET DEFAULT '{default_value}';" ) + 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 - # rows. The legacy transient value 'generating' maps onto 'rendering'. - op.execute("ALTER TYPE podcast_status RENAME TO podcast_status_old;") - op.execute( - """ +def _upgrade_status_enum() -> None: + _ensure_status_enum( + desired_labels=TARGET_STATUS_LABELS, + temporary_type="podcast_status_old", + create_sql=""" CREATE TYPE podcast_status AS ENUM ( 'pending', 'awaiting_brief', 'drafting', 'awaiting_review', 'rendering', 'ready', 'failed', 'cancelled' ); - """ - ) - op.execute("ALTER TABLE podcasts ALTER COLUMN status DROP DEFAULT;") - op.execute( - """ + """, + alter_sql=""" ALTER TABLE podcasts ALTER COLUMN status TYPE podcast_status USING ( @@ -63,45 +146,20 @@ def upgrade() -> None: ELSE status::text END )::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: - _drop_podcasts_from_zero_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. - 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( - """ +def _downgrade_status_enum() -> None: + _ensure_status_enum( + desired_labels=LEGACY_STATUS_LABELS, + temporary_type="podcast_status_new", + create_sql=( + "CREATE TYPE podcast_status AS ENUM " + "('pending', 'generating', 'ready', 'failed');" + ), + alter_sql=""" ALTER TABLE podcasts ALTER COLUMN status TYPE podcast_status USING ( @@ -114,7 +172,44 @@ def downgrade() -> None: ELSE status::text END )::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() diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py index 0a9a8e659..43a99f16e 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 @@ -45,6 +47,7 @@ from app.utils.rbac import check_permission from .schemas import ( CreatePodcastRequest, + LanguageOptions, PodcastDetail, PodcastSummary, 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") async def preview_voice( voice_id: str, @@ -324,19 +341,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/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.""" 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/__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", diff --git a/surfsense_backend/app/podcasts/voices/catalog.py b/surfsense_backend/app/podcasts/voices/catalog.py index 28914e742..6bf39510a 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: @@ -36,9 +51,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)] @@ -46,10 +59,22 @@ 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: """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/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", +) 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/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: 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/app/zero_publication.py b/surfsense_backend/app/zero_publication.py index 869559c55..b14ee14d1 100644 --- a/surfsense_backend/app/zero_publication.py +++ b/surfsense_backend/app/zero_publication.py @@ -86,66 +86,54 @@ 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 _table_exists(conn: Connection, table: str) -> bool: - 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: +def _expected_columns(conn: Connection, table: str) -> list[str] | None: columns = ZERO_PUBLICATION[table] if columns is None: return None - if include_missing_columns: - expected = list(columns) - else: - 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 = list(columns) + 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, *, include_missing_columns: bool = True -) -> str | None: - if not include_missing_columns and not _table_exists(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 - columns = _expected_columns( - conn, table, include_missing_columns=include_missing_columns - ) table_sql = _quote_identifier(table) + columns = _expected_columns(conn, table) if columns is None: return table_sql - if not include_missing_columns and not columns: + + if any(column not in actual for column in columns): return None 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: """Build the canonical plain SET TABLE statement for Zero's event triggers.""" - table_entries = [ - entry - for table in ZERO_PUBLICATION - if ( - entry := _format_table_entry( - conn, table, include_missing_columns=False - ) - ) - is not None - ] - table_list = ", ".join(table_entries) + 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}" 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/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/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 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..d120d4bfc 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(): @@ -73,6 +75,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): 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/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 28fa79c9d..344176629 100644 --- a/surfsense_web/components/new-chat/chat-example-prompts.tsx +++ b/surfsense_web/components/new-chat/chat-example-prompts.tsx @@ -3,9 +3,9 @@ import { AlarmClock, FilePlus2, + type LucideIcon, Search, Settings2, - type LucideIcon, WandSparkles, X, } from "lucide-react"; 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 bonus credits by completing tasks -
+Earn bonus credits by completing tasks