From d2558e546ed3abbf2bebc5acc3a99f05152d5d37 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 19:12:09 +0200 Subject: [PATCH 01/27] feat(podcasts): add audio_exists storage helper --- surfsense_backend/app/podcasts/storage.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_backend/app/podcasts/storage.py b/surfsense_backend/app/podcasts/storage.py index f02429dff..c3326460d 100644 --- a/surfsense_backend/app/podcasts/storage.py +++ b/surfsense_backend/app/podcasts/storage.py @@ -42,6 +42,13 @@ def open_audio_stream(podcast: Podcast) -> AsyncIterator[bytes]: return get_storage_backend().open_stream(podcast.storage_key) +async def audio_exists(podcast: Podcast) -> bool: + """Whether the podcast's stored audio object is actually present.""" + return bool(podcast.storage_key) and await get_storage_backend().exists( + podcast.storage_key + ) + + async def purge_audio(podcast: Podcast) -> None: """Delete a podcast's stored audio if present; a missing object is fine.""" await purge_audio_object(podcast.storage_key) From 0c2808640a4d87341e27e3572e85cf1ebed5b51b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 19:12:09 +0200 Subject: [PATCH 02/27] fix(podcasts): guard stream against missing audio --- surfsense_backend/app/podcasts/api/routes.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py index 80e5e1c64..f55febcbd 100644 --- a/surfsense_backend/app/podcasts/api/routes.py +++ b/surfsense_backend/app/podcasts/api/routes.py @@ -27,14 +27,14 @@ from app.db import ( get_async_session, ) from app.podcasts.generation.brief import propose_brief -from app.podcasts.persistence import Podcast, PodcastRepository +from app.podcasts.persistence import Podcast, PodcastRepository, PodcastStatus from app.podcasts.service import ( InvalidTransitionError, PodcastService, PreconditionFailedError, SpecConflictError, ) -from app.podcasts.storage import open_audio_stream, purge_audio +from app.podcasts.storage import audio_exists, open_audio_stream, purge_audio from app.podcasts.tasks import draft_transcript_task from app.podcasts.tts import get_text_to_speech from app.podcasts.voices import ( @@ -272,6 +272,11 @@ async def stream_podcast( podcast = await _load(session, user, podcast_id, Permission.PODCASTS_READ) if podcast.storage_key: + # Verify first so a missing object is a 404, not a mid-stream crash. + if not await audio_exists(podcast): + raise HTTPException( + status_code=404, detail="Podcast audio is no longer available" + ) return StreamingResponse( open_audio_stream(podcast), media_type="audio/mpeg", @@ -295,7 +300,10 @@ async def stream_podcast( }, ) - raise HTTPException(status_code=404, detail="Podcast audio not found") + # No audio: terminal states never will have any, otherwise it's in flight. + if PodcastStatus(podcast.status).is_terminal: + raise HTTPException(status_code=404, detail="Podcast audio not found") + raise HTTPException(status_code=409, detail="Podcast audio is not ready yet") async def _require( From 1d70af46846292e139fa93322accc2da625b7444 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 19:12:09 +0200 Subject: [PATCH 03/27] fix(podcasts): guard public stream against missing audio --- surfsense_backend/app/routes/public_chat_routes.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py index 53f4c2651..516e976e6 100644 --- a/surfsense_backend/app/routes/public_chat_routes.py +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -103,8 +103,14 @@ async def stream_public_podcast( if storage_key: from app.file_storage.factory import get_storage_backend + backend = get_storage_backend() + # Verify first so a missing object is a 404, not a mid-stream crash. + if not await backend.exists(storage_key): + raise HTTPException( + status_code=404, detail="Podcast audio is no longer available" + ) return StreamingResponse( - get_storage_backend().open_stream(storage_key), + backend.open_stream(storage_key), media_type="audio/mpeg", headers={"Accept-Ranges": "bytes"}, ) From 86a8833fb48adf83b526014f9a1839ffcf202fd5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 19:12:09 +0200 Subject: [PATCH 04/27] test(podcasts): add exists to fake storage backend --- surfsense_backend/tests/integration/podcasts/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/surfsense_backend/tests/integration/podcasts/conftest.py b/surfsense_backend/tests/integration/podcasts/conftest.py index f244c17d2..240f57f96 100644 --- a/surfsense_backend/tests/integration/podcasts/conftest.py +++ b/surfsense_backend/tests/integration/podcasts/conftest.py @@ -120,6 +120,9 @@ class FakeStorageBackend: async def open_stream(self, key: str) -> AsyncIterator[bytes]: yield self.objects.get(key, b"audio-bytes") + async def exists(self, key: str) -> bool: + return key in self.objects + async def delete(self, key: str) -> None: self.deleted.append(key) From 810ded2dde8b2dabcd1ec67c955dbf6c56167943 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 19:12:09 +0200 Subject: [PATCH 05/27] test(podcasts): cover in-flight 409 and missing-object 404 --- .../integration/podcasts/test_streaming.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/tests/integration/podcasts/test_streaming.py b/surfsense_backend/tests/integration/podcasts/test_streaming.py index 82456bac9..b924e2971 100644 --- a/surfsense_backend/tests/integration/podcasts/test_streaming.py +++ b/surfsense_backend/tests/integration/podcasts/test_streaming.py @@ -1,8 +1,7 @@ """Streaming a podcast's rendered audio over HTTP. -A ready podcast streams its bytes from the storage backend; a podcast with no -stored audio returns 404. Storage is an in-memory backend (the object store is a -system boundary). +A ready podcast streams its bytes; an in-flight one is 409, a stored-but-missing +object is 404. Storage is an in-memory backend (the object store is a boundary). """ from __future__ import annotations @@ -31,11 +30,23 @@ async def test_stream_serves_stored_audio( assert resp.content == b"the-audio" -async def test_stream_404_when_no_audio(client, db_search_space, make_podcast): +async def test_stream_409_while_in_flight(client, db_search_space, make_podcast): podcast = await make_podcast( search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING ) resp = await client.get(f"{BASE}/{podcast.id}/stream") + assert resp.status_code == 409 + + +async def test_stream_404_when_object_missing( + client, db_search_space, make_podcast, fake_storage +): + podcast = await make_podcast( + search_space_id=db_search_space.id, status=PodcastStatus.READY + ) + + resp = await client.get(f"{BASE}/{podcast.id}/stream") + assert resp.status_code == 404 From 1048d0afc30b07a2b8c54a7489e3ef5bb4c66ebd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 19:12:09 +0200 Subject: [PATCH 06/27] test(podcasts): cover public stream missing-object 404 --- .../integration/podcasts/test_public_stream.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/surfsense_backend/tests/integration/podcasts/test_public_stream.py b/surfsense_backend/tests/integration/podcasts/test_public_stream.py index d2ba1d1b9..63f634234 100644 --- a/surfsense_backend/tests/integration/podcasts/test_public_stream.py +++ b/surfsense_backend/tests/integration/podcasts/test_public_stream.py @@ -48,6 +48,22 @@ async def test_public_stream_serves_audio_via_storage_key( assert resp.content == b"public-audio" +async def test_public_stream_404_when_object_missing( + client, db_session, db_search_space, db_user, fake_storage +): + await _snapshot( + db_session, + search_space_id=db_search_space.id, + user=db_user, + token="tok-gone", + podcasts=[{"original_id": 556, "storage_key": "podcasts/gone.mp3"}], + ) + + resp = await client.get("/api/v1/public/tok-gone/podcasts/556/stream") + + assert resp.status_code == 404 + + async def test_public_stream_404_when_podcast_absent_from_snapshot( client, db_session, db_search_space, db_user ): From fc045d200d01534bf890f61ceaf7aae6a0edf998 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 19:17:16 +0200 Subject: [PATCH 07/27] fix(docker): share persistent object_store volume across services --- docker/docker-compose.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9bbf28ffd..dc0d8b3ae 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -100,6 +100,7 @@ services: - "${BACKEND_PORT:-8929}:8000" volumes: - shared_temp:/shared_tmp + - object_store:/app/.local_object_store env_file: - .env extra_hosts: @@ -113,6 +114,7 @@ services: PYTHONPATH: /app UVICORN_LOOP: asyncio UNSTRUCTURED_HAS_PATCHED_LOOP: "1" + FILE_STORAGE_LOCAL_PATH: /app/.local_object_store NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}} SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080} WHATSAPP_BRIDGE_URL: ${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929} @@ -165,6 +167,7 @@ services: image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}${SURFSENSE_VARIANT:+-${SURFSENSE_VARIANT}} volumes: - shared_temp:/shared_tmp + - object_store:/app/.local_object_store env_file: - .env extra_hosts: @@ -176,6 +179,7 @@ services: REDIS_APP_URL: ${REDIS_URL:-redis://redis:6379/0} CELERY_TASK_DEFAULT_QUEUE: surfsense PYTHONPATH: /app + FILE_STORAGE_LOCAL_PATH: /app/.local_object_store SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080} SERVICE_ROLE: worker depends_on: @@ -278,6 +282,8 @@ volumes: name: surfsense-redis shared_temp: name: surfsense-shared-temp + object_store: + name: surfsense-object-store zero_cache_data: name: surfsense-zero-cache whatsapp_sessions: From a7be41d50a93952b7a4729f319d7768ede1d35ba Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 19:17:16 +0200 Subject: [PATCH 08/27] fix(docker): share persistent object_store volume in dev --- docker/docker-compose.dev.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 35effefc0..e8c9f96a9 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -106,6 +106,7 @@ services: volumes: - ../surfsense_backend/app:/app/app - shared_temp:/shared_tmp + - object_store:/app/.local_object_store env_file: - ../surfsense_backend/.env extra_hosts: @@ -119,6 +120,7 @@ services: - PYTHONPATH=/app - UVICORN_LOOP=asyncio - UNSTRUCTURED_HAS_PATCHED_LOOP=1 + - FILE_STORAGE_LOCAL_PATH=/app/.local_object_store - LANGCHAIN_TRACING_V2=false - LANGSMITH_TRACING=false - AUTH_TYPE=${AUTH_TYPE:-LOCAL} @@ -171,6 +173,7 @@ services: volumes: - ../surfsense_backend/app:/app/app - shared_temp:/shared_tmp + - object_store:/app/.local_object_store env_file: - ../surfsense_backend/.env extra_hosts: @@ -182,6 +185,7 @@ services: - REDIS_APP_URL=${REDIS_URL:-redis://redis:6379/0} - CELERY_TASK_DEFAULT_QUEUE=surfsense - PYTHONPATH=/app + - FILE_STORAGE_LOCAL_PATH=/app/.local_object_store - SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080} - SERVICE_ROLE=worker depends_on: @@ -278,6 +282,8 @@ volumes: name: surfsense-dev-redis shared_temp: name: surfsense-dev-shared-temp + object_store: + name: surfsense-dev-object-store zero_cache_data: name: surfsense-dev-zero-cache whatsapp_sessions: From 9583e8f250ac67cfafc03cc9f4fdcb7f83252d6f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 23:38:27 +0200 Subject: [PATCH 09/27] feat(podcasts): add shared duration limit constants --- surfsense_backend/app/podcasts/duration_limits.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 surfsense_backend/app/podcasts/duration_limits.py diff --git a/surfsense_backend/app/podcasts/duration_limits.py b/surfsense_backend/app/podcasts/duration_limits.py new file mode 100644 index 000000000..fc7d29890 --- /dev/null +++ b/surfsense_backend/app/podcasts/duration_limits.py @@ -0,0 +1,6 @@ +"""Shared bounds and defaults for podcast target duration.""" + +MAX_DURATION_SECONDS = 24 * 60 * 60 +MIN_DURATION_SECONDS = 15 +DEFAULT_MIN_SECONDS = 20 +DEFAULT_MAX_SECONDS = 30 From 32e0d2160416999964d7886cb70685a589e566b8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 23:38:27 +0200 Subject: [PATCH 10/27] feat(podcasts): store brief duration in seconds with legacy load --- .../app/podcasts/schemas/spec.py | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/surfsense_backend/app/podcasts/schemas/spec.py b/surfsense_backend/app/podcasts/schemas/spec.py index 1ef3dcfff..2f7df84c7 100644 --- a/surfsense_backend/app/podcasts/schemas/spec.py +++ b/surfsense_backend/app/podcasts/schemas/spec.py @@ -10,17 +10,21 @@ from __future__ import annotations import re from enum import StrEnum +from typing import Any + +from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from app.podcasts.duration_limits import ( + MAX_DURATION_SECONDS, + MIN_DURATION_SECONDS, +) + # A speaker count beyond this is almost never a real podcast and explodes the # voice/turn-attribution space, so we reject it at the brief gate. MAX_SPEAKERS = 6 -# Long-form is a goal, but an open-ended upper bound invites runaway TTS bills. -# One day of audio is a generous ceiling that still blocks obvious mistakes. -MAX_DURATION_MINUTES = 24 * 60 - # BCP-47 primary subtag plus optional region (e.g. ``en``, ``en-US``, ``pt-BR``). # Kept deliberately permissive: the voice catalog, not the brief, decides which # languages can actually be synthesised. Casing is normalised after matching. @@ -91,7 +95,7 @@ class SpeakerSpec(BaseModel): class DurationTarget(BaseModel): - """The desired finished length as an inclusive minute range. + """The desired finished length as an inclusive second range. Drafting aims for the midpoint and treats the bounds as soft guardrails; storing a range (rather than a point) keeps long-form expectations honest @@ -100,19 +104,34 @@ class DurationTarget(BaseModel): model_config = ConfigDict(extra="forbid") - min_minutes: int = Field(..., ge=1, le=MAX_DURATION_MINUTES) - max_minutes: int = Field(..., ge=1, le=MAX_DURATION_MINUTES) + min_seconds: int = Field(..., ge=MIN_DURATION_SECONDS, le=MAX_DURATION_SECONDS) + max_seconds: int = Field(..., ge=MIN_DURATION_SECONDS, le=MAX_DURATION_SECONDS) + + @model_validator(mode="before") + @classmethod + def _coerce_legacy_minutes(cls, data: Any) -> Any: + """Rows stored before seconds-based briefs still load from JSONB.""" + if isinstance(data, dict) and "min_seconds" not in data and "min_minutes" in data: + migrated = dict(data) + migrated["min_seconds"] = int(migrated.pop("min_minutes")) * 60 + migrated["max_seconds"] = int(migrated.pop("max_minutes")) * 60 + return migrated + return data @model_validator(mode="after") def _check_order(self) -> DurationTarget: - if self.max_minutes < self.min_minutes: - raise ValueError("max_minutes must be >= min_minutes") + if self.max_seconds < self.min_seconds: + raise ValueError("max_seconds must be >= min_seconds") return self @property - def midpoint_minutes(self) -> float: + def midpoint_seconds(self) -> float: """The runtime drafting should aim for within the range.""" - return (self.min_minutes + self.max_minutes) / 2 + return (self.min_seconds + self.max_seconds) / 2 + + @property + def midpoint_minutes(self) -> float: + return self.midpoint_seconds / 60 class PodcastSpec(BaseModel): From 085442ed9aafa2e0f75850d27560a0d810b2408b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 23:38:27 +0200 Subject: [PATCH 11/27] feat(podcasts): use seconds defaults on create podcast request --- surfsense_backend/app/podcasts/api/schemas.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/app/podcasts/api/schemas.py b/surfsense_backend/app/podcasts/api/schemas.py index 7f1f8cc7c..72c2a5f7a 100644 --- a/surfsense_backend/app/podcasts/api/schemas.py +++ b/surfsense_backend/app/podcasts/api/schemas.py @@ -11,6 +11,12 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field +from app.podcasts.duration_limits import ( + DEFAULT_MAX_SECONDS, + DEFAULT_MIN_SECONDS, + MAX_DURATION_SECONDS, + MIN_DURATION_SECONDS, +) from app.podcasts.persistence import Podcast, PodcastStatus from app.podcasts.schemas import PodcastSpec, Transcript from app.podcasts.service import has_stored_episode, read_spec, read_transcript @@ -18,8 +24,6 @@ from app.podcasts.service import has_stored_episode, read_spec, read_transcript # Defaults applied when a create request omits brief sizing; the brief gate lets # the user adjust before any cost is incurred. DEFAULT_SPEAKER_COUNT = 2 -DEFAULT_MIN_MINUTES = 10 -DEFAULT_MAX_MINUTES = 20 class CreatePodcastRequest(BaseModel): @@ -30,8 +34,16 @@ class CreatePodcastRequest(BaseModel): source_content: str = Field(..., min_length=1) thread_id: int | None = None speaker_count: int = Field(default=DEFAULT_SPEAKER_COUNT, ge=1, le=6) - min_minutes: int = Field(default=DEFAULT_MIN_MINUTES, ge=1) - max_minutes: int = Field(default=DEFAULT_MAX_MINUTES, ge=1) + min_seconds: int = Field( + default=DEFAULT_MIN_SECONDS, + ge=MIN_DURATION_SECONDS, + le=MAX_DURATION_SECONDS, + ) + max_seconds: int = Field( + default=DEFAULT_MAX_SECONDS, + ge=MIN_DURATION_SECONDS, + le=MAX_DURATION_SECONDS, + ) focus: str | None = Field(default=None, max_length=2000) From 845653cbacced01c9978b446e4de89b6e3e5d4cd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 23:38:27 +0200 Subject: [PATCH 12/27] feat(podcasts): pass min_seconds and max_seconds when proposing brief --- surfsense_backend/app/podcasts/api/routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py index f55febcbd..f2d0c6b47 100644 --- a/surfsense_backend/app/podcasts/api/routes.py +++ b/surfsense_backend/app/podcasts/api/routes.py @@ -157,8 +157,8 @@ async def create_podcast( session, search_space_id=body.search_space_id, speaker_count=body.speaker_count, - min_minutes=body.min_minutes, - max_minutes=body.max_minutes, + min_seconds=body.min_seconds, + max_seconds=body.max_seconds, focus=body.focus, ) await service.attach_brief(podcast, spec) From d0ed5b94d97cc89c966d365e65003205b8970b84 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 23:38:28 +0200 Subject: [PATCH 13/27] refactor(podcasts): use shared second-based brief duration defaults --- .../app/podcasts/generation/brief/config.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/surfsense_backend/app/podcasts/generation/brief/config.py b/surfsense_backend/app/podcasts/generation/brief/config.py index 4f92585ae..9b206bde4 100644 --- a/surfsense_backend/app/podcasts/generation/brief/config.py +++ b/surfsense_backend/app/podcasts/generation/brief/config.py @@ -6,10 +6,13 @@ from dataclasses import dataclass, field, fields from langchain_core.runnables import RunnableConfig +from app.podcasts.duration_limits import ( + DEFAULT_MAX_SECONDS, + DEFAULT_MIN_SECONDS, +) + # Sensible defaults for a fresh brief; the user adjusts the range at the gate. DEFAULT_SPEAKER_COUNT = 2 -DEFAULT_MIN_MINUTES = 10 -DEFAULT_MAX_MINUTES = 20 @dataclass(kw_only=True) @@ -17,8 +20,8 @@ class BriefConfig: """Signals used to propose a brief; everything here is non-LLM context.""" speaker_count: int = DEFAULT_SPEAKER_COUNT - min_minutes: int = DEFAULT_MIN_MINUTES - max_minutes: int = DEFAULT_MAX_MINUTES + min_seconds: int = DEFAULT_MIN_SECONDS + max_seconds: int = DEFAULT_MAX_SECONDS focus: str | None = None last_used_language: str | None = None last_used_voices: list[str] = field(default_factory=list) From af08e2f0331222f4bfaef0ee10b317fedd56b7ff Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 23:38:28 +0200 Subject: [PATCH 14/27] refactor(podcasts): propose brief with min_seconds and max_seconds --- .../app/podcasts/generation/brief/propose.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/surfsense_backend/app/podcasts/generation/brief/propose.py b/surfsense_backend/app/podcasts/generation/brief/propose.py index 17344702b..09d74840e 100644 --- a/surfsense_backend/app/podcasts/generation/brief/propose.py +++ b/surfsense_backend/app/podcasts/generation/brief/propose.py @@ -4,11 +4,12 @@ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession +from app.podcasts.duration_limits import DEFAULT_MAX_SECONDS, DEFAULT_MIN_SECONDS from app.podcasts.persistence import PodcastRepository from app.podcasts.schemas import PodcastSpec from app.podcasts.service import preferences_from -from .config import DEFAULT_MAX_MINUTES, DEFAULT_MIN_MINUTES, DEFAULT_SPEAKER_COUNT +from .config import DEFAULT_SPEAKER_COUNT from .graph import graph as brief_graph from .state import BriefState @@ -18,8 +19,8 @@ async def propose_brief( *, search_space_id: int, speaker_count: int = DEFAULT_SPEAKER_COUNT, - min_minutes: int = DEFAULT_MIN_MINUTES, - max_minutes: int = DEFAULT_MAX_MINUTES, + min_seconds: int = DEFAULT_MIN_SECONDS, + max_seconds: int = DEFAULT_MAX_SECONDS, focus: str | None = None, ) -> PodcastSpec: """Reuse the last-used language and voices, else English; return the spec.""" @@ -29,8 +30,8 @@ async def propose_brief( config = { "configurable": { "speaker_count": speaker_count, - "min_minutes": min_minutes, - "max_minutes": max_minutes, + "min_seconds": min_seconds, + "max_seconds": max_seconds, "focus": focus, "last_used_language": last_language, "last_used_voices": last_voices, From 116c38feac5a4a1ab52fd89dba1245cd337d5a00 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 23:38:28 +0200 Subject: [PATCH 15/27] refactor(podcasts): build DurationTarget from brief seconds config --- surfsense_backend/app/podcasts/generation/brief/nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_backend/app/podcasts/generation/brief/nodes.py b/surfsense_backend/app/podcasts/generation/brief/nodes.py index c0a6f1ae1..de6a9717e 100644 --- a/surfsense_backend/app/podcasts/generation/brief/nodes.py +++ b/surfsense_backend/app/podcasts/generation/brief/nodes.py @@ -79,7 +79,7 @@ def propose_spec(state: BriefState, config: RunnableConfig) -> dict[str, Any]: style=PodcastStyle.CONVERSATIONAL, speakers=speakers, duration=DurationTarget( - min_minutes=brief.min_minutes, max_minutes=brief.max_minutes + min_seconds=brief.min_seconds, max_seconds=brief.max_seconds ), focus=brief.focus, ) From 16d226e5ce47e689981641615ae0dbb328a0c8ba Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 23:38:28 +0200 Subject: [PATCH 16/27] refactor(podcasts): plan transcript length from midpoint seconds --- surfsense_backend/app/podcasts/generation/transcript/nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_backend/app/podcasts/generation/transcript/nodes.py b/surfsense_backend/app/podcasts/generation/transcript/nodes.py index 44d6b219d..7b472348d 100644 --- a/surfsense_backend/app/podcasts/generation/transcript/nodes.py +++ b/surfsense_backend/app/podcasts/generation/transcript/nodes.py @@ -38,7 +38,7 @@ async def plan_outline( tc = TranscriptConfig.from_runnable_config(config) llm = await _require_llm(state, tc) - target_words = round(tc.spec.duration.midpoint_minutes * _WORDS_PER_MINUTE) + target_words = round(tc.spec.duration.midpoint_seconds * _WORDS_PER_MINUTE / 60) suggested_segments = max(1, round(target_words / _WORDS_PER_SEGMENT)) messages = [ From 608620d6490f2151027e6488edf43b93b31763a6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 23:38:28 +0200 Subject: [PATCH 17/27] feat(web): add seconds-based podcast duration types with legacy support --- .../contracts/types/podcast.types.ts | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts index e6332d5b2..6ceb18dfc 100644 --- a/surfsense_web/contracts/types/podcast.types.ts +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -47,6 +47,11 @@ export type PodcastStyle = z.infer; export const MAX_SPEAKERS = 6; +export const MAX_DURATION_SECONDS = 24 * 60 * 60; +export const MIN_DURATION_SECONDS = 15; +export const DEFAULT_MIN_SECONDS = 20; +export const DEFAULT_MAX_SECONDS = 30; + export const speakerSpec = z.object({ slot: z.number().int().min(0), name: z.string().min(1).max(120), @@ -55,10 +60,40 @@ export const speakerSpec = z.object({ }); export type SpeakerSpec = z.infer; -export const durationTarget = z.object({ - min_minutes: z.number().int().min(1), - max_minutes: z.number().int().min(1), -}); +export const durationTarget = z.preprocess( + (raw) => { + if ( + raw && + typeof raw === "object" && + "min_minutes" in raw && + !("min_seconds" in raw) + ) { + const legacy = raw as { min_minutes: number; max_minutes: number }; + return { + min_seconds: legacy.min_minutes * 60, + max_seconds: legacy.max_minutes * 60, + }; + } + return raw; + }, + z + .object({ + min_seconds: z + .number() + .int() + .min(MIN_DURATION_SECONDS) + .max(MAX_DURATION_SECONDS), + max_seconds: z + .number() + .int() + .min(MIN_DURATION_SECONDS) + .max(MAX_DURATION_SECONDS), + }) + .refine((duration) => duration.max_seconds >= duration.min_seconds, { + message: "Max length must be at least min length", + path: ["max_seconds"], + }), +); export type DurationTarget = z.infer; export const podcastSpec = z From bab3f7c0d49288c3dc9366974077639e6657de9e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Jun 2026 23:38:28 +0200 Subject: [PATCH 18/27] feat(web): add unit dropdown for podcast brief target length --- .../tool-ui/podcast/brief-review.tsx | 151 ++++++++++++++---- 1 file changed, 118 insertions(+), 33 deletions(-) diff --git a/surfsense_web/components/tool-ui/podcast/brief-review.tsx b/surfsense_web/components/tool-ui/podcast/brief-review.tsx index 3473b64d6..d3962374a 100644 --- a/surfsense_web/components/tool-ui/podcast/brief-review.tsx +++ b/surfsense_web/components/tool-ui/podcast/brief-review.tsx @@ -15,7 +15,9 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { + MAX_DURATION_SECONDS, MAX_SPEAKERS, + MIN_DURATION_SECONDS, type PodcastSpec, type PodcastStyle, podcastStyle, @@ -55,6 +57,9 @@ interface BriefReviewProps { */ export function BriefReview({ podcast, spec }: BriefReviewProps) { const [draft, setDraft] = useState(spec); + const [durationUnit, setDurationUnit] = useState(() => + defaultDurationUnit(spec.duration.max_seconds), + ); const [voices, setVoices] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -63,6 +68,7 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) { // biome-ignore lint/correctness/useExhaustiveDependencies: reset only when the server version moves useEffect(() => { setDraft(spec); + setDurationUnit(defaultDurationUnit(spec.duration.max_seconds)); }, [podcast.specVersion]); useEffect(() => { @@ -304,39 +310,72 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) { ))} -
-
- - - setDraft((current) => ({ - ...current, - duration: { ...current.duration, min_minutes: Number(e.target.value) || 1 }, - })) - } - /> +
+
+ +
-
- - - setDraft((current) => ({ - ...current, - duration: { - ...current.duration, - max_minutes: Number(e.target.value) || current.duration.min_minutes, - }, - })) - } - /> +
+
+ + { + const seconds = clampDurationSeconds( + fromUnitValue(Number(e.target.value), durationUnit), + ); + setDraft((current) => ({ + ...current, + duration: { ...current.duration, min_seconds: seconds }, + })); + }} + /> +
+
+ + { + const parsed = Number(e.target.value); + const fallback = secondsToUnitValue( + draft.duration.min_seconds, + durationUnit, + ); + const seconds = clampDurationSeconds( + fromUnitValue( + Number.isFinite(parsed) ? parsed : fallback, + durationUnit, + ), + ); + setDraft((current) => ({ + ...current, + duration: { ...current.duration, max_seconds: seconds }, + })); + }} + /> +
@@ -365,7 +404,9 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {