diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py index 61c8cdf72..98b7dd373 100644 --- a/surfsense_backend/app/podcasts/api/routes.py +++ b/surfsense_backend/app/podcasts/api/routes.py @@ -193,7 +193,7 @@ async def get_podcast( auth: AuthContext = Depends(get_auth_context), ): podcast = await _load(session, auth, podcast_id, Permission.PODCASTS_READ) - return PodcastDetail.of(podcast) + return await PodcastDetail.resolve(session, podcast) @router.patch("/podcasts/{podcast_id}/spec", response_model=PodcastDetail) diff --git a/surfsense_backend/app/podcasts/api/schemas.py b/surfsense_backend/app/podcasts/api/schemas.py index 12b978c09..6e846e3ac 100644 --- a/surfsense_backend/app/podcasts/api/schemas.py +++ b/surfsense_backend/app/podcasts/api/schemas.py @@ -102,9 +102,11 @@ class PodcastDetail(BaseModel): created_at: datetime workspace_id: int thread_id: int | None + # Cutover: dual-written Artifact id when present (Phase 3 single-read). + artifact_id: int | None = None @classmethod - def of(cls, podcast: Podcast) -> PodcastDetail: + def of(cls, podcast: Podcast, *, artifact_id: int | None = None) -> PodcastDetail: return cls( id=podcast.id, title=podcast.title, @@ -118,4 +120,17 @@ class PodcastDetail(BaseModel): created_at=podcast.created_at, workspace_id=podcast.workspace_id, thread_id=podcast.thread_id, + artifact_id=artifact_id, ) + + @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) diff --git a/surfsense_backend/app/routes/video_presentations_routes.py b/surfsense_backend/app/routes/video_presentations_routes.py index 37796070b..befa48174 100644 --- a/surfsense_backend/app/routes/video_presentations_routes.py +++ b/surfsense_backend/app/routes/video_presentations_routes.py @@ -119,7 +119,17 @@ async def read_video_presentation( "You don't have permission to read video presentations in this workspace", ) - return VideoPresentationRead.from_orm_with_slides(video_pres) + from app.artifacts.media.legacy import existing_legacy_artifact + + art = await existing_legacy_artifact( + session, + workspace_id=video_pres.workspace_id, + kind="video", + legacy_id=video_pres.id, + ) + return VideoPresentationRead.from_orm_with_slides( + video_pres, artifact_id=art.id if art else None + ) except HTTPException as he: raise he except SQLAlchemyError: diff --git a/surfsense_backend/app/schemas/video_presentations.py b/surfsense_backend/app/schemas/video_presentations.py index c34a756f5..d7c34dae6 100644 --- a/surfsense_backend/app/schemas/video_presentations.py +++ b/surfsense_backend/app/schemas/video_presentations.py @@ -45,12 +45,14 @@ class VideoPresentationRead(VideoPresentationBase): created_at: datetime slide_count: int | None = None thread_id: int | None = None + # Cutover: dual-written Artifact id when present (Phase 3). + artifact_id: int | None = None class Config: from_attributes = True @classmethod - def from_orm_with_slides(cls, obj): + def from_orm_with_slides(cls, obj, *, artifact_id: int | None = None): """Create VideoPresentationRead with slide_count computed. Replaces raw server file paths in `audio_file` with API streaming @@ -70,6 +72,7 @@ class VideoPresentationRead(VideoPresentationBase): "created_at": obj.created_at, "slide_count": len(obj.slides) if obj.slides else None, "thread_id": obj.thread_id, + "artifact_id": artifact_id, } return cls(**data) diff --git a/surfsense_backend/tests/unit/podcasts/test_api_schemas.py b/surfsense_backend/tests/unit/podcasts/test_api_schemas.py index 59a4b7abf..3685f4f63 100644 --- a/surfsense_backend/tests/unit/podcasts/test_api_schemas.py +++ b/surfsense_backend/tests/unit/podcasts/test_api_schemas.py @@ -9,6 +9,8 @@ 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 @@ -85,10 +87,25 @@ def test_a_ready_podcast_reports_available_audio(make_spec, make_transcript): duration_seconds=120, ) - detail = PodcastDetail.of(podcast) + detail = PodcastDetail.of(podcast, artifact_id=77) assert detail.status == PodcastStatus.READY assert detail.has_audio is True assert detail.duration_seconds == 120 assert detail.transcript is not None assert detail.error is None + assert detail.artifact_id == 77 + + +@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)), + ) + detail = await PodcastDetail.resolve(AsyncMock(), podcast) + assert detail.artifact_id == 55 diff --git a/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx b/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx index f881be9dd..e2fe0ab26 100644 --- a/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx @@ -213,18 +213,25 @@ function LivePodcastCard({ // a regeneration starts with one). const status = podcast?.status; const [hasEpisode, setHasEpisode] = useState(false); + const [artifactId, setArtifactId] = useState(); useEffect(() => { - if (!status || !BACK_OUT_STATUSES.has(status)) return; - let stale = false; - podcastsApiService - .getDetail(podcastId) - .then((detail) => { - if (!stale) setHasEpisode(detail.has_audio); - }) - .catch(() => {}); - return () => { - stale = true; - }; + 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) { @@ -296,6 +303,8 @@ function LivePodcastCard({
diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts index 8852c4e18..b5c490379 100644 --- a/surfsense_web/contracts/types/podcast.types.ts +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -153,6 +153,7 @@ export const podcastDetail = z.object({ created_at: z.string(), workspace_id: z.number(), thread_id: z.number().nullable(), + artifact_id: z.number().int().positive().nullish(), }); export type PodcastDetail = z.infer;