# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/mcp.md
#	docs/parity-checklist.md
This commit is contained in:
Brandon Thomas
2026-10-14 21:55:01 -04:00
43 changed files with 689 additions and 58 deletions
+40 -21
View File
@@ -201,12 +201,16 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts:
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`], and a
/// preview-only photo this version hasn't checked yet: its stamp is refreshed so it isn't probed
/// again (see [`lightcraft_catalog::Photo::preview_checked`]).
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
let preview_checked = lightcraft_catalog::preview_stamp(&info.preview_only);
let same = info.content_hash == ph.content_hash
&& info.file_size == ph.file_size
&& (info.width, info.height) == (ph.width, ph.height)
&& info.preview_only == ph.preview_only;
&& info.preview_only == ph.preview_only
&& preview_checked == ph.preview_checked;
(!same).then_some(Op::SetContent {
id,
width: info.width,
@@ -214,6 +218,7 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo
file_size: info.file_size,
content_hash: info.content_hash,
preview_only: info.preview_only,
preview_checked,
})
}
@@ -271,6 +276,37 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
}
/// The ops that bring photo `id` and its virtual copies up to date with what their file is now
/// (`info`, a fresh probe), appended to `ops`; the photos they change are appended to `changed`.
/// Reload and the preview-only recheck (`library.recheckPreviewOnly`) share it.
pub(crate) fn reload_ops(
cat: &lightcraft_catalog::Catalog,
id: lightcraft_catalog::PhotoId,
info: &crate::media::ProbeInfo,
ops: &mut Vec<Op>,
changed: &mut Vec<lightcraft_catalog::PhotoId>,
) {
let Some(ph) = cat.photo(id) else { return };
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, info);
let mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, info)).chain(as_shot_wb_op(id, ph, info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
changed.push(id);
}
// virtual copies share the file
for c in cat.photos().filter(|c| c.copy_of == Some(id)) {
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, info)).chain(as_shot_wb_op(c.id, c, info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
changed.push(c.id);
}
}
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
@@ -286,25 +322,8 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, &info);
let mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
reloaded.push(id);
}
// virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
reloaded.push(c.id);
}
}
let Ok(info) = info else { continue };
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
}
for id in &reloaded {
s.media.forget(*id);
+2
View File
@@ -34,6 +34,7 @@ mod preset_files;
pub mod previews;
mod query;
mod slideshow;
pub mod recheck;
mod sync;
mod xmp;
@@ -159,6 +160,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
v.extend(recheck::specs());
v.extend(sync::specs());
v.extend(metadata::specs());
v.extend(geocode::specs());
+129
View File
@@ -0,0 +1,129 @@
//! Preview-only raws rechecked after an update (issue #662): a raw imported while its format or
//! variant couldn't be decoded keeps [`Photo::preview_only`] until something re-reads its file.
//! Each flag is stamped with the app version that set it ([`Photo::preview_checked`]); when a
//! library opens in another version, its stale preview-only photos are probed again and get
//! exactly what Reload from Disk would give them: a file that decodes now loses the flag, one that
//! still doesn't gets the new reason and a fresh stamp, so it isn't probed again.
//!
//! The work is split so the app can probe on a worker thread: [`stale`] lists what to probe (no
//! file-system calls), [`probe`] reads the files without the session, and [`apply`] applies the
//! results as LightCraft's own bookkeeping (journaled, not an undo step). Agents and tests use
//! `library.recheckPreviewOnly`, which does all three at once.
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
use crate::media::{FileProbe, ProbeInfo};
use crate::{Result, Session};
/// A preview-only photo to probe, and what the library knew about it when it was listed (so a
/// result for a photo that changed meanwhile is dropped).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stale {
pub id: PhotoId,
pub path: String,
preview_only: Option<String>,
preview_checked: String,
}
impl Stale {
fn of(p: &Photo, path: &str) -> Stale {
Stale { id: p.id, path: path.to_string(), preview_only: p.preview_only.clone(), preview_checked: p.preview_checked.clone() }
}
/// The photo is still as it was listed: same file, flag and stamp.
fn current(&self, p: &Photo) -> bool {
crate::cmd::missing::checked_path(p) == Some(self.path.as_str())
&& p.preview_only == self.preview_only
&& p.preview_checked == self.preview_checked
}
}
/// The library's preview-only photos whose flag another app version set (or every preview-only
/// photo, with `all`): library photos with a file, not virtual copies (they follow the photo they
/// were copied from). No file-system calls.
pub fn stale(cat: &Catalog, all: bool) -> Vec<Stale> {
cat.photos()
.filter(|p| p.copy_of.is_none() && p.preview_only.is_some() && (all || p.preview_only_stale()))
.filter_map(|p| crate::cmd::missing::checked_path(p).map(|path| Stale::of(p, path)))
.collect()
}
/// The session's file probe, to [`probe`] with on a worker thread; `None` when the session has no
/// file access (a demo or browser session), where nothing can be rechecked.
pub fn file_probe(s: &Session) -> Option<FileProbe> {
s.media.file_probe.clone()
}
/// Probe each listed photo's file as Reload would. Doesn't touch the session.
pub fn probe(probe: &FileProbe, stale: Vec<Stale>) -> Vec<(Stale, std::result::Result<ProbeInfo, String>)> {
let paths: Vec<String> = stale.iter().map(|x| x.path.clone()).collect();
stale.into_iter().zip(crate::import::probe_with(probe, &paths)).collect()
}
/// What a recheck changed.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Rechecked {
/// Photos brought up to date (virtual copies included).
pub updated: Vec<PhotoId>,
/// Of those, the ones that develop from their raw data now.
pub cleared: Vec<PhotoId>,
}
impl Rechecked {
pub fn to_json(&self) -> Value {
json!({"updated": self.updated.iter().map(|i| i.0).collect::<Vec<_>>(), "cleared": self.cleared.iter().map(|i| i.0).collect::<Vec<_>>()})
}
}
/// Apply [`probe`]'s results: each photo gets the ops Reload would give it, all in one batch that is
/// journaled but isn't an undo step. A file that can't be read (missing, offline) changes nothing,
/// so it is tried again next time; a photo that changed since it was listed (relinked, reloaded,
/// removed) is skipped.
pub fn apply(s: &mut Session, results: Vec<(Stale, std::result::Result<ProbeInfo, String>)>) -> Result<Rechecked> {
let mut ops = Vec::new();
let mut out = Rechecked::default();
for (item, info) in results {
let Ok(info) = info else { continue };
if !s.catalog.photo(item.id).is_some_and(|p| item.current(p)) {
continue;
}
let first = out.updated.len();
crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut out.updated);
if info.preview_only.is_none() {
out.cleared.extend_from_slice(&out.updated[first..]);
}
}
if !ops.is_empty() {
s.apply_system(Op::Batch { ops })?;
}
for id in &out.updated {
s.media.forget(*id);
}
Ok(out)
}
fn recheck(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.recheckPreviewOnly";
let all = p.get("all").and_then(Value::as_bool).unwrap_or(false);
// without file access every probe "succeeds" with nothing known, which would clear every flag
let fp = file_probe(s).ok_or_else(|| bad(C, "this session has no file access"))?;
let listed = stale(&s.catalog, all);
let n = listed.len();
let mut r = apply(s, probe(&fp, listed))?.to_json();
r["checked"] = json!(n);
Ok(r)
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"library.recheckPreviewOnly",
"Recheck Preview-Only Raws",
[],
None,
"{all?: bool} — probe again the raws shown from their embedded preview whose flag another LightCraft version set (the app does this in the background when a library opens; `all` rechecks every preview-only raw, e.g. when testing a decoder build of the same version), and bring each up to date as Reload from Disk would; not an undo step, develop settings and history are kept; files that can't be read are left for next time → {checked, updated: [ids], cleared: [ids] (develop from their raw data now)}",
always,
recheck
)]
}
+8 -2
View File
@@ -1048,7 +1048,7 @@ impl crate::Session {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the
/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides
/// them; an exported `travel|Italy|Rome` comes back as itself.
@@ -1633,7 +1633,7 @@ mod tests {
}
/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box.
fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec();
@@ -2157,6 +2157,9 @@ mod tests {
let p = s.catalog.photo(id).unwrap().clone();
let why = p.preview_only.clone().expect("marked preview only");
assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}");
// stamped with the version that decided it, so a later version rechecks it (#662)
assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION);
assert!(!p.preview_only_stale());
assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)");
assert!(!p.develops_raw());
// rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults
@@ -2174,14 +2177,17 @@ mod tests {
let mut s = crate::Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION);
// the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back
std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
assert_eq!(r["reloaded"], json!([id.0]), "{r}");
assert!(s.catalog.photo(id).unwrap().develops_raw());
assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag");
assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION);
// relinking to a file that decodes clears it too
let dng = files.join("DSC_0001.dng");
std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
+7 -1
View File
@@ -470,6 +470,12 @@ pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo
probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel)
}
/// Probe files with `probe` (a clone of the session's file probe), off the session: on a worker thread.
pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
let progress = ScanProgress::default();
probe_all(Some(probe), paths, &progress.done, &progress.cancel)
}
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
/// `cancel` is set: the files not reached come back as errors.
fn probe_all(
@@ -1386,7 +1392,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
p.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
p.set_preview_only(info.preview_only.clone());
apply_import_defaults(s, &mut p);
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
+4 -2
View File
@@ -585,8 +585,8 @@ impl Session {
}
/// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background
/// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the
/// redo stack alone.
/// scan, a raw's as-shot white read by a decode, raws rechecked after an update): journaled like any op, but not
/// an undo step, and it leaves the redo stack alone.
pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> {
let fwd = op.clone();
self.catalog.apply(op)?;
@@ -1090,6 +1090,8 @@ mod tests_presets_scale;
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_recheck;
#[cfg(test)]
mod tests_segment;
#[cfg(test)]
mod tests_settings_files;
+1
View File
@@ -1749,6 +1749,7 @@ mod thumbnail_hash_tests {
file_size: 99,
content_hash: Some("replacement".into()),
preview_only: None,
preview_checked: String::new(),
})
.unwrap();
s.media.forget(PhotoId(1));
+1
View File
@@ -705,6 +705,7 @@ fn a_photo_whose_file_changed_has_no_picture_until_it_is_made_again() {
file_size: p.file_size + 1,
content_hash: Some("ffffffffffffffffffffffffffffffff".into()),
preview_only: None,
preview_checked: String::new(),
},
)
.unwrap();
+155
View File
@@ -0,0 +1,155 @@
//! Issue #662: preview-only raws are rechecked when a library opens in another app version.
use std::path::PathBuf;
use lightcraft_catalog::{APP_VERSION, Op, PhotoId};
use serde_json::json;
use crate::Session;
use crate::cmd::recheck;
use crate::files::tests::cr3_with_preview;
/// A version other than this build's: the flag was set by an older (or newer) LightCraft.
const OTHER: &str = "0.5.0";
struct Lib {
s: Session,
dir: PathBuf,
lib: PathBuf,
file: PathBuf,
id: PhotoId,
}
impl Drop for Lib {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// A library with one preview-only CR3, its flag stamped by [`OTHER`] as a library saved by
/// another version would be.
fn library(name: &str) -> Lib {
let dir = std::env::temp_dir().join(format!("lc-recheck-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (lib, files) = (dir.join("lib"), dir.join("files"));
std::fs::create_dir_all(&files).unwrap();
let file = files.join("IMG_0001.cr3");
std::fs::write(&file, cr3_with_preview(96, 64)).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = PhotoId(r["imported"][0].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap().clone();
assert!(p.preview_only.is_some() && !p.preview_only_stale(), "just imported: decided by this version");
s.apply_system(Op::SetContent {
id,
width: p.width,
height: p.height,
file_size: p.file_size,
content_hash: p.content_hash.clone(),
preview_only: p.preview_only.clone(),
preview_checked: OTHER.into(),
})
.unwrap();
assert!(s.catalog.photo(id).unwrap().preview_only_stale());
Lib { s, dir, lib, file, id }
}
fn decodable_now(x: &Lib) {
std::fs::write(&x.file, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
}
/// The acceptance test: a stale flag on a file that decodes now is cleared without a manual
/// Reload, adds no undo step, leaves develop settings and history alone, and survives a reopen.
#[test]
fn stale_flag_on_a_file_that_decodes_now_is_cleared() {
let mut x = library("decodes");
x.s.execute("develop.set", &json!({"ids": [x.id.0], "control": "light.exposure", "value": 0.5})).unwrap();
let before = x.s.catalog.photo(x.id).unwrap().clone();
let undo = x.s.undo.len();
decodable_now(&x);
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
assert_eq!(r, json!({"checked": 1, "updated": [x.id.0], "cleared": [x.id.0]}));
let p = x.s.catalog.photo(x.id).unwrap().clone();
assert_eq!(p.preview_only, None);
assert_eq!(p.preview_checked, "", "the stamp goes with the flag");
assert!(p.develops_raw());
assert_eq!(x.s.undo.len(), undo, "not an undo step");
assert_eq!(*p.develop, *before.develop);
assert_eq!(p.history.len(), before.history.len());
// journaled: a reopen keeps it
x.s.persist().unwrap();
x.s = Session::new().with_fs();
x.s.open_library(&x.lib, false).unwrap();
assert_eq!(x.s.catalog.photo(x.id).unwrap().preview_only, None);
}
/// A file that still doesn't decode is stamped by this version (with the decoder's current
/// reason), so the next open doesn't probe it again.
#[test]
fn still_undecodable_file_gets_a_fresh_stamp_and_isnt_probed_again() {
let mut x = library("still");
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
assert_eq!(r, json!({"checked": 1, "updated": [x.id.0], "cleared": []}));
let p = x.s.catalog.photo(x.id).unwrap();
assert!(p.preview_only.is_some());
assert_eq!(p.preview_checked, APP_VERSION);
assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap()["checked"], 0);
// `all` ignores stamps (testing a decoder build of the same version)
assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({"all": true})).unwrap()["checked"], 1);
}
/// A file that can't be read (unplugged drive) changes nothing and is tried again next time.
#[test]
fn missing_file_keeps_its_flag_and_stamp_and_is_retried() {
let mut x = library("missing");
std::fs::remove_file(&x.file).unwrap();
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
assert_eq!(r, json!({"checked": 1, "updated": [], "cleared": []}));
let p = x.s.catalog.photo(x.id).unwrap();
assert!(p.preview_only.is_some());
assert_eq!(p.preview_checked, OTHER);
// back again, and decodable now
decodable_now(&x);
assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap()["cleared"], json!([x.id.0]));
}
/// A photo that changed between the probe and applying it (here: relinked) is left alone.
#[test]
fn photo_changed_while_probing_is_skipped() {
let mut x = library("changed");
let listed = recheck::stale(&x.s.catalog, false);
assert_eq!(listed.iter().map(|i| i.id).collect::<Vec<_>>(), vec![x.id]);
let probed = recheck::probe(&recheck::file_probe(&x.s).unwrap(), listed);
decodable_now(&x);
let other = x.dir.join("files").join("IMG_0001-moved.cr3");
std::fs::write(&other, cr3_with_preview(96, 64)).unwrap();
x.s.execute("photo.relink", &json!({"id": x.id.0, "path": other.to_string_lossy()})).unwrap();
let relinked = x.s.catalog.photo(x.id).unwrap().clone();
assert_eq!(recheck::apply(&mut x.s, probed).unwrap(), recheck::Rechecked::default());
assert_eq!(*x.s.catalog.photo(x.id).unwrap().clone(), *relinked);
}
/// Virtual copies share the file: they follow the photo they were copied from.
#[test]
fn virtual_copies_follow_their_photo() {
let mut x = library("copies");
let r = x.s.execute("photo.virtualCopy", &json!({"ids": [x.id.0]})).unwrap();
let copy = PhotoId(r["ids"][0].as_u64().unwrap());
assert!(x.s.catalog.photo(copy).unwrap().preview_only.is_some());
assert_eq!(recheck::stale(&x.s.catalog, true).len(), 1, "copies aren't probed on their own");
decodable_now(&x);
let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap();
assert_eq!(r["cleared"], json!([x.id.0, copy.0]));
assert!(x.s.catalog.photo(copy).unwrap().develops_raw());
}
/// Without file access every probe would "succeed" knowing nothing and clear every flag: refused.
#[test]
fn session_without_file_access_refuses() {
let mut x = library("nofs");
x.s.media.file_probe = None;
let e = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap_err().to_string();
assert!(e.contains("no file access"), "{e}");
assert!(x.s.catalog.photo(x.id).unwrap().preview_only.is_some());
}