- "backend/routers/creators.py" - "backend/schemas.py" - "frontend/src/api/templates.ts" - "frontend/src/pages/HighlightQueue.tsx" - "frontend/src/pages/HighlightQueue.module.css" - "backend/routers/shorts.py" - "backend/pipeline/stages.py" - "frontend/src/api/shorts.ts" GSD-Task: S04/T03
298 lines
9.6 KiB
Python
298 lines
9.6 KiB
Python
"""Creator endpoints for Chrysopedia API.
|
|
|
|
Enhanced with sort (random default per R014), genre filter, and
|
|
technique/video counts for browse pages. Includes admin endpoints
|
|
for shorts template configuration.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from auth import require_role
|
|
from database import get_session
|
|
from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage, User, UserRole
|
|
from schemas import (
|
|
CreatorBrowseItem,
|
|
CreatorDetail,
|
|
CreatorRead,
|
|
CreatorTechniqueItem,
|
|
ShortsTemplateConfig,
|
|
ShortsTemplateUpdate,
|
|
)
|
|
|
|
logger = logging.getLogger("chrysopedia.creators")
|
|
|
|
router = APIRouter(prefix="/creators", tags=["creators"])
|
|
|
|
_require_admin = require_role(UserRole.admin)
|
|
|
|
|
|
@router.get("")
|
|
async def list_creators(
|
|
sort: Annotated[str, Query()] = "random",
|
|
genre: Annotated[str | None, Query()] = None,
|
|
offset: Annotated[int, Query(ge=0)] = 0,
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
|
db: AsyncSession = Depends(get_session),
|
|
):
|
|
"""List creators with sort, genre filter, and technique/video counts.
|
|
|
|
- **sort**: ``random`` (default, R014 creator equity), ``alpha``, ``views``
|
|
- **genre**: filter by genre (matches against ARRAY column)
|
|
"""
|
|
# Subqueries for counts
|
|
technique_count_sq = (
|
|
select(func.count())
|
|
.where(TechniquePage.creator_id == Creator.id)
|
|
.correlate(Creator)
|
|
.scalar_subquery()
|
|
)
|
|
video_count_sq = (
|
|
select(func.count())
|
|
.where(SourceVideo.creator_id == Creator.id)
|
|
.correlate(Creator)
|
|
.scalar_subquery()
|
|
)
|
|
last_technique_sq = (
|
|
select(func.max(TechniquePage.created_at))
|
|
.where(TechniquePage.creator_id == Creator.id)
|
|
.correlate(Creator)
|
|
.scalar_subquery()
|
|
)
|
|
|
|
stmt = select(
|
|
Creator,
|
|
technique_count_sq.label("technique_count"),
|
|
video_count_sq.label("video_count"),
|
|
last_technique_sq.label("last_technique_at"),
|
|
).where(Creator.hidden != True) # noqa: E712
|
|
|
|
# Genre filter
|
|
if genre:
|
|
stmt = stmt.where(Creator.genres.any(genre))
|
|
|
|
# Sorting
|
|
if sort == "alpha":
|
|
stmt = stmt.order_by(Creator.name)
|
|
elif sort == "views":
|
|
stmt = stmt.order_by(Creator.view_count.desc())
|
|
else:
|
|
# Default: random (small dataset <100, func.random() is fine)
|
|
stmt = stmt.order_by(func.random())
|
|
|
|
stmt = stmt.offset(offset).limit(limit)
|
|
result = await db.execute(stmt)
|
|
rows = result.all()
|
|
|
|
items: list[CreatorBrowseItem] = []
|
|
for row in rows:
|
|
creator = row[0]
|
|
tc = row[1] or 0
|
|
vc = row[2] or 0
|
|
lta = row[3] # None when creator has 0 techniques
|
|
base = CreatorRead.model_validate(creator)
|
|
items.append(
|
|
CreatorBrowseItem(
|
|
**base.model_dump(),
|
|
technique_count=tc,
|
|
video_count=vc,
|
|
last_technique_at=lta,
|
|
)
|
|
)
|
|
|
|
# Get total count (without offset/limit)
|
|
count_stmt = select(func.count()).select_from(Creator).where(Creator.hidden != True) # noqa: E712
|
|
if genre:
|
|
count_stmt = count_stmt.where(Creator.genres.any(genre))
|
|
total = (await db.execute(count_stmt)).scalar() or 0
|
|
|
|
logger.debug(
|
|
"Listed %d creators (sort=%s, genre=%s, offset=%d, limit=%d)",
|
|
len(items), sort, genre, offset, limit,
|
|
)
|
|
return {"items": items, "total": total, "offset": offset, "limit": limit}
|
|
|
|
|
|
@router.get("/{slug}", response_model=CreatorDetail)
|
|
async def get_creator(
|
|
slug: str,
|
|
db: AsyncSession = Depends(get_session),
|
|
) -> CreatorDetail:
|
|
"""Get a single creator by slug with full profile data.
|
|
|
|
Returns video/technique counts, full technique list, genre breakdown,
|
|
bio, social links, and avatar for the creator landing page.
|
|
"""
|
|
stmt = select(Creator).where(Creator.slug == slug)
|
|
result = await db.execute(stmt)
|
|
creator = result.scalar_one_or_none()
|
|
|
|
if creator is None:
|
|
raise HTTPException(status_code=404, detail=f"Creator '{slug}' not found")
|
|
|
|
# Video count
|
|
video_count = (await db.execute(
|
|
select(func.count()).select_from(SourceVideo)
|
|
.where(SourceVideo.creator_id == creator.id)
|
|
)).scalar() or 0
|
|
|
|
# Moment count (key moments across all creator's videos)
|
|
moment_count = (await db.execute(
|
|
select(func.count()).select_from(KeyMoment)
|
|
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
|
|
.where(SourceVideo.creator_id == creator.id)
|
|
)).scalar() or 0
|
|
|
|
# Technique pages for this creator
|
|
key_moment_count_sq = (
|
|
select(func.count(KeyMoment.id))
|
|
.where(KeyMoment.technique_page_id == TechniquePage.id)
|
|
.correlate(TechniquePage)
|
|
.scalar_subquery()
|
|
.label("key_moment_count")
|
|
)
|
|
technique_rows = (await db.execute(
|
|
select(
|
|
TechniquePage.title,
|
|
TechniquePage.slug,
|
|
TechniquePage.topic_category,
|
|
TechniquePage.created_at,
|
|
TechniquePage.summary,
|
|
TechniquePage.topic_tags,
|
|
key_moment_count_sq,
|
|
)
|
|
.where(TechniquePage.creator_id == creator.id)
|
|
.order_by(TechniquePage.created_at.desc())
|
|
)).all()
|
|
|
|
techniques = [
|
|
CreatorTechniqueItem(
|
|
title=t.title, slug=t.slug,
|
|
topic_category=t.topic_category, created_at=t.created_at,
|
|
summary=t.summary, topic_tags=t.topic_tags,
|
|
key_moment_count=t.key_moment_count,
|
|
)
|
|
for t in technique_rows
|
|
]
|
|
|
|
# Genre breakdown across this creator's techniques
|
|
genre_breakdown: dict[str, int] = {}
|
|
for t in technique_rows:
|
|
cat = t.topic_category
|
|
genre_breakdown[cat] = genre_breakdown.get(cat, 0) + 1
|
|
|
|
creator_data = CreatorRead.model_validate(creator)
|
|
|
|
# Follower count
|
|
follower_count = (await db.execute(
|
|
select(func.count()).select_from(CreatorFollow)
|
|
.where(CreatorFollow.creator_id == creator.id)
|
|
)).scalar() or 0
|
|
|
|
return CreatorDetail(
|
|
**creator_data.model_dump(),
|
|
bio=creator.bio,
|
|
social_links=creator.social_links,
|
|
personality_profile=creator.personality_profile,
|
|
featured=creator.featured,
|
|
video_count=video_count,
|
|
technique_count=len(techniques),
|
|
moment_count=moment_count,
|
|
techniques=techniques,
|
|
genre_breakdown=genre_breakdown,
|
|
follower_count=follower_count,
|
|
)
|
|
|
|
|
|
# ── Admin: Shorts Template Config ────────────────────────────────────────────
|
|
|
|
admin_router = APIRouter(prefix="/admin/creators", tags=["admin-creators"])
|
|
|
|
|
|
@admin_router.get(
|
|
"/{creator_id}/shorts-template",
|
|
response_model=ShortsTemplateConfig,
|
|
)
|
|
async def get_shorts_template(
|
|
creator_id: str,
|
|
_admin: Annotated[User, Depends(_require_admin)],
|
|
db: AsyncSession = Depends(get_session),
|
|
) -> ShortsTemplateConfig:
|
|
"""Return the current shorts template config for a creator.
|
|
|
|
Returns default values when the creator has no template set.
|
|
"""
|
|
stmt = select(Creator).where(Creator.id == creator_id)
|
|
result = await db.execute(stmt)
|
|
creator = result.scalar_one_or_none()
|
|
|
|
if creator is None:
|
|
raise HTTPException(status_code=404, detail=f"Creator not found: {creator_id}")
|
|
|
|
raw = creator.shorts_template or {}
|
|
return ShortsTemplateConfig(
|
|
show_intro=bool(raw.get("show_intro", False)),
|
|
intro_text=str(raw.get("intro_text", "")),
|
|
intro_duration_secs=float(raw.get("intro_duration", 2.0)),
|
|
show_outro=bool(raw.get("show_outro", False)),
|
|
outro_text=str(raw.get("outro_text", "")),
|
|
outro_duration_secs=float(raw.get("outro_duration", 2.0)),
|
|
accent_color=str(raw.get("accent_color", "#22d3ee")),
|
|
font_family=str(raw.get("font_family", "Inter")),
|
|
)
|
|
|
|
|
|
@admin_router.put(
|
|
"/{creator_id}/shorts-template",
|
|
response_model=ShortsTemplateConfig,
|
|
)
|
|
async def update_shorts_template(
|
|
creator_id: str,
|
|
body: ShortsTemplateUpdate,
|
|
_admin: Annotated[User, Depends(_require_admin)],
|
|
db: AsyncSession = Depends(get_session),
|
|
) -> ShortsTemplateConfig:
|
|
"""Save shorts template config for a creator.
|
|
|
|
Validates all fields and stores as JSONB on the Creator row.
|
|
"""
|
|
stmt = select(Creator).where(Creator.id == creator_id)
|
|
result = await db.execute(stmt)
|
|
creator = result.scalar_one_or_none()
|
|
|
|
if creator is None:
|
|
raise HTTPException(status_code=404, detail=f"Creator not found: {creator_id}")
|
|
|
|
# Store using the keys that card_renderer.parse_template_config expects
|
|
creator.shorts_template = {
|
|
"show_intro": body.show_intro,
|
|
"intro_text": body.intro_text,
|
|
"intro_duration": body.intro_duration_secs,
|
|
"show_outro": body.show_outro,
|
|
"outro_text": body.outro_text,
|
|
"outro_duration": body.outro_duration_secs,
|
|
"accent_color": body.accent_color,
|
|
"font_family": body.font_family,
|
|
}
|
|
await db.commit()
|
|
await db.refresh(creator)
|
|
|
|
logger.info(
|
|
"Updated shorts template for creator_id=%s (intro=%s, outro=%s)",
|
|
creator_id, body.show_intro, body.show_outro,
|
|
)
|
|
|
|
return ShortsTemplateConfig(
|
|
show_intro=body.show_intro,
|
|
intro_text=body.intro_text,
|
|
intro_duration_secs=body.intro_duration_secs,
|
|
show_outro=body.show_outro,
|
|
outro_text=body.outro_text,
|
|
outro_duration_secs=body.outro_duration_secs,
|
|
accent_color=body.accent_color,
|
|
font_family=body.font_family,
|
|
)
|