mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-28 21:49:40 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/unified-model-connections
This commit is contained in:
commit
ab5423d2d2
45 changed files with 775 additions and 272 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.0.27
|
0.0.28
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ Revises: 157
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "158"
|
revision: str = "158"
|
||||||
|
|
@ -14,47 +16,128 @@ branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
PUBLICATION_NAME = "zero_publication"
|
PUBLICATION_NAME = "zero_publication"
|
||||||
|
TARGET_STATUS_LABELS = (
|
||||||
|
"pending",
|
||||||
|
"awaiting_brief",
|
||||||
|
"drafting",
|
||||||
|
"awaiting_review",
|
||||||
|
"rendering",
|
||||||
|
"ready",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
)
|
||||||
|
LEGACY_STATUS_LABELS = ("pending", "generating", "ready", "failed")
|
||||||
|
|
||||||
|
|
||||||
def _drop_podcasts_from_zero_publication() -> None:
|
def _drop_podcasts_from_publication() -> None:
|
||||||
"""Temporarily unpublish podcasts while changing published columns."""
|
"""Detach podcasts from zero_publication so status can be retyped.
|
||||||
|
|
||||||
|
Postgres refuses ``ALTER COLUMN ... TYPE`` on a column a publication
|
||||||
|
depends on. Some databases reach this migration with podcasts already
|
||||||
|
published (an interim apply_publication ran during 156); drop it here and
|
||||||
|
let migration 159 reconcile the publication to the canonical shape.
|
||||||
|
"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
published = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_publication_tables "
|
||||||
|
"WHERE pubname = :publication "
|
||||||
|
"AND schemaname = current_schema() AND tablename = 'podcasts'"
|
||||||
|
),
|
||||||
|
{"publication": PUBLICATION_NAME},
|
||||||
|
).fetchone()
|
||||||
|
if published:
|
||||||
|
op.execute(f'ALTER PUBLICATION "{PUBLICATION_NAME}" DROP TABLE "podcasts";')
|
||||||
|
|
||||||
|
|
||||||
|
def _enum_labels(type_name: str) -> list[str] | None:
|
||||||
|
rows = (
|
||||||
|
op.get_bind()
|
||||||
|
.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT e.enumlabel "
|
||||||
|
"FROM pg_type t "
|
||||||
|
"JOIN pg_namespace n ON n.oid = t.typnamespace "
|
||||||
|
"JOIN pg_enum e ON e.enumtypid = t.oid "
|
||||||
|
"WHERE n.nspname = current_schema() AND t.typname = :type_name "
|
||||||
|
"ORDER BY e.enumsortorder"
|
||||||
|
),
|
||||||
|
{"type_name": type_name},
|
||||||
|
)
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return [str(row[0]) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _column_type_name(table: str, column: str) -> str | None:
|
||||||
|
row = (
|
||||||
|
op.get_bind()
|
||||||
|
.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT udt_name "
|
||||||
|
"FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = current_schema() "
|
||||||
|
"AND table_name = :table AND column_name = :column"
|
||||||
|
),
|
||||||
|
{"table": table, "column": column},
|
||||||
|
)
|
||||||
|
.fetchone()
|
||||||
|
)
|
||||||
|
return str(row[0]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_status_enum(
|
||||||
|
*,
|
||||||
|
desired_labels: tuple[str, ...],
|
||||||
|
temporary_type: str,
|
||||||
|
create_sql: str,
|
||||||
|
alter_sql: str,
|
||||||
|
default_value: str,
|
||||||
|
) -> None:
|
||||||
|
current_labels = _enum_labels("podcast_status")
|
||||||
|
desired = list(desired_labels)
|
||||||
|
|
||||||
|
if current_labels != desired:
|
||||||
|
if current_labels is None:
|
||||||
|
if _enum_labels(temporary_type) is None:
|
||||||
|
raise RuntimeError("podcast_status enum is missing")
|
||||||
|
elif _enum_labels(temporary_type) is None:
|
||||||
|
op.execute(f"ALTER TYPE podcast_status RENAME TO {temporary_type};")
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
"podcast_status and its temporary replacement both exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
if _enum_labels("podcast_status") is None:
|
||||||
|
op.execute(create_sql)
|
||||||
|
|
||||||
|
if _enum_labels("podcast_status") != desired:
|
||||||
|
raise RuntimeError("podcast_status enum is not in the expected shape")
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE podcasts ALTER COLUMN status DROP DEFAULT;")
|
||||||
|
if _column_type_name("podcasts", "status") != "podcast_status":
|
||||||
|
op.execute(alter_sql)
|
||||||
op.execute(
|
op.execute(
|
||||||
f"""
|
f"ALTER TABLE podcasts ALTER COLUMN status SET DEFAULT '{default_value}';"
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM pg_publication_tables
|
|
||||||
WHERE pubname = '{PUBLICATION_NAME}'
|
|
||||||
AND schemaname = current_schema()
|
|
||||||
AND tablename = 'podcasts'
|
|
||||||
) THEN
|
|
||||||
ALTER PUBLICATION "{PUBLICATION_NAME}" DROP TABLE "podcasts";
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if _enum_labels(temporary_type) is not None:
|
||||||
|
op.execute(f"DROP TYPE {temporary_type};")
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
_drop_podcasts_from_zero_publication()
|
|
||||||
|
|
||||||
# Retype the status enum by swapping in a fresh type and casting existing
|
def _upgrade_status_enum() -> None:
|
||||||
# rows. The legacy transient value 'generating' maps onto 'rendering'.
|
_ensure_status_enum(
|
||||||
op.execute("ALTER TYPE podcast_status RENAME TO podcast_status_old;")
|
desired_labels=TARGET_STATUS_LABELS,
|
||||||
op.execute(
|
temporary_type="podcast_status_old",
|
||||||
"""
|
create_sql="""
|
||||||
CREATE TYPE podcast_status AS ENUM (
|
CREATE TYPE podcast_status AS ENUM (
|
||||||
'pending', 'awaiting_brief', 'drafting', 'awaiting_review',
|
'pending', 'awaiting_brief', 'drafting', 'awaiting_review',
|
||||||
'rendering', 'ready', 'failed', 'cancelled'
|
'rendering', 'ready', 'failed', 'cancelled'
|
||||||
);
|
);
|
||||||
"""
|
""",
|
||||||
)
|
alter_sql="""
|
||||||
op.execute("ALTER TABLE podcasts ALTER COLUMN status DROP DEFAULT;")
|
|
||||||
op.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE podcasts
|
ALTER TABLE podcasts
|
||||||
ALTER COLUMN status TYPE podcast_status
|
ALTER COLUMN status TYPE podcast_status
|
||||||
USING (
|
USING (
|
||||||
|
|
@ -63,45 +146,20 @@ def upgrade() -> None:
|
||||||
ELSE status::text
|
ELSE status::text
|
||||||
END
|
END
|
||||||
)::podcast_status;
|
)::podcast_status;
|
||||||
"""
|
""",
|
||||||
|
default_value="pending",
|
||||||
)
|
)
|
||||||
op.execute("ALTER TABLE podcasts ALTER COLUMN status SET DEFAULT 'pending';")
|
|
||||||
op.execute("DROP TYPE podcast_status_old;")
|
|
||||||
|
|
||||||
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS source_content TEXT;")
|
|
||||||
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS spec JSONB;")
|
|
||||||
op.execute(
|
|
||||||
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS spec_version "
|
|
||||||
"INTEGER NOT NULL DEFAULT 1;"
|
|
||||||
)
|
|
||||||
op.execute(
|
|
||||||
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_backend VARCHAR(32);"
|
|
||||||
)
|
|
||||||
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_key TEXT;")
|
|
||||||
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;")
|
|
||||||
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS error TEXT;")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def _downgrade_status_enum() -> None:
|
||||||
_drop_podcasts_from_zero_publication()
|
_ensure_status_enum(
|
||||||
|
desired_labels=LEGACY_STATUS_LABELS,
|
||||||
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS error;")
|
temporary_type="podcast_status_new",
|
||||||
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS duration_seconds;")
|
create_sql=(
|
||||||
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS storage_key;")
|
"CREATE TYPE podcast_status AS ENUM "
|
||||||
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS storage_backend;")
|
"('pending', 'generating', 'ready', 'failed');"
|
||||||
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS spec_version;")
|
),
|
||||||
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS spec;")
|
alter_sql="""
|
||||||
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS source_content;")
|
|
||||||
|
|
||||||
# Collapse the expanded lifecycle back onto the original four values.
|
|
||||||
op.execute("ALTER TYPE podcast_status RENAME TO podcast_status_new;")
|
|
||||||
op.execute(
|
|
||||||
"CREATE TYPE podcast_status AS ENUM "
|
|
||||||
"('pending', 'generating', 'ready', 'failed');"
|
|
||||||
)
|
|
||||||
op.execute("ALTER TABLE podcasts ALTER COLUMN status DROP DEFAULT;")
|
|
||||||
op.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE podcasts
|
ALTER TABLE podcasts
|
||||||
ALTER COLUMN status TYPE podcast_status
|
ALTER COLUMN status TYPE podcast_status
|
||||||
USING (
|
USING (
|
||||||
|
|
@ -114,7 +172,44 @@ def downgrade() -> None:
|
||||||
ELSE status::text
|
ELSE status::text
|
||||||
END
|
END
|
||||||
)::podcast_status;
|
)::podcast_status;
|
||||||
"""
|
""",
|
||||||
|
default_value="ready",
|
||||||
)
|
)
|
||||||
op.execute("ALTER TABLE podcasts ALTER COLUMN status SET DEFAULT 'ready';")
|
|
||||||
op.execute("DROP TYPE podcast_status_new;")
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_drop_podcasts_from_publication()
|
||||||
|
|
||||||
|
# Retype the status enum by swapping in a fresh type and casting existing
|
||||||
|
# rows. The legacy transient value 'generating' maps onto 'rendering'.
|
||||||
|
_upgrade_status_enum()
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS source_content TEXT;")
|
||||||
|
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS spec JSONB;")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS spec_version "
|
||||||
|
"INTEGER NOT NULL DEFAULT 1;"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_backend VARCHAR(32);"
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_key TEXT;")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;"
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS error TEXT;")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_drop_podcasts_from_publication()
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS error;")
|
||||||
|
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS duration_seconds;")
|
||||||
|
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS storage_key;")
|
||||||
|
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS storage_backend;")
|
||||||
|
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS spec_version;")
|
||||||
|
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS spec;")
|
||||||
|
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS source_content;")
|
||||||
|
|
||||||
|
# Collapse the expanded lifecycle back onto the original four values.
|
||||||
|
_downgrade_status_enum()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ then enqueues the matching Celery task; lifecycle errors map to 409/422.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
|
@ -27,10 +29,10 @@ from app.db import (
|
||||||
from app.podcasts.generation.brief import propose_brief
|
from app.podcasts.generation.brief import propose_brief
|
||||||
from app.podcasts.persistence import Podcast, PodcastRepository
|
from app.podcasts.persistence import Podcast, PodcastRepository
|
||||||
from app.podcasts.service import (
|
from app.podcasts.service import (
|
||||||
InvalidTransition,
|
InvalidTransitionError,
|
||||||
PodcastService,
|
PodcastService,
|
||||||
PreconditionFailed,
|
PreconditionFailedError,
|
||||||
SpecConflict,
|
SpecConflictError,
|
||||||
)
|
)
|
||||||
from app.podcasts.storage import open_audio_stream, purge_audio
|
from app.podcasts.storage import open_audio_stream, purge_audio
|
||||||
from app.podcasts.tasks import draft_transcript_task
|
from app.podcasts.tasks import draft_transcript_task
|
||||||
|
|
@ -45,6 +47,7 @@ from app.utils.rbac import check_permission
|
||||||
|
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
CreatePodcastRequest,
|
CreatePodcastRequest,
|
||||||
|
LanguageOptions,
|
||||||
PodcastDetail,
|
PodcastDetail,
|
||||||
PodcastSummary,
|
PodcastSummary,
|
||||||
UpdateSpecRequest,
|
UpdateSpecRequest,
|
||||||
|
|
@ -112,6 +115,20 @@ async def list_voices(language: str | None = None):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/podcasts/languages", response_model=LanguageOptions)
|
||||||
|
async def list_languages():
|
||||||
|
"""Languages the active TTS provider can offer the brief editor."""
|
||||||
|
if not app_config.TTS_SERVICE:
|
||||||
|
raise HTTPException(status_code=503, detail="No TTS provider configured")
|
||||||
|
|
||||||
|
provider = provider_from_service(app_config.TTS_SERVICE)
|
||||||
|
offering = get_voice_catalog().offerable_languages(provider)
|
||||||
|
return LanguageOptions(
|
||||||
|
languages=offering.languages,
|
||||||
|
allows_custom=offering.allows_custom,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/podcasts/voices/{voice_id}/preview")
|
@router.get("/podcasts/voices/{voice_id}/preview")
|
||||||
async def preview_voice(
|
async def preview_voice(
|
||||||
voice_id: str,
|
voice_id: str,
|
||||||
|
|
@ -324,19 +341,12 @@ async def _load(
|
||||||
return podcast
|
return podcast
|
||||||
|
|
||||||
|
|
||||||
class _lifecycle_errors:
|
@asynccontextmanager
|
||||||
|
async def _lifecycle_errors() -> AsyncIterator[None]:
|
||||||
"""Map service lifecycle errors onto HTTP responses."""
|
"""Map service lifecycle errors onto HTTP responses."""
|
||||||
|
try:
|
||||||
async def __aenter__(self) -> None:
|
yield
|
||||||
return None
|
except (SpecConflictError, InvalidTransitionError) as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
except PreconditionFailedError as exc:
|
||||||
if exc is None:
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
return False
|
|
||||||
if isinstance(exc, SpecConflict):
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
if isinstance(exc, InvalidTransition):
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
if isinstance(exc, PreconditionFailed):
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
return False
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,17 @@ class VoiceOption(BaseModel):
|
||||||
gender: str
|
gender: str
|
||||||
|
|
||||||
|
|
||||||
|
class LanguageOptions(BaseModel):
|
||||||
|
"""The languages the brief editor may offer for the active provider.
|
||||||
|
|
||||||
|
When ``allows_custom`` is true the list is a curated starting point and
|
||||||
|
the editor accepts any BCP-47 tag beyond it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
languages: list[str]
|
||||||
|
allows_custom: bool
|
||||||
|
|
||||||
|
|
||||||
class PodcastSummary(BaseModel):
|
class PodcastSummary(BaseModel):
|
||||||
"""Lightweight list item."""
|
"""Lightweight list item."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ class StructuredOutputError(RuntimeError):
|
||||||
"""The model reply could not be parsed into the expected shape."""
|
"""The model reply could not be parsed into the expected shape."""
|
||||||
|
|
||||||
|
|
||||||
async def invoke_json(llm, messages: list[BaseMessage], model: type[T]) -> T:
|
async def invoke_json[T: BaseModel](
|
||||||
|
llm, messages: list[BaseMessage], model: type[T]
|
||||||
|
) -> T:
|
||||||
"""Invoke ``llm`` and validate its reply as ``model``."""
|
"""Invoke ``llm`` and validate its reply as ``model``."""
|
||||||
response = await llm.ainvoke(messages)
|
response = await llm.ainvoke(messages)
|
||||||
content = strip_markdown_fences(extract_text_content(response.content))
|
content = strip_markdown_fences(extract_text_content(response.content))
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from app.services.llm_service import get_agent_llm
|
||||||
from ..prompts import draft_segment_prompt, plan_outline_prompt
|
from ..prompts import draft_segment_prompt, plan_outline_prompt
|
||||||
from ..structured import invoke_json
|
from ..structured import invoke_json
|
||||||
from .config import TranscriptConfig
|
from .config import TranscriptConfig
|
||||||
from .planning import Outline, OutlineSegment, SegmentDraft
|
from .planning import Outline, SegmentDraft
|
||||||
from .state import TranscriptState
|
from .state import TranscriptState
|
||||||
|
|
||||||
# Average speaking rate; converts target minutes to a target word count.
|
# Average speaking rate; converts target minutes to a target word count.
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ async def concat_to_mp3(segment_paths: list[Path], output_path: Path) -> None:
|
||||||
.output(str(output_path), {"c:a": "libmp3lame"})
|
.output(str(output_path), {"c:a": "libmp3lame"})
|
||||||
)
|
)
|
||||||
await ffmpeg.execute()
|
await ffmpeg.execute()
|
||||||
except Exception as exc: # noqa: BLE001 - normalise ffmpeg failures
|
except Exception as exc:
|
||||||
raise RenderError(f"audio merge failed: {exc}") from exc
|
raise RenderError(f"audio merge failed: {exc}") from exc
|
||||||
finally:
|
finally:
|
||||||
list_file.unlink(missing_ok=True)
|
list_file.unlink(missing_ok=True)
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,7 @@ class PodcastRenderer:
|
||||||
await concat_to_mp3(list(segment_paths), output_path)
|
await concat_to_mp3(list(segment_paths), output_path)
|
||||||
return RenderedPodcast(data=output_path.read_bytes(), container="mp3")
|
return RenderedPodcast(data=output_path.read_bytes(), container="mp3")
|
||||||
|
|
||||||
def _request_for(
|
def _request_for(self, spec: PodcastSpec, turn: TranscriptTurn) -> SynthesisRequest:
|
||||||
self, spec: PodcastSpec, turn: TranscriptTurn
|
|
||||||
) -> SynthesisRequest:
|
|
||||||
try:
|
try:
|
||||||
speaker = spec.speaker_for(turn.speaker)
|
speaker = spec.speaker_for(turn.speaker)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
|
|
@ -132,7 +130,7 @@ class _SegmentSynthesizer:
|
||||||
if owner:
|
if owner:
|
||||||
try:
|
try:
|
||||||
path = await self._synthesize(request, key)
|
path = await self._synthesize(request, key)
|
||||||
except BaseException as exc: # noqa: BLE001 - relayed to all waiters
|
except BaseException as exc:
|
||||||
future.set_exception(exc)
|
future.set_exception(exc)
|
||||||
else:
|
else:
|
||||||
future.set_result(path)
|
future.set_result(path)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,9 @@ class SpeakerSpec(BaseModel):
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
slot: int = Field(..., ge=0, description="Stable index a transcript turn references")
|
slot: int = Field(
|
||||||
|
..., ge=0, description="Stable index a transcript turn references"
|
||||||
|
)
|
||||||
name: str = Field(..., min_length=1, max_length=120)
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
role: SpeakerRole
|
role: SpeakerRole
|
||||||
voice_id: str = Field(
|
voice_id: str = Field(
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,11 @@ class PodcastError(RuntimeError):
|
||||||
"""Base class for lifecycle errors."""
|
"""Base class for lifecycle errors."""
|
||||||
|
|
||||||
|
|
||||||
class InvalidTransition(PodcastError):
|
class InvalidTransitionError(PodcastError):
|
||||||
"""A requested status change is not permitted from the current state."""
|
"""A requested status change is not permitted from the current state."""
|
||||||
|
|
||||||
|
|
||||||
class SpecConflict(PodcastError):
|
class SpecConflictError(PodcastError):
|
||||||
"""A spec edit raced another: the expected version is stale."""
|
"""A spec edit raced another: the expected version is stale."""
|
||||||
|
|
||||||
def __init__(self, expected: int, actual: int) -> None:
|
def __init__(self, expected: int, actual: int) -> None:
|
||||||
|
|
@ -74,7 +74,7 @@ class SpecConflict(PodcastError):
|
||||||
self.actual = actual
|
self.actual = actual
|
||||||
|
|
||||||
|
|
||||||
class PreconditionFailed(PodcastError):
|
class PreconditionFailedError(PodcastError):
|
||||||
"""A transition's data precondition (brief/transcript present) is unmet."""
|
"""A transition's data precondition (brief/transcript present) is unmet."""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -110,12 +110,12 @@ class PodcastService:
|
||||||
) -> Podcast:
|
) -> Podcast:
|
||||||
"""Edit the brief at the gate, guarded by optimistic concurrency."""
|
"""Edit the brief at the gate, guarded by optimistic concurrency."""
|
||||||
if _status(podcast) is not PodcastStatus.AWAITING_BRIEF:
|
if _status(podcast) is not PodcastStatus.AWAITING_BRIEF:
|
||||||
raise InvalidTransition(
|
raise InvalidTransitionError(
|
||||||
f"the brief can only be edited while awaiting_brief, "
|
f"the brief can only be edited while awaiting_brief, "
|
||||||
f"not {_status(podcast).value}"
|
f"not {_status(podcast).value}"
|
||||||
)
|
)
|
||||||
if expected_version != podcast.spec_version:
|
if expected_version != podcast.spec_version:
|
||||||
raise SpecConflict(expected_version, podcast.spec_version)
|
raise SpecConflictError(expected_version, podcast.spec_version)
|
||||||
podcast.spec = spec.model_dump(mode="json")
|
podcast.spec = spec.model_dump(mode="json")
|
||||||
podcast.spec_version += 1
|
podcast.spec_version += 1
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
|
|
@ -124,7 +124,7 @@ class PodcastService:
|
||||||
async def begin_drafting(self, podcast: Podcast) -> Podcast:
|
async def begin_drafting(self, podcast: Podcast) -> Podcast:
|
||||||
"""Approve the brief and start transcript drafting."""
|
"""Approve the brief and start transcript drafting."""
|
||||||
if podcast.spec is None:
|
if podcast.spec is None:
|
||||||
raise PreconditionFailed("cannot draft without a brief")
|
raise PreconditionFailedError("cannot draft without a brief")
|
||||||
self._transition(podcast, PodcastStatus.DRAFTING)
|
self._transition(podcast, PodcastStatus.DRAFTING)
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
return podcast
|
return podcast
|
||||||
|
|
@ -145,13 +145,13 @@ class PodcastService:
|
||||||
async def regenerate(self, podcast: Podcast) -> Podcast:
|
async def regenerate(self, podcast: Podcast) -> Podcast:
|
||||||
"""Reopen the brief gate; the saved spec becomes the new starting point."""
|
"""Reopen the brief gate; the saved spec becomes the new starting point."""
|
||||||
if _status(podcast) not in self._REGENERABLE:
|
if _status(podcast) not in self._REGENERABLE:
|
||||||
raise InvalidTransition(
|
raise InvalidTransitionError(
|
||||||
f"nothing to regenerate from {_status(podcast).value}"
|
f"nothing to regenerate from {_status(podcast).value}"
|
||||||
)
|
)
|
||||||
# Legacy episodes finished before briefs existed; a gate with nothing
|
# Legacy episodes finished before briefs existed; a gate with nothing
|
||||||
# to review would strand them.
|
# to review would strand them.
|
||||||
if podcast.spec is None:
|
if podcast.spec is None:
|
||||||
raise PreconditionFailed("cannot regenerate without a brief")
|
raise PreconditionFailedError("cannot regenerate without a brief")
|
||||||
self._transition(podcast, PodcastStatus.AWAITING_BRIEF)
|
self._transition(podcast, PodcastStatus.AWAITING_BRIEF)
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
return podcast
|
return podcast
|
||||||
|
|
@ -164,7 +164,7 @@ class PodcastService:
|
||||||
has no regeneration to revert and is rejected.
|
has no regeneration to revert and is rejected.
|
||||||
"""
|
"""
|
||||||
if not has_stored_episode(podcast):
|
if not has_stored_episode(podcast):
|
||||||
raise InvalidTransition("no finished episode to fall back to")
|
raise InvalidTransitionError("no finished episode to fall back to")
|
||||||
self._transition(podcast, PodcastStatus.READY)
|
self._transition(podcast, PodcastStatus.READY)
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
return podcast
|
return podcast
|
||||||
|
|
@ -200,7 +200,7 @@ class PodcastService:
|
||||||
backing out goes through revert_regeneration instead.
|
backing out goes through revert_regeneration instead.
|
||||||
"""
|
"""
|
||||||
if has_stored_episode(podcast):
|
if has_stored_episode(podcast):
|
||||||
raise InvalidTransition(
|
raise InvalidTransitionError(
|
||||||
"a finished episode exists; revert the regeneration instead"
|
"a finished episode exists; revert the regeneration instead"
|
||||||
)
|
)
|
||||||
self._transition(podcast, PodcastStatus.CANCELLED)
|
self._transition(podcast, PodcastStatus.CANCELLED)
|
||||||
|
|
@ -210,7 +210,7 @@ class PodcastService:
|
||||||
def _transition(self, podcast: Podcast, target: PodcastStatus) -> None:
|
def _transition(self, podcast: Podcast, target: PodcastStatus) -> None:
|
||||||
current = _status(podcast)
|
current = _status(podcast)
|
||||||
if target not in _ALLOWED[current]:
|
if target not in _ALLOWED[current]:
|
||||||
raise InvalidTransition(
|
raise InvalidTransitionError(
|
||||||
f"{current.value} -> {target.value} is not allowed"
|
f"{current.value} -> {target.value} is not allowed"
|
||||||
)
|
)
|
||||||
podcast.status = target
|
podcast.status = target
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,10 @@ def draft_transcript_task(self, podcast_id: int, search_space_id: int) -> dict:
|
||||||
return run_async_celery_task(
|
return run_async_celery_task(
|
||||||
lambda: _draft_transcript(podcast_id, search_space_id)
|
lambda: _draft_transcript(podcast_id, search_space_id)
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 - record and report, never crash worker
|
except Exception as exc:
|
||||||
logger.error("Podcast %s drafting failed: %s", podcast_id, exc)
|
logger.error("Podcast %s drafting failed: %s", podcast_id, exc)
|
||||||
run_async_celery_task(lambda: mark_failed(podcast_id, str(exc)))
|
message = str(exc)
|
||||||
|
run_async_celery_task(lambda: mark_failed(podcast_id, message))
|
||||||
return {"status": "failed", "podcast_id": podcast_id}
|
return {"status": "failed", "podcast_id": podcast_id}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from app.celery_app import celery_app
|
||||||
from app.podcasts.persistence import PodcastRepository
|
from app.podcasts.persistence import PodcastRepository
|
||||||
from app.podcasts.rendering import PodcastRenderer
|
from app.podcasts.rendering import PodcastRenderer
|
||||||
from app.podcasts.service import (
|
from app.podcasts.service import (
|
||||||
InvalidTransition,
|
InvalidTransitionError,
|
||||||
PodcastService,
|
PodcastService,
|
||||||
read_spec,
|
read_spec,
|
||||||
read_transcript,
|
read_transcript,
|
||||||
|
|
@ -36,9 +36,10 @@ _WORKDIR_BASE = Path(tempfile.gettempdir()) / "surfsense_podcasts"
|
||||||
def render_audio_task(self, podcast_id: int) -> dict:
|
def render_audio_task(self, podcast_id: int) -> dict:
|
||||||
try:
|
try:
|
||||||
return run_async_celery_task(lambda: _render_audio(podcast_id))
|
return run_async_celery_task(lambda: _render_audio(podcast_id))
|
||||||
except Exception as exc: # noqa: BLE001 - record and report, never crash worker
|
except Exception as exc:
|
||||||
logger.error("Podcast %s render failed: %s", podcast_id, exc)
|
logger.error("Podcast %s render failed: %s", podcast_id, exc)
|
||||||
run_async_celery_task(lambda: mark_failed(podcast_id, str(exc)))
|
message = str(exc)
|
||||||
|
run_async_celery_task(lambda: mark_failed(podcast_id, message))
|
||||||
return {"status": "failed", "podcast_id": podcast_id}
|
return {"status": "failed", "podcast_id": podcast_id}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,7 +76,7 @@ async def _render_audio(podcast_id: int) -> dict:
|
||||||
podcast, storage_backend=backend_name, storage_key=key
|
podcast, storage_backend=backend_name, storage_key=key
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except InvalidTransition:
|
except InvalidTransitionError:
|
||||||
# A user back-out won the race (e.g. the regeneration was
|
# A user back-out won the race (e.g. the regeneration was
|
||||||
# reverted): drop the stale render and leave the row alone.
|
# reverted): drop the stale render and leave the row alone.
|
||||||
await purge_audio_object(key)
|
await purge_audio_object(key)
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,7 @@ class KokoroTextToSpeech(TextToSpeech):
|
||||||
|
|
||||||
async def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
async def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||||
if not isinstance(request.voice, str):
|
if not isinstance(request.voice, str):
|
||||||
raise TextToSpeechError(
|
raise TextToSpeechError("Kokoro voices are named by string, not a mapping")
|
||||||
"Kokoro voices are named by string, not a mapping"
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline = self._pipeline_for(request.language)
|
pipeline = self._pipeline_for(request.language)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
@ -67,7 +65,7 @@ class KokoroTextToSpeech(TextToSpeech):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
segments = [audio for _gs, _ps, audio in generator]
|
segments = [audio for _gs, _ps, audio in generator]
|
||||||
except Exception as exc: # noqa: BLE001 - normalise provider errors
|
except Exception as exc:
|
||||||
raise TextToSpeechError(f"Kokoro synthesis failed: {exc}") from exc
|
raise TextToSpeechError(f"Kokoro synthesis failed: {exc}") from exc
|
||||||
|
|
||||||
if not segments:
|
if not segments:
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,8 @@ class LiteLlmTextToSpeech(TextToSpeech):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await aspeech(**kwargs)
|
response = await aspeech(**kwargs)
|
||||||
except Exception as exc: # noqa: BLE001 - normalise provider errors
|
except Exception as exc:
|
||||||
raise TextToSpeechError(
|
raise TextToSpeechError(f"{self._model} synthesis failed: {exc}") from exc
|
||||||
f"{self._model} synthesis failed: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
data = getattr(response, "content", None)
|
data = getattr(response, "content", None)
|
||||||
if not data:
|
if not data:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ configured provider via :func:`provider_from_service`.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .catalog import VoiceCatalog, get_voice_catalog
|
from .catalog import LanguageOffering, VoiceCatalog, get_voice_catalog
|
||||||
from .preview import render_voice_preview
|
from .preview import render_voice_preview
|
||||||
from .provider import TtsProvider, provider_from_service
|
from .provider import TtsProvider, provider_from_service
|
||||||
from .voice import ANY_LANGUAGE, CatalogVoice, VoiceGender
|
from .voice import ANY_LANGUAGE, CatalogVoice, VoiceGender
|
||||||
|
|
@ -14,6 +14,7 @@ from .voice import ANY_LANGUAGE, CatalogVoice, VoiceGender
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ANY_LANGUAGE",
|
"ANY_LANGUAGE",
|
||||||
"CatalogVoice",
|
"CatalogVoice",
|
||||||
|
"LanguageOffering",
|
||||||
"TtsProvider",
|
"TtsProvider",
|
||||||
"VoiceCatalog",
|
"VoiceCatalog",
|
||||||
"VoiceGender",
|
"VoiceGender",
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,26 @@ provider-native reference.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from .data import AZURE_VOICES, KOKORO_VOICES, OPENAI_VOICES, VERTEX_VOICES
|
from .data import AZURE_VOICES, KOKORO_VOICES, OPENAI_VOICES, VERTEX_VOICES
|
||||||
|
from .data.languages import COMMON_LANGUAGES
|
||||||
from .provider import TtsProvider
|
from .provider import TtsProvider
|
||||||
from .voice import CatalogVoice
|
from .voice import ANY_LANGUAGE, CatalogVoice
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LanguageOffering:
|
||||||
|
"""The languages a provider's roster can offer the brief form.
|
||||||
|
|
||||||
|
``allows_custom`` is true when the roster has wildcard voices: the listed
|
||||||
|
languages are then a curated starting point, not a limit, and any BCP-47
|
||||||
|
tag may be entered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
languages: list[str]
|
||||||
|
allows_custom: bool
|
||||||
|
|
||||||
|
|
||||||
class VoiceCatalog:
|
class VoiceCatalog:
|
||||||
|
|
@ -36,9 +51,7 @@ class VoiceCatalog:
|
||||||
"""All voices offered by ``provider``, in catalog order."""
|
"""All voices offered by ``provider``, in catalog order."""
|
||||||
return list(self._by_provider.get(provider, ()))
|
return list(self._by_provider.get(provider, ()))
|
||||||
|
|
||||||
def for_language(
|
def for_language(self, provider: TtsProvider, language: str) -> list[CatalogVoice]:
|
||||||
self, provider: TtsProvider, language: str
|
|
||||||
) -> list[CatalogVoice]:
|
|
||||||
"""``provider`` voices that can render ``language``, in catalog order."""
|
"""``provider`` voices that can render ``language``, in catalog order."""
|
||||||
return [v for v in self.for_provider(provider) if v.speaks(language)]
|
return [v for v in self.for_provider(provider) if v.speaks(language)]
|
||||||
|
|
||||||
|
|
@ -46,10 +59,22 @@ class VoiceCatalog:
|
||||||
"""Whether ``provider`` has at least one voice for ``language``."""
|
"""Whether ``provider`` has at least one voice for ``language``."""
|
||||||
return any(v.speaks(language) for v in self.for_provider(provider))
|
return any(v.speaks(language) for v in self.for_provider(provider))
|
||||||
|
|
||||||
|
def offerable_languages(self, provider: TtsProvider) -> LanguageOffering:
|
||||||
|
"""The languages ``provider`` can offer up front.
|
||||||
|
|
||||||
|
Language-bound voices contribute their concrete tags; wildcard voices
|
||||||
|
cannot enumerate languages, so their presence merges in the curated
|
||||||
|
common list and opens free entry.
|
||||||
|
"""
|
||||||
|
voices = self.for_provider(provider)
|
||||||
|
tags = {v.language for v in voices if v.language != ANY_LANGUAGE}
|
||||||
|
has_wildcard = any(v.language == ANY_LANGUAGE for v in voices)
|
||||||
|
if has_wildcard:
|
||||||
|
tags.update(COMMON_LANGUAGES)
|
||||||
|
return LanguageOffering(languages=sorted(tags), allows_custom=has_wildcard)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_voice_catalog() -> VoiceCatalog:
|
def get_voice_catalog() -> VoiceCatalog:
|
||||||
"""The process-wide catalog assembled from every provider's roster."""
|
"""The process-wide catalog assembled from every provider's roster."""
|
||||||
return VoiceCatalog(
|
return VoiceCatalog((*KOKORO_VOICES, *OPENAI_VOICES, *AZURE_VOICES, *VERTEX_VOICES))
|
||||||
(*KOKORO_VOICES, *OPENAI_VOICES, *AZURE_VOICES, *VERTEX_VOICES)
|
|
||||||
)
|
|
||||||
|
|
|
||||||
33
surfsense_backend/app/podcasts/voices/data/languages.py
Normal file
33
surfsense_backend/app/podcasts/voices/data/languages.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Curated languages offered when a roster has wildcard (any-language) voices.
|
||||||
|
|
||||||
|
OpenAI-style multilingual voices speak whatever language the text is in, so
|
||||||
|
there is no provider list to enumerate. This is the set the brief form offers
|
||||||
|
up front for such providers; it is an offering, not a limit — the API flags
|
||||||
|
``allows_custom`` so users can enter any BCP-47 tag beyond it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
COMMON_LANGUAGES: tuple[str, ...] = (
|
||||||
|
"ar",
|
||||||
|
"bn",
|
||||||
|
"de",
|
||||||
|
"en",
|
||||||
|
"es",
|
||||||
|
"fr",
|
||||||
|
"hi",
|
||||||
|
"id",
|
||||||
|
"it",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"nl",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"ru",
|
||||||
|
"sw",
|
||||||
|
"th",
|
||||||
|
"tr",
|
||||||
|
"uk",
|
||||||
|
"vi",
|
||||||
|
"zh",
|
||||||
|
)
|
||||||
|
|
@ -30,10 +30,52 @@ def _voice(
|
||||||
|
|
||||||
|
|
||||||
VERTEX_VOICES: tuple[CatalogVoice, ...] = (
|
VERTEX_VOICES: tuple[CatalogVoice, ...] = (
|
||||||
_voice("en-US-Studio-O", "en-US", "en-US", "en-US-Studio-O", "Studio O (US)", VoiceGender.FEMALE),
|
_voice(
|
||||||
_voice("en-US-Studio-M", "en-US", "en-US", "en-US-Studio-M", "Studio M (US)", VoiceGender.MALE),
|
"en-US-Studio-O",
|
||||||
_voice("en-GB-Studio-A", "en-GB", "en-UK", "en-UK-Studio-A", "Studio A (UK)", VoiceGender.FEMALE),
|
"en-US",
|
||||||
_voice("en-GB-Studio-B", "en-GB", "en-UK", "en-UK-Studio-B", "Studio B (UK)", VoiceGender.MALE),
|
"en-US",
|
||||||
_voice("en-AU-Studio-A", "en-AU", "en-AU", "en-AU-Studio-A", "Studio A (AU)", VoiceGender.FEMALE),
|
"en-US-Studio-O",
|
||||||
_voice("en-AU-Studio-B", "en-AU", "en-AU", "en-AU-Studio-B", "Studio B (AU)", VoiceGender.MALE),
|
"Studio O (US)",
|
||||||
|
VoiceGender.FEMALE,
|
||||||
|
),
|
||||||
|
_voice(
|
||||||
|
"en-US-Studio-M",
|
||||||
|
"en-US",
|
||||||
|
"en-US",
|
||||||
|
"en-US-Studio-M",
|
||||||
|
"Studio M (US)",
|
||||||
|
VoiceGender.MALE,
|
||||||
|
),
|
||||||
|
_voice(
|
||||||
|
"en-GB-Studio-A",
|
||||||
|
"en-GB",
|
||||||
|
"en-UK",
|
||||||
|
"en-UK-Studio-A",
|
||||||
|
"Studio A (UK)",
|
||||||
|
VoiceGender.FEMALE,
|
||||||
|
),
|
||||||
|
_voice(
|
||||||
|
"en-GB-Studio-B",
|
||||||
|
"en-GB",
|
||||||
|
"en-UK",
|
||||||
|
"en-UK-Studio-B",
|
||||||
|
"Studio B (UK)",
|
||||||
|
VoiceGender.MALE,
|
||||||
|
),
|
||||||
|
_voice(
|
||||||
|
"en-AU-Studio-A",
|
||||||
|
"en-AU",
|
||||||
|
"en-AU",
|
||||||
|
"en-AU-Studio-A",
|
||||||
|
"Studio A (AU)",
|
||||||
|
VoiceGender.FEMALE,
|
||||||
|
),
|
||||||
|
_voice(
|
||||||
|
"en-AU-Studio-B",
|
||||||
|
"en-AU",
|
||||||
|
"en-AU",
|
||||||
|
"en-AU-Studio-B",
|
||||||
|
"Studio B (AU)",
|
||||||
|
VoiceGender.MALE,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ _SAMPLE_TEXTS = {
|
||||||
"it": "Ciao! Questa è la mia voce quando racconto il tuo podcast.",
|
"it": "Ciao! Questa è la mia voce quando racconto il tuo podcast.",
|
||||||
"ja": "こんにちは。ポッドキャストをお届けするときの私の声です。",
|
"ja": "こんにちは。ポッドキャストをお届けするときの私の声です。",
|
||||||
"pt": "Olá! É assim que eu soo ao narrar o seu podcast.",
|
"pt": "Olá! É assim que eu soo ao narrar o seu podcast.",
|
||||||
"zh": "你好!这就是我为你播报播客时的声音。",
|
"zh": "你好!这就是我为你播报播客时的声音。", # noqa: RUF001
|
||||||
}
|
}
|
||||||
|
|
||||||
_CONTENT_TYPES = {"mp3": "audio/mpeg", "wav": "audio/wav"}
|
_CONTENT_TYPES = {"mp3": "audio/mpeg", "wav": "audio/wav"}
|
||||||
|
|
@ -40,9 +40,7 @@ async def render_voice_preview(
|
||||||
voice: CatalogVoice, tts: TextToSpeech
|
voice: CatalogVoice, tts: TextToSpeech
|
||||||
) -> tuple[bytes, str]:
|
) -> tuple[bytes, str]:
|
||||||
"""Return ``(audio_bytes, content_type)`` for a sample spoken by ``voice``."""
|
"""Return ``(audio_bytes, content_type)`` for a sample spoken by ``voice``."""
|
||||||
language = (
|
language = _FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language
|
||||||
_FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language
|
|
||||||
)
|
|
||||||
request = SynthesisRequest(
|
request = SynthesisRequest(
|
||||||
text=_sample_text(language), voice=voice.native_ref, language=language
|
text=_sample_text(language), voice=voice.native_ref, language=language
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ def _card_error_payment_intent_id(exc: CardError) -> str | None:
|
||||||
@celery_app.task(name="auto_reload_credits")
|
@celery_app.task(name="auto_reload_credits")
|
||||||
def auto_reload_credits_task(user_id: str):
|
def auto_reload_credits_task(user_id: str):
|
||||||
"""Charge the user's saved card to top up credits when below threshold."""
|
"""Charge the user's saved card to top up credits when below threshold."""
|
||||||
return run_async_celery_task(_auto_reload_credits, user_id)
|
return run_async_celery_task(lambda: _auto_reload_credits(user_id))
|
||||||
|
|
||||||
|
|
||||||
async def _auto_reload_credits(user_id: str) -> None:
|
async def _auto_reload_credits(user_id: str) -> None:
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,11 @@ def iter_completion_emission_frames(
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
elif status in ("failed", "error"):
|
elif status in ("failed", "error"):
|
||||||
error_msg = out.get("error", "Unknown error") if isinstance(out, dict) else "Unknown error"
|
error_msg = (
|
||||||
|
out.get("error", "Unknown error")
|
||||||
|
if isinstance(out, dict)
|
||||||
|
else "Unknown error"
|
||||||
|
)
|
||||||
yield ctx.streaming_service.format_terminal_info(
|
yield ctx.streaming_service.format_terminal_info(
|
||||||
f"Podcast generation failed: {error_msg}",
|
f"Podcast generation failed: {error_msg}",
|
||||||
"error",
|
"error",
|
||||||
|
|
|
||||||
|
|
@ -86,66 +86,54 @@ def _quote_identifier(identifier: str) -> str:
|
||||||
return '"' + identifier.replace('"', '""') + '"'
|
return '"' + identifier.replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
def _column_exists(conn: Connection, table: str, column: str) -> bool:
|
def _table_columns(conn: Connection, table: str) -> set[str]:
|
||||||
return (
|
rows = conn.execute(
|
||||||
conn.execute(
|
text(
|
||||||
text(
|
"SELECT column_name FROM information_schema.columns "
|
||||||
"SELECT 1 FROM information_schema.columns "
|
"WHERE table_schema = current_schema() AND table_name = :table"
|
||||||
"WHERE table_schema = current_schema() "
|
),
|
||||||
"AND table_name = :table AND column_name = :column"
|
{"table": table},
|
||||||
),
|
).fetchall()
|
||||||
{"table": table, "column": column},
|
return {row[0] for row in rows}
|
||||||
).fetchone()
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _table_exists(conn: Connection, table: str) -> bool:
|
def _expected_columns(conn: Connection, table: str) -> list[str] | None:
|
||||||
return (
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"SELECT 1 FROM information_schema.tables "
|
|
||||||
"WHERE table_schema = current_schema() "
|
|
||||||
"AND table_name = :table"
|
|
||||||
),
|
|
||||||
{"table": table},
|
|
||||||
).fetchone()
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _expected_columns(
|
|
||||||
conn: Connection, table: str, *, include_missing_columns: bool = True
|
|
||||||
) -> list[str] | None:
|
|
||||||
columns = ZERO_PUBLICATION[table]
|
columns = ZERO_PUBLICATION[table]
|
||||||
if columns is None:
|
if columns is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if include_missing_columns:
|
expected = list(columns)
|
||||||
expected = list(columns)
|
if table in {"documents", "user", "podcasts"} and "_0_version" in _table_columns(
|
||||||
else:
|
conn, table
|
||||||
expected = [column for column in columns if _column_exists(conn, table, column)]
|
|
||||||
|
|
||||||
if table in {"documents", "user", "podcasts"} and _column_exists(
|
|
||||||
conn, table, "_0_version"
|
|
||||||
):
|
):
|
||||||
expected.append("_0_version")
|
expected.append("_0_version")
|
||||||
return expected
|
return expected
|
||||||
|
|
||||||
|
|
||||||
def _format_table_entry(
|
def _format_table_entry(conn: Connection, table: str) -> str | None:
|
||||||
conn: Connection, table: str, *, include_missing_columns: bool = True
|
"""Render one SET TABLE entry, or ``None`` if the table isn't ready.
|
||||||
) -> str | None:
|
|
||||||
if not include_missing_columns and not _table_exists(conn, table):
|
Historical migrations (e.g. 155/156) call ``apply_publication`` while the
|
||||||
|
schema is still mid-history, before later migrations add columns that the
|
||||||
|
canonical shape references. A table is only published once it exists AND
|
||||||
|
every canonical column exists; otherwise it is omitted entirely and a later
|
||||||
|
reconcile migration (e.g. 159) picks it up once its columns land. Partial
|
||||||
|
column lists are deliberately avoided: publishing a column early would
|
||||||
|
block later ``ALTER COLUMN ... TYPE`` migrations on it (Postgres forbids
|
||||||
|
retyping columns a publication depends on). ``verify_publication`` remains
|
||||||
|
strict against the unfiltered canonical shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
actual = _table_columns(conn, table)
|
||||||
|
if not actual:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
columns = _expected_columns(
|
|
||||||
conn, table, include_missing_columns=include_missing_columns
|
|
||||||
)
|
|
||||||
table_sql = _quote_identifier(table)
|
table_sql = _quote_identifier(table)
|
||||||
|
columns = _expected_columns(conn, table)
|
||||||
if columns is None:
|
if columns is None:
|
||||||
return table_sql
|
return table_sql
|
||||||
if not include_missing_columns and not columns:
|
|
||||||
|
if any(column not in actual for column in columns):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
column_sql = ", ".join(_quote_identifier(column) for column in columns)
|
column_sql = ", ".join(_quote_identifier(column) for column in columns)
|
||||||
|
|
@ -155,17 +143,8 @@ def _format_table_entry(
|
||||||
def build_set_table_sql(conn: Connection) -> str:
|
def build_set_table_sql(conn: Connection) -> str:
|
||||||
"""Build the canonical plain SET TABLE statement for Zero's event triggers."""
|
"""Build the canonical plain SET TABLE statement for Zero's event triggers."""
|
||||||
|
|
||||||
table_entries = [
|
entries = [_format_table_entry(conn, table) for table in ZERO_PUBLICATION]
|
||||||
entry
|
table_list = ", ".join(entry for entry in entries if entry is not None)
|
||||||
for table in ZERO_PUBLICATION
|
|
||||||
if (
|
|
||||||
entry := _format_table_entry(
|
|
||||||
conn, table, include_missing_columns=False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
is not None
|
|
||||||
]
|
|
||||||
table_list = ", ".join(table_entries)
|
|
||||||
return f"ALTER PUBLICATION {_quote_identifier(PUBLICATION_NAME)} SET TABLE {table_list}"
|
return f"ALTER PUBLICATION {_quote_identifier(PUBLICATION_NAME)} SET TABLE {table_list}"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "surf-new-backend"
|
name = "surf-new-backend"
|
||||||
version = "0.0.27"
|
version = "0.0.28"
|
||||||
description = "SurfSense Backend"
|
description = "SurfSense Backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.app import app, limiter
|
from app.app import app, limiter
|
||||||
from app.config import config as app_config
|
from app.config import config as app_config
|
||||||
from app.db import SearchSpace, User, get_async_session
|
from app.db import SearchSpace, User, get_async_session
|
||||||
from app.routes.search_spaces_routes import create_default_roles_and_membership
|
|
||||||
from app.podcasts.persistence import Podcast, PodcastStatus
|
from app.podcasts.persistence import Podcast, PodcastStatus
|
||||||
from app.podcasts.schemas import (
|
from app.podcasts.schemas import (
|
||||||
DurationTarget,
|
DurationTarget,
|
||||||
|
|
@ -39,6 +38,7 @@ from app.podcasts.schemas import (
|
||||||
)
|
)
|
||||||
from app.podcasts.service import PodcastService
|
from app.podcasts.service import PodcastService
|
||||||
from app.podcasts.tts import SynthesisRequest, SynthesizedAudio, TextToSpeech
|
from app.podcasts.tts import SynthesisRequest, SynthesizedAudio, TextToSpeech
|
||||||
|
from app.routes.search_spaces_routes import create_default_roles_and_membership
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
@ -128,12 +128,8 @@ class FakeStorageBackend:
|
||||||
def fake_storage(monkeypatch) -> FakeStorageBackend:
|
def fake_storage(monkeypatch) -> FakeStorageBackend:
|
||||||
"""Route audio storage to an in-memory backend for the stream routes."""
|
"""Route audio storage to an in-memory backend for the stream routes."""
|
||||||
backend = FakeStorageBackend()
|
backend = FakeStorageBackend()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr("app.podcasts.storage.get_storage_backend", lambda: backend)
|
||||||
"app.podcasts.storage.get_storage_backend", lambda: backend
|
monkeypatch.setattr("app.file_storage.factory.get_storage_backend", lambda: backend)
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.file_storage.factory.get_storage_backend", lambda: backend
|
|
||||||
)
|
|
||||||
return backend
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -159,9 +155,7 @@ def bind_task_session(db_session: AsyncSession, monkeypatch) -> AsyncSession:
|
||||||
"app.podcasts.tasks.render",
|
"app.podcasts.tasks.render",
|
||||||
"app.podcasts.tasks.runtime",
|
"app.podcasts.tasks.runtime",
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(f"{module}.get_celery_session_maker", lambda: _make_session)
|
||||||
f"{module}.get_celery_session_maker", lambda: _make_session
|
|
||||||
)
|
|
||||||
return db_session
|
return db_session
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -213,8 +207,12 @@ def build_spec(
|
||||||
language=language,
|
language=language,
|
||||||
style=PodcastStyle.CONVERSATIONAL,
|
style=PodcastStyle.CONVERSATIONAL,
|
||||||
speakers=[
|
speakers=[
|
||||||
SpeakerSpec(slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_ids[0]),
|
SpeakerSpec(
|
||||||
SpeakerSpec(slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]),
|
slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_ids[0]
|
||||||
|
),
|
||||||
|
SpeakerSpec(
|
||||||
|
slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
duration=DurationTarget(min_minutes=10, max_minutes=20),
|
duration=DurationTarget(min_minutes=10, max_minutes=20),
|
||||||
)
|
)
|
||||||
|
|
@ -237,7 +235,7 @@ def make_podcast(db_session: AsyncSession):
|
||||||
session, so the endpoint under test reads a realistically-built row.
|
session, so the endpoint under test reads a realistically-built row.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_LADDER = [
|
ladder = [
|
||||||
PodcastStatus.AWAITING_BRIEF,
|
PodcastStatus.AWAITING_BRIEF,
|
||||||
PodcastStatus.DRAFTING,
|
PodcastStatus.DRAFTING,
|
||||||
PodcastStatus.RENDERING,
|
PodcastStatus.RENDERING,
|
||||||
|
|
@ -259,7 +257,7 @@ def make_podcast(db_session: AsyncSession):
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
return podcast
|
return podcast
|
||||||
|
|
||||||
targets = _LADDER[: _LADDER.index(status) + 1]
|
targets = ladder[: ladder.index(status) + 1]
|
||||||
for target in targets:
|
for target in targets:
|
||||||
if target is PodcastStatus.AWAITING_BRIEF:
|
if target is PodcastStatus.AWAITING_BRIEF:
|
||||||
await service.attach_brief(podcast, build_spec())
|
await service.attach_brief(podcast, build_spec())
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,7 @@ pytestmark = pytest.mark.integration
|
||||||
BASE = "/api/v1/podcasts"
|
BASE = "/api/v1/podcasts"
|
||||||
|
|
||||||
|
|
||||||
async def test_cancel_from_a_live_state_succeeds(
|
async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_podcast):
|
||||||
client, db_search_space, make_podcast
|
|
||||||
):
|
|
||||||
podcast = await make_podcast(
|
podcast = await make_podcast(
|
||||||
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
|
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,12 @@ BASE = "/api/v1/podcasts"
|
||||||
def preview_tts(monkeypatch, tmp_path) -> FakeTextToSpeech:
|
def preview_tts(monkeypatch, tmp_path) -> FakeTextToSpeech:
|
||||||
"""Route preview synthesis to the fake provider and an isolated cache."""
|
"""Route preview synthesis to the fake provider and an isolated cache."""
|
||||||
provider = FakeTextToSpeech()
|
provider = FakeTextToSpeech()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr("app.podcasts.api.routes.get_text_to_speech", lambda: provider)
|
||||||
"app.podcasts.api.routes.get_text_to_speech", lambda: provider
|
monkeypatch.setattr("app.podcasts.voices.preview.PREVIEW_CACHE_ROOT", tmp_path)
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.podcasts.voices.preview.PREVIEW_CACHE_ROOT", tmp_path
|
|
||||||
)
|
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
async def test_preview_returns_playable_audio_for_a_catalog_voice(
|
async def test_preview_returns_playable_audio_for_a_catalog_voice(client, preview_tts):
|
||||||
client, preview_tts
|
|
||||||
):
|
|
||||||
resp = await client.get(f"{BASE}/voices/openai:alloy/preview")
|
resp = await client.get(f"{BASE}/voices/openai:alloy/preview")
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
@ -42,9 +36,7 @@ async def test_preview_returns_playable_audio_for_a_catalog_voice(
|
||||||
assert resp.content == b"segment-audio"
|
assert resp.content == b"segment-audio"
|
||||||
|
|
||||||
|
|
||||||
async def test_preview_is_synthesised_once_then_served_from_cache(
|
async def test_preview_is_synthesised_once_then_served_from_cache(client, preview_tts):
|
||||||
client, preview_tts
|
|
||||||
):
|
|
||||||
first = await client.get(f"{BASE}/voices/openai:alloy/preview")
|
first = await client.get(f"{BASE}/voices/openai:alloy/preview")
|
||||||
second = await client.get(f"{BASE}/voices/openai:alloy/preview")
|
second = await client.get(f"{BASE}/voices/openai:alloy/preview")
|
||||||
|
|
||||||
|
|
@ -69,9 +61,7 @@ async def test_preview_voice_of_inactive_provider_is_404(client, preview_tts):
|
||||||
assert preview_tts.requests == []
|
assert preview_tts.requests == []
|
||||||
|
|
||||||
|
|
||||||
async def test_preview_without_tts_provider_is_503(
|
async def test_preview_without_tts_provider_is_503(client, preview_tts, monkeypatch):
|
||||||
client, preview_tts, monkeypatch
|
|
||||||
):
|
|
||||||
monkeypatch.setattr(app_config, "TTS_SERVICE", None)
|
monkeypatch.setattr(app_config, "TTS_SERVICE", None)
|
||||||
|
|
||||||
resp = await client.get(f"{BASE}/voices/openai:alloy/preview")
|
resp = await client.get(f"{BASE}/voices/openai:alloy/preview")
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,23 @@ async def test_voices_503_when_no_tts_configured(client, monkeypatch):
|
||||||
resp = await client.get(f"{BASE}/voices")
|
resp = await client.get(f"{BASE}/voices")
|
||||||
|
|
||||||
assert resp.status_code == 503
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
async def test_languages_returns_the_active_providers_offering(client):
|
||||||
|
"""The brief form renders exactly what the backend offers — for a wildcard
|
||||||
|
provider (openai/tts-1) that is the curated list plus free entry."""
|
||||||
|
resp = await client.get(f"{BASE}/languages")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
offering = resp.json()
|
||||||
|
assert "en" in offering["languages"]
|
||||||
|
assert "fr" in offering["languages"]
|
||||||
|
assert offering["allows_custom"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_languages_503_when_no_tts_configured(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(app_config, "TTS_SERVICE", "")
|
||||||
|
|
||||||
|
resp = await client.get(f"{BASE}/languages")
|
||||||
|
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ def make_spec():
|
||||||
if speakers is None:
|
if speakers is None:
|
||||||
speakers = [
|
speakers = [
|
||||||
SpeakerSpec(
|
SpeakerSpec(
|
||||||
slot=0, name="Host", role=SpeakerRole.HOST, voice_id="kokoro:am_adam"
|
slot=0,
|
||||||
|
name="Host",
|
||||||
|
role=SpeakerRole.HOST,
|
||||||
|
voice_id="kokoro:am_adam",
|
||||||
),
|
),
|
||||||
SpeakerSpec(
|
SpeakerSpec(
|
||||||
slot=1,
|
slot=1,
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ def _spec(voice_id: str) -> PodcastSpec:
|
||||||
|
|
||||||
|
|
||||||
async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path):
|
async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path):
|
||||||
renderer = PodcastRenderer(tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam"))
|
renderer = PodcastRenderer(
|
||||||
|
tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")
|
||||||
|
)
|
||||||
transcript = Transcript(turns=[TranscriptTurn(speaker=5, text="Who am I?")])
|
transcript = Transcript(turns=[TranscriptTurn(speaker=5, text="Who am I?")])
|
||||||
|
|
||||||
with pytest.raises(RenderError):
|
with pytest.raises(RenderError):
|
||||||
|
|
@ -81,7 +83,9 @@ async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path):
|
||||||
|
|
||||||
|
|
||||||
async def test_render_rejects_a_speaker_whose_voice_is_not_in_the_catalog(tmp_path):
|
async def test_render_rejects_a_speaker_whose_voice_is_not_in_the_catalog(tmp_path):
|
||||||
renderer = PodcastRenderer(tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam"))
|
renderer = PodcastRenderer(
|
||||||
|
tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")
|
||||||
|
)
|
||||||
transcript = Transcript(turns=[TranscriptTurn(speaker=0, text="Hello.")])
|
transcript = Transcript(turns=[TranscriptTurn(speaker=0, text="Hello.")])
|
||||||
|
|
||||||
with pytest.raises(RenderError):
|
with pytest.raises(RenderError):
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,9 @@ def test_a_preferred_voice_invalid_for_the_language_is_replaced():
|
||||||
speaker_count=1,
|
speaker_count=1,
|
||||||
preferred=["kokoro:does-not-exist"],
|
preferred=["kokoro:does-not-exist"],
|
||||||
)
|
)
|
||||||
assert voices[0].voice_id in {v.voice_id for v in catalog.for_provider(TtsProvider.KOKORO)}
|
assert voices[0].voice_id in {
|
||||||
|
v.voice_id for v in catalog.for_provider(TtsProvider.KOKORO)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_resolution_fails_when_no_voice_speaks_the_language():
|
def test_resolution_fails_when_no_voice_speaks_the_language():
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,9 @@ def test_for_provider_returns_only_that_providers_voices():
|
||||||
def test_for_language_matches_on_the_primary_subtag():
|
def test_for_language_matches_on_the_primary_subtag():
|
||||||
"""A request for 'en' should match an 'en-US' voice (region-insensitive)."""
|
"""A request for 'en' should match an 'en-US' voice (region-insensitive)."""
|
||||||
catalog = VoiceCatalog([_voice("k1", language="en-US")])
|
catalog = VoiceCatalog([_voice("k1", language="en-US")])
|
||||||
assert [v.voice_id for v in catalog.for_language(TtsProvider.KOKORO, "en")] == ["k1"]
|
assert [v.voice_id for v in catalog.for_language(TtsProvider.KOKORO, "en")] == [
|
||||||
|
"k1"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_for_language_excludes_other_languages():
|
def test_for_language_excludes_other_languages():
|
||||||
|
|
@ -73,6 +75,59 @@ def test_supports_language_reports_availability():
|
||||||
assert not catalog.supports_language(TtsProvider.KOKORO, "de")
|
assert not catalog.supports_language(TtsProvider.KOKORO, "de")
|
||||||
|
|
||||||
|
|
||||||
|
def test_offerable_languages_for_a_concrete_roster_are_its_tags_only():
|
||||||
|
"""A provider whose voices are language-bound offers exactly those tags."""
|
||||||
|
catalog = VoiceCatalog(
|
||||||
|
[
|
||||||
|
_voice("k1", language="en-US"),
|
||||||
|
_voice("k2", language="fr"),
|
||||||
|
_voice("k3", language="fr"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
offering = catalog.offerable_languages(TtsProvider.KOKORO)
|
||||||
|
|
||||||
|
assert offering.languages == ["en-US", "fr"]
|
||||||
|
assert offering.allows_custom is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_wildcard_roster_offers_the_curated_languages_and_custom_entry():
|
||||||
|
"""Voices that speak anything can't enumerate languages themselves, so the
|
||||||
|
catalog offers the curated common list and invites free entry."""
|
||||||
|
catalog = VoiceCatalog(
|
||||||
|
[_voice("o1", provider=TtsProvider.OPENAI, language=ANY_LANGUAGE)]
|
||||||
|
)
|
||||||
|
|
||||||
|
offering = catalog.offerable_languages(TtsProvider.OPENAI)
|
||||||
|
|
||||||
|
assert {"en", "fr", "sw", "hi", "zh"} <= set(offering.languages)
|
||||||
|
assert offering.allows_custom is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mixed_roster_offers_the_union_of_concrete_and_curated():
|
||||||
|
catalog = VoiceCatalog(
|
||||||
|
[
|
||||||
|
_voice("v1", provider=TtsProvider.VERTEX_AI, language="en-GB"),
|
||||||
|
_voice("v2", provider=TtsProvider.VERTEX_AI, language=ANY_LANGUAGE),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
offering = catalog.offerable_languages(TtsProvider.VERTEX_AI)
|
||||||
|
|
||||||
|
assert "en-GB" in offering.languages
|
||||||
|
assert "fr" in offering.languages
|
||||||
|
assert offering.allows_custom is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_provider_with_no_voices_offers_nothing():
|
||||||
|
catalog = VoiceCatalog([_voice("k1")])
|
||||||
|
|
||||||
|
offering = catalog.offerable_languages(TtsProvider.OPENAI)
|
||||||
|
|
||||||
|
assert offering.languages == []
|
||||||
|
assert offering.allows_custom is False
|
||||||
|
|
||||||
|
|
||||||
def test_get_raises_for_an_unknown_voice():
|
def test_get_raises_for_an_unknown_voice():
|
||||||
catalog = VoiceCatalog([_voice("k1")])
|
catalog = VoiceCatalog([_voice("k1")])
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
|
|
|
||||||
134
surfsense_backend/uv.lock
generated
134
surfsense_backend/uv.lock
generated
|
|
@ -15,6 +15,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -28,6 +31,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -41,6 +47,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -54,6 +63,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -67,6 +79,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -80,6 +95,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -93,6 +111,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -106,6 +127,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -119,6 +143,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -132,6 +159,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -149,6 +179,10 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -162,6 +196,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -175,6 +212,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -188,6 +228,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -201,6 +244,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -214,6 +260,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -227,6 +276,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -240,6 +292,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -253,6 +308,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -266,6 +324,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -279,6 +340,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -296,6 +360,10 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -309,6 +377,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -322,6 +393,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -335,6 +409,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -348,6 +425,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -361,6 +441,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -374,6 +457,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -387,6 +473,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -400,6 +489,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -413,6 +505,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -426,6 +521,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -443,6 +541,10 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -456,6 +558,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -481,6 +586,12 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -494,6 +605,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -507,6 +621,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -532,6 +649,12 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -557,6 +680,12 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
|
@ -570,6 +699,9 @@ resolution-markers = [
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
"python_version < '0'",
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
|
"python_version < '0'",
|
||||||
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||||
]
|
]
|
||||||
conflicts = [[
|
conflicts = [[
|
||||||
|
|
@ -9482,7 +9614,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "surf-new-backend"
|
name = "surf-new-backend"
|
||||||
version = "0.0.27"
|
version = "0.0.28"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense_browser_extension",
|
"name": "surfsense_browser_extension",
|
||||||
"displayName": "Surfsense Browser Extension",
|
"displayName": "Surfsense Browser Extension",
|
||||||
"version": "0.0.27",
|
"version": "0.0.28",
|
||||||
"description": "Extension to collect Browsing History for SurfSense.",
|
"description": "Extension to collect Browsing History for SurfSense.",
|
||||||
"author": "https://github.com/MODSetter",
|
"author": "https://github.com/MODSetter",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense-desktop",
|
"name": "surfsense-desktop",
|
||||||
"productName": "SurfSense",
|
"productName": "SurfSense",
|
||||||
"version": "0.0.27",
|
"version": "0.0.28",
|
||||||
"description": "SurfSense Desktop App",
|
"description": "SurfSense Desktop App",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,7 @@ import type { MDXComponents } from "mdx/types";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { changelog } from "@/.source/server";
|
import { changelog } from "@/.source/server";
|
||||||
import {
|
import { ChangelogTimeline, type ChangelogTimelineEntry } from "@/components/ui/changelog-timeline";
|
||||||
ChangelogTimeline,
|
|
||||||
type ChangelogTimelineEntry,
|
|
||||||
} from "@/components/ui/changelog-timeline";
|
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { getMDXComponents } from "@/mdx-components";
|
import { getMDXComponents } from "@/mdx-components";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,7 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import type {
|
import type { CreditPurchase, PagePurchase, PurchaseStatus } from "@/contracts/types/stripe.types";
|
||||||
CreditPurchase,
|
|
||||||
PagePurchase,
|
|
||||||
PurchaseStatus,
|
|
||||||
} from "@/contracts/types/stripe.types";
|
|
||||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,8 @@ import {
|
||||||
isCommentReplyMetadata,
|
isCommentReplyMetadata,
|
||||||
isConnectorIndexingMetadata,
|
isConnectorIndexingMetadata,
|
||||||
isDocumentProcessingMetadata,
|
isDocumentProcessingMetadata,
|
||||||
isNewMentionMetadata,
|
|
||||||
isInsufficientCreditsMetadata,
|
isInsufficientCreditsMetadata,
|
||||||
|
isNewMentionMetadata,
|
||||||
} from "@/contracts/types/inbox.types";
|
} from "@/contracts/types/inbox.types";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import type { InboxItem } from "@/hooks/use-inbox";
|
import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar";
|
export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar";
|
||||||
export { ChatListItem } from "./ChatListItem";
|
export { ChatListItem } from "./ChatListItem";
|
||||||
|
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
||||||
export { DocumentsSidebar } from "./DocumentsSidebar";
|
export { DocumentsSidebar } from "./DocumentsSidebar";
|
||||||
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
|
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
|
||||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||||
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
|
||||||
export { NavSection } from "./NavSection";
|
export { NavSection } from "./NavSection";
|
||||||
export { Sidebar } from "./Sidebar";
|
export { Sidebar } from "./Sidebar";
|
||||||
export { SidebarCollapseButton } from "./SidebarCollapseButton";
|
export { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import {
|
import {
|
||||||
AlarmClock,
|
AlarmClock,
|
||||||
FilePlus2,
|
FilePlus2,
|
||||||
|
type LucideIcon,
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
Settings2,
|
||||||
type LucideIcon,
|
|
||||||
WandSparkles,
|
WandSparkles,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -286,8 +286,8 @@ function PricingFAQ() {
|
||||||
Frequently Asked Questions
|
Frequently Asked Questions
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
||||||
Everything you need to know about SurfSense credits and billing.
|
Everything you need to know about SurfSense credits and billing. Can't find what you
|
||||||
Can't find what you need? Reach out at{" "}
|
need? Reach out at{" "}
|
||||||
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
|
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
|
||||||
rohan@surfsense.com
|
rohan@surfsense.com
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,7 @@ export function EarnCreditsContent() {
|
||||||
<div className="w-full space-y-5">
|
<div className="w-full space-y-5">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-xl font-bold tracking-tight">Earn Credits</h2>
|
<h2 className="text-xl font-bold tracking-tight">Earn Credits</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">Earn bonus credits by completing tasks</p>
|
||||||
Earn bonus credits by completing tasks
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Loader2, Plus, Trash2 } from "lucide-react";
|
import { Check, ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -15,6 +24,7 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
|
type LanguageOptions,
|
||||||
MAX_SPEAKERS,
|
MAX_SPEAKERS,
|
||||||
type PodcastSpec,
|
type PodcastSpec,
|
||||||
type PodcastStyle,
|
type PodcastStyle,
|
||||||
|
|
@ -56,6 +66,7 @@ interface BriefReviewProps {
|
||||||
export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
||||||
const [draft, setDraft] = useState<PodcastSpec>(spec);
|
const [draft, setDraft] = useState<PodcastSpec>(spec);
|
||||||
const [voices, setVoices] = useState<VoiceOption[] | null>(null);
|
const [voices, setVoices] = useState<VoiceOption[] | null>(null);
|
||||||
|
const [offering, setOffering] = useState<LanguageOptions | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// A pushed spec change (saved edit or concurrent editor) resets the form to
|
// A pushed spec change (saved edit or concurrent editor) resets the form to
|
||||||
|
|
@ -75,19 +86,26 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setVoices([]);
|
if (!cancelled) setVoices([]);
|
||||||
});
|
});
|
||||||
|
podcastsApiService
|
||||||
|
.listLanguages()
|
||||||
|
.then((options) => {
|
||||||
|
if (!cancelled) setOffering(options);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setOffering({ languages: [], allows_custom: false });
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The backend owns the offering; the draft's language stays listed even
|
||||||
|
// when it falls outside it (e.g. a custom tag entered earlier).
|
||||||
const languages = useMemo(() => {
|
const languages = useMemo(() => {
|
||||||
const tags = new Set<string>();
|
const tags = new Set(offering?.languages ?? []);
|
||||||
for (const voice of voices ?? []) {
|
|
||||||
if (voice.language !== ANY_LANGUAGE) tags.add(voice.language);
|
|
||||||
}
|
|
||||||
tags.add(draft.language);
|
tags.add(draft.language);
|
||||||
return [...tags].sort();
|
return [...tags].sort();
|
||||||
}, [voices, draft.language]);
|
}, [offering, draft.language]);
|
||||||
|
|
||||||
const voicesForLanguage = useMemo(
|
const voicesForLanguage = useMemo(
|
||||||
() => (voices ?? []).filter((voice) => speaks(voice, draft.language)),
|
() => (voices ?? []).filter((voice) => speaks(voice, draft.language)),
|
||||||
|
|
@ -193,18 +211,22 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="podcast-language">Language</Label>
|
<Label htmlFor="podcast-language">Language</Label>
|
||||||
<Select value={draft.language} onValueChange={setLanguage}>
|
{offering?.allows_custom ? (
|
||||||
<SelectTrigger id="podcast-language">
|
<LanguageCombobox value={draft.language} languages={languages} onSelect={setLanguage} />
|
||||||
<SelectValue placeholder="Language" />
|
) : (
|
||||||
</SelectTrigger>
|
<Select value={draft.language} onValueChange={setLanguage}>
|
||||||
<SelectContent>
|
<SelectTrigger id="podcast-language">
|
||||||
{languages.map((tag) => (
|
<SelectValue placeholder="Language" />
|
||||||
<SelectItem key={tag} value={tag}>
|
</SelectTrigger>
|
||||||
{languageLabel(tag)}
|
<SelectContent>
|
||||||
</SelectItem>
|
{languages.map((tag) => (
|
||||||
))}
|
<SelectItem key={tag} value={tag}>
|
||||||
</SelectContent>
|
{languageLabel(tag)}
|
||||||
</Select>
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="podcast-style">Style</Label>
|
<Label htmlFor="podcast-style">Style</Label>
|
||||||
|
|
@ -375,6 +397,80 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A searchable language picker for providers whose voices speak anything:
|
||||||
|
* the offered list comes from the backend, and any BCP-47 tag may be typed
|
||||||
|
* when none of them fits. */
|
||||||
|
function LanguageCombobox({
|
||||||
|
value,
|
||||||
|
languages,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
languages: string[];
|
||||||
|
onSelect: (language: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const pick = (tag: string) => {
|
||||||
|
onSelect(tag);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const customTag = query.trim();
|
||||||
|
const isNewTag =
|
||||||
|
customTag.length > 0 && !languages.some((tag) => tag.toLowerCase() === customTag.toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
id="podcast-language"
|
||||||
|
className="border-popover-border flex h-9 w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="line-clamp-1 text-left">{languageLabel(value)}</span>
|
||||||
|
<ChevronDown className="size-4 shrink-0 opacity-50" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search or type a language tag…"
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No matching language.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{languages.map((tag) => (
|
||||||
|
<CommandItem
|
||||||
|
key={tag}
|
||||||
|
value={tag}
|
||||||
|
keywords={[languageLabel(tag)]}
|
||||||
|
onSelect={() => pick(tag)}
|
||||||
|
>
|
||||||
|
<Check className={tag === value ? "size-4" : "size-4 opacity-0"} />
|
||||||
|
{languageLabel(tag)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
{isNewTag ? (
|
||||||
|
<CommandItem value={customTag} onSelect={() => pick(customTag)}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Use “{customTag}”
|
||||||
|
</CommandItem>
|
||||||
|
) : null}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The current selection stays listed even when it no longer matches the
|
/** The current selection stays listed even when it no longer matches the
|
||||||
* language filter, so the Select never renders an orphaned value. */
|
* language filter, so the Select never renders an orphaned value. */
|
||||||
function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {
|
function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,15 @@ export const voiceOption = z.object({
|
||||||
});
|
});
|
||||||
export type VoiceOption = z.infer<typeof voiceOption>;
|
export type VoiceOption = z.infer<typeof voiceOption>;
|
||||||
|
|
||||||
|
// The languages the backend offers for the active TTS provider. When
|
||||||
|
// `allows_custom` is true the list is a starting point and any BCP-47 tag
|
||||||
|
// may be entered.
|
||||||
|
export const languageOptions = z.object({
|
||||||
|
languages: z.array(z.string()),
|
||||||
|
allows_custom: z.boolean(),
|
||||||
|
});
|
||||||
|
export type LanguageOptions = z.infer<typeof languageOptions>;
|
||||||
|
|
||||||
export const updateSpecRequest = z.object({
|
export const updateSpecRequest = z.object({
|
||||||
spec: podcastSpec,
|
spec: podcastSpec,
|
||||||
expected_version: z.number().int().min(1),
|
expected_version: z.number().int().min(1),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
|
languageOptions,
|
||||||
type PodcastSpec,
|
type PodcastSpec,
|
||||||
podcastDetail,
|
podcastDetail,
|
||||||
updateSpecRequest,
|
updateSpecRequest,
|
||||||
|
|
@ -60,6 +61,12 @@ class PodcastsApiService {
|
||||||
return baseApiService.get(`${BASE}/voices${qs}`, voiceOptionList);
|
return baseApiService.get(`${BASE}/voices${qs}`, voiceOptionList);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The languages the active provider can offer; the brief form renders
|
||||||
|
// exactly this list and only opens free entry when the backend allows it.
|
||||||
|
listLanguages = async () => {
|
||||||
|
return baseApiService.get(`${BASE}/languages`, languageOptions);
|
||||||
|
};
|
||||||
|
|
||||||
// A short audio sample of a voice, cached server-side per voice.
|
// A short audio sample of a voice, cached server-side per voice.
|
||||||
previewVoice = async (voiceId: string) => {
|
previewVoice = async (voiceId: string) => {
|
||||||
return baseApiService.getBlob(`${BASE}/voices/${encodeURIComponent(voiceId)}/preview`);
|
return baseApiService.getBlob(`${BASE}/voices/${encodeURIComponent(voiceId)}/preview`);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense_web",
|
"name": "surfsense_web",
|
||||||
"version": "0.0.27",
|
"version": "0.0.28",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.26.0",
|
"packageManager": "pnpm@10.26.0",
|
||||||
"description": "SurfSense Frontend",
|
"description": "SurfSense Frontend",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue