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