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