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:
parent
8e27f994db
commit
09177b9d36
6 changed files with 149 additions and 1 deletions
45
alembic/versions/026_add_share_token.py
Normal file
45
alembic/versions/026_add_share_token.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
95
backend/routers/shorts_public.py
Normal file
95
backend/routers/shorts_public.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue