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:
jlightner 2026-04-04 10:33:00 +00:00
parent 2c5d084c49
commit 5f4b960dc1
15 changed files with 16564 additions and 23 deletions

View file

@ -0,0 +1 @@
[]

View file

@ -1,6 +1,42 @@
# S01: [A] Shorts Publishing Flow # 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 **Demo:** After this: Creator approves a short → it renders → gets a shareable URL and embed code
## Tasks ## 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

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

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

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

View 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

File diff suppressed because one or more lines are too long

View file

@ -130,7 +130,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
</div> </div>
<div class="hdr-right"> <div class="hdr-right">
<span class="gen-lbl">Updated</span> <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>
</div> </div>
</header> </header>
@ -164,6 +164,10 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<div class="toc-group-label">M022</div> <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> <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>
<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> </aside>
<!-- Main content --> <!-- Main content -->
@ -172,45 +176,47 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<h2>Project Overview</h2> <h2>Project Overview</h2>
<div class="idx-summary"> <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">$550.85</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">777.32M</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">23h 59m</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">106/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">23/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">7</span><span class="idx-lbl">Reports</span></div>
</div> </div>
<div class="idx-progress"> <div class="idx-progress">
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:82%"></div></div> <div class="idx-bar-track"><div class="idx-bar-fill" style="width:86%"></div></div>
<span class="idx-pct">82% complete</span> <span class="idx-pct">86% complete</span>
</div> </div>
<div class="sparkline-wrap"><h3>Cost Progression</h3> <div class="sparkline-wrap"><h3>Cost Progression</h3>
<div class="sparkline"> <div class="sparkline">
<svg viewBox="0 0 600 60" width="600" height="60" class="spark-svg"> <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"/> <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.0" r="3" class="spark-dot"> <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> <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 &amp; First Impression — $180.97</title> <title>M009: Homepage &amp; 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 &amp; Documentation — Site Audit and Forgejo Wiki Bootstrap — $365.18</title> <title>M018: M018: Phase 2 Research &amp; 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 &amp; LightRAG — $411.26</title> <title>M019: Foundations — Auth, Consent &amp; 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 &amp; Search Cutover — $485.08</title> <title>M021: Intelligence Online — Chat, Chapters &amp; 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 &amp; Personality — $516.43</title> <title>M022: Creator Tools &amp; 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> </circle>
<text x="12" y="58" class="spark-lbl">$172.23</text> <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> </svg>
<div class="spark-axis"> <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></div> </div></div>
</section> </section>
<section class="idx-cards"> <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"> <div class="cards-grid">
<a class="report-card" href="M008-2026-03-31T05-31-26.html"> <a class="report-card" href="M008-2026-03-31T05-31-26.html">
<div class="card-top"> <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> <div class="card-delta"><span>+$73.82</span><span>+15 slices</span><span>+2 milestones</span></div>
</a> </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"> <div class="card-top">
<span class="card-label">M022: Creator Tools &amp; Personality</span> <span class="card-label">M022: Creator Tools &amp; Personality</span>
<span class="card-kind card-kind-milestone">milestone</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> <span>101/123 slices</span>
</div> </div>
<div class="card-delta"><span>+$31.35</span><span>+7 slices</span><span>+1 milestone</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> <div class="card-latest-badge">Latest</div>
</a></div> </a></div>
</section> </section>
@ -350,7 +377,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<span class="ftr-sep"></span> <span class="ftr-sep"></span>
<span>/home/aux/projects/content-to-kb-automator</span> <span>/home/aux/projects/content-to-kb-automator</span>
<span class="ftr-sep"></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> </div>
</footer> </footer>
</body> </body>

View file

@ -99,6 +99,22 @@
"doneMilestones": 22, "doneMilestones": 22,
"totalMilestones": 25, "totalMilestones": 25,
"phase": "planning" "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"
} }
] ]
} }

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

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import get_settings 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: 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(reports.router, prefix="/api/v1")
app.include_router(search.router, prefix="/api/v1") app.include_router(search.router, prefix="/api/v1")
app.include_router(shorts.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(stats.router, prefix="/api/v1")
app.include_router(techniques.router, prefix="/api/v1") app.include_router(techniques.router, prefix="/api/v1")
app.include_router(topics.router, prefix="/api/v1") app.include_router(topics.router, prefix="/api/v1")

View file

@ -857,6 +857,9 @@ class GeneratedShort(Base):
server_default="pending", server_default="pending",
) )
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) 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( created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now() default=_now, server_default=func.now()
) )

View file

@ -14,6 +14,7 @@ import json
import logging import logging
import os import os
import re import re
import secrets
import subprocess import subprocess
import time import time
from collections import defaultdict from collections import defaultdict
@ -2999,6 +3000,7 @@ def stage_generate_shorts(self, highlight_candidate_id: str) -> str:
short.status = ShortStatus.complete short.status = ShortStatus.complete
short.file_size_bytes = file_size short.file_size_bytes = file_size
short.minio_object_key = minio_key short.minio_object_key = minio_key
short.share_token = secrets.token_urlsafe(8)
session.commit() session.commit()
elapsed_preset = time.monotonic() - preset_start elapsed_preset = time.monotonic() - preset_start

View file

@ -41,6 +41,7 @@ class GeneratedShortResponse(BaseModel):
duration_secs: float | None = None duration_secs: float | None = None
width: int width: int
height: int height: int
share_token: str | None = None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@ -158,6 +159,7 @@ async def list_shorts(
duration_secs=s.duration_secs, duration_secs=s.duration_secs,
width=s.width, width=s.width,
height=s.height, height=s.height,
share_token=s.share_token,
created_at=s.created_at, created_at=s.created_at,
) )
for s in shorts for s in shorts

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