use crate::models::{Track, AudibleMetadata};
use anyhow::{Context, Result};
use id3::TagLike;
use std::path::Path;
pub fn extract_mp3_metadata(track: &mut Track) -> Result<()> {
let tag = id3::Tag::read_from_path(&track.file_path)
.context("Failed to read ID3 tags")?;
track.title = tag.title().map(|s| s.to_string());
track.artist = tag.artist().map(|s| s.to_string());
track.album = tag.album().map(|s| s.to_string());
track.album_artist = tag.album_artist().map(|s| s.to_string());
track.genre = tag.genre().map(|s| s.to_string());
track.year = tag.year().map(|y| y as u32);
track.comment = tag.comments().next().map(|c| c.text.clone());
track.composer = tag.get("TCOM").and_then(|frame| frame.content().text()).map(|s| s.to_string());
track.track_number = tag.track();
Ok(())
}
pub fn extract_m4a_metadata(track: &mut Track) -> Result<()> {
let tag = mp4ameta::Tag::read_from_path(&track.file_path)
.context("Failed to read M4A metadata")?;
track.title = tag.title().map(|s| s.to_string());
track.artist = tag.artist().map(|s| s.to_string());
track.album = tag.album().map(|s| s.to_string());
track.album_artist = tag.album_artist().map(|s| s.to_string());
track.genre = tag.genre().map(|s| s.to_string());
track.year = tag.year().map(|s| s.parse::<u32>().ok()).flatten();
track.comment = tag.comment().map(|s| s.to_string());
track.composer = tag.composer().map(|s| s.to_string());
if let Some(track_num) = tag.track_number() {
track.track_number = Some(track_num as u32);
}
Ok(())
}
pub fn extract_metadata(track: &mut Track) -> Result<()> {
if track.is_mp3() {
extract_mp3_metadata(track)
} else if track.is_m4a() {
extract_m4a_metadata(track)
} else {
Ok(())
}
}
pub fn extract_mp3_cover_art(file_path: &Path, output_path: &Path) -> Result<bool> {
let tag = id3::Tag::read_from_path(file_path)
.context("Failed to read ID3 tag")?;
let pictures: Vec<_> = tag.pictures().collect();
if let Some(picture) = pictures.first() {
tracing::debug!(
"Extracting embedded cover from MP3: {} ({} bytes, type: {:?})",
file_path.display(),
picture.data.len(),
picture.picture_type
);
std::fs::write(output_path, &picture.data)
.context("Failed to write extracted cover")?;
Ok(true)
} else {
tracing::debug!("No embedded cover found in MP3: {}", file_path.display());
Ok(false)
}
}
pub fn extract_m4a_cover_art(file_path: &Path, output_path: &Path) -> Result<bool> {
let tag = mp4ameta::Tag::read_from_path(file_path)
.context("Failed to read M4A tag")?;
if let Some(artwork) = tag.artwork() {
tracing::debug!(
"Extracting embedded cover from M4A: {} ({} bytes)",
file_path.display(),
artwork.data.len()
);
std::fs::write(output_path, &artwork.data)
.context("Failed to write extracted cover")?;
Ok(true)
} else {
tracing::debug!("No embedded cover found in M4A: {}", file_path.display());
Ok(false)
}
}
pub fn extract_embedded_cover(file_path: &Path, output_path: &Path) -> Result<bool> {
let extension = file_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
match extension.to_lowercase().as_str() {
"mp3" => extract_mp3_cover_art(file_path, output_path),
"m4a" | "m4b" => extract_m4a_cover_art(file_path, output_path),
_ => {
tracing::debug!("Unsupported format for cover extraction: {}", extension);
Ok(false)
}
}
}
pub async fn inject_metadata_atomicparsley(
file_path: &Path,
title: Option<&str>,
artist: Option<&str>,
album: Option<&str>,
album_artist: Option<&str>,
year: Option<u32>,
genre: Option<&str>,
composer: Option<&str>,
comment: Option<&str>,
cover_art: Option<&Path>,
) -> Result<()> {
let mut cmd = tokio::process::Command::new("AtomicParsley");
cmd.arg(file_path);
if let Some(title) = title {
cmd.args(&["--title", title]);
}
if let Some(artist) = artist {
cmd.args(&["--artist", artist]);
}
if let Some(album) = album {
cmd.args(&["--album", album]);
}
if let Some(album_artist) = album_artist {
cmd.args(&["--albumArtist", album_artist]);
}
if let Some(year) = year {
cmd.args(&["--year", &year.to_string()]);
}
if let Some(genre) = genre {
cmd.args(&["--genre", genre]);
}
if let Some(composer) = composer {
cmd.args(&["--composer", composer]);
}
if let Some(comment) = comment {
cmd.args(&["--comment", comment]);
}
if let Some(cover) = cover_art {
cmd.args(&["--artwork", &cover.display().to_string()]);
}
cmd.args(&["--overWrite"]);
tracing::debug!("AtomicParsley command: {:?}", cmd.as_std());
let output = cmd
.output()
.await
.context("Failed to execute AtomicParsley")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("AtomicParsley failed: {}", stderr);
}
Ok(())
}
pub async fn inject_audible_metadata(
file_path: &Path,
audible: &AudibleMetadata,
cover_art: Option<&Path>,
) -> Result<()> {
let mut cmd = tokio::process::Command::new("AtomicParsley");
cmd.arg(file_path);
let full_title = if let Some(subtitle) = &audible.subtitle {
format!("{}: {}", audible.title, subtitle)
} else {
audible.title.clone()
};
cmd.args(&["--title", &full_title]);
cmd.args(&["--album", &audible.title]);
if let Some(author) = audible.primary_author() {
cmd.args(&["--artist", author]);
cmd.args(&["--albumArtist", author]);
}
if let Some(narrator) = audible.primary_narrator() {
cmd.args(&["--composer", narrator]);
}
if let Some(subtitle) = &audible.subtitle {
cmd.args(&["--description", subtitle]);
}
if let Some(desc) = &audible.description {
let truncated_desc = if desc.len() > 4000 {
format!("{}...", &desc[..4000])
} else {
desc.clone()
};
cmd.args(&["--longdesc", &truncated_desc]);
cmd.args(&["--comment", &truncated_desc]);
}
if let Some(publisher) = &audible.publisher {
cmd.args(&["--rDNSatom", &format!("{}", publisher), "name=publisher", "domain=com.apple.iTunes"]);
}
if let Some(year) = audible.published_year {
cmd.args(&["--year", &year.to_string()]);
}
if let Some(genre) = audible.genres.first() {
cmd.args(&["--genre", genre]);
}
cmd.args(&["--rDNSatom", &audible.asin, "name=asin", "domain=com.audible"]);
if let Some(cover_path) = cover_art {
cmd.args(&["--artwork", &cover_path.display().to_string()]);
}
cmd.args(&["--overWrite"]);
let output = cmd
.output()
.await
.context("Failed to execute AtomicParsley")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("AtomicParsley failed: {}", stderr);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::QualityProfile;
use std::path::PathBuf;
#[test]
fn test_extract_metadata_mp3() {
let quality = QualityProfile::new(128, 44100, 2, "mp3".to_string(), 3600.0).unwrap();
let mut track = Track::new(PathBuf::from("test.mp3"), quality);
let _ = extract_mp3_metadata(&mut track);
}
#[test]
fn test_extract_metadata_m4a() {
let quality = QualityProfile::new(128, 44100, 2, "aac".to_string(), 3600.0).unwrap();
let mut track = Track::new(PathBuf::from("test.m4a"), quality);
let _ = extract_m4a_metadata(&mut track);
}
}