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, StandardTagKey};
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::LoftyError),
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                // lofty 0.23 removed year() — use TrackDate or RecordingDate.
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::CODEC_TYPE_NULL;
216    use symphonia::core::formats::FormatOptions;
217    use symphonia::core::io::MediaSourceStream;
218    use symphonia::core::meta::MetadataOptions;
219    use symphonia::core::probe::Hint;
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 probed = match symphonia::default::get_probe().format(
241        &hint,
242        mss,
243        &FormatOptions::default(),
244        &MetadataOptions::default(),
245    ) {
246        Ok(p) => p,
247        Err(_) => return empty,
248    };
249
250    let track = match probed.format.default_track() {
251        Some(t) => t,
252        None => return empty,
253    };
254
255    let params = &track.codec_params;
256    let sample_rate = params.sample_rate.map(|r| r as i32);
257    let bit_depth = params.bits_per_sample.map(|b| b as i32);
258    let channels = params.channels.map(|c| c.count() as i32);
259
260    let duration_ms = params.n_frames.and_then(|frames| {
261        params
262            .sample_rate
263            .map(|sr| (frames as f64 / sr as f64 * 1000.0) as i64)
264    });
265
266    let bitrate = params.sample_rate.and_then(|sr| {
267        params.bits_per_sample.and_then(|bps| {
268            params
269                .channels
270                .map(|ch| (sr as i32 * bps as i32 * ch.count() as i32) / 1000)
271        })
272    });
273
274    let codec = if params.codec != CODEC_TYPE_NULL {
275        Some(symphonia_codec_name(params.codec))
276    } else {
277        None
278    };
279
280    SymphoniaProps {
281        duration_ms,
282        sample_rate,
283        bit_depth,
284        channels,
285        bitrate,
286        codec,
287    }
288}
289
290/// Map Symphonia codec type to a human-readable string.
291fn symphonia_codec_name(codec: symphonia::core::codecs::CodecType) -> String {
292    use symphonia::core::codecs;
293    match codec {
294        codecs::CODEC_TYPE_FLAC => "FLAC".to_string(),
295        codecs::CODEC_TYPE_MP3 => "MP3".to_string(),
296        codecs::CODEC_TYPE_AAC => "AAC".to_string(),
297        codecs::CODEC_TYPE_ALAC => "ALAC".to_string(),
298        codecs::CODEC_TYPE_VORBIS => "Vorbis".to_string(),
299        codecs::CODEC_TYPE_OPUS => "Opus".to_string(),
300        codecs::CODEC_TYPE_WAVPACK => "WavPack".to_string(),
301        codecs::CODEC_TYPE_PCM_S16LE
302        | codecs::CODEC_TYPE_PCM_S24LE
303        | codecs::CODEC_TYPE_PCM_S32LE
304        | codecs::CODEC_TYPE_PCM_F32LE
305        | codecs::CODEC_TYPE_PCM_F64LE
306        | codecs::CODEC_TYPE_PCM_S16BE
307        | codecs::CODEC_TYPE_PCM_S24BE
308        | codecs::CODEC_TYPE_PCM_S32BE
309        | codecs::CODEC_TYPE_PCM_F32BE
310        | codecs::CODEC_TYPE_PCM_F64BE
311        | codecs::CODEC_TYPE_PCM_U8 => "PCM".to_string(),
312        _ => "Unknown".to_string(),
313    }
314}
315
316/// Try to extract basic tags (title, artist, album) via Symphonia's metadata reader.
317/// Symphonia is more lenient with corrupted ID3 frames than lofty.
318fn probe_symphonia_tags(path: &Path) -> (Option<String>, Option<String>, Option<String>) {
319    use symphonia::core::formats::FormatOptions;
320    use symphonia::core::io::MediaSourceStream;
321    use symphonia::core::meta::{MetadataOptions, StandardTagKey};
322    use symphonia::core::probe::Hint;
323
324    let file = match std::fs::File::open(path) {
325        Ok(f) => f,
326        Err(_) => return (None, None, None),
327    };
328    let mss = MediaSourceStream::new(Box::new(file), Default::default());
329    let mut hint = Hint::new();
330    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
331        hint.with_extension(ext);
332    }
333
334    let mut probed = match symphonia::default::get_probe().format(
335        &hint,
336        mss,
337        &FormatOptions::default(),
338        &MetadataOptions::default(),
339    ) {
340        Ok(p) => p,
341        Err(_) => return (None, None, None),
342    };
343
344    let mut title = None;
345    let mut artist = None;
346    let mut album = None;
347
348    // Check metadata on the probe result itself.
349    if let Some(md) = probed.metadata.get()
350        && let Some(rev) = md.current()
351    {
352        for tag in rev.tags() {
353            match tag.std_key {
354                Some(StandardTagKey::TrackTitle) => title = Some(tag.value.to_string()),
355                Some(StandardTagKey::Artist) => artist = Some(tag.value.to_string()),
356                Some(StandardTagKey::Album) => album = Some(tag.value.to_string()),
357                _ => {}
358            }
359        }
360    }
361
362    // Also check format-level metadata.
363    if let Some(md) = probed.format.metadata().current() {
364        for tag in md.tags() {
365            match tag.std_key {
366                Some(StandardTagKey::TrackTitle) if title.is_none() => {
367                    title = Some(tag.value.to_string())
368                }
369                Some(StandardTagKey::Artist) if artist.is_none() => {
370                    artist = Some(tag.value.to_string())
371                }
372                Some(StandardTagKey::Album) if album.is_none() => {
373                    album = Some(tag.value.to_string())
374                }
375                _ => {}
376            }
377        }
378    }
379
380    (title, artist, album)
381}
382
383/// Determine codec for an MP4 container file (AAC, ALAC, etc.).
384/// Falls back to "AAC" if the file cannot be parsed as Mp4File.
385fn mp4_codec(path: &Path) -> String {
386    let file = match std::fs::File::open(path) {
387        Ok(f) => f,
388        Err(_) => return "AAC".to_string(),
389    };
390    let mut reader = std::io::BufReader::new(file);
391    match Mp4File::read_from(&mut reader, ParseOptions::new()) {
392        Ok(mp4) => match mp4.properties().codec() {
393            Mp4Codec::ALAC => "ALAC".to_string(),
394            Mp4Codec::MP3 => "MP3".to_string(),
395            Mp4Codec::FLAC => "FLAC".to_string(),
396            _ => "AAC".to_string(),
397        },
398        Err(_) => "AAC".to_string(),
399    }
400}
401
402/// Map lofty file type to a human-readable codec string.
403pub fn codec_string(ft: lofty::file::FileType) -> &'static str {
404    match ft {
405        lofty::file::FileType::Flac => "FLAC",
406        lofty::file::FileType::Mpeg => "MP3",
407        lofty::file::FileType::Mp4 => "AAC",
408        lofty::file::FileType::Opus => "Opus",
409        lofty::file::FileType::Vorbis => "Vorbis",
410        lofty::file::FileType::WavPack => "WavPack",
411        lofty::file::FileType::Wav => "WAV",
412        lofty::file::FileType::Aiff => "AIFF",
413        lofty::file::FileType::Ape => "APE",
414        _ => "Unknown",
415    }
416}
417
418/// Extract embedded front cover art bytes from an audio file.
419/// Returns raw image bytes (JPEG/PNG) or None. TIFF images are
420/// skipped — the `image` crate only has jpeg+png features and macOS
421/// CGImageDestination rejects TIFF for Now Playing artwork.
422pub fn extract_cover_art(path: &Path) -> Option<Vec<u8>> {
423    let tagged_file = lofty::read_from_path(path).ok()?;
424    let tag = tagged_file
425        .primary_tag()
426        .or_else(|| tagged_file.first_tag())?;
427
428    // Prefer CoverFront, fall back to first picture.
429    let pictures = tag.pictures();
430    let pic = pictures
431        .iter()
432        .find(|p| p.pic_type() == lofty::picture::PictureType::CoverFront && !is_tiff(p.data()))
433        .or_else(|| pictures.iter().find(|p| !is_tiff(p.data())))?;
434
435    Some(pic.data().to_vec())
436}
437
438/// TIFF magic bytes: `II*\0` (little-endian) or `MM\0*` (big-endian).
439fn is_tiff(data: &[u8]) -> bool {
440    data.len() >= 4
441        && ((data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00)
442            || (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A))
443}
444
445/// Extract partial track metadata from a Symphonia probe `MetadataRevision`.
446///
447/// Used during streaming playback to populate track info before the full file
448/// is downloaded (and before lofty can read complete tags).
449///
450/// Fields not present in the probe metadata are left as `None` or defaults.
451/// Callers should merge this with a full `read_metadata()` result once the
452/// download completes.
453pub fn metadata_from_probe_result(meta: &MetadataRevision, fallback_title: &str) -> TrackMeta {
454    let mut title: Option<String> = None;
455    let mut artist: Option<String> = None;
456    let mut album_artist: Option<String> = None;
457    let mut album: Option<String> = None;
458    let mut date: Option<String> = None;
459    let mut disc: Option<i32> = None;
460    let mut track_number: Option<i32> = None;
461    let mut genre: Option<String> = None;
462    let mut label: Option<String> = None;
463
464    for tag in meta.tags() {
465        let Some(std_key) = tag.std_key else { continue };
466        let value = tag.value.to_string();
467        if value.is_empty() {
468            continue;
469        }
470        match std_key {
471            StandardTagKey::TrackTitle => title = Some(value),
472            StandardTagKey::Artist => artist = Some(value),
473            StandardTagKey::AlbumArtist => album_artist = Some(value),
474            StandardTagKey::Album => album = Some(value),
475            StandardTagKey::Date => date = Some(value),
476            StandardTagKey::OriginalDate if date.is_none() => date = Some(value),
477            StandardTagKey::TrackNumber => {
478                track_number = value.split('/').next().and_then(|s| s.trim().parse().ok());
479            }
480            StandardTagKey::DiscNumber => {
481                disc = value.split('/').next().and_then(|s| s.trim().parse().ok());
482            }
483            StandardTagKey::Genre => genre = Some(value),
484            StandardTagKey::Label => label = Some(value),
485            _ => {}
486        }
487    }
488
489    TrackMeta {
490        title: title.unwrap_or_else(|| fallback_title.to_string()),
491        artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
492        album_artist,
493        album: album.unwrap_or_else(|| "Unknown Album".to_string()),
494        date,
495        disc,
496        track_number,
497        genre,
498        label,
499        duration_ms: None,
500        codec: None,
501        sample_rate: None,
502        bit_depth: None,
503        channels: None,
504        bitrate: None,
505        size_bytes: None,
506        mtime: None,
507        path: None,
508        source: "streaming".to_string(),
509        remote_id: None,
510        remote_url: None,
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn test_is_audio_file() {
520        assert!(is_audio_file(Path::new("track.flac")));
521        assert!(is_audio_file(Path::new("track.FLAC")));
522        assert!(is_audio_file(Path::new("track.mp3")));
523        assert!(is_audio_file(Path::new("track.m4a")));
524        assert!(is_audio_file(Path::new("track.ogg")));
525        assert!(is_audio_file(Path::new("track.opus")));
526        assert!(is_audio_file(Path::new("track.wv")));
527        assert!(is_audio_file(Path::new("track.wav")));
528        assert!(is_audio_file(Path::new("track.aiff")));
529        assert!(is_audio_file(Path::new("track.ape")));
530
531        assert!(!is_audio_file(Path::new("cover.jpg")));
532        assert!(!is_audio_file(Path::new("notes.txt")));
533        assert!(!is_audio_file(Path::new("playlist.m3u")));
534        assert!(!is_audio_file(Path::new("track.pdf")));
535        assert!(!is_audio_file(Path::new("noext")));
536    }
537
538    #[test]
539    fn test_is_audio_file_paths() {
540        assert!(is_audio_file(Path::new("/music/artist/album/01.flac")));
541        assert!(!is_audio_file(Path::new("/music/artist/album/cover.png")));
542    }
543
544    #[test]
545    fn test_read_metadata_nonexistent() {
546        let result = read_metadata(Path::new("/nonexistent/track.flac"));
547        assert!(result.is_err());
548    }
549
550    #[test]
551    fn test_codec_string() {
552        assert_eq!(codec_string(lofty::file::FileType::Flac), "FLAC");
553        assert_eq!(codec_string(lofty::file::FileType::Mpeg), "MP3");
554        assert_eq!(codec_string(lofty::file::FileType::Opus), "Opus");
555        assert_eq!(codec_string(lofty::file::FileType::Wav), "WAV");
556    }
557
558    // --- metadata_from_probe_result tests ---
559    //
560    // MetadataRevision has private fields and can only be constructed via
561    // MetadataBuilder (symphonia::core::meta::MetadataBuilder). Tag::new()
562    // is public, so we can build arbitrary revisions in tests.
563
564    use symphonia::core::meta::{MetadataBuilder, StandardTagKey, Tag, Value};
565
566    fn make_revision(tags: &[(StandardTagKey, &str)]) -> symphonia::core::meta::MetadataRevision {
567        let mut builder = MetadataBuilder::new();
568        for (key, value) in tags {
569            builder.add_tag(Tag::new(Some(*key), "", Value::String(value.to_string())));
570        }
571        builder.metadata()
572    }
573
574    #[test]
575    fn test_probe_track_number_slash_format() {
576        // "3/12" in TrackNumber tag should parse to track_number = Some(3),
577        // ignoring the total-tracks part after the slash.
578        let rev = make_revision(&[
579            (StandardTagKey::TrackTitle, "My Song"),
580            (StandardTagKey::Artist, "Artist"),
581            (StandardTagKey::Album, "Album"),
582            (StandardTagKey::TrackNumber, "3/12"),
583        ]);
584        let meta = metadata_from_probe_result(&rev, "fallback");
585        assert_eq!(
586            meta.track_number,
587            Some(3),
588            "slash-format track number should parse to the first component"
589        );
590    }
591
592    #[test]
593    fn test_probe_original_date_fallback() {
594        // When Date is absent, OriginalDate should be used as the date.
595        let rev = make_revision(&[
596            (StandardTagKey::TrackTitle, "My Song"),
597            (StandardTagKey::OriginalDate, "1991"),
598        ]);
599        let meta = metadata_from_probe_result(&rev, "fallback");
600        assert_eq!(
601            meta.date,
602            Some("1991".to_string()),
603            "OriginalDate should be used when Date is missing"
604        );
605    }
606
607    #[test]
608    fn test_probe_original_date_not_used_when_date_present() {
609        // When both Date and OriginalDate are present, Date wins.
610        let rev = make_revision(&[
611            (StandardTagKey::Date, "2005"),
612            (StandardTagKey::OriginalDate, "1991"),
613        ]);
614        let meta = metadata_from_probe_result(&rev, "fallback");
615        assert_eq!(
616            meta.date,
617            Some("2005".to_string()),
618            "Date should take precedence over OriginalDate"
619        );
620    }
621
622    #[test]
623    fn test_probe_empty_values_skipped() {
624        // Tags with empty string values should be silently skipped,
625        // leaving the corresponding fields as None (or falling back to defaults).
626        let rev = make_revision(&[
627            (StandardTagKey::Artist, ""),
628            (StandardTagKey::Album, ""),
629            (StandardTagKey::Genre, ""),
630        ]);
631        let meta = metadata_from_probe_result(&rev, "Title");
632        // Empty artist/album fall back to defaults, not empty string.
633        assert_eq!(
634            meta.artist, "Unknown Artist",
635            "empty artist tag should fall back to 'Unknown Artist'"
636        );
637        assert_eq!(
638            meta.album, "Unknown Album",
639            "empty album tag should fall back to 'Unknown Album'"
640        );
641        assert_eq!(meta.genre, None, "empty genre tag should produce None");
642    }
643
644    #[test]
645    fn test_probe_defaults() {
646        // When no tags are present, artist and album should use the hardcoded defaults.
647        // Title should fall back to the fallback_title argument.
648        let rev = make_revision(&[]);
649        let meta = metadata_from_probe_result(&rev, "Fallback Title");
650        assert_eq!(
651            meta.title, "Fallback Title",
652            "missing title should use fallback_title argument"
653        );
654        assert_eq!(
655            meta.artist, "Unknown Artist",
656            "missing artist should default to 'Unknown Artist'"
657        );
658        assert_eq!(
659            meta.album, "Unknown Album",
660            "missing album should default to 'Unknown Album'"
661        );
662        assert_eq!(meta.track_number, None);
663        assert_eq!(meta.date, None);
664        assert_eq!(meta.genre, None);
665        assert_eq!(meta.source, "streaming");
666    }
667
668    #[test]
669    fn test_mp4_codec_nonexistent_file_falls_back_to_aac() {
670        assert_eq!(mp4_codec(Path::new("/nonexistent/track.m4a")), "AAC");
671    }
672
673    #[test]
674    fn test_mp4_codec_non_mp4_file_falls_back_to_aac() {
675        // A non-MP4 file should fail to parse and fall back to "AAC".
676        let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
677        assert_eq!(mp4_codec(&manifest), "AAC");
678    }
679
680    #[test]
681    #[cfg(target_os = "macos")]
682    fn test_mp4_codec_real_alac_file() {
683        // Integration test: verify that a real ALAC .m4a file is correctly
684        // identified as "ALAC" rather than "AAC". Skipped silently when the
685        // Turtlehead volume is not mounted.
686        let alac_path = Path::new(
687            "/Volumes/Turtlehead/music/Valet Girls/(2017) PERENNIAL VICE [ALAC]/0101. Valet Girls - Tis the Season.m4a",
688        );
689        if !alac_path.exists() {
690            eprintln!("SKIP: ALAC test file not found (volume not mounted)");
691            return;
692        }
693        assert_eq!(
694            mp4_codec(alac_path),
695            "ALAC",
696            "real ALAC .m4a should be identified as ALAC, not AAC"
697        );
698    }
699}