test: Added GET /videos/{video_id} and GET /videos/{video_id}/transcrip…
- "backend/routers/videos.py" - "backend/schemas.py" - "backend/tests/test_video_detail.py" GSD-Task: S01/T01
This commit is contained in:
parent
3710c3f8bb
commit
87cb667848
13 changed files with 14269 additions and 22 deletions
1
.gsd/completed-units-M019.json
Normal file
1
.gsd/completed-units-M019.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -1,6 +1,171 @@
|
|||
# S01: [A] Web Media Player MVP
|
||||
|
||||
**Goal:** Build a custom media player for educational video content with HLS, speed controls, and transcript sync
|
||||
**Goal:** Custom video player page with HLS playback, speed controls (0.5x–2x), and synchronized transcript sidebar, accessible from technique page key moment timestamps.
|
||||
**Demo:** After this: Custom video player with HLS playback, speed controls (0.5x-2x), and synchronized transcript sidebar
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Added GET /videos/{video_id} and GET /videos/{video_id}/transcript endpoints with creator info, ordered segments, and 5 integration tests** — Add two new endpoints to the existing videos router:
|
||||
|
||||
1. `GET /videos/{video_id}` — single video detail with eager-loaded creator (name, slug). Returns `SourceVideoDetail` schema (extends SourceVideoRead with creator_name, creator_slug fields).
|
||||
|
||||
2. `GET /videos/{video_id}/transcript` — returns all TranscriptSegments for a video, ordered by segment_index. Returns `TranscriptForPlayerResponse` with video_id and segments list.
|
||||
|
||||
Both return 404 for non-existent video IDs.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Read `backend/schemas.py` to understand existing SourceVideoRead and TranscriptSegmentRead schemas.
|
||||
2. Add new schemas to `backend/schemas.py`:
|
||||
- `SourceVideoDetail` — extends SourceVideoRead, adds `creator_name: str`, `creator_slug: str`, `video_url: str | None = None` (always None for now)
|
||||
- `TranscriptForPlayerResponse` — `video_id: uuid.UUID`, `segments: list[TranscriptSegmentRead]`, `total: int`
|
||||
3. Read `backend/routers/videos.py` to understand the existing list endpoint pattern.
|
||||
4. Add `GET /videos/{video_id}` endpoint to `backend/routers/videos.py`:
|
||||
- Query SourceVideo by ID with `selectinload(SourceVideo.creator)`
|
||||
- Return 404 if not found
|
||||
- Map to SourceVideoDetail with creator_name and creator_slug from the joined creator
|
||||
5. Add `GET /videos/{video_id}/transcript` endpoint:
|
||||
- Query TranscriptSegments where source_video_id matches, ordered by segment_index
|
||||
- Return 404 if video doesn't exist
|
||||
- Return TranscriptForPlayerResponse
|
||||
6. Write `backend/tests/test_video_detail.py` with tests:
|
||||
- test_get_video_detail_success — creates a video+creator, verifies response shape
|
||||
- test_get_video_detail_404 — random UUID returns 404
|
||||
- test_get_transcript_success — creates video + segments, verifies ordering and count
|
||||
- test_get_transcript_404 — random UUID returns 404
|
||||
- test_get_transcript_empty — video exists but has no segments, returns empty list
|
||||
7. Run tests and fix any issues.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] GET /videos/{video_id} returns video detail with creator_name and creator_slug
|
||||
- [ ] GET /videos/{video_id}/transcript returns segments ordered by segment_index
|
||||
- [ ] Both endpoints return 404 for non-existent video IDs
|
||||
- [ ] Tests pass
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|------------|----------|-----------|----------------------|
|
||||
| PostgreSQL | 500 with logged traceback | FastAPI default timeout | N/A (ORM-mapped) |
|
||||
| video_id UUID parse | FastAPI returns 422 automatically | N/A | N/A |
|
||||
- Estimate: 45m
|
||||
- Files: backend/routers/videos.py, backend/schemas.py, backend/tests/test_video_detail.py
|
||||
- Verify: cd backend && python -m pytest tests/test_video_detail.py -v
|
||||
- [ ] **T02: Build VideoPlayer component with HLS, custom controls, and media sync hook** — Build the core video player infrastructure: hls.js integration, custom playback controls, and the useMediaSync hook that shares playback state between player and transcript.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Install hls.js: `cd frontend && npm install hls.js`
|
||||
2. Create `frontend/src/hooks/useMediaSync.ts`:
|
||||
- Custom hook managing shared playback state: `currentTime`, `duration`, `isPlaying`, `playbackRate`, `videoRef`
|
||||
- Listens to `timeupdate` (fires ~4Hz), `play`, `pause`, `ratechange`, `loadedmetadata` events on the video element
|
||||
- Exposes `seekTo(time: number)`, `setPlaybackRate(rate: number)`, `togglePlay()` actions
|
||||
- Returns `{ currentTime, duration, isPlaying, playbackRate, videoRef, seekTo, setPlaybackRate, togglePlay }`
|
||||
3. Create `frontend/src/components/VideoPlayer.tsx`:
|
||||
- Accepts props: `src: string | null`, `startTime?: number`, `mediaSync: ReturnType<typeof useMediaSync>`
|
||||
- If `src` is null, render a styled "Video not available" placeholder with explanation text
|
||||
- If src ends in `.m3u8` or device doesn't support native HLS: lazy-load hls.js via dynamic `import('hls.js')`, call `hls.loadSource(src)`, `hls.attachMedia(videoEl)`
|
||||
- If native HLS supported (Safari): set `video.src = src` directly
|
||||
- For `.mp4` URLs: use native `<video src={src}>` directly
|
||||
- Handle HLS errors: on `Hls.Events.ERROR` with `data.fatal`, log to console.error and show error state
|
||||
- `useEffect` cleanup: call `hls.destroy()` on unmount
|
||||
- If `startTime` is provided, seek to it after `loadedmetadata`
|
||||
4. Create `frontend/src/components/PlayerControls.tsx`:
|
||||
- Accepts `mediaSync` prop
|
||||
- Play/pause button (toggles icon based on isPlaying)
|
||||
- Seek bar: `<input type="range">` from 0 to duration, value = currentTime, onChange seeks
|
||||
- Current time / duration display in MM:SS format
|
||||
- Speed selector: dropdown/button group with options [0.5, 0.75, 1, 1.25, 1.5, 2], active state highlighted
|
||||
- Volume control: `<input type="range">` 0–1, mute toggle button
|
||||
- Fullscreen button: calls `videoContainerRef.requestFullscreen()`
|
||||
- Keyboard shortcuts: Space = play/pause, Left/Right arrows = ±5s seek, Up/Down = ±0.1 volume
|
||||
5. Add player styles to `frontend/src/App.css`:
|
||||
- `.video-player` container with 16:9 aspect ratio
|
||||
- `.video-player__unavailable` placeholder styling (dark background, centered message)
|
||||
- `.player-controls` bar styling (dark background, flex layout)
|
||||
- `.player-controls__speed` button group with active state
|
||||
- `.player-controls__seek` range input custom styling (cyan accent)
|
||||
- `.player-controls__volume` range input
|
||||
- Responsive: controls stack or resize at mobile widths
|
||||
6. Verify: `cd frontend && npx tsc --noEmit` passes with zero errors.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] hls.js added to package.json
|
||||
- [ ] useMediaSync hook tracks currentTime, duration, isPlaying, playbackRate
|
||||
- [ ] VideoPlayer renders HLS via hls.js with Safari native fallback
|
||||
- [ ] VideoPlayer shows "Video not available" when src is null
|
||||
- [ ] PlayerControls has play/pause, seek, speed (0.5–2x), volume, fullscreen
|
||||
- [ ] hls.js is lazy-loaded via dynamic import (not in main bundle)
|
||||
- [ ] Cleanup: hls.destroy() on unmount
|
||||
|
||||
## Negative Tests
|
||||
|
||||
- Malformed inputs: null src → "Video not available" state, NaN startTime → clamp to 0
|
||||
- Error paths: HLS fatal error → error message in player area, non-HLS URL → native video fallback
|
||||
- Boundary conditions: duration 0 → seek bar disabled, playbackRate at limits (0.5/2x) → buttons reflect state
|
||||
- Estimate: 2h
|
||||
- Files: frontend/src/hooks/useMediaSync.ts, frontend/src/components/VideoPlayer.tsx, frontend/src/components/PlayerControls.tsx, frontend/src/App.css, frontend/package.json
|
||||
- Verify: cd frontend && npx tsc --noEmit && npm run build
|
||||
- [ ] **T03: Build WatchPage, TranscriptSidebar, route wiring, and TechniquePage timestamp links** — Compose the full watch experience: TranscriptSidebar synced to playback, WatchPage layout, route in App.tsx, and clickable timestamp links on TechniquePage.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `frontend/src/api/videos.ts`:
|
||||
- `fetchVideo(id: string)` → GET `/api/v1/videos/{id}` returning video detail (id, filename, creator_name, creator_slug, video_url, duration_seconds)
|
||||
- `fetchTranscript(videoId: string)` → GET `/api/v1/videos/{videoId}/transcript` returning `{ video_id, segments: [{start_time, end_time, text, segment_index}], total }`
|
||||
- Define TypeScript interfaces: `VideoDetail`, `TranscriptSegment`, `TranscriptResponse`
|
||||
2. Create `frontend/src/components/TranscriptSidebar.tsx`:
|
||||
- Props: `segments: TranscriptSegment[]`, `currentTime: number`, `onSeek: (time: number) => void`
|
||||
- Renders scrollable list of segments, each showing timestamp (MM:SS) and text
|
||||
- Active segment determination: binary search on sorted segments array to find segment where `start_time <= currentTime < end_time` (O(log n) for 500+ segments)
|
||||
- Active segment gets `.transcript-segment--active` class with cyan left border highlight
|
||||
- Auto-scroll: `useEffect` watching activeSegmentIndex, calls `Element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })` on the active element. Use a ref map or data attribute to find the element.
|
||||
- Click handler: clicking a segment calls `onSeek(segment.start_time)`
|
||||
- Empty state: if segments array is empty, show "No transcript available"
|
||||
3. Create `frontend/src/pages/WatchPage.tsx`:
|
||||
- Route: `/watch/:videoId` — read videoId from useParams, optional `t` from useSearchParams (parse as float, clamp to 0 if NaN/negative)
|
||||
- Fetch video detail and transcript via the API functions
|
||||
- Loading/error states
|
||||
- Layout: CSS grid — video player (main area) + transcript sidebar (right column on desktop, below on mobile)
|
||||
- Instantiate `useMediaSync`, pass to `VideoPlayer` (with `src=video.video_url`, `startTime=t`) and `PlayerControls`
|
||||
- Pass transcript segments and mediaSync.currentTime/seekTo to `TranscriptSidebar`
|
||||
- Page title: `useDocumentTitle` with video filename
|
||||
- Header: show video filename and link to creator detail page
|
||||
4. Add route to `frontend/src/App.tsx`:
|
||||
- Lazy-load WatchPage: `const WatchPage = React.lazy(() => import('./pages/WatchPage'));`
|
||||
- Add route: `<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />`
|
||||
- Place it in the public routes section
|
||||
5. Update `frontend/src/pages/TechniquePage.tsx`:
|
||||
- In the key moments list, wrap each timestamp span in a `<Link>` to `/watch/${km.source_video_id}?t=${km.start_time}`
|
||||
- Only link if `km.source_video_id` is truthy
|
||||
- Style the timestamp link with cyan color and underline on hover
|
||||
6. Add styles to `frontend/src/App.css`:
|
||||
- `.watch-page` grid layout: `grid-template-columns: 1fr 22rem` on desktop, single column on mobile (768px breakpoint)
|
||||
- `.watch-page__header` with video title and creator link
|
||||
- `.transcript-sidebar` with max-height, overflow-y auto, scrollbar styling
|
||||
- `.transcript-segment` row styling with hover state
|
||||
- `.transcript-segment--active` with cyan left border and subtle background
|
||||
- `.transcript-segment__time` monospace font
|
||||
- Responsive: sidebar below player on mobile
|
||||
7. Verify: `cd frontend && npx tsc --noEmit && npm run build` — zero errors, WatchPage chunk separate from main.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] TranscriptSidebar uses binary search for active segment (not linear scan)
|
||||
- [ ] Active segment auto-scrolls into view with smooth behavior
|
||||
- [ ] Click on segment seeks video to that timestamp
|
||||
- [ ] WatchPage fetches video + transcript, composes player + sidebar
|
||||
- [ ] `/watch/:videoId` route registered and lazy-loaded
|
||||
- [ ] `?t=` query param sets initial seek position
|
||||
- [ ] TechniquePage timestamps are clickable links to /watch/:videoId?t=X
|
||||
- [ ] Responsive layout: sidebar beside player on desktop, below on mobile
|
||||
|
||||
## Negative Tests
|
||||
|
||||
- Malformed inputs: `?t=abc` → clamp to 0, `?t=-5` → clamp to 0
|
||||
- Error paths: invalid videoId → 404 error state, transcript fetch fails → player still works without sidebar
|
||||
- Boundary conditions: empty segments array → "No transcript available", video with 500+ segments → binary search keeps performance O(log n)
|
||||
- Estimate: 2h
|
||||
- Files: frontend/src/api/videos.ts, frontend/src/components/TranscriptSidebar.tsx, frontend/src/pages/WatchPage.tsx, frontend/src/App.tsx, frontend/src/pages/TechniquePage.tsx, frontend/src/App.css
|
||||
- Verify: cd frontend && npx tsc --noEmit && npm run build
|
||||
|
|
|
|||
127
.gsd/milestones/M020/slices/S01/S01-RESEARCH.md
Normal file
127
.gsd/milestones/M020/slices/S01/S01-RESEARCH.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# S01 Research: Web Media Player MVP
|
||||
|
||||
## Summary
|
||||
|
||||
This slice adds a custom video player with HLS playback, speed controls, and synchronized transcript sidebar to the Chrysopedia frontend. The work is **high-risk** because the required video infrastructure (file storage, transcoding, streaming) does not exist yet — the system currently processes only transcript JSON files.
|
||||
|
||||
### Key Findings
|
||||
|
||||
1. **No video files on the server.** `/vmPool/r/services/chrysopedia_data/` contains `transcripts/` and `video_meta/` but zero MP4/MKV files. `SourceVideo.file_path` stores relative paths like `Chee/01 Day 1 - Drums - Part 1.mp4` but these are metadata references — the actual videos live elsewhere (likely the source machine used for Whisper transcription).
|
||||
|
||||
2. **No HLS infrastructure.** ffmpeg is not installed on ub01 or in any container. No HLS transcoding pipeline exists. No `.m3u8` playlists or `.ts` segments exist.
|
||||
|
||||
3. **No media serving endpoint.** The nginx config only proxies `/api/` to the FastAPI backend and serves the SPA. There's no `/media/` location for video files.
|
||||
|
||||
4. **MinIO is planned but not deployed.** D035 chose MinIO for file/object storage but the compose stack doesn't include a MinIO service yet.
|
||||
|
||||
5. **Rich transcript data exists.** 206,767 transcript segments across 383 videos with `start_time`, `end_time`, `text` fields. Word-level timing is in the raw JSON files. KeyMoments have `raw_transcript` text and `start_time`/`end_time` timestamps.
|
||||
|
||||
6. **Frontend has no media dependencies.** `package.json` dependencies: react, react-dom, react-router-dom. No video player library.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Phased approach — build the player UI now against a mock/demo video, wire real video infrastructure separately.**
|
||||
|
||||
The player component, transcript sync, speed controls, and UI integration are substantial frontend work independent of video storage. Build and verify the player components against a single test video (or a publicly available HLS test stream), then wire real video serving when MinIO + ffmpeg transcoding land.
|
||||
|
||||
### Phase 1 (this slice): Player UI + Transcript Sync
|
||||
- New `VideoPlayer` component using **hls.js** for HLS playback, falling back to native `<video>` for MP4
|
||||
- Custom controls: play/pause, seek bar, speed selector (0.5x, 0.75x, 1x, 1.25x, 1.5x, 2x), volume, fullscreen
|
||||
- `TranscriptSidebar` component: scrollable segment list synced to playback position, auto-scroll to active segment, click-to-seek
|
||||
- New `/watch/:videoId` route with player + transcript layout
|
||||
- Integration on `TechniquePage`: key moment timestamps become clickable → navigate to player at that timestamp
|
||||
- New API endpoint: `GET /api/v1/videos/:id/transcript` returning segments for a video
|
||||
- New API field: `video_url` on video responses (nullable, empty for now until MinIO is up)
|
||||
|
||||
### Phase 2 (separate slice): Video Storage + Transcoding
|
||||
- MinIO service in docker-compose
|
||||
- Upload endpoint or batch import for video files
|
||||
- ffmpeg-based HLS transcoding (background task)
|
||||
- `video_url` population with signed MinIO URLs
|
||||
|
||||
## Implementation Landscape
|
||||
|
||||
### New files needed
|
||||
|
||||
**Backend:**
|
||||
- `backend/routers/videos.py` — extend with `GET /videos/{id}` (single video detail), `GET /videos/{id}/transcript` (transcript segments ordered by start_time)
|
||||
- `backend/schemas.py` — add `video_url: str | None = None` to `SourceVideoRead`, add `TranscriptForPlayerResponse` schema
|
||||
|
||||
**Frontend:**
|
||||
- `frontend/src/components/VideoPlayer.tsx` — HLS player component wrapping hls.js + HTML5 `<video>` with custom controls
|
||||
- `frontend/src/components/TranscriptSidebar.tsx` — time-synced transcript display
|
||||
- `frontend/src/components/PlayerControls.tsx` — custom playback controls (speed, seek, volume)
|
||||
- `frontend/src/pages/WatchPage.tsx` — new route `/watch/:videoId` composing player + transcript
|
||||
- `frontend/src/hooks/useMediaSync.ts` — shared state between player and transcript (currentTime, playing, playbackRate)
|
||||
- `frontend/src/api/videos.ts` — API functions for video detail + transcript
|
||||
|
||||
**Config:**
|
||||
- `frontend/package.json` — add `hls.js` dependency
|
||||
- `docker/nginx.conf` — no changes needed yet (video serving deferred)
|
||||
|
||||
### Existing files modified
|
||||
|
||||
- `frontend/src/App.tsx` — add `/watch/:videoId` route
|
||||
- `frontend/src/pages/TechniquePage.tsx` — make key moment timestamps clickable (link to `/watch/:videoId?t=startTime`)
|
||||
- `frontend/src/api/techniques.ts` — no changes needed (KeyMomentSummary already has source_video_id)
|
||||
- `frontend/src/App.css` — add player/transcript styles (or new CSS file)
|
||||
|
||||
### Key library: hls.js
|
||||
|
||||
- NPM: `hls.js` (current: v1.5.x)
|
||||
- Zero dependencies, ~300KB gzipped
|
||||
- Attaches to a native `<video>` element via MediaSource Extensions
|
||||
- Pattern: `const hls = new Hls(); hls.loadSource(url); hls.attachMedia(videoEl);`
|
||||
- Events: `MANIFEST_PARSED` (ready), `ERROR` (with `fatal` flag), `LEVEL_SWITCHED`
|
||||
- Cleanup: `hls.destroy()` in React useEffect return
|
||||
- Native HLS fallback: Safari supports HLS natively — check `video.canPlayType('application/vnd.apple.mpegstream')` and skip hls.js if true
|
||||
- Speed control: use native `videoEl.playbackRate = rate`, works with hls.js attached
|
||||
|
||||
### Data flow for transcript sync
|
||||
|
||||
```
|
||||
TranscriptSegment (DB)
|
||||
→ GET /api/v1/videos/:id/transcript → [{start_time, end_time, text, segment_index}]
|
||||
→ TranscriptSidebar component
|
||||
→ useMediaSync hook tracks video.currentTime via timeupdate event (~4Hz)
|
||||
→ Binary search on segments array to find active segment
|
||||
→ Auto-scroll to active segment, highlight it
|
||||
→ Click segment → video.currentTime = segment.start_time
|
||||
```
|
||||
|
||||
### Video URL strategy (interim)
|
||||
|
||||
Until MinIO is deployed, `video_url` will be `null` for all videos. The player page should show a clear "Video not available" state when `video_url` is null. For development/demo, use a public HLS test stream (e.g., Apple's Bipbop stream or similar) so the player can be built and tested end-to-end.
|
||||
|
||||
Alternatively, the player can be tested with a local MP4 served via the API (FastAPI `FileResponse` from a test file). This is better for verifying transcript sync since we'd have matching timestamps.
|
||||
|
||||
### Seams for task decomposition
|
||||
|
||||
1. **Backend: Video detail + transcript endpoint** — standalone, testable API work
|
||||
2. **Frontend: VideoPlayer component + hls.js integration** — can be built/tested with a test stream URL, independent of transcript
|
||||
3. **Frontend: TranscriptSidebar + sync hook** — needs the transcript API, independent of actual video
|
||||
4. **Frontend: WatchPage route + layout** — composes player + transcript
|
||||
5. **Frontend: TechniquePage integration** — make timestamps clickable, link to watch page
|
||||
6. **CSS/styling** — player chrome, transcript sidebar, responsive layout
|
||||
|
||||
### Risks and constraints
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| No video files available for testing | Can't verify real HLS playback end-to-end | Use public HLS test stream for player testing, mock transcript data for sync testing |
|
||||
| hls.js bundle size (~300KB) | Adds to initial load | Lazy-load via dynamic import on WatchPage (already have Suspense pattern) |
|
||||
| Transcript segment count per video | Some videos have 500+ segments — could cause scroll performance issues | Virtualized list or windowed rendering if needed; start without and measure |
|
||||
| Safari native HLS vs hls.js | Different code paths for Safari vs Chrome/Firefox | Standard hls.js pattern: check `canPlayType` first, fall back to hls.js |
|
||||
| `timeupdate` event frequency | ~4Hz is enough for segment-level sync but choppy for word-level | Segment-level sync is the MVP; word-level highlighting is a follow-up |
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
- **Video player library:** Use `hls.js`, not a custom MSE implementation. It handles all HLS complexity (adaptive bitrate, buffer management, error recovery).
|
||||
- **Scroll-into-view for transcript:** Use `Element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })`, not manual scroll position calculation.
|
||||
- **Binary search for active segment:** Use a proper binary search on the sorted segments array, not a linear scan on every timeupdate (O(log n) vs O(n) for 500+ segments).
|
||||
|
||||
## Sources
|
||||
|
||||
- hls.js GitHub: github.com/video-dev/hls.js — API docs confirm `attachMedia`, `loadSource`, events pattern
|
||||
- Existing codebase: `backend/models.py` (SourceVideo, TranscriptSegment, KeyMoment models), `frontend/src/pages/TechniquePage.tsx` (key moment display pattern), `frontend/src/api/techniques.ts` (API client pattern)
|
||||
- Infrastructure: `docker-compose.yml` (no media serving), `docker/nginx.conf` (API proxy only), `backend/config.py` (no video storage config)
|
||||
69
.gsd/milestones/M020/slices/S01/tasks/T01-PLAN.md
Normal file
69
.gsd/milestones/M020/slices/S01/tasks/T01-PLAN.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
estimated_steps: 35
|
||||
estimated_files: 3
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T01: Add video detail and transcript API endpoints
|
||||
|
||||
Add two new endpoints to the existing videos router:
|
||||
|
||||
1. `GET /videos/{video_id}` — single video detail with eager-loaded creator (name, slug). Returns `SourceVideoDetail` schema (extends SourceVideoRead with creator_name, creator_slug fields).
|
||||
|
||||
2. `GET /videos/{video_id}/transcript` — returns all TranscriptSegments for a video, ordered by segment_index. Returns `TranscriptForPlayerResponse` with video_id and segments list.
|
||||
|
||||
Both return 404 for non-existent video IDs.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Read `backend/schemas.py` to understand existing SourceVideoRead and TranscriptSegmentRead schemas.
|
||||
2. Add new schemas to `backend/schemas.py`:
|
||||
- `SourceVideoDetail` — extends SourceVideoRead, adds `creator_name: str`, `creator_slug: str`, `video_url: str | None = None` (always None for now)
|
||||
- `TranscriptForPlayerResponse` — `video_id: uuid.UUID`, `segments: list[TranscriptSegmentRead]`, `total: int`
|
||||
3. Read `backend/routers/videos.py` to understand the existing list endpoint pattern.
|
||||
4. Add `GET /videos/{video_id}` endpoint to `backend/routers/videos.py`:
|
||||
- Query SourceVideo by ID with `selectinload(SourceVideo.creator)`
|
||||
- Return 404 if not found
|
||||
- Map to SourceVideoDetail with creator_name and creator_slug from the joined creator
|
||||
5. Add `GET /videos/{video_id}/transcript` endpoint:
|
||||
- Query TranscriptSegments where source_video_id matches, ordered by segment_index
|
||||
- Return 404 if video doesn't exist
|
||||
- Return TranscriptForPlayerResponse
|
||||
6. Write `backend/tests/test_video_detail.py` with tests:
|
||||
- test_get_video_detail_success — creates a video+creator, verifies response shape
|
||||
- test_get_video_detail_404 — random UUID returns 404
|
||||
- test_get_transcript_success — creates video + segments, verifies ordering and count
|
||||
- test_get_transcript_404 — random UUID returns 404
|
||||
- test_get_transcript_empty — video exists but has no segments, returns empty list
|
||||
7. Run tests and fix any issues.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] GET /videos/{video_id} returns video detail with creator_name and creator_slug
|
||||
- [ ] GET /videos/{video_id}/transcript returns segments ordered by segment_index
|
||||
- [ ] Both endpoints return 404 for non-existent video IDs
|
||||
- [ ] Tests pass
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|------------|----------|-----------|----------------------|
|
||||
| PostgreSQL | 500 with logged traceback | FastAPI default timeout | N/A (ORM-mapped) |
|
||||
| video_id UUID parse | FastAPI returns 422 automatically | N/A | N/A |
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``backend/routers/videos.py` — existing videos router with list endpoint`
|
||||
- ``backend/schemas.py` — existing SourceVideoRead and TranscriptSegmentRead schemas`
|
||||
- ``backend/models.py` — SourceVideo, TranscriptSegment, Creator models`
|
||||
- ``backend/database.py` — get_session dependency`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``backend/routers/videos.py` — updated with GET /videos/{video_id} and GET /videos/{video_id}/transcript endpoints`
|
||||
- ``backend/schemas.py` — updated with SourceVideoDetail and TranscriptForPlayerResponse schemas`
|
||||
- ``backend/tests/test_video_detail.py` — 5 integration tests for the new endpoints`
|
||||
|
||||
## Verification
|
||||
|
||||
cd backend && python -m pytest tests/test_video_detail.py -v
|
||||
79
.gsd/milestones/M020/slices/S01/tasks/T01-SUMMARY.md
Normal file
79
.gsd/milestones/M020/slices/S01/tasks/T01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
id: T01
|
||||
parent: S01
|
||||
milestone: M020
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["backend/routers/videos.py", "backend/schemas.py", "backend/tests/test_video_detail.py"]
|
||||
key_decisions: ["Used selectinload for creator eager-loading on video detail endpoint", "Transcript endpoint verifies video existence before querying segments for consistent 404 behavior"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "All 5 tests pass: test_get_video_detail_success, test_get_video_detail_404, test_get_transcript_success, test_get_transcript_404, test_get_transcript_empty. Run via pytest against remote PostgreSQL test database through SSH tunnel."
|
||||
completed_at: 2026-04-03T23:42:31.029Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added GET /videos/{video_id} and GET /videos/{video_id}/transcript endpoints with creator info, ordered segments, and 5 integration tests
|
||||
|
||||
> Added GET /videos/{video_id} and GET /videos/{video_id}/transcript endpoints with creator info, ordered segments, and 5 integration tests
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T01
|
||||
parent: S01
|
||||
milestone: M020
|
||||
key_files:
|
||||
- backend/routers/videos.py
|
||||
- backend/schemas.py
|
||||
- backend/tests/test_video_detail.py
|
||||
key_decisions:
|
||||
- Used selectinload for creator eager-loading on video detail endpoint
|
||||
- Transcript endpoint verifies video existence before querying segments for consistent 404 behavior
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-03T23:42:31.030Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added GET /videos/{video_id} and GET /videos/{video_id}/transcript endpoints with creator info, ordered segments, and 5 integration tests
|
||||
|
||||
**Added GET /videos/{video_id} and GET /videos/{video_id}/transcript endpoints with creator info, ordered segments, and 5 integration tests**
|
||||
|
||||
## What Happened
|
||||
|
||||
Added SourceVideoDetail and TranscriptForPlayerResponse schemas to backend/schemas.py. Added two new endpoints to the videos router: GET /{video_id} with selectinload for creator eager-loading, and GET /{video_id}/transcript with video existence check and segment_index ordering. Both return 404 for missing videos. Wrote 5 integration tests covering success, 404, and empty-segments cases.
|
||||
|
||||
## Verification
|
||||
|
||||
All 5 tests pass: test_get_video_detail_success, test_get_video_detail_404, test_get_transcript_success, test_get_transcript_404, test_get_transcript_empty. Run via pytest against remote PostgreSQL test database through SSH tunnel.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd backend && python -m pytest tests/test_video_detail.py -v` | 0 | ✅ pass | 2820ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `backend/routers/videos.py`
|
||||
- `backend/schemas.py`
|
||||
- `backend/tests/test_video_detail.py`
|
||||
|
||||
|
||||
## Deviations
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
79
.gsd/milestones/M020/slices/S01/tasks/T02-PLAN.md
Normal file
79
.gsd/milestones/M020/slices/S01/tasks/T02-PLAN.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
estimated_steps: 47
|
||||
estimated_files: 5
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T02: Build VideoPlayer component with HLS, custom controls, and media sync hook
|
||||
|
||||
Build the core video player infrastructure: hls.js integration, custom playback controls, and the useMediaSync hook that shares playback state between player and transcript.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Install hls.js: `cd frontend && npm install hls.js`
|
||||
2. Create `frontend/src/hooks/useMediaSync.ts`:
|
||||
- Custom hook managing shared playback state: `currentTime`, `duration`, `isPlaying`, `playbackRate`, `videoRef`
|
||||
- Listens to `timeupdate` (fires ~4Hz), `play`, `pause`, `ratechange`, `loadedmetadata` events on the video element
|
||||
- Exposes `seekTo(time: number)`, `setPlaybackRate(rate: number)`, `togglePlay()` actions
|
||||
- Returns `{ currentTime, duration, isPlaying, playbackRate, videoRef, seekTo, setPlaybackRate, togglePlay }`
|
||||
3. Create `frontend/src/components/VideoPlayer.tsx`:
|
||||
- Accepts props: `src: string | null`, `startTime?: number`, `mediaSync: ReturnType<typeof useMediaSync>`
|
||||
- If `src` is null, render a styled "Video not available" placeholder with explanation text
|
||||
- If src ends in `.m3u8` or device doesn't support native HLS: lazy-load hls.js via dynamic `import('hls.js')`, call `hls.loadSource(src)`, `hls.attachMedia(videoEl)`
|
||||
- If native HLS supported (Safari): set `video.src = src` directly
|
||||
- For `.mp4` URLs: use native `<video src={src}>` directly
|
||||
- Handle HLS errors: on `Hls.Events.ERROR` with `data.fatal`, log to console.error and show error state
|
||||
- `useEffect` cleanup: call `hls.destroy()` on unmount
|
||||
- If `startTime` is provided, seek to it after `loadedmetadata`
|
||||
4. Create `frontend/src/components/PlayerControls.tsx`:
|
||||
- Accepts `mediaSync` prop
|
||||
- Play/pause button (toggles icon based on isPlaying)
|
||||
- Seek bar: `<input type="range">` from 0 to duration, value = currentTime, onChange seeks
|
||||
- Current time / duration display in MM:SS format
|
||||
- Speed selector: dropdown/button group with options [0.5, 0.75, 1, 1.25, 1.5, 2], active state highlighted
|
||||
- Volume control: `<input type="range">` 0–1, mute toggle button
|
||||
- Fullscreen button: calls `videoContainerRef.requestFullscreen()`
|
||||
- Keyboard shortcuts: Space = play/pause, Left/Right arrows = ±5s seek, Up/Down = ±0.1 volume
|
||||
5. Add player styles to `frontend/src/App.css`:
|
||||
- `.video-player` container with 16:9 aspect ratio
|
||||
- `.video-player__unavailable` placeholder styling (dark background, centered message)
|
||||
- `.player-controls` bar styling (dark background, flex layout)
|
||||
- `.player-controls__speed` button group with active state
|
||||
- `.player-controls__seek` range input custom styling (cyan accent)
|
||||
- `.player-controls__volume` range input
|
||||
- Responsive: controls stack or resize at mobile widths
|
||||
6. Verify: `cd frontend && npx tsc --noEmit` passes with zero errors.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] hls.js added to package.json
|
||||
- [ ] useMediaSync hook tracks currentTime, duration, isPlaying, playbackRate
|
||||
- [ ] VideoPlayer renders HLS via hls.js with Safari native fallback
|
||||
- [ ] VideoPlayer shows "Video not available" when src is null
|
||||
- [ ] PlayerControls has play/pause, seek, speed (0.5–2x), volume, fullscreen
|
||||
- [ ] hls.js is lazy-loaded via dynamic import (not in main bundle)
|
||||
- [ ] Cleanup: hls.destroy() on unmount
|
||||
|
||||
## Negative Tests
|
||||
|
||||
- Malformed inputs: null src → "Video not available" state, NaN startTime → clamp to 0
|
||||
- Error paths: HLS fatal error → error message in player area, non-HLS URL → native video fallback
|
||||
- Boundary conditions: duration 0 → seek bar disabled, playbackRate at limits (0.5/2x) → buttons reflect state
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``frontend/src/api/client.ts` — request helper pattern`
|
||||
- ``frontend/src/App.css` — existing CSS custom properties and styling patterns`
|
||||
- ``frontend/package.json` — current dependencies`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``frontend/src/hooks/useMediaSync.ts` — shared playback state hook`
|
||||
- ``frontend/src/components/VideoPlayer.tsx` — HLS player component with unavailable state`
|
||||
- ``frontend/src/components/PlayerControls.tsx` — custom playback controls`
|
||||
- ``frontend/src/App.css` — updated with player and controls styles`
|
||||
- ``frontend/package.json` — updated with hls.js dependency`
|
||||
|
||||
## Verification
|
||||
|
||||
cd frontend && npx tsc --noEmit && npm run build
|
||||
93
.gsd/milestones/M020/slices/S01/tasks/T03-PLAN.md
Normal file
93
.gsd/milestones/M020/slices/S01/tasks/T03-PLAN.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
estimated_steps: 53
|
||||
estimated_files: 6
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T03: Build WatchPage, TranscriptSidebar, route wiring, and TechniquePage timestamp links
|
||||
|
||||
Compose the full watch experience: TranscriptSidebar synced to playback, WatchPage layout, route in App.tsx, and clickable timestamp links on TechniquePage.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `frontend/src/api/videos.ts`:
|
||||
- `fetchVideo(id: string)` → GET `/api/v1/videos/{id}` returning video detail (id, filename, creator_name, creator_slug, video_url, duration_seconds)
|
||||
- `fetchTranscript(videoId: string)` → GET `/api/v1/videos/{videoId}/transcript` returning `{ video_id, segments: [{start_time, end_time, text, segment_index}], total }`
|
||||
- Define TypeScript interfaces: `VideoDetail`, `TranscriptSegment`, `TranscriptResponse`
|
||||
2. Create `frontend/src/components/TranscriptSidebar.tsx`:
|
||||
- Props: `segments: TranscriptSegment[]`, `currentTime: number`, `onSeek: (time: number) => void`
|
||||
- Renders scrollable list of segments, each showing timestamp (MM:SS) and text
|
||||
- Active segment determination: binary search on sorted segments array to find segment where `start_time <= currentTime < end_time` (O(log n) for 500+ segments)
|
||||
- Active segment gets `.transcript-segment--active` class with cyan left border highlight
|
||||
- Auto-scroll: `useEffect` watching activeSegmentIndex, calls `Element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })` on the active element. Use a ref map or data attribute to find the element.
|
||||
- Click handler: clicking a segment calls `onSeek(segment.start_time)`
|
||||
- Empty state: if segments array is empty, show "No transcript available"
|
||||
3. Create `frontend/src/pages/WatchPage.tsx`:
|
||||
- Route: `/watch/:videoId` — read videoId from useParams, optional `t` from useSearchParams (parse as float, clamp to 0 if NaN/negative)
|
||||
- Fetch video detail and transcript via the API functions
|
||||
- Loading/error states
|
||||
- Layout: CSS grid — video player (main area) + transcript sidebar (right column on desktop, below on mobile)
|
||||
- Instantiate `useMediaSync`, pass to `VideoPlayer` (with `src=video.video_url`, `startTime=t`) and `PlayerControls`
|
||||
- Pass transcript segments and mediaSync.currentTime/seekTo to `TranscriptSidebar`
|
||||
- Page title: `useDocumentTitle` with video filename
|
||||
- Header: show video filename and link to creator detail page
|
||||
4. Add route to `frontend/src/App.tsx`:
|
||||
- Lazy-load WatchPage: `const WatchPage = React.lazy(() => import('./pages/WatchPage'));`
|
||||
- Add route: `<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />`
|
||||
- Place it in the public routes section
|
||||
5. Update `frontend/src/pages/TechniquePage.tsx`:
|
||||
- In the key moments list, wrap each timestamp span in a `<Link>` to `/watch/${km.source_video_id}?t=${km.start_time}`
|
||||
- Only link if `km.source_video_id` is truthy
|
||||
- Style the timestamp link with cyan color and underline on hover
|
||||
6. Add styles to `frontend/src/App.css`:
|
||||
- `.watch-page` grid layout: `grid-template-columns: 1fr 22rem` on desktop, single column on mobile (768px breakpoint)
|
||||
- `.watch-page__header` with video title and creator link
|
||||
- `.transcript-sidebar` with max-height, overflow-y auto, scrollbar styling
|
||||
- `.transcript-segment` row styling with hover state
|
||||
- `.transcript-segment--active` with cyan left border and subtle background
|
||||
- `.transcript-segment__time` monospace font
|
||||
- Responsive: sidebar below player on mobile
|
||||
7. Verify: `cd frontend && npx tsc --noEmit && npm run build` — zero errors, WatchPage chunk separate from main.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] TranscriptSidebar uses binary search for active segment (not linear scan)
|
||||
- [ ] Active segment auto-scrolls into view with smooth behavior
|
||||
- [ ] Click on segment seeks video to that timestamp
|
||||
- [ ] WatchPage fetches video + transcript, composes player + sidebar
|
||||
- [ ] `/watch/:videoId` route registered and lazy-loaded
|
||||
- [ ] `?t=` query param sets initial seek position
|
||||
- [ ] TechniquePage timestamps are clickable links to /watch/:videoId?t=X
|
||||
- [ ] Responsive layout: sidebar beside player on desktop, below on mobile
|
||||
|
||||
## Negative Tests
|
||||
|
||||
- Malformed inputs: `?t=abc` → clamp to 0, `?t=-5` → clamp to 0
|
||||
- Error paths: invalid videoId → 404 error state, transcript fetch fails → player still works without sidebar
|
||||
- Boundary conditions: empty segments array → "No transcript available", video with 500+ segments → binary search keeps performance O(log n)
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``frontend/src/hooks/useMediaSync.ts` — media sync hook from T02`
|
||||
- ``frontend/src/components/VideoPlayer.tsx` — player component from T02`
|
||||
- ``frontend/src/components/PlayerControls.tsx` — controls component from T02`
|
||||
- ``frontend/src/api/client.ts` — request helper`
|
||||
- ``frontend/src/pages/TechniquePage.tsx` — existing key moments display`
|
||||
- ``frontend/src/App.tsx` — existing route definitions`
|
||||
- ``frontend/src/hooks/useDocumentTitle.ts` — existing page title hook`
|
||||
- ``frontend/src/App.css` — existing styles with CSS custom properties`
|
||||
- ``backend/routers/videos.py` — API contract from T01`
|
||||
- ``backend/schemas.py` — response schemas from T01`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``frontend/src/api/videos.ts` — API client functions for video detail and transcript`
|
||||
- ``frontend/src/components/TranscriptSidebar.tsx` — time-synced transcript display`
|
||||
- ``frontend/src/pages/WatchPage.tsx` — watch page composing player + transcript`
|
||||
- ``frontend/src/App.tsx` — updated with /watch/:videoId route`
|
||||
- ``frontend/src/pages/TechniquePage.tsx` — updated with clickable timestamp links`
|
||||
- ``frontend/src/App.css` — updated with watch page and transcript styles`
|
||||
|
||||
## Verification
|
||||
|
||||
cd frontend && npx tsc --noEmit && npm run build
|
||||
13386
.gsd/reports/M019-2026-04-03T23-30-16.html
Normal file
13386
.gsd/reports/M019-2026-04-03T23-30-16.html
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -130,7 +130,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
</div>
|
||||
<div class="hdr-right">
|
||||
<span class="gen-lbl">Updated</span>
|
||||
<span class="gen">Apr 3, 2026, 09:17 PM</span>
|
||||
<span class="gen">Apr 3, 2026, 11:30 PM</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -152,6 +152,10 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<div class="toc-group-label">M018</div>
|
||||
<ul><li><a href="M018-2026-04-03T21-17-51.html">Apr 3, 2026, 09:17 PM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
||||
</div>
|
||||
<div class="toc-group">
|
||||
<div class="toc-group-label">M019</div>
|
||||
<ul><li><a href="M019-2026-04-03T23-30-16.html">Apr 3, 2026, 11:30 PM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
|
|
@ -160,39 +164,41 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<h2>Project Overview</h2>
|
||||
|
||||
<div class="idx-summary">
|
||||
<div class="idx-stat"><span class="idx-val">$365.18</span><span class="idx-lbl">Total Cost</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">519.78M</span><span class="idx-lbl">Total Tokens</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">15h 36m</span><span class="idx-lbl">Duration</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">73/123</span><span class="idx-lbl">Slices</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">18/25</span><span class="idx-lbl">Milestones</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">3</span><span class="idx-lbl">Reports</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">$411.26</span><span class="idx-lbl">Total Cost</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">584.83M</span><span class="idx-lbl">Total Tokens</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">17h 48m</span><span class="idx-lbl">Duration</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">79/123</span><span class="idx-lbl">Slices</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">19/25</span><span class="idx-lbl">Milestones</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">4</span><span class="idx-lbl">Reports</span></div>
|
||||
</div>
|
||||
<div class="idx-progress">
|
||||
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:59%"></div></div>
|
||||
<span class="idx-pct">59% complete</span>
|
||||
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:64%"></div></div>
|
||||
<span class="idx-pct">64% complete</span>
|
||||
</div>
|
||||
<div class="sparkline-wrap"><h3>Cost Progression</h3>
|
||||
<div class="sparkline">
|
||||
<svg viewBox="0 0 600 60" width="600" height="60" class="spark-svg">
|
||||
<polyline points="12.0,31.0 300.0,30.2 588.0,12.0" class="spark-line" fill="none"/>
|
||||
<circle cx="12.0" cy="31.0" r="3" class="spark-dot">
|
||||
<polyline points="12.0,32.9 204.0,32.2 396.0,16.0 588.0,12.0" class="spark-line" fill="none"/>
|
||||
<circle cx="12.0" cy="32.9" r="3" class="spark-dot">
|
||||
<title>M008: M008: Credibility Debt Cleanup — Broken Links, Test Data, Jargon, Empty Metrics — $172.23</title>
|
||||
</circle><circle cx="300.0" cy="30.2" r="3" class="spark-dot">
|
||||
</circle><circle cx="204.0" cy="32.2" r="3" class="spark-dot">
|
||||
<title>M009: Homepage & First Impression — $180.97</title>
|
||||
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
|
||||
</circle><circle cx="396.0" cy="16.0" r="3" class="spark-dot">
|
||||
<title>M018: M018: Phase 2 Research & Documentation — Site Audit and Forgejo Wiki Bootstrap — $365.18</title>
|
||||
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
|
||||
<title>M019: Foundations — Auth, Consent & LightRAG — $411.26</title>
|
||||
</circle>
|
||||
<text x="12" y="58" class="spark-lbl">$172.23</text>
|
||||
<text x="588" y="58" text-anchor="end" class="spark-lbl">$365.18</text>
|
||||
<text x="588" y="58" text-anchor="end" class="spark-lbl">$411.26</text>
|
||||
</svg>
|
||||
<div class="spark-axis">
|
||||
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:50.0%" title="2026-03-31T05:52:28.456Z">M009</span><span class="spark-tick" style="left:98.0%" title="2026-04-03T21:17:51.201Z">M018</span>
|
||||
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:34.0%" title="2026-03-31T05:52:28.456Z">M009</span><span class="spark-tick" style="left:66.0%" title="2026-04-03T21:17:51.201Z">M018</span><span class="spark-tick" style="left:98.0%" title="2026-04-03T23:30:16.641Z">M019</span>
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="idx-cards">
|
||||
<h2>Progression <span class="sec-count">3</span></h2>
|
||||
<h2>Progression <span class="sec-count">4</span></h2>
|
||||
<div class="cards-grid">
|
||||
<a class="report-card" href="M008-2026-03-31T05-31-26.html">
|
||||
<div class="card-top">
|
||||
|
|
@ -236,7 +242,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<div class="card-delta"><span>+$8.74</span><span>+3 slices</span><span>+1 milestone</span></div>
|
||||
|
||||
</a>
|
||||
<a class="report-card card-latest" href="M018-2026-04-03T21-17-51.html">
|
||||
<a class="report-card" href="M018-2026-04-03T21-17-51.html">
|
||||
<div class="card-top">
|
||||
<span class="card-label">M018: M018: Phase 2 Research & Documentation — Site Audit and Forgejo Wiki Bootstrap</span>
|
||||
<span class="card-kind card-kind-milestone">milestone</span>
|
||||
|
|
@ -255,6 +261,27 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<span>73/123 slices</span>
|
||||
</div>
|
||||
<div class="card-delta"><span>+$184.21</span><span>+38 slices</span><span>+9 milestones</span></div>
|
||||
|
||||
</a>
|
||||
<a class="report-card card-latest" href="M019-2026-04-03T23-30-16.html">
|
||||
<div class="card-top">
|
||||
<span class="card-label">M019: Foundations — Auth, Consent & LightRAG</span>
|
||||
<span class="card-kind card-kind-milestone">milestone</span>
|
||||
</div>
|
||||
<div class="card-date">Apr 3, 2026, 11:30 PM</div>
|
||||
<div class="card-progress">
|
||||
<div class="card-bar-track">
|
||||
<div class="card-bar-fill" style="width:64%"></div>
|
||||
</div>
|
||||
<span class="card-pct">64%</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<span>$411.26</span>
|
||||
<span>584.83M</span>
|
||||
<span>17h 48m</span>
|
||||
<span>79/123 slices</span>
|
||||
</div>
|
||||
<div class="card-delta"><span>+$46.09</span><span>+6 slices</span><span>+1 milestone</span></div>
|
||||
<div class="card-latest-badge">Latest</div>
|
||||
</a></div>
|
||||
</section>
|
||||
|
|
@ -269,7 +296,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<span class="ftr-sep">—</span>
|
||||
<span>/home/aux/projects/content-to-kb-automator</span>
|
||||
<span class="ftr-sep">—</span>
|
||||
<span>Updated Apr 3, 2026, 09:17 PM</span>
|
||||
<span>Updated Apr 3, 2026, 11:30 PM</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,22 @@
|
|||
"doneMilestones": 18,
|
||||
"totalMilestones": 25,
|
||||
"phase": "planning"
|
||||
},
|
||||
{
|
||||
"filename": "M019-2026-04-03T23-30-16.html",
|
||||
"generatedAt": "2026-04-03T23:30:16.641Z",
|
||||
"milestoneId": "M019",
|
||||
"milestoneTitle": "Foundations — Auth, Consent & LightRAG",
|
||||
"label": "M019: Foundations — Auth, Consent & LightRAG",
|
||||
"kind": "milestone",
|
||||
"totalCost": 411.2631829999999,
|
||||
"totalTokens": 584832132,
|
||||
"totalDuration": 64114496,
|
||||
"doneSlices": 79,
|
||||
"totalSlices": 123,
|
||||
"doneMilestones": 19,
|
||||
"totalMilestones": 25,
|
||||
"phase": "planning"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
"""Source video endpoints for Chrysopedia API."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from database import get_session
|
||||
from models import SourceVideo
|
||||
from schemas import SourceVideoRead, VideoListResponse
|
||||
from models import SourceVideo, TranscriptSegment
|
||||
from schemas import (
|
||||
SourceVideoDetail,
|
||||
SourceVideoRead,
|
||||
TranscriptForPlayerResponse,
|
||||
TranscriptSegmentRead,
|
||||
VideoListResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("chrysopedia.videos")
|
||||
|
||||
|
|
@ -44,3 +52,53 @@ async def list_videos(
|
|||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{video_id}", response_model=SourceVideoDetail)
|
||||
async def get_video_detail(
|
||||
video_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> SourceVideoDetail:
|
||||
"""Get a single video with creator info for the player page."""
|
||||
stmt = (
|
||||
select(SourceVideo)
|
||||
.where(SourceVideo.id == video_id)
|
||||
.options(selectinload(SourceVideo.creator))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
video = result.scalar_one_or_none()
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
detail = SourceVideoDetail.model_validate(video)
|
||||
detail.creator_name = video.creator.name if video.creator else ""
|
||||
detail.creator_slug = video.creator.slug if video.creator else ""
|
||||
logger.debug("Video detail %s (creator=%s)", video_id, detail.creator_name)
|
||||
return detail
|
||||
|
||||
|
||||
@router.get("/{video_id}/transcript", response_model=TranscriptForPlayerResponse)
|
||||
async def get_video_transcript(
|
||||
video_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> TranscriptForPlayerResponse:
|
||||
"""Get all transcript segments for a video, ordered by segment_index."""
|
||||
# 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 = (
|
||||
select(TranscriptSegment)
|
||||
.where(TranscriptSegment.source_video_id == video_id)
|
||||
.order_by(TranscriptSegment.segment_index)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
segments = result.scalars().all()
|
||||
logger.debug("Transcript for %s: %d segments", video_id, len(segments))
|
||||
return TranscriptForPlayerResponse(
|
||||
video_id=video_id,
|
||||
segments=[TranscriptSegmentRead.model_validate(s) for s in segments],
|
||||
total=len(segments),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -386,6 +386,20 @@ class TopicListResponse(BaseModel):
|
|||
total: int = 0
|
||||
|
||||
|
||||
class SourceVideoDetail(SourceVideoRead):
|
||||
"""Single video detail with creator info for player page."""
|
||||
creator_name: str = ""
|
||||
creator_slug: str = ""
|
||||
video_url: str | None = None
|
||||
|
||||
|
||||
class TranscriptForPlayerResponse(BaseModel):
|
||||
"""Transcript segments for the video player sidebar."""
|
||||
video_id: uuid.UUID
|
||||
segments: list[TranscriptSegmentRead] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class VideoListResponse(BaseModel):
|
||||
"""Paginated list of source videos."""
|
||||
items: list[SourceVideoRead] = Field(default_factory=list)
|
||||
|
|
|
|||
133
backend/tests/test_video_detail.py
Normal file
133
backend/tests/test_video_detail.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Tests for video detail and transcript endpoints.
|
||||
|
||||
Covers:
|
||||
- GET /api/v1/videos/{video_id} — single video with creator info
|
||||
- GET /api/v1/videos/{video_id}/transcript — ordered segments
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from models import ContentType, Creator, ProcessingStatus, SourceVideo, TranscriptSegment
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def video_with_creator(db_engine):
|
||||
"""Create a Creator + SourceVideo pair. Returns dict with IDs."""
|
||||
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
creator = Creator(
|
||||
name="TestProducer",
|
||||
slug="testproducer",
|
||||
folder_name="TestProducer",
|
||||
genres=["electronic"],
|
||||
)
|
||||
session.add(creator)
|
||||
await session.flush()
|
||||
|
||||
video = SourceVideo(
|
||||
creator_id=creator.id,
|
||||
filename="synth-tutorial.mp4",
|
||||
file_path="TestProducer/synth-tutorial.mp4",
|
||||
duration_seconds=720,
|
||||
content_type=ContentType.tutorial,
|
||||
processing_status=ProcessingStatus.complete,
|
||||
)
|
||||
session.add(video)
|
||||
await session.flush()
|
||||
|
||||
result = {
|
||||
"creator_id": creator.id,
|
||||
"creator_name": creator.name,
|
||||
"creator_slug": creator.slug,
|
||||
"video_id": video.id,
|
||||
}
|
||||
await session.commit()
|
||||
return result
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def video_with_segments(db_engine, video_with_creator):
|
||||
"""Add transcript segments to the video. Returns video_with_creator + segment count."""
|
||||
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
segments = [
|
||||
TranscriptSegment(
|
||||
source_video_id=video_with_creator["video_id"],
|
||||
start_time=float(i * 10),
|
||||
end_time=float(i * 10 + 9),
|
||||
text=f"Segment {i} text content.",
|
||||
segment_index=i,
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
session.add_all(segments)
|
||||
await session.commit()
|
||||
return {**video_with_creator, "segment_count": 5}
|
||||
|
||||
|
||||
# ── Video Detail ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_video_detail_success(client, video_with_creator):
|
||||
vid = video_with_creator["video_id"]
|
||||
resp = await client.get(f"/api/v1/videos/{vid}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
assert data["id"] == str(vid)
|
||||
assert data["filename"] == "synth-tutorial.mp4"
|
||||
assert data["creator_name"] == "TestProducer"
|
||||
assert data["creator_slug"] == "testproducer"
|
||||
# video_url is always None for now
|
||||
assert data["video_url"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_video_detail_404(client):
|
||||
fake_id = uuid.uuid4()
|
||||
resp = await client.get(f"/api/v1/videos/{fake_id}")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ── Transcript ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_transcript_success(client, video_with_segments):
|
||||
vid = video_with_segments["video_id"]
|
||||
resp = await client.get(f"/api/v1/videos/{vid}/transcript")
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
assert data["video_id"] == str(vid)
|
||||
assert data["total"] == 5
|
||||
assert len(data["segments"]) == 5
|
||||
# Verify ordering by segment_index
|
||||
indices = [s["segment_index"] for s in data["segments"]]
|
||||
assert indices == [0, 1, 2, 3, 4]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_transcript_404(client):
|
||||
fake_id = uuid.uuid4()
|
||||
resp = await client.get(f"/api/v1/videos/{fake_id}/transcript")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_transcript_empty(client, video_with_creator):
|
||||
"""Video exists but has no segments — returns empty list."""
|
||||
vid = video_with_creator["video_id"]
|
||||
resp = await client.get(f"/api/v1/videos/{vid}/transcript")
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
assert data["video_id"] == str(vid)
|
||||
assert data["total"] == 0
|
||||
assert data["segments"] == []
|
||||
Loading…
Add table
Reference in a new issue