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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import get_settings
|
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:
|
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(reports.router, prefix="/api/v1")
|
||||||
app.include_router(search.router, prefix="/api/v1")
|
app.include_router(search.router, prefix="/api/v1")
|
||||||
app.include_router(shorts.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(stats.router, prefix="/api/v1")
|
||||||
app.include_router(techniques.router, prefix="/api/v1")
|
app.include_router(techniques.router, prefix="/api/v1")
|
||||||
app.include_router(topics.router, prefix="/api/v1")
|
app.include_router(topics.router, prefix="/api/v1")
|
||||||
|
|
|
||||||
|
|
@ -857,6 +857,9 @@ class GeneratedShort(Base):
|
||||||
server_default="pending",
|
server_default="pending",
|
||||||
)
|
)
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
default=_now, server_default=func.now()
|
default=_now, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
@ -2999,6 +3000,7 @@ def stage_generate_shorts(self, highlight_candidate_id: str) -> str:
|
||||||
short.status = ShortStatus.complete
|
short.status = ShortStatus.complete
|
||||||
short.file_size_bytes = file_size
|
short.file_size_bytes = file_size
|
||||||
short.minio_object_key = minio_key
|
short.minio_object_key = minio_key
|
||||||
|
short.share_token = secrets.token_urlsafe(8)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
elapsed_preset = time.monotonic() - preset_start
|
elapsed_preset = time.monotonic() - preset_start
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ class GeneratedShortResponse(BaseModel):
|
||||||
duration_secs: float | None = None
|
duration_secs: float | None = None
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
|
share_token: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
@ -158,6 +159,7 @@ async def list_shorts(
|
||||||
duration_secs=s.duration_secs,
|
duration_secs=s.duration_secs,
|
||||||
width=s.width,
|
width=s.width,
|
||||||
height=s.height,
|
height=s.height,
|
||||||
|
share_token=s.share_token,
|
||||||
created_at=s.created_at,
|
created_at=s.created_at,
|
||||||
)
|
)
|
||||||
for s in shorts
|
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