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