fix: Creators endpoint returns paginated response, review queue limit raised to 1000, added GET /review/moments/{id} endpoint

- Creators: response_model changed from list to {items, total, offset, limit} matching frontend CreatorBrowseResponse
- Review queue: limit raised from 100 to 1000
- New GET /review/moments/{moment_id} endpoint for direct moment fetch
- MomentDetail uses fetchMoment instead of fetching full queue
- Merge candidates fetch uses limit=100
This commit is contained in:
jlightner 2026-03-30 01:26:12 -05:00
parent 0b0ca598b4
commit 76138887d2
4 changed files with 44 additions and 15 deletions

View file

@ -20,14 +20,14 @@ logger = logging.getLogger("chrysopedia.creators")
router = APIRouter(prefix="/creators", tags=["creators"]) router = APIRouter(prefix="/creators", tags=["creators"])
@router.get("", response_model=list[CreatorBrowseItem]) @router.get("")
async def list_creators( async def list_creators(
sort: Annotated[str, Query()] = "random", sort: Annotated[str, Query()] = "random",
genre: Annotated[str | None, Query()] = None, genre: Annotated[str | None, Query()] = None,
offset: Annotated[int, Query(ge=0)] = 0, offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50, limit: Annotated[int, Query(ge=1, le=100)] = 50,
db: AsyncSession = Depends(get_session), db: AsyncSession = Depends(get_session),
) -> list[CreatorBrowseItem]: ):
"""List creators with sort, genre filter, and technique/video counts. """List creators with sort, genre filter, and technique/video counts.
- **sort**: ``random`` (default, R014 creator equity), ``alpha``, ``views`` - **sort**: ``random`` (default, R014 creator equity), ``alpha``, ``views``
@ -80,11 +80,17 @@ async def list_creators(
CreatorBrowseItem(**base.model_dump(), technique_count=tc, video_count=vc) CreatorBrowseItem(**base.model_dump(), technique_count=tc, video_count=vc)
) )
# Get total count (without offset/limit)
count_stmt = select(func.count()).select_from(Creator)
if genre:
count_stmt = count_stmt.where(Creator.genres.any(genre))
total = (await db.execute(count_stmt)).scalar() or 0
logger.debug( logger.debug(
"Listed %d creators (sort=%s, genre=%s, offset=%d, limit=%d)", "Listed %d creators (sort=%s, genre=%s, offset=%d, limit=%d)",
len(items), sort, genre, offset, limit, len(items), sort, genre, offset, limit,
) )
return items return {"items": items, "total": total, "offset": offset, "limit": limit}
@router.get("/{slug}", response_model=CreatorDetail) @router.get("/{slug}", response_model=CreatorDetail)

View file

@ -57,7 +57,7 @@ def _moment_to_queue_item(
async def list_queue( async def list_queue(
status: Annotated[str, Query()] = "pending", status: Annotated[str, Query()] = "pending",
offset: Annotated[int, Query(ge=0)] = 0, offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50, limit: Annotated[int, Query(ge=1, le=1000)] = 50,
db: AsyncSession = Depends(get_session), db: AsyncSession = Depends(get_session),
) -> ReviewQueueResponse: ) -> ReviewQueueResponse:
"""List key moments in the review queue, filtered by status.""" """List key moments in the review queue, filtered by status."""
@ -313,6 +313,27 @@ async def merge_moments(
return KeyMomentRead.model_validate(source) return KeyMomentRead.model_validate(source)
@router.get("/moments/{moment_id}", response_model=ReviewQueueItem)
async def get_moment(
moment_id: uuid.UUID,
db: AsyncSession = Depends(get_session),
) -> ReviewQueueItem:
"""Get a single key moment by ID with video and creator info."""
stmt = (
select(KeyMoment, SourceVideo.file_path, Creator.name)
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
.join(Creator, SourceVideo.creator_id == Creator.id)
.where(KeyMoment.id == moment_id)
)
result = await db.execute(stmt)
row = result.one_or_none()
if row is None:
raise HTTPException(status_code=404, detail=f"Moment {moment_id} not found")
moment, file_path, creator_name = row
return _moment_to_queue_item(moment, file_path or "", creator_name)
@router.get("/mode", response_model=ReviewModeResponse) @router.get("/mode", response_model=ReviewModeResponse)
async def get_mode() -> ReviewModeResponse: async def get_mode() -> ReviewModeResponse:
"""Get the current review mode (review vs auto).""" """Get the current review mode (review vs auto)."""

View file

@ -121,6 +121,12 @@ export async function fetchQueue(
); );
} }
export async function fetchMoment(
momentId: string,
): Promise<ReviewQueueItem> {
return request<ReviewQueueItem>(`${BASE}/moments/${momentId}`);
}
export async function fetchStats(): Promise<ReviewStatsResponse> { export async function fetchStats(): Promise<ReviewStatsResponse> {
return request<ReviewStatsResponse>(`${BASE}/stats`); return request<ReviewStatsResponse>(`${BASE}/stats`);
} }

View file

@ -11,6 +11,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useParams, useNavigate, Link } from "react-router-dom"; import { useParams, useNavigate, Link } from "react-router-dom";
import { import {
fetchMoment,
fetchQueue, fetchQueue,
approveMoment, approveMoment,
rejectMoment, rejectMoment,
@ -59,16 +60,11 @@ export default function MomentDetail() {
setError(null); setError(null);
try { try {
// Fetch all moments and find the one matching our ID // Fetch all moments and find the one matching our ID
const res = await fetchQueue({ limit: 500 }); const found = await fetchMoment(momentId);
const found = res.items.find((m) => m.id === momentId); setMoment(found);
if (!found) { setEditTitle(found.title);
setError("Moment not found"); setEditSummary(found.summary);
} else { setEditContentType(found.content_type);
setMoment(found);
setEditTitle(found.title);
setEditSummary(found.summary);
setEditContentType(found.content_type);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load moment"); setError(err instanceof Error ? err.message : "Failed to load moment");
} finally { } finally {
@ -174,7 +170,7 @@ export default function MomentDetail() {
setActionError(null); setActionError(null);
try { try {
// Load moments from the same video for merge candidates // Load moments from the same video for merge candidates
const res = await fetchQueue({ limit: 500 }); const res = await fetchQueue({ limit: 100 });
const candidates = res.items.filter( const candidates = res.items.filter(
(m) => m.source_video_id === moment.source_video_id && m.id !== moment.id (m) => m.source_video_id === moment.source_video_id && m.id !== moment.id
); );