//! Attach media to seeded items and flip them visible. //! //! Phase 2 left every item `scan_status='pending'` (hidden). This phase uploads a //! file per item through the storage layer and promotes the item to `'clean'`, so //! previews/downloads resolve and the catalog surfaces in discover. //! //! Every slot here has two sources. The real one is a curated public-domain / CC0 //! asset named by `media-manifest.toml` and fetched by [`super::manifest`]. The //! fallback is a generated placeholder (a silent WAV, a 16x16 grey PNG, a short //! byte blob), which keeps the pipeline functional and reproducible with no //! external URLs. Curation is per asset, so the two mix freely: an item with real //! cover art and a placeholder download is a normal intermediate state. //! //! When storage is unconfigured the whole phase is skipped and items stay hidden. use std::collections::HashMap; use super::creators::ItemSpec; use super::manifest::ResolvedAssets; use super::projects::SeededProject; use super::{SeedError, SeedMedia}; use crate::db::scan_jobs::ScanTargetKind; use crate::db::{self, FileScanStatus, ItemType}; use crate::storage::{FileType, S3Client, StorageBackend}; /// A minimal valid 16x16 grayscale PNG, used as a placeholder cover. static PLACEHOLDER_PNG: &[u8] = include_bytes!("assets/placeholder.png"); /// Attach media to every seeded item and promote it to visible. /// /// No-ops (leaving items hidden) when the main storage bucket is unconfigured. pub async fn seed_media( pool: &sqlx::PgPool, media: &SeedMedia, projects: &[SeededProject], ) -> Result<(), SeedError> { let Some(s3) = media.s3.as_deref() else { tracing::warn!("example seed: storage not configured; media skipped, items stay hidden"); return Ok(()); }; for project in projects { // Project cover (best-effort; needs the public/CDN bucket). attach_project_cover(pool, media, project).await?; // Items come back from the database; their specs carry the manifest ids. // Titles are unique within a project, which is what makes this join safe. let specs: HashMap<&str, &ItemSpec> = project.spec.items.iter().map(|s| (s.title, s)).collect(); let items = db::items::get_items_by_project(pool, project.project.id).await?; for item in &items { let spec = specs.get(item.title.as_str()).copied(); attach_item_media(pool, s3, &media.assets, project, item, spec).await?; attach_item_cover(pool, media, project, item, spec).await?; } } Ok(()) } /// Upload the item's primary file (by type) and mark the item `clean`. async fn attach_item_media( pool: &sqlx::PgPool, s3: &dyn StorageBackend, assets: &ResolvedAssets, project: &SeededProject, item: &db::DbItem, spec: Option<&ItemSpec>, ) -> Result<(), SeedError> { let user = project.user_id; // The curated file for this item, when the manifest declares one and it // resolved. Everything below falls back to a generated placeholder. let curated = assets.lookup(spec.and_then(|s| s.media)); match item.item_type { ItemType::Audio => { let (filename, content_type, bytes) = curated.map_or_else( || ("placeholder.wav", "audio/wav", silent_wav()), |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()), ); let key = S3Client::generate_key(user, item.id, FileType::Audio, filename); s3.upload_object(&key, content_type, bytes, None).await?; db::scanning::promote_gated( pool, ScanTargetKind::Item, FileType::Audio, *item.id.as_uuid(), key.as_str(), ) .await?; } ItemType::Video => { let (filename, content_type, bytes) = curated.map_or_else( // Placeholder bytes, not a playable video. || ("placeholder.mp4", "video/mp4", placeholder_blob("video")), |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()), ); let key = S3Client::generate_key(user, item.id, FileType::Video, filename); s3.upload_object(&key, content_type, bytes, None).await?; db::scanning::promote_gated( pool, ScanTargetKind::Item, FileType::Video, *item.id.as_uuid(), key.as_str(), ) .await?; } ItemType::Text => { // Body was set in Phase 2; a text item needs no file, only visibility. db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?; } ItemType::Image => { // The cover (attached separately) is the media; just make it visible. db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?; } // Everything else is served as a downloadable version. ItemType::Sample | ItemType::Plugin | ItemType::Preset | ItemType::Course | ItemType::Template | ItemType::Digital | ItemType::Bundle => { let (filename, content_type, blob, notes) = curated.map_or_else( || { ( "placeholder.txt", "application/octet-stream", placeholder_blob(&item.title), "Initial placeholder release.", ) }, |(a, b)| { ( a.filename.as_str(), a.media_type.as_str(), b.to_vec(), "Initial release.", ) }, ); let size = blob.len() as i64; let version = db::versions::create_version( pool, item.id, "1.0.0", Some(notes), None, Some(size), Some(filename), None, ) .await?; let key = S3Client::generate_version_key(user, item.id, version.id, filename); s3.upload_object(&key, content_type, blob, None).await?; db::scanning::promote_gated( pool, ScanTargetKind::Version, FileType::Download, *version.id.as_uuid(), key.as_str(), ) .await?; // Version clean makes it downloadable; the item itself must be clean // too to surface in discover. db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?; } } tracing::info!( title = %item.title, item_type = ?item.item_type, curated = curated.is_some(), "example seed: attached media" ); Ok(()) } /// Attach a cover to an item, curated when the manifest has one. Best-effort: /// requires the public/CDN bucket + render base, else skipped. async fn attach_item_cover( pool: &sqlx::PgPool, media: &SeedMedia, project: &SeededProject, item: &db::DbItem, spec: Option<&ItemSpec>, ) -> Result<(), SeedError> { let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref()) else { return Ok(()); }; let (filename, content_type, bytes) = cover_source(&media.assets, spec.and_then(|s| s.cover)); let key = S3Client::generate_key(project.user_id, item.id, FileType::Cover, filename); public .upload_object(&key, content_type, bytes, None) .await?; let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str()); sqlx::query( "UPDATE items SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean', \ updated_at = NOW() WHERE id = $3", ) .bind(key.as_str()) .bind(&url) .bind(item.id) .execute(pool) .await?; Ok(()) } /// Attach a cover to a project. Best-effort (see [`attach_item_cover`]). async fn attach_project_cover( pool: &sqlx::PgPool, media: &SeedMedia, project: &SeededProject, ) -> Result<(), SeedError> { let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref()) else { return Ok(()); }; let (filename, content_type, bytes) = cover_source(&media.assets, project.spec.cover); let key = S3Client::generate_project_image_key(project.project.id, filename); public .upload_object(&key, content_type, bytes, None) .await?; let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str()); sqlx::query( "UPDATE projects SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean', \ updated_at = NOW() WHERE id = $3", ) .bind(key.as_str()) .bind(&url) .bind(project.project.id) .execute(pool) .await?; Ok(()) } /// The bytes to upload for a cover slot: the curated asset when the id resolved, /// the generated grey PNG otherwise. fn cover_source<'a>( assets: &'a ResolvedAssets, id: Option<&'a str>, ) -> (&'a str, &'a str, Vec) { assets.lookup(id).map_or_else( || ("cover.png", "image/png", PLACEHOLDER_PNG.to_vec()), |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()), ) } /// Synthesize a short silent PCM WAV (8 kHz, 16-bit mono, ~0.5 s), a valid, /// tiny audio file for the placeholder audio player. fn silent_wav() -> Vec { const SAMPLE_RATE: u32 = 8000; const BITS: u16 = 16; const CHANNELS: u16 = 1; const SAMPLES: u32 = SAMPLE_RATE / 2; // 0.5 s let data_len = SAMPLES * u32::from(BITS / 8) * u32::from(CHANNELS); let byte_rate = SAMPLE_RATE * u32::from(CHANNELS) * u32::from(BITS / 8); let block_align = CHANNELS * (BITS / 8); let mut w = Vec::with_capacity(44 + data_len as usize); w.extend_from_slice(b"RIFF"); w.extend_from_slice(&(36 + data_len).to_le_bytes()); w.extend_from_slice(b"WAVE"); w.extend_from_slice(b"fmt "); w.extend_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size w.extend_from_slice(&1u16.to_le_bytes()); // audio format = PCM w.extend_from_slice(&CHANNELS.to_le_bytes()); w.extend_from_slice(&SAMPLE_RATE.to_le_bytes()); w.extend_from_slice(&byte_rate.to_le_bytes()); w.extend_from_slice(&block_align.to_le_bytes()); w.extend_from_slice(&BITS.to_le_bytes()); w.extend_from_slice(b"data"); w.extend_from_slice(&data_len.to_le_bytes()); w.resize(44 + data_len as usize, 0); // silence w } /// A short UTF-8 placeholder blob for downloads / non-audio media. fn placeholder_blob(label: &str) -> Vec { format!( "{label}\n\nExample-seed placeholder file. Real public-domain media \ attaches in a later pass.\n" ) .into_bytes() }