From 0a2c856288fae7d50c2b306d215d129c8a67a870 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 13 Aug 2026 20:52:59 +0200 Subject: [PATCH] feat(podcasts): align delivery to the Artifact model Rename podcasts -> podcast_runs and treat the run row as job state only: delivered audio and its markdown live in the Artifact, so the row drops storage_backend/storage_key/file_location and gains artifact_id. mark_ready commits status and artifact_id atomically, preserving the READY <-> artifact_id invariant that Zero publishes. Public shares stream audio through the artifact content route and get artifact_id + workspace_id injected at snapshot time; the authenticated player and library read artifactId from Zero. Retire the legacy authenticated stream route, the podcast storage helpers, and the REST list endpoint. --- .../versions/182_add_podcast_artifact_id.py | 28 +++++ .../183_rename_podcasts_to_podcast_runs.py | 72 ++++++++++++ .../app/artifacts/media/podcast/__init__.py | 6 +- .../app/artifacts/media/podcast/record.py | 5 +- .../app/artifacts/media/podcast/storage.py | 54 --------- surfsense_backend/app/podcasts/api/routes.py | 93 +--------------- surfsense_backend/app/podcasts/api/schemas.py | 25 +---- .../app/podcasts/persistence/models.py | 22 ++-- surfsense_backend/app/podcasts/service.py | 20 ++-- .../app/podcasts/tasks/render.py | 104 ++++++++---------- .../app/services/public_chat_service.py | 55 +++------ surfsense_backend/app/zero_publication.py | 5 +- .../tests/integration/podcasts/conftest.py | 35 ++++-- .../integration/podcasts/test_regeneration.py | 1 - .../integration/podcasts/test_render_task.py | 60 +++++----- .../integration/podcasts/test_scoping.py | 17 +-- .../integration/podcasts/test_streaming.py | 52 --------- .../tests/unit/podcasts/test_api_schemas.py | 21 +--- .../services/test_public_chat_artifacts.py | 22 ++++ .../tool-ui/podcast/generate-podcast.tsx | 37 +------ .../components/tool-ui/podcast/player.tsx | 41 +++---- .../contracts/types/podcast.types.ts | 13 --- .../hooks/use-library-artifacts.ts | 34 +----- .../hooks/use-library-podcast-runs.ts | 48 ++++++++ .../ui/artifacts-library.tsx | 8 +- .../ui/media-viewer-dialog.tsx | 20 ++-- surfsense_web/hooks/use-podcast-live.ts | 8 +- .../lib/apis/podcasts-api.service.ts | 9 -- surfsense_web/zero/queries/index.ts | 4 +- .../queries/{podcasts.ts => podcast-runs.ts} | 6 +- surfsense_web/zero/schema/index.ts | 4 +- .../schema/{podcasts.ts => podcast-runs.ts} | 3 +- 32 files changed, 385 insertions(+), 547 deletions(-) create mode 100644 surfsense_backend/alembic/versions/182_add_podcast_artifact_id.py create mode 100644 surfsense_backend/alembic/versions/183_rename_podcasts_to_podcast_runs.py delete mode 100644 surfsense_backend/app/artifacts/media/podcast/storage.py delete mode 100644 surfsense_backend/tests/integration/podcasts/test_streaming.py create mode 100644 surfsense_web/features/artifacts-library/hooks/use-library-podcast-runs.ts rename surfsense_web/zero/queries/{podcasts.ts => podcast-runs.ts} (75%) rename surfsense_web/zero/schema/{podcasts.ts => podcast-runs.ts} (86%) diff --git a/surfsense_backend/alembic/versions/182_add_podcast_artifact_id.py b/surfsense_backend/alembic/versions/182_add_podcast_artifact_id.py new file mode 100644 index 000000000..d12f63fb2 --- /dev/null +++ b/surfsense_backend/alembic/versions/182_add_podcast_artifact_id.py @@ -0,0 +1,28 @@ +"""Add podcasts.artifact_id pointing at the delivered Artifact. + +Add-only. NULL means no Artifact yet; the render task and the backfill fill it. + +Revision ID: 182 +Revises: 181 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "182" +down_revision: str | None = "181" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE podcasts " + "ADD COLUMN IF NOT EXISTS artifact_id INTEGER " + "REFERENCES artifacts(id) ON DELETE SET NULL" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS artifact_id") diff --git a/surfsense_backend/alembic/versions/183_rename_podcasts_to_podcast_runs.py b/surfsense_backend/alembic/versions/183_rename_podcasts_to_podcast_runs.py new file mode 100644 index 000000000..326d0fb8c --- /dev/null +++ b/surfsense_backend/alembic/versions/183_rename_podcasts_to_podcast_runs.py @@ -0,0 +1,72 @@ +"""Rename podcasts to podcast_runs, drop the row's audio columns, publish to Zero. + +The delivered audio now lives in the Artifact referenced by ``artifact_id``. Run +``backfill_podcast_artifacts.py --yes`` before this so no episode is lost. + +Guarded like 180: refuses while any READY row has no Artifact. Renames the +table, drops ``storage_backend`` / ``storage_key`` / ``file_location``, and +reconciles ``zero_publication`` so runs reach the UI by push instead of polling. + +Revision ID: 183 +Revises: 182 +""" + +from collections.abc import Sequence + +from sqlalchemy import text + +from alembic import op +from app.zero_publication import apply_publication + +revision: str = "183" +down_revision: str | None = "182" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + pending = ( + op.get_bind() + .execute( + text( + "SELECT count(*) FROM podcasts " + "WHERE status = 'ready' AND artifact_id IS NULL" + ) + ) + .scalar() + ) + if pending: + raise RuntimeError( + f"{pending} READY podcasts row(s) have no Artifact. " + "Run `python -m scripts.backfill_podcast_artifacts --yes` before this " + "migration, or their audio will be lost." + ) + + op.execute("ALTER TABLE podcasts RENAME TO podcast_runs") + op.execute("ALTER TABLE podcast_runs DROP COLUMN IF EXISTS storage_backend") + op.execute("ALTER TABLE podcast_runs DROP COLUMN IF EXISTS storage_key") + op.execute("ALTER TABLE podcast_runs DROP COLUMN IF EXISTS file_location") + apply_publication(op.get_bind()) + + +def downgrade() -> None: + # A published table's columns can't be dropped while the publication + # depends on them, so release it first. + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'zero_publication' + AND tablename = 'podcast_runs' + ) THEN + ALTER PUBLICATION zero_publication DROP TABLE podcast_runs; + END IF; + END $$; + """ + ) + op.execute("ALTER TABLE podcast_runs ADD COLUMN IF NOT EXISTS storage_backend VARCHAR(32)") + op.execute("ALTER TABLE podcast_runs ADD COLUMN IF NOT EXISTS storage_key TEXT") + op.execute("ALTER TABLE podcast_runs ADD COLUMN IF NOT EXISTS file_location TEXT") + op.execute("ALTER TABLE podcast_runs RENAME TO podcasts") diff --git a/surfsense_backend/app/artifacts/media/podcast/__init__.py b/surfsense_backend/app/artifacts/media/podcast/__init__.py index 428371e88..12bbb527d 100644 --- a/surfsense_backend/app/artifacts/media/podcast/__init__.py +++ b/surfsense_backend/app/artifacts/media/podcast/__init__.py @@ -1,7 +1,7 @@ -"""Podcast media → Artifact.""" +"""Podcast media -> Artifact.""" from __future__ import annotations -from app.artifacts.media.podcast.storage import open_stream, purge, persist +from app.artifacts.media.podcast.record import record -__all__ = ["open_stream", "purge", "persist"] +__all__ = ["record"] diff --git a/surfsense_backend/app/artifacts/media/podcast/record.py b/surfsense_backend/app/artifacts/media/podcast/record.py index bdfb68e8f..de20ad0d7 100644 --- a/surfsense_backend/app/artifacts/media/podcast/record.py +++ b/surfsense_backend/app/artifacts/media/podcast/record.py @@ -1,7 +1,4 @@ -"""Record a finished podcast as an Artifact. - -Audio blobs live in ``app.artifacts.media.podcast.storage``; this writes the Artifact. -""" +"""Record a finished podcast as an Artifact; the Artifact owns the audio blob.""" from __future__ import annotations diff --git a/surfsense_backend/app/artifacts/media/podcast/storage.py b/surfsense_backend/app/artifacts/media/podcast/storage.py deleted file mode 100644 index 6583f6094..000000000 --- a/surfsense_backend/app/artifacts/media/podcast/storage.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Durable object-store helpers for podcast audio.""" - -from __future__ import annotations - -import uuid -from collections.abc import AsyncIterator -from typing import Any - -from app.file_storage.factory import get_storage_backend - -_AUDIO_CONTENT_TYPE = "audio/mpeg" - - -def build_audio_key(*, workspace_id: int, podcast_id: int) -> str: - """Object key: ``podcasts/{workspace_id}/{podcast_id}/{uuid}.mp3``.""" - return f"podcasts/{workspace_id}/{podcast_id}/{uuid.uuid4().hex}.mp3" - - -async def persist( - *, workspace_id: int, podcast_id: int, data: bytes -) -> tuple[str, str]: - """Persist audio bytes; return ``(backend_name, storage_key)``.""" - backend = get_storage_backend() - key = build_audio_key(workspace_id=workspace_id, podcast_id=podcast_id) - await backend.put(key, data, content_type=_AUDIO_CONTENT_TYPE) - return backend.backend_name, key - - -def open_stream(storage_key: str) -> AsyncIterator[bytes]: - return get_storage_backend().open_stream(storage_key) - - -def open_podcast_stream(podcast: Any) -> AsyncIterator[bytes]: - """Stream a ready podcast's audio. Raises if it has no ``storage_key``.""" - if not podcast.storage_key: - raise FileNotFoundError(f"podcast {podcast.id} has no stored audio") - return open_stream(podcast.storage_key) - - -async def exists(podcast: Any) -> bool: - return bool(podcast.storage_key) and await get_storage_backend().exists( - podcast.storage_key - ) - - -async def purge(podcast: Any) -> None: - """Delete a podcast's stored audio if present.""" - await purge_key(podcast.storage_key) - - -async def purge_key(key: str | None) -> None: - """Delete a stored audio object by key (e.g. superseded on re-render).""" - if key: - await get_storage_backend().delete(key) diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py index 98b7dd373..8dfa4081b 100644 --- a/surfsense_backend/app/podcasts/api/routes.py +++ b/surfsense_backend/app/podcasts/api/routes.py @@ -8,37 +8,26 @@ then enqueues the matching Celery task; lifecycle errors map to 409/422. from __future__ import annotations -import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Response -from fastapi.responses import StreamingResponse -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.context import AuthContext from app.config import config as app_config from app.db import ( Permission, - Workspace, - WorkspaceMembership, get_async_session, ) from app.podcasts.generation.brief import propose_brief -from app.podcasts.persistence import Podcast, PodcastRepository, PodcastStatus +from app.podcasts.persistence import Podcast, PodcastRepository from app.podcasts.service import ( InvalidTransitionError, PodcastService, PreconditionFailedError, SpecConflictError, ) -from app.artifacts.media.podcast.storage import ( - exists as audio_exists, - open_podcast_stream, - purge, -) from app.podcasts.tasks import draft_transcript_task from app.podcasts.tts import get_text_to_speech from app.podcasts.voices import ( @@ -53,7 +42,6 @@ from .schemas import ( CreatePodcastRequest, LanguageOptions, PodcastDetail, - PodcastSummary, UpdateSpecRequest, VoiceOption, ) @@ -61,41 +49,6 @@ from .schemas import ( router = APIRouter() -@router.get("/podcasts", response_model=list[PodcastSummary]) -async def list_podcasts( - workspace_id: int | None = None, - skip: int = 0, - limit: int = 100, - session: AsyncSession = Depends(get_async_session), - auth: AuthContext = Depends(get_auth_context), -): - user = auth.user - if skip < 0 or limit < 1: - raise HTTPException(status_code=400, detail="Invalid pagination parameters") - - if workspace_id is not None: - await _require(session, auth, workspace_id, Permission.PODCASTS_READ) - query = ( - select(Podcast) - .where(Podcast.workspace_id == workspace_id) - .order_by(Podcast.created_at.desc()) - .offset(skip) - .limit(limit) - ) - else: - query = ( - select(Podcast) - .join(Workspace) - .join(WorkspaceMembership) - .where(WorkspaceMembership.user_id == user.id) - .order_by(Podcast.created_at.desc()) - .offset(skip) - .limit(limit) - ) - result = await session.execute(query) - return list(result.scalars().all()) - - @router.get("/podcasts/voices", response_model=list[VoiceOption]) async def list_voices(language: str | None = None): """Voices the active TTS provider offers, optionally filtered by language.""" @@ -277,55 +230,11 @@ async def delete_podcast( auth: AuthContext = Depends(get_auth_context), ): podcast = await _load(session, auth, podcast_id, Permission.PODCASTS_DELETE) - await purge(podcast) await session.delete(podcast) await session.commit() return {"message": "Podcast deleted successfully"} -@router.get("/podcasts/{podcast_id}/stream") -async def stream_podcast( - podcast_id: int, - session: AsyncSession = Depends(get_async_session), - auth: AuthContext = Depends(get_auth_context), -): - podcast = await _load(session, auth, 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_podcast_stream(podcast), - media_type="audio/mpeg", - headers={"Accept-Ranges": "bytes"}, - ) - - # Back-compat: rows rendered before the storage migration kept a local path. - if podcast.file_location and os.path.isfile(podcast.file_location): - path = podcast.file_location - - def iterfile(): - with open(path, mode="rb") as handle: - yield from handle - - return StreamingResponse( - iterfile(), - media_type="audio/mpeg", - headers={ - "Accept-Ranges": "bytes", - "Content-Disposition": f"inline; filename={Path(path).name}", - }, - ) - - # 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( session: AsyncSession, auth: AuthContext, diff --git a/surfsense_backend/app/podcasts/api/schemas.py b/surfsense_backend/app/podcasts/api/schemas.py index fcf563c9d..9eca29a5b 100644 --- a/surfsense_backend/app/podcasts/api/schemas.py +++ b/surfsense_backend/app/podcasts/api/schemas.py @@ -9,7 +9,7 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from app.podcasts.duration_limits import ( DEFAULT_MAX_SECONDS, @@ -74,19 +74,6 @@ class LanguageOptions(BaseModel): allows_custom: bool -class PodcastSummary(BaseModel): - """Lightweight list item.""" - - model_config = ConfigDict(from_attributes=True) - - id: int - title: str - status: PodcastStatus - created_at: datetime - workspace_id: int - thread_id: int | None = None - - class PodcastDetail(BaseModel): """Full podcast state for the detail view and action responses.""" @@ -124,12 +111,4 @@ class PodcastDetail(BaseModel): @classmethod async def resolve(cls, session, podcast: Podcast) -> PodcastDetail: - from app.artifacts.media.legacy import existing_legacy_artifact - - art = await existing_legacy_artifact( - session, - workspace_id=podcast.workspace_id, - kind="podcast", - legacy_id=podcast.id, - ) - return cls.of(podcast, artifact_id=art.id if art else None) + return cls.of(podcast, artifact_id=podcast.artifact_id) diff --git a/surfsense_backend/app/podcasts/persistence/models.py b/surfsense_backend/app/podcasts/persistence/models.py index 2c7e2a6c2..ccee549d4 100644 --- a/surfsense_backend/app/podcasts/persistence/models.py +++ b/surfsense_backend/app/podcasts/persistence/models.py @@ -1,4 +1,4 @@ -"""``podcasts`` table: a generated podcast, its brief, transcript, and state.""" +"""``podcast_runs`` table: a generated podcast, its brief, transcript, state.""" from __future__ import annotations @@ -19,16 +19,15 @@ from .enums import PodcastStatus class Podcast(BaseModel, TimestampMixin): - """A podcast across its whole lifecycle: brief, transcript, audio, status. + """A podcast run: brief, transcript, lifecycle state, and its Artifact link. ``spec`` (the reviewable brief) and ``podcast_transcript`` are JSONB so the flexible Pydantic shapes can evolve without migrations. ``spec_version`` - backs optimistic concurrency on brief edits. Rendered audio lives in the - object store, addressed by ``storage_backend`` + ``storage_key`` rather than - a raw path. + backs optimistic concurrency on brief edits. The delivered audio and + markdown live in the Artifact referenced by ``artifact_id``. """ - __tablename__ = "podcasts" + __tablename__ = "podcast_runs" title = Column(String(500), nullable=False) @@ -57,16 +56,17 @@ class Podcast(BaseModel, TimestampMixin): # The drafted dialogue (Transcript); null until drafting completes. podcast_transcript = Column(JSONB, nullable=True) - # Where the rendered audio lives in the object store; null until READY. - storage_backend = Column(String(32), nullable=True) - storage_key = Column(Text, nullable=True) duration_seconds = Column(Integer, nullable=True) # Human-readable reason when status is FAILED. error = Column(Text, nullable=True) - # Legacy local audio path; retained for back-compat until cutover. - file_location = Column(Text, nullable=True) + # The delivered Artifact; NULL until READY. The Artifact owns the audio. + artifact_id = Column( + Integer, + ForeignKey("artifacts.id", ondelete="SET NULL"), + nullable=True, + ) workspace_id = Column( Integer, diff --git a/surfsense_backend/app/podcasts/service.py b/surfsense_backend/app/podcasts/service.py index e1da27b4f..fc6a4ec94 100644 --- a/surfsense_backend/app/podcasts/service.py +++ b/surfsense_backend/app/podcasts/service.py @@ -169,19 +169,13 @@ class PodcastService: await self._session.flush() return podcast - async def attach_audio( - self, - podcast: Podcast, - *, - storage_backend: str, - storage_key: str, - duration_seconds: int | None = None, + async def mark_ready( + self, podcast: Podcast, *, duration_seconds: int | None = None ) -> Podcast: - """Record rendered audio and mark the podcast ready.""" + """Mark a rendered podcast ready. The delivered audio lives in the Artifact.""" self._transition(podcast, PodcastStatus.READY) - podcast.storage_backend = storage_backend - podcast.storage_key = storage_key - podcast.duration_seconds = duration_seconds + if duration_seconds is not None: + podcast.duration_seconds = duration_seconds podcast.error = None await self._session.flush() return podcast @@ -221,8 +215,8 @@ def _status(podcast: Podcast) -> PodcastStatus: def has_stored_episode(podcast: Podcast) -> bool: - """Whether finished audio is stored (``file_location`` covers legacy rows).""" - return bool(podcast.storage_key or podcast.file_location) + """Whether a delivered episode exists; its audio lives in the Artifact.""" + return podcast.artifact_id is not None def read_spec(podcast: Podcast) -> PodcastSpec | None: diff --git a/surfsense_backend/app/podcasts/tasks/render.py b/surfsense_backend/app/podcasts/tasks/render.py index c0af4ffba..4868c398f 100644 --- a/surfsense_backend/app/podcasts/tasks/render.py +++ b/surfsense_backend/app/podcasts/tasks/render.py @@ -1,8 +1,8 @@ """Audio-rendering task: RENDERING -> READY. -Synthesises and merges the approved transcript, stores the MP3 in the object -store, and marks the podcast ready. The working directory is stable per podcast -so a re-render (e.g. after a voice change) reuses the segment cache. +Synthesises and merges the approved transcript, records the delivered Artifact +(which owns the MP3 and markdown), and marks the podcast ready. The working +directory is stable per podcast so a re-render reuses the segment cache. """ from __future__ import annotations @@ -15,15 +15,13 @@ from sqlalchemy import select from app.celery_app import celery_app from app.observability import analytics as ph_analytics -from app.podcasts.persistence import PodcastRepository +from app.podcasts.persistence import PodcastRepository, PodcastStatus from app.podcasts.rendering import PodcastRenderer from app.podcasts.service import ( - InvalidTransitionError, PodcastService, read_spec, read_transcript, ) -from app.artifacts.media.podcast.storage import purge_key, persist from app.podcasts.tts import get_text_to_speech from app.podcasts.voices import get_voice_catalog from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task @@ -67,58 +65,48 @@ async def _render_audio(podcast_id: int) -> dict: spec=spec, transcript=transcript, workdir=workdir ) - superseded_key = podcast.storage_key - - backend_name, key = await persist( - workspace_id=podcast.workspace_id, - podcast_id=podcast_id, - data=rendered.data, - ) - try: - await PodcastService(session).attach_audio( - podcast, storage_backend=backend_name, storage_key=key - ) - await session.commit() - - from app.artifacts.media.podcast.record import record as record_podcast - - await record_podcast( - session, - podcast, - audio=rendered.data, - transcript=transcript, - ) - - # Credit-consuming deliverable; the frontend never confirms the - # render finished. Owner (workspace.user_id) resolved lazily so - # disabled installs pay nothing for the extra query. - if ph_analytics.is_enabled(): - # Local import: app.db <-> app.podcasts.persistence have a - # module-init cycle; deferring keeps this task importable. - from app.db import Workspace - - owner_id = await session.scalar( - select(Workspace.user_id).where( - Workspace.id == podcast.workspace_id - ) - ) - if owner_id: - ph_analytics.capture( - "podcast_generated", - distinct_id=str(owner_id), - properties={ - "workspace_id": podcast.workspace_id, - "podcast_id": podcast_id, - }, - groups={"workspace": str(podcast.workspace_id)}, - ) - except InvalidTransitionError: - # A user back-out won the race (e.g. the regeneration was - # reverted): drop the stale render and leave the row alone. - await purge_key(key) + # A user back-out during the render leaves the row out of RENDERING; + # bail before creating an Artifact that would never be linked. + if PodcastStatus(podcast.status) is not PodcastStatus.RENDERING: return {"status": "superseded", "podcast_id": podcast_id} - # Purge only after the new audio is committed, so a failed re-render never - # destroys the episode the user can still play. - await purge_key(superseded_key) + from app.artifacts.media.podcast.record import record as record_podcast + + # Record the Artifact while still RENDERING, then flip to READY and link + # it in one commit: a READY row is never committed without its audio. + saved = await record_podcast( + session, + podcast, + audio=rendered.data, + transcript=transcript, + ) + if saved is None: + raise RuntimeError(f"podcast {podcast_id}: recording the Artifact failed") + + await PodcastService(session).mark_ready(podcast) + podcast.artifact_id = saved.artifact_id + await session.commit() + + # Credit-consuming deliverable; the frontend never confirms the + # render finished. Owner (workspace.user_id) resolved lazily so + # disabled installs pay nothing for the extra query. + if ph_analytics.is_enabled(): + # Local import: app.db <-> app.podcasts.persistence have a + # module-init cycle; deferring keeps this task importable. + from app.db import Workspace + + owner_id = await session.scalar( + select(Workspace.user_id).where(Workspace.id == podcast.workspace_id) + ) + if owner_id: + ph_analytics.capture( + "podcast_generated", + distinct_id=str(owner_id), + properties={ + "workspace_id": podcast.workspace_id, + "podcast_id": podcast_id, + }, + groups={"workspace": str(podcast.workspace_id)}, + ) + return {"status": "ready", "podcast_id": podcast_id} diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 22d6a7f8a..a32cc1712 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -232,8 +232,17 @@ async def create_snapshot( if podcast_info: podcasts_data.append(podcast_info) podcast_ids_seen.add(podcast_id) - # Update status to "ready" so frontend renders PodcastPlayer - part["result"] = {**result_data, "status": "ready"} + # The tool result carries podcast_id, not artifact_id, + # so the generic allowlist check above misses it. + new_result = {**result_data, "status": "ready"} + artifact_id = podcast_info.get("artifact_id") + if isinstance(artifact_id, int): + artifact_ids.add(artifact_id) + new_result["artifact_id"] = artifact_id + new_result["workspace_id"] = podcast_info.get( + "workspace_id" + ) + part["result"] = new_result elif tool_name in ("generate_report", "generate_resume"): result_data = part.get("result", {}) @@ -328,14 +337,13 @@ async def _get_podcast_for_snapshot( if not podcast or podcast.status != PodcastStatus.READY: return None + # Audio is served from the Artifact; only the transcript stays embedded. return { "original_id": podcast.id, "title": podcast.title, "transcript": podcast.podcast_transcript, - "storage_backend": podcast.storage_backend, - "storage_key": podcast.storage_key, - # Legacy fallback for rows rendered before the storage migration. - "file_path": podcast.file_location, + "artifact_id": podcast.artifact_id, + "workspace_id": podcast.workspace_id, } @@ -629,7 +637,6 @@ async def clone_from_snapshot( data = snapshot.snapshot_data messages_data = data.get("messages", []) - podcasts_lookup = {p.get("original_id"): p for p in data.get("podcasts", [])} reports_lookup = {r.get("original_id"): r for r in data.get("reports", [])} new_thread = NewChatThread( @@ -646,7 +653,6 @@ async def clone_from_snapshot( session.add(new_thread) await session.flush() - podcast_id_mapping: dict[int, int] = {} report_id_mapping: dict[int, int] = {} # Check which authors from snapshot still exist in DB @@ -680,37 +686,8 @@ async def clone_from_snapshot( if isinstance(content, list): for part in content: - if ( - isinstance(part, dict) - and part.get("type") == "tool-call" - and part.get("toolName") == "generate_podcast" - ): - result = part.get("result", {}) - old_podcast_id = result.get("podcast_id") - - if old_podcast_id and old_podcast_id not in podcast_id_mapping: - podcast_info = podcasts_lookup.get(old_podcast_id) - if podcast_info: - new_podcast = Podcast( - title=podcast_info.get("title", "Cloned Podcast"), - podcast_transcript=podcast_info.get("transcript"), - storage_backend=podcast_info.get("storage_backend"), - storage_key=podcast_info.get("storage_key"), - file_location=podcast_info.get("file_path"), - status=PodcastStatus.READY, - workspace_id=target_workspace_id, - thread_id=new_thread.id, - ) - session.add(new_podcast) - await session.flush() - podcast_id_mapping[old_podcast_id] = new_podcast.id - - if old_podcast_id and old_podcast_id in podcast_id_mapping: - part["result"] = { - **result, - "podcast_id": podcast_id_mapping[old_podcast_id], - } - + # generate_podcast is copied verbatim; the snapshot already + # carries its artifact_id + workspace_id (as video does). if ( isinstance(part, dict) and part.get("type") == "tool-call" diff --git a/surfsense_backend/app/zero_publication.py b/surfsense_backend/app/zero_publication.py index 5335d39ef..585d1d8e5 100644 --- a/surfsense_backend/app/zero_publication.py +++ b/surfsense_backend/app/zero_publication.py @@ -76,6 +76,7 @@ PODCAST_COLS = [ "spec_version", "duration_seconds", "error", + "artifact_id", "workspace_id", "thread_id", "created_at", @@ -107,7 +108,7 @@ ZERO_PUBLICATION: Mapping[str, Sequence[str] | None] = { "user": USER_COLS, "automations": AUTOMATION_COLS, "automation_runs": AUTOMATION_RUN_COLS, - "podcasts": PODCAST_COLS, + "podcast_runs": PODCAST_COLS, "video_presentation_runs": VIDEO_PRESENTATION_RUN_COLS, } @@ -136,7 +137,7 @@ def _expected_columns(conn: Connection, table: str) -> list[str] | None: if table in { "documents", "user", - "podcasts", + "podcast_runs", "video_presentation_runs", } and "_0_version" in _table_columns(conn, table): expected.append("_0_version") diff --git a/surfsense_backend/tests/integration/podcasts/conftest.py b/surfsense_backend/tests/integration/podcasts/conftest.py index 22fc030a4..c5bfbb077 100644 --- a/surfsense_backend/tests/integration/podcasts/conftest.py +++ b/surfsense_backend/tests/integration/podcasts/conftest.py @@ -16,6 +16,7 @@ import contextlib import uuid from collections.abc import AsyncGenerator, AsyncIterator from pathlib import Path +from unittest.mock import AsyncMock import httpx import pytest @@ -128,14 +129,25 @@ class FakeStorageBackend: self.deleted.append(key) -@pytest.fixture -def fake_storage(monkeypatch) -> FakeStorageBackend: - """Route audio storage to an in-memory backend for the stream routes.""" +@pytest.fixture(autouse=True) +def fake_storage(monkeypatch, patched_embed_texts) -> FakeStorageBackend: + """In-memory object store + Postgres-only artifact indexing for the suite. + + The delivered audio is written by ``save_artifact``; pinning it to False + keeps that on the indexed (non-git) path so the blob lands in this backend. + """ + del patched_embed_texts backend = FakeStorageBackend() monkeypatch.setattr( - "app.artifacts.media.podcast.storage.get_storage_backend", lambda: backend + "app.artifacts.service.get_storage_backend", lambda *a, **k: backend + ) + monkeypatch.setattr( + "app.artifacts.service.knowledge_store_enabled_for", + AsyncMock(return_value=False), + ) + monkeypatch.setattr( + "app.file_storage.factory.get_storage_backend", lambda *a, **k: backend ) - monkeypatch.setattr("app.file_storage.factory.get_storage_backend", lambda: backend) return backend @@ -272,12 +284,17 @@ def make_podcast(db_session: AsyncSession): elif target is PodcastStatus.RENDERING: await service.attach_transcript(podcast, build_transcript()) elif target is PodcastStatus.READY: - await service.attach_audio( + await service.mark_ready(podcast, duration_seconds=123) + from app.artifacts.media.podcast.record import record as record_podcast + + saved = await record_podcast( + db_session, podcast, - storage_backend="memory", - storage_key="podcasts/audio.mp3", - duration_seconds=123, + audio=b"merged-audio", + transcript=build_transcript(), ) + assert saved is not None + podcast.artifact_id = saved.artifact_id await db_session.flush() return podcast diff --git a/surfsense_backend/tests/integration/podcasts/test_regeneration.py b/surfsense_backend/tests/integration/podcasts/test_regeneration.py index c93bf8dfd..e77513ddc 100644 --- a/surfsense_backend/tests/integration/podcasts/test_regeneration.py +++ b/surfsense_backend/tests/integration/podcasts/test_regeneration.py @@ -191,7 +191,6 @@ async def test_regenerate_without_a_brief_is_rejected( workspace_id=db_workspace.id, status=PodcastStatus.READY, spec_version=1, - file_location="/var/old/podcast.mp3", ) db_session.add(podcast) await db_session.flush() diff --git a/surfsense_backend/tests/integration/podcasts/test_render_task.py b/surfsense_backend/tests/integration/podcasts/test_render_task.py index 8c185e3c2..5c479a531 100644 --- a/surfsense_backend/tests/integration/podcasts/test_render_task.py +++ b/surfsense_backend/tests/integration/podcasts/test_render_task.py @@ -1,15 +1,18 @@ """The audio-rendering task against a real database. -From RENDERING, the task synthesises and merges the approved transcript, stores -the bytes, and marks the podcast READY with the storage location recorded. The -DB, service, renderer orchestration, and storage wrapper run for real; the true -externals are faked — the TTS provider, the FFmpeg merge, and the object store. +From RENDERING the task synthesises and merges the approved transcript, records +the delivered Artifact (which owns the audio), stamps ``artifact_id``, and marks +the podcast READY. The DB, service, renderer orchestration, and artifact service +run for real; the true externals are faked — the TTS provider, the FFmpeg merge, +and the object store. """ from __future__ import annotations import pytest +from sqlalchemy import select +from app.artifacts.persistence import ArtifactFile, ArtifactFileRole from app.podcasts.persistence import PodcastStatus from app.podcasts.service import PodcastService from app.podcasts.tasks import render @@ -19,8 +22,19 @@ from .conftest import build_transcript pytestmark = pytest.mark.integration -async def test_render_marks_ready_and_stores_audio( - db_workspace, make_podcast, bind_task_session, fake_tts, fake_merge, fake_storage +async def _primary_key(db_session, artifact_id: int) -> str: + row = await db_session.scalar( + select(ArtifactFile).where( + ArtifactFile.artifact_id == artifact_id, + ArtifactFile.role == ArtifactFileRole.PRIMARY, + ) + ) + assert row is not None + return row.storage_key + + +async def test_render_marks_ready_and_records_the_artifact( + db_session, db_workspace, make_podcast, bind_task_session, fake_tts, fake_merge, fake_storage ): podcast = await make_podcast( workspace_id=db_workspace.id, status=PodcastStatus.RENDERING @@ -30,12 +44,12 @@ async def test_render_marks_ready_and_stores_audio( assert result["status"] == "ready" assert podcast.status == PodcastStatus.READY - assert podcast.storage_backend == "memory" - assert podcast.storage_key - assert fake_storage.objects[podcast.storage_key] == b"merged-audio" + assert podcast.artifact_id is not None + key = await _primary_key(db_session, podcast.artifact_id) + assert fake_storage.objects[key] == b"merged-audio" -async def test_rerender_replaces_audio_and_purges_the_old_object( +async def test_rerender_reuses_the_artifact_and_purges_the_old_object( db_session, db_workspace, make_podcast, @@ -44,13 +58,11 @@ async def test_rerender_replaces_audio_and_purges_the_old_object( fake_merge, fake_storage, ): - # A regenerated episode keeps exactly one stored object: the new render - # must not leak the superseded audio in the object store. podcast = await make_podcast( workspace_id=db_workspace.id, status=PodcastStatus.READY ) - old_key = podcast.storage_key - fake_storage.objects[old_key] = b"old-audio" + original_artifact_id = podcast.artifact_id + old_key = await _primary_key(db_session, original_artifact_id) service = PodcastService(db_session) await service.regenerate(podcast) @@ -61,12 +73,15 @@ async def test_rerender_replaces_audio_and_purges_the_old_object( assert result["status"] == "ready" assert podcast.status == PodcastStatus.READY - assert podcast.storage_key != old_key - assert fake_storage.objects[podcast.storage_key] == b"merged-audio" + # The Artifact is revised in place, not replaced. + assert podcast.artifact_id == original_artifact_id + new_key = await _primary_key(db_session, podcast.artifact_id) + assert new_key != old_key + assert fake_storage.objects[new_key] == b"merged-audio" assert old_key in fake_storage.deleted -async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothing( +async def test_render_losing_to_a_user_revert_keeps_the_episode( db_session, db_workspace, make_podcast, @@ -76,13 +91,11 @@ async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothin fake_storage, ): # The user reverts the regeneration while the render is in flight: the - # stale render must neither resurrect the redo nor leak the object it - # already stored. + # stale render must not finalize a new take. podcast = await make_podcast( workspace_id=db_workspace.id, status=PodcastStatus.READY ) - old_key = podcast.storage_key - fake_storage.objects[old_key] = b"old-audio" + original_artifact_id = podcast.artifact_id service = PodcastService(db_session) await service.regenerate(podcast) @@ -94,7 +107,4 @@ async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothin assert result["status"] == "superseded" assert podcast.status == PodcastStatus.READY - assert podcast.storage_key == old_key - assert old_key not in fake_storage.deleted - stale_keys = [key for key in fake_storage.objects if key != old_key] - assert all(key in fake_storage.deleted for key in stale_keys) + assert podcast.artifact_id == original_artifact_id diff --git a/surfsense_backend/tests/integration/podcasts/test_scoping.py b/surfsense_backend/tests/integration/podcasts/test_scoping.py index d5d497091..949e56130 100644 --- a/surfsense_backend/tests/integration/podcasts/test_scoping.py +++ b/surfsense_backend/tests/integration/podcasts/test_scoping.py @@ -1,8 +1,6 @@ """Podcasts are scoped to workspace membership. -A user can only create or read podcasts in spaces they belong to, and an -unscoped listing returns only the caller's own podcasts — never another -member's. +A user can only create or read podcasts in spaces they belong to. """ import pytest @@ -38,16 +36,3 @@ async def test_creating_in_a_nonmember_space_is_forbidden( ) assert resp.status_code == 403 - - -async def test_listing_returns_only_the_callers_podcasts( - client, db_workspace, make_podcast, foreign_podcast -): - mine = await make_podcast(workspace_id=db_workspace.id, title="Mine") - - resp = await client.get(BASE) - - assert resp.status_code == 200 - ids = {p["id"] for p in resp.json()} - assert mine.id in ids - assert foreign_podcast.id not in ids diff --git a/surfsense_backend/tests/integration/podcasts/test_streaming.py b/surfsense_backend/tests/integration/podcasts/test_streaming.py deleted file mode 100644 index b8d748c07..000000000 --- a/surfsense_backend/tests/integration/podcasts/test_streaming.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Streaming a podcast's rendered audio over HTTP. - -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 - -import pytest - -from app.podcasts.persistence import PodcastStatus - -pytestmark = pytest.mark.integration - -BASE = "/api/v1/podcasts" - - -async def test_stream_serves_stored_audio( - client, db_workspace, make_podcast, fake_storage -): - podcast = await make_podcast( - workspace_id=db_workspace.id, status=PodcastStatus.READY - ) - fake_storage.objects["podcasts/audio.mp3"] = b"the-audio" - - resp = await client.get(f"{BASE}/{podcast.id}/stream") - - assert resp.status_code == 200 - assert resp.headers["content-type"] == "audio/mpeg" - assert resp.content == b"the-audio" - - -async def test_stream_409_while_in_flight(client, db_workspace, make_podcast): - podcast = await make_podcast( - workspace_id=db_workspace.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_workspace, make_podcast, fake_storage -): - podcast = await make_podcast( - workspace_id=db_workspace.id, status=PodcastStatus.READY - ) - - resp = await client.get(f"{BASE}/{podcast.id}/stream") - - assert resp.status_code == 404 diff --git a/surfsense_backend/tests/unit/podcasts/test_api_schemas.py b/surfsense_backend/tests/unit/podcasts/test_api_schemas.py index 3685f4f63..2a599db33 100644 --- a/surfsense_backend/tests/unit/podcasts/test_api_schemas.py +++ b/surfsense_backend/tests/unit/podcasts/test_api_schemas.py @@ -9,7 +9,6 @@ test builds a row in one lifecycle shape and asserts the mapping reflects it. from __future__ import annotations from datetime import UTC, datetime -from types import SimpleNamespace from unittest.mock import AsyncMock import pytest @@ -56,15 +55,15 @@ def test_an_awaiting_brief_podcast_exposes_the_deserialized_brief(make_spec): def test_a_legacy_episode_still_exposes_its_transcript_and_audio(): - # Pre-rework rows stored [{speaker_id, dialog}] and a local file path; - # they must keep flowing through the new read model, not fail validation. + # Pre-rework rows stored [{speaker_id, dialog}]; they must keep flowing + # through the new read model. Audio now lives in the linked Artifact. podcast = _podcast( status=PodcastStatus.READY, podcast_transcript=[ {"speaker_id": 0, "dialog": "Welcome back."}, {"speaker_id": 1, "dialog": "Glad to be here."}, ], - file_location="/var/old/podcast.mp3", + artifact_id=99, ) detail = PodcastDetail.of(podcast) @@ -82,8 +81,7 @@ def test_a_ready_podcast_reports_available_audio(make_spec, make_transcript): status=PodcastStatus.READY, spec=make_spec().model_dump(mode="json"), podcast_transcript=make_transcript().model_dump(mode="json"), - storage_backend="local", - storage_key="k", + artifact_id=77, duration_seconds=120, ) @@ -98,14 +96,7 @@ def test_a_ready_podcast_reports_available_audio(make_spec, make_transcript): @pytest.mark.asyncio -async def test_resolve_attaches_dual_written_artifact_id(monkeypatch): - import app.artifacts.media.legacy as legacy - - podcast = _podcast(status=PodcastStatus.READY) - monkeypatch.setattr( - legacy, - "existing_legacy_artifact", - AsyncMock(return_value=SimpleNamespace(id=55)), - ) +async def test_resolve_reads_the_stamped_artifact_id(): + podcast = _podcast(status=PodcastStatus.READY, artifact_id=55) detail = await PodcastDetail.resolve(AsyncMock(), podcast) assert detail.artifact_id == 55 diff --git a/surfsense_backend/tests/unit/services/test_public_chat_artifacts.py b/surfsense_backend/tests/unit/services/test_public_chat_artifacts.py index 2e33867e3..0ffbbd9cb 100644 --- a/surfsense_backend/tests/unit/services/test_public_chat_artifacts.py +++ b/surfsense_backend/tests/unit/services/test_public_chat_artifacts.py @@ -85,3 +85,25 @@ async def test_unreferenced_video_artifact_is_refused_without_a_query( assert served is None session.execute.assert_not_called() + + +@pytest.mark.asyncio +async def test_podcast_snapshot_carries_artifact_id_and_no_storage_key(): + from app.podcasts.persistence import PodcastStatus + + podcast = SimpleNamespace( + id=7, + title="Ep", + podcast_transcript=None, + artifact_id=42, + workspace_id=3, + status=PodcastStatus.READY, + ) + info = await public_chat_service._get_podcast_for_snapshot( + _session_returning(podcast), 7 + ) + + assert info["artifact_id"] == 42 + assert info["workspace_id"] == 3 + assert "storage_key" not in info + assert "storage_backend" not in info diff --git a/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx b/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx index e2fe0ab26..f3a752d5e 100644 --- a/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx @@ -3,7 +3,7 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { Loader2, RotateCcw, Undo2, X } from "lucide-react"; import { usePathname } from "next/navigation"; -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, useState } from "react"; import { toast } from "sonner"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { @@ -195,8 +195,6 @@ function BackOutButton({ podcastId, hasEpisode }: { podcastId: number; hasEpisod ); } -const BACK_OUT_STATUSES = new Set(["awaiting_brief", "drafting", "rendering"]); - /** Status-driven card for an authenticated viewer, fed by Zero push. */ function LivePodcastCard({ podcastId, @@ -207,33 +205,6 @@ function LivePodcastCard({ }) { const { podcast, isLoading } = usePodcastLive(podcastId); - // Whether a finished episode exists decides revert-vs-cancel, and Zero - // doesn't publish audio fields — so the in-flight states check over REST, - // re-checking on each status change (a fresh podcast gains its episode, - // a regeneration starts with one). - const status = podcast?.status; - const [hasEpisode, setHasEpisode] = useState(false); - const [artifactId, setArtifactId] = useState(); - useEffect(() => { - if (!status) return; - if (status === "ready" || BACK_OUT_STATUSES.has(status)) { - let stale = false; - podcastsApiService - .getDetail(podcastId) - .then((detail) => { - if (stale) return; - if (BACK_OUT_STATUSES.has(status)) setHasEpisode(detail.has_audio); - if (status === "ready") { - setArtifactId(detail.artifact_id ?? undefined); - } - }) - .catch(() => {}); - return () => { - stale = true; - }; - } - }, [podcastId, status]); - if (!podcast) { if (isLoading) { return ; @@ -247,8 +218,10 @@ function LivePodcastCard({ } const title = podcast.title || fallbackTitle; - - const backOut = ; + // A finished episode is exactly a stamped Artifact; it decides + // revert-vs-cancel and feeds the player, both straight from the Zero row. + const artifactId = podcast.artifactId ?? undefined; + const backOut = ; switch (podcast.status) { case "pending": diff --git a/surfsense_web/components/tool-ui/podcast/player.tsx b/surfsense_web/components/tool-ui/podcast/player.tsx index b13c36929..1e35fa97e 100644 --- a/surfsense_web/components/tool-ui/podcast/player.tsx +++ b/surfsense_web/components/tool-ui/podcast/player.tsx @@ -157,14 +157,26 @@ export function PodcastPlayer({ let lines: TranscriptLine[] = []; if (shareToken) { - if (podcastId == null) throw new Error("Podcast id required for shared chat"); + // Artifact route when available; legacy per-podcast stream for + // snapshots predating the backfill. + const audioUrl = + artifactId != null + ? `/api/v1/public/${shareToken}/artifacts/${artifactId}/content` + : podcastId != null + ? `/api/v1/public/${shareToken}/podcasts/${podcastId}/stream` + : null; + if (!audioUrl) throw new Error("Podcast identity missing for shared chat"); const [blob, details] = await Promise.all([ - baseApiService.getBlob(`/api/v1/public/${shareToken}/podcasts/${podcastId}/stream`), - baseApiService.get(`/api/v1/public/${shareToken}/podcasts/${podcastId}`), + baseApiService.getBlob(audioUrl), + podcastId != null + ? baseApiService.get(`/api/v1/public/${shareToken}/podcasts/${podcastId}`) + : Promise.resolve(null), ]); audioBlob = blob; - const parsed = publicPodcastDetailsSchema.safeParse(details); - lines = (parsed.success ? (parsed.data.podcast_transcript ?? []) : []).map( + const parsed = details + ? publicPodcastDetailsSchema.safeParse(details) + : null; + lines = (parsed?.success ? (parsed.data.podcast_transcript ?? []) : []).map( (entry, turn) => ({ key: `turn-${turn}`, label: `Speaker ${entry.speaker_id + 1}`, @@ -178,25 +190,6 @@ export function PodcastPlayer({ podcastId, controller.signal )); - } else if (podcastId != null) { - const [audioResponse, detail] = await Promise.all([ - authenticatedFetch(buildBackendUrl(`/api/v1/podcasts/${podcastId}/stream`), { - method: "GET", - signal: controller.signal, - }), - podcastsApiService.getDetail(podcastId), - ]); - - if (!audioResponse.ok) { - throw new Error(`Failed to load audio: ${audioResponse.status}`); - } - - audioBlob = await audioResponse.blob(); - lines = (detail.transcript?.turns ?? []).map((entry, turn) => ({ - key: `turn-${turn}`, - label: speakerLabel(detail.spec, entry.speaker), - text: entry.text, - })); } else { throw new Error("Podcast identity missing"); } diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts index b5c490379..6535f653d 100644 --- a/surfsense_web/contracts/types/podcast.types.ts +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -156,16 +156,3 @@ export const podcastDetail = z.object({ artifact_id: z.number().int().positive().nullish(), }); export type PodcastDetail = z.infer; - -// Lightweight list item — mirror app/podcasts/api/schemas.py PodcastSummary. -export const podcastSummary = z.object({ - id: z.number(), - title: z.string(), - status: podcastStatus, - created_at: z.string(), - workspace_id: z.number(), - thread_id: z.number().nullish(), -}); -export type PodcastSummary = z.infer; - -export const podcastSummaryList = z.array(podcastSummary); diff --git a/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts b/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts index 0a23f950b..229604dcc 100644 --- a/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts +++ b/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts @@ -1,7 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { fetchArtifacts } from "@/features/artifacts/artifact-query"; import type { ArtifactListItem } from "@/features/artifacts/model"; -import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; import { reportsApiService } from "@/lib/apis/reports-api.service"; import type { LibraryArtifact, @@ -9,12 +8,6 @@ import type { LibraryArtifactStatus, } from "../model/artifact"; -function podcastStatus(status: string): LibraryArtifactStatus { - if (status === "ready") return "ready"; - if (status === "failed" || status === "cancelled") return "error"; - return "running"; -} - function indexingStatus(status: string): LibraryArtifactStatus { if (status === "failed") return "error"; if (status === "ready") return "ready"; @@ -44,23 +37,18 @@ function fromArtifactRow(row: ArtifactListItem): LibraryArtifact { }; } -// Podcast rows still list separately: a podcast has no Artifact until delivered. +// Delivered podcasts arrive as Artifact rows; in-flight/failed runs stream from +// Zero (see useLibraryPodcastRuns), matching how videos are handled. async function fetchLibraryArtifacts(workspaceId: number): Promise { - const [rows, reports, podcasts] = await Promise.all([ + const [rows, reports] = await Promise.all([ fetchArtifacts(workspaceId).catch(() => []), reportsApiService.list(workspaceId).catch(() => []), - podcastsApiService.list(workspaceId).catch(() => []), ]); const artifacts: LibraryArtifact[] = []; - const coveredPodcasts = new Set(); for (const row of rows) { - const item = fromArtifactRow(row); - artifacts.push(item); - if (item.kind === "podcast" && row.legacy?.kind === "podcast") { - coveredPodcasts.add(row.legacy.id); - } + artifacts.push(fromArtifactRow(row)); } for (const report of reports) { @@ -77,20 +65,6 @@ async function fetchLibraryArtifacts(workspaceId: number): Promise new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); diff --git a/surfsense_web/features/artifacts-library/hooks/use-library-podcast-runs.ts b/surfsense_web/features/artifacts-library/hooks/use-library-podcast-runs.ts new file mode 100644 index 000000000..9d011aeaf --- /dev/null +++ b/surfsense_web/features/artifacts-library/hooks/use-library-podcast-runs.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useQuery as useZeroQuery } from "@rocicorp/zero/react"; +import { useMemo } from "react"; +import { queries } from "@/zero/queries"; +import type { LibraryArtifact, LibraryArtifactStatus } from "../model/artifact"; + +function runStatus(status: string): LibraryArtifactStatus { + if (status === "failed" || status === "cancelled") return "error"; + return "running"; +} + +interface ZeroPodcastRunRow { + id: number; + title: string; + status: string; + artifactId?: number | null; + workspaceId: number; + threadId?: number | null; + createdAt: number; +} + +/** + * In-flight and failed podcast runs, sourced from Zero by push. A delivered run + * ("ready") is already represented by its Artifact row, so it is filtered out + * here to avoid a duplicate card. + */ +export function useLibraryPodcastRuns(workspaceId: number): LibraryArtifact[] { + const [rows] = useZeroQuery(queries.podcastRuns.bySpace({ workspaceId })); + + return useMemo( + () => + (rows as ZeroPodcastRunRow[]) + .filter((row) => row.status !== "ready") + .map((row) => ({ + key: `podcast-run-${row.id}`, + kind: "podcast" as const, + entityId: row.id, + artifactId: row.artifactId ?? undefined, + title: row.title, + status: runStatus(row.status), + createdAt: new Date(row.createdAt).toISOString(), + contentType: "markdown" as const, + sourceThreadId: row.threadId ?? null, + })), + [rows] + ); +} diff --git a/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx index 9ee7513e5..30d41f90a 100644 --- a/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx +++ b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx @@ -8,6 +8,7 @@ import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { MobileReportPanel } from "@/components/report-panel/report-panel"; import { Button } from "@/components/ui/button"; import { useLibraryArtifacts } from "../hooks/use-library-artifacts"; +import { useLibraryPodcastRuns } from "../hooks/use-library-podcast-runs"; import { useLibraryVideoRuns } from "../hooks/use-library-video-runs"; import type { LibraryArtifact, LibraryArtifactKind } from "../model/artifact"; import { ArtifactCard } from "./artifact-card"; @@ -64,18 +65,19 @@ function EmptyState() { export function ArtifactsLibrary({ workspaceId }: { workspaceId: number }) { const { artifacts, loading, error, refresh } = useLibraryArtifacts(workspaceId); const liveVideoRuns = useLibraryVideoRuns(workspaceId); + const livePodcastRuns = useLibraryPodcastRuns(workspaceId); const openArtifactPanel = useSetAtom(openArtifactPanelAtom); const openReportPanel = useSetAtom(openReportPanelAtom); const [selectedMedia, setSelectedMedia] = useState(null); - // Delivered videos come from the Artifact API (react-query); in-flight and + // Delivered media comes from the Artifact API (react-query); in-flight and // failed runs arrive by push from Zero. Merge newest-first. const merged = useMemo( () => - [...artifacts, ...liveVideoRuns].sort( + [...artifacts, ...liveVideoRuns, ...livePodcastRuns].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ), - [artifacts, liveVideoRuns] + [artifacts, liveVideoRuns, livePodcastRuns] ); const grouped = useMemo(() => { diff --git a/surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx b/surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx index 68cbe70b1..6725df54e 100644 --- a/surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx +++ b/surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx @@ -37,17 +37,21 @@ function MediaViewerBody({ workspaceId: number; }) { if (artifact.kind === "podcast") { - if (artifact.artifactId != null) { + if (artifact.artifactId == null) { return ( - +

+ Podcast not available +

); } - return ; + return ( + + ); } if (artifact.kind === "video") { if (artifact.artifactId == null) { diff --git a/surfsense_web/hooks/use-podcast-live.ts b/surfsense_web/hooks/use-podcast-live.ts index a504dd487..9f38758a5 100644 --- a/surfsense_web/hooks/use-podcast-live.ts +++ b/surfsense_web/hooks/use-podcast-live.ts @@ -6,9 +6,9 @@ import { type PodcastSpec, type PodcastStatus, podcastSpec } from "@/contracts/t import { queries } from "@/zero/queries"; /** - * Thin live row sourced from Zero's `podcasts` publication. Drives the + * Thin live row sourced from Zero's `podcast_runs` publication. Drives the * lifecycle UI by push (no polling); heavy fields (transcript, audio) stay on - * REST and are fetched lazily when a gate or the player needs them. + * REST/the Artifact and are fetched lazily when a gate or the player needs them. */ export interface LivePodcast { id: number; @@ -18,6 +18,7 @@ export interface LivePodcast { specVersion: number; durationSeconds: number | null; error: string | null; + artifactId: number | null; workspaceId: number; threadId: number | null; } @@ -28,7 +29,7 @@ interface UsePodcastLiveResult { } export function usePodcastLive(podcastId: number | undefined): UsePodcastLiveResult { - const [row, result] = useQuery(queries.podcasts.byId({ podcastId: podcastId ?? -1 })); + const [row, result] = useQuery(queries.podcastRuns.byId({ podcastId: podcastId ?? -1 })); const podcast = useMemo(() => { if (!podcastId || !row) return undefined; @@ -40,6 +41,7 @@ export function usePodcastLive(podcastId: number | undefined): UsePodcastLiveRes specVersion: row.specVersion, durationSeconds: row.durationSeconds ?? null, error: row.error ?? null, + artifactId: row.artifactId ?? null, workspaceId: row.workspaceId, threadId: row.threadId ?? null, }; diff --git a/surfsense_web/lib/apis/podcasts-api.service.ts b/surfsense_web/lib/apis/podcasts-api.service.ts index b83b09386..2e13d63cc 100644 --- a/surfsense_web/lib/apis/podcasts-api.service.ts +++ b/surfsense_web/lib/apis/podcasts-api.service.ts @@ -3,7 +3,6 @@ import { languageOptions, type PodcastSpec, podcastDetail, - podcastSummaryList, updateSpecRequest, voiceOption, } from "@/contracts/types/podcast.types"; @@ -15,14 +14,6 @@ const BASE = "/api/v1/podcasts"; const voiceOptionList = z.array(voiceOption); class PodcastsApiService { - list = async (workspaceId: number, limit = 200) => { - const qs = new URLSearchParams({ - workspace_id: String(workspaceId), - limit: String(limit), - }).toString(); - return baseApiService.get(`${BASE}?${qs}`, podcastSummaryList); - }; - // Full state including the deserialized brief and transcript; thin lifecycle // fields (status, spec, spec_version) also arrive live via Zero. getDetail = async (podcastId: number) => { diff --git a/surfsense_web/zero/queries/index.ts b/surfsense_web/zero/queries/index.ts index f2c4d65a5..90e13223b 100644 --- a/surfsense_web/zero/queries/index.ts +++ b/surfsense_web/zero/queries/index.ts @@ -4,7 +4,7 @@ import { chatSessionQueries, commentQueries, messageQueries } from "./chat"; import { connectorQueries, documentQueries } from "./documents"; import { folderQueries } from "./folders"; import { notificationQueries } from "./inbox"; -import { podcastQueries } from "./podcasts"; +import { podcastRunQueries } from "./podcast-runs"; import { userQueries } from "./user"; import { videoPresentationRunQueries } from "./video-presentation-runs"; @@ -18,6 +18,6 @@ export const queries = defineQueries({ chatSession: chatSessionQueries, user: userQueries, automationRuns: automationRunQueries, - podcasts: podcastQueries, + podcastRuns: podcastRunQueries, videoRuns: videoPresentationRunQueries, }); diff --git a/surfsense_web/zero/queries/podcasts.ts b/surfsense_web/zero/queries/podcast-runs.ts similarity index 75% rename from surfsense_web/zero/queries/podcasts.ts rename to surfsense_web/zero/queries/podcast-runs.ts index 0c0424f91..254df9f8f 100644 --- a/surfsense_web/zero/queries/podcasts.ts +++ b/surfsense_web/zero/queries/podcast-runs.ts @@ -3,13 +3,13 @@ import { z } from "zod"; import { zql } from "../schema/index"; import { canReadSpace, constrainToAllowedSpaces, denySpace } from "./authz"; -export const podcastQueries = { +export const podcastRunQueries = { bySpace: defineQuery(z.object({ workspaceId: z.number() }), ({ args: { workspaceId }, ctx }) => { - const query = zql.podcasts.where("workspaceId", workspaceId); + const query = zql.podcast_runs.where("workspaceId", workspaceId); if (!canReadSpace(ctx, workspaceId)) return denySpace(query).orderBy("createdAt", "desc"); return constrainToAllowedSpaces(query, ctx).orderBy("createdAt", "desc"); }), byId: defineQuery(z.object({ podcastId: z.number() }), ({ args: { podcastId }, ctx }) => - constrainToAllowedSpaces(zql.podcasts.where("id", podcastId), ctx).one() + constrainToAllowedSpaces(zql.podcast_runs.where("id", podcastId), ctx).one() ), }; diff --git a/surfsense_web/zero/schema/index.ts b/surfsense_web/zero/schema/index.ts index a68b18fe2..ca9341d87 100644 --- a/surfsense_web/zero/schema/index.ts +++ b/surfsense_web/zero/schema/index.ts @@ -9,7 +9,7 @@ import { import { documentTable, searchSourceConnectorTable } from "./documents"; import { folderTable } from "./folders"; import { notificationTable } from "./inbox"; -import { podcastTable } from "./podcasts"; +import { podcastRunTable } from "./podcast-runs"; import { userTable } from "./user"; import { videoPresentationRunTable } from "./video-presentation-runs"; @@ -73,7 +73,7 @@ export const schema = createSchema({ userTable, automationTable, automationRunTable, - podcastTable, + podcastRunTable, videoPresentationRunTable, ], relationships: [ diff --git a/surfsense_web/zero/schema/podcasts.ts b/surfsense_web/zero/schema/podcast-runs.ts similarity index 86% rename from surfsense_web/zero/schema/podcasts.ts rename to surfsense_web/zero/schema/podcast-runs.ts index 39c46e0d9..e3053fc81 100644 --- a/surfsense_web/zero/schema/podcasts.ts +++ b/surfsense_web/zero/schema/podcast-runs.ts @@ -3,7 +3,7 @@ import { json, number, string, table } from "@rocicorp/zero"; // Mirrors PODCAST_COLS in the backend zero_publication. status drives the // lifecycle UI by push; spec is the reviewable brief. The bulky source_content // and transcript are intentionally not published and are fetched over REST. -export const podcastTable = table("podcasts") +export const podcastRunTable = table("podcast_runs") .columns({ id: number(), title: string(), @@ -12,6 +12,7 @@ export const podcastTable = table("podcasts") specVersion: number().from("spec_version"), durationSeconds: number().optional().from("duration_seconds"), error: string().optional(), + artifactId: number().optional().from("artifact_id"), workspaceId: number().from("workspace_id"), threadId: number().optional().from("thread_id"), createdAt: number().from("created_at"),