//! 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. //! //! This pass uploads **generated placeholders** (a silent WAV, a tiny PNG cover, a //! short byte blob), not real assets, enough to make the pipeline functional and //! reproducible with no external URLs. Real public-domain / CC0 media swaps in //! later via a `media-manifest.toml` + fetch step. When storage is unconfigured //! (testnot's stubbed default until MinIO is stood up), the whole phase is skipped //! and items stay hidden. 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 placeholder 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?; let items = db::items::get_items_by_project(pool, project.project.id).await?; for item in &items { attach_item_media(pool, s3, project, item).await?; attach_item_cover(pool, media, project, item).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, project: &SeededProject, item: &db::DbItem, ) -> Result<(), SeedError> { let user = project.user_id; match item.item_type { ItemType::Audio => { let key = S3Client::generate_key(user, item.id, FileType::Audio, "placeholder.wav"); s3.upload_object(&key, "audio/wav", silent_wav(), None) .await?; db::scanning::promote_gated( pool, ScanTargetKind::Item, FileType::Audio, *item.id.as_uuid(), key.as_str(), ) .await?; } ItemType::Video => { let key = S3Client::generate_key(user, item.id, FileType::Video, "placeholder.mp4"); // Placeholder bytes, not a playable video; real media swaps in later. s3.upload_object(&key, "video/mp4", placeholder_blob("video"), 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 blob = placeholder_blob(&item.title); let size = blob.len() as i64; let version = db::versions::create_version( pool, item.id, "1.0.0", Some("Initial placeholder release."), None, Some(size), Some("placeholder.txt"), None, ) .await?; let key = S3Client::generate_version_key(user, item.id, version.id, "placeholder.txt"); s3.upload_object(&key, "application/octet-stream", 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, "example seed: attached media"); Ok(()) } /// Attach a placeholder cover to an item. 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, ) -> Result<(), SeedError> { let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref()) else { return Ok(()); }; let key = S3Client::generate_key(project.user_id, item.id, FileType::Cover, "cover.png"); public .upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), 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 placeholder 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 key = S3Client::generate_project_image_key(project.project.id, "cover.png"); public .upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), 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(()) } /// 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() }