This commit is contained in:
@@ -118,10 +118,7 @@ fn cover_name_priority(path: &Path) -> usize {
|
||||
/// 2. Try to extract embedded cover art from each audio file.
|
||||
///
|
||||
/// Returns the first usable image found, or None.
|
||||
pub async fn find_best_cover(
|
||||
folder: &Path,
|
||||
audio_files: &[PathBuf],
|
||||
) -> Option<CoverImage> {
|
||||
pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<CoverImage> {
|
||||
// Strategy 1: folder images
|
||||
let folder_images = find_folder_images(folder);
|
||||
for img_path in &folder_images {
|
||||
|
||||
@@ -10,6 +10,9 @@ pub struct RawMetadata {
|
||||
pub year: Option<u32>,
|
||||
pub genre: Option<String>,
|
||||
pub duration_secs: Option<f64>,
|
||||
pub audio_bitrate: Option<i32>,
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
pub audio_bit_depth: Option<i32>,
|
||||
}
|
||||
|
||||
/// Hints parsed from the file path (directory structure + filename).
|
||||
|
||||
+36
-8
@@ -18,7 +18,10 @@ use super::dto::RawMetadata;
|
||||
/// Must be called from a blocking context (`spawn_blocking`).
|
||||
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
match extract_via_symphonia(path) {
|
||||
Ok(meta) => Ok(meta),
|
||||
Ok(mut meta) => {
|
||||
fill_average_bitrate(path, &mut meta);
|
||||
Ok(meta)
|
||||
}
|
||||
Err(e) => {
|
||||
let is_mp3 = path
|
||||
.extension()
|
||||
@@ -27,7 +30,9 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
.unwrap_or(false);
|
||||
if is_mp3 {
|
||||
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
|
||||
extract_mp3_via_id3(path)
|
||||
let mut meta = extract_mp3_via_id3(path)?;
|
||||
fill_average_bitrate(path, &mut meta);
|
||||
Ok(meta)
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
@@ -35,6 +40,22 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_average_bitrate(path: &Path, meta: &mut RawMetadata) {
|
||||
if meta.audio_bitrate.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(duration_secs) = meta.duration_secs.filter(|duration| *duration > 0.0) else {
|
||||
return;
|
||||
};
|
||||
let Ok(metadata) = std::fs::metadata(path) else {
|
||||
return;
|
||||
};
|
||||
let kbps = ((metadata.len() as f64 * 8.0) / duration_secs / 1000.0).round();
|
||||
if kbps.is_finite() && kbps > 0.0 && kbps <= i32::MAX as f64 {
|
||||
meta.audio_bitrate = Some(kbps as i32);
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
@@ -68,17 +89,24 @@ fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
}
|
||||
}
|
||||
|
||||
// Duration
|
||||
meta.duration_secs = probed
|
||||
let audio_track = probed
|
||||
.format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.and_then(|t| {
|
||||
let n_frames = t.codec_params.n_frames?;
|
||||
let tb = t.codec_params.time_base?;
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL);
|
||||
|
||||
if let Some(track) = audio_track {
|
||||
let params = &track.codec_params;
|
||||
meta.duration_secs = params.n_frames.and_then(|n_frames| {
|
||||
let tb = params.time_base?;
|
||||
Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64)
|
||||
});
|
||||
meta.audio_sample_rate = params.sample_rate.and_then(|rate| i32::try_from(rate).ok());
|
||||
meta.audio_bit_depth = params
|
||||
.bits_per_sample
|
||||
.or(params.bits_per_coded_sample)
|
||||
.and_then(|bits| i32::try_from(bits).ok());
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
+5
-6
@@ -27,11 +27,7 @@ pub struct AgentProbeResult {
|
||||
|
||||
/// Send a lightweight "introduce yourself" prompt to the LLM and return the
|
||||
/// response together with timing / usage statistics when available.
|
||||
pub async fn probe_llm(
|
||||
llm_url: &str,
|
||||
llm_model: &str,
|
||||
llm_auth: &str,
|
||||
) -> AgentProbeResult {
|
||||
pub async fn probe_llm(llm_url: &str, llm_model: &str, llm_auth: &str) -> AgentProbeResult {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
@@ -85,7 +81,10 @@ pub async fn probe_llm(
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
return AgentProbeResult {
|
||||
latency_ms,
|
||||
error: format!("HTTP {status}: {}", body_text.chars().take(300).collect::<String>()),
|
||||
error: format!(
|
||||
"HTTP {status}: {}",
|
||||
body_text.chars().take(300).collect::<String>()
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
+149
-50
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease};
|
||||
use super::dto::{
|
||||
FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -171,18 +173,40 @@ fn estimate_batch_tokens(
|
||||
let mut per_file_tokens: u64 = 0;
|
||||
for f in files {
|
||||
let mut chars: u64 = 40 + f.filename.len() as u64; // header
|
||||
if let Some(v) = &f.raw.title { chars += 10 + v.len() as u64; }
|
||||
if let Some(v) = &f.raw.artist { chars += 12 + v.len() as u64; }
|
||||
if let Some(v) = &f.raw.album { chars += 12 + v.len() as u64; }
|
||||
if f.raw.year.is_some() { chars += 12; }
|
||||
if f.raw.track_number.is_some() { chars += 18; }
|
||||
if let Some(v) = &f.raw.genre { chars += 10 + v.len() as u64; }
|
||||
if let Some(v) = &f.raw.title {
|
||||
chars += 10 + v.len() as u64;
|
||||
}
|
||||
if let Some(v) = &f.raw.artist {
|
||||
chars += 12 + v.len() as u64;
|
||||
}
|
||||
if let Some(v) = &f.raw.album {
|
||||
chars += 12 + v.len() as u64;
|
||||
}
|
||||
if f.raw.year.is_some() {
|
||||
chars += 12;
|
||||
}
|
||||
if f.raw.track_number.is_some() {
|
||||
chars += 18;
|
||||
}
|
||||
if let Some(v) = &f.raw.genre {
|
||||
chars += 10 + v.len() as u64;
|
||||
}
|
||||
// hints
|
||||
if let Some(v) = &f.hints.artist { chars += 16 + v.len() as u64; }
|
||||
if let Some(v) = &f.hints.album { chars += 16 + v.len() as u64; }
|
||||
if let Some(v) = &f.hints.title { chars += 15 + v.len() as u64; }
|
||||
if f.hints.year.is_some() { chars += 14; }
|
||||
if f.hints.track_number.is_some() { chars += 20; }
|
||||
if let Some(v) = &f.hints.artist {
|
||||
chars += 16 + v.len() as u64;
|
||||
}
|
||||
if let Some(v) = &f.hints.album {
|
||||
chars += 16 + v.len() as u64;
|
||||
}
|
||||
if let Some(v) = &f.hints.title {
|
||||
chars += 15 + v.len() as u64;
|
||||
}
|
||||
if f.hints.year.is_some() {
|
||||
chars += 14;
|
||||
}
|
||||
if f.hints.track_number.is_some() {
|
||||
chars += 20;
|
||||
}
|
||||
per_file_tokens += chars / 4;
|
||||
// Expected response per file (~150 tokens)
|
||||
per_file_tokens += 150;
|
||||
@@ -210,7 +234,10 @@ fn build_batch_user_message(
|
||||
if !similar_artists.is_empty() {
|
||||
msg.push_str("## Existing artists in database\n");
|
||||
for a in similar_artists {
|
||||
msg.push_str(&format!("- \"{}\" (similarity: {:.2})\n", a.name, a.similarity));
|
||||
msg.push_str(&format!(
|
||||
"- \"{}\" (similarity: {:.2})\n",
|
||||
a.name, a.similarity
|
||||
));
|
||||
}
|
||||
msg.push('\n');
|
||||
}
|
||||
@@ -219,7 +246,10 @@ fn build_batch_user_message(
|
||||
msg.push_str("## Existing releases in database\n");
|
||||
for r in similar_releases {
|
||||
let year_str = r.year.map(|y| format!(", year: {y}")).unwrap_or_default();
|
||||
msg.push_str(&format!("- \"{}\" (similarity: {:.2}{})\n", r.title, r.similarity, year_str));
|
||||
msg.push_str(&format!(
|
||||
"- \"{}\" (similarity: {:.2}{})\n",
|
||||
r.title, r.similarity, year_str
|
||||
));
|
||||
}
|
||||
msg.push('\n');
|
||||
}
|
||||
@@ -230,12 +260,24 @@ fn build_batch_user_message(
|
||||
for f in files {
|
||||
msg.push_str(&format!("### {}\n", f.filename));
|
||||
|
||||
if let Some(v) = &f.raw.title { msg.push_str(&format!("Title: \"{v}\"\n")); }
|
||||
if let Some(v) = &f.raw.artist { msg.push_str(&format!("Artist: \"{v}\"\n")); }
|
||||
if let Some(v) = &f.raw.album { msg.push_str(&format!("Release: \"{v}\"\n")); }
|
||||
if let Some(v) = f.raw.year { msg.push_str(&format!("Year: {v}\n")); }
|
||||
if let Some(v) = f.raw.track_number { msg.push_str(&format!("Track: {v}\n")); }
|
||||
if let Some(v) = &f.raw.genre { msg.push_str(&format!("Genre: \"{v}\"\n")); }
|
||||
if let Some(v) = &f.raw.title {
|
||||
msg.push_str(&format!("Title: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = &f.raw.artist {
|
||||
msg.push_str(&format!("Artist: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = &f.raw.album {
|
||||
msg.push_str(&format!("Release: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = f.raw.year {
|
||||
msg.push_str(&format!("Year: {v}\n"));
|
||||
}
|
||||
if let Some(v) = f.raw.track_number {
|
||||
msg.push_str(&format!("Track: {v}\n"));
|
||||
}
|
||||
if let Some(v) = &f.raw.genre {
|
||||
msg.push_str(&format!("Genre: \"{v}\"\n"));
|
||||
}
|
||||
|
||||
// Path hints (only if different from tag metadata)
|
||||
let has_hints = f.hints.artist.is_some()
|
||||
@@ -244,11 +286,21 @@ fn build_batch_user_message(
|
||||
|| f.hints.year.is_some()
|
||||
|| f.hints.track_number.is_some();
|
||||
if has_hints {
|
||||
if let Some(v) = &f.hints.artist { msg.push_str(&format!("Path artist: \"{v}\"\n")); }
|
||||
if let Some(v) = &f.hints.album { msg.push_str(&format!("Path release: \"{v}\"\n")); }
|
||||
if let Some(v) = &f.hints.title { msg.push_str(&format!("Path title: \"{v}\"\n")); }
|
||||
if let Some(v) = f.hints.year { msg.push_str(&format!("Path year: {v}\n")); }
|
||||
if let Some(v) = f.hints.track_number { msg.push_str(&format!("Path track: {v}\n")); }
|
||||
if let Some(v) = &f.hints.artist {
|
||||
msg.push_str(&format!("Path artist: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = &f.hints.album {
|
||||
msg.push_str(&format!("Path release: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = &f.hints.title {
|
||||
msg.push_str(&format!("Path title: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = f.hints.year {
|
||||
msg.push_str(&format!("Path year: {v}\n"));
|
||||
}
|
||||
if let Some(v) = f.hints.track_number {
|
||||
msg.push_str(&format!("Path track: {v}\n"));
|
||||
}
|
||||
}
|
||||
msg.push('\n');
|
||||
}
|
||||
@@ -272,7 +324,11 @@ pub async fn normalize_batch(
|
||||
) -> anyhow::Result<BatchNormalizeResult> {
|
||||
// Estimate tokens
|
||||
let estimated = estimate_batch_tokens(
|
||||
system_prompt, &files, similar_artists, similar_releases, folder_ctx,
|
||||
system_prompt,
|
||||
&files,
|
||||
similar_artists,
|
||||
similar_releases,
|
||||
folder_ctx,
|
||||
);
|
||||
|
||||
// If over 80% of context limit and more than 1 file, split
|
||||
@@ -290,14 +346,30 @@ pub async fn normalize_batch(
|
||||
let left = files_vec;
|
||||
|
||||
let left_result = Box::pin(normalize_batch(
|
||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
||||
left, similar_artists, similar_releases, folder_ctx,
|
||||
)).await?;
|
||||
llm_url,
|
||||
llm_model,
|
||||
llm_auth,
|
||||
system_prompt,
|
||||
context_limit,
|
||||
left,
|
||||
similar_artists,
|
||||
similar_releases,
|
||||
folder_ctx,
|
||||
))
|
||||
.await?;
|
||||
|
||||
let right_result = Box::pin(normalize_batch(
|
||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
||||
right, similar_artists, similar_releases, folder_ctx,
|
||||
)).await?;
|
||||
llm_url,
|
||||
llm_model,
|
||||
llm_auth,
|
||||
system_prompt,
|
||||
context_limit,
|
||||
right,
|
||||
similar_artists,
|
||||
similar_releases,
|
||||
folder_ctx,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Merge results
|
||||
let mut results = left_result.results;
|
||||
@@ -312,20 +384,32 @@ pub async fn normalize_batch(
|
||||
}
|
||||
|
||||
// Build and send
|
||||
let user_message = build_batch_user_message(
|
||||
&files, similar_artists, similar_releases, folder_ctx,
|
||||
);
|
||||
let user_message =
|
||||
build_batch_user_message(&files, similar_artists, similar_releases, folder_ctx);
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage { role: "system".into(), content: system_prompt.to_owned() },
|
||||
ChatMessage { role: "user".into(), content: user_message },
|
||||
ChatMessage {
|
||||
role: "system".into(),
|
||||
content: system_prompt.to_owned(),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".into(),
|
||||
content: user_message,
|
||||
},
|
||||
];
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let call_result = call_llm_chat(
|
||||
llm_url, llm_model, &messages,
|
||||
if llm_auth.is_empty() { None } else { Some(llm_auth) },
|
||||
).await;
|
||||
llm_url,
|
||||
llm_model,
|
||||
&messages,
|
||||
if llm_auth.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(llm_auth)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let duration_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
// If LLM error and batch > 1, try splitting (handles context overflow errors)
|
||||
@@ -349,13 +433,29 @@ pub async fn normalize_batch(
|
||||
let left = files_vec;
|
||||
|
||||
let left_result = Box::pin(normalize_batch(
|
||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
||||
left, similar_artists, similar_releases, folder_ctx,
|
||||
)).await?;
|
||||
llm_url,
|
||||
llm_model,
|
||||
llm_auth,
|
||||
system_prompt,
|
||||
context_limit,
|
||||
left,
|
||||
similar_artists,
|
||||
similar_releases,
|
||||
folder_ctx,
|
||||
))
|
||||
.await?;
|
||||
let right_result = Box::pin(normalize_batch(
|
||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
||||
right, similar_artists, similar_releases, folder_ctx,
|
||||
)).await?;
|
||||
llm_url,
|
||||
llm_model,
|
||||
llm_auth,
|
||||
system_prompt,
|
||||
context_limit,
|
||||
right,
|
||||
similar_artists,
|
||||
similar_releases,
|
||||
folder_ctx,
|
||||
))
|
||||
.await?;
|
||||
|
||||
let mut results = left_result.results;
|
||||
results.extend(right_result.results);
|
||||
@@ -363,7 +463,8 @@ pub async fn normalize_batch(
|
||||
results,
|
||||
model: left_result.model,
|
||||
prompt_tokens: left_result.prompt_tokens + right_result.prompt_tokens,
|
||||
completion_tokens: left_result.completion_tokens + right_result.completion_tokens,
|
||||
completion_tokens: left_result.completion_tokens
|
||||
+ right_result.completion_tokens,
|
||||
duration_ms: left_result.duration_ms + right_result.duration_ms,
|
||||
});
|
||||
}
|
||||
@@ -398,9 +499,7 @@ fn parse_batch_response(
|
||||
|
||||
// Strip markdown code fences if present
|
||||
let json_str = if cleaned.starts_with("```") {
|
||||
let start = cleaned.find('[')
|
||||
.or_else(|| cleaned.find('{'))
|
||||
.unwrap_or(0);
|
||||
let start = cleaned.find('[').or_else(|| cleaned.find('{')).unwrap_or(0);
|
||||
let end_bracket = cleaned.rfind(']').map(|i| i + 1);
|
||||
let end_brace = cleaned.rfind('}').map(|i| i + 1);
|
||||
let end = end_bracket.or(end_brace).unwrap_or(cleaned.len());
|
||||
|
||||
@@ -113,11 +113,8 @@ fn parse_album_with_year(dir: &str) -> (String, Option<i32>) {
|
||||
let inside = &dir[start + 1..start + end];
|
||||
if let Ok(year) = inside.trim().parse::<i32>() {
|
||||
if (1900..=2100).contains(&year) {
|
||||
let album = format!(
|
||||
"{}{}",
|
||||
&dir[..start].trim(),
|
||||
&dir[start + end + 1..].trim()
|
||||
);
|
||||
let album =
|
||||
format!("{}{}", &dir[..start].trim(), &dir[start + end + 1..].trim());
|
||||
let album = album.trim().to_owned();
|
||||
return (album, Some(year));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user