feat: Added share_token column with migration 026, wired token generati…

- "backend/models.py"
- "alembic/versions/026_add_share_token.py"
- "backend/pipeline/stages.py"
- "backend/routers/shorts.py"
- "backend/routers/shorts_public.py"
- "backend/main.py"

GSD-Task: S01/T01
This commit is contained in:
jlightner 2026-04-04 10:33:00 +00:00
parent 8e27f994db
commit 09177b9d36
6 changed files with 149 additions and 1 deletions

View file

@ -0,0 +1,45 @@
"""Add share_token column to generated_shorts for public sharing.
Revision ID: 026_add_share_token
Revises: 025_add_generated_shorts
"""
import secrets
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
from alembic import op
revision = "026_add_share_token"
down_revision = "025_add_generated_shorts"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add nullable column first
op.add_column(
"generated_shorts",
sa.Column("share_token", sa.String(16), nullable=True),
)
# Backfill existing complete shorts with unique tokens
conn = op.get_bind()
rows = conn.execute(
sa.text("SELECT id FROM generated_shorts WHERE status = 'complete' AND share_token IS NULL")
).fetchall()
for (row_id,) in rows:
token = secrets.token_urlsafe(8) # ~11 chars, fits in String(16)
conn.execute(
sa.text("UPDATE generated_shorts SET share_token = :token WHERE id = :id"),
{"token": token, "id": row_id},
)
# Create unique index
op.create_index("ix_generated_shorts_share_token", "generated_shorts", ["share_token"], unique=True)
def downgrade() -> None:
op.drop_index("ix_generated_shorts_share_token", table_name="generated_shorts")
op.drop_column("generated_shorts", "share_token")

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import get_settings
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, shorts, stats, techniques, topics, videos
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, shorts, shorts_public, stats, techniques, topics, videos
def _setup_logging() -> None:
@ -102,6 +102,7 @@ app.include_router(files.router, prefix="/api/v1")
app.include_router(reports.router, prefix="/api/v1")
app.include_router(search.router, prefix="/api/v1")
app.include_router(shorts.router, prefix="/api/v1")
app.include_router(shorts_public.router, prefix="/api/v1")
app.include_router(stats.router, prefix="/api/v1")
app.include_router(techniques.router, prefix="/api/v1")
app.include_router(topics.router, prefix="/api/v1")

View file

@ -857,6 +857,9 @@ class GeneratedShort(Base):
server_default="pending",
)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
share_token: Mapped[str | None] = mapped_column(
String(16), nullable=True, unique=True, index=True,
)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)

View file

@ -14,6 +14,7 @@ import json
import logging
import os
import re
import secrets
import subprocess
import time
from collections import defaultdict
@ -2999,6 +3000,7 @@ def stage_generate_shorts(self, highlight_candidate_id: str) -> str:
short.status = ShortStatus.complete
short.file_size_bytes = file_size
short.minio_object_key = minio_key
short.share_token = secrets.token_urlsafe(8)
session.commit()
elapsed_preset = time.monotonic() - preset_start

View file

@ -41,6 +41,7 @@ class GeneratedShortResponse(BaseModel):
duration_secs: float | None = None
width: int
height: int
share_token: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
@ -158,6 +159,7 @@ async def list_shorts(
duration_secs=s.duration_secs,
width=s.width,
height=s.height,
share_token=s.share_token,
created_at=s.created_at,
)
for s in shorts

View file

@ -0,0 +1,95 @@
"""Public (unauthenticated) endpoint for sharing generated shorts.
Resolves a share_token to video metadata and a presigned download URL.
No auth dependency anyone with the token can access the short.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from database import get_session
from models import GeneratedShort, HighlightCandidate, ShortStatus, SourceVideo
logger = logging.getLogger("chrysopedia.shorts_public")
router = APIRouter(prefix="/public/shorts", tags=["shorts-public"])
class PublicShortResponse(BaseModel):
"""Public metadata for a shared short — no internal IDs exposed."""
format_preset: str
width: int
height: int
duration_secs: float | None
creator_name: str
highlight_title: str
download_url: str
@router.get("/{share_token}", response_model=PublicShortResponse)
async def get_public_short(
share_token: str,
db: AsyncSession = Depends(get_session),
):
"""Resolve a share token to short metadata and a presigned download URL."""
stmt = (
select(GeneratedShort)
.where(GeneratedShort.share_token == share_token)
.options(
selectinload(GeneratedShort.highlight_candidate)
.selectinload(HighlightCandidate.key_moment),
selectinload(GeneratedShort.highlight_candidate)
.selectinload(HighlightCandidate.source_video)
.selectinload(SourceVideo.creator),
)
)
result = await db.execute(stmt)
short = result.scalar_one_or_none()
if short is None:
raise HTTPException(status_code=404, detail="Short not found")
if short.status != ShortStatus.complete:
raise HTTPException(status_code=404, detail="Short not found")
if not short.minio_object_key:
raise HTTPException(
status_code=500,
detail="Short is complete but has no storage key",
)
highlight = short.highlight_candidate
key_moment = highlight.key_moment
source_video = highlight.source_video
creator = source_video.creator
from minio_client import generate_download_url
try:
url = generate_download_url(short.minio_object_key)
except Exception as exc:
logger.error(
"Failed to generate download URL for share_token=%s: %s",
share_token, exc,
)
raise HTTPException(
status_code=503,
detail="Failed to generate download URL",
) from exc
return PublicShortResponse(
format_preset=short.format_preset.value,
width=short.width,
height=short.height,
duration_secs=short.duration_secs,
creator_name=creator.name,
highlight_title=key_moment.title,
download_url=url,
)