Skip to main content

max / makenotwork

seed: Phase 3 — attach placeholder media, flip items visible Wire the storage layer into the seed so items gain media and become discover-visible. seed::media uploads a generated placeholder per item (silent WAV for audio, a small blob + version row for downloads, a tiny PNG cover) directly via StorageBackend::upload_object, then promotes each item to scan_status='clean' (promote_gated for gated media, a direct status flip for text/image/version items). Item + project covers go to the public/CDN bucket. Storage is threaded through a new SeedMedia context, built inside the one-shot --seed-examples block from config (so normal boot is untouched). When S3 is unconfigured (testnot's stubbed default), the media phase no-ops and items stay hidden. This pass is placeholders + code/tests only, by decision: MinIO on testnot and a real PD/CC0 asset manifest come later. DB test proves the full path with InMemoryStorage — all 11 items visible, audio/version keys set, covers clean, idempotent. Suite now 5 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-11 21:17 UTC
Commit: 9d4d7b5c57cb89a423bebd70cc6bb879eecc528a
Parent: 81d2afc
5 files changed, +357 insertions, -10 deletions
@@ -167,7 +167,31 @@ async fn main() {
167 167 // See `makenotwork::seed` and `_private/docs/mnw/testnot-example-seed.md`.
168 168 if std::env::args().any(|a| a == "--seed-examples") {
169 169 let opts = makenotwork::seed::SeedOptions::from_env(&config.host_url);
170 - match makenotwork::seed::run(&db, &opts).await {
170 +
171 + // Build the storage handles the media phase needs (main + public/CDN
172 + // buckets). Both are `None` when S3 is unconfigured — the media phase then
173 + // no-ops and items stay hidden. Done here, inside the one-shot seed block,
174 + // so normal boot (which builds these later) is untouched.
175 + async fn seed_client(
176 + cfg: Option<&makenotwork::config::StorageConfig>,
177 + host_url: &str,
178 + ) -> Option<std::sync::Arc<dyn makenotwork::storage::StorageBackend>> {
179 + let cfg = cfg?;
180 + match S3Client::new(cfg, host_url).await {
181 + Ok(client) => Some(std::sync::Arc::new(client) as _),
182 + Err(e) => {
183 + tracing::warn!(error = ?e, "seed: failed to init S3 storage; media skipped");
184 + None
185 + }
186 + }
187 + }
188 + let media = makenotwork::seed::SeedMedia {
189 + s3: seed_client(config.storage.as_ref(), &config.host_url).await,
190 + public_s3: seed_client(config.public_storage.as_ref(), &config.host_url).await,
191 + cdn_base_url: config.cdn_base_url.clone(),
192 + };
193 +
194 + match makenotwork::seed::run(&db, &opts, &media).await {
171 195 Ok(()) => {
172 196 tracing::info!("example seed complete");
173 197 std::process::exit(0);
@@ -1,2 +1,220 @@
1 - //! Fetch and upload public-domain / CC0 media (audio, images, docs) through the
2 - //! storage layer, per `media-manifest.toml`. Filled in Phase 3.
1 + //! Attach media to seeded items and flip them visible.
2 + //!
3 + //! Phase 2 left every item `scan_status='pending'` (hidden). This phase uploads a
4 + //! file per item through the storage layer and promotes the item to `'clean'`, so
5 + //! previews/downloads resolve and the catalog surfaces in discover.
6 + //!
7 + //! This pass uploads **generated placeholders** (a silent WAV, a tiny PNG cover, a
8 + //! short byte blob), not real assets — enough to make the pipeline functional and
9 + //! reproducible with no external URLs. Real public-domain / CC0 media swaps in
10 + //! later via a `media-manifest.toml` + fetch step. When storage is unconfigured
11 + //! (testnot's stubbed default until MinIO is stood up), the whole phase is skipped
12 + //! and items stay hidden.
13 +
14 + use super::projects::SeededProject;
15 + use super::{SeedError, SeedMedia};
16 + use crate::db::scan_jobs::ScanTargetKind;
17 + use crate::db::{self, FileScanStatus, ItemType};
18 + use crate::storage::{FileType, S3Client, StorageBackend};
19 +
20 + /// A minimal valid 16x16 grayscale PNG, used as a placeholder cover.
21 + static PLACEHOLDER_PNG: &[u8] = include_bytes!("assets/placeholder.png");
22 +
23 + /// Attach placeholder media to every seeded item and promote it to visible.
24 + ///
25 + /// No-ops (leaving items hidden) when the main storage bucket is unconfigured.
26 + pub async fn seed_media(
27 + pool: &sqlx::PgPool,
28 + media: &SeedMedia,
29 + projects: &[SeededProject],
30 + ) -> Result<(), SeedError> {
31 + let Some(s3) = media.s3.as_deref() else {
32 + tracing::warn!("example seed: storage not configured; media skipped, items stay hidden");
33 + return Ok(());
34 + };
35 +
36 + for project in projects {
37 + // Project cover (best-effort; needs the public/CDN bucket).
38 + attach_project_cover(pool, media, project).await?;
39 +
40 + let items = db::items::get_items_by_project(pool, project.project.id).await?;
41 + for item in &items {
42 + attach_item_media(pool, s3, project, item).await?;
43 + attach_item_cover(pool, media, project, item).await?;
44 + }
45 + }
46 + Ok(())
47 + }
48 +
49 + /// Upload the item's primary file (by type) and mark the item `clean`.
50 + async fn attach_item_media(
51 + pool: &sqlx::PgPool,
52 + s3: &dyn StorageBackend,
53 + project: &SeededProject,
54 + item: &db::DbItem,
55 + ) -> Result<(), SeedError> {
56 + let user = project.user_id;
57 + match item.item_type {
58 + ItemType::Audio => {
59 + let key = S3Client::generate_key(user, item.id, FileType::Audio, "placeholder.wav");
60 + s3.upload_object(&key, "audio/wav", silent_wav(), None).await?;
61 + db::scanning::promote_gated(
62 + pool,
63 + ScanTargetKind::Item,
64 + FileType::Audio,
65 + *item.id.as_uuid(),
66 + key.as_str(),
67 + )
68 + .await?;
69 + }
70 + ItemType::Video => {
71 + let key = S3Client::generate_key(user, item.id, FileType::Video, "placeholder.mp4");
72 + // Placeholder bytes — not a playable video; real media swaps in later.
73 + s3.upload_object(&key, "video/mp4", placeholder_blob("video"), None).await?;
74 + db::scanning::promote_gated(
75 + pool,
76 + ScanTargetKind::Item,
77 + FileType::Video,
78 + *item.id.as_uuid(),
79 + key.as_str(),
80 + )
81 + .await?;
82 + }
83 + ItemType::Text => {
84 + // Body was set in Phase 2; a text item needs no file, only visibility.
85 + db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?;
86 + }
87 + ItemType::Image => {
88 + // The cover (attached separately) is the media; just make it visible.
89 + db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?;
90 + }
91 + // Everything else is served as a downloadable version.
92 + ItemType::Sample
93 + | ItemType::Plugin
94 + | ItemType::Preset
95 + | ItemType::Course
96 + | ItemType::Template
97 + | ItemType::Digital
98 + | ItemType::Bundle => {
99 + let blob = placeholder_blob(&item.title);
100 + let size = blob.len() as i64;
101 + let version = db::versions::create_version(
102 + pool,
103 + item.id,
104 + "1.0.0",
105 + Some("Initial placeholder release."),
106 + None,
107 + Some(size),
108 + Some("placeholder.txt"),
109 + None,
110 + )
111 + .await?;
112 + let key =
113 + S3Client::generate_version_key(user, item.id, version.id, "placeholder.txt");
114 + s3.upload_object(&key, "application/octet-stream", blob, None).await?;
115 + db::scanning::promote_gated(
116 + pool,
117 + ScanTargetKind::Version,
118 + FileType::Download,
119 + *version.id.as_uuid(),
120 + key.as_str(),
121 + )
122 + .await?;
123 + // Version clean makes it downloadable; the item itself must be clean
124 + // too to surface in discover.
125 + db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?;
126 + }
127 + }
128 + tracing::info!(title = %item.title, item_type = ?item.item_type, "example seed: attached media");
129 + Ok(())
130 + }
131 +
132 + /// Attach a placeholder cover to an item. Best-effort: requires the public/CDN
133 + /// bucket + render base, else skipped.
134 + async fn attach_item_cover(
135 + pool: &sqlx::PgPool,
136 + media: &SeedMedia,
137 + project: &SeededProject,
138 + item: &db::DbItem,
139 + ) -> Result<(), SeedError> {
140 + let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref())
141 + else {
142 + return Ok(());
143 + };
144 + let key = S3Client::generate_key(project.user_id, item.id, FileType::Cover, "cover.png");
145 + public.upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), None).await?;
146 + let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str());
147 + sqlx::query(
148 + "UPDATE items SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean', \
149 + updated_at = NOW() WHERE id = $3",
150 + )
151 + .bind(key.as_str())
152 + .bind(&url)
153 + .bind(item.id)
154 + .execute(pool)
155 + .await?;
156 + Ok(())
157 + }
158 +
159 + /// Attach a placeholder cover to a project. Best-effort (see [`attach_item_cover`]).
160 + async fn attach_project_cover(
161 + pool: &sqlx::PgPool,
162 + media: &SeedMedia,
163 + project: &SeededProject,
164 + ) -> Result<(), SeedError> {
165 + let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref())
166 + else {
167 + return Ok(());
168 + };
169 + let key = S3Client::generate_project_image_key(project.project.id, "cover.png");
170 + public.upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), None).await?;
171 + let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str());
172 + sqlx::query(
173 + "UPDATE projects SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean', \
174 + updated_at = NOW() WHERE id = $3",
175 + )
176 + .bind(key.as_str())
177 + .bind(&url)
178 + .bind(project.project.id)
179 + .execute(pool)
180 + .await?;
181 + Ok(())
182 + }
183 +
184 + /// Synthesize a short silent PCM WAV (8 kHz, 16-bit mono, ~0.5 s) — a valid,
185 + /// tiny audio file for the placeholder audio player.
186 + fn silent_wav() -> Vec<u8> {
187 + const SAMPLE_RATE: u32 = 8000;
188 + const BITS: u16 = 16;
189 + const CHANNELS: u16 = 1;
190 + const SAMPLES: u32 = SAMPLE_RATE / 2; // 0.5 s
191 + let data_len = SAMPLES * u32::from(BITS / 8) * u32::from(CHANNELS);
192 + let byte_rate = SAMPLE_RATE * u32::from(CHANNELS) * u32::from(BITS / 8);
193 + let block_align = CHANNELS * (BITS / 8);
194 +
195 + let mut w = Vec::with_capacity(44 + data_len as usize);
196 + w.extend_from_slice(b"RIFF");
197 + w.extend_from_slice(&(36 + data_len).to_le_bytes());
198 + w.extend_from_slice(b"WAVE");
199 + w.extend_from_slice(b"fmt ");
200 + w.extend_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size
201 + w.extend_from_slice(&1u16.to_le_bytes()); // audio format = PCM
202 + w.extend_from_slice(&CHANNELS.to_le_bytes());
203 + w.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
204 + w.extend_from_slice(&byte_rate.to_le_bytes());
205 + w.extend_from_slice(&block_align.to_le_bytes());
206 + w.extend_from_slice(&BITS.to_le_bytes());
207 + w.extend_from_slice(b"data");
208 + w.extend_from_slice(&data_len.to_le_bytes());
209 + w.resize(44 + data_len as usize, 0); // silence
210 + w
211 + }
212 +
213 + /// A short UTF-8 placeholder blob for downloads / non-audio media.
214 + fn placeholder_blob(label: &str) -> Vec<u8> {
215 + format!(
216 + "{label}\n\nExample-seed placeholder file. Real public-domain media \
217 + attaches in a later pass.\n"
218 + )
219 + .into_bytes()
220 + }
@@ -37,8 +37,33 @@ pub mod media;
37 37 pub mod projects;
38 38 pub mod social;
39 39
40 + use std::sync::Arc;
41 +
40 42 use sqlx::PgPool;
41 43
44 + use crate::storage::StorageBackend;
45 +
46 + /// Storage handles the media phase needs, resolved from config at the seed call
47 + /// site. When `s3` is `None` (testnot's stubbed default until MinIO is stood up),
48 + /// the media phase is skipped and items stay hidden. Covers additionally require
49 + /// `public_s3` + `cdn_base_url` (the CDN render base).
50 + pub struct SeedMedia {
51 + /// Main (gated) bucket for audio/video/download files.
52 + pub s3: Option<Arc<dyn StorageBackend>>,
53 + /// Public/CDN bucket for cover images.
54 + pub public_s3: Option<Arc<dyn StorageBackend>>,
55 + /// CDN render base for `cover_image_url` (`{cdn_base}/{key}`).
56 + pub cdn_base_url: Option<String>,
57 + }
58 +
59 + impl SeedMedia {
60 + /// No storage — the media phase is skipped and items remain hidden. Used on a
61 + /// stubbed-S3 box and in tests that only exercise the DB-only phases.
62 + pub fn none() -> Self {
63 + Self { s3: None, public_s3: None, cdn_base_url: None }
64 + }
65 + }
66 +
42 67 /// Reserved email domain for every seeded example account. It is the marker the
43 68 /// prod-safety guard uses to tell fabricated seed data from real users: the seed
44 69 /// refuses to run when any account with a different domain exists, and the reset
@@ -100,7 +125,7 @@ impl SeedOptions {
100 125 /// Refuses (returns `Err`) unless every prod-safety guard passes. On success the
101 126 /// database holds a fresh fabricated catalog. Callers should treat this as a
102 127 /// one-shot: run it, then exit the process.
103 - pub async fn run(pool: &PgPool, opts: &SeedOptions) -> Result<(), SeedError> {
128 + pub async fn run(pool: &PgPool, opts: &SeedOptions, media: &SeedMedia) -> Result<(), SeedError> {
104 129 // Guards 1 + 2: opt-in switch and approved host. Pure, no DB.
105 130 check_static_guards(opts.allow_example_seed, &opts.host_url)?;
106 131
@@ -125,7 +150,11 @@ pub async fn run(pool: &PgPool, opts: &SeedOptions) -> Result<(), SeedError> {
125 150 let creators = creators::seed_creators(pool).await?;
126 151 let projects = projects::seed_projects(pool, &creators).await?;
127 152 items::seed_items(pool, &projects).await?;
128 - tracing::warn!("example seed: media/blog/social pending (Phases 3-4)");
153 +
154 + // Phase 3: attach placeholder media and flip items visible. Skipped (items
155 + // stay hidden) when storage is unconfigured.
156 + media::seed_media(pool, media, &projects).await?;
157 + tracing::warn!("example seed: blog/social pending (Phase 4)");
129 158
130 159 Ok(())
131 160 }
@@ -16,8 +16,12 @@
16 16 //! counts stay fixed instead of doubling.
17 17 //! 5. The prod-host guard still refuses even with the opt-in flag set.
18 18
19 + use std::sync::Arc;
20 +
19 21 use crate::harness::db::TestDb;
20 - use makenotwork::seed::{self, SeedOptions};
22 + use crate::harness::storage::InMemoryStorage;
23 + use makenotwork::seed::{self, SeedMedia, SeedOptions};
24 + use makenotwork::storage::StorageBackend;
21 25
22 26 /// The Phase-1 roster size (see `seed::creators::ROSTER`).
23 27 const SEEDED_CREATORS: i64 = 5;
@@ -113,7 +117,7 @@ async fn seeds_publicly_visible_creators_and_projects() {
113 117 .expect("count real users");
114 118 assert_eq!(real, 0, "a migrated DB should hold no non-example accounts");
115 119
116 - seed::run(&db.pool, &testnot_opts())
120 + seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
117 121 .await
118 122 .expect("seed should run on a clean migrated DB");
119 123
@@ -142,7 +146,7 @@ async fn seeds_publicly_visible_creators_and_projects() {
142 146 #[tokio::test]
143 147 async fn seeds_items_across_types_and_pricing_but_hidden() {
144 148 let db = TestDb::new().await;
145 - seed::run(&db.pool, &testnot_opts()).await.expect("seed");
149 + seed::run(&db.pool, &testnot_opts(), &SeedMedia::none()).await.expect("seed");
146 150
147 151 // One item per ItemType (11), and every type represented exactly once.
148 152 assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
@@ -214,8 +218,8 @@ async fn seeds_items_across_types_and_pricing_but_hidden() {
214 218 async fn seed_is_idempotent() {
215 219 let db = TestDb::new().await;
216 220
217 - seed::run(&db.pool, &testnot_opts()).await.expect("first seed");
218 - seed::run(&db.pool, &testnot_opts()).await.expect("second seed");
221 + seed::run(&db.pool, &testnot_opts(), &SeedMedia::none()).await.expect("first seed");
222 + seed::run(&db.pool, &testnot_opts(), &SeedMedia::none()).await.expect("second seed");
219 223
220 224 // Re-running wipes prior example data first (cascading to projects + items),
221 225 // so counts stay fixed instead of doubling.
@@ -234,6 +238,7 @@ async fn refuses_on_prod_host() {
234 238 allow_example_seed: true,
235 239 host_url: "https://makenot.work".to_string(),
236 240 },
241 + &SeedMedia::none(),
237 242 )
238 243 .await
239 244 .expect_err("prod host must be refused");
@@ -242,3 +247,74 @@ async fn refuses_on_prod_host() {
242 247 // Nothing was created, and the demo account is untouched.
243 248 assert_eq!(count_example_creators(&db.pool).await, 0);
244 249 }
250 +
251 + /// Guard-passing options bundled with an in-memory storage backend, so the media
252 + /// phase runs and flips items visible.
253 + fn media_ctx() -> SeedMedia {
254 + let s3: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
255 + let public: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
256 + SeedMedia {
257 + s3: Some(s3),
258 + public_s3: Some(public),
259 + cdn_base_url: Some("https://cdn.example.test".to_string()),
260 + }
261 + }
262 +
263 + #[tokio::test]
264 + async fn seeds_media_makes_items_visible() {
265 + let db = TestDb::new().await;
266 + seed::run(&db.pool, &testnot_opts(), &media_ctx())
267 + .await
268 + .expect("seed with media");
269 +
270 + // With media attached, every item is promoted 'clean' and now clears the
271 + // full item-discover gate (contrast the no-storage `..._but_hidden` test).
272 + assert_eq!(
273 + count_discover_visible_example_items(&db.pool).await,
274 + SEEDED_ITEMS,
275 + "all items should be visible once media is attached"
276 + );
277 +
278 + // The audio item has its audio key set.
279 + let audio_keyed: i64 = sqlx::query_scalar(
280 + "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
281 + JOIN users u ON u.id = p.user_id \
282 + WHERE lower(split_part(u.email,'@',2))='example.test' \
283 + AND i.item_type = 'audio' AND i.audio_s3_key IS NOT NULL",
284 + )
285 + .fetch_one(&db.pool)
286 + .await
287 + .expect("audio keyed");
288 + assert!(audio_keyed >= 1, "audio item should have an audio_s3_key");
289 +
290 + // Download-type items produced clean, keyed versions.
291 + let clean_versions: i64 = sqlx::query_scalar(
292 + "SELECT COUNT(*) FROM versions v JOIN items i ON i.id = v.item_id \
293 + JOIN projects p ON p.id = i.project_id JOIN users u ON u.id = p.user_id \
294 + WHERE lower(split_part(u.email,'@',2))='example.test' \
295 + AND v.s3_key IS NOT NULL AND v.scan_status = 'clean'",
296 + )
297 + .fetch_one(&db.pool)
298 + .await
299 + .expect("clean versions");
300 + assert!(clean_versions >= 1, "expected a clean, keyed download version");
301 +
302 + // Covers were attached (public bucket + CDN base present).
303 + let covered: i64 = sqlx::query_scalar(
304 + "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \
305 + JOIN users u ON u.id = p.user_id \
306 + WHERE lower(split_part(u.email,'@',2))='example.test' \
307 + AND i.cover_s3_key IS NOT NULL AND i.cover_scan_status = 'clean'",
308 + )
309 + .fetch_one(&db.pool)
310 + .await
311 + .expect("covered items");
312 + assert!(covered >= 1, "expected item covers with cover_scan_status clean");
313 +
314 + // Idempotent with media too: re-running stays at the roster size.
315 + seed::run(&db.pool, &testnot_opts(), &media_ctx())
316 + .await
317 + .expect("re-seed with media");
318 + assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS);
319 + assert_eq!(count_discover_visible_example_items(&db.pool).await, SEEDED_ITEMS);
320 + }