Skip to main content

max / makenotwork

8.4 KB · 227 lines History Blame Raw
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)
61 .await?;
62 db::scanning::promote_gated(
63 pool,
64 ScanTargetKind::Item,
65 FileType::Audio,
66 *item.id.as_uuid(),
67 key.as_str(),
68 )
69 .await?;
70 }
71 ItemType::Video => {
72 let key = S3Client::generate_key(user, item.id, FileType::Video, "placeholder.mp4");
73 // Placeholder bytes, not a playable video; real media swaps in later.
74 s3.upload_object(&key, "video/mp4", placeholder_blob("video"), None)
75 .await?;
76 db::scanning::promote_gated(
77 pool,
78 ScanTargetKind::Item,
79 FileType::Video,
80 *item.id.as_uuid(),
81 key.as_str(),
82 )
83 .await?;
84 }
85 ItemType::Text => {
86 // Body was set in Phase 2; a text item needs no file, only visibility.
87 db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?;
88 }
89 ItemType::Image => {
90 // The cover (attached separately) is the media; just make it visible.
91 db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?;
92 }
93 // Everything else is served as a downloadable version.
94 ItemType::Sample
95 | ItemType::Plugin
96 | ItemType::Preset
97 | ItemType::Course
98 | ItemType::Template
99 | ItemType::Digital
100 | ItemType::Bundle => {
101 let blob = placeholder_blob(&item.title);
102 let size = blob.len() as i64;
103 let version = db::versions::create_version(
104 pool,
105 item.id,
106 "1.0.0",
107 Some("Initial placeholder release."),
108 None,
109 Some(size),
110 Some("placeholder.txt"),
111 None,
112 )
113 .await?;
114 let key = S3Client::generate_version_key(user, item.id, version.id, "placeholder.txt");
115 s3.upload_object(&key, "application/octet-stream", blob, None)
116 .await?;
117 db::scanning::promote_gated(
118 pool,
119 ScanTargetKind::Version,
120 FileType::Download,
121 *version.id.as_uuid(),
122 key.as_str(),
123 )
124 .await?;
125 // Version clean makes it downloadable; the item itself must be clean
126 // too to surface in discover.
127 db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?;
128 }
129 }
130 tracing::info!(title = %item.title, item_type = ?item.item_type, "example seed: attached media");
131 Ok(())
132 }
133
134 /// Attach a placeholder cover to an item. Best-effort: requires the public/CDN
135 /// bucket + render base, else skipped.
136 async fn attach_item_cover(
137 pool: &sqlx::PgPool,
138 media: &SeedMedia,
139 project: &SeededProject,
140 item: &db::DbItem,
141 ) -> Result<(), SeedError> {
142 let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref())
143 else {
144 return Ok(());
145 };
146 let key = S3Client::generate_key(project.user_id, item.id, FileType::Cover, "cover.png");
147 public
148 .upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), None)
149 .await?;
150 let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str());
151 sqlx::query(
152 "UPDATE items SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean', \
153 updated_at = NOW() WHERE id = $3",
154 )
155 .bind(key.as_str())
156 .bind(&url)
157 .bind(item.id)
158 .execute(pool)
159 .await?;
160 Ok(())
161 }
162
163 /// Attach a placeholder cover to a project. Best-effort (see [`attach_item_cover`]).
164 async fn attach_project_cover(
165 pool: &sqlx::PgPool,
166 media: &SeedMedia,
167 project: &SeededProject,
168 ) -> Result<(), SeedError> {
169 let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref())
170 else {
171 return Ok(());
172 };
173 let key = S3Client::generate_project_image_key(project.project.id, "cover.png");
174 public
175 .upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), None)
176 .await?;
177 let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str());
178 sqlx::query(
179 "UPDATE projects SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean', \
180 updated_at = NOW() WHERE id = $3",
181 )
182 .bind(key.as_str())
183 .bind(&url)
184 .bind(project.project.id)
185 .execute(pool)
186 .await?;
187 Ok(())
188 }
189
190 /// Synthesize a short silent PCM WAV (8 kHz, 16-bit mono, ~0.5 s), a valid,
191 /// tiny audio file for the placeholder audio player.
192 fn silent_wav() -> Vec<u8> {
193 const SAMPLE_RATE: u32 = 8000;
194 const BITS: u16 = 16;
195 const CHANNELS: u16 = 1;
196 const SAMPLES: u32 = SAMPLE_RATE / 2; // 0.5 s
197 let data_len = SAMPLES * u32::from(BITS / 8) * u32::from(CHANNELS);
198 let byte_rate = SAMPLE_RATE * u32::from(CHANNELS) * u32::from(BITS / 8);
199 let block_align = CHANNELS * (BITS / 8);
200
201 let mut w = Vec::with_capacity(44 + data_len as usize);
202 w.extend_from_slice(b"RIFF");
203 w.extend_from_slice(&(36 + data_len).to_le_bytes());
204 w.extend_from_slice(b"WAVE");
205 w.extend_from_slice(b"fmt ");
206 w.extend_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size
207 w.extend_from_slice(&1u16.to_le_bytes()); // audio format = PCM
208 w.extend_from_slice(&CHANNELS.to_le_bytes());
209 w.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
210 w.extend_from_slice(&byte_rate.to_le_bytes());
211 w.extend_from_slice(&block_align.to_le_bytes());
212 w.extend_from_slice(&BITS.to_le_bytes());
213 w.extend_from_slice(b"data");
214 w.extend_from_slice(&data_len.to_le_bytes());
215 w.resize(44 + data_len as usize, 0); // silence
216 w
217 }
218
219 /// A short UTF-8 placeholder blob for downloads / non-audio media.
220 fn placeholder_blob(label: &str) -> Vec<u8> {
221 format!(
222 "{label}\n\nExample-seed placeholder file. Real public-domain media \
223 attaches in a later pass.\n"
224 )
225 .into_bytes()
226 }
227