Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 3 additions & 19 deletions apps/locallibrary/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,6 @@ Open work left by the 2026-10-11 and 2026-10-12 sessions was added on 2026-10-12
`cargo about` in `locallibrary-release.yml`, plus rekordbox-pdb's MIT notice for the vendored
code and `crates/locallibrary/src/convert/template.pdb`. Keep the task here or in
CONTRIBUTING.md's release checklist, not both.
- [ ] Jobs (`refresh`, `prep`) re-plan later steps on any `StalePlan`, including a rekordbox export
mid-job, which breaks `op.rs`'s promise that the confirmed plan is what runs. Re-plan only
drives this job already wrote; refuse otherwise.
- [ ] `clean` backs up only the databases. It deletes only rekordbox's demo samples, which every
export brings back, yet `refresh` runs it after every export, so audio backups pile up and a
full drive can't be cleaned at all.
Expand Down Expand Up @@ -167,33 +164,20 @@ Open work left by the 2026-10-11 and 2026-10-12 sessions was added on 2026-10-12

## Open bugs and gaps

- [ ] Found while raising coverage, each a decision: a lenient replay that skips
`DeleteHistoryLists` only says "not found", dropping which list; merging the same drive
twice in folder mode makes "(2)" copies of every playlist; under `LOCALLIBRARY_VOLUMES`,
`format::disk_at` still runs read-only `diskutil info`/`lsblk` on the disk holding the test
folder.
- [ ] Redo: the backup an undo makes doesn't carry `edits.json` or the remembered player state, so
undo then redo leaves edits that replay won't redo, and restoring an older backup then a
newer one puts back edits the journal has forgotten.
- [ ] A replay cut off by a crash leaves its backup without `after`: running the replay again
finishes it, but taking it back needs `undo --force`.
- [ ] KEYCHAIN had an "interrupted operation" marker from 2026-10-04 16:56: run `locallibrary
status KEYCHAIN` next time it's mounted.
- [ ] `mix` matches one drive's library; tracks only on another drive (or only in rekordbox) show
as "not in the library". `collection` could supply rekordbox's tracks.
- [ ] `cue_edit.rs` sums its backup's size itself; it could use `backup::needed_bytes` like pack
and clean (its sum also counts staged analysis files).
- [ ] A sync or pack onto a stick with no `exportLibrary.db` takes on the source's player name and
colour, since there's none to keep. For set sticks, clearing it may be better.
- [ ] Type-check `docs/rpc/locallibrary.d.ts` with `tsc` in CI once something consumes the RPC.
- [ ] Apple signing secrets (`APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`,
`APPLE_SIGNING_IDENTITY`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`) need a paid
Developer ID; the shell installer and Homebrew run an unsigned CLI fine. Windows binaries are
unsigned too (dist supports Azure Artifact Signing).
- [ ] `collection export`'s space check counts `written.needed_bytes` but not `download_bytes`
(online-only Dropbox files that download onto the boot disk): both the fit warning
(`collection_export.rs:276`) and `apply`'s `NoSpace` check (`:473`) miss them. A full export
to `~/rekordbox-export` stopped at 1.8 of 25 GB.
- [ ] `collection export` copies online-only files one at a time, so one that takes minutes to
download stalls the export (it now says which file it's waiting on). Start the next few
downloads while one copies (reading a file's first byte is enough).
- [ ] Not investigated: `track show` may show the My Tags of another track that shares its file,
and `account logout` may forget the handle after a refused status check.
- [ ] Two property tests failed once and passed on reruns, with no saved case:
Expand Down
27 changes: 27 additions & 0 deletions apps/locallibrary/changes/drive-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
### Fixed

- `refresh` and `prep` plan a step again only when the drive is as the job's own earlier steps
left it. Anything else that changed it since the plan was confirmed (a rekordbox export
mid-job) stops the job with the stale plan, as a single apply does, instead of being planned
over and applied unconfirmed.
- A backup carries the drive's recorded edits and the player name and colour it remembers, and
putting one back puts them back too: undo then redo, or an older backup then a newer one,
leaves `edits replay` and `refresh` knowing exactly the edits and name the drive has. The undo
plan says how many recorded edits go back on the journal. A replay cut off part way notes
what each step left in its backup, so it's undone without `--force`.
- A file copied, hashed or snapshotted in the second or two after it was written is no longer
taken as unchanged by its size and modification time alone (FAT32 keeps those to 2 s, so a
rewrite then can look the same): sync, pack, collection export and the after-a-set changes
compare it by content, and taking embedded art back out checks each file by content.
- A sync or pack onto a stick with no `exportLibrary.db` leaves it without a player name and
colour, where it took the source's, and the plan says so.
- A My Tag category numbered past what `exportExt.pdb` holds in a OneLibrary database no longer
makes an edit that plans cleanly and fails as it's applied: `exportExt.pdb` gets a free id, and
an id that can't fit is refused when planning.
- `collection export` counts the online-only files it downloads in its space check when their
cloud folder is on the target's disk, and says which file it's waiting on when one takes more
than ten seconds to download.
- A replay that skips deleting history lists none of which are on the drive names them.
Merging the same drive into a folder again adds nothing, where it made a "(2)" copy of every
playlist. Under `LOCALLIBRARY_VOLUMES`, the disk holding a drive folder isn't looked up with
`diskutil` or `lsblk`.
11 changes: 9 additions & 2 deletions apps/locallibrary/docs/rpc/locallibrary.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3585,6 +3585,12 @@ export type EmbeddedFile = {
/** The file once the art went in: it's only put back while it's still that. */
size: number;
mtime: number;
/**
* What it held once the art went in, by content: its size and mtime, taken moments after it
* was written, can't tell a rewrite in the same second or two (see [`fsutil::settled`]).
* Missing from embeds an earlier version made, which go by size and mtime alone.
*/
hash?: Hash | null;
};

export type Comparison = {
Expand Down Expand Up @@ -7961,8 +7967,9 @@ export type CollectionExportPlan = {
*/
unsaved: Unsaved | null;
/**
* Space the export needs on the target: the files it copies, its new databases and the
* backup of what it replaces.
* Space the export needs on the target: the files it copies, its new databases, the backup
* of what it replaces, and the online-only files it downloads when their cloud folder is on
* the same disk.
*/
needed_bytes: number;
free_space: number | null;
Expand Down
13 changes: 12 additions & 1 deletion apps/locallibrary/docs/rpc/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -15849,6 +15849,17 @@
"mtime": {
"type": "integer",
"format": "int64"
},
"hash": {
"anyOf": [
{
"$ref": "#/$defs/Hash"
},
{
"type": "null"
}
],
"description": "What it held once the art went in, by content: its size and mtime, taken moments after it\nwas written, can't tell a rewrite in the same second or two (see [`fsutil::settled`]).\nMissing from embeds an earlier version made, which go by size and mtime alone."
}
},
"required": [
Expand Down Expand Up @@ -30551,7 +30562,7 @@
"type": "integer",
"format": "uint64",
"minimum": 0,
"description": "Space the export needs on the target: the files it copies, its new databases and the\nbackup of what it replaces."
"description": "Space the export needs on the target: the files it copies, its new databases, the backup\nof what it replaces, and the online-only files it downloads when their cloud folder is on\nthe same disk."
},
"free_space": {
"type": [
Expand Down
6 changes: 3 additions & 3 deletions apps/locallibrary/tests/it/props_cli_human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,16 +339,16 @@ pub fn build(s: State) -> Env {
/// (by id too), recorded where `fx`'s were.
pub fn twin(fx: &Env) -> Env {
let root = fx.volumes.parent().unwrap();
// First every file a moment older than FAT's 2 s: the hash cache distrusts a hash taken within
// 2 s of a file's mtime, so until then the two copies, run side by side but not in step, could
// First every file settled: the hash cache distrusts a hash taken too soon after a file's mtime
// (`fsutil::settled`), so until then the two copies, run side by side but not in step, could
// count different files verified.
let secs = || filetime::FileTime::now().unix_seconds();
let mtimes = walkdir::WalkDir::new(root).into_iter().flatten().filter_map(|e| e.metadata().ok());
let now = secs();
let newest =
mtimes.map(|m| filetime::FileTime::from_last_modification_time(&m).unix_seconds()).filter(|t| *t <= now);
if let Some(newest) = newest.max() {
while secs() <= newest + 2 {
while !locallibrary::fsutil::settled(newest, secs()) {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
Expand Down
21 changes: 16 additions & 5 deletions crates/locallibrary/src/after_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ use crate::Result;
use crate::device::Device;
use crate::doctor::Databases;
use crate::fsutil;
use crate::hash;
use crate::model::{Library, LibraryDiff};
use crate::paths::FileClass;
use crate::progress::Ctx;
use crate::progress::{Ctx, Phase};
use crate::store::{Snapshot, SnapshotInfo, Store};

/// The library databases (their changes are judged through the library diff).
Expand Down Expand Up @@ -118,16 +119,26 @@ pub fn changes_since(store: &Store, device: &Device, ancestor: &Snapshot, ctx: &
let old_files: FxHashMap<String, &crate::tree::FileEntry> =
ancestor.files.iter().map(|f| (f.path.to_lowercase(), f)).collect();
let mut seen = FxHashSet::default();
let pulled = ancestor.created.timestamp();
for e in scanned.tree.library_entries() {
let key = e.path.to_lowercase();
seen.insert(key.clone());
let old = old_files.get(&key);
let changed = match old {
None => true,
Some(o) => match (o.hash, e.hash) {
(Some(a), Some(b)) => a != b,
_ => o.size != e.size || !fsutil::mtime_eq(o.mtime, e.mtime),
},
Some(o) => {
let stamped = o.size == e.size && fsutil::mtime_eq(o.mtime, e.mtime);
match (o.hash, e.hash) {
(Some(a), Some(b)) => a != b,
// Written moments before the snapshot, it may have changed since without its
// size or mtime showing it: by content.
(Some(a), None) if stamped && !fsutil::settled(o.mtime, pulled) => {
let path = scanned.tree.fs_path(&e.path);
!hash::hash_file(&path, ctx, Phase::Hash).is_ok_and(|now| now == a)
}
_ => !stamped || !fsutil::settled(o.mtime, pulled),
}
}
};
if !changed {
continue;
Expand Down
4 changes: 1 addition & 3 deletions crates/locallibrary/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1896,9 +1896,7 @@ pub fn artwork_unembed_plan(store: &Store, req: &UnembedRequest) -> Result<Plann
let (mut restored, mut skipped) = (Vec::new(), Vec::new());
for f in &backup.files {
match std::fs::metadata(&f.path) {
Ok(m) if m.len() == f.size && fsutil::mtime_eq(fsutil::mtime_secs(&m), f.mtime) => {
restored.push(f.path.clone())
}
Ok(m) if f.unchanged(&m) => restored.push(f.path.clone()),
Ok(_) => skipped.push(Skip::new(f.path.display().to_string(), "changed since the art went in")),
Err(_) => skipped.push(Skip::new(f.path.display().to_string(), "gone")),
}
Expand Down
77 changes: 71 additions & 6 deletions crates/locallibrary/src/api/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
//! runs, made by the same `*_plan` functions the single commands use, so one confirmation covers
//! them all. Applying runs the steps in order, each operation through its own apply and so
//! through the shared write routine. An operation whose drive an earlier step changed is planned
//! again on the drive as that step left it; the steps that only make sense after the others (the
//! pull and the sync) are planned as they run. The first step that fails stops the job, and the
//! report says what was done ([`JobReport::stopped`]).
//! again on the drive as that step left it, but only when nothing else changed it since: an
//! outside write (a rekordbox export mid-job) stops the job with the stale plan. The steps that
//! only make sense after the others (the pull and the sync) are planned as they run. The first
//! step that fails stops the job, and the report says what was done ([`JobReport::stopped`]).

use std::collections::BTreeSet;

Expand All @@ -34,6 +35,7 @@ use crate::edit::{self, Edit, EditOptions};
use crate::eject::EjectReport;
use crate::error::{Action, Reason};
use crate::fleet::{self, Role};
use crate::hash::Hash;
use crate::local_time;
use crate::op::{self, AnyPlanned, CloneTarget, Operation, Planned, Touched};
use crate::progress::{Ctx, Steps};
Expand Down Expand Up @@ -363,12 +365,15 @@ fn run_job(plan: &JobPlan, store: Option<&Store>, ctx: &Ctx<'_>) -> Result<JobRe
let steps = Steps::new(ctx, plan.running().map(|s| s.step.id()).collect());
let ctx = &steps.ctx();
let mut report = JobReport::default();
let mut left = Left::new(plan);
for step in plan.running() {
steps.begin(step.step.id());
let mut done = StepReport { step: step.step, done: Vec::new(), warnings: Vec::new(), reports: Vec::new() };
let mut outcome = Ok(true);
for w in &step.work {
outcome = plan.same_drives().and_then(|()| run_work(w, step, store, &mut done, &mut report, ctx));
left.before();
outcome = plan.same_drives().and_then(|()| run_work(w, step, store, &mut done, &mut report, &left, ctx));
left.after(w);
if !matches!(outcome, Ok(true)) {
break;
}
Expand All @@ -395,19 +400,79 @@ fn run_job(plan: &JobPlan, store: Option<&Store>, ctx: &Ctx<'_>) -> Result<JobRe
Ok(report)
}

/// What the job's own work left on what its plans made up front touch, measured around each
/// piece of work, so a plan refused as stale is planned again only when the job itself made it
/// so. A change from anywhere else (a rekordbox export mid-job) stops the job, as it would stop
/// a single apply.
struct Left<'p> {
/// The plans still to apply, with the states of what each touches as the job last left them
/// (`None` once something other than the job changed them).
plans: Vec<(&'p dyn AnyPlanned, Option<Vec<Hash>>)>,
}

impl<'p> Left<'p> {
fn new(plan: &'p JobPlan) -> Left<'p> {
let plans = plan
.running()
.flat_map(|s| &s.work)
.filter_map(|w| match w {
Work::Op { plan: Some(p), .. } => Some(p.as_ref()),
_ => None,
})
.map(|p| (p, Some(p.drives().iter().map(Touched::planned_state).collect())))
.collect();
Left { plans }
}

fn measure(plan: &dyn AnyPlanned) -> Vec<Hash> {
plan.drives().iter().map(Touched::measured).collect()
}

/// Before a piece of work: whatever changed since the job last looked, it didn't change.
fn before(&mut self) {
for (p, left) in &mut self.plans {
if left.as_ref().is_some_and(|l| *l != Self::measure(*p)) {
*left = None;
}
}
}

/// After `w`: what it left, for the plans still to apply.
fn after(&mut self, w: &Work) {
if let Work::Op { plan: Some(done), .. } = w {
self.plans.retain(|(p, _)| !std::ptr::addr_eq(*p, done.as_ref()));
}
for (p, left) in &mut self.plans {
if left.is_some() {
*left = Some(Self::measure(*p));
}
}
}

/// Whether everything `plan` touches is as the job's own work left it.
fn ours(&self, plan: &dyn AnyPlanned) -> bool {
let left = self.plans.iter().find(|(p, _)| std::ptr::addr_eq(*p, plan)).and_then(|(_, l)| l.as_ref());
left.is_some_and(|l| *l == Self::measure(plan))
}
}

/// Run one piece of a step. `Ok(false)` stops the job there (the check found problems).
fn run_work(
w: &Work,
step: &JobStep,
store: Option<&Store>,
done: &mut StepReport,
report: &mut JobReport,
left: &Left<'_>,
ctx: &Ctx<'_>,
) -> Result<bool> {
match w {
Work::Op { plan: Some(plan), again } => match apply_op(plan.as_ref(), step, store, done, ctx) {
// An earlier step changed the drive: the same operation, on the drive as it is now.
Err(Error::StalePlan(_)) => apply_op(again(store, ctx)?.as_ref(), step, store, done, ctx)?,
// An earlier step of this job changed the drive: the same operation, on the drive as
// that step left it.
Err(Error::StalePlan(_)) if left.ours(plan.as_ref()) => {
apply_op(again(store, ctx)?.as_ref(), step, store, done, ctx)?
}
r => r?,
},
Work::Op { plan: None, again } => apply_op(again(store, ctx)?.as_ref(), step, store, done, ctx)?,
Expand Down
Loading
Loading