Skip to main content

max / makenotwork

10.8 KB · 287 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 //! Every slot here has two sources. The real one is a curated public-domain / CC0
8 //! asset named by `media-manifest.toml` and fetched by [`super::manifest`]. The
9 //! fallback is a generated placeholder (a silent WAV, a 16x16 grey PNG, a short
10 //! byte blob), which keeps the pipeline functional and reproducible with no
11 //! external URLs. Curation is per asset, so the two mix freely: an item with real
12 //! cover art and a placeholder download is a normal intermediate state.
13 //!
14 //! When storage is unconfigured the whole phase is skipped and items stay hidden.
15
16 use std::collections::HashMap;
17
18 use super::creators::ItemSpec;
19 use super::manifest::ResolvedAssets;
20 use super::projects::SeededProject;
21 use super::{SeedError, SeedMedia};
22 use crate::db::scan_jobs::ScanTargetKind;
23 use crate::db::{self, FileScanStatus, ItemType};
24 use crate::storage::{FileType, S3Client, StorageBackend};
25
26 /// A minimal valid 16x16 grayscale PNG, used as a placeholder cover.
27 static PLACEHOLDER_PNG: &[u8] = include_bytes!("assets/placeholder.png");
28
29 /// Attach media to every seeded item and promote it to visible.
30 ///
31 /// No-ops (leaving items hidden) when the main storage bucket is unconfigured.
32 pub async fn seed_media(
33 pool: &sqlx::PgPool,
34 media: &SeedMedia,
35 projects: &[SeededProject],
36 ) -> Result<(), SeedError> {
37 let Some(s3) = media.s3.as_deref() else {
38 tracing::warn!("example seed: storage not configured; media skipped, items stay hidden");
39 return Ok(());
40 };
41
42 for project in projects {
43 // Project cover (best-effort; needs the public/CDN bucket).
44 attach_project_cover(pool, media, project).await?;
45
46 // Items come back from the database; their specs carry the manifest ids.
47 // Titles are unique within a project, which is what makes this join safe.
48 let specs: HashMap<&str, &ItemSpec> =
49 project.spec.items.iter().map(|s| (s.title, s)).collect();
50
51 let items = db::items::get_items_by_project(pool, project.project.id).await?;
52 for item in &items {
53 let spec = specs.get(item.title.as_str()).copied();
54 attach_item_media(pool, s3, &media.assets, project, item, spec).await?;
55 attach_item_cover(pool, media, project, item, spec).await?;
56 }
57 }
58 Ok(())
59 }
60
61 /// Upload the item's primary file (by type) and mark the item `clean`.
62 async fn attach_item_media(
63 pool: &sqlx::PgPool,
64 s3: &dyn StorageBackend,
65 assets: &ResolvedAssets,
66 project: &SeededProject,
67 item: &db::DbItem,
68 spec: Option<&ItemSpec>,
69 ) -> Result<(), SeedError> {
70 let user = project.user_id;
71 // The curated file for this item, when the manifest declares one and it
72 // resolved. Everything below falls back to a generated placeholder.
73 let curated = assets.lookup(spec.and_then(|s| s.media));
74
75 match item.item_type {
76 ItemType::Audio => {
77 let (filename, content_type, bytes) = curated.map_or_else(
78 || ("placeholder.wav", "audio/wav", silent_wav()),
79 |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()),
80 );
81 let key = S3Client::generate_key(user, item.id, FileType::Audio, filename);
82 s3.upload_object(&key, content_type, bytes, None).await?;
83 db::scanning::promote_gated(
84 pool,
85 ScanTargetKind::Item,
86 FileType::Audio,
87 *item.id.as_uuid(),
88 key.as_str(),
89 )
90 .await?;
91 }
92 ItemType::Video => {
93 let (filename, content_type, bytes) = curated.map_or_else(
94 // Placeholder bytes, not a playable video.
95 || ("placeholder.mp4", "video/mp4", placeholder_blob("video")),
96 |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()),
97 );
98 let key = S3Client::generate_key(user, item.id, FileType::Video, filename);
99 s3.upload_object(&key, content_type, bytes, None).await?;
100 db::scanning::promote_gated(
101 pool,
102 ScanTargetKind::Item,
103 FileType::Video,
104 *item.id.as_uuid(),
105 key.as_str(),
106 )
107 .await?;
108 }
109 ItemType::Text => {
110 // Body was set in Phase 2; a text item needs no file, only visibility.
111 db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?;
112 }
113 ItemType::Image => {
114 // The cover (attached separately) is the media; just make it visible.
115 db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?;
116 }
117 // Everything else is served as a downloadable version.
118 ItemType::Sample
119 | ItemType::Plugin
120 | ItemType::Preset
121 | ItemType::Course
122 | ItemType::Template
123 | ItemType::Digital
124 | ItemType::Bundle => {
125 let (filename, content_type, blob, notes) = curated.map_or_else(
126 || {
127 (
128 "placeholder.txt",
129 "application/octet-stream",
130 placeholder_blob(&item.title),
131 "Initial placeholder release.",
132 )
133 },
134 |(a, b)| {
135 (
136 a.filename.as_str(),
137 a.media_type.as_str(),
138 b.to_vec(),
139 "Initial release.",
140 )
141 },
142 );
143 let size = blob.len() as i64;
144 let version = db::versions::create_version(
145 pool,
146 item.id,
147 "1.0.0",
148 Some(notes),
149 None,
150 Some(size),
151 Some(filename),
152 None,
153 )
154 .await?;
155 let key = S3Client::generate_version_key(user, item.id, version.id, filename);
156 s3.upload_object(&key, content_type, blob, None).await?;
157 db::scanning::promote_gated(
158 pool,
159 ScanTargetKind::Version,
160 FileType::Download,
161 *version.id.as_uuid(),
162 key.as_str(),
163 )
164 .await?;
165 // Version clean makes it downloadable; the item itself must be clean
166 // too to surface in discover.
167 db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?;
168 }
169 }
170 tracing::info!(
171 title = %item.title,
172 item_type = ?item.item_type,
173 curated = curated.is_some(),
174 "example seed: attached media"
175 );
176 Ok(())
177 }
178
179 /// Attach a cover to an item, curated when the manifest has one. Best-effort:
180 /// requires the public/CDN bucket + render base, else skipped.
181 async fn attach_item_cover(
182 pool: &sqlx::PgPool,
183 media: &SeedMedia,
184 project: &SeededProject,
185 item: &db::DbItem,
186 spec: Option<&ItemSpec>,
187 ) -> Result<(), SeedError> {
188 let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref())
189 else {
190 return Ok(());
191 };
192 let (filename, content_type, bytes) = cover_source(&media.assets, spec.and_then(|s| s.cover));
193 let key = S3Client::generate_key(project.user_id, item.id, FileType::Cover, filename);
194 public
195 .upload_object(&key, content_type, bytes, None)
196 .await?;
197 let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str());
198 sqlx::query(
199 "UPDATE items SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean', \
200 updated_at = NOW() WHERE id = $3",
201 )
202 .bind(key.as_str())
203 .bind(&url)
204 .bind(item.id)
205 .execute(pool)
206 .await?;
207 Ok(())
208 }
209
210 /// Attach a cover to a project. Best-effort (see [`attach_item_cover`]).
211 async fn attach_project_cover(
212 pool: &sqlx::PgPool,
213 media: &SeedMedia,
214 project: &SeededProject,
215 ) -> Result<(), SeedError> {
216 let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref())
217 else {
218 return Ok(());
219 };
220 let (filename, content_type, bytes) = cover_source(&media.assets, project.spec.cover);
221 let key = S3Client::generate_project_image_key(project.project.id, filename);
222 public
223 .upload_object(&key, content_type, bytes, None)
224 .await?;
225 let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str());
226 sqlx::query(
227 "UPDATE projects SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean', \
228 updated_at = NOW() WHERE id = $3",
229 )
230 .bind(key.as_str())
231 .bind(&url)
232 .bind(project.project.id)
233 .execute(pool)
234 .await?;
235 Ok(())
236 }
237
238 /// The bytes to upload for a cover slot: the curated asset when the id resolved,
239 /// the generated grey PNG otherwise.
240 fn cover_source<'a>(
241 assets: &'a ResolvedAssets,
242 id: Option<&'a str>,
243 ) -> (&'a str, &'a str, Vec<u8>) {
244 assets.lookup(id).map_or_else(
245 || ("cover.png", "image/png", PLACEHOLDER_PNG.to_vec()),
246 |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()),
247 )
248 }
249
250 /// Synthesize a short silent PCM WAV (8 kHz, 16-bit mono, ~0.5 s), a valid,
251 /// tiny audio file for the placeholder audio player.
252 fn silent_wav() -> Vec<u8> {
253 const SAMPLE_RATE: u32 = 8000;
254 const BITS: u16 = 16;
255 const CHANNELS: u16 = 1;
256 const SAMPLES: u32 = SAMPLE_RATE / 2; // 0.5 s
257 let data_len = SAMPLES * u32::from(BITS / 8) * u32::from(CHANNELS);
258 let byte_rate = SAMPLE_RATE * u32::from(CHANNELS) * u32::from(BITS / 8);
259 let block_align = CHANNELS * (BITS / 8);
260
261 let mut w = Vec::with_capacity(44 + data_len as usize);
262 w.extend_from_slice(b"RIFF");
263 w.extend_from_slice(&(36 + data_len).to_le_bytes());
264 w.extend_from_slice(b"WAVE");
265 w.extend_from_slice(b"fmt ");
266 w.extend_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size
267 w.extend_from_slice(&1u16.to_le_bytes()); // audio format = PCM
268 w.extend_from_slice(&CHANNELS.to_le_bytes());
269 w.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
270 w.extend_from_slice(&byte_rate.to_le_bytes());
271 w.extend_from_slice(&block_align.to_le_bytes());
272 w.extend_from_slice(&BITS.to_le_bytes());
273 w.extend_from_slice(b"data");
274 w.extend_from_slice(&data_len.to_le_bytes());
275 w.resize(44 + data_len as usize, 0); // silence
276 w
277 }
278
279 /// A short UTF-8 placeholder blob for downloads / non-audio media.
280 fn placeholder_blob(label: &str) -> Vec<u8> {
281 format!(
282 "{label}\n\nExample-seed placeholder file. Real public-domain media \
283 attaches in a later pass.\n"
284 )
285 .into_bytes()
286 }
287