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

+28
View File
@@ -70,6 +70,8 @@ pub struct TagWrite {
pub title: Option<String>,
pub track_no: Option<u32>,
pub year: Option<u32>,
pub label: Option<String>,
pub genre: Option<String>,
}
/// Cover bytes come from several providers; PNG originals exist (Cover Art
@@ -117,6 +119,12 @@ pub fn write_tags(
if let Some(v) = &write.album_artist {
tag.insert_text(lofty::tag::ItemKey::AlbumArtist, v.clone());
}
if let Some(v) = &write.genre {
tag.set_genre(v.clone());
}
if let Some(v) = &write.label {
tag.insert_text(lofty::tag::ItemKey::Label, v.clone());
}
if let Some(bytes) = art_jpeg {
if replace_art {
@@ -139,6 +147,26 @@ pub fn write_tags(
Ok(())
}
/// The embedded artwork's bytes, front cover first, any picture otherwise.
///
/// This is the import tool's "yours" side: in the reference library 449 of
/// 515 records carry their best cover INSIDE the files, and a scan that only
/// looks at loose images sees Windows Media Player's 200px cache instead.
/// lofty parses the whole tag, so the 20MB tags that defeated a capped read
/// (a 586MB single-file live set) are handled like any other.
pub fn extract_art(path: &Path) -> Result<Option<Vec<u8>>> {
let tagged = Probe::open(path)?.read()?;
let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else {
return Ok(None);
};
let pics = tag.pictures();
let best = pics
.iter()
.find(|p| p.pic_type() == PictureType::CoverFront)
.or_else(|| pics.first());
Ok(best.map(|p| p.data().to_vec()))
}
/// Replace any existing embedded pictures with the given front cover.
pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> {
let mut tagged = Probe::open(path)?.read()?;