use anyhow::{Context, Result}; use std::io::Read; use std::path::{Path, PathBuf}; use crate::common::PIPESIZE; use crate::config; use crate::db; use crate::services::compression_service::CompressionService; use crate::services::meta_service::MetaService; use clap::Command; use log::debug; use rusqlite::Connection; use std::sync::{Arc, Mutex}; /// Handles the update mode: modifies tags and metadata for an existing item by ID. /// /// This function processes a single item ID, updating its metadata based on `--meta` /// arguments and optionally replacing its tags with positional arguments. /// If the item's size is not set, it backfills it by streaming through the content file. /// /// # Arguments /// /// * `cmd` - Clap command for error handling. /// * `settings` - Global settings containing metadata and meta plugin config. /// * `ids` - List containing exactly one item ID. /// * `conn` - Database connection. /// * `data_path` - Path to data directory. /// /// # Returns /// /// `Result<()>` on success, or an error if the update fails. pub fn mode_update( cmd: &mut Command, settings: &config::Settings, ids: &mut [i64], tags: &mut Vec, conn: &mut Connection, data_path: PathBuf, ) -> Result<()> { if ids.len() != 1 { cmd.error( clap::error::ErrorKind::InvalidValue, "--update requires exactly one numeric ID", ) .exit(); } let item_id = ids[0]; // Look up the item let item = db::get_item(conn, item_id)?.ok_or_else(|| anyhow::anyhow!("Item {item_id} not found"))?; debug!("UPDATE: Found item {item_id}: {item:?}"); // Parse --meta arguments into set and delete lists let mut set_meta: Vec<(String, String)> = Vec::new(); let mut delete_keys: Vec = Vec::new(); for (key, value) in &settings.meta { match value { Some(v) => set_meta.push((key.clone(), v.clone())), None => delete_keys.push(key.clone()), } } // Apply metadata changes for (key, value) in &set_meta { debug!("UPDATE: Setting meta {key}={value}"); db::store_meta( conn, db::Meta { id: item_id, name: key.clone(), value: value.clone(), }, )?; } for key in &delete_keys { debug!("UPDATE: Deleting meta {key}"); db::query_delete_meta( conn, db::Meta { id: item_id, name: key.clone(), value: String::new(), }, )?; } // Replace tags if provided if !tags.is_empty() { debug!("UPDATE: Replacing tags with {:?}", tags); db::set_item_tags(conn, item.clone(), tags)?; } // Run meta plugins if --meta-plugin flags are provided let plugin_names = settings.meta_plugins_names(); if !plugin_names.is_empty() { debug!("UPDATE: Running meta plugins: {:?}", plugin_names); run_meta_plugins_on_item(conn, cmd, settings, &data_path, &item, item_id)?; } // Backfill size if not set let mut updated_item = item.clone(); if item.size.is_none() { debug!("UPDATE: Size not set, backfilling from content file"); if let Some(size) = compute_item_size(&data_path, &item) { debug!("UPDATE: Computed size: {size}"); updated_item.size = Some(size); db::update_item(conn, updated_item.clone())?; } } // Print confirmation if !settings.quiet { let mut parts = Vec::new(); if !set_meta.is_empty() { parts.push(format!("set {} metadata", set_meta.len())); } if !delete_keys.is_empty() { parts.push(format!("deleted {} metadata", delete_keys.len())); } if !tags.is_empty() { parts.push(format!("tags: {}", tags.join(" "))); } let action = if parts.is_empty() { "no changes".to_string() } else { parts.join(", ") }; eprintln!("KEEP: Updated item {item_id} ({action})"); } Ok(()) } /// Computes the decompressed size of an item by streaming through its content file. /// /// Reads the compressed file in PIPESIZE chunks and counts total decompressed bytes. /// Returns None if the file doesn't exist or decompression fails. fn compute_item_size(data_path: &Path, item: &db::Item) -> Option { let item_id = item.id?; let mut item_path = data_path.to_path_buf(); item_path.push(item_id.to_string()); if !item_path.exists() { debug!("UPDATE: Content file not found: {item_path:?}"); return None; } let compression_service = CompressionService::new(); let mut reader = match compression_service.stream_item_content(item_path, &item.compression) { Ok(r) => r, Err(e) => { debug!("UPDATE: Failed to open content stream: {e}"); return None; } }; let mut buffer = [0u8; PIPESIZE]; let mut total_bytes: i64 = 0; loop { match reader.read(&mut buffer) { Ok(0) => break, Ok(n) => { total_bytes += n as i64; } Err(e) => { debug!("UPDATE: Error reading content: {e}"); return None; } } } Some(total_bytes) } /// Runs meta plugins on an existing item's content and stores the results. fn run_meta_plugins_on_item( conn: &mut Connection, cmd: &mut Command, settings: &config::Settings, data_path: &Path, item: &db::Item, item_id: i64, ) -> Result<()> { let mut item_path = data_path.to_path_buf(); item_path.push(item_id.to_string()); if !item_path.exists() { debug!("UPDATE: Content file not found: {item_path:?}"); return Ok(()); } // Collect metadata in memory let collected_meta: Arc>> = Arc::new(Mutex::new(Vec::new())); let collector = collected_meta.clone(); let save_meta: crate::meta_plugin::SaveMetaFn = Arc::new(Mutex::new(move |name: &str, value: &str| { if let Ok(mut v) = collector.lock() { v.push((name.to_string(), value.to_string())); } })); let meta_service = MetaService::new(save_meta); let mut plugins = meta_service.get_plugins(cmd, settings); if plugins.is_empty() { return Ok(()); } let compression_service = CompressionService::new(); let mut reader = compression_service.stream_item_content(item_path, &item.compression)?; meta_service.initialize_plugins(&mut plugins); crate::common::stream_copy(&mut reader, |chunk| { meta_service.process_chunk(&mut plugins, chunk); Ok(()) })?; meta_service.finalize_plugins(&mut plugins); // Write collected plugin metadata to DB if let Ok(entries) = collected_meta.lock() { for (name, value) in entries.iter() { db::add_meta(conn, item_id, name, value)?; } } Ok(()) }