use futures::StreamExt; use id3::{Tag, TagLike}; use sqlx::{FromRow, PgPool}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; use crate::application::ports::file_lifecycle::FileLifecycleHook; use crate::common::errors::DomainError; #[derive(Debug, FromRow)] pub struct AudioFileRow { pub file_id: Uuid, pub blob_hash: String, } pub struct AudioMetadataService { pool: Arc, blob_root: PathBuf, } impl AudioMetadataService { pub fn new(pool: Arc, blob_root: PathBuf) -> Self { Self { pool, blob_root } } pub fn is_audio_file(mime_type: &str) -> bool { mime_type.starts_with("audio/") } pub fn spawn_extraction_background(service: Arc, file_id: Uuid, file_path: PathBuf) { tokio::spawn(async move { tracing::info!("🎵 Extracting audio metadata for: {}", file_id); if let Err(e) = service.extract_and_save(&file_id, &file_path).await { tracing::warn!("Failed to extract audio metadata: {}", e); } }); } pub fn spawn_extraction_with_delete_background( service: Arc, file_id: Uuid, file_path: PathBuf, ) { tokio::spawn(async move { tracing::info!("🎵 Updating audio metadata for: {}", file_id); let _ = service.delete_metadata(&file_id).await; if let Err(e) = service.extract_and_save(&file_id, &file_path).await { tracing::warn!("Failed to update audio metadata: {}", e); } }); } fn blob_path(&self, hash: &str) -> PathBuf { let prefix = &hash[0..2]; self.blob_root.join(prefix).join(format!("{}.blob", hash)) } /// Extract ID3 tag and MP3 duration from a file. /// /// All I/O is synchronous (id3 + mp3_duration crates), so this MUST /// only be called inside `spawn_blocking`. fn extract_metadata_blocking(file_path: &Path) -> Option { if !file_path.exists() { warn!("File does not exist: {:?}", file_path); return None; } let tag = match Tag::read_from_path(file_path) { Ok(t) => t, Err(e) => { warn!("Failed to read ID3 tag from {:?}: {}", file_path, e); return None; } }; let duration_secs = match mp3_duration::from_path(file_path) { Ok(dur) => dur.as_secs_f64().round() as i32, Err(_) => tag.duration().unwrap_or(0) as i32, }; let album_artist = tag.frames() .find(|f| f.id() == "TPE2") .and_then(|f| match f.content() { id3::frame::Content::Text(t) => Some(t.clone()), _ => None, }); Some(AudioMetadataFields { title: tag.title().map(|s| s.to_string()), artist: tag.artist().map(|s| s.to_string()), album: tag.album().map(|s| s.to_string()), album_artist, genre: tag.genre().map(|s| s.to_string()), track_number: tag.track().map(|n| n as i32), disc_number: tag.disc().map(|n| n as i32), year: tag.year(), duration_secs, }) } pub async fn extract_and_save( &self, file_id: &Uuid, file_path: &Path, ) -> Result<(), DomainError> { info!( "AudioMetadataService: blob_root={:?}, file_id={}, file_path={:?}", self.blob_root, file_id, file_path, ); // ── Sync I/O on the blocking thread pool (never stalls Tokio workers) ── let path = file_path.to_path_buf(); let metadata = tokio::task::spawn_blocking(move || Self::extract_metadata_blocking(&path)) .await .map_err(|e| { DomainError::internal_error( "AudioMetadataService", format!("spawn_blocking join error: {e}"), ) })?; let Some(m) = metadata else { return Ok(()); }; info!( "Extracted audio metadata for file {}: title={:?}, artist={:?}, album={:?}, duration={}s", file_id, m.title, m.artist, m.album, m.duration_secs ); info!("Saving metadata to database for file_id={}", file_id); sqlx::query( r#" INSERT INTO audio.file_metadata (file_id, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (file_id) DO UPDATE SET title = EXCLUDED.title, artist = EXCLUDED.artist, album = EXCLUDED.album, album_artist = EXCLUDED.album_artist, genre = EXCLUDED.genre, track_number = EXCLUDED.track_number, disc_number = EXCLUDED.disc_number, year = EXCLUDED.year, duration_secs = EXCLUDED.duration_secs, format = EXCLUDED.format, updated_at = CURRENT_TIMESTAMP "#, ) .bind(file_id) .bind(&m.title) .bind(&m.artist) .bind(&m.album) .bind(&m.album_artist) .bind(&m.genre) .bind(m.track_number) .bind(m.disc_number) .bind(m.year) .bind(m.duration_secs) .bind("MPEG") .execute(&*self.pool) .await .map_err(|e| { DomainError::database_error(format!("Failed to save audio metadata: {}", e)) })?; Ok(()) } pub async fn delete_metadata(&self, file_id: &Uuid) -> Result<(), DomainError> { sqlx::query("DELETE FROM audio.file_metadata WHERE file_id = $1") .bind(file_id) .execute(&*self.pool) .await .map_err(|e| { DomainError::database_error(format!("Failed to delete audio metadata: {}", e)) })?; Ok(()) } pub async fn reextract_all_audio_metadata( &self, ) -> Result { // Stream rows one-by-one instead of fetch_all to keep memory O(1). let mut stream = sqlx::query_as::<_, AudioFileRow>( r#" SELECT id as file_id, blob_hash FROM storage.files WHERE mime_type LIKE 'audio/%' "#, ) .fetch(&*self.pool); let mut total: usize = 0; let mut processed: usize = 0; let mut failed: usize = 0; info!("Starting streaming metadata extraction for audio files"); while let Some(row) = stream.next().await { total += 1; let audio_file = row.map_err(|e| { DomainError::database_error(format!("Failed to fetch audio file row: {}", e)) })?; let file_path = self.blob_path(&audio_file.blob_hash); match self.extract_and_save(&audio_file.file_id, &file_path).await { Ok(()) => processed += 1, Err(e) => { warn!( "Failed to extract metadata for file {}: {}", audio_file.file_id, e ); failed += 1; } } } info!( "Metadata extraction complete: {} processed, {} failed out of {} total", processed, failed, total ); Ok(MetadataExtractionResult { total, processed, failed, }) } /// Copy audio metadata from an existing file that shares the same blob. /// /// Used when `is_new_blob=false` (copy/dedup hit): instead of re-parsing /// the blob, clone the existing metadata row for `new_file_id`. Falls back /// Clones audio metadata from a known source file, falling back to /// [`clone_or_extract_background`] if the source has not been processed yet. pub fn clone_from_source_background( service: Arc, new_file_id: Uuid, source_file_id: Uuid, blob_hash: String, ) { tokio::spawn(async move { let result = sqlx::query( r#" INSERT INTO audio.file_metadata (file_id, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, format) SELECT $1, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, format FROM audio.file_metadata WHERE file_id = $2 ON CONFLICT (file_id) DO NOTHING "#, ) .bind(new_file_id) .bind(source_file_id) .execute(&*service.pool) .await; match result { Ok(r) if r.rows_affected() > 0 => { info!( "Cloned audio metadata from {} to {}", source_file_id, new_file_id ); } Ok(_) => { // Source not yet processed — fall back to blob-hash lookup or extraction. Self::clone_or_extract_background(service, new_file_id, blob_hash); } Err(e) => { warn!( "Failed to clone audio metadata from {} to {}: {}", source_file_id, new_file_id, e ); } } }); } /// to full extraction if no existing row is found (race: original not yet /// processed). pub fn clone_or_extract_background(service: Arc, new_file_id: Uuid, blob_hash: String) { tokio::spawn(async move { let rows_inserted = sqlx::query( r#" INSERT INTO audio.file_metadata (file_id, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, format) SELECT $1, title, artist, album, album_artist, genre, track_number, disc_number, year, duration_secs, format FROM audio.file_metadata am JOIN storage.files sf ON sf.id = am.file_id WHERE sf.blob_hash = $2 LIMIT 1 ON CONFLICT (file_id) DO NOTHING "#, ) .bind(new_file_id) .bind(&blob_hash) .execute(&*service.pool) .await; match rows_inserted { Ok(result) if result.rows_affected() > 0 => { info!("Cloned audio metadata for file {}", new_file_id); } Ok(_) => { // No existing metadata found — original not yet processed; fall back. let file_path = service.blob_path(&blob_hash); if let Err(e) = service.extract_and_save(&new_file_id, &file_path).await { warn!( "Failed to extract audio metadata for {}: {}", new_file_id, e ); } } Err(e) => { warn!("Failed to clone audio metadata for {}: {}", new_file_id, e); } } }); } } /// Extracted audio metadata fields transferred from the blocking thread. struct AudioMetadataFields { title: Option, artist: Option, album: Option, album_artist: Option, genre: Option, track_number: Option, disc_number: Option, year: Option, duration_secs: i32, } #[derive(Debug, serde::Serialize)] pub struct MetadataExtractionResult { pub total: usize, pub processed: usize, pub failed: usize, } // ─── FileLifecycleHook ─────────────────────────────────────────────────────── impl FileLifecycleHook for AudioMetadataService { fn on_file_created( &self, file_id: &str, blob_hash: &str, content_type: &str, is_new_blob: bool, ) { if !Self::is_audio_file(content_type) { return; } let Ok(uuid) = file_id.parse::() else { warn!("on_file_created: invalid file_id UUID: {}", file_id); return; }; let service = Arc::new(Self { pool: self.pool.clone(), blob_root: self.blob_root.clone(), }); if is_new_blob { Self::spawn_extraction_background(service, uuid, self.blob_path(blob_hash)); } else { Self::clone_or_extract_background(service, uuid, blob_hash.to_string()); } } fn on_file_copied( &self, file_id: &str, blob_hash: &str, content_type: &str, source_file_id: &str, ) { if !Self::is_audio_file(content_type) { return; } let Ok(uuid) = file_id.parse::() else { warn!("on_file_copied: invalid file_id UUID: {}", file_id); return; }; let Ok(source_uuid) = source_file_id.parse::() else { warn!( "on_file_copied: invalid source_file_id UUID: {}", source_file_id ); return; }; let service = Arc::new(Self { pool: self.pool.clone(), blob_root: self.blob_root.clone(), }); Self::clone_from_source_background(service, uuid, source_uuid, blob_hash.to_string()); } fn on_file_updated(&self, file_id: &str, blob_hash: &str, content_type: &str) { if !Self::is_audio_file(content_type) { return; } let Ok(uuid) = file_id.parse::() else { warn!("on_file_updated: invalid file_id UUID: {}", file_id); return; }; let service = Arc::new(Self { pool: self.pool.clone(), blob_root: self.blob_root.clone(), }); Self::spawn_extraction_with_delete_background(service, uuid, self.blob_path(blob_hash)); } fn on_file_deleted(&self, _file_id: &str) { // audio.file_metadata has ON DELETE CASCADE on file_id — DB handles cleanup. } }