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

+5
View File
@@ -13,6 +13,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dev-dependencies]
# Import-apply tests build a whole source/pen/library bed on disk; already in
# the lock file via the dependency tree, so this adds no download.
tempfile = "3"
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-log = "2"
+74
View File
@@ -665,6 +665,80 @@ pub async fn connect_and_login(
}
}
}
} else if crate::import::is_audition(update.id) {
// Audition downloads are candidate versions under
// review in the (Cache) pen — they stay OUT of the
// library pipeline; the import window watches them.
use soulseek_client::DownloadState as DS;
// WHICH record and WHICH track, straight from the
// registry — looked up before audition_done retires
// the id. The UI matches on this, never on the id:
// the first events of a transfer are emitted before
// import_audition has returned its ids at all.
let (au_rel, au_idx) = crate::import::audition_of(update.id)
.map(|(r, i)| (r, i as i64))
.unwrap_or_else(|| (String::new(), -1));
match &update.state {
DS::Complete { local_path } => {
let _ = event_app.emit(
"import:audition",
serde_json::json!({
"id": update.id,
"rel": au_rel,
"idx": au_idx,
"localPath": local_path,
"remote": update.filename,
"done": true,
}),
);
crate::import::audition_done(update.id);
}
DS::Failed { reason } => {
let _ = event_app.emit(
"import:audition",
serde_json::json!({
"id": update.id,
"rel": au_rel,
"idx": au_idx,
"remote": update.filename,
"failed": reason,
}),
);
crate::import::audition_done(update.id);
}
DS::Cancelled => {
crate::import::audition_done(update.id);
}
// In flight. The picker's row can only say what it
// is told, and "arriving…" was all it had: the
// phase, the QUEUE PLACE and the speed were all
// known here and thrown away. A place that ticks
// down is the difference between waiting and
// wondering whether anything is happening.
rest => {
let (phase, place) = match rest {
DS::Connecting => ("connecting", None),
DS::Requested => ("requested", None),
DS::Queued { place } => ("queued", *place),
DS::Transferring => ("transferring", None),
_ => ("working", None),
};
let _ = event_app.emit(
"import:audition",
serde_json::json!({
"id": update.id,
"rel": au_rel,
"idx": au_idx,
"remote": update.filename,
"received": update.received,
"total": update.total,
"speedBps": update.speed_bps,
"phase": phase,
"place": place,
}),
);
}
}
} else {
let _ = event_app.emit("soulseek:download", download_payload(&update));
// Completed real downloads go through the tag/art
+6
View File
@@ -84,6 +84,12 @@ pub struct AppConfig {
/// tag values are flattened to `_`).
#[serde(default)]
pub library_layout: Option<String>,
/// The managed library root the import tool copies into. Deliberately no
/// default: the Source step requires choosing it before Scan enables, and
/// it must never silently coincide with the download/staging dir, whose
/// prune sweeps would eat imported files.
#[serde(default)]
pub library_dir: Option<String>,
/// Extra folders to share (the downloads folder is always shared).
#[serde(default)]
pub shared_folders: Vec<String>,
File diff suppressed because it is too large. Load diff
+14
View File
@@ -3,6 +3,7 @@ mod config;
mod cover_logo;
mod diagnostics;
mod hero_crop;
mod import;
mod hero_detector;
mod loudness;
mod pipeline;
@@ -99,6 +100,19 @@ pub fn run() {
commands::add_to_playlist,
commands::remove_from_playlist,
commands::catalog_search,
import::import_scan,
import::import_session_get,
import::import_session_set,
import::import_session_clear,
import::import_get_dest,
import::import_set_dest,
import::import_apply,
import::import_identify,
import::import_cover_report,
import::import_local_art,
import::import_audition,
import::import_probe_file,
pipeline::rescan_library,
commands::catalog_artist,
commands::catalog_artist_art,
commands::catalog_artist_logos,
+214 -40
View File
@@ -11,7 +11,11 @@ use library::Library;
use metadata::{AlbumMeta, MetadataClient};
use tagger::{TagSummary, TagWrite};
const AUDIO_EXTS: [&str; 8] = ["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma"];
// aiff/aif joined for the import tool: the reference library carries 13 AIFF
// tracks that a scan would otherwise silently drop, and "silently" is the
// problem — the album imports incomplete with no warning.
pub(crate) const AUDIO_EXTS: [&str; 10] =
["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "aiff", "aif"];
pub struct LibraryState {
pub library: Arc<Library>,
@@ -20,7 +24,7 @@ pub struct LibraryState {
/// identity warmer only works while this has been quiet, so background
/// warmth never queues ahead of a page the user is actually looking at.
pub(crate) catalog_activity: std::sync::atomic::AtomicU64,
art_dir: PathBuf,
pub(crate) art_dir: PathBuf,
/// (artist, album) lowercase → resolved metadata, deduping MB lookups.
album_cache: tokio::sync::Mutex<HashMap<(String, String), Option<AlbumMeta>>>,
/// Serializes art fetch per album key.
@@ -223,13 +227,106 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
})
.await;
// Old staging leftovers: folders emptied by organizing are removed
// on the fly now, but sweep empties left behind by earlier runs.
// on the fly now, but sweep empties left behind by earlier runs —
// dead transfers first, so the folders they emptied qualify.
sweep_stale_partials(&downloads_dir(&handle));
sweep_stale_auditions(&downloads_dir(&handle));
sweep_empty_dirs(&downloads_dir(&handle), 0);
scan_downloads(&handle).await;
});
Ok(())
}
/// A `.partial` in the "(Cache)" staging pen whose bytes last moved more
/// than this long ago is a transfer that died — an active download's mtime
/// is always fresh, and a retry recreates its file from wherever the peer
/// resumes.
const STALE_PARTIAL_SECS: u64 = 3 * 24 * 60 * 60;
fn sweep_stale_partials(root: &Path) {
fn walk(dir: &Path, removed: &mut u32) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, removed);
} else if p.extension().is_some_and(|x| x == "partial") {
let stale = e
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age.as_secs() > STALE_PARTIAL_SECS);
if stale && std::fs::remove_file(&p).is_ok() {
*removed += 1;
}
}
}
}
let mut removed = 0u32;
walk(&root.join("(Cache)"), &mut removed);
if removed > 0 {
log::info!("staging sweep: removed {removed} stale .partial file(s)");
}
}
/// An audition album nobody claimed within this window is a version the
/// review looked at and walked away from. Longer than the .partial window on
/// purpose: these are COMPLETE albums, and the whole point of caching them is
/// that a mind changed a few days later costs nothing.
const STALE_AUDITION_SECS: u64 = 7 * 24 * 60 * 60;
/// Reclaim `(Cache)\_audition\<album>\` folders that have sat untouched past
/// the retention window. Claiming one moves it into the library, so anything
/// still here was passed over; the newest file in the folder is what ages,
/// so a transfer still filling in is never swept out from under itself.
pub(crate) fn sweep_stale_auditions(root: &Path) {
let pen = root.join("(Cache)").join("_audition");
let Ok(entries) = std::fs::read_dir(&pen) else { return };
let (mut albums, mut bytes) = (0u32, 0u64);
for e in entries.flatten() {
let dir = e.path();
if !dir.is_dir() {
continue;
}
// The FOLDER's own mtime is the floor. import_audition creates the
// directory before the first byte lands, so an empty one is usually a
// transfer that just started — ageing it from the epoch would have a
// Rescan in that window delete the pen out from under it. An empty
// folder that has genuinely sat a week still ages out on its own date.
let mut newest = e
.metadata()
.and_then(|m| m.modified())
.unwrap_or_else(|_| std::time::SystemTime::now());
let mut size = 0u64;
// Every file in the folder ages it, strays included — a peer's .m3u
// lands at the same moment as the audio, so nothing here can hold a
// spent folder open.
for f in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
if let Ok(m) = f.metadata() {
size += m.len();
if let Ok(t) = m.modified() {
newest = newest.max(t);
}
}
}
let stale = newest
.elapsed()
.ok()
.is_some_and(|age| age.as_secs() > STALE_AUDITION_SECS);
if stale && std::fs::remove_dir_all(&dir).is_ok() {
albums += 1;
bytes += size;
}
}
if albums > 0 {
log::info!(
"audition sweep: reclaimed {albums} unclaimed album(s), {:.1} MB",
bytes as f64 / 1_048_576.0
);
}
}
/// Remove empty directory trees below `dir` (post-order; `dir` itself stays).
fn sweep_empty_dirs(dir: &Path, depth: u8) {
if depth > 6 {
@@ -253,6 +350,25 @@ fn downloads_dir(app: &tauri::AppHandle) -> PathBuf {
crate::config::download_dir(app, &cfg)
}
/// The Library page's Rescan: what startup does — drop rows whose files
/// are gone (a manual delete in Explorer is a legitimate edit), then index
/// anything new under the root. Without the prune, deletions were ghosts
/// until the next app launch.
#[tauri::command]
pub async fn rescan_library(app: tauri::AppHandle) -> Result<(), String> {
let state = app.state::<LibraryState>();
let lib = state.library.clone();
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing())
.await
.map_err(|e| e.to_string())?;
let root = downloads_dir(&app);
sweep_stale_partials(&root);
sweep_stale_auditions(&root);
sweep_empty_dirs(&root, 0);
scan_downloads(&app).await;
Ok(())
}
/// Index every audio file under the downloads dir that isn't known yet.
async fn scan_downloads(app: &tauri::AppHandle) {
let root = downloads_dir(app);
@@ -330,7 +446,7 @@ async fn repair_artist_portraits(app: &tauri::AppHandle) {
}
}
fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
pub(crate) fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
if depth > 6 {
return;
}
@@ -340,6 +456,12 @@ fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// "(Cache)" is the download staging pen — a completed file
// sitting there is seconds from being organized out, and
// indexing it would pin a library row to a staging path.
if path.file_name().is_some_and(|n| n == "(Cache)") {
continue;
}
collect_audio_files(&path, out, depth + 1);
} else if path
.extension()
@@ -488,14 +610,31 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path
}
// 4. Album art (once per album; serialized against concurrent tracks).
// The master is the album FOLDER's cover.jpg. For a download the layout
// makes that folder knowable before the file is organized into it, so
// the cover is filed first and the audio moves in beside it; a scanned
// file's album folder is simply where it already sits.
let cfg = crate::config::load(app);
let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone());
let album_dir = if remote_path.is_some() {
album_layout_dir(
app,
&final_artist,
&final_album,
release_date.as_deref(),
final_year,
)
} else {
local_path.parent().map(Path::to_path_buf).unwrap_or_default()
};
let art_path = fetch_art_cached(
&state,
&album_dir,
&final_artist,
&final_album,
mbid.as_deref(),
rg_mbid.as_deref(),
false,
remote_path.is_some() && crate::config::write_folder_cover(&cfg),
)
.await;
@@ -505,7 +644,6 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path
// whatever picture the uploader embedded (config-gated) so the file, the
// library, and the catalog all show the same artwork. Startup-scanned
// files are never stripped: they only gain art when they have none.
let cfg = crate::config::load(app);
let replace_embedded = remote_path.is_some() && crate::config::replace_embedded_art(&cfg);
let embed_max_edge = crate::config::embed_art_max_edge(&cfg);
let art_bytes = match (&art_path, replace_embedded || !summary.has_art) {
@@ -522,6 +660,8 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path
title: summary.title.is_none().then_some(title.clone()),
track_no: summary.track_no.or(parsed.track_no),
year: summary.year.is_none().then(|| final_year).flatten(),
label: None,
genre: None,
};
{
let p = local_path.to_path_buf();
@@ -551,17 +691,9 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path
local_path.to_path_buf()
};
// 6b. The canonical cover also becomes the album folder's cover.jpg /
// folder.jpg, so Explorer and any other player show the artwork the app
// does — stale uploader art under those names is replaced.
if remote_path.is_some() && crate::config::write_folder_cover(&cfg) {
if let (Some(album_dir), Some(art)) = (library_path.parent(), &art_path) {
let (dir, art) = (album_dir.to_path_buf(), art.clone());
let _ =
tauri::async_runtime::spawn_blocking(move || write_album_folder_cover(&dir, &art))
.await;
}
}
// (The folder cover needs no separate write any more — step 4 filed the
// master INTO the album folder, which is the same folder organize just
// moved the audio into.)
// 7. Library rows.
{
@@ -704,9 +836,11 @@ pub fn is_audio_path(path: &Path) -> bool {
/// Move a newly completed download into one album directory under the managed
/// root. The filename remains intact, preserving track numbering supplied by
/// the source. If a matching file exists, retain both files with a suffix.
fn organize_download(
/// The album directory the configured layout renders for this record —
/// deterministic from the tags alone, so the cover can be filed there
/// before the first track even arrives. Nothing is created here.
fn album_layout_dir(
app: &tauri::AppHandle,
source: &Path,
artist: &str,
album: &str,
release_date: Option<&str>,
@@ -741,6 +875,18 @@ fn organize_download(
if depth == 0 {
target_dir = target_dir.join("Unknown Album");
}
target_dir
}
fn organize_download(
app: &tauri::AppHandle,
source: &Path,
artist: &str,
album: &str,
release_date: Option<&str>,
year: Option<u32>,
) -> PathBuf {
let target_dir = album_layout_dir(app, artist, album, release_date, year);
if std::fs::create_dir_all(&target_dir).is_err() {
return source.to_path_buf();
}
@@ -852,7 +998,7 @@ fn cleanup_staging(app: &tauri::AppHandle, staging: &Path, album_dir: &Path) {
}
}
fn render_layout(layout: &str, artist: &str, album: &str, date: &str, year: Option<u32>) -> String {
pub(crate) fn render_layout(layout: &str, artist: &str, album: &str, date: &str, year: Option<u32>) -> String {
layout
.replace("{artist}", &clean_chars(artist))
.replace("{album}", &clean_chars(album))
@@ -893,14 +1039,14 @@ fn clean_chars(value: &str) -> String {
/// One path segment: cleaned and trimmed; Windows rejects trailing dots and
/// spaces. May return an empty string (caller decides the fallback).
fn safe_segment(value: &str) -> String {
pub(crate) fn safe_segment(value: &str) -> String {
clean_chars(value)
.trim()
.trim_end_matches([' ', '.'])
.to_string()
}
fn safe_component(value: &str) -> String {
pub(crate) fn safe_component(value: &str) -> String {
let trimmed = safe_segment(value);
if trimmed.is_empty() {
"Unknown Album".into()
@@ -911,13 +1057,20 @@ fn safe_component(value: &str) -> String {
/// `<art_dir>/<artist - album>.jpg`, fetched at most once per album.
/// `force` refetches even when a cached file exists.
/// The album's master cover lives IN the album folder as `cover.jpg` — art
/// travels with the music, one copy exists, and deleting the album deletes
/// its art (his call, 2026-08-07; AppData keeps only artist portraits and
/// the import picker's transient extractions). `replace` stomps whatever
/// cover is there (a fresh download's canonical art, a forced refresh);
/// without it an existing folder cover IS the master and no fetch happens.
pub async fn fetch_art_cached(
state: &tauri::State<'_, LibraryState>,
album_dir: &Path,
artist: &str,
album: &str,
mbid: Option<&str>,
rg_mbid: Option<&str>,
force: bool,
replace: bool,
) -> Option<PathBuf> {
let key: String = format!("{artist} - {album}")
.to_lowercase()
@@ -928,8 +1081,6 @@ pub async fn fetch_art_cached(
c => c,
})
.collect();
let target = state.art_dir.join(format!("{key}.jpg"));
let lock = {
let mut locks = state.art_locks.lock().unwrap();
locks
@@ -939,20 +1090,26 @@ pub async fn fetch_art_cached(
};
let _guard = lock.lock().await;
if target.exists() && !force {
return Some(target);
if !replace {
for name in ["cover.jpg", "folder.jpg"] {
let existing = album_dir.join(name);
if existing.exists() {
return Some(existing);
}
}
}
let bytes = state
.metadata
.fetch_art(mbid, rg_mbid, artist, album)
.await?;
std::fs::write(&target, &bytes).ok()?;
std::fs::create_dir_all(album_dir).ok()?;
let cover = write_album_cover_files(album_dir, &bytes)?;
log::info!(
"album art saved: {} ({} KB)",
target.display(),
cover.display(),
bytes.len() / 1024
);
Some(target)
Some(cover)
}
/// Downscale the canonical cover for embedding into audio files. `max_edge`
@@ -960,7 +1117,7 @@ pub async fn fetch_art_cached(
/// passes through byte-for-byte. Oversized covers are Lanczos3-resized and
/// re-encoded as JPEG 92 so a 3000px original doesn't add megabytes to every
/// track — the full-resolution file stays in the art cache and album folder.
fn prepare_embed_art(bytes: Vec<u8>, max_edge: u32) -> Vec<u8> {
pub(crate) fn prepare_embed_art(bytes: Vec<u8>, max_edge: u32) -> Vec<u8> {
if max_edge == 0 {
return bytes;
}
@@ -987,14 +1144,21 @@ fn write_album_folder_cover(album_dir: &Path, art_file: &Path) {
let Ok(bytes) = std::fs::read(art_file) else {
return;
};
let _ = write_album_cover_files(album_dir, &bytes);
}
/// Write cover bytes into the album folder under the canonical names, and
/// return the `cover.jpg` path — the album's master. See the doc above.
pub(crate) fn write_album_cover_files(album_dir: &Path, bytes: &[u8]) -> Option<PathBuf> {
let bytes = bytes.to_vec();
let jpeg = if bytes.starts_with(b"\x89PNG") {
let Ok(img) = image::load_from_memory(&bytes) else {
return;
return None;
};
let mut output = std::io::Cursor::new(Vec::new());
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95);
if img.into_rgb8().write_with_encoder(encoder).is_err() {
return;
return None;
}
output.into_inner()
} else {
@@ -1037,6 +1201,8 @@ fn write_album_folder_cover(album_dir: &Path, art_file: &Path) {
log::warn!("could not write album cover {}: {e}", target.display());
}
}
let cover = album_dir.join("cover.jpg");
cover.exists().then_some(cover)
}
/// Force-refetch an album's art with fresh MusicBrainz data and re-embed
@@ -1060,8 +1226,21 @@ pub async fn refresh_album_art(app: &tauri::AppHandle, album_id: i64) -> Result<
let mbid = meta.as_ref().and_then(|m| m.release_mbid.clone());
let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone());
// The tracks come first now: the album FOLDER is where the master cover
// lives, and the folder is wherever the tracks actually are.
let lib = state.library.clone();
let tracks = tauri::async_runtime::spawn_blocking(move || lib.album_tracks(album_id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let album_dir = tracks
.first()
.and_then(|t| Path::new(&t.path).parent().map(Path::to_path_buf))
.ok_or("album has no tracks on disk")?;
let art_path = fetch_art_cached(
&state,
&album_dir,
&album.artist,
&album.title,
mbid.as_deref(),
@@ -1081,15 +1260,10 @@ pub async fn refresh_album_art(app: &tauri::AppHandle, album_id: i64) -> Result<
.map_err(|e| e.to_string())?;
}
// Re-embed into every track, replacing the old picture, and refresh the
// album folder's cover.jpg/folder.jpg so every surface agrees again.
// Re-embed into every track, replacing the old picture; tracks living in
// OTHER folders (multi-disc spreads) get their own cover copies below.
let cfg = crate::config::load(app);
let embed_max_edge = crate::config::embed_art_max_edge(&cfg);
let lib = state.library.clone();
let tracks = tauri::async_runtime::spawn_blocking(move || lib.album_tracks(album_id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let bytes = std::fs::read(&art_path).map_err(|e| e.to_string())?;
let embed = {
let b = bytes.clone();