diff --git a/rust_backend/crates/core/src/tags.rs b/rust_backend/crates/core/src/tags.rs index 223169e4..375853d6 100644 --- a/rust_backend/crates/core/src/tags.rs +++ b/rust_backend/crates/core/src/tags.rs @@ -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 { + [ + ((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" + ); + } +} diff --git a/rust_backend/crates/core/src/tags/file.rs b/rust_backend/crates/core/src/tags/file.rs index 06fe1952..a1473296 100644 --- a/rust_backend/crates/core/src/tags/file.rs +++ b/rust_backend/crates/core/src/tags/file.rs @@ -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 { 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() { diff --git a/rust_backend/crates/core/src/tags/library.rs b/rust_backend/crates/core/src/tags/library.rs index 22f8ea4a..09abaa29 100644 --- a/rust_backend/crates/core/src/tags/library.rs +++ b/rust_backend/crates/core/src/tags/library.rs @@ -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>, ) -> Result { 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)