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    // Embedded art is skipped: a scan wants tags and audio properties, and
55    // decoding a few hundred KB of JPEG out of every ID3v2 and FLAC block only
56    // to drop it dominated the run. `extract_cover_art` reads it separately,
57    // for the one track that needs it at the time it needs it.
58    match read_tagged_file(path) {
59        Ok(tagged_file) => read_metadata_lofty(path, &tagged_file),
60        Err(e) => {
61            log::warn!(
62                "lofty failed for {}: {}; falling back to probe",
63                path.display(),
64                e
65            );
66            read_metadata_fallback(path)
67        }
68    }
69}
70
71/// lofty refuses any tag that would allocate past a global cap, and its default
72/// is 16 MB — small enough that one record with 4000x4000 cover art in its
73/// Vorbis comments fails to parse at all, dropping every track on it to the
74/// Symphonia fallback with worse metadata and three file parses instead of one.
75/// The cap exists to stop a hostile file exhausting memory, which 256 MB still
76/// does on any machine that can run a music player.
77fn raise_allocation_limit() {
78    static ONCE: std::sync::Once = std::sync::Once::new();
79    ONCE.call_once(|| {
80        lofty::config::apply_global_options(
81            lofty::config::GlobalOptions::new().allocation_limit(256 * 1024 * 1024),
82        );
83    });
84}
85
86/// lofty's own reader, minus the pictures.
87///
88/// `extract_cover_art` still reads them, with the defaults, for whichever
89/// single track actually needs artwork.
90fn read_tagged_file(path: &Path) -> Result<lofty::file::TaggedFile, Box<dyn std::error::Error>> {
91    raise_allocation_limit();
92    Ok(lofty::probe::Probe::open(path)?
93        .options(ParseOptions::new().read_cover_art(false))
94        .read()?)
95}
96
97/// Full metadata read via lofty (happy path).
98fn read_metadata_lofty(
99    path: &Path,
100    tagged_file: &lofty::file::TaggedFile,
101) -> Result<TrackMeta, MetadataError> {
102    let properties = tagged_file.properties();
103    let duration_ms = properties.duration().as_millis() as i64;
104    let sample_rate = properties.sample_rate().map(|r| r as i32);
105    let bit_depth = properties.bit_depth().map(|b| b as i32);
106    let channels = properties.channels().map(|c| c as i32);
107    let bitrate = properties.audio_bitrate().map(|b| b as i32);
108
109    let tag = tagged_file
110        .primary_tag()
111        .or_else(|| tagged_file.first_tag());
112
113    let (title, artist, album_artist, album, date, disc, track_number, genre, label) =
114        if let Some(tag) = tag {
115            (
116                tag.title().map(|s| s.to_string()),
117                tag.artist().map(|s| s.to_string()),
118                tag.get_string(ItemKey::AlbumArtist).map(|s| s.to_string()),
119                tag.album().map(|s| s.to_string()),
120                // Prefer the track date, falling back to the recording date.
121                tag.get_string(ItemKey::Year)
122                    .or_else(|| tag.get_string(ItemKey::RecordingDate))
123                    .map(|s| s.to_string()),
124                tag.disk().map(|d| d as i32),
125                tag.track().map(|t| t as i32),
126                tag.genre().map(|s| s.to_string()),
127                tag.get_string(ItemKey::Label).map(|s| s.to_string()),
128            )
129        } else {
130            (None, None, None, None, None, None, None, None, None)
131        };
132
133    let title = title.unwrap_or_else(|| {
134        path.file_stem()
135            .and_then(|s| s.to_str())
136            .unwrap_or("Unknown")
137            .to_string()
138    });
139    let artist = artist.unwrap_or_else(|| "Unknown Artist".to_string());
140    let album = album.unwrap_or_else(|| "Unknown Album".to_string());
141
142    let file_meta = fs::metadata(path)?;
143    let size_bytes = file_meta.len() as i64;
144    let mtime = file_meta
145        .modified()
146        .ok()
147        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
148        .map(|d| d.as_secs() as i64);
149
150    let codec = if tagged_file.file_type() == lofty::file::FileType::Mp4 {
151        mp4_codec(path)
152    } else {
153        codec_string(tagged_file.file_type()).to_string()
154    };
155
156    Ok(TrackMeta {
157        title,
158        artist,
159        album_artist,
160        album,
161        date,
162        disc,
163        track_number,
164        genre,
165        label,
166        duration_ms: Some(duration_ms),
167        codec: Some(codec),
168        sample_rate,
169        bit_depth,
170        channels,
171        bitrate,
172        size_bytes: Some(size_bytes),
173        mtime,
174        path: Some(path.to_string_lossy().to_string()),
175        source: "local".to_string(),
176        remote_id: None,
177        album_remote_id: None,
178        artist_remote_id: None,
179        mbid: None,
180        remote_url: None,
181        album_added_at: mtime.and_then(iso8601_utc),
182    })
183}
184
185/// Fallback metadata read when lofty fails. Uses Symphonia to probe
186/// duration/codec/properties, and infers artist/album/title from the path.
187fn read_metadata_fallback(path: &Path) -> Result<TrackMeta, MetadataError> {
188    let file_meta = fs::metadata(path)?;
189    let size_bytes = file_meta.len() as i64;
190    let mtime = file_meta
191        .modified()
192        .ok()
193        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
194        .map(|d| d.as_secs() as i64);
195
196    // Probe via Symphonia for duration, codec, and audio properties.
197    let props = probe_symphonia(path);
198
199    // Try to extract tags from Symphonia's metadata (it's more lenient than lofty
200    // for corrupted frames — it skips bad frames instead of erroring).
201    //
202    // Everything it offers is taken, not just the three obvious fields: a row
203    // missing its track number cannot content-match the same track synced from
204    // a remote server, so a file that lands here would stay a duplicate even
205    // once its tags read correctly.
206    let tags = probe_symphonia_tags(path);
207
208    let title = tags.title.unwrap_or_else(|| {
209        path.file_stem()
210            .and_then(|s| s.to_str())
211            .unwrap_or("Unknown")
212            .to_string()
213    });
214    let artist = tags.artist.unwrap_or_else(|| "Unknown Artist".to_string());
215    let album = tags.album.unwrap_or_else(|| "Unknown Album".to_string());
216
217    Ok(TrackMeta {
218        title,
219        artist,
220        album_artist: tags.album_artist,
221        album,
222        date: tags.date,
223        disc: tags.disc,
224        track_number: tags.track_number,
225        genre: tags.genre,
226        label: None,
227        duration_ms: props.duration_ms,
228        codec: props.codec,
229        sample_rate: props.sample_rate,
230        bit_depth: props.bit_depth,
231        channels: props.channels,
232        bitrate: props.bitrate,
233        size_bytes: Some(size_bytes),
234        mtime,
235        path: Some(path.to_string_lossy().to_string()),
236        source: "local".to_string(),
237        remote_id: None,
238        album_remote_id: None,
239        artist_remote_id: None,
240        mbid: None,
241        remote_url: None,
242        album_added_at: mtime.and_then(iso8601_utc),
243    })
244}
245
246/// A unix timestamp as the same ISO 8601 UTC string a Subsonic server uses for
247/// `created`, so a library mixing local files and remote entries can be ordered
248/// by one column without comparing two date formats.
249fn iso8601_utc(unix_secs: i64) -> Option<String> {
250    chrono::DateTime::from_timestamp(unix_secs, 0)
251        .map(|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string())
252}
253
254/// Probed audio properties from Symphonia.
255struct SymphoniaProps {
256    duration_ms: Option<i64>,
257    sample_rate: Option<i32>,
258    bit_depth: Option<i32>,
259    channels: Option<i32>,
260    bitrate: Option<i32>,
261    codec: Option<String>,
262}
263
264/// Probe audio properties via Symphonia (duration, sample rate, codec, etc.).
265fn probe_symphonia(path: &Path) -> SymphoniaProps {
266    use symphonia::core::codecs::audio::CODEC_ID_NULL_AUDIO;
267    use symphonia::core::formats::probe::Hint;
268    use symphonia::core::formats::{FormatOptions, TrackType};
269    use symphonia::core::io::MediaSourceStream;
270    use symphonia::core::meta::MetadataOptions;
271
272    let empty = SymphoniaProps {
273        duration_ms: None,
274        sample_rate: None,
275        bit_depth: None,
276        channels: None,
277        bitrate: None,
278        codec: None,
279    };
280
281    let file = match std::fs::File::open(path) {
282        Ok(f) => f,
283        Err(_) => return empty,
284    };
285    let mss = MediaSourceStream::new(Box::new(file), Default::default());
286    let mut hint = Hint::new();
287    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
288        hint.with_extension(ext);
289    }
290
291    let reader = match symphonia::default::get_probe().probe(
292        &hint,
293        mss,
294        FormatOptions::default(),
295        MetadataOptions::default(),
296    ) {
297        Ok(r) => r,
298        Err(_) => return empty,
299    };
300
301    let track = match reader.default_track(TrackType::Audio) {
302        Some(t) => t,
303        None => return empty,
304    };
305
306    let params = match track.codec_params.as_ref().and_then(|p| p.audio()) {
307        Some(p) => p,
308        None => return empty,
309    };
310    let sample_rate = params.sample_rate.map(|r| r as i32);
311    let bit_depth = params.bits_per_sample.map(|b| b as i32);
312    let channels = params.channels.as_ref().map(|c| c.count() as i32);
313
314    let duration_ms = params.sample_rate.and_then(|sr| {
315        let ms = crate::audio::buffer::track_duration_ms(&*reader, track, sr);
316        (ms > 0).then_some(ms as i64)
317    });
318
319    let bitrate = params.sample_rate.and_then(|sr| {
320        params.bits_per_sample.and_then(|bps| {
321            params
322                .channels
323                .as_ref()
324                .map(|ch| (sr as i32 * bps as i32 * ch.count() as i32) / 1000)
325        })
326    });
327
328    let codec = if params.codec != CODEC_ID_NULL_AUDIO {
329        Some(symphonia_codec_name(params.codec))
330    } else {
331        None
332    };
333
334    SymphoniaProps {
335        duration_ms,
336        sample_rate,
337        bit_depth,
338        channels,
339        bitrate,
340        codec,
341    }
342}
343
344/// Map Symphonia codec type to a human-readable string.
345fn symphonia_codec_name(codec: symphonia::core::codecs::audio::AudioCodecId) -> String {
346    use symphonia::core::codecs::audio::well_known as ids;
347    match codec {
348        ids::CODEC_ID_FLAC => "FLAC".to_string(),
349        ids::CODEC_ID_MP3 => "MP3".to_string(),
350        ids::CODEC_ID_AAC => "AAC".to_string(),
351        ids::CODEC_ID_ALAC => "ALAC".to_string(),
352        ids::CODEC_ID_VORBIS => "Vorbis".to_string(),
353        ids::CODEC_ID_OPUS => "Opus".to_string(),
354        ids::CODEC_ID_WAVPACK => "WavPack".to_string(),
355        ids::CODEC_ID_PCM_S16LE
356        | ids::CODEC_ID_PCM_S24LE
357        | ids::CODEC_ID_PCM_S32LE
358        | ids::CODEC_ID_PCM_F32LE
359        | ids::CODEC_ID_PCM_F64LE
360        | ids::CODEC_ID_PCM_S16BE
361        | ids::CODEC_ID_PCM_S24BE
362        | ids::CODEC_ID_PCM_S32BE
363        | ids::CODEC_ID_PCM_F32BE
364        | ids::CODEC_ID_PCM_F64BE
365        | ids::CODEC_ID_PCM_U8 => "PCM".to_string(),
366        _ => "Unknown".to_string(),
367    }
368}
369
370/// Try to extract basic tags (title, artist, album) via Symphonia's metadata reader.
371/// Symphonia is more lenient with corrupted ID3 frames than lofty.
372/// Tags Symphonia can give us when lofty cannot parse the file at all.
373#[derive(Default)]
374struct SymphoniaTags {
375    title: Option<String>,
376    artist: Option<String>,
377    album_artist: Option<String>,
378    album: Option<String>,
379    date: Option<String>,
380    track_number: Option<i32>,
381    disc: Option<i32>,
382    genre: Option<String>,
383}
384
385fn probe_symphonia_tags(path: &Path) -> SymphoniaTags {
386    use symphonia::core::formats::FormatOptions;
387    use symphonia::core::formats::probe::Hint;
388    use symphonia::core::io::MediaSourceStream;
389    use symphonia::core::meta::{MetadataOptions, StandardTag};
390
391    let file = match std::fs::File::open(path) {
392        Ok(f) => f,
393        Err(_) => return SymphoniaTags::default(),
394    };
395    let mss = MediaSourceStream::new(Box::new(file), Default::default());
396    let mut hint = Hint::new();
397    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
398        hint.with_extension(ext);
399    }
400
401    let mut reader = match symphonia::default::get_probe().probe(
402        &hint,
403        mss,
404        FormatOptions::default(),
405        MetadataOptions::default(),
406    ) {
407        Ok(r) => r,
408        Err(_) => return SymphoniaTags::default(),
409    };
410
411    let mut tags = SymphoniaTags::default();
412
413    // Later revisions win. Symphonia's log is time-ordered and `current()` is
414    // the *oldest* revision, not the best one — an MP3 carrying both tags
415    // surfaces ID3v1 first and ID3v2 second. ID3v1 is never the one you want:
416    // its fields are a fixed 30 bytes, so anything longer arrives silently
417    // truncated, and a truncated title then matches nothing on a remote server.
418    //
419    // This walk accumulates rather than calling `skip_to_latest()`, so a field
420    // present only in an older revision still fills a gap the newest one leaves.
421    let mut log = reader.metadata();
422    loop {
423        if let Some(rev) = log.current() {
424            for tag in &rev.media.tags {
425                match &tag.std {
426                    Some(StandardTag::TrackTitle(v)) => tags.title = Some(v.to_string()),
427                    Some(StandardTag::Artist(v)) => tags.artist = Some(v.to_string()),
428                    Some(StandardTag::AlbumArtist(v)) => tags.album_artist = Some(v.to_string()),
429                    Some(StandardTag::Album(v)) => tags.album = Some(v.to_string()),
430                    Some(StandardTag::Genre(v)) => tags.genre = Some(v.to_string()),
431                    Some(StandardTag::RecordingDate(v)) => tags.date = Some(v.to_string()),
432                    Some(StandardTag::RecordingYear(v)) => tags.date = Some(v.to_string()),
433                    Some(StandardTag::TrackNumber(v)) => tags.track_number = Some(*v as i32),
434                    Some(StandardTag::DiscNumber(v)) => tags.disc = Some(*v as i32),
435                    _ => {}
436                }
437            }
438        }
439        if log.pop().is_none() {
440            break;
441        }
442    }
443
444    tags
445}
446
447/// Determine codec for an MP4 container file (AAC, ALAC, etc.).
448/// Falls back to "AAC" if the file cannot be parsed as Mp4File.
449fn mp4_codec(path: &Path) -> String {
450    let file = match std::fs::File::open(path) {
451        Ok(f) => f,
452        Err(_) => return "AAC".to_string(),
453    };
454    let mut reader = std::io::BufReader::new(file);
455    match Mp4File::read_from(&mut reader, ParseOptions::new()) {
456        Ok(mp4) => match mp4.properties().codec() {
457            Some(Mp4Codec::ALAC) => "ALAC".to_string(),
458            Some(Mp4Codec::MP3) => "MP3".to_string(),
459            Some(Mp4Codec::FLAC) => "FLAC".to_string(),
460            _ => "AAC".to_string(),
461        },
462        Err(_) => "AAC".to_string(),
463    }
464}
465
466/// Map lofty file type to a human-readable codec string.
467pub fn codec_string(ft: lofty::file::FileType) -> &'static str {
468    match ft {
469        lofty::file::FileType::Flac => "FLAC",
470        lofty::file::FileType::Mpeg => "MP3",
471        lofty::file::FileType::Mp4 => "AAC",
472        lofty::file::FileType::Opus => "Opus",
473        lofty::file::FileType::Vorbis => "Vorbis",
474        lofty::file::FileType::WavPack => "WavPack",
475        lofty::file::FileType::Wav => "WAV",
476        lofty::file::FileType::Aiff => "AIFF",
477        lofty::file::FileType::Ape => "APE",
478        _ => "Unknown",
479    }
480}
481
482/// Extract embedded front cover art bytes from an audio file.
483/// Returns raw image bytes (JPEG/PNG) or None. TIFF images are
484/// skipped — the `image` crate only has jpeg+png features and macOS
485/// CGImageDestination rejects TIFF for Now Playing artwork.
486pub fn extract_cover_art(path: &Path) -> Option<Vec<u8>> {
487    raise_allocation_limit();
488    let tagged_file = lofty::read_from_path(path).ok()?;
489    let tag = tagged_file
490        .primary_tag()
491        .or_else(|| tagged_file.first_tag())?;
492
493    // Prefer CoverFront, fall back to first picture.
494    let pictures = tag.pictures();
495    let pic = pictures
496        .iter()
497        .find(|p| p.pic_type() == lofty::picture::PictureType::CoverFront && !is_tiff(p.data()))
498        .or_else(|| pictures.iter().find(|p| !is_tiff(p.data())))?;
499
500    Some(pic.data().to_vec())
501}
502
503/// TIFF magic bytes: `II*\0` (little-endian) or `MM\0*` (big-endian).
504fn is_tiff(data: &[u8]) -> bool {
505    data.len() >= 4
506        && ((data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00)
507            || (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A))
508}
509
510/// Extract partial track metadata from a Symphonia probe `MetadataRevision`.
511///
512/// Used during streaming playback to populate track info before the full file
513/// is downloaded (and before lofty can read complete tags).
514///
515/// Fields not present in the probe metadata are left as `None` or defaults.
516/// Callers should merge this with a full `read_metadata()` result once the
517/// download completes.
518pub fn metadata_from_probe_result(meta: &MetadataRevision, fallback_title: &str) -> TrackMeta {
519    let mut title: Option<String> = None;
520    let mut artist: Option<String> = None;
521    let mut album_artist: Option<String> = None;
522    let mut album: Option<String> = None;
523    let mut date: Option<String> = None;
524    let mut disc: Option<i32> = None;
525    let mut track_number: Option<i32> = None;
526    let mut genre: Option<String> = None;
527    let mut label: Option<String> = None;
528
529    // A non-empty text tag replaces the field; empty values are ignored so a
530    // blank tag never shadows a later populated one or the fallback.
531    let set_text = |slot: &mut Option<String>, value: &str| {
532        if !value.is_empty() {
533            *slot = Some(value.to_string());
534        }
535    };
536
537    for tag in &meta.media.tags {
538        let Some(std) = &tag.std else { continue };
539        match std {
540            StandardTag::TrackTitle(v) => set_text(&mut title, v),
541            StandardTag::Artist(v) => set_text(&mut artist, v),
542            StandardTag::AlbumArtist(v) => set_text(&mut album_artist, v),
543            StandardTag::Album(v) => set_text(&mut album, v),
544            StandardTag::ReleaseDate(v) | StandardTag::RecordingDate(v) => set_text(&mut date, v),
545            StandardTag::ReleaseYear(y) | StandardTag::RecordingYear(y) if date.is_none() => {
546                date = Some(y.to_string())
547            }
548            StandardTag::OriginalReleaseDate(v) | StandardTag::OriginalRecordingDate(v)
549                if date.is_none() =>
550            {
551                set_text(&mut date, v)
552            }
553            StandardTag::OriginalReleaseYear(y) | StandardTag::OriginalRecordingYear(y)
554                if date.is_none() =>
555            {
556                date = Some(y.to_string())
557            }
558            StandardTag::TrackNumber(n) => track_number = Some(*n as i32),
559            StandardTag::DiscNumber(n) => disc = Some(*n as i32),
560            StandardTag::Genre(v) => set_text(&mut genre, v),
561            StandardTag::Label(v) => set_text(&mut label, v),
562            _ => {}
563        }
564    }
565
566    TrackMeta {
567        title: title.unwrap_or_else(|| fallback_title.to_string()),
568        artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
569        album_artist,
570        album: album.unwrap_or_else(|| "Unknown Album".to_string()),
571        date,
572        disc,
573        track_number,
574        genre,
575        label,
576        duration_ms: None,
577        codec: None,
578        sample_rate: None,
579        bit_depth: None,
580        channels: None,
581        bitrate: None,
582        size_bytes: None,
583        mtime: None,
584        path: None,
585        source: "streaming".to_string(),
586        remote_id: None,
587        album_remote_id: None,
588        artist_remote_id: None,
589        mbid: None,
590        remote_url: None,
591        album_added_at: None,
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    /// A file carrying both tags must be read from ID3v2. ID3v1's fields are a
600    /// fixed 30 bytes, so preferring it silently truncates every longer title —
601    /// which then matches nothing on a remote server, splitting one record into
602    /// two albums. Symphonia surfaces the v1 tag as the *first* metadata
603    /// revision, so "take the first" is exactly the wrong rule.
604    #[test]
605    fn id3v2_beats_id3v1() {
606        let dir = tempfile::tempdir().unwrap();
607        let path = dir.path().join("both.mp3");
608        crate::test_utils::generate_mp3_with_both_tags(
609            &path,
610            "Golden Skans (David E Sugar Remix)",
611            "Golden Skans (David E Sugar R",
612        );
613
614        let tags = probe_symphonia_tags(&path);
615        assert_eq!(
616            tags.title.as_deref(),
617            Some("Golden Skans (David E Sugar Remix)")
618        );
619        // ID3v1 has no track number at all, so this also proves the v2 frame
620        // was reached rather than the walk stopping at the first revision.
621        assert_eq!(tags.track_number, Some(7));
622    }
623
624    #[test]
625    fn test_is_audio_file() {
626        assert!(is_audio_file(Path::new("track.flac")));
627        assert!(is_audio_file(Path::new("track.FLAC")));
628        assert!(is_audio_file(Path::new("track.mp3")));
629        assert!(is_audio_file(Path::new("track.m4a")));
630        assert!(is_audio_file(Path::new("track.ogg")));
631        assert!(is_audio_file(Path::new("track.opus")));
632        assert!(is_audio_file(Path::new("track.wv")));
633        assert!(is_audio_file(Path::new("track.wav")));
634        assert!(is_audio_file(Path::new("track.aiff")));
635        assert!(is_audio_file(Path::new("track.ape")));
636
637        assert!(!is_audio_file(Path::new("cover.jpg")));
638        assert!(!is_audio_file(Path::new("notes.txt")));
639        assert!(!is_audio_file(Path::new("playlist.m3u")));
640        assert!(!is_audio_file(Path::new("track.pdf")));
641        assert!(!is_audio_file(Path::new("noext")));
642    }
643
644    #[test]
645    fn test_is_audio_file_paths() {
646        assert!(is_audio_file(Path::new("/music/artist/album/01.flac")));
647        assert!(!is_audio_file(Path::new("/music/artist/album/cover.png")));
648    }
649
650    #[test]
651    fn test_read_metadata_nonexistent() {
652        let result = read_metadata(Path::new("/nonexistent/track.flac"));
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn test_codec_string() {
658        assert_eq!(codec_string(lofty::file::FileType::Flac), "FLAC");
659        assert_eq!(codec_string(lofty::file::FileType::Mpeg), "MP3");
660        assert_eq!(codec_string(lofty::file::FileType::Opus), "Opus");
661        assert_eq!(codec_string(lofty::file::FileType::Wav), "WAV");
662    }
663
664    // --- metadata_from_probe_result tests ---
665
666    use symphonia::core::meta::well_known::METADATA_ID_ID3V2;
667    use symphonia::core::meta::{MetadataBuilder, MetadataInfo, StandardTag, Tag};
668
669    const TEST_META_INFO: MetadataInfo = MetadataInfo {
670        metadata: METADATA_ID_ID3V2,
671        short_name: "id3v2",
672        long_name: "ID3v2",
673    };
674
675    fn make_revision(tags: &[StandardTag]) -> symphonia::core::meta::MetadataRevision {
676        let mut builder = MetadataBuilder::new(TEST_META_INFO);
677        for std in tags {
678            builder.add_tag(Tag::new_from_parts("", "", Some(std.clone())));
679        }
680        builder.build()
681    }
682
683    #[test]
684    fn test_probe_track_and_disc_numbers() {
685        let rev = make_revision(&[
686            StandardTag::TrackTitle("My Song".to_string().into()),
687            StandardTag::Artist("Artist".to_string().into()),
688            StandardTag::Album("Album".to_string().into()),
689            StandardTag::TrackNumber(3),
690            StandardTag::TrackTotal(12),
691            StandardTag::DiscNumber(2),
692        ]);
693        let meta = metadata_from_probe_result(&rev, "fallback");
694        assert_eq!(
695            meta.track_number,
696            Some(3),
697            "track number should come from the track number tag, not the total"
698        );
699        assert_eq!(meta.disc, Some(2));
700    }
701
702    #[test]
703    fn test_probe_original_date_fallback() {
704        // When no release/recording date is present, the original date is used.
705        let rev = make_revision(&[
706            StandardTag::TrackTitle("My Song".to_string().into()),
707            StandardTag::OriginalReleaseDate("1991".to_string().into()),
708        ]);
709        let meta = metadata_from_probe_result(&rev, "fallback");
710        assert_eq!(
711            meta.date,
712            Some("1991".to_string()),
713            "original release date should be used when the release date is missing"
714        );
715    }
716
717    #[test]
718    fn test_probe_original_date_not_used_when_date_present() {
719        // The release date takes precedence over the original release date.
720        let rev = make_revision(&[
721            StandardTag::ReleaseDate("2005".to_string().into()),
722            StandardTag::OriginalReleaseDate("1991".to_string().into()),
723        ]);
724        let meta = metadata_from_probe_result(&rev, "fallback");
725        assert_eq!(
726            meta.date,
727            Some("2005".to_string()),
728            "release date should take precedence over original release date"
729        );
730    }
731
732    #[test]
733    fn test_probe_empty_values_skipped() {
734        // Tags with empty string values should be silently skipped,
735        // leaving the corresponding fields as None (or falling back to defaults).
736        let rev = make_revision(&[
737            StandardTag::Artist(String::new().into()),
738            StandardTag::Album(String::new().into()),
739            StandardTag::Genre(String::new().into()),
740        ]);
741        let meta = metadata_from_probe_result(&rev, "Title");
742        // Empty artist/album fall back to defaults, not empty string.
743        assert_eq!(
744            meta.artist, "Unknown Artist",
745            "empty artist tag should fall back to 'Unknown Artist'"
746        );
747        assert_eq!(
748            meta.album, "Unknown Album",
749            "empty album tag should fall back to 'Unknown Album'"
750        );
751        assert_eq!(meta.genre, None, "empty genre tag should produce None");
752    }
753
754    #[test]
755    fn test_probe_defaults() {
756        // When no tags are present, artist and album should use the hardcoded defaults.
757        // Title should fall back to the fallback_title argument.
758        let rev = make_revision(&[]);
759        let meta = metadata_from_probe_result(&rev, "Fallback Title");
760        assert_eq!(
761            meta.title, "Fallback Title",
762            "missing title should use fallback_title argument"
763        );
764        assert_eq!(
765            meta.artist, "Unknown Artist",
766            "missing artist should default to 'Unknown Artist'"
767        );
768        assert_eq!(
769            meta.album, "Unknown Album",
770            "missing album should default to 'Unknown Album'"
771        );
772        assert_eq!(meta.track_number, None);
773        assert_eq!(meta.date, None);
774        assert_eq!(meta.genre, None);
775        assert_eq!(meta.source, "streaming");
776    }
777
778    #[test]
779    fn test_mp4_codec_nonexistent_file_falls_back_to_aac() {
780        assert_eq!(mp4_codec(Path::new("/nonexistent/track.m4a")), "AAC");
781    }
782
783    #[test]
784    fn test_mp4_codec_non_mp4_file_falls_back_to_aac() {
785        // A non-MP4 file should fail to parse and fall back to "AAC".
786        let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
787        assert_eq!(mp4_codec(&manifest), "AAC");
788    }
789
790    #[test]
791    #[cfg(target_os = "macos")]
792    fn test_mp4_codec_real_alac_file() {
793        // Integration test: verify that a real ALAC .m4a file is correctly
794        // identified as "ALAC" rather than "AAC". Skipped silently when the
795        // Turtlehead volume is not mounted.
796        let alac_path = Path::new(
797            "/Volumes/Turtlehead/music/Valet Girls/(2017) PERENNIAL VICE [ALAC]/0101. Valet Girls - Tis the Season.m4a",
798        );
799        if !alac_path.exists() {
800            eprintln!("SKIP: ALAC test file not found (volume not mounted)");
801            return;
802        }
803        assert_eq!(
804            mp4_codec(alac_path),
805            "ALAC",
806            "real ALAC .m4a should be identified as ALAC, not AAC"
807        );
808    }
809}