import: the whole feature, and releases put back together

Everything the import grew over the last week, landing in one go — the
window, the picker, the audition pen, apply, and the review that stands
between them.

The fold is the new part. A release filed as a container of parts arrived
as N albums, because the scan groups audio by its immediate parent and the
container holds none of its own: Analord's eleven volumes were eleven
records, and six more releases split the same way. Records now fold into
the release above them, and apply is handed an explicit part list — never a
recursive walk, which is what let one stray file swallow 288 descendants
and duplicate 9 GB.

Where releases live is read off the collection rather than assumed, so a
genre-nested library moves the answer instead of breaking; below a dominant
majority at one depth the pass refuses entirely, because no folding beats
wrong folding. Three vetoes independent of depth catch a misread level.
Every fold is listed in the review with a way out.

research/import/fold-audit.mjs imports the shipping module, so the audited
code is the running code: 9 folds, 0 swallows across four library shapes,
and none of the 148 artist folders touched. It was that harness that killed
the tempting version of this rule — matching a part against its container
folded 40 artist folders, Mac Demarco's 8 albums into one, because album
folders carry the artist's name.

The regression test found a live bug on the way: parts number from 01 each,
so flattening two discs collided, and "same name, same size" read the
second file as already-imported and dropped it while still counting it.
That reuse now also requires that this run has not already written the
path.

Also here: the picker's accent crossfades between records instead of
cutting through the app's blue (ownership moved to the window, which
survives the per-record re-key), the fullscreen viewer wears the album's
accent instead of the one it left behind in the portal, Skip defers a
record without deciding it, and Keep mine is gone — with the cards picked
field by field it was Next under another name.
This commit is contained in:
KeelrLeek committed 2026-08-10 07:55:59 -05:00
1 parent 4b30c5e573
commit e7d0971de2
23 files changed
+6498 -258

No files matched your search

+18 -2
View File
@@ -379,13 +379,29 @@ impl Client {
/// Start downloading `filename` (full remote path) from `username`.
/// Progress streams out as [`Event::Download`]. Returns the download id.
pub fn download(&self, username: &str, filename: &str, size: u64) -> u64 {
transfer::start_download(self.inner.clone(), username, filename, size, None)
transfer::start_download(self.inner.clone(), username, filename, size, None, |_| {})
}
/// Like [`download`](Self::download) but writing to an explicit local
/// path (used for stream-to-cache).
pub fn download_to(&self, username: &str, filename: &str, size: u64, target: PathBuf) -> u64 {
transfer::start_download(self.inner.clone(), username, filename, size, Some(target))
transfer::start_download(self.inner.clone(), username, filename, size, Some(target), |_| {})
}
/// `download_to`, with `claim` run on the new id BEFORE the transfer task
/// is spawned. A download emits `Connecting` the instant it starts, so a
/// caller that must recognise its own transfers — the import tool's
/// audition pen — has to be able to register the id first. Doing it on the
/// returned value is a race the emitter usually wins.
pub fn download_to_claimed(
&self,
username: &str,
filename: &str,
size: u64,
target: PathBuf,
claim: impl FnOnce(u64),
) -> u64 {
transfer::start_download(self.inner.clone(), username, filename, size, Some(target), claim)
}
/// Cancel an active download by id.
+5
View File
@@ -160,6 +160,11 @@ fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// The download staging pen is not part of anyone's library —
// half-finished transfers are not shareable records.
if path.file_name().is_some_and(|n| n == "(Cache)") {
continue;
}
walk(&path, base, root, index);
} else if is_audio(&path) {
let Ok(meta) = entry.metadata() else { continue };
+10
View File
@@ -57,10 +57,15 @@ pub(crate) fn start_download(
filename: &str,
size: u64,
target: Option<PathBuf>,
claim: impl FnOnce(u64),
) -> u64 {
let id = inner
.download_counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// Before the spawn below, never after: the task's first act is to emit
// Connecting, and a listener that has not yet claimed this id cannot tell
// the event is theirs.
claim(id);
let cancel = CancellationToken::new();
inner.downloads_by_file.lock().unwrap().insert(
(username.to_string(), filename.to_string()),
@@ -362,6 +367,11 @@ impl Download {
None => ("", self.filename.as_str()),
};
let mut dir = self.inner.downloads_dir.clone();
// Staging lives in ONE visible pen — "(Cache)" — never loose in the
// library root: a half-finished transfer is not a record, and a
// peer's username is not an artist. organize_download lifts the
// finished file out; cleanup_staging tidies what it leaves behind.
dir.push("(Cache)");
dir.push(sanitize(&self.username));
if !folder.is_empty() {
dir.push(sanitize(folder));