- Add SaveMetaFn callback pattern: meta plugins receive a closure instead of
&Connection, enabling the same plugin code to work in local, client, and
server contexts (collect-to-Vec, collect-to-HashMap, or direct DB write)
- Client save now runs meta plugins locally during streaming (smart client
sets meta=false, server skips its own plugins)
- Add POST /api/item/{id}/update endpoint for re-running plugins on stored
content without downloading compressed data
- Add client update mode (--update with --meta-plugin flags)
- Extract shared utilities: stream_copy, print_serialized, build_path_table,
ensure_default_tag to reduce duplication across modes
- Add upsert_tag for idempotent tag addition (INSERT OR IGNORE)
- Add warn logging on save_meta lock failure in BaseMetaPlugin and MetaService
237 lines
6.9 KiB
Rust
237 lines
6.9 KiB
Rust
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<String>,
|
|
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<String> = 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<i64> {
|
|
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<Mutex<Vec<(String, String)>>> = 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(())
|
|
}
|