2026-08-23 00:33:53 -04:00
|
|
|
//! Audio tag extraction via [`lofty`]: title, artist, album, genre, and
|
|
|
|
|
//! comment concatenate into the searchable `text`.
|
2026-04-21 23:00:47 -04:00
|
|
|
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
use lofty::{
|
2026-08-17 22:30:43 -04:00
|
|
|
file::TaggedFileExt,
|
2026-04-21 23:00:47 -04:00
|
|
|
probe::Probe,
|
|
|
|
|
tag::{Accessor, ItemKey},
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
use super::{ExtractError, Extractor, Scratch};
|
2026-04-21 23:00:47 -04:00
|
|
|
|
|
|
|
|
pub struct AudioExtractor;
|
|
|
|
|
|
|
|
|
|
impl Extractor for AudioExtractor {
|
|
|
|
|
fn supports(&self, mime: &str) -> bool {
|
|
|
|
|
mime.starts_with("audio/")
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
fn extract(
|
|
|
|
|
&self,
|
|
|
|
|
path: &Path,
|
|
|
|
|
out: &mut String,
|
|
|
|
|
_scratch: &mut Scratch,
|
|
|
|
|
) -> Result<(), ExtractError> {
|
2026-04-21 23:00:47 -04:00
|
|
|
let tagged = Probe::open(path)
|
|
|
|
|
.map_err(|e| format!("lofty probe {}: {}", path.display(), e))?
|
|
|
|
|
.read()
|
|
|
|
|
.map_err(|e| format!("lofty read {}: {}", path.display(), e))?;
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
// Straight into the caller's buffer: the fields are short and few,
|
|
|
|
|
// and a `Vec<String>` then `join` allocated every piece twice over.
|
2026-04-21 23:00:47 -04:00
|
|
|
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
|
2026-08-25 01:04:36 -04:00
|
|
|
// The `Accessor` shortcuts hand back a `Cow`, so their fallbacks
|
|
|
|
|
// are bound here rather than inside an `or_else` that would let
|
|
|
|
|
// the temporary die before it is read.
|
|
|
|
|
let (title, artist, album) = (tag.title(), tag.artist(), tag.album());
|
|
|
|
|
let mut push = |value: Option<&str>| {
|
|
|
|
|
if let Some(v) = value.filter(|v: &&str| !v.is_empty()) {
|
|
|
|
|
if !out.is_empty() {
|
|
|
|
|
out.push(' ');
|
|
|
|
|
}
|
|
|
|
|
out.push_str(v);
|
2026-04-21 23:00:47 -04:00
|
|
|
}
|
2026-08-17 22:30:43 -04:00
|
|
|
};
|
2026-08-23 00:33:53 -04:00
|
|
|
// A tag can carry a value under `ItemKey` or the `Accessor` shortcut.
|
2026-08-17 22:30:43 -04:00
|
|
|
push(
|
|
|
|
|
tag.get_string(&ItemKey::TrackTitle)
|
|
|
|
|
.filter(|v| !v.is_empty())
|
2026-08-25 01:04:36 -04:00
|
|
|
.or(title.as_deref()),
|
2026-08-17 22:30:43 -04:00
|
|
|
);
|
|
|
|
|
push(
|
|
|
|
|
tag.get_string(&ItemKey::TrackArtist)
|
|
|
|
|
.filter(|v| !v.is_empty())
|
2026-08-25 01:04:36 -04:00
|
|
|
.or(artist.as_deref()),
|
2026-08-17 22:30:43 -04:00
|
|
|
);
|
|
|
|
|
push(
|
|
|
|
|
tag.get_string(&ItemKey::AlbumTitle)
|
|
|
|
|
.filter(|v| !v.is_empty())
|
2026-08-25 01:04:36 -04:00
|
|
|
.or(album.as_deref()),
|
2026-08-17 22:30:43 -04:00
|
|
|
);
|
2026-08-25 01:04:36 -04:00
|
|
|
push(tag.get_string(&ItemKey::Genre));
|
|
|
|
|
push(tag.get_string(&ItemKey::Comment));
|
2026-04-21 23:00:47 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
Ok(())
|
2026-04-21 23:00:47 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
/// The one-file form: these assert on text, not on buffer reuse.
|
|
|
|
|
fn extract(path: &std::path::Path) -> Result<String, ExtractError> {
|
|
|
|
|
let mut out = String::new();
|
|
|
|
|
let mut scratch = Scratch::new(&crate::config::Config::default());
|
|
|
|
|
AudioExtractor.extract(path, &mut out, &mut scratch).map(|()| out)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// An ID3v2.3 tag carrying `frames`, then silent MPEG frames so the probe
|
|
|
|
|
/// recognizes the format from content.
|
2026-08-17 22:30:43 -04:00
|
|
|
fn write_mp3(tag: &str, frames: &[(&str, &str)]) -> std::path::PathBuf {
|
|
|
|
|
let mut body = Vec::new();
|
|
|
|
|
for (id, value) in frames {
|
|
|
|
|
let mut payload = vec![0x00]; // ISO-8859-1
|
|
|
|
|
payload.extend_from_slice(value.as_bytes());
|
|
|
|
|
body.extend_from_slice(id.as_bytes());
|
|
|
|
|
// ID3v2.3 frame sizes are plain big-endian, unlike the tag size.
|
|
|
|
|
body.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
|
|
|
|
body.extend_from_slice(&[0, 0]); // flags
|
|
|
|
|
body.extend_from_slice(&payload);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
out.extend_from_slice(b"ID3");
|
|
|
|
|
out.extend_from_slice(&[0x03, 0x00, 0x00]); // v2.3, no flags
|
|
|
|
|
// Tag size is syncsafe: seven bits per byte.
|
|
|
|
|
let n = body.len() as u32;
|
|
|
|
|
out.extend_from_slice(&[
|
|
|
|
|
((n >> 21) & 0x7F) as u8,
|
|
|
|
|
((n >> 14) & 0x7F) as u8,
|
|
|
|
|
((n >> 7) & 0x7F) as u8,
|
|
|
|
|
(n & 0x7F) as u8,
|
|
|
|
|
]);
|
|
|
|
|
out.extend_from_slice(&body);
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
// MPEG-1 Layer III, 128 kbps, 44.1 kHz, no padding: 417-byte frames;
|
|
|
|
|
// the probe confirms sync by finding the next frame where the first says.
|
2026-08-17 22:30:43 -04:00
|
|
|
for _ in 0..4 {
|
|
|
|
|
out.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
|
|
|
|
|
out.resize(out.len() + 413, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let path = crate::testutil::scratch_dir(tag).join("track.mp3");
|
|
|
|
|
std::fs::write(&path, &out).expect("write fixture mp3");
|
|
|
|
|
path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tag_values_become_searchable_text() {
|
|
|
|
|
let path = write_mp3(
|
|
|
|
|
"audio-tags",
|
|
|
|
|
&[
|
|
|
|
|
("TIT2", "Blue Monday"),
|
|
|
|
|
("TPE1", "New Order"),
|
|
|
|
|
("TALB", "Power Corruption"),
|
|
|
|
|
("TCON", "Synthpop"),
|
|
|
|
|
],
|
|
|
|
|
);
|
2026-08-25 01:04:36 -04:00
|
|
|
let out = extract(&path).expect("extract");
|
2026-08-17 22:30:43 -04:00
|
|
|
for expected in ["Blue Monday", "New Order", "Power Corruption", "Synthpop"] {
|
|
|
|
|
assert!(
|
2026-08-23 00:33:53 -04:00
|
|
|
out.contains(expected),
|
2026-08-17 22:30:43 -04:00
|
|
|
"{:?} missing from {:?}",
|
|
|
|
|
expected,
|
2026-08-23 00:33:53 -04:00
|
|
|
out
|
2026-08-17 22:30:43 -04:00
|
|
|
);
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
assert_eq!(out, "Blue Monday New Order Power Corruption Synthpop");
|
2026-08-17 22:30:43 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_untagged_file_yields_empty_text() {
|
|
|
|
|
let path = write_mp3("audio-untagged", &[]);
|
2026-08-25 01:04:36 -04:00
|
|
|
let out = extract(&path).expect("extract");
|
2026-08-23 00:33:53 -04:00
|
|
|
assert!(out.is_empty(), "unexpected text {:?}", out);
|
2026-08-17 22:30:43 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 23:00:47 -04:00
|
|
|
#[test]
|
|
|
|
|
fn supports_audio_mimes() {
|
|
|
|
|
let e = AudioExtractor;
|
|
|
|
|
assert!(e.supports("audio/mpeg"));
|
|
|
|
|
assert!(e.supports("audio/flac"));
|
|
|
|
|
assert!(!e.supports("video/mp4"));
|
|
|
|
|
assert!(!e.supports("image/png"));
|
|
|
|
|
}
|
|
|
|
|
}
|