2026-08-17 22:30:43 -04:00
|
|
|
//! Audio tag extraction via [`lofty`]. Concatenates the searchable tag
|
|
|
|
|
//! values — title, artist, album, genre, comment — into `text` so full-text
|
|
|
|
|
//! search works across them.
|
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},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use super::{ExtractError, ExtractedContent, Extractor};
|
|
|
|
|
|
|
|
|
|
pub struct AudioExtractor;
|
|
|
|
|
|
|
|
|
|
impl Extractor for AudioExtractor {
|
|
|
|
|
fn supports(&self, mime: &str) -> bool {
|
|
|
|
|
mime.starts_with("audio/")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn extract(&self, path: &Path) -> Result<ExtractedContent, ExtractError> {
|
|
|
|
|
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-17 22:30:43 -04:00
|
|
|
// properties (parked): year, track and duration went to the property
|
|
|
|
|
// map alone and never reached `text`, so nothing collects them now.
|
|
|
|
|
// See `super::ExtractedContent`.
|
|
|
|
|
let mut pieces: Vec<String> = Vec::new();
|
2026-04-21 23:00:47 -04:00
|
|
|
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
|
2026-08-17 22:30:43 -04:00
|
|
|
let mut push = |value: Option<String>| {
|
|
|
|
|
if let Some(v) = value.filter(|v: &String| !v.is_empty()) {
|
|
|
|
|
pieces.push(v);
|
2026-04-21 23:00:47 -04:00
|
|
|
}
|
2026-08-17 22:30:43 -04:00
|
|
|
};
|
|
|
|
|
// `ItemKey` first, falling back to the `Accessor` shortcut for the
|
|
|
|
|
// three fields that have one — a tag can carry the value under
|
|
|
|
|
// either.
|
|
|
|
|
push(
|
|
|
|
|
tag.get_string(&ItemKey::TrackTitle)
|
|
|
|
|
.filter(|v| !v.is_empty())
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
.or_else(|| tag.title().map(|t| t.to_string())),
|
|
|
|
|
);
|
|
|
|
|
push(
|
|
|
|
|
tag.get_string(&ItemKey::TrackArtist)
|
|
|
|
|
.filter(|v| !v.is_empty())
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
.or_else(|| tag.artist().map(|a| a.to_string())),
|
|
|
|
|
);
|
|
|
|
|
push(
|
|
|
|
|
tag.get_string(&ItemKey::AlbumTitle)
|
|
|
|
|
.filter(|v| !v.is_empty())
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
.or_else(|| tag.album().map(|a| a.to_string())),
|
|
|
|
|
);
|
|
|
|
|
push(tag.get_string(&ItemKey::Genre).map(str::to_string));
|
|
|
|
|
push(tag.get_string(&ItemKey::Comment).map(str::to_string));
|
2026-04-21 23:00:47 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 22:30:43 -04:00
|
|
|
Ok(ExtractedContent::with_text(pieces.join(" ")))
|
2026-04-21 23:00:47 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
2026-08-17 22:30:43 -04:00
|
|
|
/// A minimal but real MPEG file: an ID3v2.3 tag carrying `frames`
|
|
|
|
|
/// (`("TPE1", "…")` and friends), followed by one silent MPEG-1 Layer III
|
|
|
|
|
/// frame so the probe recognizes the format from its content.
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// MPEG-1 Layer III, 128 kbps, 44.1 kHz, no padding: 417-byte frames.
|
|
|
|
|
// Four of them, because the probe confirms a sync word by finding the
|
|
|
|
|
// next frame where the first one says it will be.
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The searchable text is assembled from the tag values, which is the
|
|
|
|
|
/// only reason audio files are full-text indexed at all. Pins the
|
|
|
|
|
/// rewrite that dropped the property map this used to be built from.
|
|
|
|
|
#[test]
|
|
|
|
|
fn tag_values_become_searchable_text() {
|
|
|
|
|
let path = write_mp3(
|
|
|
|
|
"audio-tags",
|
|
|
|
|
&[
|
|
|
|
|
("TIT2", "Blue Monday"),
|
|
|
|
|
("TPE1", "New Order"),
|
|
|
|
|
("TALB", "Power Corruption"),
|
|
|
|
|
("TCON", "Synthpop"),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
let out = AudioExtractor.extract(&path).expect("extract");
|
|
|
|
|
for expected in ["Blue Monday", "New Order", "Power Corruption", "Synthpop"] {
|
|
|
|
|
assert!(
|
|
|
|
|
out.text.contains(expected),
|
|
|
|
|
"{:?} missing from {:?}",
|
|
|
|
|
expected,
|
|
|
|
|
out.text
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// Title first, then artist, album, genre — a stable order so the
|
|
|
|
|
// stored text does not churn between runs.
|
|
|
|
|
assert_eq!(out.text, "Blue Monday New Order Power Corruption Synthpop");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// No tags at all is a successful extraction with nothing to store, not
|
|
|
|
|
/// a failure — `set_content_done` writes no sidecar row for it.
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_untagged_file_yields_empty_text() {
|
|
|
|
|
let path = write_mp3("audio-untagged", &[]);
|
|
|
|
|
let out = AudioExtractor.extract(&path).expect("extract");
|
|
|
|
|
assert!(out.text.is_empty(), "unexpected text {:?}", out.text);
|
|
|
|
|
}
|
|
|
|
|
|
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"));
|
|
|
|
|
}
|
|
|
|
|
}
|