use super::picture_info::{PictureInfo, collect_pictures};
use crate::prelude::*;
use lofty::config::{ParseOptions, ParsingMode};
use lofty::error::{ErrorKind as LoftyErrorKind, LoftyError};
use lofty::file::{AudioFile, TaggedFile, TaggedFileExt};
use lofty::flac::FlacFile as LoftyFlacFile;
use lofty::mpeg::{ChannelMode, MpegFile};
use lofty::tag::{ItemKey, ItemValue};
use std::io::{Seek, SeekFrom};
const FLAC: &str = "flac";
const MP3: &str = "mp3";
pub(super) const EXTENSIONS: &[&str] = &[FLAC, MP3];
pub(crate) struct TagEntry {
pub(super) key: ItemKey,
pub(super) native: Option<String>,
pub(super) value: String,
}
pub(crate) struct TrackInfo {
pub(super) sub_path: String,
pub(super) file_type: String,
pub(super) file_size: u64,
pub(super) track: Option<String>,
pub(super) disc: Option<String>,
pub(super) duration: Duration,
pub(super) bit_rate: u32,
pub(super) sample_rate: u32,
pub(super) channels: String,
pub(super) bit_depth: Option<u8>,
pub(super) tags: Vec<TagEntry>,
pub(super) pictures: Vec<PictureInfo>,
pub(super) parsing_mode: Option<ParsingMode>,
pub(super) parsing_error: Option<String>,
}
impl TrackInfo {
#[cfg(test)]
pub(super) fn mock_flac() -> Self {
Self {
sub_path: String::new(),
file_type: "FLAC".to_owned(),
file_size: 1_048_576,
track: Some("1".to_owned()),
disc: Some("1".to_owned()),
duration: Duration::from_mins(1),
bit_rate: 800,
sample_rate: 44100,
channels: "2".to_owned(),
bit_depth: Some(16),
tags: Vec::new(),
pictures: Vec::new(),
parsing_mode: None,
parsing_error: None,
}
}
#[cfg(test)]
pub(super) fn mock_mp3() -> Self {
Self {
sub_path: String::new(),
file_type: "MP3".to_owned(),
file_size: 2_457_600,
track: Some("1".to_owned()),
disc: Some("1".to_owned()),
duration: Duration::from_mins(1),
bit_rate: 320,
sample_rate: 44100,
channels: "Joint stereo".to_owned(),
bit_depth: None,
tags: Vec::new(),
pictures: Vec::new(),
parsing_mode: None,
parsing_error: None,
}
}
pub(crate) fn read_dir(dir: &Path) -> Result<Vec<TrackInfo>, Failure<InspectAction>> {
let mut paths = DirectoryReader::new()
.with_extensions(EXTENSIONS.to_vec())
.read(dir)
.map_err(Failure::wrap_with_path(InspectAction::ReadDir, dir))?;
paths.sort();
let mut tracks: Vec<TrackInfo> = Vec::new();
for file_path in &paths {
tracks.push(TrackInfo::read(dir, file_path)?);
}
log_parsing_fallbacks(&tracks);
Ok(tracks)
}
pub(super) fn read(base: &Path, path: &Path) -> Result<Self, Failure<InspectAction>> {
let mut file =
File::open(path).map_err(Failure::wrap_with_path(InspectAction::OpenFile, path))?;
let file_size = file
.metadata()
.map_err(Failure::wrap_with_path(InspectAction::OpenFile, path))?
.len();
let sub_path = path
.strip_prefix(base)
.unwrap_or(path)
.to_string_lossy()
.into_owned();
let extension = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let mut info = match extension.as_str() {
FLAC => Self::from_flac(&mut file, path, ParseOptions::default()),
MP3 => read_mpeg_with_fallback(&mut file, path),
_ => Err(
Failure::new(InspectAction::OpenFile, InspectError::UnsupportedExtension)
.with("extension", extension)
.with_path(path),
),
}?;
info.sub_path = sub_path;
info.file_size = file_size;
Ok(info)
}
fn from_flac(
file: &mut File,
path: &Path,
options: ParseOptions,
) -> Result<Self, Failure<InspectAction>> {
let flac = LoftyFlacFile::read_from(file, options)
.map_err(Failure::wrap_with_path(InspectAction::ReadFlacFile, path))?;
let props = *flac.properties();
let tagged = TaggedFile::from(flac);
Ok(Self {
sub_path: String::new(),
file_type: "FLAC".to_owned(),
file_size: 0,
track: get_tag_string(&tagged, ItemKey::TrackNumber),
disc: get_tag_string(&tagged, ItemKey::DiscNumber),
duration: props.duration(),
bit_rate: props.audio_bitrate(),
sample_rate: props.sample_rate(),
channels: props.channels().to_string(),
bit_depth: Some(props.bit_depth()),
tags: collect_tags(&tagged),
pictures: collect_pictures(&tagged),
parsing_mode: None,
parsing_error: None,
})
}
fn from_mpeg(mpeg: MpegFile) -> Self {
let props = *mpeg.properties();
let tagged = TaggedFile::from(mpeg);
Self {
sub_path: String::new(),
file_type: "MP3".to_owned(),
file_size: 0,
track: get_tag_string(&tagged, ItemKey::TrackNumber),
disc: get_tag_string(&tagged, ItemKey::DiscNumber),
duration: props.duration(),
bit_rate: props.audio_bitrate(),
sample_rate: props.sample_rate(),
channels: format_channel_mode(*props.channel_mode()),
bit_depth: None,
tags: collect_tags(&tagged),
pictures: collect_pictures(&tagged),
parsing_mode: None,
parsing_error: None,
}
}
}
fn collect_tags(file: &TaggedFile) -> Vec<TagEntry> {
let mut result = Vec::new();
for tag in file.tags() {
let tag_type = tag.tag_type();
for item in tag.items() {
let native = item.key().map_key(tag_type).map(ToOwned::to_owned);
if let ItemValue::Text(text) | ItemValue::Locator(text) = item.value() {
result.push(TagEntry {
key: item.key(),
native,
value: text.clone(),
});
}
}
}
result
}
fn get_tag_string(file: &TaggedFile, key: ItemKey) -> Option<String> {
file.tags()
.iter()
.find_map(|t| t.get_string(key))
.map(ToOwned::to_owned)
}
fn log_parsing_fallbacks(tracks: &[TrackInfo]) {
let mut groups: BTreeMap<(String, String), Vec<&str>> = BTreeMap::new();
for track in tracks {
if let (Some(mode), Some(error)) = (&track.parsing_mode, &track.parsing_error) {
let key = (format_parsing_mode(*mode), error.clone());
groups.entry(key).or_default().push(&track.sub_path);
}
}
for ((mode, error), paths) in &groups {
let count = paths.len();
let noun = if count == 1 { "file" } else { "files" };
let mut message = format!("{count} {noun} required {mode} MPEG parsing\n{error}");
for path in paths {
message.push('\n');
message.push_str(path);
}
warn!("{message}");
}
}
fn read_mpeg_with_fallback(
file: &mut File,
path: &Path,
) -> Result<TrackInfo, Failure<InspectAction>> {
let modes = [
ParsingMode::Strict,
ParsingMode::BestAttempt,
ParsingMode::Relaxed,
];
let mut last_error: Option<LoftyError> = None;
for mode in modes {
let options = ParseOptions::default().parsing_mode(mode);
trace!(
"Parsing with {} mode: {}",
format_parsing_mode(mode),
path.display()
);
match MpegFile::read_from(&mut *file, options) {
Ok(mpeg) => {
let mut info = TrackInfo::from_mpeg(mpeg);
info.parsing_mode = Some(mode);
info.parsing_error = last_error.map(|e| format!("{e}"));
return Ok(info);
}
Err(e) => {
if !matches!(e.kind(), LoftyErrorKind::BadTimestamp(_)) {
return Err(Failure::new(InspectAction::ReadMpegFile, e).with_path(path));
}
trace!(
"Unable to parse with {} mode: {}",
format_parsing_mode(mode),
e
);
last_error = Some(e);
file.seek(SeekFrom::Start(0))
.map_err(Failure::wrap_with_path(InspectAction::SeekFile, path))?;
}
}
}
Err(Failure::new(
InspectAction::ReadMpegFile,
last_error.expect("should have at least one error"),
)
.with_path(path))
}
fn format_parsing_mode(mode: ParsingMode) -> String {
match mode {
ParsingMode::Strict => "strict".to_owned(),
ParsingMode::BestAttempt => "best attempt".to_owned(),
ParsingMode::Relaxed => "relaxed".to_owned(),
unknown => format!("{unknown:?}"),
}
}
fn format_channel_mode(mode: ChannelMode) -> String {
match mode {
ChannelMode::Stereo => "Stereo".to_owned(),
ChannelMode::JointStereo => "Joint stereo".to_owned(),
ChannelMode::DualChannel => "Dual channel".to_owned(),
ChannelMode::SingleChannel => "Mono".to_owned(),
}
}