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
12 changes: 12 additions & 0 deletions apps/locallibrary/changes/loc-235-merge-records-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
### Fixed

- `sync` goes ahead after a `merge` of two drives that both changed, as the docs say: a merge
that takes in all of the source now counts the target as newer, so the sync copies the merged
library over the source. The merge plan lists tracks both drives have whose details, file or
analysis differ (the target's stay, and the sync copies them over), and says when a merge
leaves some of the source out (`--only`, `--playlists none`, history lists without
`--history`), which leaves the drives diverged. Undoing the merge leaves them diverged too.

### Changed

- The merge plan's list of new tracks shows every one with `--all`, as `compare`'s lists do.
6 changes: 5 additions & 1 deletion apps/locallibrary/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,10 +305,14 @@ locallibrary library push /Volumes/NEW -l Home --snapshot 3f9a2c

```bash
locallibrary diff REKORDBOX BACKUP --summary # "both changed since they were last in sync (2026-09-30 21:04)"
locallibrary merge BACKUP REKORDBOX # keep both sets of new tracks
locallibrary merge BACKUP REKORDBOX --history # keep both sets of new tracks
locallibrary sync Home # then make them identical again
```

The merge plan lists tracks both drives have whose details differ: REKORDBOX's stay, and the
sync copies them to BACKUP. A merge that leaves some of BACKUP out (`--only`, `--playlists none`,
history lists without `--history`) leaves the drives diverged, and so does undoing it.

**A new drive**

```bash
Expand Down
13 changes: 13 additions & 0 deletions apps/locallibrary/docs/rpc/locallibrary.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2921,6 +2921,11 @@ export type LineageOp =
| "export"
/** Damaged files rewritten from a known-good copy (see [`crate::scrub`]). */
| "repair"
/**
* Another drive's state a merge took in whole, recorded before the merged state (see
* [`crate::merge`]).
*/
| "merged_in"
/** An operation from a newer version of locallibrary. */
| "other";

Expand Down Expand Up @@ -3906,6 +3911,14 @@ export type MergePlan = {
new_tracks: PlannedTrack[];
/** Source tracks already in the target. */
matched_tracks: number;
/** Those whose details, file or analysis differ: the target's stay as they are. */
differing_tracks: TrackChange[];
/**
* Whether the target holds everything the source has once merged (every track, its
* playlists and history), `differing_tracks` aside. Then the source's state goes into the
* target's lineage, so a `sync` afterwards copies the merged drive over the source.
*/
takes_in_source: boolean;
playlists: PlannedPlaylist[];
artwork_files: number;
copy_bytes: number;
Expand Down
18 changes: 18 additions & 0 deletions apps/locallibrary/docs/rpc/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -13794,6 +13794,11 @@
"const": "repair",
"description": "Damaged files rewritten from a known-good copy (see [`crate::scrub`])."
},
{
"type": "string",
"const": "merged_in",
"description": "Another drive's state a merge took in whole, recorded before the merged state (see\n[`crate::merge`])."
},
{
"type": "string",
"const": "other",
Expand Down Expand Up @@ -17058,6 +17063,17 @@
"minimum": 0,
"description": "Source tracks already in the target."
},
"differing_tracks": {
"type": "array",
"items": {
"$ref": "#/$defs/TrackChange"
},
"description": "Those whose details, file or analysis differ: the target's stay as they are."
},
"takes_in_source": {
"type": "boolean",
"description": "Whether the target holds everything the source has once merged (every track, its\nplaylists and history), `differing_tracks` aside. Then the source's state goes into the\ntarget's lineage, so a `sync` afterwards copies the merged drive over the source."
},
"playlists": {
"type": "array",
"items": {
Expand Down Expand Up @@ -17123,6 +17139,8 @@
"target",
"new_tracks",
"matched_tracks",
"differing_tracks",
"takes_in_source",
"playlists",
"artwork_files",
"copy_bytes",
Expand Down
47 changes: 35 additions & 12 deletions apps/locallibrary/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,12 +574,7 @@ pub fn comparison(c: &Comparison) {
let list = |label: &str, items: Vec<String>| {
if !items.is_empty() {
say!(" {label} ({}):", items.len());
for i in items.iter().take(crate::output::cap(10)) {
say!(" {i}");
}
if items.len() > crate::output::cap(10) {
say!(" {}", dim(format!("... and {} more", items.len() - 10)));
}
capped(&items, 10);
}
};
list("+ tracks", d.added_tracks.iter().map(|t| t.name.clone()).collect());
Expand Down Expand Up @@ -652,12 +647,29 @@ pub fn sync_report(r: &SyncReport) {
pub fn merge_plan(p: &MergePlan) {
say!("{} → {}", style(&p.source).bold(), p.target.display());
say!(" {} new track(s), {} already present, {}", p.new_tracks.len(), p.matched_tracks, bytes(p.copy_bytes));
for t in p.new_tracks.iter().take(15) {
let note = if t.renamed { format!(" (as {})", t.audio) } else { String::new() };
say!(" + {}{}", t.name, dim(note));
}
if p.new_tracks.len() > 15 {
say!(" {}", dim(format!("... and {} more", p.new_tracks.len() - 15)));
let new: Vec<String> = p
.new_tracks
.iter()
.map(|t| format!("+ {}{}", t.name, dim(if t.renamed { format!(" (as {})", t.audio) } else { String::new() })))
.collect();
capped(&new, 15);
if !p.differing_tracks.is_empty() {
say!(" {} already present differ; the target's stay as they are", p.differing_tracks.len());
}
let differing: Vec<String> = p
.differing_tracks
.iter()
.map(|t| {
let fields: Vec<&str> = t.fields.iter().map(|f| f.field.as_str()).collect();
format!("~ {} {}", t.track.name, dim(format!("({})", fields.join(", "))))
})
.collect();
capped(&differing, 15);
match p.takes_in_source {
true => say!(" {}", dim(format!("afterwards, sync copies the merged library to {}", p.source))),
false => {
say!(" {}", dim(format!("{} keeps what this merge leaves out, so sync still asks which way", p.source)))
}
}
for pl in &p.playlists {
let verb = if pl.create { "create" } else { "extend" };
Expand All @@ -677,6 +689,17 @@ pub fn merge_plan(p: &MergePlan) {
}
}

/// The first `n` of `lines` (all of them with `--all`), indented, then how many more there are.
fn capped(lines: &[String], n: usize) {
let shown = crate::output::cap(n);
for l in lines.iter().take(shown) {
say!(" {l}");
}
if lines.len() > shown {
say!(" {}", dim(format!("... and {} more", lines.len() - shown)));
}
}

pub fn merge_report(r: &MergeReport, target: &str) {
ok(format!(
"added {} track(s), {} playlist(s), {} playlist entries, {} artwork",
Expand Down
6 changes: 3 additions & 3 deletions benchmarks/budgets/alloc.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@
"measures": "Bytes allocated filtering 2,000 tracks by artist, tempo and genre."
},
"compare.2k.allocations": {
"limit": 69034,
"limit": 51034,
"unit": "allocations",
"measures": "Allocations made comparing two 2,000-track libraries."
},
"compare.2k.bytes": {
"limit": 5690400,
"limit": 5589600,
"unit": "bytes",
"measures": "Bytes allocated comparing two 2,000-track libraries."
},
Expand All @@ -54,7 +54,7 @@
"measures": "Bytes allocated planning a sync of two 500-track drives."
},
"check.500.allocations": {
"limit": 210117,
"limit": 205609,
"unit": "allocations",
"measures": "Allocations made checking two 500-track drives."
},
Expand Down
3 changes: 3 additions & 0 deletions crates/locallibrary/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,9 @@ pub fn restore_with(device: &mut Device, id: Option<&str>, force: bool, ctx: &Ct
let remembers = device.state().is_some_and(|s| s.player.is_some()) && carried(device, &info)?.is_none();
let shown = remembers.then(|| crate::player::db_identity(device.root())).flatten();
commit::commit(device, changes, opts, ctx)?;
// A merge it takes back no longer holds the other drive's state it took in.
device.state_mut()?.lineage.retain(|e| e.op != LineageOp::MergedIn || e.at < info.created);
device.save_state()?;
put_back_carried(device, &info, shown)?;
Ok(info)
}
Expand Down
8 changes: 8 additions & 0 deletions crates/locallibrary/src/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ pub(crate) struct Options<'a> {
pub lineage: bool,
/// The drive the content came from (merge).
pub source: Option<Uuid>,
/// That drive's state, when the write took all of it in (merge): recorded before the new
/// state, so the drive counts as having been through it.
pub held: Option<LineageEntry>,
/// Edits to add to the drive's journal, with what they did.
pub journal: Option<(&'a [Edit], &'a [String])>,
/// Files it writes in place of ones the databases name (artwork replaced in place), for the
Expand All @@ -211,6 +214,7 @@ impl Options<'_> {
undoes: None,
lineage: true,
source: None,
held: None,
journal: None,
in_place: Vec::new(),
}
Expand Down Expand Up @@ -409,6 +413,10 @@ pub(crate) fn commit(device: &mut Device, changes: Changes<'_>, opts: Options<'_
backup::seal(device, &id)?;
}
if opts.lineage {
// Stamped now, after the backup: putting that back drops it (see `backup::restore_with`).
if let Some(held) = opts.held {
device.state_mut()?.record(LineageEntry { at: Utc::now(), ..held });
}
let library = device.library()?;
let scanned = device.scan_fingerprinted(Some(&library), ctx)?;
done.fingerprint = scanned.fingerprint;
Expand Down
5 changes: 4 additions & 1 deletion crates/locallibrary/src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ pub enum LineageOp {
Export,
/// Damaged files rewritten from a known-good copy (see [`crate::scrub`]).
Repair,
/// Another drive's state a merge took in whole, recorded before the merged state (see
/// [`crate::merge`]).
MergedIn,
/// An operation from a newer version of locallibrary.
#[serde(other)]
Other,
Expand Down Expand Up @@ -144,7 +147,7 @@ impl PendingOp {
LineageOp::Artwork => Some("art"),
LineageOp::Restore => Some("undo"),
LineageOp::Repair => Some("drive verify --repair"),
LineageOp::Edit | LineageOp::Observed | LineageOp::Other => None,
LineageOp::Edit | LineageOp::Observed | LineageOp::MergedIn | LineageOp::Other => None,
};
let what = command.map_or("a locallibrary operation".into(), |c| format!("a `locallibrary {c}`"));
let about = if self.description.is_empty() { String::new() } else { format!(" ({})", self.description) };
Expand Down
Loading
Loading