fix: fix services accessig directly to localstorage

Prevent services accessing directly to localstorage and prefer using an astraction layer
to expose full blob. The abstraction layer (dedup services) will cover backend storage
election (local, s3, ...), encryption, etc

This change permit audio_metadata_service, media_metadaa_service, face_indexing_service to handle
blobs without worring of the backend.

note: prefered way to handle blob is the streamed way. Some services may not have this possibility
This commit is contained in:
Edouard Vanbelle
2026-08-02 19:20:23 +02:00
parent 74866bea81
commit 7663f803d3
11 changed files with 621 additions and 78 deletions
@@ -8,6 +8,7 @@ use uuid::Uuid;
use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::common::errors::DomainError;
use crate::infrastructure::services::dedup_service::DedupService;
#[derive(Debug, FromRow)]
pub struct AudioFileRow {
@@ -17,22 +18,32 @@ pub struct AudioFileRow {
pub struct AudioMetadataService {
pool: Arc<PgPool>,
blob_root: PathBuf,
/// CDC-aware blob reader. Same abstraction `thumbnail_service` uses —
/// hides both the chunk-manifest concatenation and the underlying
/// `BlobStorageBackend` wrapper stack.
dedup: Arc<DedupService>,
/// Tier-1 scratch directory for `stream_blob_to_tempfile`. Pulled
/// from `AppConfig::temp_dir` (env `OXICLOUD_TEMP_DIR`) at DI time.
temp_dir: PathBuf,
}
impl AudioMetadataService {
pub fn new(pool: Arc<PgPool>, blob_root: PathBuf) -> Self {
Self { pool, blob_root }
pub fn new(pool: Arc<PgPool>, dedup: Arc<DedupService>, temp_dir: PathBuf) -> Self {
Self {
pool,
dedup,
temp_dir,
}
}
pub fn is_audio_file(mime_type: &str) -> bool {
mime_type.starts_with("audio/")
}
pub fn spawn_extraction_background(service: Arc<Self>, file_id: Uuid, file_path: PathBuf) {
pub fn spawn_extraction_background(service: Arc<Self>, file_id: Uuid, blob_hash: String) {
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 {
if let Err(e) = service.extract_and_save(&file_id, &blob_hash).await {
tracing::warn!("Failed to extract audio metadata: {}", e);
}
});
@@ -41,22 +52,17 @@ impl AudioMetadataService {
pub fn spawn_extraction_with_delete_background(
service: Arc<Self>,
file_id: Uuid,
file_path: PathBuf,
blob_hash: String,
) {
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 {
if let Err(e) = service.extract_and_save(&file_id, &blob_hash).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
@@ -104,15 +110,26 @@ impl AudioMetadataService {
pub async fn extract_and_save(
&self,
file_id: &Uuid,
file_path: &Path,
blob_hash: &str,
) -> Result<(), DomainError> {
info!(
"AudioMetadataService: blob_root={:?}, file_id={}, file_path={:?}",
self.blob_root, file_id, file_path,
"AudioMetadataService: extracting file_id={}, blob_hash={}",
file_id, blob_hash,
);
// Stream the blob (CDC-aware — chunks concatenated on the fly for
// chunked files; wrapper stack handles encryption + retry + cache)
// to a tempfile in the configured tier-1 temp dir, then hand its
// `.path()` to the id3 + mp3_duration crates which only expose
// `from_path` APIs. Peak process-heap = one chunk (~1 MiB)
// regardless of MP3 size. Guard drops → tempfile auto-removed.
let named = self
.dedup
.stream_blob_to_tempfile(blob_hash, &self.temp_dir, ".mp3")
.await?;
let path = named.path().to_path_buf();
// ── 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| {
@@ -121,6 +138,10 @@ impl AudioMetadataService {
format!("spawn_blocking join error: {e}"),
)
})?;
// Explicitly hold `named` alive until after the extraction — the
// `spawn_blocking` closure only borrows the raw `path`, so the
// guard must not drop while the extractor is running.
drop(named);
let Some(m) = metadata else {
return Ok(());
@@ -208,8 +229,10 @@ impl AudioMetadataService {
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 {
match self
.extract_and_save(&audio_file.file_id, &audio_file.blob_hash)
.await
{
Ok(()) => processed += 1,
Err(e) => {
warn!(
@@ -313,8 +336,7 @@ impl AudioMetadataService {
}
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 {
if let Err(e) = service.extract_and_save(&new_file_id, &blob_hash).await {
warn!(
"Failed to extract audio metadata for {}: {}",
new_file_id, e
@@ -368,10 +390,11 @@ impl FileLifecycleHook for AudioMetadataService {
};
let service = Arc::new(Self {
pool: self.pool.clone(),
blob_root: self.blob_root.clone(),
dedup: self.dedup.clone(),
temp_dir: self.temp_dir.clone(),
});
if is_new_blob {
Self::spawn_extraction_background(service, uuid, self.blob_path(blob_hash));
Self::spawn_extraction_background(service, uuid, blob_hash.to_string());
} else {
Self::clone_or_extract_background(service, uuid, blob_hash.to_string());
}
@@ -400,7 +423,8 @@ impl FileLifecycleHook for AudioMetadataService {
};
let service = Arc::new(Self {
pool: self.pool.clone(),
blob_root: self.blob_root.clone(),
dedup: self.dedup.clone(),
temp_dir: self.temp_dir.clone(),
});
Self::clone_from_source_background(service, uuid, source_uuid, blob_hash.to_string());
}
@@ -415,9 +439,10 @@ impl FileLifecycleHook for AudioMetadataService {
};
let service = Arc::new(Self {
pool: self.pool.clone(),
blob_root: self.blob_root.clone(),
dedup: self.dedup.clone(),
temp_dir: self.temp_dir.clone(),
});
Self::spawn_extraction_with_delete_background(service, uuid, self.blob_path(blob_hash));
Self::spawn_extraction_with_delete_background(service, uuid, blob_hash.to_string());
}
fn on_file_deleted(&self, _file_id: &str) {