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:
1 parent
4b30c5e573
commit
e7d0971de2
23 files changed
+6498
-258
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user