mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-12 20:45:20 +02:00
feat(podcasts): add persistence model and repository
This commit is contained in:
parent
73e191af09
commit
65b6c2d357
5 changed files with 181 additions and 0 deletions
9
surfsense_backend/app/podcasts/persistence/__init__.py
Normal file
9
surfsense_backend/app/podcasts/persistence/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""Models, enums, and data access for the podcasts table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .enums import PodcastStatus
|
||||
from .models import Podcast
|
||||
from .repository import PodcastRepository
|
||||
|
||||
__all__ = ["Podcast", "PodcastRepository", "PodcastStatus"]
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"""Enums for the podcasts table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .podcast_status import PodcastStatus
|
||||
|
||||
__all__ = ["PodcastStatus"]
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"""Podcast generation lifecycle.
|
||||
|
||||
The status drives a guarded state machine. A podcast is proposed (``PENDING``),
|
||||
gets a reviewable brief (``AWAITING_BRIEF``), is drafted into a transcript
|
||||
(``DRAFTING`` → ``AWAITING_REVIEW``), then rendered to audio (``RENDERING`` →
|
||||
``READY``). ``FAILED`` and ``CANCELLED`` are terminal. The Python enum is kept
|
||||
in lockstep with the ``podcast_status`` Postgres type via its paired migration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class PodcastStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
AWAITING_BRIEF = "awaiting_brief"
|
||||
DRAFTING = "drafting"
|
||||
AWAITING_REVIEW = "awaiting_review"
|
||||
RENDERING = "rendering"
|
||||
READY = "ready"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
"""Whether no further transition is possible from this state."""
|
||||
return self in _TERMINAL
|
||||
|
||||
@property
|
||||
def is_gate(self) -> bool:
|
||||
"""Whether this state waits on user input before proceeding."""
|
||||
return self in _GATES
|
||||
|
||||
|
||||
_TERMINAL = frozenset({PodcastStatus.READY, PodcastStatus.FAILED, PodcastStatus.CANCELLED})
|
||||
_GATES = frozenset({PodcastStatus.AWAITING_BRIEF, PodcastStatus.AWAITING_REVIEW})
|
||||
82
surfsense_backend/app/podcasts/persistence/models.py
Normal file
82
surfsense_backend/app/podcasts/persistence/models.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""``podcasts`` table: a generated podcast, its brief, transcript, and state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Enum as SQLAlchemyEnum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db import BaseModel, TimestampMixin
|
||||
|
||||
from .enums import PodcastStatus
|
||||
|
||||
|
||||
class Podcast(BaseModel, TimestampMixin):
|
||||
"""A podcast across its whole lifecycle: brief, transcript, audio, status.
|
||||
|
||||
``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.
|
||||
"""
|
||||
|
||||
__tablename__ = "podcasts"
|
||||
|
||||
title = Column(String(500), nullable=False)
|
||||
|
||||
status = Column(
|
||||
SQLAlchemyEnum(
|
||||
PodcastStatus,
|
||||
name="podcast_status",
|
||||
create_type=False,
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
nullable=False,
|
||||
default=PodcastStatus.PENDING,
|
||||
server_default=PodcastStatus.PENDING.value,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# The source material the episode is generated from. Persisted because
|
||||
# drafting happens after the brief gate, long after creation.
|
||||
source_content = Column(Text, nullable=True)
|
||||
|
||||
# The reviewable brief (PodcastSpec); null until the brief gate is reached.
|
||||
spec = Column(JSONB, nullable=True)
|
||||
# Bumped on every spec edit; guards concurrent edits at the brief gate.
|
||||
spec_version = Column(Integer, nullable=False, default=1, server_default="1")
|
||||
|
||||
# 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)
|
||||
|
||||
search_space_id = Column(
|
||||
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
search_space = relationship("SearchSpace", back_populates="podcasts")
|
||||
|
||||
thread_id = Column(
|
||||
Integer,
|
||||
ForeignKey("new_chat_threads.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
thread = relationship("NewChatThread")
|
||||
46
surfsense_backend/app/podcasts/persistence/repository.py
Normal file
46
surfsense_backend/app/podcasts/persistence/repository.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Data access for the ``podcasts`` table.
|
||||
|
||||
A thin async repository so the service and tasks never write raw queries. It
|
||||
only loads and persists rows; lifecycle rules and (de)serialization live in the
|
||||
service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .models import Podcast
|
||||
|
||||
|
||||
class PodcastRepository:
|
||||
"""Loads and stores :class:`Podcast` rows for one session."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get(self, podcast_id: int) -> Podcast | None:
|
||||
return await self._session.get(Podcast, podcast_id)
|
||||
|
||||
async def add(self, podcast: Podcast) -> Podcast:
|
||||
"""Persist a new row and assign its primary key."""
|
||||
self._session.add(podcast)
|
||||
await self._session.flush()
|
||||
return podcast
|
||||
|
||||
async def latest_with_spec(self, search_space_id: int) -> Podcast | None:
|
||||
"""Most recent podcast in the space that has a stored brief.
|
||||
|
||||
Used to seed language/voice defaults for a new podcast from what the
|
||||
user chose last.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(Podcast)
|
||||
.where(
|
||||
Podcast.search_space_id == search_space_id,
|
||||
Podcast.spec.is_not(None),
|
||||
)
|
||||
.order_by(Podcast.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
Loading…
Add table
Add a link
Reference in a new issue