feat: Added share_token column with migration 026, wired token generati…
- "backend/models.py" - "alembic/versions/026_add_share_token.py" - "backend/pipeline/stages.py" - "backend/routers/shorts.py" - "backend/routers/shorts_public.py" - "backend/main.py" GSD-Task: S01/T01
This commit is contained in:
parent
2c5d084c49
commit
5f4b960dc1
15 changed files with 16564 additions and 23 deletions
1
.gsd/completed-units-M023.json
Normal file
1
.gsd/completed-units-M023.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -1,6 +1,42 @@
|
|||
# S01: [A] Shorts Publishing Flow
|
||||
|
||||
**Goal:** Build the shorts publishing flow from approval to shareable output
|
||||
**Goal:** Creator approves a short → it renders → gets a shareable URL and embed code
|
||||
**Demo:** After this: Creator approves a short → it renders → gets a shareable URL and embed code
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Added share_token column with migration 026, wired token generation into pipeline completion, and created public unauthenticated endpoint GET /api/v1/public/shorts/{share_token}** — Add a `share_token` column (String, nullable, unique indexed) to the `GeneratedShort` model. Create Alembic migration 026. Wire token generation into `stage_generate_shorts` at the point where status is set to `complete`. Add `share_token` to the existing `GeneratedShortResponse` so the admin UI can see it. Backfill existing complete shorts with tokens via a one-time migration data step. Create a new public (unauthenticated) API endpoint `GET /api/v1/shorts/public/{share_token}` that resolves the token to video metadata (format_preset, dimensions, duration, creator name, highlight title) plus a fresh MinIO presigned download URL. Register the new public router in `main.py`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add `share_token: Mapped[str | None]` column to `GeneratedShort` in `backend/models.py`. Use `String(16)`, nullable, with a unique index.
|
||||
2. Create `alembic/versions/026_add_share_token.py` migration: add column, create unique index, backfill existing `complete` rows with `secrets.token_urlsafe(8)` tokens.
|
||||
3. In `backend/pipeline/stages.py` `stage_generate_shorts`, after setting `short.status = ShortStatus.complete`, generate and set `short.share_token = secrets.token_urlsafe(8)`.
|
||||
4. Add `share_token: str | None = None` to `GeneratedShortResponse` in `backend/routers/shorts.py` and include it in the response construction.
|
||||
5. Create `backend/routers/shorts_public.py` with a single endpoint: `GET /public/shorts/{share_token}`. Query `GeneratedShort` by share_token, join to `HighlightCandidate` → `KeyMoment` → `SourceVideo` to get metadata. Return presigned URL via `minio_client.generate_download_url`. No auth dependency.
|
||||
6. Register the new router in `backend/main.py`: `app.include_router(shorts_public.router, prefix="/api/v1")`.
|
||||
7. Verify: `alembic upgrade head` succeeds. Python can import the new router without errors.
|
||||
- Estimate: 1.5h
|
||||
- Files: backend/models.py, alembic/versions/026_add_share_token.py, backend/pipeline/stages.py, backend/routers/shorts.py, backend/routers/shorts_public.py, backend/main.py
|
||||
- Verify: cd backend && python -c "from routers.shorts_public import router; print('import ok')" && cd .. && echo 'alembic migration file exists:' && test -f alembic/versions/026_add_share_token.py && echo 'OK'
|
||||
- [ ] **T02: Public shorts page and share/embed buttons on HighlightQueue** — Create the `/shorts/:token` public frontend page with a video player and metadata display. Add share URL copy and embed snippet copy buttons to the HighlightQueue page for completed shorts. Create the frontend API client for the public endpoint.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add `fetchPublicShort` function to `frontend/src/api/shorts.ts` — `GET /api/v1/public/shorts/{token}`, no auth header. Define `PublicShortResponse` type with fields: `video_url`, `format_preset`, `width`, `height`, `duration_secs`, `creator_name`, `highlight_title`, `share_token`.
|
||||
2. Create `frontend/src/pages/ShortPlayer.tsx` — a lightweight public page. Uses `useParams<{ token: string }>()`. Fetches public short data on mount. Renders a `<video>` element with the presigned URL, metadata (creator name, highlight title), and a "Copy Embed Code" button that copies `<iframe src="{origin}/shorts/{token}" width="{width}" height="{height}" frameborder="0" allowfullscreen></iframe>` to clipboard.
|
||||
3. Create `frontend/src/pages/ShortPlayer.module.css` with minimal dark-theme styling matching the existing design system (uses CSS custom properties from `:root`).
|
||||
4. Register route in `frontend/src/App.tsx`: `<Route path="/shorts/:token" element={<Suspense fallback={<LoadingFallback />}><ShortPlayer /></Suspense>} />`. This route must be OUTSIDE the `<ProtectedRoute>` wrapper — it's public.
|
||||
5. In `frontend/src/pages/HighlightQueue.tsx`, add share and embed buttons next to the existing download "↓" button for completed shorts. The share button copies `{window.location.origin}/shorts/{s.share_token}` to clipboard. The embed button copies the iframe snippet. Use a brief "Copied!" tooltip/flash on successful copy. Requires `share_token` field on the `GeneratedShort` TypeScript type.
|
||||
6. Update `GeneratedShort` interface in `frontend/src/api/shorts.ts` to include `share_token: string | null`.
|
||||
7. Run `npm run build` — must pass with zero TypeScript errors.
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|---|---|---|---|
|
||||
| Public shorts API | Show error message on ShortPlayer page | Show loading timeout message | Show "Short not found" |
|
||||
| MinIO presigned URL (in video src) | `<video>` shows browser error state | Same | Same |
|
||||
| Clipboard API | Fallback to `document.execCommand('copy')` or show URL in selectable text | N/A | N/A |
|
||||
- Estimate: 1.5h
|
||||
- Files: frontend/src/api/shorts.ts, frontend/src/pages/ShortPlayer.tsx, frontend/src/pages/ShortPlayer.module.css, frontend/src/App.tsx, frontend/src/pages/HighlightQueue.tsx
|
||||
- Verify: cd frontend && npm run build 2>&1 | tail -5
|
||||
|
|
|
|||
68
.gsd/milestones/M024/slices/S01/S01-RESEARCH.md
Normal file
68
.gsd/milestones/M024/slices/S01/S01-RESEARCH.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# S01 Research — Shorts Publishing Flow
|
||||
|
||||
## Summary
|
||||
|
||||
The existing infrastructure covers the full pipeline from highlight detection → creator review/trim/approve → Celery+ffmpeg clip extraction → MinIO upload → presigned download. What's missing is the **public sharing layer**: a way for a completed short to have a stable, shareable URL and an embeddable iframe snippet.
|
||||
|
||||
This is a targeted extension of well-understood patterns already in the codebase. No new technology is needed.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Add a `share_token` column to `GeneratedShort`, a public (unauthenticated) API endpoint to resolve tokens to video metadata + presigned URLs, a lightweight frontend page at `/shorts/:token` that plays the video, and share/embed UI in the existing HighlightQueue page.
|
||||
|
||||
## Implementation Landscape
|
||||
|
||||
### What Exists
|
||||
|
||||
| Component | File(s) | State |
|
||||
|---|---|---|
|
||||
| `GeneratedShort` model | `backend/models.py:836` | Complete — id, highlight_candidate_id, format_preset, minio_object_key, status, dimensions, etc. No share_token or slug. |
|
||||
| Shorts generation Celery task | `backend/pipeline/stages.py:2868` | Complete — `stage_generate_shorts` extracts clips per FormatPreset, uploads to MinIO, updates status. |
|
||||
| FFmpeg clip extraction | `backend/pipeline/shorts_generator.py` | Complete — pure functions, 3 preset specs (vertical 1080×1920, square 1080×1080, horizontal 1920×1080). |
|
||||
| Admin shorts router | `backend/routers/shorts.py` | Complete — POST generate, GET list by highlight, GET download presigned URL. All under `/admin/shorts`, auth-guarded. |
|
||||
| Creator highlights router | `backend/routers/creator_highlights.py` | Complete — list, detail, approve/reject, trim. Auth-guarded via `get_current_user`. |
|
||||
| Frontend API clients | `frontend/src/api/shorts.ts`, `frontend/src/api/highlights.ts` | Complete — all CRUD operations. |
|
||||
| Frontend HighlightQueue | `frontend/src/pages/HighlightQueue.tsx` | Complete — filter tabs, approve/reject, trim, generate shorts, poll for completion, download button. |
|
||||
| MinIO client | `backend/minio_client.py` | Complete — `upload_file`, `generate_download_url` (presigned, 1hr default), `delete_file`. |
|
||||
| Alembic migration | `alembic/versions/025_add_generated_shorts.py` | Applied — `generated_shorts` table with enums. |
|
||||
|
||||
### What Needs Building
|
||||
|
||||
1. **Schema: `share_token` on `GeneratedShort`** — A short, URL-safe, unique string (e.g., `secrets.token_urlsafe(8)` → 11 chars). Generated when the short reaches `complete` status in `stage_generate_shorts`. Alembic migration 026 adds the nullable column + unique index.
|
||||
|
||||
2. **Config: `site_url` setting** — Needed to construct full shareable URLs (e.g., `https://chrysopedia.com/shorts/abc123`). Add to `Settings` in `backend/config.py` with default `http://localhost:8096`. The frontend already uses relative URLs (`/api/v1/...`), so the site_url is only needed server-side for generating absolute share links.
|
||||
|
||||
3. **Public API endpoint** — New router or addition to existing shorts router: `GET /api/v1/shorts/public/{share_token}` (unauthenticated). Returns: video presigned URL, format_preset, dimensions, duration, creator name, highlight title. This endpoint must NOT require auth — it's the URL that gets shared.
|
||||
|
||||
4. **Public frontend page** — Route: `/shorts/:token`. Lightweight page with a video player (reuse existing `<video>` element pattern from WatchPage), short metadata (creator, technique), and a copy-embed-code button. No sidebar, minimal chrome.
|
||||
|
||||
5. **Embed code generation** — The public page already serves as the embed target. The embed snippet is just `<iframe src="{site_url}/shorts/{token}" ...>`. This can be constructed client-side from the share_token. No separate backend endpoint needed — the frontend page IS the embed target.
|
||||
|
||||
6. **Creator UI: share + embed buttons** — On HighlightQueue, for completed shorts: "Share" button copies the public URL, "Embed" button shows/copies the iframe snippet. These replace or augment the current download-only "↓" button.
|
||||
|
||||
### Natural Task Seams
|
||||
|
||||
1. **Backend schema + migration + token generation** — Add `share_token` column, migration, generate token in `stage_generate_shorts` on completion. Also backfill existing complete shorts. Self-contained DB+pipeline change.
|
||||
|
||||
2. **Public API endpoint** — New unauthenticated endpoint resolving share_token → metadata + presigned URL. Depends on (1) for the column to exist.
|
||||
|
||||
3. **Public frontend page** — `/shorts/:token` route + lightweight player page. Depends on (2) for the API.
|
||||
|
||||
4. **Creator UI share/embed buttons** — Add share URL copy + embed snippet copy to HighlightQueue completed shorts. Can run in parallel with (3) since it just constructs URLs from known token values.
|
||||
|
||||
### Key Constraints
|
||||
|
||||
- **Presigned URL expiry**: MinIO presigned URLs expire (default 1hr). The public page must fetch a fresh presigned URL on each load via the public API, not cache it in the share link itself. The share link is a stable token; the video URL behind it is ephemeral.
|
||||
- **No auth on public endpoint**: The share_token acts as the access control — knowing the token grants view access. Tokens should be unguessable (cryptographic randomness).
|
||||
- **S03 overlap**: S03 ("Embed Support — iframe Snippet") covers embed for the main player. This slice's embed is specifically for shorts. Different pages, same pattern (iframe + embed code copy). No conflict — they're independent features.
|
||||
- **Video streaming**: The public endpoint returns a MinIO presigned URL. The frontend `<video>` element loads directly from MinIO. No backend proxy needed for the video bytes.
|
||||
- **Share token generation timing**: Generate at short completion time in `stage_generate_shorts`, not lazily on first share. Simpler, and avoids race conditions if two requests try to create the token simultaneously.
|
||||
|
||||
### Verification Strategy
|
||||
|
||||
- **Migration**: Run `alembic upgrade head` — no errors.
|
||||
- **Token generation**: Generate shorts for an approved highlight, verify `share_token` is populated in DB.
|
||||
- **Public API**: `curl /api/v1/shorts/public/{token}` without auth token → 200 with video URL.
|
||||
- **Frontend page**: Navigate to `/shorts/{token}` in browser → video plays.
|
||||
- **Share/Embed UI**: On HighlightQueue, click share → clipboard contains full URL. Click embed → clipboard contains iframe snippet.
|
||||
- **Build**: `npm run build` passes with zero TypeScript errors.
|
||||
40
.gsd/milestones/M024/slices/S01/tasks/T01-PLAN.md
Normal file
40
.gsd/milestones/M024/slices/S01/tasks/T01-PLAN.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
estimated_steps: 9
|
||||
estimated_files: 6
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T01: Add share_token column, migration, token generation, and public API endpoint
|
||||
|
||||
Add a `share_token` column (String, nullable, unique indexed) to the `GeneratedShort` model. Create Alembic migration 026. Wire token generation into `stage_generate_shorts` at the point where status is set to `complete`. Add `share_token` to the existing `GeneratedShortResponse` so the admin UI can see it. Backfill existing complete shorts with tokens via a one-time migration data step. Create a new public (unauthenticated) API endpoint `GET /api/v1/shorts/public/{share_token}` that resolves the token to video metadata (format_preset, dimensions, duration, creator name, highlight title) plus a fresh MinIO presigned download URL. Register the new public router in `main.py`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add `share_token: Mapped[str | None]` column to `GeneratedShort` in `backend/models.py`. Use `String(16)`, nullable, with a unique index.
|
||||
2. Create `alembic/versions/026_add_share_token.py` migration: add column, create unique index, backfill existing `complete` rows with `secrets.token_urlsafe(8)` tokens.
|
||||
3. In `backend/pipeline/stages.py` `stage_generate_shorts`, after setting `short.status = ShortStatus.complete`, generate and set `short.share_token = secrets.token_urlsafe(8)`.
|
||||
4. Add `share_token: str | None = None` to `GeneratedShortResponse` in `backend/routers/shorts.py` and include it in the response construction.
|
||||
5. Create `backend/routers/shorts_public.py` with a single endpoint: `GET /public/shorts/{share_token}`. Query `GeneratedShort` by share_token, join to `HighlightCandidate` → `KeyMoment` → `SourceVideo` to get metadata. Return presigned URL via `minio_client.generate_download_url`. No auth dependency.
|
||||
6. Register the new router in `backend/main.py`: `app.include_router(shorts_public.router, prefix="/api/v1")`.
|
||||
7. Verify: `alembic upgrade head` succeeds. Python can import the new router without errors.
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``backend/models.py` — GeneratedShort model at line 836`
|
||||
- ``backend/pipeline/stages.py` — stage_generate_shorts at line 2868`
|
||||
- ``backend/routers/shorts.py` — existing GeneratedShortResponse schema and admin endpoints`
|
||||
- ``backend/main.py` — router registration`
|
||||
- ``backend/minio_client.py` — generate_download_url function`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``backend/models.py` — GeneratedShort with share_token column`
|
||||
- ``alembic/versions/026_add_share_token.py` — migration adding share_token column with backfill`
|
||||
- ``backend/pipeline/stages.py` — token generation on short completion`
|
||||
- ``backend/routers/shorts.py` — share_token in GeneratedShortResponse`
|
||||
- ``backend/routers/shorts_public.py` — public unauthenticated endpoint`
|
||||
- ``backend/main.py` — shorts_public router registered`
|
||||
|
||||
## Verification
|
||||
|
||||
cd backend && python -c "from routers.shorts_public import router; print('import ok')" && cd .. && echo 'alembic migration file exists:' && test -f alembic/versions/026_add_share_token.py && echo 'OK'
|
||||
88
.gsd/milestones/M024/slices/S01/tasks/T01-SUMMARY.md
Normal file
88
.gsd/milestones/M024/slices/S01/tasks/T01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
id: T01
|
||||
parent: S01
|
||||
milestone: M024
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["backend/models.py", "alembic/versions/026_add_share_token.py", "backend/pipeline/stages.py", "backend/routers/shorts.py", "backend/routers/shorts_public.py", "backend/main.py"]
|
||||
key_decisions: ["Public endpoint uses async FastAPI with get_session dependency rather than sync session from pipeline internals", "Public endpoint returns 404 for both missing and non-complete shorts to avoid leaking status info"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Import check passed, migration file exists, model column present, response schema includes share_token, route /api/v1/public/shorts/{share_token} registered in app."
|
||||
completed_at: 2026-04-04T10:32:53.889Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added share_token column with migration 026, wired token generation into pipeline completion, and created public unauthenticated endpoint GET /api/v1/public/shorts/{share_token}
|
||||
|
||||
> Added share_token column with migration 026, wired token generation into pipeline completion, and created public unauthenticated endpoint GET /api/v1/public/shorts/{share_token}
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T01
|
||||
parent: S01
|
||||
milestone: M024
|
||||
key_files:
|
||||
- backend/models.py
|
||||
- alembic/versions/026_add_share_token.py
|
||||
- backend/pipeline/stages.py
|
||||
- backend/routers/shorts.py
|
||||
- backend/routers/shorts_public.py
|
||||
- backend/main.py
|
||||
key_decisions:
|
||||
- Public endpoint uses async FastAPI with get_session dependency rather than sync session from pipeline internals
|
||||
- Public endpoint returns 404 for both missing and non-complete shorts to avoid leaking status info
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-04T10:32:53.889Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added share_token column with migration 026, wired token generation into pipeline completion, and created public unauthenticated endpoint GET /api/v1/public/shorts/{share_token}
|
||||
|
||||
**Added share_token column with migration 026, wired token generation into pipeline completion, and created public unauthenticated endpoint GET /api/v1/public/shorts/{share_token}**
|
||||
|
||||
## What Happened
|
||||
|
||||
Added share_token (String(16), nullable, unique-indexed) to GeneratedShort model. Created Alembic migration 026 with column addition, backfill of existing complete shorts, and unique index. Wired secrets.token_urlsafe(8) into stage_generate_shorts at the completion point. Added share_token to GeneratedShortResponse schema and list endpoint construction. Created shorts_public.py with async GET endpoint that resolves tokens via selectinload joins through the full relation chain to return metadata and presigned download URL. Registered the router in main.py.
|
||||
|
||||
## Verification
|
||||
|
||||
Import check passed, migration file exists, model column present, response schema includes share_token, route /api/v1/public/shorts/{share_token} registered in app.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd backend && python -c "from routers.shorts_public import router; print('import ok')"` | 0 | ✅ pass | 800ms |
|
||||
| 2 | `test -f alembic/versions/026_add_share_token.py` | 0 | ✅ pass | 50ms |
|
||||
| 3 | `cd backend && python -c "from models import GeneratedShort; print(hasattr(GeneratedShort, 'share_token'))"` | 0 | ✅ pass | 700ms |
|
||||
| 4 | `cd backend && python -c "from main import app; routes=[r.path for r in app.routes]; print([r for r in routes if 'public/shorts' in r])"` | 0 | ✅ pass | 900ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Used async endpoint with get_session dependency instead of sync session — matches all other routers and avoids importing private pipeline internals.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `backend/models.py`
|
||||
- `alembic/versions/026_add_share_token.py`
|
||||
- `backend/pipeline/stages.py`
|
||||
- `backend/routers/shorts.py`
|
||||
- `backend/routers/shorts_public.py`
|
||||
- `backend/main.py`
|
||||
|
||||
|
||||
## Deviations
|
||||
Used async endpoint with get_session dependency instead of sync session — matches all other routers and avoids importing private pipeline internals.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
48
.gsd/milestones/M024/slices/S01/tasks/T02-PLAN.md
Normal file
48
.gsd/milestones/M024/slices/S01/tasks/T02-PLAN.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
estimated_steps: 15
|
||||
estimated_files: 5
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T02: Public shorts page and share/embed buttons on HighlightQueue
|
||||
|
||||
Create the `/shorts/:token` public frontend page with a video player and metadata display. Add share URL copy and embed snippet copy buttons to the HighlightQueue page for completed shorts. Create the frontend API client for the public endpoint.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add `fetchPublicShort` function to `frontend/src/api/shorts.ts` — `GET /api/v1/public/shorts/{token}`, no auth header. Define `PublicShortResponse` type with fields: `video_url`, `format_preset`, `width`, `height`, `duration_secs`, `creator_name`, `highlight_title`, `share_token`.
|
||||
2. Create `frontend/src/pages/ShortPlayer.tsx` — a lightweight public page. Uses `useParams<{ token: string }>()`. Fetches public short data on mount. Renders a `<video>` element with the presigned URL, metadata (creator name, highlight title), and a "Copy Embed Code" button that copies `<iframe src="{origin}/shorts/{token}" width="{width}" height="{height}" frameborder="0" allowfullscreen></iframe>` to clipboard.
|
||||
3. Create `frontend/src/pages/ShortPlayer.module.css` with minimal dark-theme styling matching the existing design system (uses CSS custom properties from `:root`).
|
||||
4. Register route in `frontend/src/App.tsx`: `<Route path="/shorts/:token" element={<Suspense fallback={<LoadingFallback />}><ShortPlayer /></Suspense>} />`. This route must be OUTSIDE the `<ProtectedRoute>` wrapper — it's public.
|
||||
5. In `frontend/src/pages/HighlightQueue.tsx`, add share and embed buttons next to the existing download "↓" button for completed shorts. The share button copies `{window.location.origin}/shorts/{s.share_token}` to clipboard. The embed button copies the iframe snippet. Use a brief "Copied!" tooltip/flash on successful copy. Requires `share_token` field on the `GeneratedShort` TypeScript type.
|
||||
6. Update `GeneratedShort` interface in `frontend/src/api/shorts.ts` to include `share_token: string | null`.
|
||||
7. Run `npm run build` — must pass with zero TypeScript errors.
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|---|---|---|---|
|
||||
| Public shorts API | Show error message on ShortPlayer page | Show loading timeout message | Show "Short not found" |
|
||||
| MinIO presigned URL (in video src) | `<video>` shows browser error state | Same | Same |
|
||||
| Clipboard API | Fallback to `document.execCommand('copy')` or show URL in selectable text | N/A | N/A |
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``frontend/src/api/shorts.ts` — existing shorts API types and functions`
|
||||
- ``frontend/src/api/client.ts` — request helper and BASE url`
|
||||
- ``frontend/src/App.tsx` — route registration`
|
||||
- ``frontend/src/pages/HighlightQueue.tsx` — existing shorts UI with download button`
|
||||
- ``frontend/src/pages/HighlightQueue.module.css` — existing styles`
|
||||
- ``backend/routers/shorts_public.py` — public API response shape (from T01)`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``frontend/src/api/shorts.ts` — updated with PublicShortResponse type and fetchPublicShort function, share_token on GeneratedShort`
|
||||
- ``frontend/src/pages/ShortPlayer.tsx` — public shorts player page`
|
||||
- ``frontend/src/pages/ShortPlayer.module.css` — page styles`
|
||||
- ``frontend/src/App.tsx` — /shorts/:token route registered`
|
||||
- ``frontend/src/pages/HighlightQueue.tsx` — share and embed buttons on completed shorts`
|
||||
|
||||
## Verification
|
||||
|
||||
cd frontend && npm run build 2>&1 | tail -5
|
||||
16069
.gsd/reports/M023-2026-04-04T10-23-24.html
Normal file
16069
.gsd/reports/M023-2026-04-04T10-23-24.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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 4, 2026, 08:51 AM</span>
|
||||
<span class="gen">Apr 4, 2026, 10:23 AM</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -164,6 +164,10 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<div class="toc-group-label">M022</div>
|
||||
<ul><li><a href="M022-2026-04-04T08-51-51.html">Apr 4, 2026, 08:51 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
||||
</div>
|
||||
<div class="toc-group">
|
||||
<div class="toc-group-label">M023</div>
|
||||
<ul><li><a href="M023-2026-04-04T10-23-24.html">Apr 4, 2026, 10:23 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
|
|
@ -172,45 +176,47 @@ 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">$516.43</span><span class="idx-lbl">Total Cost</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">728.19M</span><span class="idx-lbl">Total Tokens</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">22h 28m</span><span class="idx-lbl">Duration</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">101/123</span><span class="idx-lbl">Slices</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">22/25</span><span class="idx-lbl">Milestones</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">6</span><span class="idx-lbl">Reports</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">$550.85</span><span class="idx-lbl">Total Cost</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">777.32M</span><span class="idx-lbl">Total Tokens</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">23h 59m</span><span class="idx-lbl">Duration</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">106/123</span><span class="idx-lbl">Slices</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">23/25</span><span class="idx-lbl">Milestones</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">7</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:82%"></div></div>
|
||||
<span class="idx-pct">82% complete</span>
|
||||
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:86%"></div></div>
|
||||
<span class="idx-pct">86% 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,36.0 127.2,35.4 242.4,22.5 357.6,19.3 472.8,14.2 588.0,12.0" class="spark-line" fill="none"/>
|
||||
<circle cx="12.0" cy="36.0" r="3" class="spark-dot">
|
||||
<polyline points="12.0,36.7 108.0,36.2 204.0,24.1 300.0,21.1 396.0,16.3 492.0,14.2 588.0,12.0" class="spark-line" fill="none"/>
|
||||
<circle cx="12.0" cy="36.7" r="3" class="spark-dot">
|
||||
<title>M008: M008: Credibility Debt Cleanup — Broken Links, Test Data, Jargon, Empty Metrics — $172.23</title>
|
||||
</circle><circle cx="127.2" cy="35.4" r="3" class="spark-dot">
|
||||
</circle><circle cx="108.0" cy="36.2" r="3" class="spark-dot">
|
||||
<title>M009: Homepage & First Impression — $180.97</title>
|
||||
</circle><circle cx="242.4" cy="22.5" r="3" class="spark-dot">
|
||||
</circle><circle cx="204.0" cy="24.1" r="3" class="spark-dot">
|
||||
<title>M018: M018: Phase 2 Research & Documentation — Site Audit and Forgejo Wiki Bootstrap — $365.18</title>
|
||||
</circle><circle cx="357.6" cy="19.3" r="3" class="spark-dot">
|
||||
</circle><circle cx="300.0" cy="21.1" r="3" class="spark-dot">
|
||||
<title>M019: Foundations — Auth, Consent & LightRAG — $411.26</title>
|
||||
</circle><circle cx="472.8" cy="14.2" r="3" class="spark-dot">
|
||||
</circle><circle cx="396.0" cy="16.3" r="3" class="spark-dot">
|
||||
<title>M021: Intelligence Online — Chat, Chapters & Search Cutover — $485.08</title>
|
||||
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
|
||||
</circle><circle cx="492.0" cy="14.2" r="3" class="spark-dot">
|
||||
<title>M022: Creator Tools & Personality — $516.43</title>
|
||||
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
|
||||
<title>M023: MVP Integration — Demo Build — $550.85</title>
|
||||
</circle>
|
||||
<text x="12" y="58" class="spark-lbl">$172.23</text>
|
||||
<text x="588" y="58" text-anchor="end" class="spark-lbl">$516.43</text>
|
||||
<text x="588" y="58" text-anchor="end" class="spark-lbl">$550.85</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:21.2%" title="2026-03-31T05:52:28.456Z">M009</span><span class="spark-tick" style="left:40.4%" title="2026-04-03T21:17:51.201Z">M018</span><span class="spark-tick" style="left:59.6%" title="2026-04-03T23:30:16.641Z">M019</span><span class="spark-tick" style="left:78.8%" title="2026-04-04T06:50:37.759Z">M021</span><span class="spark-tick" style="left:98.0%" title="2026-04-04T08:51:51.223Z">M022</span>
|
||||
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:18.0%" title="2026-03-31T05:52:28.456Z">M009</span><span class="spark-tick" style="left:34.0%" title="2026-04-03T21:17:51.201Z">M018</span><span class="spark-tick" style="left:50.0%" title="2026-04-03T23:30:16.641Z">M019</span><span class="spark-tick" style="left:66.0%" title="2026-04-04T06:50:37.759Z">M021</span><span class="spark-tick" style="left:82.0%" title="2026-04-04T08:51:51.223Z">M022</span><span class="spark-tick" style="left:98.0%" title="2026-04-04T10:23:24.247Z">M023</span>
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="idx-cards">
|
||||
<h2>Progression <span class="sec-count">6</span></h2>
|
||||
<h2>Progression <span class="sec-count">7</span></h2>
|
||||
<div class="cards-grid">
|
||||
<a class="report-card" href="M008-2026-03-31T05-31-26.html">
|
||||
<div class="card-top">
|
||||
|
|
@ -317,7 +323,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<div class="card-delta"><span>+$73.82</span><span>+15 slices</span><span>+2 milestones</span></div>
|
||||
|
||||
</a>
|
||||
<a class="report-card card-latest" href="M022-2026-04-04T08-51-51.html">
|
||||
<a class="report-card" href="M022-2026-04-04T08-51-51.html">
|
||||
<div class="card-top">
|
||||
<span class="card-label">M022: Creator Tools & Personality</span>
|
||||
<span class="card-kind card-kind-milestone">milestone</span>
|
||||
|
|
@ -336,6 +342,27 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<span>101/123 slices</span>
|
||||
</div>
|
||||
<div class="card-delta"><span>+$31.35</span><span>+7 slices</span><span>+1 milestone</span></div>
|
||||
|
||||
</a>
|
||||
<a class="report-card card-latest" href="M023-2026-04-04T10-23-24.html">
|
||||
<div class="card-top">
|
||||
<span class="card-label">M023: MVP Integration — Demo Build</span>
|
||||
<span class="card-kind card-kind-milestone">milestone</span>
|
||||
</div>
|
||||
<div class="card-date">Apr 4, 2026, 10:23 AM</div>
|
||||
<div class="card-progress">
|
||||
<div class="card-bar-track">
|
||||
<div class="card-bar-fill" style="width:86%"></div>
|
||||
</div>
|
||||
<span class="card-pct">86%</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<span>$550.85</span>
|
||||
<span>777.32M</span>
|
||||
<span>23h 59m</span>
|
||||
<span>106/123 slices</span>
|
||||
</div>
|
||||
<div class="card-delta"><span>+$34.42</span><span>+5 slices</span><span>+1 milestone</span></div>
|
||||
<div class="card-latest-badge">Latest</div>
|
||||
</a></div>
|
||||
</section>
|
||||
|
|
@ -350,7 +377,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 4, 2026, 08:51 AM</span>
|
||||
<span>Updated Apr 4, 2026, 10:23 AM</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -99,6 +99,22 @@
|
|||
"doneMilestones": 22,
|
||||
"totalMilestones": 25,
|
||||
"phase": "planning"
|
||||
},
|
||||
{
|
||||
"filename": "M023-2026-04-04T10-23-24.html",
|
||||
"generatedAt": "2026-04-04T10:23:24.247Z",
|
||||
"milestoneId": "M023",
|
||||
"milestoneTitle": "MVP Integration — Demo Build",
|
||||
"label": "M023: MVP Integration — Demo Build",
|
||||
"kind": "milestone",
|
||||
"totalCost": 550.8491965000002,
|
||||
"totalTokens": 777321484,
|
||||
"totalDuration": 86381470,
|
||||
"doneSlices": 106,
|
||||
"totalSlices": 123,
|
||||
"doneMilestones": 23,
|
||||
"totalMilestones": 25,
|
||||
"phase": "planning"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
45
alembic/versions/026_add_share_token.py
Normal file
45
alembic/versions/026_add_share_token.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Add share_token column to generated_shorts for public sharing.
|
||||
|
||||
Revision ID: 026_add_share_token
|
||||
Revises: 025_add_generated_shorts
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "026_add_share_token"
|
||||
down_revision = "025_add_generated_shorts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add nullable column first
|
||||
op.add_column(
|
||||
"generated_shorts",
|
||||
sa.Column("share_token", sa.String(16), nullable=True),
|
||||
)
|
||||
|
||||
# Backfill existing complete shorts with unique tokens
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id FROM generated_shorts WHERE status = 'complete' AND share_token IS NULL")
|
||||
).fetchall()
|
||||
for (row_id,) in rows:
|
||||
token = secrets.token_urlsafe(8) # ~11 chars, fits in String(16)
|
||||
conn.execute(
|
||||
sa.text("UPDATE generated_shorts SET share_token = :token WHERE id = :id"),
|
||||
{"token": token, "id": row_id},
|
||||
)
|
||||
|
||||
# Create unique index
|
||||
op.create_index("ix_generated_shorts_share_token", "generated_shorts", ["share_token"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generated_shorts_share_token", table_name="generated_shorts")
|
||||
op.drop_column("generated_shorts", "share_token")
|
||||
|
|
@ -12,7 +12,7 @@ from fastapi import FastAPI
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import get_settings
|
||||
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, shorts, stats, techniques, topics, videos
|
||||
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, shorts, shorts_public, stats, techniques, topics, videos
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
|
|
@ -102,6 +102,7 @@ app.include_router(files.router, prefix="/api/v1")
|
|||
app.include_router(reports.router, prefix="/api/v1")
|
||||
app.include_router(search.router, prefix="/api/v1")
|
||||
app.include_router(shorts.router, prefix="/api/v1")
|
||||
app.include_router(shorts_public.router, prefix="/api/v1")
|
||||
app.include_router(stats.router, prefix="/api/v1")
|
||||
app.include_router(techniques.router, prefix="/api/v1")
|
||||
app.include_router(topics.router, prefix="/api/v1")
|
||||
|
|
|
|||
|
|
@ -857,6 +857,9 @@ class GeneratedShort(Base):
|
|||
server_default="pending",
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
share_token: Mapped[str | None] = mapped_column(
|
||||
String(16), nullable=True, unique=True, index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=_now, server_default=func.now()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
|
@ -2999,6 +3000,7 @@ def stage_generate_shorts(self, highlight_candidate_id: str) -> str:
|
|||
short.status = ShortStatus.complete
|
||||
short.file_size_bytes = file_size
|
||||
short.minio_object_key = minio_key
|
||||
short.share_token = secrets.token_urlsafe(8)
|
||||
session.commit()
|
||||
|
||||
elapsed_preset = time.monotonic() - preset_start
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class GeneratedShortResponse(BaseModel):
|
|||
duration_secs: float | None = None
|
||||
width: int
|
||||
height: int
|
||||
share_token: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
|
@ -158,6 +159,7 @@ async def list_shorts(
|
|||
duration_secs=s.duration_secs,
|
||||
width=s.width,
|
||||
height=s.height,
|
||||
share_token=s.share_token,
|
||||
created_at=s.created_at,
|
||||
)
|
||||
for s in shorts
|
||||
|
|
|
|||
95
backend/routers/shorts_public.py
Normal file
95
backend/routers/shorts_public.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Public (unauthenticated) endpoint for sharing generated shorts.
|
||||
|
||||
Resolves a share_token to video metadata and a presigned download URL.
|
||||
No auth dependency — anyone with the token can access the short.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from database import get_session
|
||||
from models import GeneratedShort, HighlightCandidate, ShortStatus, SourceVideo
|
||||
|
||||
logger = logging.getLogger("chrysopedia.shorts_public")
|
||||
|
||||
router = APIRouter(prefix="/public/shorts", tags=["shorts-public"])
|
||||
|
||||
|
||||
class PublicShortResponse(BaseModel):
|
||||
"""Public metadata for a shared short — no internal IDs exposed."""
|
||||
format_preset: str
|
||||
width: int
|
||||
height: int
|
||||
duration_secs: float | None
|
||||
creator_name: str
|
||||
highlight_title: str
|
||||
download_url: str
|
||||
|
||||
|
||||
@router.get("/{share_token}", response_model=PublicShortResponse)
|
||||
async def get_public_short(
|
||||
share_token: str,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Resolve a share token to short metadata and a presigned download URL."""
|
||||
stmt = (
|
||||
select(GeneratedShort)
|
||||
.where(GeneratedShort.share_token == share_token)
|
||||
.options(
|
||||
selectinload(GeneratedShort.highlight_candidate)
|
||||
.selectinload(HighlightCandidate.key_moment),
|
||||
selectinload(GeneratedShort.highlight_candidate)
|
||||
.selectinload(HighlightCandidate.source_video)
|
||||
.selectinload(SourceVideo.creator),
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
short = result.scalar_one_or_none()
|
||||
|
||||
if short is None:
|
||||
raise HTTPException(status_code=404, detail="Short not found")
|
||||
|
||||
if short.status != ShortStatus.complete:
|
||||
raise HTTPException(status_code=404, detail="Short not found")
|
||||
|
||||
if not short.minio_object_key:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Short is complete but has no storage key",
|
||||
)
|
||||
|
||||
highlight = short.highlight_candidate
|
||||
key_moment = highlight.key_moment
|
||||
source_video = highlight.source_video
|
||||
creator = source_video.creator
|
||||
|
||||
from minio_client import generate_download_url
|
||||
|
||||
try:
|
||||
url = generate_download_url(short.minio_object_key)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to generate download URL for share_token=%s: %s",
|
||||
share_token, exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Failed to generate download URL",
|
||||
) from exc
|
||||
|
||||
return PublicShortResponse(
|
||||
format_preset=short.format_preset.value,
|
||||
width=short.width,
|
||||
height=short.height,
|
||||
duration_secs=short.duration_secs,
|
||||
creator_name=creator.name,
|
||||
highlight_title=key_moment.title,
|
||||
download_url=url,
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue