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
22const AUDIO_EXTENSIONS: &[&str] = &[
24 "flac", "mp3", "m4a", "aac", "ogg", "opus", "wv", "wav", "aiff", "aif", "alac", "ape",
25];
26
27pub 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
34pub fn read_metadata(path: &Path) -> Result<TrackMeta, MetadataError> {
39 match std::fs::metadata(path) {
41 Ok(m) if m.len() == 0 => {
42 return Err(MetadataError::Io(std::io::Error::new(
43 std::io::ErrorKind::InvalidData,
44 format!(
45 "empty file (0 bytes) — may be a stale mount or incomplete transfer, will retry on next scan: {}",
46 path.display()
47 ),
48 )));
49 }
50 Err(e) => return Err(MetadataError::Io(e)),
51 _ => {}
52 }
53
54 match 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
71fn 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
86fn read_tagged_file(path: &Path) -> Result<lofty::file::TaggedFile, Box<dyn std::error::Error>> {
91 raise_allocation_limit();
92 Ok(lofty::probe::Probe::open(path)?
93 .options(ParseOptions::new().read_cover_art(false))
94 .read()?)
95}
96
97fn read_metadata_lofty(
99 path: &Path,
100 tagged_file: &lofty::file::TaggedFile,
101) -> Result<TrackMeta, MetadataError> {
102 let properties = tagged_file.properties();
103 let duration_ms = properties.duration().as_millis() as i64;
104 let sample_rate = properties.sample_rate().map(|r| r as i32);
105 let bit_depth = properties.bit_depth().map(|b| b as i32);
106 let channels = properties.channels().map(|c| c as i32);
107 let bitrate = properties.audio_bitrate().map(|b| b as i32);
108
109 let tag = tagged_file
110 .primary_tag()
111 .or_else(|| tagged_file.first_tag());
112
113 let (title, artist, album_artist, album, date, disc, track_number, genre, label) =
114 if let Some(tag) = tag {
115 (
116 tag.title().map(|s| s.to_string()),
117 tag.artist().map(|s| s.to_string()),
118 tag.get_string(ItemKey::AlbumArtist).map(|s| s.to_string()),
119 tag.album().map(|s| s.to_string()),
120 tag.get_string(ItemKey::Year)
122 .or_else(|| tag.get_string(ItemKey::RecordingDate))
123 .map(|s| s.to_string()),
124 tag.disk().map(|d| d as i32),
125 tag.track().map(|t| t as i32),
126 tag.genre().map(|s| s.to_string()),
127 tag.get_string(ItemKey::Label).map(|s| s.to_string()),
128 )
129 } else {
130 (None, None, None, None, None, None, None, None, None)
131 };
132
133 let title = title.unwrap_or_else(|| {
134 path.file_stem()
135 .and_then(|s| s.to_str())
136 .unwrap_or("Unknown")
137 .to_string()
138 });
139 let artist = artist.unwrap_or_else(|| "Unknown Artist".to_string());
140 let album = album.unwrap_or_else(|| "Unknown Album".to_string());
141
142 let file_meta = fs::metadata(path)?;
143 let size_bytes = file_meta.len() as i64;
144 let mtime = file_meta
145 .modified()
146 .ok()
147 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
148 .map(|d| d.as_secs() as i64);
149
150 let codec = if tagged_file.file_type() == lofty::file::FileType::Mp4 {
151 mp4_codec(path)
152 } else {
153 codec_string(tagged_file.file_type()).to_string()
154 };
155
156 Ok(TrackMeta {
157 title,
158 artist,
159 album_artist,
160 album,
161 date,
162 disc,
163 track_number,
164 genre,
165 label,
166 duration_ms: Some(duration_ms),
167 codec: Some(codec),
168 sample_rate,
169 bit_depth,
170 channels,
171 bitrate,
172 size_bytes: Some(size_bytes),
173 mtime,
174 path: Some(path.to_string_lossy().to_string()),
175 source: "local".to_string(),
176 remote_id: None,
177 album_remote_id: None,
178 artist_remote_id: None,
179 mbid: None,
180 remote_url: None,
181 album_added_at: mtime.and_then(iso8601_utc),
182 })
183}
184
185fn read_metadata_fallback(path: &Path) -> Result<TrackMeta, MetadataError> {
188 let file_meta = fs::metadata(path)?;
189 let size_bytes = file_meta.len() as i64;
190 let mtime = file_meta
191 .modified()
192 .ok()
193 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
194 .map(|d| d.as_secs() as i64);
195
196 let props = probe_symphonia(path);
198
199 let tags = probe_symphonia_tags(path);
207
208 let title = tags.title.unwrap_or_else(|| {
209 path.file_stem()
210 .and_then(|s| s.to_str())
211 .unwrap_or("Unknown")
212 .to_string()
213 });
214 let artist = tags.artist.unwrap_or_else(|| "Unknown Artist".to_string());
215 let album = tags.album.unwrap_or_else(|| "Unknown Album".to_string());
216
217 Ok(TrackMeta {
218 title,
219 artist,
220 album_artist: tags.album_artist,
221 album,
222 date: tags.date,
223 disc: tags.disc,
224 track_number: tags.track_number,
225 genre: tags.genre,
226 label: None,
227 duration_ms: props.duration_ms,
228 codec: props.codec,
229 sample_rate: props.sample_rate,
230 bit_depth: props.bit_depth,
231 channels: props.channels,
232 bitrate: props.bitrate,
233 size_bytes: Some(size_bytes),
234 mtime,
235 path: Some(path.to_string_lossy().to_string()),
236 source: "local".to_string(),
237 remote_id: None,
238 album_remote_id: None,
239 artist_remote_id: None,
240 mbid: None,
241 remote_url: None,
242 album_added_at: mtime.and_then(iso8601_utc),
243 })
244}
245
246fn iso8601_utc(unix_secs: i64) -> Option<String> {
250 chrono::DateTime::from_timestamp(unix_secs, 0)
251 .map(|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string())
252}
253
254struct SymphoniaProps {
256 duration_ms: Option<i64>,
257 sample_rate: Option<i32>,
258 bit_depth: Option<i32>,
259 channels: Option<i32>,
260 bitrate: Option<i32>,
261 codec: Option<String>,
262}
263
264fn probe_symphonia(path: &Path) -> SymphoniaProps {
266 use symphonia::core::codecs::audio::CODEC_ID_NULL_AUDIO;
267 use symphonia::core::formats::probe::Hint;
268 use symphonia::core::formats::{FormatOptions, TrackType};
269 use symphonia::core::io::MediaSourceStream;
270 use symphonia::core::meta::MetadataOptions;
271
272 let empty = SymphoniaProps {
273 duration_ms: None,
274 sample_rate: None,
275 bit_depth: None,
276 channels: None,
277 bitrate: None,
278 codec: None,
279 };
280
281 let file = match std::fs::File::open(path) {
282 Ok(f) => f,
283 Err(_) => return empty,
284 };
285 let mss = MediaSourceStream::new(Box::new(file), Default::default());
286 let mut hint = Hint::new();
287 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
288 hint.with_extension(ext);
289 }
290
291 let reader = match symphonia::default::get_probe().probe(
292 &hint,
293 mss,
294 FormatOptions::default(),
295 MetadataOptions::default(),
296 ) {
297 Ok(r) => r,
298 Err(_) => return empty,
299 };
300
301 let track = match reader.default_track(TrackType::Audio) {
302 Some(t) => t,
303 None => return empty,
304 };
305
306 let params = match track.codec_params.as_ref().and_then(|p| p.audio()) {
307 Some(p) => p,
308 None => return empty,
309 };
310 let sample_rate = params.sample_rate.map(|r| r as i32);
311 let bit_depth = params.bits_per_sample.map(|b| b as i32);
312 let channels = params.channels.as_ref().map(|c| c.count() as i32);
313
314 let duration_ms = params.sample_rate.and_then(|sr| {
315 let ms = crate::audio::buffer::track_duration_ms(&*reader, track, sr);
316 (ms > 0).then_some(ms as i64)
317 });
318
319 let bitrate = params.sample_rate.and_then(|sr| {
320 params.bits_per_sample.and_then(|bps| {
321 params
322 .channels
323 .as_ref()
324 .map(|ch| (sr as i32 * bps as i32 * ch.count() as i32) / 1000)
325 })
326 });
327
328 let codec = if params.codec != CODEC_ID_NULL_AUDIO {
329 Some(symphonia_codec_name(params.codec))
330 } else {
331 None
332 };
333
334 SymphoniaProps {
335 duration_ms,
336 sample_rate,
337 bit_depth,
338 channels,
339 bitrate,
340 codec,
341 }
342}
343
344fn symphonia_codec_name(codec: symphonia::core::codecs::audio::AudioCodecId) -> String {
346 use symphonia::core::codecs::audio::well_known as ids;
347 match codec {
348 ids::CODEC_ID_FLAC => "FLAC".to_string(),
349 ids::CODEC_ID_MP3 => "MP3".to_string(),
350 ids::CODEC_ID_AAC => "AAC".to_string(),
351 ids::CODEC_ID_ALAC => "ALAC".to_string(),
352 ids::CODEC_ID_VORBIS => "Vorbis".to_string(),
353 ids::CODEC_ID_OPUS => "Opus".to_string(),
354 ids::CODEC_ID_WAVPACK => "WavPack".to_string(),
355 ids::CODEC_ID_PCM_S16LE
356 | ids::CODEC_ID_PCM_S24LE
357 | ids::CODEC_ID_PCM_S32LE
358 | ids::CODEC_ID_PCM_F32LE
359 | ids::CODEC_ID_PCM_F64LE
360 | ids::CODEC_ID_PCM_S16BE
361 | ids::CODEC_ID_PCM_S24BE
362 | ids::CODEC_ID_PCM_S32BE
363 | ids::CODEC_ID_PCM_F32BE
364 | ids::CODEC_ID_PCM_F64BE
365 | ids::CODEC_ID_PCM_U8 => "PCM".to_string(),
366 _ => "Unknown".to_string(),
367 }
368}
369
370#[derive(Default)]
374struct SymphoniaTags {
375 title: Option<String>,
376 artist: Option<String>,
377 album_artist: Option<String>,
378 album: Option<String>,
379 date: Option<String>,
380 track_number: Option<i32>,
381 disc: Option<i32>,
382 genre: Option<String>,
383}
384
385fn probe_symphonia_tags(path: &Path) -> SymphoniaTags {
386 use symphonia::core::formats::FormatOptions;
387 use symphonia::core::formats::probe::Hint;
388 use symphonia::core::io::MediaSourceStream;
389 use symphonia::core::meta::{MetadataOptions, StandardTag};
390
391 let file = match std::fs::File::open(path) {
392 Ok(f) => f,
393 Err(_) => return SymphoniaTags::default(),
394 };
395 let mss = MediaSourceStream::new(Box::new(file), Default::default());
396 let mut hint = Hint::new();
397 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
398 hint.with_extension(ext);
399 }
400
401 let mut reader = match symphonia::default::get_probe().probe(
402 &hint,
403 mss,
404 FormatOptions::default(),
405 MetadataOptions::default(),
406 ) {
407 Ok(r) => r,
408 Err(_) => return SymphoniaTags::default(),
409 };
410
411 let mut tags = SymphoniaTags::default();
412
413 let mut log = reader.metadata();
422 loop {
423 if let Some(rev) = log.current() {
424 for tag in &rev.media.tags {
425 match &tag.std {
426 Some(StandardTag::TrackTitle(v)) => tags.title = Some(v.to_string()),
427 Some(StandardTag::Artist(v)) => tags.artist = Some(v.to_string()),
428 Some(StandardTag::AlbumArtist(v)) => tags.album_artist = Some(v.to_string()),
429 Some(StandardTag::Album(v)) => tags.album = Some(v.to_string()),
430 Some(StandardTag::Genre(v)) => tags.genre = Some(v.to_string()),
431 Some(StandardTag::RecordingDate(v)) => tags.date = Some(v.to_string()),
432 Some(StandardTag::RecordingYear(v)) => tags.date = Some(v.to_string()),
433 Some(StandardTag::TrackNumber(v)) => tags.track_number = Some(*v as i32),
434 Some(StandardTag::DiscNumber(v)) => tags.disc = Some(*v as i32),
435 _ => {}
436 }
437 }
438 }
439 if log.pop().is_none() {
440 break;
441 }
442 }
443
444 tags
445}
446
447fn mp4_codec(path: &Path) -> String {
450 let file = match std::fs::File::open(path) {
451 Ok(f) => f,
452 Err(_) => return "AAC".to_string(),
453 };
454 let mut reader = std::io::BufReader::new(file);
455 match Mp4File::read_from(&mut reader, ParseOptions::new()) {
456 Ok(mp4) => match mp4.properties().codec() {
457 Some(Mp4Codec::ALAC) => "ALAC".to_string(),
458 Some(Mp4Codec::MP3) => "MP3".to_string(),
459 Some(Mp4Codec::FLAC) => "FLAC".to_string(),
460 _ => "AAC".to_string(),
461 },
462 Err(_) => "AAC".to_string(),
463 }
464}
465
466pub fn codec_string(ft: lofty::file::FileType) -> &'static str {
468 match ft {
469 lofty::file::FileType::Flac => "FLAC",
470 lofty::file::FileType::Mpeg => "MP3",
471 lofty::file::FileType::Mp4 => "AAC",
472 lofty::file::FileType::Opus => "Opus",
473 lofty::file::FileType::Vorbis => "Vorbis",
474 lofty::file::FileType::WavPack => "WavPack",
475 lofty::file::FileType::Wav => "WAV",
476 lofty::file::FileType::Aiff => "AIFF",
477 lofty::file::FileType::Ape => "APE",
478 _ => "Unknown",
479 }
480}
481
482pub fn extract_cover_art(path: &Path) -> Option<Vec<u8>> {
487 raise_allocation_limit();
488 let tagged_file = lofty::read_from_path(path).ok()?;
489 let tag = tagged_file
490 .primary_tag()
491 .or_else(|| tagged_file.first_tag())?;
492
493 let pictures = tag.pictures();
495 let pic = pictures
496 .iter()
497 .find(|p| p.pic_type() == lofty::picture::PictureType::CoverFront && !is_tiff(p.data()))
498 .or_else(|| pictures.iter().find(|p| !is_tiff(p.data())))?;
499
500 Some(pic.data().to_vec())
501}
502
503fn is_tiff(data: &[u8]) -> bool {
505 data.len() >= 4
506 && ((data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00)
507 || (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A))
508}
509
510pub fn metadata_from_probe_result(meta: &MetadataRevision, fallback_title: &str) -> TrackMeta {
519 let mut title: Option<String> = None;
520 let mut artist: Option<String> = None;
521 let mut album_artist: Option<String> = None;
522 let mut album: Option<String> = None;
523 let mut date: Option<String> = None;
524 let mut disc: Option<i32> = None;
525 let mut track_number: Option<i32> = None;
526 let mut genre: Option<String> = None;
527 let mut label: Option<String> = None;
528
529 let set_text = |slot: &mut Option<String>, value: &str| {
532 if !value.is_empty() {
533 *slot = Some(value.to_string());
534 }
535 };
536
537 for tag in &meta.media.tags {
538 let Some(std) = &tag.std else { continue };
539 match std {
540 StandardTag::TrackTitle(v) => set_text(&mut title, v),
541 StandardTag::Artist(v) => set_text(&mut artist, v),
542 StandardTag::AlbumArtist(v) => set_text(&mut album_artist, v),
543 StandardTag::Album(v) => set_text(&mut album, v),
544 StandardTag::ReleaseDate(v) | StandardTag::RecordingDate(v) => set_text(&mut date, v),
545 StandardTag::ReleaseYear(y) | StandardTag::RecordingYear(y) if date.is_none() => {
546 date = Some(y.to_string())
547 }
548 StandardTag::OriginalReleaseDate(v) | StandardTag::OriginalRecordingDate(v)
549 if date.is_none() =>
550 {
551 set_text(&mut date, v)
552 }
553 StandardTag::OriginalReleaseYear(y) | StandardTag::OriginalRecordingYear(y)
554 if date.is_none() =>
555 {
556 date = Some(y.to_string())
557 }
558 StandardTag::TrackNumber(n) => track_number = Some(*n as i32),
559 StandardTag::DiscNumber(n) => disc = Some(*n as i32),
560 StandardTag::Genre(v) => set_text(&mut genre, v),
561 StandardTag::Label(v) => set_text(&mut label, v),
562 _ => {}
563 }
564 }
565
566 TrackMeta {
567 title: title.unwrap_or_else(|| fallback_title.to_string()),
568 artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
569 album_artist,
570 album: album.unwrap_or_else(|| "Unknown Album".to_string()),
571 date,
572 disc,
573 track_number,
574 genre,
575 label,
576 duration_ms: None,
577 codec: None,
578 sample_rate: None,
579 bit_depth: None,
580 channels: None,
581 bitrate: None,
582 size_bytes: None,
583 mtime: None,
584 path: None,
585 source: "streaming".to_string(),
586 remote_id: None,
587 album_remote_id: None,
588 artist_remote_id: None,
589 mbid: None,
590 remote_url: None,
591 album_added_at: None,
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 #[test]
605 fn id3v2_beats_id3v1() {
606 let dir = tempfile::tempdir().unwrap();
607 let path = dir.path().join("both.mp3");
608 crate::test_utils::generate_mp3_with_both_tags(
609 &path,
610 "Golden Skans (David E Sugar Remix)",
611 "Golden Skans (David E Sugar R",
612 );
613
614 let tags = probe_symphonia_tags(&path);
615 assert_eq!(
616 tags.title.as_deref(),
617 Some("Golden Skans (David E Sugar Remix)")
618 );
619 assert_eq!(tags.track_number, Some(7));
622 }
623
624 #[test]
625 fn test_is_audio_file() {
626 assert!(is_audio_file(Path::new("track.flac")));
627 assert!(is_audio_file(Path::new("track.FLAC")));
628 assert!(is_audio_file(Path::new("track.mp3")));
629 assert!(is_audio_file(Path::new("track.m4a")));
630 assert!(is_audio_file(Path::new("track.ogg")));
631 assert!(is_audio_file(Path::new("track.opus")));
632 assert!(is_audio_file(Path::new("track.wv")));
633 assert!(is_audio_file(Path::new("track.wav")));
634 assert!(is_audio_file(Path::new("track.aiff")));
635 assert!(is_audio_file(Path::new("track.ape")));
636
637 assert!(!is_audio_file(Path::new("cover.jpg")));
638 assert!(!is_audio_file(Path::new("notes.txt")));
639 assert!(!is_audio_file(Path::new("playlist.m3u")));
640 assert!(!is_audio_file(Path::new("track.pdf")));
641 assert!(!is_audio_file(Path::new("noext")));
642 }
643
644 #[test]
645 fn test_is_audio_file_paths() {
646 assert!(is_audio_file(Path::new("/music/artist/album/01.flac")));
647 assert!(!is_audio_file(Path::new("/music/artist/album/cover.png")));
648 }
649
650 #[test]
651 fn test_read_metadata_nonexistent() {
652 let result = read_metadata(Path::new("/nonexistent/track.flac"));
653 assert!(result.is_err());
654 }
655
656 #[test]
657 fn test_codec_string() {
658 assert_eq!(codec_string(lofty::file::FileType::Flac), "FLAC");
659 assert_eq!(codec_string(lofty::file::FileType::Mpeg), "MP3");
660 assert_eq!(codec_string(lofty::file::FileType::Opus), "Opus");
661 assert_eq!(codec_string(lofty::file::FileType::Wav), "WAV");
662 }
663
664 use symphonia::core::meta::well_known::METADATA_ID_ID3V2;
667 use symphonia::core::meta::{MetadataBuilder, MetadataInfo, StandardTag, Tag};
668
669 const TEST_META_INFO: MetadataInfo = MetadataInfo {
670 metadata: METADATA_ID_ID3V2,
671 short_name: "id3v2",
672 long_name: "ID3v2",
673 };
674
675 fn make_revision(tags: &[StandardTag]) -> symphonia::core::meta::MetadataRevision {
676 let mut builder = MetadataBuilder::new(TEST_META_INFO);
677 for std in tags {
678 builder.add_tag(Tag::new_from_parts("", "", Some(std.clone())));
679 }
680 builder.build()
681 }
682
683 #[test]
684 fn test_probe_track_and_disc_numbers() {
685 let rev = make_revision(&[
686 StandardTag::TrackTitle("My Song".to_string().into()),
687 StandardTag::Artist("Artist".to_string().into()),
688 StandardTag::Album("Album".to_string().into()),
689 StandardTag::TrackNumber(3),
690 StandardTag::TrackTotal(12),
691 StandardTag::DiscNumber(2),
692 ]);
693 let meta = metadata_from_probe_result(&rev, "fallback");
694 assert_eq!(
695 meta.track_number,
696 Some(3),
697 "track number should come from the track number tag, not the total"
698 );
699 assert_eq!(meta.disc, Some(2));
700 }
701
702 #[test]
703 fn test_probe_original_date_fallback() {
704 let rev = make_revision(&[
706 StandardTag::TrackTitle("My Song".to_string().into()),
707 StandardTag::OriginalReleaseDate("1991".to_string().into()),
708 ]);
709 let meta = metadata_from_probe_result(&rev, "fallback");
710 assert_eq!(
711 meta.date,
712 Some("1991".to_string()),
713 "original release date should be used when the release date is missing"
714 );
715 }
716
717 #[test]
718 fn test_probe_original_date_not_used_when_date_present() {
719 let rev = make_revision(&[
721 StandardTag::ReleaseDate("2005".to_string().into()),
722 StandardTag::OriginalReleaseDate("1991".to_string().into()),
723 ]);
724 let meta = metadata_from_probe_result(&rev, "fallback");
725 assert_eq!(
726 meta.date,
727 Some("2005".to_string()),
728 "release date should take precedence over original release date"
729 );
730 }
731
732 #[test]
733 fn test_probe_empty_values_skipped() {
734 let rev = make_revision(&[
737 StandardTag::Artist(String::new().into()),
738 StandardTag::Album(String::new().into()),
739 StandardTag::Genre(String::new().into()),
740 ]);
741 let meta = metadata_from_probe_result(&rev, "Title");
742 assert_eq!(
744 meta.artist, "Unknown Artist",
745 "empty artist tag should fall back to 'Unknown Artist'"
746 );
747 assert_eq!(
748 meta.album, "Unknown Album",
749 "empty album tag should fall back to 'Unknown Album'"
750 );
751 assert_eq!(meta.genre, None, "empty genre tag should produce None");
752 }
753
754 #[test]
755 fn test_probe_defaults() {
756 let rev = make_revision(&[]);
759 let meta = metadata_from_probe_result(&rev, "Fallback Title");
760 assert_eq!(
761 meta.title, "Fallback Title",
762 "missing title should use fallback_title argument"
763 );
764 assert_eq!(
765 meta.artist, "Unknown Artist",
766 "missing artist should default to 'Unknown Artist'"
767 );
768 assert_eq!(
769 meta.album, "Unknown Album",
770 "missing album should default to 'Unknown Album'"
771 );
772 assert_eq!(meta.track_number, None);
773 assert_eq!(meta.date, None);
774 assert_eq!(meta.genre, None);
775 assert_eq!(meta.source, "streaming");
776 }
777
778 #[test]
779 fn test_mp4_codec_nonexistent_file_falls_back_to_aac() {
780 assert_eq!(mp4_codec(Path::new("/nonexistent/track.m4a")), "AAC");
781 }
782
783 #[test]
784 fn test_mp4_codec_non_mp4_file_falls_back_to_aac() {
785 let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
787 assert_eq!(mp4_codec(&manifest), "AAC");
788 }
789
790 #[test]
791 #[cfg(target_os = "macos")]
792 fn test_mp4_codec_real_alac_file() {
793 let alac_path = Path::new(
797 "/Volumes/Turtlehead/music/Valet Girls/(2017) PERENNIAL VICE [ALAC]/0101. Valet Girls - Tis the Season.m4a",
798 );
799 if !alac_path.exists() {
800 eprintln!("SKIP: ALAC test file not found (volume not mounted)");
801 return;
802 }
803 assert_eq!(
804 mp4_codec(alac_path),
805 "ALAC",
806 "real ALAC .m4a should be identified as ALAC, not AAC"
807 );
808 }
809}