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:
parent
76880d0477
commit
ed9aa7a83a
16 changed files with 878 additions and 8 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
114
.gsd/milestones/M021/slices/S05/S05-SUMMARY.md
Normal file
114
.gsd/milestones/M021/slices/S05/S05-SUMMARY.md
Normal 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
|
||||
66
.gsd/milestones/M021/slices/S05/S05-UAT.md
Normal file
66
.gsd/milestones/M021/slices/S05/S05-UAT.md
Normal 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
|
||||
42
.gsd/milestones/M021/slices/S05/tasks/T03-VERIFY.json
Normal file
42
.gsd/milestones/M021/slices/S05/tasks/T03-VERIFY.json
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
99
.gsd/milestones/M021/slices/S06/S06-RESEARCH.md
Normal file
99
.gsd/milestones/M021/slices/S06/S06-RESEARCH.md
Normal 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.
|
||||
46
.gsd/milestones/M021/slices/S06/tasks/T01-PLAN.md
Normal file
46
.gsd/milestones/M021/slices/S06/tasks/T01-PLAN.md
Normal 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
|
||||
90
.gsd/milestones/M021/slices/S06/tasks/T01-SUMMARY.md
Normal file
90
.gsd/milestones/M021/slices/S06/tasks/T01-SUMMARY.md
Normal 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.
|
||||
48
.gsd/milestones/M021/slices/S06/tasks/T02-PLAN.md
Normal file
48
.gsd/milestones/M021/slices/S06/tasks/T02-PLAN.md
Normal 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
|
||||
36
.gsd/milestones/M021/slices/S06/tasks/T03-PLAN.md
Normal file
36
.gsd/milestones/M021/slices/S06/tasks/T03-PLAN.md
Normal 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
|
||||
37
alembic/versions/020_add_chapter_status_and_sort_order.py
Normal file
37
alembic/versions/020_add_chapter_status_and_sort_order.py
Normal 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
172
backend/routers/creator_chapters.py
Normal file
172
backend/routers/creator_chapters.py
Normal 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],
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue