feat: Added ChapterStatus enum, sort_order column, migration 020, chapt…

- "backend/models.py"
- "backend/schemas.py"
- "alembic/versions/020_add_chapter_status_and_sort_order.py"
- "backend/routers/creator_chapters.py"
- "backend/routers/videos.py"
- "backend/main.py"

GSD-Task: S06/T01
This commit is contained in:
jlightner 2026-04-04 06:03:49 +00:00
parent 76880d0477
commit ed9aa7a83a
16 changed files with 878 additions and 8 deletions

View file

@ -10,7 +10,7 @@ LightRAG becomes the primary search engine. Chat engine goes live (encyclopedic
| S02 | [B] Creator-Scoped Retrieval Cascade | medium | S01 | ✅ | Question on Keota's profile first checks Keota's content, then sound design domain, then full KB, then graceful fallback |
| S03 | [B] Chat Engine MVP | high | S02 | ✅ | User asks a question, receives a streamed response with citations linking to source videos and technique pages |
| S04 | [B] Highlight Detection v1 | medium | — | ✅ | Scored highlight candidates generated from existing pipeline data for a sample of videos |
| S05 | [A] Audio Mode + Chapter Markers | medium | — | | Media player with waveform visualization in audio mode and chapter markers on the timeline |
| S05 | [A] Audio Mode + Chapter Markers | medium | — | | Media player with waveform visualization in audio mode and chapter markers on the timeline |
| S06 | [A] Auto-Chapters Review UI | low | — | ⬜ | Creator reviews detected chapters: drag boundaries, rename, reorder, approve for publication |
| S07 | [A] Impersonation Polish + Write Mode | low | — | ⬜ | Impersonation write mode with confirmation modal. Audit log admin view shows all sessions. |
| S08 | Forgejo KB Update — Chat, Retrieval, Highlights | low | S01, S02, S03, S04, S05, S06, S07 | ⬜ | Forgejo wiki updated with chat engine, retrieval routing, and highlight detection docs |

View file

@ -0,0 +1,114 @@
---
id: S05
parent: M021
milestone: M021
provides:
- GET /videos/{video_id}/stream endpoint for media file serving
- GET /videos/{video_id}/chapters endpoint returning KeyMoment-based chapter data
- fetchChapters() frontend API client function
- AudioWaveform component for waveform visualization
- ChapterMarkers component for seek bar chapter overlay
- HTMLMediaElement-widened useMediaSync hook
requires:
[]
affects:
- S06
key_files:
- backend/routers/videos.py
- backend/schemas.py
- frontend/src/api/videos.ts
- frontend/src/components/AudioWaveform.tsx
- frontend/src/components/ChapterMarkers.tsx
- frontend/src/components/PlayerControls.tsx
- frontend/src/hooks/useMediaSync.ts
- frontend/src/pages/WatchPage.tsx
- frontend/src/App.css
key_decisions:
- Stream endpoint uses FileResponse with mimetypes.guess_type for content-type detection
- Chapters endpoint maps KeyMoment records directly to ChapterMarkerRead schema
- wavesurfer.js uses MediaElement backend with shared audio ref so useMediaSync controls playback identically to video mode
- useMediaSync widened from HTMLVideoElement to HTMLMediaElement for audio/video polymorphism
- Chapter ticks use button elements for keyboard accessibility
- RegionsPlugin registered at WaveSurfer creation, regions added on ready event
patterns_established:
- Conditional media rendering pattern: WatchPage checks video_url to decide AudioWaveform vs VideoPlayer
- HTMLMediaElement polymorphism: useMediaSync works for both audio and video elements without code changes
- Non-critical data fetching: chapters are fetched with silent error catching so page works even if chapters fail
observability_surfaces:
- none
drill_down_paths:
- .gsd/milestones/M021/slices/S05/tasks/T01-SUMMARY.md
- .gsd/milestones/M021/slices/S05/tasks/T02-SUMMARY.md
- .gsd/milestones/M021/slices/S05/tasks/T03-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-04-04T05:54:51.869Z
blocker_discovered: false
---
# S05: [A] Audio Mode + Chapter Markers
**Media player renders waveform visualization for audio-only content and chapter markers on the timeline derived from KeyMoment data.**
## What Happened
This slice added three capabilities across backend and frontend:
**T01 — Backend endpoints + API client:** Added `GET /videos/{video_id}/stream` (serves media files via FileResponse with MIME type detection) and `GET /videos/{video_id}/chapters` (returns KeyMoment records as chapter markers sorted by start_time). Added `ChapterMarkerRead` and `ChaptersResponse` Pydantic schemas. Added `fetchChapters()` to the frontend API client.
**T02 — Audio waveform rendering:** Installed wavesurfer.js. Widened `useMediaSync` ref type from `HTMLVideoElement` to `HTMLMediaElement` so the same playback hook works for both audio and video. Created `AudioWaveform.tsx` that renders a hidden `<audio>` element shared between useMediaSync and a WaveSurfer instance (MediaElement backend). WatchPage conditionally renders AudioWaveform when `video_url` is null, or VideoPlayer when present. Dark-themed CSS matches the video player area.
**T03 — Chapter markers integration:** Created `ChapterMarkers.tsx` as a positioned overlay rendering tick marks on the seek bar with hover tooltips and click-to-seek. Updated PlayerControls to accept chapters and wrap the seek input in a container div. Added RegionsPlugin to AudioWaveform for labeled chapter regions in waveform mode. WatchPage fetches chapters on load and distributes to both components. Chapter fetch errors are caught silently since chapters are non-critical.
## Verification
All slice-level verification checks pass:
- `cd frontend && npx tsc --noEmit` — exits 0, no type errors
- `grep -q 'ChapterMarkers' frontend/src/components/PlayerControls.tsx` — found
- `grep -q 'fetchChapters' frontend/src/pages/WatchPage.tsx` — found
- `grep -q 'chapter-marker' frontend/src/App.css` — found
- `cd backend && python -c "from routers.videos import router; print('ok')"` — exits 0
- `grep -q 'def get_video_chapters' backend/routers/videos.py` — found
- `grep -q 'def stream_video' backend/routers/videos.py` — found
- `grep -q 'HTMLMediaElement' frontend/src/hooks/useMediaSync.ts` — found
## Requirements Advanced
None.
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
Used project accent color (#22d3ee cyan) instead of plan-suggested #00ffd1 for waveform colors to match existing theme. Chapter tick marks use button elements instead of divs for keyboard accessibility.
## Known Limitations
Stream endpoint serves files from local disk paths — requires file_path to be set on SourceVideo records. No range request support (no seeking in large files without full download). Chapter regions in waveform mode are visual-only (no click handler on regions — users click the seek bar ticks instead).
## Follow-ups
Consider adding HTTP range request support to the stream endpoint for large audio files. AudioWaveform region click-to-seek could be added via wavesurfer region-clicked event.
## Files Created/Modified
- `backend/routers/videos.py` — Added stream_video and get_video_chapters endpoints
- `backend/schemas.py` — Added ChapterMarkerRead and ChaptersResponse Pydantic models
- `frontend/src/api/videos.ts` — Added Chapter/ChaptersResponse interfaces and fetchChapters function
- `frontend/src/components/AudioWaveform.tsx` — New component: wavesurfer.js waveform with RegionsPlugin for chapters
- `frontend/src/components/ChapterMarkers.tsx` — New component: positioned chapter tick overlay for seek bar
- `frontend/src/components/PlayerControls.tsx` — Added chapters prop, seek container wrapper, ChapterMarkers rendering
- `frontend/src/hooks/useMediaSync.ts` — Widened ref type from HTMLVideoElement to HTMLMediaElement
- `frontend/src/pages/WatchPage.tsx` — Added chapter fetching, conditional AudioWaveform/VideoPlayer rendering
- `frontend/src/App.css` — Added audio-waveform and chapter-marker CSS styles
- `frontend/package.json` — Added wavesurfer.js dependency

View file

@ -0,0 +1,66 @@
# S05: [A] Audio Mode + Chapter Markers — UAT
**Milestone:** M021
**Written:** 2026-04-04T05:54:51.869Z
# S05 UAT: Audio Mode + Chapter Markers
## Preconditions
- Chrysopedia stack running on ub01 (docker compose up)
- At least one SourceVideo with `file_path` set and no `video_url` (audio-only)
- At least one SourceVideo with `video_url` set (video mode)
- KeyMoment records exist for at least one video
---
## Test Case 1: Stream Endpoint Serves Audio File
1. Identify a video_id with `file_path` set: `SELECT id, file_path FROM source_videos WHERE file_path IS NOT NULL LIMIT 1`
2. `curl -I http://ub01:8096/api/v1/videos/{video_id}/stream`
3. **Expected:** 200 OK, Content-Type matches file extension (audio/wav, audio/mpeg, etc.)
## Test Case 2: Stream Endpoint 404 on Missing Video
1. `curl -s http://ub01:8096/api/v1/videos/00000000-0000-0000-0000-000000000000/stream`
2. **Expected:** 404 with detail message
## Test Case 3: Chapters Endpoint Returns Sorted KeyMoments
1. Identify a video_id with KeyMoments: `SELECT source_video_id FROM key_moments GROUP BY source_video_id LIMIT 1`
2. `curl -s http://ub01:8096/api/v1/videos/{video_id}/chapters | python3 -m json.tool`
3. **Expected:** JSON with `video_id` and `chapters` array. Chapters sorted by `start_time` ascending. Each chapter has id, title, start_time, end_time, content_type.
## Test Case 4: Chapters Endpoint Returns Empty for Video Without KeyMoments
1. Find a video with no key moments
2. `curl -s http://ub01:8096/api/v1/videos/{video_id}/chapters`
3. **Expected:** 200 with `chapters: []`
## Test Case 5: Audio Mode Waveform Renders
1. Navigate to WatchPage for a video with `file_path` but no `video_url`
2. **Expected:** Audio waveform visualization renders in dark container matching video player area styling. No `<video>` element visible. Hidden `<audio>` element present in DOM.
## Test Case 6: Video Mode Still Works
1. Navigate to WatchPage for a video with `video_url` set
2. **Expected:** Normal VideoPlayer renders (not AudioWaveform). Play/pause/seek controls work as before.
## Test Case 7: Chapter Markers on Seek Bar
1. Navigate to WatchPage for a video with KeyMoments
2. **Expected:** Colored tick marks appear on the seek bar at positions corresponding to chapter start times
3. Hover a tick mark
4. **Expected:** Tooltip appears showing chapter title
5. Click a tick mark
6. **Expected:** Playback seeks to chapter start_time
## Test Case 8: Chapter Regions in Waveform Mode
1. Navigate to WatchPage for an audio-only video with KeyMoments
2. **Expected:** Waveform displays labeled regions for each chapter with semi-transparent accent color
## Test Case 9: Graceful Degradation Without Chapters
1. Navigate to WatchPage for a video with no KeyMoments
2. **Expected:** Player renders normally. No chapter markers on seek bar. No errors in console.
## Test Case 10: TypeScript Compilation
1. `cd frontend && npx tsc --noEmit`
2. **Expected:** Exits 0 with no type errors
## Edge Cases
- Video with `file_path` pointing to non-existent file → stream endpoint returns 404
- Video with chapters but duration=0 → ChapterMarkers renders nothing (guard clause)
- Network error fetching chapters → WatchPage catches silently, player works without chapters

View file

@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"taskId": "T03",
"unitId": "M021/S05/T03",
"timestamp": 1775281999230,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd /home/aux/projects/content-to-kb-automator/frontend",
"exitCode": 0,
"durationMs": 6,
"verdict": "pass"
},
{
"command": "npx tsc --noEmit",
"exitCode": 1,
"durationMs": 784,
"verdict": "fail"
},
{
"command": "grep -q 'ChapterMarkers' src/components/PlayerControls.tsx",
"exitCode": 2,
"durationMs": 7,
"verdict": "fail"
},
{
"command": "grep -q 'fetchChapters' src/pages/WatchPage.tsx",
"exitCode": 2,
"durationMs": 6,
"verdict": "fail"
},
{
"command": "grep -q 'chapter-marker' src/App.css",
"exitCode": 2,
"durationMs": 6,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -1,6 +1,65 @@
# S06: [A] Auto-Chapters Review UI
**Goal:** Build chapter review and editing UI for creators to approve auto-detected chapters
**Goal:** Creator can review auto-detected chapters for their videos: drag to adjust boundaries on the waveform, rename, reorder, and approve chapters for publication. Only approved chapters display in the public player.
**Demo:** After this: Creator reviews detected chapters: drag boundaries, rename, reorder, approve for publication
## Tasks
- [x] **T01: Added ChapterStatus enum, sort_order column, migration 020, chapter management schemas, and 4 auth-guarded creator chapter endpoints with approved-only public filtering** — Add `chapter_status` enum (draft/approved/hidden) and `sort_order` integer column to the KeyMoment model. Create Alembic migration 020. Add new Pydantic schemas (ChapterUpdate, ChapterReorderRequest, ChapterBulkApproveRequest). Create a new `backend/routers/creator_chapters.py` router with auth-guarded endpoints: GET creator's chapters for a video, PATCH single chapter, PUT reorder, POST bulk-approve. Update the public GET /videos/{id}/chapters to filter to approved-only (fallback to all if none approved). Register the new router in main.py.
Key patterns to follow:
- `backend/routers/creator_dashboard.py` lines 14, 37-58 for auth pattern (`get_current_user`, verify `current_user.creator_id`, check creator owns video via SourceVideo.creator_id)
- `alembic/versions/019_add_highlight_candidates.py` for migration pattern (create enum type, add columns)
- `backend/models.py` line 83 for HighlightStatus enum pattern
- `backend/schemas.py` line 666 for ChapterMarkerRead pattern
Steps:
1. Add `ChapterStatus` enum to `backend/models.py` (draft, approved, hidden) following HighlightStatus pattern
2. Add `chapter_status` and `sort_order` fields to KeyMoment class
3. Create `alembic/versions/020_add_chapter_status_and_sort_order.py` — add enum type, add both columns with defaults (draft, 0)
4. Add schemas to `backend/schemas.py`: ChapterUpdate (all optional: title, start_time, end_time, chapter_status), ChapterReorderRequest (list of {id, sort_order}), ChapterBulkApproveRequest (list of chapter_ids), extend ChapterMarkerRead with chapter_status and sort_order
5. Create `backend/routers/creator_chapters.py` with 4 endpoints: GET /{video_id}/chapters, PATCH /chapters/{chapter_id}, PUT /{video_id}/chapters/reorder, POST /{video_id}/chapters/approve
6. Register router in `backend/main.py` with prefix `/api/v1/creator`
7. Update public chapters endpoint in `backend/routers/videos.py` to prefer approved chapters (if any exist), else return all
- Estimate: 1.5h
- Files: backend/models.py, backend/schemas.py, alembic/versions/020_add_chapter_status_and_sort_order.py, backend/routers/creator_chapters.py, backend/routers/videos.py, backend/main.py
- Verify: python -c "from models import KeyMoment, ChapterStatus; print(ChapterStatus.draft)" && python -c "from schemas import ChapterUpdate, ChapterReorderRequest, ChapterBulkApproveRequest; print('schemas OK')" && grep -q 'creator_chapters' backend/main.py && test -f alembic/versions/020_add_chapter_status_and_sort_order.py
- [ ] **T02: Build Chapter Review page with editable waveform regions and chapter list** — Create the frontend Chapter Review page at `/creator/chapters/:videoId`. This page lets a creator view their video's chapters on an interactive waveform (drag to resize regions) and in a sortable list below (inline rename, status badges, approve/hide actions, bulk approve).
The page reuses the WaveSurfer + RegionsPlugin pattern from `frontend/src/components/AudioWaveform.tsx` but with `drag: true` and `resize: true` on regions. Region update events sync back to component state. The chapter list uses simple up/down arrow buttons for reorder (no DnD library needed for 5-15 items).
Steps:
1. Extend `frontend/src/api/videos.ts`: add `chapter_status` and `sort_order` to Chapter interface; add `fetchCreatorChapters(videoId)`, `updateChapter(id, patch)`, `reorderChapters(videoId, order)`, `approveChapters(videoId, ids)` functions using the `/api/v1/creator/` endpoints
2. Create `frontend/src/pages/ChapterReview.module.css` — styles for the review layout (chapter list, inline edit inputs, status badges, bulk action bar, waveform container)
3. Create `frontend/src/pages/ChapterReview.tsx`:
- Fetch creator's chapters for the video via `fetchCreatorChapters`
- Render WaveSurfer waveform with RegionsPlugin (`drag: true`, `resize: true`)
- Listen for region-updated events → call `updateChapter` with new start/end times
- Render chapter list: each row has editable title input, time display, status badge (draft/approved/hidden), approve/hide toggle buttons
- Up/down arrow buttons per row to reorder → call `reorderChapters`
- Bulk action bar: checkbox per row, 'Approve Selected' and 'Approve All' buttons → call `approveChapters`
- Loading/error/empty states
4. Use `SidebarNav` layout wrapper pattern from CreatorDashboard (import and reuse)
Key constraints:
- The `request()` helper from `frontend/src/api/client.ts` auto-attaches auth tokens
- For PATCH/PUT/POST, pass `{ method, body: JSON.stringify(data) }` as second arg to `request()`
- WaveSurfer region events: listen on the regions plugin instance for 'region-updated' which fires with the region object containing updated start/end
- Use `frontend/src/pages/CreatorDashboard.module.css` as style reference for consistent creator area look
- Estimate: 2h
- Files: frontend/src/api/videos.ts, frontend/src/pages/ChapterReview.tsx, frontend/src/pages/ChapterReview.module.css
- Verify: cd frontend && npx tsc --noEmit 2>&1 | head -30 && test -f src/pages/ChapterReview.tsx && test -f src/pages/ChapterReview.module.css && grep -q 'updateChapter' src/api/videos.ts
- [ ] **T03: Wire Chapter Review into App routes and CreatorDashboard sidebar navigation** — Connect the Chapter Review page to the application routing and navigation.
Steps:
1. In `frontend/src/App.tsx`:
- Import ChapterReview (lazy-loaded like other creator pages)
- Add route: `<Route path='/creator/chapters/:videoId' element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />`
- Pattern: follow the existing creator route pattern at lines 198-200
2. In `frontend/src/pages/CreatorDashboard.tsx`:
- Replace the disabled 'Content' `<span>` (lines 29-37) with a `<NavLink to='/creator/chapters' className={linkClass}>` with the same document icon SVG, label changed to 'Chapters'
- Note: the /creator/chapters route (without videoId) needs a simple video-picker view. Add a minimal video list component inside ChapterReview.tsx that shows when no videoId param is present (list creator's videos with chapter counts, each linking to /creator/chapters/:videoId). OR, link to /creator/dashboard and let the user navigate from there. Simplest approach: link to /creator/chapters and add a video-list view to ChapterReview.
3. In `frontend/src/App.tsx` also add the video-list route: `<Route path='/creator/chapters' element={<ProtectedRoute>...</ProtectedRoute>} />`
4. Verify the full flow: sidebar link → video list → chapter review page loads without errors
- Estimate: 45m
- Files: frontend/src/App.tsx, frontend/src/pages/CreatorDashboard.tsx, frontend/src/pages/ChapterReview.tsx
- Verify: cd frontend && npx tsc --noEmit 2>&1 | head -30 && grep -q 'ChapterReview' src/App.tsx && grep -q '/creator/chapters' src/pages/CreatorDashboard.tsx

View file

@ -0,0 +1,99 @@
# S06 Research: Auto-Chapters Review UI
## Summary
This slice adds a creator-facing UI for reviewing auto-detected chapters (which are KeyMoment records). Creators can drag boundaries, rename, reorder, and approve chapters for publication. The codebase already has read-only chapter display (player timeline ticks + wavesurfer regions) and a creator dashboard with sidebar navigation — the review UI plugs into this existing shell. The main work is: (1) backend CRUD endpoints for chapter editing, (2) a chapter_status field on KeyMoment, and (3) a new React page with interactive timeline editing.
**Depth:** Targeted — known patterns (CRUD + interactive UI) but first use of draggable regions for editing and first write endpoint for KeyMoments.
## Requirement Coverage
No active requirements are directly owned by S06. This slice delivers new functionality (chapter review workflow) that doesn't map to an existing requirement. A new requirement could be surfaced during planning.
## Recommendation
Build backend-first (model migration + API endpoints), then the frontend page. Use WaveSurfer's existing RegionsPlugin for drag-to-resize chapter boundaries — it's already imported and initialized in AudioWaveform.tsx. The creator dashboard sidebar already has a disabled "Content" link placeholder — wire the chapters review into that slot.
## Implementation Landscape
### Backend — Model & Migration
**File:** `backend/models.py`
- `KeyMoment` (line 238) has no review/approval status. Need to add a `chapter_status` field (enum: `draft`, `approved`, `hidden`) with default `draft`.
- The `HighlightStatus` enum (line 86) shows the established pattern: `candidate`, `approved`, `rejected`. Chapter status follows the same pattern.
- Migration via Alembic — the project uses `alembic upgrade head` inside the Docker container.
**File:** `backend/schemas.py`
- `ChapterMarkerRead` (line 666) is the existing read schema. Needs extending with `chapter_status`, `summary`.
- Need new schemas: `ChapterUpdate` (title, start_time, end_time, chapter_status — all optional), `ChapterReorderRequest` (list of {id, sort_order}), `ChapterBulkApproveRequest` (list of chapter IDs).
- `KeyMomentRead` (line 134) already exists but doesn't include chapter_status.
### Backend — API Endpoints
**File:** `backend/routers/videos.py`
- Currently only has `GET /{video_id}/chapters` (line 143). No write endpoints at all.
- Need to add under the same router or a new `/creator/chapters` router:
- `GET /creator/videos/{video_id}/chapters` — chapters for a video owned by the logged-in creator (with status info)
- `PATCH /creator/chapters/{chapter_id}` — update title, start_time, end_time
- `PUT /creator/videos/{video_id}/chapters/reorder` — batch reorder
- `POST /creator/videos/{video_id}/chapters/approve` — bulk approve selected chapters
- Auth: use existing `get_current_user` + verify creator owns the video. Pattern established in `backend/routers/creator_dashboard.py` and `backend/routers/consent.py`.
### Frontend — API Client
**File:** `frontend/src/api/videos.ts`
- Has `Chapter` type and `fetchChapters()`. Need to add:
- `updateChapter(id, patch)` — PATCH
- `reorderChapters(videoId, order)` — PUT
- `approveChapters(videoId, ids)` — POST
- Extended `Chapter` type with `chapter_status`, `summary`, `sort_order`
### Frontend — Review Page
**New file:** `frontend/src/pages/ChapterReview.tsx`
- Add as a new route: `/creator/chapters/:videoId`
- Sits inside the creator dashboard layout (uses `SidebarNav` from `CreatorDashboard.tsx` — already exported at line ~68)
- Page structure:
1. **Video selector** — if no videoId, list creator's videos with chapter counts
2. **Waveform timeline** — reuse `AudioWaveform` component pattern with `RegionsPlugin` in edit mode (regions are draggable/resizable by default in wavesurfer.js)
3. **Chapter list** — sortable list below timeline: each row has title (editable inline), timestamps (editable or drag-synced), status badge, approve/hide actions
4. **Bulk actions bar** — "Approve Selected", "Approve All" buttons
**Key WaveSurfer detail:** `RegionsPlugin` already creates colored regions for chapters in `AudioWaveform.tsx`. The plugin natively supports `drag: true` and `resize: true` on regions — these emit `region-updated` events with new start/end times. The review page needs a variant of AudioWaveform that enables these interactions and wires region changes back to state.
### Frontend — Routing & Navigation
**File:** `frontend/src/App.tsx`
- Add route: `/creator/chapters` (video list) and `/creator/chapters/:videoId` (review UI)
- Both wrapped in `<ProtectedRoute>` like other creator routes (line 198-200).
**File:** `frontend/src/pages/CreatorDashboard.tsx`
- `SidebarNav` (line ~33) has a disabled "Content" placeholder. Replace with a link to `/creator/chapters` (or keep "Content" and add "Chapters" as a sub-link).
### CSS
**File:** `frontend/src/pages/CreatorDashboard.module.css` — existing creator dashboard styles. The review page can share the layout but needs its own module CSS for the timeline editor, sortable list, and inline editing.
## Natural Seams (Task Boundaries)
1. **Backend: Model + Migration** — Add `chapter_status` enum and field to KeyMoment. Alembic migration. Self-contained, unblocks everything.
2. **Backend: CRUD Endpoints** — PATCH/PUT/POST endpoints for chapter editing, reorder, approve. Depends on T1. Testable with curl.
3. **Frontend: API Client + Types** — Extend `videos.ts` with mutation functions and updated types. Small, mechanical.
4. **Frontend: Chapter Review Page** — The main UI work. Waveform with editable regions, sortable chapter list, inline rename, approve flow. Depends on T2+T3.
5. **Frontend: Routing + Nav Integration** — Wire page into App.tsx routes and CreatorDashboard sidebar. Small, depends on T4.
## Risks & Constraints
- **No existing write endpoints for KeyMoments** — all chapter mutation is new backend work. Pattern is clear from consent.py and auth.py (PATCH/PUT with auth guards), but it's the first write path for this model.
- **WaveSurfer RegionsPlugin editability** — the plugin supports drag/resize natively, but the current AudioWaveform component creates regions as non-interactive (read-only display). The review page needs a modified version that enables interaction.
- **Sort order** — KeyMoment has no `sort_order` column. Reorder requires either adding a column or using start_time as implicit order. Adding a `sort_order` integer column in the migration is cleaner.
- **Scope boundary** — "approve for publication" implies a gate: only approved chapters show in the public player. The existing `GET /videos/{id}/chapters` endpoint currently returns ALL KeyMoments. After this slice, it should filter to `chapter_status = 'approved'` (or return all if no chapters are approved yet, as a graceful fallback).
## Don't Hand-Roll
- **Drag-and-drop reorder** — use `@dnd-kit/core` + `@dnd-kit/sortable` (or similar). The project doesn't currently use a DnD library. However, given the list is small (typically 5-15 chapters), a simpler approach with up/down arrow buttons may be sufficient and avoids adding a dependency.
- **WaveSurfer regions** — already in the project via `wavesurfer.js/dist/plugins/regions.esm.js`. Do NOT introduce a separate timeline library.
## Skill Discovery
No professional agent skills are directly relevant to this slice's core work (FastAPI CRUD + React interactive UI with WaveSurfer). The existing `react-best-practices` and `make-interfaces-feel-better` skills from the installed set are applicable for the frontend work.

View file

@ -0,0 +1,46 @@
---
estimated_steps: 14
estimated_files: 6
skills_used: []
---
# T01: Add chapter_status/sort_order to KeyMoment model, migration, and CRUD endpoints
Add `chapter_status` enum (draft/approved/hidden) and `sort_order` integer column to the KeyMoment model. Create Alembic migration 020. Add new Pydantic schemas (ChapterUpdate, ChapterReorderRequest, ChapterBulkApproveRequest). Create a new `backend/routers/creator_chapters.py` router with auth-guarded endpoints: GET creator's chapters for a video, PATCH single chapter, PUT reorder, POST bulk-approve. Update the public GET /videos/{id}/chapters to filter to approved-only (fallback to all if none approved). Register the new router in main.py.
Key patterns to follow:
- `backend/routers/creator_dashboard.py` lines 14, 37-58 for auth pattern (`get_current_user`, verify `current_user.creator_id`, check creator owns video via SourceVideo.creator_id)
- `alembic/versions/019_add_highlight_candidates.py` for migration pattern (create enum type, add columns)
- `backend/models.py` line 83 for HighlightStatus enum pattern
- `backend/schemas.py` line 666 for ChapterMarkerRead pattern
Steps:
1. Add `ChapterStatus` enum to `backend/models.py` (draft, approved, hidden) following HighlightStatus pattern
2. Add `chapter_status` and `sort_order` fields to KeyMoment class
3. Create `alembic/versions/020_add_chapter_status_and_sort_order.py` — add enum type, add both columns with defaults (draft, 0)
4. Add schemas to `backend/schemas.py`: ChapterUpdate (all optional: title, start_time, end_time, chapter_status), ChapterReorderRequest (list of {id, sort_order}), ChapterBulkApproveRequest (list of chapter_ids), extend ChapterMarkerRead with chapter_status and sort_order
5. Create `backend/routers/creator_chapters.py` with 4 endpoints: GET /{video_id}/chapters, PATCH /chapters/{chapter_id}, PUT /{video_id}/chapters/reorder, POST /{video_id}/chapters/approve
6. Register router in `backend/main.py` with prefix `/api/v1/creator`
7. Update public chapters endpoint in `backend/routers/videos.py` to prefer approved chapters (if any exist), else return all
## Inputs
- ``backend/models.py` — KeyMoment model (line 238), HighlightStatus enum pattern (line 83)`
- ``backend/schemas.py` — ChapterMarkerRead (line 666), ChaptersResponse (line 677)`
- ``backend/routers/videos.py` — existing GET chapters endpoint (line 143)`
- ``backend/routers/creator_dashboard.py` — auth pattern with get_current_user (lines 14, 37-58)`
- ``backend/main.py` — router registration`
- ``alembic/versions/019_add_highlight_candidates.py` — migration pattern reference`
## Expected Output
- ``backend/models.py` — ChapterStatus enum + chapter_status/sort_order on KeyMoment`
- ``backend/schemas.py` — ChapterUpdate, ChapterReorderRequest, ChapterBulkApproveRequest schemas; extended ChapterMarkerRead`
- ``alembic/versions/020_add_chapter_status_and_sort_order.py` — migration adding columns`
- ``backend/routers/creator_chapters.py` — new router with 4 auth-guarded endpoints`
- ``backend/routers/videos.py` — public chapters filtered to approved-only with fallback`
- ``backend/main.py` — creator_chapters router registered`
## Verification
python -c "from models import KeyMoment, ChapterStatus; print(ChapterStatus.draft)" && python -c "from schemas import ChapterUpdate, ChapterReorderRequest, ChapterBulkApproveRequest; print('schemas OK')" && grep -q 'creator_chapters' backend/main.py && test -f alembic/versions/020_add_chapter_status_and_sort_order.py

View file

@ -0,0 +1,90 @@
---
id: T01
parent: S06
milestone: M021
provides: []
requires: []
affects: []
key_files: ["backend/models.py", "backend/schemas.py", "alembic/versions/020_add_chapter_status_and_sort_order.py", "backend/routers/creator_chapters.py", "backend/routers/videos.py", "backend/main.py"]
key_decisions: ["Public chapters endpoint falls back to all chapters when none are approved for backward compatibility", "Creator chapters sorted by sort_order then start_time", "_verify_creator_owns_video helper pattern for ownership checks"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Ran task plan verification: model import (ChapterStatus.draft prints correctly), schema import (all 3 new schemas load), main.py grep confirms creator_chapters registered, migration file exists. Additionally verified router loads with 4 routes."
completed_at: 2026-04-04T06:03:45.525Z
blocker_discovered: false
---
# T01: Added ChapterStatus enum, sort_order column, migration 020, chapter management schemas, and 4 auth-guarded creator chapter endpoints with approved-only public filtering
> Added ChapterStatus enum, sort_order column, migration 020, chapter management schemas, and 4 auth-guarded creator chapter endpoints with approved-only public filtering
## What Happened
---
id: T01
parent: S06
milestone: M021
key_files:
- backend/models.py
- backend/schemas.py
- alembic/versions/020_add_chapter_status_and_sort_order.py
- backend/routers/creator_chapters.py
- backend/routers/videos.py
- backend/main.py
key_decisions:
- Public chapters endpoint falls back to all chapters when none are approved for backward compatibility
- Creator chapters sorted by sort_order then start_time
- _verify_creator_owns_video helper pattern for ownership checks
duration: ""
verification_result: passed
completed_at: 2026-04-04T06:03:45.526Z
blocker_discovered: false
---
# T01: Added ChapterStatus enum, sort_order column, migration 020, chapter management schemas, and 4 auth-guarded creator chapter endpoints with approved-only public filtering
**Added ChapterStatus enum, sort_order column, migration 020, chapter management schemas, and 4 auth-guarded creator chapter endpoints with approved-only public filtering**
## What Happened
Added ChapterStatus enum (draft/approved/hidden) to models.py, extended KeyMoment with chapter_status and sort_order columns, created Alembic migration 020, added 4 Pydantic schemas (ChapterUpdate, ChapterReorderItem, ChapterReorderRequest, ChapterBulkApproveRequest), extended ChapterMarkerRead with new fields, created creator_chapters router with GET/PATCH/PUT/POST endpoints for chapter management, registered it in main.py, and updated public chapters endpoint to prefer approved chapters with fallback to all.
## Verification
Ran task plan verification: model import (ChapterStatus.draft prints correctly), schema import (all 3 new schemas load), main.py grep confirms creator_chapters registered, migration file exists. Additionally verified router loads with 4 routes.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `python -c "from models import KeyMoment, ChapterStatus; print(ChapterStatus.draft)"` | 0 | ✅ pass | 1000ms |
| 2 | `python -c "from schemas import ChapterUpdate, ChapterReorderRequest, ChapterBulkApproveRequest; print('schemas OK')"` | 0 | ✅ pass | 1000ms |
| 3 | `grep -q 'creator_chapters' backend/main.py` | 0 | ✅ pass | 100ms |
| 4 | `test -f alembic/versions/020_add_chapter_status_and_sort_order.py` | 0 | ✅ pass | 100ms |
| 5 | `python -c "from routers.creator_chapters import router; print(f'Router OK: {len(router.routes)} routes')"` | 0 | ✅ pass | 1000ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `backend/models.py`
- `backend/schemas.py`
- `alembic/versions/020_add_chapter_status_and_sort_order.py`
- `backend/routers/creator_chapters.py`
- `backend/routers/videos.py`
- `backend/main.py`
## Deviations
None.
## Known Issues
None.

View file

@ -0,0 +1,48 @@
---
estimated_steps: 19
estimated_files: 3
skills_used: []
---
# T02: Build Chapter Review page with editable waveform regions and chapter list
Create the frontend Chapter Review page at `/creator/chapters/:videoId`. This page lets a creator view their video's chapters on an interactive waveform (drag to resize regions) and in a sortable list below (inline rename, status badges, approve/hide actions, bulk approve).
The page reuses the WaveSurfer + RegionsPlugin pattern from `frontend/src/components/AudioWaveform.tsx` but with `drag: true` and `resize: true` on regions. Region update events sync back to component state. The chapter list uses simple up/down arrow buttons for reorder (no DnD library needed for 5-15 items).
Steps:
1. Extend `frontend/src/api/videos.ts`: add `chapter_status` and `sort_order` to Chapter interface; add `fetchCreatorChapters(videoId)`, `updateChapter(id, patch)`, `reorderChapters(videoId, order)`, `approveChapters(videoId, ids)` functions using the `/api/v1/creator/` endpoints
2. Create `frontend/src/pages/ChapterReview.module.css` — styles for the review layout (chapter list, inline edit inputs, status badges, bulk action bar, waveform container)
3. Create `frontend/src/pages/ChapterReview.tsx`:
- Fetch creator's chapters for the video via `fetchCreatorChapters`
- Render WaveSurfer waveform with RegionsPlugin (`drag: true`, `resize: true`)
- Listen for region-updated events → call `updateChapter` with new start/end times
- Render chapter list: each row has editable title input, time display, status badge (draft/approved/hidden), approve/hide toggle buttons
- Up/down arrow buttons per row to reorder → call `reorderChapters`
- Bulk action bar: checkbox per row, 'Approve Selected' and 'Approve All' buttons → call `approveChapters`
- Loading/error/empty states
4. Use `SidebarNav` layout wrapper pattern from CreatorDashboard (import and reuse)
Key constraints:
- The `request()` helper from `frontend/src/api/client.ts` auto-attaches auth tokens
- For PATCH/PUT/POST, pass `{ method, body: JSON.stringify(data) }` as second arg to `request()`
- WaveSurfer region events: listen on the regions plugin instance for 'region-updated' which fires with the region object containing updated start/end
- Use `frontend/src/pages/CreatorDashboard.module.css` as style reference for consistent creator area look
## Inputs
- ``frontend/src/api/videos.ts` — existing Chapter interface and fetchChapters`
- ``frontend/src/api/client.ts` — request() helper and BASE constant`
- ``frontend/src/components/AudioWaveform.tsx` — WaveSurfer + RegionsPlugin pattern`
- ``frontend/src/pages/CreatorDashboard.tsx` — SidebarNav export (line 56), layout pattern`
- ``frontend/src/pages/CreatorDashboard.module.css` — creator area styles reference`
## Expected Output
- ``frontend/src/api/videos.ts` — extended Chapter type + 4 new mutation functions`
- ``frontend/src/pages/ChapterReview.tsx` — full review page component`
- ``frontend/src/pages/ChapterReview.module.css` — review page styles`
## Verification
cd frontend && npx tsc --noEmit 2>&1 | head -30 && test -f src/pages/ChapterReview.tsx && test -f src/pages/ChapterReview.module.css && grep -q 'updateChapter' src/api/videos.ts

View file

@ -0,0 +1,36 @@
---
estimated_steps: 11
estimated_files: 3
skills_used: []
---
# T03: Wire Chapter Review into App routes and CreatorDashboard sidebar navigation
Connect the Chapter Review page to the application routing and navigation.
Steps:
1. In `frontend/src/App.tsx`:
- Import ChapterReview (lazy-loaded like other creator pages)
- Add route: `<Route path='/creator/chapters/:videoId' element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />`
- Pattern: follow the existing creator route pattern at lines 198-200
2. In `frontend/src/pages/CreatorDashboard.tsx`:
- Replace the disabled 'Content' `<span>` (lines 29-37) with a `<NavLink to='/creator/chapters' className={linkClass}>` with the same document icon SVG, label changed to 'Chapters'
- Note: the /creator/chapters route (without videoId) needs a simple video-picker view. Add a minimal video list component inside ChapterReview.tsx that shows when no videoId param is present (list creator's videos with chapter counts, each linking to /creator/chapters/:videoId). OR, link to /creator/dashboard and let the user navigate from there. Simplest approach: link to /creator/chapters and add a video-list view to ChapterReview.
3. In `frontend/src/App.tsx` also add the video-list route: `<Route path='/creator/chapters' element={<ProtectedRoute>...</ProtectedRoute>} />`
4. Verify the full flow: sidebar link → video list → chapter review page loads without errors
## Inputs
- ``frontend/src/App.tsx` — existing creator routes (lines 198-200), ProtectedRoute import (line 28)`
- ``frontend/src/pages/CreatorDashboard.tsx` — SidebarNav with disabled Content placeholder (lines 29-37)`
- ``frontend/src/pages/ChapterReview.tsx` — the review page component from T02`
## Expected Output
- ``frontend/src/App.tsx` — ChapterReview routes registered`
- ``frontend/src/pages/CreatorDashboard.tsx` — Chapters NavLink replacing disabled Content span`
- ``frontend/src/pages/ChapterReview.tsx` — video-list view when no videoId param`
## Verification
cd frontend && npx tsc --noEmit 2>&1 | head -30 && grep -q 'ChapterReview' src/App.tsx && grep -q '/creator/chapters' src/pages/CreatorDashboard.tsx

View file

@ -0,0 +1,37 @@
"""Add chapter_status and sort_order columns to key_moments.
Revision ID: 020_add_chapter_status_and_sort_order
Revises: 019_add_highlight_candidates
"""
from alembic import op
import sqlalchemy as sa
revision = "020_add_chapter_status_and_sort_order"
down_revision = "019_add_highlight_candidates"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create the chapter_status enum type
chapter_status = sa.Enum("draft", "approved", "hidden", name="chapter_status", create_constraint=True)
chapter_status.create(op.get_bind(), checkfirst=True)
op.add_column(
"key_moments",
sa.Column("chapter_status", chapter_status, nullable=False, server_default="draft"),
)
op.add_column(
"key_moments",
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
)
op.create_index("ix_key_moments_chapter_status", "key_moments", ["chapter_status"])
def downgrade() -> None:
op.drop_index("ix_key_moments_chapter_status")
op.drop_column("key_moments", "sort_order")
op.drop_column("key_moments", "chapter_status")
sa.Enum(name="chapter_status").drop(op.get_bind(), checkfirst=True)

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_dashboard, creators, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creators, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos
def _setup_logging() -> None:
@ -83,6 +83,7 @@ app.include_router(auth.router, prefix="/api/v1")
app.include_router(chat.router, prefix="/api/v1")
app.include_router(consent.router, prefix="/api/v1")
app.include_router(creator_dashboard.router, prefix="/api/v1")
app.include_router(creator_chapters.router, prefix="/api/v1")
app.include_router(creators.router, prefix="/api/v1")
app.include_router(highlights.router, prefix="/api/v1")
app.include_router(ingest.router, prefix="/api/v1")

View file

@ -87,6 +87,13 @@ class HighlightStatus(str, enum.Enum):
rejected = "rejected"
class ChapterStatus(str, enum.Enum):
"""Review status for auto-detected chapters."""
draft = "draft"
approved = "approved"
hidden = "hidden"
# ── Helpers ──────────────────────────────────────────────────────────────────
def _uuid_pk() -> Mapped[uuid.UUID]:
@ -255,6 +262,13 @@ class KeyMoment(Base):
)
plugins: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True)
raw_transcript: Mapped[str | None] = mapped_column(Text, nullable=True)
chapter_status: Mapped[ChapterStatus] = mapped_column(
Enum(ChapterStatus, name="chapter_status", create_constraint=True),
nullable=False,
server_default="draft",
default=ChapterStatus.draft,
)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0", default=0)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)

View file

@ -0,0 +1,172 @@
"""Creator chapter management endpoints — review, edit, reorder, approve chapters.
Auth-guarded endpoints for creators to manage auto-detected chapters for
their videos before publication.
"""
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from auth import get_current_user
from database import get_session
from models import ChapterStatus, KeyMoment, SourceVideo, User
from schemas import (
ChapterBulkApproveRequest,
ChapterMarkerRead,
ChapterReorderRequest,
ChapterUpdate,
ChaptersResponse,
)
logger = logging.getLogger("chrysopedia.creator_chapters")
router = APIRouter(prefix="/creator", tags=["creator-chapters"])
async def _verify_creator_owns_video(
current_user: User,
video_id: uuid.UUID,
db: AsyncSession,
) -> None:
"""Verify the user is a creator and owns the specified video."""
if current_user.creator_id is None:
raise HTTPException(status_code=403, detail="No creator profile linked")
video = (await db.execute(
select(SourceVideo).where(
SourceVideo.id == video_id,
SourceVideo.creator_id == current_user.creator_id,
)
)).scalar_one_or_none()
if video is None:
raise HTTPException(status_code=404, detail="Video not found or not owned by you")
@router.get("/{video_id}/chapters", response_model=ChaptersResponse)
async def get_creator_chapters(
video_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> ChaptersResponse:
"""Return all chapters for a creator's video (all statuses)."""
await _verify_creator_owns_video(current_user, video_id, db)
stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(stmt)
moments = result.scalars().all()
logger.debug("Creator chapters for %s: %d", video_id, len(moments))
return ChaptersResponse(
video_id=video_id,
chapters=[ChapterMarkerRead.model_validate(m) for m in moments],
)
@router.patch("/chapters/{chapter_id}", response_model=ChapterMarkerRead)
async def update_chapter(
chapter_id: uuid.UUID,
body: ChapterUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> ChapterMarkerRead:
"""Update a single chapter (title, times, status)."""
if current_user.creator_id is None:
raise HTTPException(status_code=403, detail="No creator profile linked")
# Fetch the chapter and verify ownership via the video
chapter = (await db.execute(
select(KeyMoment).where(KeyMoment.id == chapter_id)
)).scalar_one_or_none()
if chapter is None:
raise HTTPException(status_code=404, detail="Chapter not found")
await _verify_creator_owns_video(current_user, chapter.source_video_id, db)
# Apply partial updates
update_data = body.model_dump(exclude_unset=True)
if "chapter_status" in update_data:
update_data["chapter_status"] = ChapterStatus(update_data["chapter_status"])
for field, value in update_data.items():
setattr(chapter, field, value)
await db.commit()
await db.refresh(chapter)
logger.info("Updated chapter %s: %s", chapter_id, list(update_data.keys()))
return ChapterMarkerRead.model_validate(chapter)
@router.put("/{video_id}/chapters/reorder", response_model=ChaptersResponse)
async def reorder_chapters(
video_id: uuid.UUID,
body: ChapterReorderRequest,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> ChaptersResponse:
"""Reorder chapters for a video by setting sort_order values."""
await _verify_creator_owns_video(current_user, video_id, db)
for item in body.chapters:
await db.execute(
update(KeyMoment)
.where(KeyMoment.id == item.id, KeyMoment.source_video_id == video_id)
.values(sort_order=item.sort_order)
)
await db.commit()
# Return updated list
stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(stmt)
moments = result.scalars().all()
logger.info("Reordered %d chapters for video %s", len(body.chapters), video_id)
return ChaptersResponse(
video_id=video_id,
chapters=[ChapterMarkerRead.model_validate(m) for m in moments],
)
@router.post("/{video_id}/chapters/approve", response_model=ChaptersResponse)
async def bulk_approve_chapters(
video_id: uuid.UUID,
body: ChapterBulkApproveRequest,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> ChaptersResponse:
"""Bulk-approve chapters by ID list."""
await _verify_creator_owns_video(current_user, video_id, db)
if body.chapter_ids:
await db.execute(
update(KeyMoment)
.where(
KeyMoment.id.in_(body.chapter_ids),
KeyMoment.source_video_id == video_id,
)
.values(chapter_status=ChapterStatus.approved)
)
await db.commit()
logger.info("Bulk-approved %d chapters for video %s", len(body.chapter_ids), video_id)
# Return updated list
stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(stmt)
moments = result.scalars().all()
return ChaptersResponse(
video_id=video_id,
chapters=[ChapterMarkerRead.model_validate(m) for m in moments],
)

View file

@ -145,20 +145,39 @@ async def get_video_chapters(
video_id: uuid.UUID,
db: AsyncSession = Depends(get_session),
) -> ChaptersResponse:
"""Return KeyMoment records for a video as chapter markers, sorted by start_time."""
"""Return KeyMoment records for a video as chapter markers.
Prefers approved chapters if any exist; otherwise returns all chapters.
Sorted by sort_order then start_time.
"""
# Verify video exists
video_stmt = select(SourceVideo.id).where(SourceVideo.id == video_id)
video_result = await db.execute(video_stmt)
if video_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Video not found")
stmt = (
# Try approved-only first
approved_stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.start_time)
.where(
KeyMoment.source_video_id == video_id,
KeyMoment.chapter_status == "approved",
)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(stmt)
result = await db.execute(approved_stmt)
moments = result.scalars().all()
# Fallback to all if none are approved
if not moments:
all_stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(all_stmt)
moments = result.scalars().all()
logger.debug("Chapters for %s: %d key moments", video_id, len(moments))
return ChaptersResponse(
video_id=video_id,

View file

@ -672,9 +672,36 @@ class ChapterMarkerRead(BaseModel):
start_time: float
end_time: float
content_type: str
chapter_status: str = "draft"
sort_order: int = 0
class ChaptersResponse(BaseModel):
"""Chapters (KeyMoments) for a video, sorted by start_time."""
video_id: uuid.UUID
chapters: list[ChapterMarkerRead] = Field(default_factory=list)
# ── Creator Chapter Management ──────────────────────────────────────────────
class ChapterUpdate(BaseModel):
"""Partial update for a single chapter."""
title: str | None = None
start_time: float | None = None
end_time: float | None = None
chapter_status: str | None = None
class ChapterReorderItem(BaseModel):
id: uuid.UUID
sort_order: int
class ChapterReorderRequest(BaseModel):
"""Reorder chapters for a video."""
chapters: list[ChapterReorderItem]
class ChapterBulkApproveRequest(BaseModel):
"""Bulk-approve chapters by IDs."""
chapter_ids: list[uuid.UUID]