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:
jlightner 2026-04-03 23:42:43 +00:00
parent 3710c3f8bb
commit 87cb667848
13 changed files with 14269 additions and 22 deletions

View file

@ -0,0 +1 @@
[]

View file

@ -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.5x2x), 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">` 01, 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.52x), 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

View 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)

View 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

View 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.

View 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">` 01, 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.52x), 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

View 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

File diff suppressed because it is too large Load diff

View file

@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>

View file

@ -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"
}
]
}

View file

@ -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),
)

View file

@ -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)

View 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"] == []