use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;
use lofty::config::ParseOptions;
use lofty::file::AudioFile;
use lofty::mp4::{Mp4Codec, Mp4File};
use lofty::prelude::*;
use symphonia::core::meta::{MetadataRevision, StandardTagKey};
use thiserror::Error;
use crate::db::queries::TrackMeta;
#[derive(Debug, Error)]
pub enum MetadataError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("tag error: {0}")]
Tag(#[from] lofty::error::LoftyError),
}
const AUDIO_EXTENSIONS: &[&str] = &[
"flac", "mp3", "m4a", "aac", "ogg", "opus", "wv", "wav", "aiff", "aif", "alac", "ape",
];
pub fn is_audio_file(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| AUDIO_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
}
pub fn read_metadata(path: &Path) -> Result<TrackMeta, MetadataError> {
match std::fs::metadata(path) {
Ok(m) if m.len() == 0 => {
return Err(MetadataError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"empty file (0 bytes) — may be a stale mount or incomplete transfer, will retry on next scan: {}",
path.display()
),
)));
}
Err(e) => return Err(MetadataError::Io(e)),
_ => {}
}
match lofty::read_from_path(path) {
Ok(tagged_file) => read_metadata_lofty(path, &tagged_file),
Err(e) => {
log::warn!(
"lofty failed for {}: {}; falling back to probe",
path.display(),
e
);
read_metadata_fallback(path)
}
}
}
fn read_metadata_lofty(
path: &Path,
tagged_file: &lofty::file::TaggedFile,
) -> Result<TrackMeta, MetadataError> {
let properties = tagged_file.properties();
let duration_ms = properties.duration().as_millis() as i64;
let sample_rate = properties.sample_rate().map(|r| r as i32);
let bit_depth = properties.bit_depth().map(|b| b as i32);
let channels = properties.channels().map(|c| c as i32);
let bitrate = properties.audio_bitrate().map(|b| b as i32);
let tag = tagged_file
.primary_tag()
.or_else(|| tagged_file.first_tag());
let (title, artist, album_artist, album, date, disc, track_number, genre, label) =
if let Some(tag) = tag {
(
tag.title().map(|s| s.to_string()),
tag.artist().map(|s| s.to_string()),
tag.get_string(ItemKey::AlbumArtist).map(|s| s.to_string()),
tag.album().map(|s| s.to_string()),
tag.get_string(ItemKey::Year)
.or_else(|| tag.get_string(ItemKey::RecordingDate))
.map(|s| s.to_string()),
tag.disk().map(|d| d as i32),
tag.track().map(|t| t as i32),
tag.genre().map(|s| s.to_string()),
tag.get_string(ItemKey::Label).map(|s| s.to_string()),
)
} else {
(None, None, None, None, None, None, None, None, None)
};
let title = title.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string()
});
let artist = artist.unwrap_or_else(|| "Unknown Artist".to_string());
let album = album.unwrap_or_else(|| "Unknown Album".to_string());
let file_meta = fs::metadata(path)?;
let size_bytes = file_meta.len() as i64;
let mtime = file_meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
let codec = if tagged_file.file_type() == lofty::file::FileType::Mp4 {
mp4_codec(path)
} else {
codec_string(tagged_file.file_type()).to_string()
};
Ok(TrackMeta {
title,
artist,
album_artist,
album,
date,
disc,
track_number,
genre,
label,
duration_ms: Some(duration_ms),
codec: Some(codec),
sample_rate,
bit_depth,
channels,
bitrate,
size_bytes: Some(size_bytes),
mtime,
path: Some(path.to_string_lossy().to_string()),
source: "local".to_string(),
remote_id: None,
remote_url: None,
})
}
fn read_metadata_fallback(path: &Path) -> Result<TrackMeta, MetadataError> {
let file_meta = fs::metadata(path)?;
let size_bytes = file_meta.len() as i64;
let mtime = file_meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
let props = probe_symphonia(path);
let (title, artist, album) = probe_symphonia_tags(path);
let title = title.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string()
});
let artist = artist.unwrap_or_else(|| "Unknown Artist".to_string());
let album = album.unwrap_or_else(|| "Unknown Album".to_string());
Ok(TrackMeta {
title,
artist,
album_artist: None,
album,
date: None,
disc: None,
track_number: None,
genre: None,
label: None,
duration_ms: props.duration_ms,
codec: props.codec,
sample_rate: props.sample_rate,
bit_depth: props.bit_depth,
channels: props.channels,
bitrate: props.bitrate,
size_bytes: Some(size_bytes),
mtime,
path: Some(path.to_string_lossy().to_string()),
source: "local".to_string(),
remote_id: None,
remote_url: None,
})
}
struct SymphoniaProps {
duration_ms: Option<i64>,
sample_rate: Option<i32>,
bit_depth: Option<i32>,
channels: Option<i32>,
bitrate: Option<i32>,
codec: Option<String>,
}
fn probe_symphonia(path: &Path) -> SymphoniaProps {
use symphonia::core::codecs::CODEC_TYPE_NULL;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
let empty = SymphoniaProps {
duration_ms: None,
sample_rate: None,
bit_depth: None,
channels: None,
bitrate: None,
codec: None,
};
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return empty,
};
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = match symphonia::default::get_probe().format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
) {
Ok(p) => p,
Err(_) => return empty,
};
let track = match probed.format.default_track() {
Some(t) => t,
None => return empty,
};
let params = &track.codec_params;
let sample_rate = params.sample_rate.map(|r| r as i32);
let bit_depth = params.bits_per_sample.map(|b| b as i32);
let channels = params.channels.map(|c| c.count() as i32);
let duration_ms = params.n_frames.and_then(|frames| {
params
.sample_rate
.map(|sr| (frames as f64 / sr as f64 * 1000.0) as i64)
});
let bitrate = params.sample_rate.and_then(|sr| {
params.bits_per_sample.and_then(|bps| {
params
.channels
.map(|ch| (sr as i32 * bps as i32 * ch.count() as i32) / 1000)
})
});
let codec = if params.codec != CODEC_TYPE_NULL {
Some(symphonia_codec_name(params.codec))
} else {
None
};
SymphoniaProps {
duration_ms,
sample_rate,
bit_depth,
channels,
bitrate,
codec,
}
}
fn symphonia_codec_name(codec: symphonia::core::codecs::CodecType) -> String {
use symphonia::core::codecs;
match codec {
codecs::CODEC_TYPE_FLAC => "FLAC".to_string(),
codecs::CODEC_TYPE_MP3 => "MP3".to_string(),
codecs::CODEC_TYPE_AAC => "AAC".to_string(),
codecs::CODEC_TYPE_ALAC => "ALAC".to_string(),
codecs::CODEC_TYPE_VORBIS => "Vorbis".to_string(),
codecs::CODEC_TYPE_OPUS => "Opus".to_string(),
codecs::CODEC_TYPE_WAVPACK => "WavPack".to_string(),
codecs::CODEC_TYPE_PCM_S16LE
| codecs::CODEC_TYPE_PCM_S24LE
| codecs::CODEC_TYPE_PCM_S32LE
| codecs::CODEC_TYPE_PCM_F32LE
| codecs::CODEC_TYPE_PCM_F64LE
| codecs::CODEC_TYPE_PCM_S16BE
| codecs::CODEC_TYPE_PCM_S24BE
| codecs::CODEC_TYPE_PCM_S32BE
| codecs::CODEC_TYPE_PCM_F32BE
| codecs::CODEC_TYPE_PCM_F64BE
| codecs::CODEC_TYPE_PCM_U8 => "PCM".to_string(),
_ => "Unknown".to_string(),
}
}
fn probe_symphonia_tags(path: &Path) -> (Option<String>, Option<String>, Option<String>) {
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::{MetadataOptions, StandardTagKey};
use symphonia::core::probe::Hint;
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return (None, None, None),
};
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let mut probed = match symphonia::default::get_probe().format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
) {
Ok(p) => p,
Err(_) => return (None, None, None),
};
let mut title = None;
let mut artist = None;
let mut album = None;
if let Some(md) = probed.metadata.get()
&& let Some(rev) = md.current()
{
for tag in rev.tags() {
match tag.std_key {
Some(StandardTagKey::TrackTitle) => title = Some(tag.value.to_string()),
Some(StandardTagKey::Artist) => artist = Some(tag.value.to_string()),
Some(StandardTagKey::Album) => album = Some(tag.value.to_string()),
_ => {}
}
}
}
if let Some(md) = probed.format.metadata().current() {
for tag in md.tags() {
match tag.std_key {
Some(StandardTagKey::TrackTitle) if title.is_none() => {
title = Some(tag.value.to_string())
}
Some(StandardTagKey::Artist) if artist.is_none() => {
artist = Some(tag.value.to_string())
}
Some(StandardTagKey::Album) if album.is_none() => {
album = Some(tag.value.to_string())
}
_ => {}
}
}
}
(title, artist, album)
}
fn mp4_codec(path: &Path) -> String {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return "AAC".to_string(),
};
let mut reader = std::io::BufReader::new(file);
match Mp4File::read_from(&mut reader, ParseOptions::new()) {
Ok(mp4) => match mp4.properties().codec() {
Mp4Codec::ALAC => "ALAC".to_string(),
Mp4Codec::MP3 => "MP3".to_string(),
Mp4Codec::FLAC => "FLAC".to_string(),
_ => "AAC".to_string(),
},
Err(_) => "AAC".to_string(),
}
}
pub fn codec_string(ft: lofty::file::FileType) -> &'static str {
match ft {
lofty::file::FileType::Flac => "FLAC",
lofty::file::FileType::Mpeg => "MP3",
lofty::file::FileType::Mp4 => "AAC",
lofty::file::FileType::Opus => "Opus",
lofty::file::FileType::Vorbis => "Vorbis",
lofty::file::FileType::WavPack => "WavPack",
lofty::file::FileType::Wav => "WAV",
lofty::file::FileType::Aiff => "AIFF",
lofty::file::FileType::Ape => "APE",
_ => "Unknown",
}
}
pub fn extract_cover_art(path: &Path) -> Option<Vec<u8>> {
let tagged_file = lofty::read_from_path(path).ok()?;
let tag = tagged_file
.primary_tag()
.or_else(|| tagged_file.first_tag())?;
let pictures = tag.pictures();
let pic = pictures
.iter()
.find(|p| p.pic_type() == lofty::picture::PictureType::CoverFront && !is_tiff(p.data()))
.or_else(|| pictures.iter().find(|p| !is_tiff(p.data())))?;
Some(pic.data().to_vec())
}
fn is_tiff(data: &[u8]) -> bool {
data.len() >= 4
&& ((data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00)
|| (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A))
}
pub fn metadata_from_probe_result(meta: &MetadataRevision, fallback_title: &str) -> TrackMeta {
let mut title: Option<String> = None;
let mut artist: Option<String> = None;
let mut album_artist: Option<String> = None;
let mut album: Option<String> = None;
let mut date: Option<String> = None;
let mut disc: Option<i32> = None;
let mut track_number: Option<i32> = None;
let mut genre: Option<String> = None;
let mut label: Option<String> = None;
for tag in meta.tags() {
let Some(std_key) = tag.std_key else { continue };
let value = tag.value.to_string();
if value.is_empty() {
continue;
}
match std_key {
StandardTagKey::TrackTitle => title = Some(value),
StandardTagKey::Artist => artist = Some(value),
StandardTagKey::AlbumArtist => album_artist = Some(value),
StandardTagKey::Album => album = Some(value),
StandardTagKey::Date => date = Some(value),
StandardTagKey::OriginalDate => {
if date.is_none() {
date = Some(value);
}
}
StandardTagKey::TrackNumber => {
track_number = value.split('/').next().and_then(|s| s.trim().parse().ok());
}
StandardTagKey::DiscNumber => {
disc = value.split('/').next().and_then(|s| s.trim().parse().ok());
}
StandardTagKey::Genre => genre = Some(value),
StandardTagKey::Label => label = Some(value),
_ => {}
}
}
TrackMeta {
title: title.unwrap_or_else(|| fallback_title.to_string()),
artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
album_artist,
album: album.unwrap_or_else(|| "Unknown Album".to_string()),
date,
disc,
track_number,
genre,
label,
duration_ms: None,
codec: None,
sample_rate: None,
bit_depth: None,
channels: None,
bitrate: None,
size_bytes: None,
mtime: None,
path: None,
source: "streaming".to_string(),
remote_id: None,
remote_url: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_audio_file() {
assert!(is_audio_file(Path::new("track.flac")));
assert!(is_audio_file(Path::new("track.FLAC")));
assert!(is_audio_file(Path::new("track.mp3")));
assert!(is_audio_file(Path::new("track.m4a")));
assert!(is_audio_file(Path::new("track.ogg")));
assert!(is_audio_file(Path::new("track.opus")));
assert!(is_audio_file(Path::new("track.wv")));
assert!(is_audio_file(Path::new("track.wav")));
assert!(is_audio_file(Path::new("track.aiff")));
assert!(is_audio_file(Path::new("track.ape")));
assert!(!is_audio_file(Path::new("cover.jpg")));
assert!(!is_audio_file(Path::new("notes.txt")));
assert!(!is_audio_file(Path::new("playlist.m3u")));
assert!(!is_audio_file(Path::new("track.pdf")));
assert!(!is_audio_file(Path::new("noext")));
}
#[test]
fn test_is_audio_file_paths() {
assert!(is_audio_file(Path::new("/music/artist/album/01.flac")));
assert!(!is_audio_file(Path::new("/music/artist/album/cover.png")));
}
#[test]
fn test_read_metadata_nonexistent() {
let result = read_metadata(Path::new("/nonexistent/track.flac"));
assert!(result.is_err());
}
#[test]
fn test_codec_string() {
assert_eq!(codec_string(lofty::file::FileType::Flac), "FLAC");
assert_eq!(codec_string(lofty::file::FileType::Mpeg), "MP3");
assert_eq!(codec_string(lofty::file::FileType::Opus), "Opus");
assert_eq!(codec_string(lofty::file::FileType::Wav), "WAV");
}
use symphonia::core::meta::{MetadataBuilder, StandardTagKey, Tag, Value};
fn make_revision(tags: &[(StandardTagKey, &str)]) -> symphonia::core::meta::MetadataRevision {
let mut builder = MetadataBuilder::new();
for (key, value) in tags {
builder.add_tag(Tag::new(Some(*key), "", Value::String(value.to_string())));
}
builder.metadata()
}
#[test]
fn test_probe_track_number_slash_format() {
let rev = make_revision(&[
(StandardTagKey::TrackTitle, "My Song"),
(StandardTagKey::Artist, "Artist"),
(StandardTagKey::Album, "Album"),
(StandardTagKey::TrackNumber, "3/12"),
]);
let meta = metadata_from_probe_result(&rev, "fallback");
assert_eq!(
meta.track_number,
Some(3),
"slash-format track number should parse to the first component"
);
}
#[test]
fn test_probe_original_date_fallback() {
let rev = make_revision(&[
(StandardTagKey::TrackTitle, "My Song"),
(StandardTagKey::OriginalDate, "1991"),
]);
let meta = metadata_from_probe_result(&rev, "fallback");
assert_eq!(
meta.date,
Some("1991".to_string()),
"OriginalDate should be used when Date is missing"
);
}
#[test]
fn test_probe_original_date_not_used_when_date_present() {
let rev = make_revision(&[
(StandardTagKey::Date, "2005"),
(StandardTagKey::OriginalDate, "1991"),
]);
let meta = metadata_from_probe_result(&rev, "fallback");
assert_eq!(
meta.date,
Some("2005".to_string()),
"Date should take precedence over OriginalDate"
);
}
#[test]
fn test_probe_empty_values_skipped() {
let rev = make_revision(&[
(StandardTagKey::Artist, ""),
(StandardTagKey::Album, ""),
(StandardTagKey::Genre, ""),
]);
let meta = metadata_from_probe_result(&rev, "Title");
assert_eq!(
meta.artist, "Unknown Artist",
"empty artist tag should fall back to 'Unknown Artist'"
);
assert_eq!(
meta.album, "Unknown Album",
"empty album tag should fall back to 'Unknown Album'"
);
assert_eq!(meta.genre, None, "empty genre tag should produce None");
}
#[test]
fn test_probe_defaults() {
let rev = make_revision(&[]);
let meta = metadata_from_probe_result(&rev, "Fallback Title");
assert_eq!(
meta.title, "Fallback Title",
"missing title should use fallback_title argument"
);
assert_eq!(
meta.artist, "Unknown Artist",
"missing artist should default to 'Unknown Artist'"
);
assert_eq!(
meta.album, "Unknown Album",
"missing album should default to 'Unknown Album'"
);
assert_eq!(meta.track_number, None);
assert_eq!(meta.date, None);
assert_eq!(meta.genre, None);
assert_eq!(meta.source, "streaming");
}
#[test]
fn test_mp4_codec_nonexistent_file_falls_back_to_aac() {
assert_eq!(mp4_codec(Path::new("/nonexistent/track.m4a")), "AAC");
}
#[test]
fn test_mp4_codec_non_mp4_file_falls_back_to_aac() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
assert_eq!(mp4_codec(&manifest), "AAC");
}
#[test]
#[cfg(target_os = "macos")]
fn test_mp4_codec_real_alac_file() {
let alac_path = Path::new(
"/Volumes/Turtlehead/music/Valet Girls/(2017) PERENNIAL VICE [ALAC]/0101. Valet Girls - Tis the Season.m4a",
);
if !alac_path.exists() {
eprintln!("SKIP: ALAC test file not found (volume not mounted)");
return;
}
assert_eq!(
mp4_codec(alac_path),
"ALAC",
"real ALAC .m4a should be identified as ALAC, not AAC"
);
}
}