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

+13
View File
@@ -748,6 +748,19 @@ impl Library {
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// Drop specific track rows by id. Used when the import tool replaces an
/// album's audio with another version: the rows for the copy that was
/// replaced go with its files. The album row is deliberately left alone —
/// the caller is mid-apply and has already registered the replacement.
pub fn remove_tracks(&self, ids: &[i64]) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let mut removed = 0;
for id in ids {
removed += conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
}
Ok(removed)
}
/// Paths of tracks missing the audio-props backfill (size is the sentinel:
/// always available from the filesystem, so a non-null size means scanned).
pub fn tracks_missing_props(&self) -> Result<Vec<(i64, String)>> {
+507
View File
@@ -8154,6 +8154,242 @@ impl MetadataClient {
}
}
/// Identify one album record against the providers — the import review's
/// on-demand "which record is this?" (research/import/enrich.mjs
/// providerLookup, ported). Deezer is asked down the cleaned try-list
/// until something answers; when its answer is missing — or is a
/// DIFFERENT record (Bastard → Cherry Bomb: free release, Deezer search
/// happily answers with another album) — MusicBrainz answers for the
/// album itself, the same supplement the artist page already leans on.
/// A title that is merely an edition of ours does NOT read as wrong
/// here: containment either way counts as a match, and the edition rule
/// lives with the judge, not the fetcher.
pub async fn identify_album(&self, artist: &str, raw_title: &str) -> IdentifyResult {
let tries = search_titles(artist, raw_title);
let mut candidate = None;
let mut used = None;
for t in &tries {
if let Some(c) = self.deezer_identify(artist, t).await {
candidate = Some(c);
used = Some(t.clone());
break;
}
}
let deezer_wrong = candidate
.as_ref()
.is_some_and(|c| !tries.iter().any(|t| normx_overlap(&c.title, t)));
if candidate.is_none() || deezer_wrong {
if let Some((mb, t)) = self.mb_identify(artist, &tries).await {
candidate = Some(mb);
used = Some(t);
}
// MB missing too leaves the wrong Deezer answer in place ON
// PURPOSE: the judge shows it as "Wrong record" honestly, which
// beats an empty card (Jenny Death stays wrong, deliberately).
}
IdentifyResult {
tries,
used,
candidate,
deezer_wrong,
}
}
/// Deezer's answer for one cleaned query: first search hit, hydrated
/// with the album detail (label/date/genre) and the full paginated
/// disc-aware tracklist — `/album/{id}` embeds only the first ~25.
async fn deezer_identify(&self, artist: &str, t: &str) -> Option<IdentifyCandidate> {
let q = urlencode(&format!("{artist} {t}"));
let found = self
.deezer(&format!("https://api.deezer.com/search/album?q={q}&limit=3"))
.await?;
let hit = found.get("data")?.as_array()?.first()?.clone();
let id = hit.get("id")?.as_u64()?;
let full = self.deezer(&format!("https://api.deezer.com/album/{id}")).await;
let mut tracklist: Vec<IdentifyTrack> = Vec::new();
let mut next = Some(format!("https://api.deezer.com/album/{id}/tracks?limit=200"));
while let Some(url) = next {
let Some(page) = self.deezer(&url).await else { break };
for tr in page.get("data").and_then(|d| d.as_array()).into_iter().flatten() {
tracklist.push(IdentifyTrack {
n: tr.get("track_position").and_then(|v| v.as_u64()).map(|v| v as u32),
title: tr
.get("title")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
secs: tr.get("duration").and_then(|v| v.as_u64()).map(|v| v as u32),
});
}
next = page
.get("next")
.and_then(|v| v.as_str())
.map(String::from);
}
let s = |v: &serde_json::Value, k: &str| {
v.get(k).and_then(|x| x.as_str()).map(String::from)
};
Some(IdentifyCandidate {
id: id.to_string(),
source: "Deezer".into(),
title: s(&hit, "title").unwrap_or_default(),
artist: hit
.pointer("/artist/name")
.and_then(|v| v.as_str())
.map(String::from),
cover: s(&hit, "cover_xl").or_else(|| s(&hit, "cover_big")),
cover_big: s(&hit, "cover_big"),
cover_medium: s(&hit, "cover_medium"),
label: full.as_ref().and_then(|f| s(f, "label")),
release_date: full.as_ref().and_then(|f| s(f, "release_date")),
genre: full
.as_ref()
.and_then(|f| f.pointer("/genres/data/0/name"))
.and_then(|v| v.as_str())
.map(String::from),
track_count: tracklist.len(),
tracklist,
})
}
/// MusicBrainz release-group as the album RECORD, not just as cover art
/// (the mock's mbCandidate). Exact-normalized title first, containment
/// second; one official release with recordings gives the tracklist.
/// Paced by the crate's own MusicBrainz gate.
async fn mb_identify(
&self,
artist: &str,
tries: &[String],
) -> Option<(IdentifyCandidate, String)> {
for t in tries {
let query = format!("releasegroup:\"{}\" AND artist:\"{}\"", escape(t), escape(artist));
let url = format!(
"https://musicbrainz.org/ws/2/release-group?query={}&fmt=json&limit=3",
urlencode(&query)
);
let Some(body) = self.mb_get(&url).await else { continue };
let empty = Vec::new();
let groups = body
.get("release-groups")
.and_then(|v| v.as_array())
.unwrap_or(&empty);
let rg = groups
.iter()
.find(|g| {
g.get("title")
.and_then(|v| v.as_str())
.is_some_and(|title| normx(title) == normx(t))
})
.or_else(|| {
groups.iter().find(|g| {
g.get("title")
.and_then(|v| v.as_str())
.is_some_and(|title| normx_overlap(title, t))
})
});
let Some(rg) = rg else { continue };
let Some(rgid) = rg.get("id").and_then(|v| v.as_str()) else { continue };
let rel_url = format!(
"https://musicbrainz.org/ws/2/release?release-group={rgid}\
&inc=recordings&status=official&fmt=json&limit=1"
);
let rel = self.mb_get(&rel_url).await;
let tracklist: Vec<IdentifyTrack> = rel
.as_ref()
.and_then(|r| r.pointer("/releases/0/media"))
.and_then(|m| m.as_array())
.map(|media| {
media
.iter()
.flat_map(|m| {
m.get("tracks").and_then(|t| t.as_array()).cloned().unwrap_or_default()
})
.map(|tr| IdentifyTrack {
n: tr.get("position").and_then(|v| v.as_u64()).map(|v| v as u32),
title: tr
.get("title")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
secs: tr
.get("length")
.and_then(|v| v.as_u64())
.map(|ms| ((ms as f64) / 1000.0).round() as u32),
})
.collect()
})
.unwrap_or_default();
let candidate = IdentifyCandidate {
id: format!("mb:{rgid}"),
source: "MusicBrainz".into(),
title: rg
.get("title")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
artist: rg
.pointer("/artist-credit/0/name")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| Some(artist.to_string())),
cover: Some(format!(
"https://coverartarchive.org/release-group/{rgid}/front"
)),
cover_big: None,
cover_medium: None,
label: None,
release_date: rg
.get("first-release-date")
.and_then(|v| v.as_str())
.map(String::from),
genre: None,
track_count: tracklist.len(),
tracklist,
};
return Some((candidate, t.clone()));
}
None
}
/// Fetch a chosen cover's bytes for the import apply — a plain GET, the
/// caller decides what the pixels are for.
pub async fn fetch_cover_bytes(&self, url: &str) -> Option<Vec<u8>> {
let resp = self.http.get(url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
resp.bytes().await.ok().map(|b| b.to_vec())
}
/// Throttled MusicBrainz GET for identify, with one retry. Status-aware
/// on purpose: a 503 from MB's rate limiter — routine right after app
/// start, when the identity warmer shares the 1 req/s budget — must
/// read as a retryable failure, never as "record absent" (the paced-
/// probe-contamination lesson, now inside the app). The retry passes
/// back through the throttle, so it is properly spaced, and a second
/// failure surfaces as an honest miss the user can re-open.
async fn mb_get(&self, url: &str) -> Option<serde_json::Value> {
for attempt in 0..2 {
self.mb_throttle().await;
match self.http.get(url).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(v) = resp.json().await {
return Some(v);
}
}
Ok(resp) if attempt == 0 => {
log::debug!("mb identify: {} from MusicBrainz, retrying", resp.status());
}
Ok(_) | Err(_) => {}
}
}
None
}
/// Production hot-path cover upgrade for a catalog release. Returns the URL
/// of the highest-resolution image that is *verifiably the same artwork* as
/// the cover the UI already shows (`reference_url`), or `None` to keep it.
@@ -11114,6 +11350,239 @@ pub struct CoverReport {
pub chosen_url: Option<String>,
}
/* ---------------------------------------------------------------- identify --
The import review's record identification, ported from the approved mock
(research/import/enrich.mjs). The folder name is not the album name: this
kind of library writes rip descriptors into it — "Heartleap - WEB V0",
"(1997.07.24) (album) Uchu Nippon Setagaya [128kbps]" — and sometimes the
artist again. Sent verbatim, those queries miss records that are plainly
in every catalogue (58 of 100 in the mock's first pass; the cleaned
try-list + MB fallback resolved 50 of 65). The try-list mirrors the app's
own simplifyTitle (state.svelte.js:3739); the judge still decides whatever
comes back, so better recall cannot smuggle in a wrong record. */
/// One track of an identify candidate's tracklist.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IdentifyTrack {
pub n: Option<u32>,
pub title: String,
pub secs: Option<u32>,
}
/// A provider's answer for "which record is this?", in the shape the mock's
/// candidates.json established (the picker UI is built against it). `id` is
/// a string on purpose: Deezer ids are numeric ("302127"), MusicBrainz ones
/// are "mb:<release-group uuid>".
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IdentifyCandidate {
pub id: String,
pub source: String, // "Deezer" | "MusicBrainz"
pub title: String,
pub artist: Option<String>,
/// Biggest artwork URL (Deezer xl / Cover Art Archive front).
pub cover: Option<String>,
pub cover_big: Option<String>,
pub cover_medium: Option<String>,
pub label: Option<String>,
pub release_date: Option<String>,
pub genre: Option<String>,
pub track_count: usize,
pub tracklist: Vec<IdentifyTrack>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IdentifyResult {
/// The cleaned queries actually sent, least- to most-aggressive.
pub tries: Vec<String>,
/// Which try produced the accepted candidate.
pub used: Option<String>,
pub candidate: Option<IdentifyCandidate>,
/// Deezer answered with a record whose title matches no form of ours,
/// so MusicBrainz was consulted. When MB also missed, `candidate` still
/// carries Deezer's wrong answer — shown honestly as "Wrong record".
pub deezer_wrong: bool,
}
/// ASCII-normalize for title matching, exactly as the mock's normx: only
/// lowercase ASCII alphanumerics survive. A fully non-Latin title
/// normalizes to "" and therefore matches anything — the Kuchu Camp gap,
/// kept faithfully (script-aware retry is a someday).
fn normx(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
/// The mock's match rule: normalized equality or containment either way.
/// Containment is what keeps an edition ("... 10th Anniversary Edition")
/// from reading as a different record.
fn normx_overlap(a: &str, b: &str) -> bool {
let (na, nb) = (normx(a), normx(b));
na == nb || na.contains(nb.as_str()) || nb.contains(na.as_str())
}
/// Case-insensitive prefix strip, char-boundary safe.
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let mut end = 0;
let mut sc = s.chars();
for p in prefix.chars() {
let c = sc.next()?;
if !c.to_lowercase().eq(p.to_lowercase()) {
return None;
}
end += c.len_utf8();
}
Some(&s[end..])
}
/// `^[\[(]\d{4}[^\])]*[\])]\s*-?\s*` — a leading "[2015] - " or
/// "(1997.07.24) " group, dates included.
fn strip_leading_year_group(s: &str) -> String {
if !(s.starts_with('[') || s.starts_with('(')) {
return s.to_string();
}
let bytes = s.as_bytes();
if bytes.len() < 6 || !bytes[1..5].iter().all(u8::is_ascii_digit) {
return s.to_string();
}
// The group runs to the FIRST close bracket of either kind, as in the
// mock's character class.
let Some(close) = s.find([']', ')']) else {
return s.to_string();
};
if close < 5 {
return s.to_string();
}
let rest = s[close + 1..].trim_start();
let rest = rest.strip_prefix('-').unwrap_or(rest).trim_start();
rest.to_string()
}
/// Bracket groups that carry rip descriptors — "[V0]", "(320kbps)",
/// "[WEB FLAC]" — removed whole (substring match inside the group, as the
/// mock's alternation behaves).
fn strip_rip_groups(s: &str) -> String {
const TOKENS: [&str; 10] = [
"kbps", "v0", "v2", "web", "flac", "mp3", "320", "256", "192", "128",
];
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(open) = rest.find(['[', '(']) {
let after = &rest[open + 1..];
let Some(close) = after.find([']', ')']) else { break };
let low = after[..close].to_lowercase();
if TOKENS.iter().any(|t| low.contains(t)) {
out.push_str(&rest[..open]);
out.push(' ');
} else {
out.push_str(&rest[..open + 1 + close + 1]);
}
rest = &after[close + 1..];
}
out.push_str(rest);
out
}
/// Free-standing rip tokens (`\b`-bounded in the mock): whole words only,
/// so "320kbps" the word survives while "320" alone goes.
fn strip_rip_words(s: &str) -> String {
const WORDS: [&str; 14] = [
"web", "v0", "v2", "flac", "mp3", "320", "320k", "256", "192", "128", "128kbps",
"kbps", "vinyl", "rip",
];
fn flush(word: &mut String, out: &mut String) {
if word.is_empty() {
return;
}
let low = word.to_lowercase();
let user_n = low
.strip_prefix("user")
.is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()));
if WORDS.contains(&low.as_str()) || user_n {
out.push(' ');
} else {
out.push_str(word);
}
word.clear();
}
let mut out = String::with_capacity(s.len());
let mut word = String::new();
for c in s.chars() {
if c.is_ascii_alphanumeric() || c == '_' {
word.push(c);
} else {
flush(&mut word, &mut out);
out.push(c);
}
}
flush(&mut word, &mut out);
out
}
/// The cleaned query try-list for one folder title, least- to most-
/// aggressive, deduped; may be empty when nothing survives cleaning.
pub fn search_titles(artist: &str, raw_title: &str) -> Vec<String> {
// Invisible marks break matching.
let t: String = raw_title
.chars()
.filter(|c| !matches!(c, '\u{200e}' | '\u{200f}' | '\u{feff}'))
.collect();
let mut t = t.trim().to_string();
t = strip_leading_year_group(&t);
for tag in ["(album)", "(single)", "(ep)"] {
if let Some(rest) = strip_prefix_ci(&t, tag) {
t = rest.trim_start().to_string();
break;
}
}
// The artist repeated at the front of the album folder (one separator
// dash at most, as the mock's regex).
if let Some(rest) = strip_prefix_ci(&t, artist) {
let rest = rest.trim_start();
let rest = rest.strip_prefix(['-', '–', '—']).unwrap_or(rest);
let rest = rest.trim_start();
if !rest.is_empty() {
t = rest.to_string();
}
}
// Least aggressive: rip tokens and their bracket groups stripped.
let cleaned = {
let g = strip_rip_groups(&t);
let w = strip_rip_words(&g);
let d = w.trim_end();
let d = d.strip_suffix(['-', '–', '—']).unwrap_or(d);
d.split_whitespace().collect::<Vec<_>>().join(" ")
};
// The app's simplifyTitle: cut at the first ; : ( [ and cap at six words.
let base = if cleaned.is_empty() { t.clone() } else { cleaned.clone() };
let first = base
.split([';', ':', '(', '['])
.next()
.unwrap_or("")
.trim()
.to_string();
let simp_src = if first.is_empty() { base } else { first };
let simplified = simp_src
.split_whitespace()
.take(6)
.collect::<Vec<_>>()
.join(" ");
let mut out = Vec::new();
for v in [cleaned, simplified] {
if !v.is_empty() && !out.contains(&v) {
out.push(v);
}
}
out
}
struct CoverCandidate {
bytes: Arc<Vec<u8>>,
width: u32,
@@ -13875,6 +14344,44 @@ fn urlencode(s: &str) -> String {
#[cfg(test)]
mod tests {
/// The try-list port (`search_titles`) against the exact folder shapes
/// the mock proved on: rip descriptors, date/type prefixes, doubled
/// artists (research/import/enrich.mjs searchTitles).
#[test]
fn search_titles_cleans_rip_descriptors() {
use super::search_titles;
assert_eq!(
search_titles("Vashti Bunyan", "Heartleap - WEB V0"),
vec!["Heartleap".to_string()]
);
assert_eq!(
search_titles("Vashti Bunyan", "Just Another Diamond Day [V0]"),
vec!["Just Another Diamond Day".to_string()]
);
assert_eq!(
search_titles("Fishmans", "(1997.07.24) (album) Uchu Nippon Setagaya [128kbps]"),
vec!["Uchu Nippon Setagaya".to_string()]
);
assert_eq!(
search_titles("Smog", "Smog - The Doctor Came At Dawn (1996)"),
vec![
"The Doctor Came At Dawn (1996)".to_string(),
"The Doctor Came At Dawn".to_string(),
]
);
}
/// The edition rule's foundation: containment matches, disjoint titles
/// don't (Bastard → Cherry Bomb is the canonical wrong record).
#[test]
fn normx_overlap_editions_match_wrong_records_do_not() {
assert!(super::normx_overlap(
"Random Access Memories (10th Anniversary Edition)",
"Random Access Memories"
));
assert!(!super::normx_overlap("Cherry Bomb", "Bastard"));
}
/// Is a provider's image host actually failing, or is this machine?
///
/// Run this the next time a `failed a liveness probe` warning appears. It
+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));
+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()?;