fix(metadata): read tags and covers from MP4 files named opus

This commit is contained in:
zarzet committed 2026-09-15 21:44:45 +07:00
1 parent 347208b3d9
commit 097af1532f
3 files changed
+122 -20

No files matched your search

+101 -1
View File
@@ -28,8 +28,32 @@ pub struct CoverArt {
pub mime: String,
}
fn read_container_format<'a>(
reader: &mut (impl Read + Seek),
format: &'a str,
) -> Result<&'a str, String> {
if format != "opus" {
return Ok(format);
}
// Opus is also carried in MP4; the filename can still end in .opus.
reader
.seek(SeekFrom::Start(0))
.map_err(|error| error.to_string())?;
let mut header = [0; 8];
let read = reader.read_exact(&mut header);
reader
.seek(SeekFrom::Start(0))
.map_err(|error| error.to_string())?;
match read {
Ok(()) if &header[4..] == b"ftyp" => Ok("m4a"),
Ok(()) => Ok(format),
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(format),
Err(error) => Err(error.to_string()),
}
}
/// Extract the embedded bytes without decoding the image or reading audio into
/// memory. Format selection/hints and output publication belong to the owner.
/// memory. Path hints and output publication belong to the owner.
pub fn extract_cover(
reader: &mut (impl Read + Seek),
format: &str,
@@ -44,6 +68,7 @@ pub fn extract_cover(
check,
failure: None,
};
let format = read_container_format(&mut observed, format)?;
let mut reader = BufReader::new(&mut observed);
let result = match format {
"flac" => containers::flac_cover(&mut reader),
@@ -173,6 +198,7 @@ fn read_tags(
reader: BufReader::new(CheckedReader { reader, check }),
position: 0,
};
let format = read_container_format(&mut reader, format)?;
let result = match format {
"flac" => match cover {
Some(cover) => containers::flac_with_cover(&mut reader, cover),
@@ -239,3 +265,77 @@ fn truthy(value: &str) -> bool {
"1" | "true" | "yes" | "explicit"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
[
((body.len() + 8) as u32).to_be_bytes().as_slice(),
kind,
body,
]
.concat()
}
#[test]
fn opus_in_mp4_reads_tags_cover_and_library_quality() {
let mut ilst = Vec::new();
let cover = b"\xff\xd8\xff\xd9";
for (kind, value) in [
(b"\xa9nam", b"Track Title".as_slice()),
(b"\xa9ART", b"Artist Name".as_slice()),
(b"covr", cover.as_slice()),
] {
ilst.extend(atom(kind, &atom(b"data", &[&[0; 8], value].concat())));
}
let metadata = atom(
b"udta",
&atom(
b"meta",
&[&[0; 4], atom(b"ilst", &ilst).as_slice()].concat(),
),
);
let mut entry = [0; 28];
entry[24..26].copy_from_slice(&48_000_u16.to_be_bytes());
let moov = [metadata, atom(b"Opus", &entry)].concat();
let data = [atom(b"ftyp", b"isom\0\0\0\0"), atom(b"moov", &moov)].concat();
for suffix in ["opus", "m4a"] {
let mut reader = Cursor::new(&data);
reader.set_position(data.len() as u64);
let path = format!("Track Title - Artist Name.{suffix}");
let scan = read_library_metadata(&mut reader, &path, "", "", 0, &|| Ok(())).unwrap();
assert_eq!(scan["trackName"], "Track Title");
assert_eq!(scan["artistName"], "Artist Name");
assert_eq!(scan["format"], "opus");
assert_eq!(scan["sampleRate"], 48_000);
assert!(scan.get("metadataFromFilename").is_none());
assert_eq!(
read_audio_tags(&mut reader, suffix, &|| Ok(()))
.unwrap()
.title,
"Track Title"
);
let metadata = read_file_metadata(&mut reader, &path, "", &|| Ok(())).unwrap();
assert_eq!(metadata["title"], "Track Title");
assert_eq!(metadata["audio_codec"], "opus");
assert_eq!(
extract_cover(&mut reader, suffix, &|| Ok(())).unwrap().data,
cover
);
assert_eq!(reader.into_inner(), &data);
}
let mut reader = Cursor::new(&data);
assert!(read_file_metadata(&mut reader, "misnamed.flac", "", &|| Ok(())).is_err());
assert_eq!(
read_audio_tags(&mut reader, "opus", &|| Err("cancelled".into())).unwrap_err(),
"cancelled"
);
assert_eq!(
extract_cover(&mut reader, "opus", &|| Err("cancelled".into())).unwrap_err(),
"cancelled"
);
}
}
+13 -12
View File
@@ -1,6 +1,6 @@
//! Application metadata JSON, preserving the distinct legacy tag-reader JSON.
use super::{AudioMetadata, read_audio_tags};
use super::{AudioMetadata, read_audio_tags, read_container_format};
use crate::media::{mp3_quality, ogg_quality, probe_mp4_quality, probe_quality, riff_quality};
use serde_json::{Map, Value};
use std::io::{self, Read, Seek, SeekFrom};
@@ -31,20 +31,21 @@ pub fn read_file_metadata(
fn read_metadata(file: &mut (impl Read + Seek), path: &str, hint: &str) -> Result<Value, String> {
let extension = file_metadata_extension(path, hint)?;
let (mut format, mut codec) = match extension.as_str() {
".flac" => ("flac", "flac"),
".m4a" | ".mp4" | ".aac" => ("m4a", ""),
".mp3" => ("mp3", "mp3"),
".ogg" | ".opus" => ("opus", "opus"),
".ape" => ("ape", "ape"),
".wv" => ("wv", "wv"),
".mpc" => ("mpc", "mpc"),
".wav" => ("wav", "pcm"),
".aiff" | ".aif" | ".aifc" => ("aiff", "pcm"),
let container = read_container_format(file, &extension[1..])?;
let (mut format, mut codec) = match container {
"flac" => ("flac", "flac"),
"m4a" | "mp4" | "aac" => ("m4a", ""),
"mp3" => ("mp3", "mp3"),
"ogg" | "opus" => ("opus", "opus"),
"ape" => ("ape", "ape"),
"wv" => ("wv", "wv"),
"mpc" => ("mpc", "mpc"),
"wav" => ("wav", "pcm"),
"aiff" | "aif" | "aifc" => ("aiff", "pcm"),
_ => unreachable!("validated metadata extension"),
};
let size = file.seek(SeekFrom::End(0)).map_err(|e| e.to_string())? as i64;
let mut metadata = read_audio_tags(file, &extension[1..], &|| Ok(()));
let mut metadata = read_audio_tags(file, container, &|| Ok(()));
if format == "flac" && metadata.is_err() {
let fallback = read_audio_tags(file, "ogg", &|| Ok(()));
if fallback.is_ok() {
+8 -7
View File
@@ -1,6 +1,6 @@
//! Library scan DTOs composed from the existing tag and quality readers.
use super::{AudioMetadata, CoverArt, file::ObservedReader, read_tags};
use super::{AudioMetadata, CoverArt, file::ObservedReader, read_container_format, read_tags};
use crate::lyrics::lrc::has_usable_content;
use crate::media::{mp3_quality, ogg_quality, probe_mp4_quality, probe_quality, riff_quality};
use serde_json::{Value, json};
@@ -140,19 +140,20 @@ fn read(
mut cover: Option<&mut Option<CoverArt>>,
) -> Result<Value, String> {
let mut result = library_metadata(path, hint, scan_time, mod_time);
let format = library_extension(path, hint);
let metadata = read_tags(file, &format, &|| Ok(()), cover.as_deref_mut());
let extension = library_extension(path, hint);
let format = read_container_format(file, &extension)?;
let metadata = read_tags(file, format, &|| Ok(()), cover.as_deref_mut());
let tagged = metadata.is_ok();
if !tagged && let Some(cover) = cover {
*cover = None;
}
if let Ok(metadata) = metadata {
apply_tags(&mut result, metadata, &format, path, hint)?;
apply_tags(&mut result, metadata, format, path, hint)?;
}
// MP4 and RIFF probe quality independently of whether tags are present.
if !tagged
&& !matches!(
format.as_str(),
format,
"m4a" | "mp4" | "aac" | "wav" | "aiff" | "aif" | "aifc"
)
{
@@ -163,7 +164,7 @@ fn read(
.map_err(|error| error.to_string())?;
file.seek(SeekFrom::Start(0))
.map_err(|error| error.to_string())?;
let quality = match format.as_str() {
let quality = match format {
"flac" => probe_quality(file, &|| Ok(())),
"m4a" | "mp4" | "aac" => probe_mp4_quality(file, &|| Ok(())),
"mp3" => mp3_quality(file, size as i64),
@@ -181,7 +182,7 @@ fn read(
result[key] = value.into();
}
}
let bitrate = match format.as_str() {
let bitrate = match format {
"flac" if quality.total_samples > 0 && quality.sample_rate > 0 => {
(size as f64 * 8.0
/ (quality.total_samples as f64 / quality.sample_rate as f64)