Skip to main content

max / makenotwork

Video support, storage refactor, landing page cleanup, v0.3.20 Migration 053: FileType::Video (MP4/WebM/MOV, 20GB max), ContentData::Video, HTML5 player in item page, wizard video group, 6 integration tests. Storage refactor: extract shared S3 client to Shared/s3-storage crate, simplify upload/download paths, content type detection improvements. Landing page: remove pricing prose and stats counter from hero section. Maintainability: dead code removal, fingerprint query consolidation, code size audit updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-07 21:10 UTC
Commit: 550b5f3339013fb5163e0f8108b3264533c147af
Parent: 8211fd9
41 files changed, +906 insertions, -360 deletions
M Cargo.lock +11 -3
@@ -3351,15 +3351,13 @@ dependencies = [
3351 3351
3352 3352 [[package]]
3353 3353 name = "makenotwork"
3354 - version = "0.3.18"
3354 + version = "0.3.19"
3355 3355 dependencies = [
3356 3356 "anyhow",
3357 3357 "argon2",
3358 3358 "askama",
3359 3359 "async-stripe",
3360 3360 "async-trait",
3361 - "aws-config",
3362 - "aws-sdk-s3",
3363 3361 "axum",
3364 3362 "axum-extra",
3365 3363 "base64 0.22.1",
@@ -3380,6 +3378,7 @@ dependencies = [
3380 3378 "rand 0.8.5",
3381 3379 "regex",
3382 3380 "reqwest",
3381 + "s3-storage",
3383 3382 "semver",
3384 3383 "serde",
3385 3384 "serde_json",
@@ -4770,6 +4769,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4770 4769 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
4771 4770
4772 4771 [[package]]
4772 + name = "s3-storage"
4773 + version = "0.1.0"
4774 + dependencies = [
4775 + "aws-config",
4776 + "aws-sdk-s3",
4777 + "tracing",
4778 + ]
4779 +
4780 + [[package]]
4773 4781 name = "same-file"
4774 4782 version = "1.0.6"
4775 4783 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +2 -3
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.19"
3 + version = "0.3.20"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -89,8 +89,7 @@ regex = "1"
89 89 semver = "1"
90 90
91 91 # S3 Storage
92 - aws-sdk-s3 = "1.119.0"
93 - aws-config = { version = "1.8.14", features = ["behavior-version-latest"] }
92 + s3-storage = { path = "../Shared/s3-storage" }
94 93
95 94 # Stripe Payments
96 95 async-stripe = { version = "0.37.3", features = ["runtime-tokio-hyper", "checkout", "connect", "billing"] }
@@ -32,13 +32,13 @@ MNW/src/
32 32 error.rs AppError enum → HTTP status + HTML/JSON response
33 33 auth.rs Argon2 hashing, session management, extractors (AuthUser, MaybeUser, AdminUser)
34 34 csrf.rs CSRF middleware (token generation + validation)
35 - validation.rs Input validation helpers
35 + validation/ Input validation helpers (directory module)
36 36 helpers.rs Shared utility functions, rate limiter constructors
37 - payments.rs Stripe Connect client wrapper
37 + payments/ Stripe Connect client wrapper (directory module)
38 38 storage.rs S3 storage backend (trait + implementation, presigned URLs)
39 39 synckit_auth.rs SyncKit JWT token creation + extraction
40 40 build_runner.rs SSH-based remote build execution
41 - git.rs Git source browser logic (git2, syntax highlighting)
41 + git/ Git source browser logic (git2, syntax highlighting, directory module)
42 42 monitor.rs Background health monitor (DB, S3, sessions)
43 43 scheduler.rs Background scheduler (scheduled publish, mailing list delivery)
44 44 mt_client.rs HTTP client for Multithreaded internal API
@@ -346,11 +346,11 @@ Two spawned Tokio tasks, coordinated via `watch::channel` for graceful shutdown:
346 346 | Template structs | `src/templates/` |
347 347 | Email service | `src/email/` |
348 348 | File scanning | `src/scanning/` |
349 - | Stripe integration | `src/payments.rs` |
349 + | Stripe integration | `src/payments/` |
350 350 | S3 storage | `src/storage.rs` |
351 351 | SyncKit auth | `src/synckit_auth.rs` |
352 352 | Build runner | `src/build_runner.rs` |
353 - | Git browser | `src/git.rs` |
353 + | Git browser | `src/git/` |
354 354 | MT client | `src/mt_client.rs` |
355 355 | Health monitor | `src/monitor.rs` |
356 356 | Scheduler | `src/scheduler.rs` |
@@ -1,11 +1,11 @@
1 1 # MakeNotWork -- Audit Review
2 2
3 - **Last audited:** 2026-03-28 (thirty-third audit, Run 12 cross-project)
4 - **Previous audit:** 2026-03-22 (thirty-second audit, Run 11 MNW-focused)
3 + **Last audited:** 2026-04-06 (thirty-fourth audit, Run 13 cross-project)
4 + **Previous audit:** 2026-03-28 (thirty-third audit, Run 12 cross-project)
5 5
6 6 ## Overall Grade: A
7 7
8 - Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin + 28 health). 0 clippy warnings. v0.3.18. Grade stable at A. Major additions since Run 11: email-first issue tracker (G6), I5 git patch inbound, bundles + batch upload, ProjectFeature trait. 2 integration test failures found (delete_item_returns_toast, item_wizard_license_keys).
8 + Run 13 cross-project audit. ~1,186 tests (632 unit + ~551 integration + 17 admin + 28 health). 0 clippy warnings. v0.3.19. Grade stable at A. Major additions since Run 12: video upload/playback (migration 053), content fingerprinting (051), bundled license text (052), maintainability splits (validation/, git/, payments/ directory modules), S3 storage extraction to shared crate (`Shared/s3-storage/`). Dead code/duplication audit: ~40 lines removed. Run 12 test failures (delete_item_returns_toast, item_wizard_license_keys) resolved.
9 9
10 10 ## Scorecard
11 11
@@ -13,7 +13,7 @@ Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin +
13 13 |-----------|:-----:|-------|
14 14 | Code Quality | A | thiserror errors; clean naming; `//!` on 153/153 files; proper `?` propagation; 0 clippy warnings |
15 15 | Architecture | A | Single crate with clean internal modules; `lib.rs` exports `build_app` for test reuse; `pub(crate)` hides internal DB modules; view model layer separates DB from templates; mailing list delivery cleanly layered (db → scheduler → email) |
16 - | Testing | A | 1,174 tests (584 unit + 545 integration + 17 admin + 28 health), 2 failures (see action items); per-test DB isolation; load test scaffolding; 61 adversarial tests; 17 wizard tests; 10 mailing list tests |
16 + | Testing | A | ~1,186 tests (632 unit + ~551 integration + 17 admin + 28 health); per-test DB isolation; load test scaffolding; 61 adversarial tests; 17 wizard tests; 10 mailing list tests; 6 video workflow tests |
17 17 | Security | A+ | Zero SQL injection (parameterized queries only); Argon2 hashing; CSRF synchronizer tokens with constant-time comparison; session fixation prevention; account lockout; HIBP breach check; multi-layer file scanning (ClamAV + YARA + hash + archive); ammonia HTML sanitization; HMAC-signed email links; rate limiting on all write/auth endpoints; SameSite=Strict cookies; body size limit; admin routes hidden; Debug impls redact secrets; passkeys/WebAuthn; TOTP 2FA on all auth paths; self-purchase prevention; trust tiers for uploads. Security deep dive (2026-03-13): session cache fail-closed, scanner errors fail-closed (HeldForReview), scan status allowlist, JWT issuer validation. |
18 18 | Performance | A | Parameterized queries with safety LIMITs; presigned S3 URLs (no proxy); `FOR UPDATE` on reorder; admin user list paginated (LIMIT/OFFSET + count query + HTMX prev/next); no N+1 queries; max_connections=10 default |
19 19 | Documentation | A | Module-level `//!` on 100% of source files; public functions have `///` doc comments; API response conventions documented in `routes/api/mod.rs`; edge cases and state invariants documented |
@@ -24,8 +24,8 @@ Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin +
24 24 | Concurrency | A+ | Atomic DB operations for race-prone paths (Stripe connect, broadcast rate limit, release announcements); transaction + `FOR UPDATE` for reorder; graceful shutdown via watch channel; all purchase flows transactional |
25 25 | Resilience | A | Graceful shutdown (SIGINT/SIGTERM); optional services degrade gracefully (S3, Stripe, scanning, git browser); timeouts on external HTTP calls (email: 10s, HIBP: 3s, ClamAV: 30s, MalwareBazaar: 5s); 3s DB pool acquire timeout |
26 26 | API Consistency | A | Documented response conventions; JSON error layer on API routes; `ListResponse<T>` envelope on all list endpoints; HTMX-aware dual responses documented as intentional; rate limit tiers clearly separated |
27 - | Migration Safety | A | 48 additive migrations (001-048), auto-applied on boot; all schema changes additive (new tables, columns with defaults, indexes); no destructive migrations |
28 - | Codebase Size | A | ~29K source LOC for a full creator platform (~30+ features) is efficient; no files exceed the 500-line branching guideline. Largest files: tokens.rs (500), notifications.rs (311). DocEngine extracted to standalone crate (reduced from ~50K). |
27 + | Migration Safety | A | 53 additive migrations (001-053), auto-applied on boot; all schema changes additive (new tables, columns with defaults, indexes); no destructive migrations |
28 + | Codebase Size | A | ~29K source LOC for a full creator platform (~30+ features) is efficient; no files exceed the 500-line branching guideline. Largest files: tokens.rs (500), notifications.rs (311). DocEngine extracted to standalone crate (reduced from ~50K). Maintainability splits: validation/, git/, payments/ converted to directory modules. |
29 29 | Infrastructure | -- | Not yet audited. Checklist below. |
30 30
31 31 ## Module Heatmap
@@ -42,15 +42,15 @@ Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin +
42 42 | monitor.rs | A | A | n/a | A | A | A | A | n/a |
43 43 | scheduler.rs | A | A | n/a | A | A | A | A | n/a |
44 44 | constants.rs | A+ | A | n/a | A | n/a | A | n/a | n/a |
45 - | validation.rs | A | A | A | A | n/a | A | n/a | n/a |
45 + | validation/ | A | A | A | A | n/a | A | n/a | n/a |
46 46 | email/ | A | A | A | A | A | A | n/a | n/a |
47 47 | storage.rs | A | A | A | A | A | A | n/a | n/a |
48 - | payments.rs | A | A | A | A | n/a | A | n/a | n/a |
48 + | payments/ | A | A | A | A | n/a | A | n/a | n/a |
49 49 | helpers.rs | A | A | A | A | n/a | A | n/a | n/a |
50 50 | rss.rs | A | A | A | A | n/a | A | n/a | n/a |
51 51 | markdown.rs | A | A | A | A+ | n/a | A | n/a | n/a |
52 52 | docs.rs | A | A | A | A | n/a | A | n/a | n/a |
53 - | git.rs | A- | A | A | A | A | A | A | n/a |
53 + | git/ | A- | A | A | A | A | A | A | n/a |
54 54 | wordlist.rs | A | n/a | n/a | n/a | n/a | A | n/a | n/a |
55 55 | types/ | A | A | A- | A | n/a | A | n/a | n/a |
56 56 | templates/ | A | A | n/a | A | n/a | A | A | n/a |
@@ -78,11 +78,11 @@ Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin +
78 78 | db/other (9 files) | A | A | n/a | A | A | A | n/a | n/a |
79 79 | routes/auth.rs | A | A | n/a | A+ | A | A | A | n/a |
80 80 | routes/admin.rs | A | A | n/a | A | A | A | A | n/a |
81 - | routes/storage.rs | A | A | n/a | A | A | A | A | n/a |
81 + | routes/storage/ | A | A | n/a | A | A | A | A | n/a |
82 82 | routes/oauth.rs | A | A | n/a | A | A | A | A | n/a |
83 - | routes/synckit.rs | A | A | n/a | A | A | A | A | n/a |
84 - | routes/git.rs | A | A | n/a | A | A | A | A | n/a |
85 - | routes/postmark.rs | A | A | n/a | A | A | A | A | n/a |
83 + | routes/synckit/ | A | A | n/a | A | A | A | A | n/a |
84 + | routes/git/ | A | A | n/a | A | A | A | A | n/a |
85 + | routes/postmark/ | A | A | n/a | A | A | A | A | n/a |
86 86 | routes/stripe/checkout.rs | A | A | n/a | A | A | A | A | A |
87 87 | routes/stripe/webhook.rs | A | A | n/a | A | A | A | A | A |
88 88 | routes/stripe/connect.rs | A | A | n/a | A | A | A | A | A |
@@ -106,7 +106,7 @@ Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin +
106 106
107 107 ### Cold Spots
108 108
109 - None -- all module-level cold spots resolved. email/ split into 6 files (mod.rs 216, tokens.rs 500, templates/auth.rs 180, templates/monetization.rs 187, templates/notifications.rs 311, templates/onboarding.rs 94). No file exceeds 500 lines.
109 + None -- all module-level cold spots resolved. email/ split into 6 files. validation/, git/, payments/ split into directory modules (Run 13 maintainability splits). No file exceeds 500 lines.
110 110
111 111 Dependencies A- (upstream-blocked transitive vulnerabilities) is the only project-level dimension below A.
112 112
@@ -193,7 +193,7 @@ cargo --version
193 193 Every attack surface covered. Argon2 with 128-char max, CSRF synchronizer tokens with constant-time comparison, session fixation prevention, account lockout, rate limiting on all sensitive endpoints, HMAC-signed URLs, Stripe webhook verification, login tokens hashed with SHA-256, OAuth PKCE with S256, passkeys/WebAuthn, TOTP 2FA enforced on all auth paths (login link, OAuth), account deletion via POST with confirmation, self-purchase prevention, 6-layer malware scanning pipeline, trust tiers for new uploads. No SQL injection vectors -- zero `format!()` in any `sqlx::query` call confirmed by grep.
194 194
195 195 ### 2. Comprehensive test suite
196 - 821 tests across ~60 test files. Per-test database isolation. In-process load test harness. 53 adversarial exploit-attempt tests. 14.4 tests/KLOC. Test:source ratio 0.40.
196 + ~1,186 tests across ~67 test files. Per-test database isolation. In-process load test harness. 61 adversarial exploit-attempt tests. ~40 tests/KLOC. Test:source ratio ~0.40.
197 197
198 198 ### 3. Zero N+1 queries
199 199 Systematic prevention: batch queries with ANY($1), LEFT JOINs with aggregation, pre-computed denormalized fields, single round-trip health checks. No N+1 patterns found.
@@ -293,11 +293,14 @@ Filed in `docs/mnw/todo.md`.
293 293 24. Monitor async-stripe for instant fix (RUSTSEC-2024-0384)
294 294 25. Monitor aws-sdk-s3 for rustls-webpki 0.101.7 fix (RUSTSEC-2026-0049)
295 295
296 - ### New (Run 12)
297 - 31. **[MEDIUM]** Fix `delete_item_returns_toast` test failure — HX-Trigger header missing on item delete response
298 - 32. **[MEDIUM]** Fix `item_wizard_license_keys` test failure — RowNotFound after license key wizard step
296 + ### New (Run 12) — All resolved
297 + 31. ~~**[MEDIUM]** Fix `delete_item_returns_toast` test failure~~ -- done
298 + 32. ~~**[MEDIUM]** Fix `item_wizard_license_keys` test failure~~ -- done
299 299 33. **[LOW]** bincode unmaintained (RUSTSEC-2025-0141) — upstream via syntect/yara-x, warning only
300 300
301 + ### Run 13 (2026-04-06) — Maintenance
302 + No new action items. Dead code/duplication audit: ~40 lines removed across MNW+GO. Maintainability splits completed (validation/, git/, payments/). S3 storage extraction to `Shared/s3-storage/` shared crate (MNW + MT).
303 +
301 304 ### New (twenty-fifth audit)
302 305 25. ~~Split email.rs into submodules~~ -- Done (email/mod.rs + email/tokens.rs, ~800 + ~500 LOC)
303 306 26. ~~Upgrade DMARC policy from p=none to p=quarantine~~ -- Done
@@ -335,10 +338,10 @@ Key changes:
335 338 - **HIGH fix:** Suspended users could manage promo codes and license keys — added `check_not_suspended()` to all handlers
336 339 - **HIGH fix:** Session `suspended` flag stale after admin suspension — `touch_session` now returns `TouchResult` with live `suspended` value from DB, session updated on mismatch
337 340 - **HIGH fix:** Webhook signature had no timestamp freshness check — added 300s tolerance, rejects stale and future timestamps
338 - - **6 new webhook timestamp unit tests** in payments.rs
341 + - **6 new webhook timestamp unit tests** in payments/
339 342 - **10 new integration tests** in suspension.rs (checkout, promo codes, license keys, session staleness, webhook timestamps)
340 343
341 - Total: 3 open items (3 upstream-blocked deps)
344 + Total: 4 open items (3 upstream-blocked deps + 1 low-severity warning)
342 345
343 346 ## Previous Action Item Verification
344 347
@@ -19,7 +19,7 @@
19 19 | `src/wordlist.rs` | 2,056 | Static 2048-word array |
20 20 | `src/db/models.rs` | 2,045 | FromRow structs + simple accessors |
21 21 | `src/db/enums.rs` | 1,217 | 35 `impl_str_enum!` macro enums |
22 - | `src/validation.rs` | 1,177 | 60+ linear validation functions |
22 + | `src/validation/` | 1,177 | 60+ linear validation functions (split into directory module) |
23 23 | `src/templates/public.rs` | 952 | Askama HTML markup |
24 24 | `src/types/mod.rs` | 871 | Type definitions / newtypes |
25 25
@@ -28,10 +28,10 @@
28 28 | File | Lines | Domains | Split recommendation |
29 29 |------|-------|---------|---------------------|
30 30 | `src/routes/api/internal.rs` | 1,634 | 10 (SSH, items, blog, promo, licenses, analytics, git auth...) | `internal/` dir with 5-6 submodules |
31 - | `src/git.rs` | 1,176 | 4 (refs, commit graph, blame, annotation) | `git/{refs,graph,blame}.rs` |
32 - | `src/payments.rs` | 1,173 | 5 (PWYW, discounts, licenses, subs, transactions) | `payments/{checkout,subscriptions,discounts}.rs` |
33 - | `src/routes/storage.rs` | 920 | 4 (presign, confirm+scan, download, health) | `routes/storage/` dir |
34 - | `src/routes/postmark.rs` | 887 | 4 (bounces, complaints, tracking, delivery) | `routes/email_webhooks/` dir |
31 + | ~~`src/git.rs`~~ | 1,176 | Split into `src/git/` (refs.rs, objects.rs, history.rs) |
32 + | ~~`src/payments.rs`~~ | 1,173 | Split into `src/payments/` (checkout.rs, webhooks.rs, connect.rs) |
33 + | ~~`src/routes/storage.rs`~~ | 920 | Split into `src/routes/storage/` dir |
34 + | ~~`src/routes/postmark.rs`~~ | 887 | Split into `src/routes/postmark/` dir |
35 35 | `src/routes/pages/dashboard/wizards/item.rs` | 791 | 6 wizard steps | `wizards/item/` dir |
36 36 | `src/helpers.rs` | 775 | 8 (slugs, CSV, URLs, dates, crypto, email, cache, forms) | `helpers/` dir with 4 submodules |
37 37 | `src/routes/stripe/checkout.rs` | 768 | 4 (forms, promo validation, session, payment intent) | Extract `checkout/promo.rs` |
@@ -279,11 +279,11 @@ routes/
279 279 feeds.rs RSS/Atom feeds
280 280 blog.rs Blog pages
281 281 auth.rs Login, signup, logout, password reset
282 - git.rs Source browser
283 - git_issues.rs Issue tracker
282 + git/ Source browser (directory module)
283 + git_issues/ Issue tracker (directory module)
284 284 oauth.rs OAuth provider (for Multithreaded)
285 - postmark.rs Inbound email webhook
286 - storage.rs File upload/download (presigned URLs)
285 + postmark/ Inbound email webhook (directory module)
286 + storage/ File upload/download (presigned URLs, directory module)
287 287 stripe/ Stripe webhooks and Connect callbacks
288 288 synckit.rs SyncKit cloud sync + OTA API
289 289 ```
M docs/todo.md +27 -8
@@ -1,9 +1,9 @@
1 1 # Makenotwork TODO
2 2
3 3 ## Status
4 - Done: All pre-beta phases + frontend audit + content fingerprinting. Active: None. Next: Post-beta features below.
4 + Done: All pre-beta phases + frontend audit + content fingerprinting + bundled license text + video upload/playback + maintainability splits + S3 storage extraction (shared crate). Active: None. Next: Deploy v0.3.20, then post-beta features below.
5 5
6 - Live at makenot.work. v0.3.18. Audit grade A. Stripe + Postmark live. All platform integrations (I1-I5) deployed.
6 + Live at makenot.work. v0.3.19. Audit grade A. Stripe + Postmark live. All platform integrations (I1-I5) deployed.
7 7
8 8 **Scope:** Sections tagged `(pre-beta)` ship before initial beta. Untagged sections are post-beta.
9 9
@@ -247,8 +247,27 @@ Competitive context: Gumroad has per-product affiliates with configurable rates.
247 247 ### Phase 13D: Importers
248 248 Shared infra first: `POST /api/users/me/import` multipart upload, `ImportJob` table, background `tokio::spawn`, progress polling. Subscriber pipeline: dedup by email, re-confirmation, rate-limit 100/hr. Content helpers: HTML-to-markdown, image re-upload to S3, tag normalization, Stripe customer ID mapping. Build order: (1) Generic CSV with column mapping UI + presets for Ko-fi/Itch.io/Sellfy, (2) Substack ZIP, (3) Ghost JSON, (4) Gumroad API/CSV, (5) Bandcamp sales CSV, (6) Lemon Squeezy REST API, (7) Patreon OAuth API (hardest).
249 249
250 - ### Phase 14: Video
251 - - [ ] Video upload to S3, HTML5 player, streaming via presigned URLs, thumbnails
250 + ### Phase 14: Video — Done
251 + Migration 053. 6 integration tests.
252 + - [x] `video_s3_key`, `video_file_size_bytes`, `video_duration_seconds`, `video_width`, `video_height` columns on items
253 + - [x] `FileType::Video` (MP4, WebM, MOV, 20 GB max)
254 + - [x] `ContentData::Video` + `ItemContent::Video` view type
255 + - [x] Upload via presign/confirm flow
256 + - [x] HTML5 `<video>` player on item page (lazy stream URL fetch)
257 + - [x] Stream URL endpoint supports video (reuses audio access control + fingerprinting)
258 + - [x] Wizard "video" group with dedicated upload step + JS handler
259 + - [x] Storage tracking includes `video_file_size_bytes` (dashboard breakdown, delete cleanup)
260 + - [x] Scanning: content-type verification for video
261 + - [x] Data export includes video fields
262 +
263 + ### Phase 14E: Video Streaming Tier Features (post-beta)
264 + Streaming tier ($40/mo) premium features. Trigger: >100 creators with video content.
265 + - [ ] Server-side transcoding (ffmpeg): upload any format, serve optimized MP4/WebM
266 + - [ ] Adaptive bitrate streaming (HLS/DASH) — multiple quality levels per video
267 + - [ ] Background lossless re-encoding: re-mux uploaded videos to more efficient containers, optimize keyframes, strip unused metadata. No quality loss, just smaller files
268 + - [ ] Bandwidth metering + per-tier bandwidth caps
269 + - [ ] Auto-generated thumbnails (ffmpeg frame extraction at configurable timestamp)
270 + - [ ] Video duration auto-detection on upload (ffprobe)
252 271
253 272 ### Phase 14B: Embeddable Widgets
254 273 - [ ] Embed endpoint /embed/i/{uuid} — embeddable buy button, audio player, 30-sec preview
@@ -366,7 +385,7 @@ Share search infrastructure between MNW and MT instead of building independently
366 385
367 386 ### Image Upload Pipeline
368 387 Consolidate S3 upload infrastructure shared between MNW and MT.
369 - - [ ] Extract shared presigned-upload flow into reusable module (MNW `storage.rs` + MT `storage.rs`)
388 + - [x] Extract shared S3 client into `Shared/s3-storage/` crate (2026-04-06). MNW + MT both delegate to shared client. Removed direct `aws-sdk-s3`/`aws-config` deps from both.
370 389 - [ ] Shared image processing: thumbnail generation, format validation, size limits
371 390 - [ ] Consistent upload UX patterns across MNW dashboard and MT post composer
372 391
@@ -420,12 +439,12 @@ Archive policy: items on platform 12+ months stay hosted if creator cancels.
420 439 ```
421 440 MNW/src/
422 441 lib.rs, main.rs, config.rs, error.rs, auth.rs, db/
423 - storage.rs, payments.rs, templates/, routes/
424 - git.rs, git_issues.rs, synckit_auth.rs, build_runner.rs, validation.rs
442 + storage.rs, payments/, templates/, routes/
443 + git/, git_issues/, synckit_auth.rs, build_runner.rs, validation/
425 444 fingerprint/ (registry, visible stamps, watermarks, streaming)
426 445 MNW/tests/
427 446 integration.rs, harness/, workflows/*.rs
428 - MNW/migrations/ (001-051)
447 + MNW/migrations/ (001-053)
429 448 MNW/templates/
430 449 MNW/deploy/
431 450 MNW/site-docs/public/, MNW/site-docs/unpublished/
@@ -0,0 +1,9 @@
1 + -- Video item support: direct-file video upload and playback.
2 + -- Video items store their file in S3 (video_s3_key) and reuse the existing
3 + -- cover_s3_key/cover_image_url fields for the poster image.
4 +
5 + ALTER TABLE items ADD COLUMN video_s3_key TEXT;
6 + ALTER TABLE items ADD COLUMN video_file_size_bytes BIGINT;
7 + ALTER TABLE items ADD COLUMN video_duration_seconds INTEGER;
8 + ALTER TABLE items ADD COLUMN video_width INTEGER;
9 + ALTER TABLE items ADD COLUMN video_height INTEGER;
@@ -303,11 +303,18 @@ pub async fn recalculate_storage_used(pool: &PgPool, user_id: UserId) -> Result<
303 303 FROM items i
304 304 JOIN projects p ON i.project_id = p.id
305 305 WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
306 + ),
307 + video_bytes AS (
308 + SELECT COALESCE(SUM(i.video_file_size_bytes)::BIGINT, 0) AS total
309 + FROM items i
310 + JOIN projects p ON i.project_id = p.id
311 + WHERE p.user_id = $1 AND i.video_file_size_bytes IS NOT NULL
306 312 )
307 313 SELECT ((SELECT total FROM version_bytes)
308 314 + (SELECT total FROM insertion_bytes)
309 315 + (SELECT total FROM audio_bytes)
310 - + (SELECT total FROM cover_bytes))::BIGINT AS total
316 + + (SELECT total FROM cover_bytes)
317 + + (SELECT total FROM video_bytes))::BIGINT AS total
311 318 "#,
312 319 )
313 320 .bind(user_id)
@@ -369,12 +376,24 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto
369 376 .fetch_one(pool)
370 377 .await?;
371 378
379 + let video: i64 = sqlx::query_scalar(
380 + r#"
381 + SELECT COALESCE(SUM(i.video_file_size_bytes)::BIGINT, 0)
382 + FROM items i JOIN projects p ON i.project_id = p.id
383 + WHERE p.user_id = $1 AND i.video_file_size_bytes IS NOT NULL
384 + "#,
385 + )
386 + .bind(user_id)
387 + .fetch_one(pool)
388 + .await?;
389 +
372 390 Ok(StorageBreakdown {
373 391 audio_bytes: audio,
374 392 cover_bytes: cover,
375 393 download_bytes: download,
376 394 insertion_bytes: insertion,
377 - total_bytes: audio + cover + download + insertion,
395 + video_bytes: video,
396 + total_bytes: audio + cover + download + insertion + video,
378 397 })
379 398 }
380 399
@@ -23,7 +23,6 @@ pub async fn insert_email_signup(pool: &PgPool, email: &str, source: &str) -> Re
23 23
24 24 /// Row returned by the admin email signups query.
25 25 pub struct DbEmailSignup {
26 - pub id: Uuid,
27 26 pub email: String,
28 27 pub source: String,
29 28 pub created_at: chrono::DateTime<chrono::Utc>,
@@ -34,7 +33,7 @@ pub async fn get_all_email_signups(pool: &PgPool) -> Result<Vec<DbEmailSignup>>
34 33 let rows = sqlx::query_as!(
35 34 DbEmailSignup,
36 35 r#"
37 - SELECT id, email, source,
36 + SELECT email, source,
38 37 created_at as "created_at: chrono::DateTime<chrono::Utc>"
39 38 FROM email_signups
40 39 ORDER BY created_at DESC
M src/db/enums.rs +15 -7
@@ -340,12 +340,14 @@ impl ItemType {
340 340 /// Determines what the content step looks like:
341 341 /// - `"text"` → Markdown editor
342 342 /// - `"audio"` → Audio file upload
343 + /// - `"video"` → Video file upload
343 344 /// - `"bundle"` → Item picker for bundle contents
344 345 /// - `"file"` → Generic file upload
345 346 pub fn wizard_group(&self) -> &'static str {
346 347 match self {
347 348 Self::Text => "text",
348 349 Self::Audio => "audio",
350 + Self::Video => "video",
349 351 Self::Bundle => "bundle",
350 352 _ => "file",
351 353 }
@@ -642,6 +644,7 @@ impl ProjectFeature {
642 644 let (label, desc) = match group {
643 645 "text" => ("Text", "Write in the editor"),
644 646 "audio" => ("Audio", "Upload audio files"),
647 + "video" => ("Video", "Upload video files"),
645 648 "bundle" => ("Bundle", "Collection of other items"),
646 649 _ => ("File", "Upload any file"),
647 650 };
@@ -1148,10 +1151,14 @@ mod tests {
1148 1151 }
1149 1152
1150 1153 #[test]
1154 + fn wizard_group_video() {
1155 + assert_eq!(ItemType::Video.wizard_group(), "video");
1156 + }
1157 +
1158 + #[test]
1151 1159 fn wizard_group_file_types() {
1152 1160 for t in [
1153 1161 ItemType::Digital,
1154 - ItemType::Video,
1155 1162 ItemType::Course,
1156 1163 ItemType::Plugin,
1157 1164 ItemType::Sample,
@@ -1181,12 +1188,13 @@ mod tests {
1181 1188 }
1182 1189
1183 1190 #[test]
1184 - fn wizard_cards_downloads_two_groups() {
1185 - // All download types are in the "file" group + bundle → 2 cards
1191 + fn wizard_cards_downloads_three_groups() {
1192 + // Download types split into "file" + "video" groups + bundle → 3 cards
1186 1193 let cards = ProjectFeature::wizard_type_cards(&["downloads".into()]);
1187 - assert_eq!(cards.len(), 2);
1194 + assert_eq!(cards.len(), 3);
1188 1195 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1189 1196 assert!(groups.contains(&"digital")); // first type in file group
1197 + assert!(groups.contains(&"video")); // video group
1190 1198 assert!(groups.contains(&"bundle"));
1191 1199 }
1192 1200
@@ -1209,9 +1217,9 @@ mod tests {
1209 1217 }
1210 1218
1211 1219 #[test]
1212 - fn wizard_cards_empty_features_all_four_groups() {
1213 - // No content features → all types → 4 wizard groups (text, audio, file, bundle)
1220 + fn wizard_cards_empty_features_all_five_groups() {
1221 + // No content features → all types → 5 wizard groups (text, audio, video, file, bundle)
1214 1222 let cards = ProjectFeature::wizard_type_cards(&[]);
1215 - assert_eq!(cards.len(), 4);
1223 + assert_eq!(cards.len(), 5);
1216 1224 }
1217 1225 }
@@ -76,29 +76,6 @@ pub async fn get_by_fingerprint_id(
76 76 .await
77 77 }
78 78
79 - /// Look up fingerprints for a given user and content.
80 - #[tracing::instrument(skip_all)]
81 - pub async fn get_by_user_and_content(
82 - pool: &PgPool,
83 - user_id: UserId,
84 - content_type: &str,
85 - content_id: &str,
86 - ) -> Result<Vec<DbDownloadFingerprint>, sqlx::Error> {
87 - sqlx::query_as(
88 - "SELECT id, user_id, content_type, content_id, fingerprint_id,
89 - watermark_method, ip_address::TEXT, user_agent, created_at
90 - FROM download_fingerprints
91 - WHERE user_id = $1 AND content_type = $2 AND content_id = $3
92 - ORDER BY created_at DESC
93 - LIMIT 100",
94 - )
95 - .bind(user_id)
96 - .bind(content_type)
97 - .bind(content_id)
98 - .fetch_all(pool)
99 - .await
100 - }
101 -
102 79 // ── Streaming sessions ──
103 80
104 81 /// Create a new streaming session. Returns the session token.
M src/db/items.rs +74 -8
@@ -480,16 +480,16 @@ pub async fn set_mt_thread_id(
480 480 Ok(())
481 481 }
482 482
483 - /// Collect all S3 keys for items owned by a user (audio + cover).
483 + /// Collect all S3 keys for items owned by a user (audio + cover + video).
484 484 ///
485 - /// Returns item title, project slug, and optional S3 keys for audio/cover.
485 + /// Returns item title, project slug, and optional S3 keys for audio/cover/video.
486 486 /// Only includes items that have at least one S3 key.
487 487 pub async fn get_user_s3_keys(pool: &PgPool, user_id: UserId) -> Result<Vec<ItemS3KeyRow>> {
488 488 let rows = sqlx::query_as::<_, ItemS3KeyRow>(
489 489 r#"
490 - SELECT i.title, p.slug AS project_slug, i.audio_s3_key, i.cover_s3_key
490 + SELECT i.title, p.slug AS project_slug, i.audio_s3_key, i.cover_s3_key, i.video_s3_key
491 491 FROM items i JOIN projects p ON i.project_id = p.id
492 - WHERE p.user_id = $1 AND (i.audio_s3_key IS NOT NULL OR i.cover_s3_key IS NOT NULL)
492 + WHERE p.user_id = $1 AND (i.audio_s3_key IS NOT NULL OR i.cover_s3_key IS NOT NULL OR i.video_s3_key IS NOT NULL)
493 493 ORDER BY p.slug, i.sort_order
494 494 LIMIT 500
495 495 "#,
@@ -748,26 +748,28 @@ pub async fn duplicate_item(pool: &PgPool, source_id: ItemId) -> Result<DbItem>
748 748 Ok(new_item)
749 749 }
750 750
751 - /// Get the audio and cover file sizes for an item (for storage decrement on delete).
751 + /// Get the audio, cover, and video file sizes for an item (for storage decrement on delete).
752 752 pub async fn get_item_file_sizes(
753 753 pool: &PgPool,
754 754 id: ItemId,
755 755 ) -> Result<super::models::ItemFileSizes> {
756 - let row = sqlx::query_as::<_, (Option<i64>, Option<i64>)>(
757 - "SELECT audio_file_size_bytes, cover_file_size_bytes FROM items WHERE id = $1",
756 + let row = sqlx::query_as::<_, (Option<i64>, Option<i64>, Option<i64>)>(
757 + "SELECT audio_file_size_bytes, cover_file_size_bytes, video_file_size_bytes FROM items WHERE id = $1",
758 758 )
759 759 .bind(id)
760 760 .fetch_optional(pool)
761 761 .await?;
762 762
763 763 match row {
764 - Some((audio, cover)) => Ok(super::models::ItemFileSizes {
764 + Some((audio, cover, video)) => Ok(super::models::ItemFileSizes {
765 765 audio_file_size_bytes: audio,
766 766 cover_file_size_bytes: cover,
767 + video_file_size_bytes: video,
767 768 }),
768 769 None => Ok(super::models::ItemFileSizes {
769 770 audio_file_size_bytes: None,
770 771 cover_file_size_bytes: None,
772 + video_file_size_bytes: None,
771 773 }),
772 774 }
773 775 }
@@ -823,6 +825,70 @@ pub async fn update_item_cover_file_size(
823 825 Ok(())
824 826 }
825 827
828 + /// Update the video S3 key for an item.
829 + pub async fn update_item_video_s3_key(
830 + pool: &PgPool,
831 + item_id: ItemId,
832 + s3_key: &str,
833 + ) -> Result<DbItem> {
834 + let item = sqlx::query_as::<_, DbItem>(
835 + r#"
836 + UPDATE items
837 + SET video_s3_key = $2, updated_at = NOW()
838 + WHERE id = $1
839 + RETURNING *
840 + "#,
841 + )
842 + .bind(item_id)
843 + .bind(s3_key)
844 + .fetch_one(pool)
845 + .await?;
846 +
847 + Ok(item)
848 + }
849 +
850 + /// Update the video file size on an item.
851 + pub async fn update_item_video_file_size(
852 + pool: &PgPool,
853 + item_id: ItemId,
854 + bytes: i64,
855 + ) -> Result<()> {
856 + sqlx::query(
857 + "UPDATE items SET video_file_size_bytes = $2 WHERE id = $1",
858 + )
859 + .bind(item_id)
860 + .bind(bytes)
861 + .execute(pool)
862 + .await?;
863 +
864 + Ok(())
865 + }
866 +
867 + /// Update video metadata (duration, resolution) on an item.
868 + pub async fn update_item_video_metadata(
869 + pool: &PgPool,
870 + item_id: ItemId,
871 + duration_seconds: Option<i32>,
872 + width: Option<i32>,
873 + height: Option<i32>,
874 + ) -> Result<()> {
875 + sqlx::query(
876 + r#"
877 + UPDATE items
878 + SET video_duration_seconds = $2, video_width = $3, video_height = $4, updated_at = NOW()
879 + WHERE id = $1
880 + "#,
881 + )
882 + .bind(item_id)
883 + .bind(duration_seconds)
884 + .bind(width)
885 + .bind(height)
886 + .execute(pool)
887 + .await?;
888 +
889 + Ok(())
890 + }
891 +
826 892 /// Fetch a public item by project ID and slug (for custom domain routing).
827 893 pub async fn get_item_by_project_and_slug(
828 894 pool: &PgPool,
M src/db/models.rs +65 -3
@@ -319,6 +319,17 @@ pub struct DbItem {
319 319 pub license_preset: Option<String>,
320 320 /// Custom license text, used when license_preset = "custom".
321 321 pub custom_license_text: Option<String>,
322 + // Video content fields
323 + /// S3 object key for the video file.
324 + pub video_s3_key: Option<String>,
325 + /// Size of the video file in bytes.
326 + pub video_file_size_bytes: Option<i64>,
327 + /// Video duration in seconds.
328 + pub video_duration_seconds: Option<i32>,
329 + /// Video width in pixels.
330 + pub video_width: Option<i32>,
331 + /// Video height in pixels.
332 + pub video_height: Option<i32>,
322 333 }
323 334
324 335 /// Content-type-specific data extracted from a `DbItem`.
@@ -342,6 +353,14 @@ pub enum ContentData {
342 353 duration_seconds: Option<i32>,
343 354 episode_number: Option<i32>,
344 355 },
356 + Video {
357 + video_s3_key: Option<String>,
358 + cover_s3_key: Option<String>,
359 + cover_image_url: Option<String>,
360 + duration_seconds: Option<i32>,
361 + width: Option<i32>,
362 + height: Option<i32>,
363 + },
345 364 Other,
346 365 }
347 366
@@ -362,13 +381,21 @@ impl DbItem {
362 381 duration_seconds: self.duration_seconds,
363 382 episode_number: self.episode_number,
364 383 },
384 + super::ItemType::Video => ContentData::Video {
385 + video_s3_key: self.video_s3_key.clone(),
386 + cover_s3_key: self.cover_s3_key.clone(),
387 + cover_image_url: self.cover_image_url.clone(),
388 + duration_seconds: self.video_duration_seconds,
389 + width: self.video_width,
390 + height: self.video_height,
391 + },
365 392 _ => ContentData::Other,
366 393 }
367 394 }
368 395
369 - /// Whether this item has any S3-hosted content (audio or cover image).
396 + /// Whether this item has any S3-hosted content (audio, video, or cover image).
370 397 pub fn has_s3_content(&self) -> bool {
371 - self.audio_s3_key.is_some() || self.cover_s3_key.is_some()
398 + self.audio_s3_key.is_some() || self.cover_s3_key.is_some() || self.video_s3_key.is_some()
372 399 }
373 400 }
374 401
@@ -1293,6 +1320,7 @@ pub struct ItemS3KeyRow {
1293 1320 pub project_slug: Slug,
1294 1321 pub audio_s3_key: Option<String>,
1295 1322 pub cover_s3_key: Option<String>,
1323 + pub video_s3_key: Option<String>,
1296 1324 }
1297 1325
1298 1326 /// A version's S3 key for content export.
@@ -1579,11 +1607,12 @@ pub struct DbCreatorSubscription {
1579 1607 pub grace_enforced_at: Option<DateTime<Utc>>,
1580 1608 }
1581 1609
1582 - /// File sizes for an item's audio and cover uploads (for storage decrement on delete).
1610 + /// File sizes for an item's audio, video, and cover uploads (for storage decrement on delete).
1583 1611 #[derive(Debug, Clone)]
1584 1612 pub struct ItemFileSizes {
1585 1613 pub audio_file_size_bytes: Option<i64>,
1586 1614 pub cover_file_size_bytes: Option<i64>,
1615 + pub video_file_size_bytes: Option<i64>,
1587 1616 }
1588 1617
1589 1618 /// Per-category storage breakdown for the creator dashboard.
@@ -1593,6 +1622,7 @@ pub struct StorageBreakdown {
1593 1622 pub cover_bytes: i64,
1594 1623 pub download_bytes: i64,
1595 1624 pub insertion_bytes: i64,
1625 + pub video_bytes: i64,
1596 1626 pub total_bytes: i64,
1597 1627 }
1598 1628
@@ -2002,6 +2032,11 @@ mod tests {
2002 2032 listed: true,
2003 2033 license_preset: None,
2004 2034 custom_license_text: None,
2035 + video_s3_key: None,
2036 + video_file_size_bytes: None,
2037 + video_duration_seconds: None,
2038 + video_width: None,
2039 + video_height: None,
2005 2040 }
2006 2041 }
2007 2042
@@ -2032,6 +2067,24 @@ mod tests {
2032 2067 }
2033 2068
2034 2069 #[test]
2070 + fn content_video_variant() {
2071 + let mut item = make_item(super::super::ItemType::Video);
2072 + item.video_s3_key = Some("video/test.mp4".to_string());
2073 + item.video_duration_seconds = Some(300);
2074 + item.video_width = Some(1920);
2075 + item.video_height = Some(1080);
2076 + match item.content() {
2077 + ContentData::Video { video_s3_key, duration_seconds, width, height, .. } => {
2078 + assert_eq!(video_s3_key.as_deref(), Some("video/test.mp4"));
2079 + assert_eq!(duration_seconds, Some(300));
2080 + assert_eq!(width, Some(1920));
2081 + assert_eq!(height, Some(1080));
2082 + }
2083 + _ => panic!("expected Video variant"),
2084 + }
2085 + }
2086 +
2087 + #[test]
2035 2088 fn content_other_variant() {
2036 2089 let item = make_item(super::super::ItemType::Digital);
2037 2090 assert!(matches!(item.content(), ContentData::Other));
@@ -2050,4 +2103,13 @@ mod tests {
2050 2103 item.cover_s3_key = None;
2051 2104 assert!(!item.has_s3_content());
2052 2105 }
2106 +
2107 + #[test]
2108 + fn has_s3_content_video() {
2109 + let mut item = make_item(super::super::ItemType::Video);
2110 + item.audio_s3_key = None;
2111 + item.cover_s3_key = None;
2112 + item.video_s3_key = Some("video/test.mp4".to_string());
2113 + assert!(item.has_s3_content());
2114 + }
2053 2115 }
@@ -85,12 +85,6 @@ impl TextWatermarker {
85 85 pos.max(1).min(token_count.saturating_sub(1))
86 86 }
87 87
88 - /// Strip all ZWC characters from text (for clean extraction scanning).
89 - fn strip_zwc(text: &str) -> String {
90 - text.chars()
91 - .filter(|c| !matches!(*c, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}'))
92 - .collect()
93 - }
94 88 }
95 89
96 90 impl Watermarker for TextWatermarker {
@@ -174,7 +168,11 @@ mod tests {
174 168
175 169 let embedded = wm.embed(text.as_bytes(), fp).unwrap();
176 170 let embedded_str = std::str::from_utf8(&embedded).unwrap();
177 - let stripped = TextWatermarker::strip_zwc(embedded_str);
171 + // Strip zero-width characters to verify visible text is preserved
172 + let stripped: String = embedded_str
173 + .chars()
174 + .filter(|c| !matches!(*c, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}'))
175 + .collect();
178 176 assert_eq!(stripped, text);
179 177 }
180 178
@@ -2,7 +2,7 @@
2 2
3 3 use std::path::Path;
4 4
5 - use git2::{Diff, DiffOptions, ObjectType, Oid, Repository, Sort};
5 + use git2::{Diff, DiffOptions, Oid, Repository, Sort};
6 6
7 7 use super::{
8 8 BlameLine, CommitDetail, CommitInfo, DiffFile, DiffHunk, DiffLine, DiffStatus, GitError,
M src/helpers.rs +6 -14
@@ -119,24 +119,16 @@ pub fn format_revenue(cents: i64) -> String {
119 119 }
120 120
121 121 /// Format a byte count as a human-readable file size string.
122 + /// Returns "N/A" for zero bytes (useful for optional file sizes).
122 123 pub fn format_file_size(bytes: i64) -> String {
123 124 if bytes == 0 {
124 - "N/A".to_string()
125 - } else if bytes < 1024 {
126 - format!("{} B", bytes)
127 - } else if bytes < 1024 * 1024 {
128 - format!("{:.1} KB", bytes as f64 / 1024.0)
129 - } else if bytes < 1024 * 1024 * 1024 {
130 - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
131 - } else {
132 - format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
125 + return "N/A".to_string();
133 126 }
127 + format_bytes(bytes)
134 128 }
135 129
136 130 /// Format a byte count as a compact human-readable string (e.g. "1.5 GB").
137 - ///
138 - /// Unlike [`format_file_size`], this is designed for storage quota display where
139 - /// zero means "0 B" rather than "N/A".
131 + /// Returns "0 B" for zero bytes (useful for storage quota display).
140 132 pub fn format_bytes(bytes: i64) -> String {
141 133 let bytes = bytes.max(0) as u64;
142 134 if bytes < 1024 {
@@ -146,7 +138,7 @@ pub fn format_bytes(bytes: i64) -> String {
146 138 } else if bytes < 1024 * 1024 * 1024 {
147 139 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
148 140 } else {
149 - format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
141 + format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
150 142 }
151 143 }
152 144
@@ -563,7 +555,7 @@ mod tests {
563 555
564 556 #[test]
565 557 fn format_bytes_gigabytes() {
566 - assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.00 GB");
558 + assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB");
567 559 }
568 560
569 561 #[test]
@@ -19,7 +19,6 @@ mod connect;
19 19 mod webhooks;
20 20
21 21 pub use checkout::*;
22 - pub use connect::*;
23 22 pub use webhooks::*;
24 23
25 24 use stripe::Client;
@@ -663,6 +663,11 @@ mod tests {
663 663 web_only: false,
664 664 audio_file_size_bytes: None,
665 665 cover_file_size_bytes: None,
666 + video_s3_key: None,
667 + video_file_size_bytes: None,
668 + video_duration_seconds: None,
669 + video_width: None,
670 + video_height: None,
666 671 slug: "test".to_string(),
667 672 listed: true,
668 673 license_preset: None,
@@ -91,6 +91,13 @@ pub(super) async fn export_projects(
91 91 "episode_number": episode_number,
92 92 })
93 93 }
94 + db::ContentData::Video { duration_seconds, width, height, .. } => {
95 + serde_json::json!({
96 + "duration_seconds": duration_seconds,
97 + "width": width,
98 + "height": height,
99 + })
100 + }
94 101 db::ContentData::Other => serde_json::json!({}),
95 102 };
96 103
@@ -416,6 +423,10 @@ pub(super) async fn export_content(
416 423 let ext = extension_from_key(key);
417 424 files.push((key.clone(), format!("projects/{}/{}-cover.{}", slug, title, ext)));
418 425 }
426 + if let Some(ref key) = item.video_s3_key {
427 + let ext = extension_from_key(key);
428 + files.push((key.clone(), format!("projects/{}/{}-video.{}", slug, title, ext)));
429 + }
419 430 }
420 431
421 432 for ver in &version_keys {
M src/storage.rs +116 -229