Thirty-nine spawn sites in dr-ui, and one in the Android entry point, called std::thread::spawn or a Builder of their own, and most of the threads they started were <unnamed> in a panic message or a profiler. Each now calls executors::spawn with its executor and a role, so the thread is named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open — and knows which executor it is on. The three that already set a name (automation, import, prefetch) keep their name as the role. Behaviour is unchanged: each job still gets a thread of its own when it starts, and spawn panics where std::thread::spawn did. The module's documentation now says how a job is assigned: by what it spends its time on, so a sweep that fetches bytes and then decodes them is Decode, and a sidecar write that touches the catalog is Network. Left as they were: the segmentation and refine workers in masks_ui.rs, which another change is reworking, and test-only threads.
194 lines
7.2 KiB
Rust
194 lines
7.2 KiB
Rust
//! Pushing local judgements out to XMP sidecars, and reloading sidecars a
|
|
//! person chose to trust by hand.
|
|
|
|
use crate::executors::{self, Executor};
|
|
use dr_catalog::Catalog;
|
|
use dr_sync::{Connection, RemoteBackend, RemoteId, RemotePath};
|
|
use std::path::PathBuf;
|
|
use std::sync::mpsc::Receiver;
|
|
|
|
/// TRACES: FR-CAT-13 | NFR-R4
|
|
/// One image's ratings, label and keywords, on their way to the `.xmp`
|
|
/// beside it.
|
|
///
|
|
/// Distinct from [`SidecarWrite`], which amends DarkRoom's own document
|
|
/// through the cache and the outbox. This is best-effort in the other
|
|
/// direction: the catalog and the `.drsc` are authoritative, the `.xmp` is a
|
|
/// courtesy to whatever else reads the folder, and a write that cannot
|
|
/// happen now is written again — from the catalog, whole — the next time
|
|
/// anything about the photograph is judged. So nothing is queued.
|
|
#[derive(Debug, Clone)]
|
|
pub struct XmpWrite {
|
|
pub image_path: String,
|
|
pub record: dr_xmp::Xmp,
|
|
}
|
|
|
|
/// TRACES: FR-CAT-13 | NFR-R4
|
|
/// Write each record into the XMP sidecar beside its image.
|
|
///
|
|
/// An existing sidecar of either spelling is rewritten in place, which is the
|
|
/// whole point of `dr_xmp::rewrite`: only the properties DarkRoom owns move,
|
|
/// and another application's settings, comments and namespaces come through
|
|
/// byte for byte. A photograph with neither gets a new file under Lightroom's
|
|
/// name. Reported once at the end, as the judgement writes are.
|
|
pub fn spawn_xmp_writes(conn: Connection, writes: Vec<XmpWrite>) -> Receiver<XmpMessage> {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
executors::spawn(Executor::Network, "xmp-write", move || {
|
|
let mut written = 0usize;
|
|
let mut failed = 0usize;
|
|
let mut last_error = None;
|
|
let rt = match crate::net_runtime::build() {
|
|
Ok(rt) => rt,
|
|
Err(e) => {
|
|
let _ = tx.send(XmpMessage::Finished {
|
|
written: 0,
|
|
failed: writes.len(),
|
|
last_error: Some(e.to_string()),
|
|
});
|
|
return;
|
|
}
|
|
};
|
|
rt.block_on(async {
|
|
let backend = match crate::remote::connect(&conn) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
failed = writes.len();
|
|
last_error = Some(e.to_string());
|
|
return;
|
|
}
|
|
};
|
|
for w in &writes {
|
|
match write_one_xmp(&*backend, w).await {
|
|
Ok(()) => written += 1,
|
|
Err(e) => {
|
|
log::warn!("xmp sidecar for {}: {e}", w.image_path);
|
|
last_error = Some(e);
|
|
failed += 1;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let _ = tx.send(XmpMessage::Finished {
|
|
written,
|
|
failed,
|
|
last_error,
|
|
});
|
|
});
|
|
rx
|
|
}
|
|
|
|
/// The outcome of a batch of XMP writes.
|
|
#[derive(Debug)]
|
|
pub enum XmpMessage {
|
|
Finished {
|
|
written: usize,
|
|
failed: usize,
|
|
last_error: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Read-modify-write one image's XMP sidecar on the server.
|
|
pub(super) async fn write_one_xmp(backend: &dyn RemoteBackend, w: &XmpWrite) -> Result<(), String> {
|
|
let [darktable, lightroom] = crate::xmp_sync::candidate_paths(&w.image_path);
|
|
// Whichever exists is the one rewritten; neither existing means the
|
|
// Lightroom spelling is created. An existing file this build cannot
|
|
// parse is left alone rather than replaced — it is somebody else's
|
|
// document, and a refusal is recoverable where an overwrite is not.
|
|
let mut target = lightroom.clone();
|
|
let mut existing: Option<String> = None;
|
|
for path in [&darktable, &lightroom] {
|
|
if let Ok(bytes) = backend
|
|
.get(&RemoteId::Path(RemotePath::new(path.clone())), None)
|
|
.await
|
|
{
|
|
if !bytes.is_empty() {
|
|
target = path.clone();
|
|
existing = Some(String::from_utf8_lossy(&bytes).into_owned());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let text = match existing {
|
|
Some(text) => {
|
|
// The file's caption, copyright and hierarchy come through: the
|
|
// catalog has nowhere to keep them, and a rewrite that said
|
|
// nothing about them would remove them.
|
|
let theirs = dr_xmp::Xmp::parse(&text)
|
|
.map_err(|e| format!("{target} is not a sidecar this build can read: {e}"))?;
|
|
let mut record = w.record.clone();
|
|
crate::xmp_sync::carry_through(&mut record, &theirs);
|
|
record
|
|
.rewrite(&text)
|
|
.map_err(|e| format!("{target} is not a sidecar this build can rewrite: {e}"))?
|
|
}
|
|
None => w.record.to_text(),
|
|
};
|
|
backend
|
|
.put(&RemotePath::new(target), text.into_bytes(), None)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// TRACES: FR-CAT-13
|
|
/// The offered reload: re-read the sidecars a person chose to trust, with
|
|
/// the sidecar winning. One fetch per path, the catalog opened on this
|
|
/// thread as the scan opens it.
|
|
pub fn spawn_xmp_reload(
|
|
conn: Connection,
|
|
root: String,
|
|
catalog_path: PathBuf,
|
|
paths: Vec<String>,
|
|
) -> Receiver<XmpMessage> {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
executors::spawn(Executor::Network, "xmp-reload", move || {
|
|
let mut written = 0usize;
|
|
let mut failed = 0usize;
|
|
let mut last_error = None;
|
|
let outcome: Result<(), String> = (|| {
|
|
let catalog = Catalog::open(&catalog_path).map_err(|e| e.to_string())?;
|
|
let root_id: i64 = catalog
|
|
.connection()
|
|
.query_row(
|
|
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
|
|
[&root],
|
|
|r| r.get(0),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
let rt = crate::net_runtime::build().map_err(|e| e.to_string())?;
|
|
rt.block_on(async {
|
|
let backend = crate::remote::connect(&conn).map_err(|e| e.to_string())?;
|
|
for path in &paths {
|
|
let fetched = backend
|
|
.get(&RemoteId::Path(RemotePath::new(path.clone())), None)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
.and_then(|bytes| {
|
|
let text = String::from_utf8_lossy(&bytes);
|
|
crate::xmp_sync::reload(catalog.connection(), root_id, path, &text)
|
|
});
|
|
match fetched {
|
|
Ok(taken) => written += taken.changed,
|
|
Err(e) => {
|
|
log::warn!("reloading {path}: {e}");
|
|
last_error = Some(e);
|
|
failed += 1;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
})
|
|
})();
|
|
if let Err(e) = outcome {
|
|
failed = paths.len();
|
|
last_error = Some(e);
|
|
}
|
|
let _ = tx.send(XmpMessage::Finished {
|
|
written,
|
|
failed,
|
|
last_error,
|
|
});
|
|
});
|
|
rx
|
|
}
|