Skip to main content

koan_core/index/
metadata.rs

1use std::fs;
2use std::path::Path;
3use std::time::UNIX_EPOCH;
4
5use lofty::config::ParseOptions;
6use lofty::file::AudioFile;
7use lofty::mp4::{Mp4Codec, Mp4File};
8use lofty::prelude::*;
9use symphonia::core::meta::{MetadataRevision, StandardTag};
10use thiserror::Error;
11
12use crate::db::queries::TrackMeta;
13
14#[derive(Debug, Error)]
15pub enum MetadataError {
16    #[error("io error: {0}")]
17    Io(#[from] std::io::Error),
18    #[error("tag error: {0}")]
19    Tag(#[from] lofty::error::FileParseError),
20}
21
22/// Audio file extensions we care about.
23const AUDIO_EXTENSIONS: &[&str] = &[
24    "flac", "mp3", "m4a", "aac", "ogg", "opus", "wv", "wav", "aiff", "aif", "alac", "ape",
25];
26
27/// Check if a path has a supported audio extension.
28pub fn is_audio_file(path: &Path) -> bool {
29    path.extension()
30        .and_then(|e| e.to_str())
31        .is_some_and(|ext| AUDIO_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
32}
33
34/// Read metadata from an audio file, returning a TrackMeta ready for DB insertion.
35///
36/// If lofty fails to parse tags (e.g. corrupted UTF-16 ID3 frames), falls back
37/// to Symphonia for duration/properties and infers what we can from the path.
38pub fn read_metadata(path: &Path) -> Result<TrackMeta, MetadataError> {
39    // Skip empty/tiny files — avoid confusing error messages from lofty/symphonia.
40    match std::fs::metadata(path) {
41        Ok(m) if m.len() == 0 => {
42            return Err(MetadataError::Io(std::io::Error::new(
43                std::io::ErrorKind::InvalidData,
44                format!(
45                    "empty file (0 bytes) — may be a stale mount or incomplete transfer, will retry on next scan: {}",
46                    path.display()
47                ),
48            )));
49        }
50        Err(e) => return Err(MetadataError::Io(e)),
51        _ => {}
52    }
53
54    match lofty::read_from_path(path) {
55        Ok(tagged_file) => read_metadata_lofty(path, &tagged_file),
56        Err(e) => {
57            log::warn!(
58                "lofty failed for {}: {}; falling back to probe",
59                path.display(),
60                e
61            );
62            read_metadata_fallback(path)
63        }
64    }
65}
66
67/// Full metadata read via lofty (happy path).
68fn read_metadata_lofty(
69    path: &Path,
70    tagged_file: &lofty::file::TaggedFile,
71) -> Result<TrackMeta, MetadataError> {
72    let properties = tagged_file.properties();
73    let duration_ms = properties.duration().as_millis() as i64;
74    let sample_rate = properties.sample_rate().map(|r| r as i32);
75    let bit_depth = properties.bit_depth().map(|b| b as i32);
76    let channels = properties.channels().map(|c| c as i32);
77    let bitrate = properties.audio_bitrate().map(|b| b as i32);
78
79    let tag = tagged_file
80        .primary_tag()
81        .or_else(|| tagged_file.first_tag());
82
83    let (title, artist, album_artist, album, date, disc, track_number, genre, label) =
84        if let Some(tag) = tag {
85            (
86                tag.title().map(|s| s.to_string()),
87                tag.artist().map(|s| s.to_string()),
88                tag.get_string(ItemKey::AlbumArtist).map(|s| s.to_string()),
89                tag.album().map(|s| s.to_string()),
90                // Prefer the track date, falling back to the recording date.
91                tag.get_string(ItemKey::Year)
92                    .or_else(|| tag.get_string(ItemKey::RecordingDate))
93                    .map(|s| s.to_string()),
94                tag.disk().map(|d| d as i32),
95                tag.track().map(|t| t as i32),
96                tag.genre().map(|s| s.to_string()),
97                tag.get_string(ItemKey::Label).map(|s| s.to_string()),
98            )
99        } else {
100            (None, None, None, None, None, None, None, None, None)
101        };
102
103    let title = title.unwrap_or_else(|| {
104        path.file_stem()
105            .and_then(|s| s.to_str())
106            .unwrap_or("Unknown")
107            .to_string()
108    });
109    let artist = artist.unwrap_or_else(|| "Unknown Artist".to_string());
110    let album = album.unwrap_or_else(|| "Unknown Album".to_string());
111
112    let file_meta = fs::metadata(path)?;
113    let size_bytes = file_meta.len() as i64;
114    let mtime = file_meta
115        .modified()
116        .ok()
117        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
118        .map(|d| d.as_secs() as i64);
119
120    let codec = if tagged_file.file_type() == lofty::file::FileType::Mp4 {
121        mp4_codec(path)
122    } else {
123        codec_string(tagged_file.file_type()).to_string()
124    };
125
126    Ok(TrackMeta {
127        title,
128        artist,
129        album_artist,
130        album,
131        date,
132        disc,
133        track_number,
134        genre,
135        label,
136        duration_ms: Some(duration_ms),
137        codec: Some(codec),
138        sample_rate,
139        bit_depth,
140        channels,
141        bitrate,
142        size_bytes: Some(size_bytes),
143        mtime,
144        path: Some(path.to_string_lossy().to_string()),
145        source: "local".to_string(),
146        remote_id: None,
147        remote_url: None,
148    })
149}
150
151/// Fallback metadata read when lofty fails. Uses Symphonia to probe
152/// duration/codec/properties, and infers artist/album/title from the path.
153fn read_metadata_fallback(path: &Path) -> Result<TrackMeta, MetadataError> {
154    let file_meta = fs::metadata(path)?;
155    let size_bytes = file_meta.len() as i64;
156    let mtime = file_meta
157        .modified()
158        .ok()
159        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
160        .map(|d| d.as_secs() as i64);
161
162    // Probe via Symphonia for duration, codec, and audio properties.
163    let props = probe_symphonia(path);
164
165    // Try to extract tags from Symphonia's metadata (it's more lenient than lofty
166    // for corrupted frames — it skips bad frames instead of erroring).
167    let (title, artist, album) = probe_symphonia_tags(path);
168
169    let title = title.unwrap_or_else(|| {
170        path.file_stem()
171            .and_then(|s| s.to_str())
172            .unwrap_or("Unknown")
173            .to_string()
174    });
175    let artist = artist.unwrap_or_else(|| "Unknown Artist".to_string());
176    let album = album.unwrap_or_else(|| "Unknown Album".to_string());
177
178    Ok(TrackMeta {
179        title,
180        artist,
181        album_artist: None,
182        album,
183        date: None,
184        disc: None,
185        track_number: None,
186        genre: None,
187        label: None,
188        duration_ms: props.duration_ms,
189        codec: props.codec,
190        sample_rate: props.sample_rate,
191        bit_depth: props.bit_depth,
192        channels: props.channels,
193        bitrate: props.bitrate,
194        size_bytes: Some(size_bytes),
195        mtime,
196        path: Some(path.to_string_lossy().to_string()),
197        source: "local".to_string(),
198        remote_id: None,
199        remote_url: None,
200    })
201}
202
203/// Probed audio properties from Symphonia.
204struct SymphoniaProps {
205    duration_ms: Option<i64>,
206    sample_rate: Option<i32>,
207    bit_depth: Option<i32>,
208    channels: Option<i32>,
209    bitrate: Option<i32>,
210    codec: Option<String>,
211}
212
213/// Probe audio properties via Symphonia (duration, sample rate, codec, etc.).
214fn probe_symphonia(path: &Path) -> SymphoniaProps {
215    use symphonia::core::codecs::audio::CODEC_ID_NULL_AUDIO;
216    use symphonia::core::formats::probe::Hint;
217    use symphonia::core::formats::{FormatOptions, TrackType};
218    use symphonia::core::io::MediaSourceStream;
219    use symphonia::core::meta::MetadataOptions;
220
221    let empty = SymphoniaProps {
222        duration_ms: None,
223        sample_rate: None,
224        bit_depth: None,
225        channels: None,
226        bitrate: None,
227        codec: None,
228    };
229
230    let file = match std::fs::File::open(path) {
231        Ok(f) => f,
232        Err(_) => return empty,
233    };
234    let mss = MediaSourceStream::new(Box::new(file), Default::default());
235    let mut hint = Hint::new();
236    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
237        hint.with_extension(ext);
238    }
239
240    let reader = match symphonia::default::get_probe().probe(
241        &hint,
242        mss,
243        FormatOptions::default(),
244        MetadataOptions::default(),
245    ) {
246        Ok(r) => r,
247        Err(_) => return empty,
248    };
249
250    let track = match reader.default_track(TrackType::Audio) {
251        Some(t) => t,
252        None => return empty,
253    };
254
255    let params = match track.codec_params.as_ref().and_then(|p| p.audio()) {
256        Some(p) => p,
257        None => return empty,
258    };
259    let sample_rate = params.sample_rate.map(|r| r as i32);
260    let bit_depth = params.bits_per_sample.map(|b| b as i32);
261    let channels = params.channels.as_ref().map(|c| c.count() as i32);
262
263    let duration_ms = params.sample_rate.and_then(|sr| {
264        let ms = crate::audio::buffer::track_duration_ms(&*reader, track, sr);
265        (ms > 0).then_some(ms as i64)
266    });
267
268    let bitrate = params.sample_rate.and_then(|sr| {
269        params.bits_per_sample.and_then(|bps| {
270            params
271                .channels
272                .as_ref()
273                .map(|ch| (sr as i32 * bps as i32 * ch.count() as i32) / 1000)
274        })
275    });
276
277    let codec = if params.codec != CODEC_ID_NULL_AUDIO {
278        Some(symphonia_codec_name(params.codec))
279    } else {
280        None
281    };
282
283    SymphoniaProps {
284        duration_ms,
285        sample_rate,
286        bit_depth,
287        channels,
288        bitrate,
289        codec,
290    }
291}
292
293/// Map Symphonia codec type to a human-readable string.
294fn symphonia_codec_name(codec: symphonia::core::codecs::audio::AudioCodecId) -> String {
295    use symphonia::core::codecs::audio::well_known as ids;
296    match codec {
297        ids::CODEC_ID_FLAC => "FLAC".to_string(),
298        ids::CODEC_ID_MP3 => "MP3".to_string(),
299        ids::CODEC_ID_AAC => "AAC".to_string(),
300        ids::CODEC_ID_ALAC => "ALAC".to_string(),
301        ids::CODEC_ID_VORBIS => "Vorbis".to_string(),
302        ids::CODEC_ID_OPUS => "Opus".to_string(),
303        ids::CODEC_ID_WAVPACK => "WavPack".to_string(),
304        ids::CODEC_ID_PCM_S16LE
305        | ids::CODEC_ID_PCM_S24LE
306        | ids::CODEC_ID_PCM_S32LE
307        | ids::CODEC_ID_PCM_F32LE
308        | ids::CODEC_ID_PCM_F64LE
309        | ids::CODEC_ID_PCM_S16BE
310        | ids::CODEC_ID_PCM_S24BE
311        | ids::CODEC_ID_PCM_S32BE
312        | ids::CODEC_ID_PCM_F32BE
313        | ids::CODEC_ID_PCM_F64BE
314        | ids::CODEC_ID_PCM_U8 => "PCM".to_string(),
315        _ => "Unknown".to_string(),
316    }
317}
318
319/// Try to extract basic tags (title, artist, album) via Symphonia's metadata reader.
320/// Symphonia is more lenient with corrupted ID3 frames than lofty.
321fn probe_symphonia_tags(path: &Path) -> (Option<String>, Option<String>, Option<String>) {
322    use symphonia::core::formats::FormatOptions;
323    use symphonia::core::formats::probe::Hint;
324    use symphonia::core::io::MediaSourceStream;
325    use symphonia::core::meta::{MetadataOptions, StandardTag};
326
327    let file = match std::fs::File::open(path) {
328        Ok(f) => f,
329        Err(_) => return (None, None, None),
330    };
331    let mss = MediaSourceStream::new(Box::new(file), Default::default());
332    let mut hint = Hint::new();
333    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
334        hint.with_extension(ext);
335    }
336
337    let mut reader = match symphonia::default::get_probe().probe(
338        &hint,
339        mss,
340        FormatOptions::default(),
341        MetadataOptions::default(),
342    ) {
343        Ok(r) => r,
344        Err(_) => return (None, None, None),
345    };
346
347    let mut title = None;
348    let mut artist = None;
349    let mut album = None;
350
351    // Metadata found while probing is queued on the reader ahead of the
352    // container's own, so walking the revision log oldest-first keeps the
353    // probe's tags authoritative and fills any gaps from the container.
354    let mut log = reader.metadata();
355    loop {
356        if let Some(rev) = log.current() {
357            for tag in &rev.media.tags {
358                match &tag.std {
359                    Some(StandardTag::TrackTitle(v)) if title.is_none() => {
360                        title = Some(v.to_string())
361                    }
362                    Some(StandardTag::Artist(v)) if artist.is_none() => {
363                        artist = Some(v.to_string())
364                    }
365                    Some(StandardTag::Album(v)) if album.is_none() => album = Some(v.to_string()),
366                    _ => {}
367                }
368            }
369        }
370        if log.pop().is_none() {
371            break;
372        }
373    }
374
375    (title, artist, album)
376}
377
378/// Determine codec for an MP4 container file (AAC, ALAC, etc.).
379/// Falls back to "AAC" if the file cannot be parsed as Mp4File.
380fn mp4_codec(path: &Path) -> String {
381    let file = match std::fs::File::open(path) {
382        Ok(f) => f,
383        Err(_) => return "AAC".to_string(),
384    };
385    let mut reader = std::io::BufReader::new(file);
386    match Mp4File::read_from(&mut reader, ParseOptions::new()) {
387        Ok(mp4) => match mp4.properties().codec() {
388            Some(Mp4Codec::ALAC) => "ALAC".to_string(),
389            Some(Mp4Codec::MP3) => "MP3".to_string(),
390            Some(Mp4Codec::FLAC) => "FLAC".to_string(),
391            _ => "AAC".to_string(),
392        },
393        Err(_) => "AAC".to_string(),
394    }
395}
396
397/// Map lofty file type to a human-readable codec string.
398pub fn codec_string(ft: lofty::file::FileType) -> &'static str {
399    match ft {
400        lofty::file::FileType::Flac => "FLAC",
401        lofty::file::FileType::Mpeg => "MP3",
402        lofty::file::FileType::Mp4 => "AAC",
403        lofty::file::FileType::Opus => "Opus",
404        lofty::file::FileType::Vorbis => "Vorbis",
405        lofty::file::FileType::WavPack => "WavPack",
406        lofty::file::FileType::Wav => "WAV",
407        lofty::file::FileType::Aiff => "AIFF",
408        lofty::file::FileType::Ape => "APE",
409        _ => "Unknown",
410    }
411}
412
413/// Extract embedded front cover art bytes from an audio file.
414/// Returns raw image bytes (JPEG/PNG) or None. TIFF images are
415/// skipped — the `image` crate only has jpeg+png features and macOS
416/// CGImageDestination rejects TIFF for Now Playing artwork.
417pub fn extract_cover_art(path: &Path) -> Option<Vec<u8>> {
418    let tagged_file = lofty::read_from_path(path).ok()?;
419    let tag = tagged_file
420        .primary_tag()
421        .or_else(|| tagged_file.first_tag())?;
422
423    // Prefer CoverFront, fall back to first picture.
424    let pictures = tag.pictures();
425    let pic = pictures
426        .iter()
427        .find(|p| p.pic_type() == lofty::picture::PictureType::CoverFront && !is_tiff(p.data()))
428        .or_else(|| pictures.iter().find(|p| !is_tiff(p.data())))?;
429
430    Some(pic.data().to_vec())
431}
432
433/// TIFF magic bytes: `II*\0` (little-endian) or `MM\0*` (big-endian).
434fn is_tiff(data: &[u8]) -> bool {
435    data.len() >= 4
436        && ((data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00)
437            || (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A))
438}
439
440/// Extract partial track metadata from a Symphonia probe `MetadataRevision`.
441///
442/// Used during streaming playback to populate track info before the full file
443/// is downloaded (and before lofty can read complete tags).
444///
445/// Fields not present in the probe metadata are left as `None` or defaults.
446/// Callers should merge this with a full `read_metadata()` result once the
447/// download completes.
448pub fn metadata_from_probe_result(meta: &MetadataRevision, fallback_title: &str) -> TrackMeta {
449    let mut title: Option<String> = None;
450    let mut artist: Option<String> = None;
451    let mut album_artist: Option<String> = None;
452    let mut album: Option<String> = None;
453    let mut date: Option<String> = None;
454    let mut disc: Option<i32> = None;
455    let mut track_number: Option<i32> = None;
456    let mut genre: Option<String> = None;
457    let mut label: Option<String> = None;
458
459    // A non-empty text tag replaces the field; empty values are ignored so a
460    // blank tag never shadows a later populated one or the fallback.
461    let set_text = |slot: &mut Option<String>, value: &str| {
462        if !value.is_empty() {
463            *slot = Some(value.to_string());
464        }
465    };
466
467    for tag in &meta.media.tags {
468        let Some(std) = &tag.std else { continue };
469        match std {
470            StandardTag::TrackTitle(v) => set_text(&mut title, v),
471            StandardTag::Artist(v) => set_text(&mut artist, v),
472            StandardTag::AlbumArtist(v) => set_text(&mut album_artist, v),
473            StandardTag::Album(v) => set_text(&mut album, v),
474            StandardTag::ReleaseDate(v) | StandardTag::RecordingDate(v) => set_text(&mut date, v),
475            StandardTag::ReleaseYear(y) | StandardTag::RecordingYear(y) if date.is_none() => {
476                date = Some(y.to_string())
477            }
478            StandardTag::OriginalReleaseDate(v) | StandardTag::OriginalRecordingDate(v)
479                if date.is_none() =>
480            {
481                set_text(&mut date, v)
482            }
483            StandardTag::OriginalReleaseYear(y) | StandardTag::OriginalRecordingYear(y)
484                if date.is_none() =>
485            {
486                date = Some(y.to_string())
487            }
488            StandardTag::TrackNumber(n) => track_number = Some(*n as i32),
489            StandardTag::DiscNumber(n) => disc = Some(*n as i32),
490            StandardTag::Genre(v) => set_text(&mut genre, v),
491            StandardTag::Label(v) => set_text(&mut label, v),
492            _ => {}
493        }
494    }
495
496    TrackMeta {
497        title: title.unwrap_or_else(|| fallback_title.to_string()),
498        artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
499        album_artist,
500        album: album.unwrap_or_else(|| "Unknown Album".to_string()),
501        date,
502        disc,
503        track_number,
504        genre,
505        label,
506        duration_ms: None,
507        codec: None,
508        sample_rate: None,
509        bit_depth: None,
510        channels: None,
511        bitrate: None,
512        size_bytes: None,
513        mtime: None,
514        path: None,
515        source: "streaming".to_string(),
516        remote_id: None,
517        remote_url: None,
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn test_is_audio_file() {
527        assert!(is_audio_file(Path::new("track.flac")));
528        assert!(is_audio_file(Path::new("track.FLAC")));
529        assert!(is_audio_file(Path::new("track.mp3")));
530        assert!(is_audio_file(Path::new("track.m4a")));
531        assert!(is_audio_file(Path::new("track.ogg")));
532        assert!(is_audio_file(Path::new("track.opus")));
533        assert!(is_audio_file(Path::new("track.wv")));
534        assert!(is_audio_file(Path::new("track.wav")));
535        assert!(is_audio_file(Path::new("track.aiff")));
536        assert!(is_audio_file(Path::new("track.ape")));
537
538        assert!(!is_audio_file(Path::new("cover.jpg")));
539        assert!(!is_audio_file(Path::new("notes.txt")));
540        assert!(!is_audio_file(Path::new("playlist.m3u")));
541        assert!(!is_audio_file(Path::new("track.pdf")));
542        assert!(!is_audio_file(Path::new("noext")));
543    }
544
545    #[test]
546    fn test_is_audio_file_paths() {
547        assert!(is_audio_file(Path::new("/music/artist/album/01.flac")));
548        assert!(!is_audio_file(Path::new("/music/artist/album/cover.png")));
549    }
550
551    #[test]
552    fn test_read_metadata_nonexistent() {
553        let result = read_metadata(Path::new("/nonexistent/track.flac"));
554        assert!(result.is_err());
555    }
556
557    #[test]
558    fn test_codec_string() {
559        assert_eq!(codec_string(lofty::file::FileType::Flac), "FLAC");
560        assert_eq!(codec_string(lofty::file::FileType::Mpeg), "MP3");
561        assert_eq!(codec_string(lofty::file::FileType::Opus), "Opus");
562        assert_eq!(codec_string(lofty::file::FileType::Wav), "WAV");
563    }
564
565    // --- metadata_from_probe_result tests ---
566
567    use symphonia::core::meta::well_known::METADATA_ID_ID3V2;
568    use symphonia::core::meta::{MetadataBuilder, MetadataInfo, StandardTag, Tag};
569
570    const TEST_META_INFO: MetadataInfo = MetadataInfo {
571        metadata: METADATA_ID_ID3V2,
572        short_name: "id3v2",
573        long_name: "ID3v2",
574    };
575
576    fn make_revision(tags: &[StandardTag]) -> symphonia::core::meta::MetadataRevision {
577        let mut builder = MetadataBuilder::new(TEST_META_INFO);
578        for std in tags {
579            builder.add_tag(Tag::new_from_parts("", "", Some(std.clone())));
580        }
581        builder.build()
582    }
583
584    #[test]
585    fn test_probe_track_and_disc_numbers() {
586        let rev = make_revision(&[
587            StandardTag::TrackTitle("My Song".to_string().into()),
588            StandardTag::Artist("Artist".to_string().into()),
589            StandardTag::Album("Album".to_string().into()),
590            StandardTag::TrackNumber(3),
591            StandardTag::TrackTotal(12),
592            StandardTag::DiscNumber(2),
593        ]);
594        let meta = metadata_from_probe_result(&rev, "fallback");
595        assert_eq!(
596            meta.track_number,
597            Some(3),
598            "track number should come from the track number tag, not the total"
599        );
600        assert_eq!(meta.disc, Some(2));
601    }
602
603    #[test]
604    fn test_probe_original_date_fallback() {
605        // When no release/recording date is present, the original date is used.
606        let rev = make_revision(&[
607            StandardTag::TrackTitle("My Song".to_string().into()),
608            StandardTag::OriginalReleaseDate("1991".to_string().into()),
609        ]);
610        let meta = metadata_from_probe_result(&rev, "fallback");
611        assert_eq!(
612            meta.date,
613            Some("1991".to_string()),
614            "original release date should be used when the release date is missing"
615        );
616    }
617
618    #[test]
619    fn test_probe_original_date_not_used_when_date_present() {
620        // The release date takes precedence over the original release date.
621        let rev = make_revision(&[
622            StandardTag::ReleaseDate("2005".to_string().into()),
623            StandardTag::OriginalReleaseDate("1991".to_string().into()),
624        ]);
625        let meta = metadata_from_probe_result(&rev, "fallback");
626        assert_eq!(
627            meta.date,
628            Some("2005".to_string()),
629            "release date should take precedence over original release date"
630        );
631    }
632
633    #[test]
634    fn test_probe_empty_values_skipped() {
635        // Tags with empty string values should be silently skipped,
636        // leaving the corresponding fields as None (or falling back to defaults).
637        let rev = make_revision(&[
638            StandardTag::Artist(String::new().into()),
639            StandardTag::Album(String::new().into()),
640            StandardTag::Genre(String::new().into()),
641        ]);
642        let meta = metadata_from_probe_result(&rev, "Title");
643        // Empty artist/album fall back to defaults, not empty string.
644        assert_eq!(
645            meta.artist, "Unknown Artist",
646            "empty artist tag should fall back to 'Unknown Artist'"
647        );
648        assert_eq!(
649            meta.album, "Unknown Album",
650            "empty album tag should fall back to 'Unknown Album'"
651        );
652        assert_eq!(meta.genre, None, "empty genre tag should produce None");
653    }
654
655    #[test]
656    fn test_probe_defaults() {
657        // When no tags are present, artist and album should use the hardcoded defaults.
658        // Title should fall back to the fallback_title argument.
659        let rev = make_revision(&[]);
660        let meta = metadata_from_probe_result(&rev, "Fallback Title");
661        assert_eq!(
662            meta.title, "Fallback Title",
663            "missing title should use fallback_title argument"
664        );
665        assert_eq!(
666            meta.artist, "Unknown Artist",
667            "missing artist should default to 'Unknown Artist'"
668        );
669        assert_eq!(
670            meta.album, "Unknown Album",
671            "missing album should default to 'Unknown Album'"
672        );
673        assert_eq!(meta.track_number, None);
674        assert_eq!(meta.date, None);
675        assert_eq!(meta.genre, None);
676        assert_eq!(meta.source, "streaming");
677    }
678
679    #[test]
680    fn test_mp4_codec_nonexistent_file_falls_back_to_aac() {
681        assert_eq!(mp4_codec(Path::new("/nonexistent/track.m4a")), "AAC");
682    }
683
684    #[test]
685    fn test_mp4_codec_non_mp4_file_falls_back_to_aac() {
686        // A non-MP4 file should fail to parse and fall back to "AAC".
687        let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
688        assert_eq!(mp4_codec(&manifest), "AAC");
689    }
690
691    #[test]
692    #[cfg(target_os = "macos")]
693    fn test_mp4_codec_real_alac_file() {
694        // Integration test: verify that a real ALAC .m4a file is correctly
695        // identified as "ALAC" rather than "AAC". Skipped silently when the
696        // Turtlehead volume is not mounted.
697        let alac_path = Path::new(
698            "/Volumes/Turtlehead/music/Valet Girls/(2017) PERENNIAL VICE [ALAC]/0101. Valet Girls - Tis the Season.m4a",
699        );
700        if !alac_path.exists() {
701            eprintln!("SKIP: ALAC test file not found (volume not mounted)");
702            return;
703        }
704        assert_eq!(
705            mp4_codec(alac_path),
706            "ALAC",
707            "real ALAC .m4a should be identified as ALAC, not AAC"
708        );
709    }
710}