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 remote_url: None,
178 album_added_at: mtime.and_then(iso8601_utc),
179 })
180}
181
182fn read_metadata_fallback(path: &Path) -> Result<TrackMeta, MetadataError> {
185 let file_meta = fs::metadata(path)?;
186 let size_bytes = file_meta.len() as i64;
187 let mtime = file_meta
188 .modified()
189 .ok()
190 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
191 .map(|d| d.as_secs() as i64);
192
193 let props = probe_symphonia(path);
195
196 let tags = probe_symphonia_tags(path);
204
205 let title = tags.title.unwrap_or_else(|| {
206 path.file_stem()
207 .and_then(|s| s.to_str())
208 .unwrap_or("Unknown")
209 .to_string()
210 });
211 let artist = tags.artist.unwrap_or_else(|| "Unknown Artist".to_string());
212 let album = tags.album.unwrap_or_else(|| "Unknown Album".to_string());
213
214 Ok(TrackMeta {
215 title,
216 artist,
217 album_artist: tags.album_artist,
218 album,
219 date: tags.date,
220 disc: tags.disc,
221 track_number: tags.track_number,
222 genre: tags.genre,
223 label: None,
224 duration_ms: props.duration_ms,
225 codec: props.codec,
226 sample_rate: props.sample_rate,
227 bit_depth: props.bit_depth,
228 channels: props.channels,
229 bitrate: props.bitrate,
230 size_bytes: Some(size_bytes),
231 mtime,
232 path: Some(path.to_string_lossy().to_string()),
233 source: "local".to_string(),
234 remote_id: None,
235 remote_url: None,
236 album_added_at: mtime.and_then(iso8601_utc),
237 })
238}
239
240fn iso8601_utc(unix_secs: i64) -> Option<String> {
244 chrono::DateTime::from_timestamp(unix_secs, 0)
245 .map(|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string())
246}
247
248struct SymphoniaProps {
250 duration_ms: Option<i64>,
251 sample_rate: Option<i32>,
252 bit_depth: Option<i32>,
253 channels: Option<i32>,
254 bitrate: Option<i32>,
255 codec: Option<String>,
256}
257
258fn probe_symphonia(path: &Path) -> SymphoniaProps {
260 use symphonia::core::codecs::audio::CODEC_ID_NULL_AUDIO;
261 use symphonia::core::formats::probe::Hint;
262 use symphonia::core::formats::{FormatOptions, TrackType};
263 use symphonia::core::io::MediaSourceStream;
264 use symphonia::core::meta::MetadataOptions;
265
266 let empty = SymphoniaProps {
267 duration_ms: None,
268 sample_rate: None,
269 bit_depth: None,
270 channels: None,
271 bitrate: None,
272 codec: None,
273 };
274
275 let file = match std::fs::File::open(path) {
276 Ok(f) => f,
277 Err(_) => return empty,
278 };
279 let mss = MediaSourceStream::new(Box::new(file), Default::default());
280 let mut hint = Hint::new();
281 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
282 hint.with_extension(ext);
283 }
284
285 let reader = match symphonia::default::get_probe().probe(
286 &hint,
287 mss,
288 FormatOptions::default(),
289 MetadataOptions::default(),
290 ) {
291 Ok(r) => r,
292 Err(_) => return empty,
293 };
294
295 let track = match reader.default_track(TrackType::Audio) {
296 Some(t) => t,
297 None => return empty,
298 };
299
300 let params = match track.codec_params.as_ref().and_then(|p| p.audio()) {
301 Some(p) => p,
302 None => return empty,
303 };
304 let sample_rate = params.sample_rate.map(|r| r as i32);
305 let bit_depth = params.bits_per_sample.map(|b| b as i32);
306 let channels = params.channels.as_ref().map(|c| c.count() as i32);
307
308 let duration_ms = params.sample_rate.and_then(|sr| {
309 let ms = crate::audio::buffer::track_duration_ms(&*reader, track, sr);
310 (ms > 0).then_some(ms as i64)
311 });
312
313 let bitrate = params.sample_rate.and_then(|sr| {
314 params.bits_per_sample.and_then(|bps| {
315 params
316 .channels
317 .as_ref()
318 .map(|ch| (sr as i32 * bps as i32 * ch.count() as i32) / 1000)
319 })
320 });
321
322 let codec = if params.codec != CODEC_ID_NULL_AUDIO {
323 Some(symphonia_codec_name(params.codec))
324 } else {
325 None
326 };
327
328 SymphoniaProps {
329 duration_ms,
330 sample_rate,
331 bit_depth,
332 channels,
333 bitrate,
334 codec,
335 }
336}
337
338fn symphonia_codec_name(codec: symphonia::core::codecs::audio::AudioCodecId) -> String {
340 use symphonia::core::codecs::audio::well_known as ids;
341 match codec {
342 ids::CODEC_ID_FLAC => "FLAC".to_string(),
343 ids::CODEC_ID_MP3 => "MP3".to_string(),
344 ids::CODEC_ID_AAC => "AAC".to_string(),
345 ids::CODEC_ID_ALAC => "ALAC".to_string(),
346 ids::CODEC_ID_VORBIS => "Vorbis".to_string(),
347 ids::CODEC_ID_OPUS => "Opus".to_string(),
348 ids::CODEC_ID_WAVPACK => "WavPack".to_string(),
349 ids::CODEC_ID_PCM_S16LE
350 | ids::CODEC_ID_PCM_S24LE
351 | ids::CODEC_ID_PCM_S32LE
352 | ids::CODEC_ID_PCM_F32LE
353 | ids::CODEC_ID_PCM_F64LE
354 | ids::CODEC_ID_PCM_S16BE
355 | ids::CODEC_ID_PCM_S24BE
356 | ids::CODEC_ID_PCM_S32BE
357 | ids::CODEC_ID_PCM_F32BE
358 | ids::CODEC_ID_PCM_F64BE
359 | ids::CODEC_ID_PCM_U8 => "PCM".to_string(),
360 _ => "Unknown".to_string(),
361 }
362}
363
364#[derive(Default)]
368struct SymphoniaTags {
369 title: Option<String>,
370 artist: Option<String>,
371 album_artist: Option<String>,
372 album: Option<String>,
373 date: Option<String>,
374 track_number: Option<i32>,
375 disc: Option<i32>,
376 genre: Option<String>,
377}
378
379fn probe_symphonia_tags(path: &Path) -> SymphoniaTags {
380 use symphonia::core::formats::FormatOptions;
381 use symphonia::core::formats::probe::Hint;
382 use symphonia::core::io::MediaSourceStream;
383 use symphonia::core::meta::{MetadataOptions, StandardTag};
384
385 let file = match std::fs::File::open(path) {
386 Ok(f) => f,
387 Err(_) => return SymphoniaTags::default(),
388 };
389 let mss = MediaSourceStream::new(Box::new(file), Default::default());
390 let mut hint = Hint::new();
391 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
392 hint.with_extension(ext);
393 }
394
395 let mut reader = match symphonia::default::get_probe().probe(
396 &hint,
397 mss,
398 FormatOptions::default(),
399 MetadataOptions::default(),
400 ) {
401 Ok(r) => r,
402 Err(_) => return SymphoniaTags::default(),
403 };
404
405 let mut tags = SymphoniaTags::default();
406
407 let mut log = reader.metadata();
416 loop {
417 if let Some(rev) = log.current() {
418 for tag in &rev.media.tags {
419 match &tag.std {
420 Some(StandardTag::TrackTitle(v)) => tags.title = Some(v.to_string()),
421 Some(StandardTag::Artist(v)) => tags.artist = Some(v.to_string()),
422 Some(StandardTag::AlbumArtist(v)) => tags.album_artist = Some(v.to_string()),
423 Some(StandardTag::Album(v)) => tags.album = Some(v.to_string()),
424 Some(StandardTag::Genre(v)) => tags.genre = Some(v.to_string()),
425 Some(StandardTag::RecordingDate(v)) => tags.date = Some(v.to_string()),
426 Some(StandardTag::RecordingYear(v)) => tags.date = Some(v.to_string()),
427 Some(StandardTag::TrackNumber(v)) => tags.track_number = Some(*v as i32),
428 Some(StandardTag::DiscNumber(v)) => tags.disc = Some(*v as i32),
429 _ => {}
430 }
431 }
432 }
433 if log.pop().is_none() {
434 break;
435 }
436 }
437
438 tags
439}
440
441fn mp4_codec(path: &Path) -> String {
444 let file = match std::fs::File::open(path) {
445 Ok(f) => f,
446 Err(_) => return "AAC".to_string(),
447 };
448 let mut reader = std::io::BufReader::new(file);
449 match Mp4File::read_from(&mut reader, ParseOptions::new()) {
450 Ok(mp4) => match mp4.properties().codec() {
451 Some(Mp4Codec::ALAC) => "ALAC".to_string(),
452 Some(Mp4Codec::MP3) => "MP3".to_string(),
453 Some(Mp4Codec::FLAC) => "FLAC".to_string(),
454 _ => "AAC".to_string(),
455 },
456 Err(_) => "AAC".to_string(),
457 }
458}
459
460pub fn codec_string(ft: lofty::file::FileType) -> &'static str {
462 match ft {
463 lofty::file::FileType::Flac => "FLAC",
464 lofty::file::FileType::Mpeg => "MP3",
465 lofty::file::FileType::Mp4 => "AAC",
466 lofty::file::FileType::Opus => "Opus",
467 lofty::file::FileType::Vorbis => "Vorbis",
468 lofty::file::FileType::WavPack => "WavPack",
469 lofty::file::FileType::Wav => "WAV",
470 lofty::file::FileType::Aiff => "AIFF",
471 lofty::file::FileType::Ape => "APE",
472 _ => "Unknown",
473 }
474}
475
476pub fn extract_cover_art(path: &Path) -> Option<Vec<u8>> {
481 raise_allocation_limit();
482 let tagged_file = lofty::read_from_path(path).ok()?;
483 let tag = tagged_file
484 .primary_tag()
485 .or_else(|| tagged_file.first_tag())?;
486
487 let pictures = tag.pictures();
489 let pic = pictures
490 .iter()
491 .find(|p| p.pic_type() == lofty::picture::PictureType::CoverFront && !is_tiff(p.data()))
492 .or_else(|| pictures.iter().find(|p| !is_tiff(p.data())))?;
493
494 Some(pic.data().to_vec())
495}
496
497fn is_tiff(data: &[u8]) -> bool {
499 data.len() >= 4
500 && ((data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00)
501 || (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A))
502}
503
504pub fn metadata_from_probe_result(meta: &MetadataRevision, fallback_title: &str) -> TrackMeta {
513 let mut title: Option<String> = None;
514 let mut artist: Option<String> = None;
515 let mut album_artist: Option<String> = None;
516 let mut album: Option<String> = None;
517 let mut date: Option<String> = None;
518 let mut disc: Option<i32> = None;
519 let mut track_number: Option<i32> = None;
520 let mut genre: Option<String> = None;
521 let mut label: Option<String> = None;
522
523 let set_text = |slot: &mut Option<String>, value: &str| {
526 if !value.is_empty() {
527 *slot = Some(value.to_string());
528 }
529 };
530
531 for tag in &meta.media.tags {
532 let Some(std) = &tag.std else { continue };
533 match std {
534 StandardTag::TrackTitle(v) => set_text(&mut title, v),
535 StandardTag::Artist(v) => set_text(&mut artist, v),
536 StandardTag::AlbumArtist(v) => set_text(&mut album_artist, v),
537 StandardTag::Album(v) => set_text(&mut album, v),
538 StandardTag::ReleaseDate(v) | StandardTag::RecordingDate(v) => set_text(&mut date, v),
539 StandardTag::ReleaseYear(y) | StandardTag::RecordingYear(y) if date.is_none() => {
540 date = Some(y.to_string())
541 }
542 StandardTag::OriginalReleaseDate(v) | StandardTag::OriginalRecordingDate(v)
543 if date.is_none() =>
544 {
545 set_text(&mut date, v)
546 }
547 StandardTag::OriginalReleaseYear(y) | StandardTag::OriginalRecordingYear(y)
548 if date.is_none() =>
549 {
550 date = Some(y.to_string())
551 }
552 StandardTag::TrackNumber(n) => track_number = Some(*n as i32),
553 StandardTag::DiscNumber(n) => disc = Some(*n as i32),
554 StandardTag::Genre(v) => set_text(&mut genre, v),
555 StandardTag::Label(v) => set_text(&mut label, v),
556 _ => {}
557 }
558 }
559
560 TrackMeta {
561 title: title.unwrap_or_else(|| fallback_title.to_string()),
562 artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
563 album_artist,
564 album: album.unwrap_or_else(|| "Unknown Album".to_string()),
565 date,
566 disc,
567 track_number,
568 genre,
569 label,
570 duration_ms: None,
571 codec: None,
572 sample_rate: None,
573 bit_depth: None,
574 channels: None,
575 bitrate: None,
576 size_bytes: None,
577 mtime: None,
578 path: None,
579 source: "streaming".to_string(),
580 remote_id: None,
581 remote_url: None,
582 album_added_at: None,
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
596 fn id3v2_beats_id3v1() {
597 let dir = tempfile::tempdir().unwrap();
598 let path = dir.path().join("both.mp3");
599 crate::test_utils::generate_mp3_with_both_tags(
600 &path,
601 "Golden Skans (David E Sugar Remix)",
602 "Golden Skans (David E Sugar R",
603 );
604
605 let tags = probe_symphonia_tags(&path);
606 assert_eq!(
607 tags.title.as_deref(),
608 Some("Golden Skans (David E Sugar Remix)")
609 );
610 assert_eq!(tags.track_number, Some(7));
613 }
614
615 #[test]
616 fn test_is_audio_file() {
617 assert!(is_audio_file(Path::new("track.flac")));
618 assert!(is_audio_file(Path::new("track.FLAC")));
619 assert!(is_audio_file(Path::new("track.mp3")));
620 assert!(is_audio_file(Path::new("track.m4a")));
621 assert!(is_audio_file(Path::new("track.ogg")));
622 assert!(is_audio_file(Path::new("track.opus")));
623 assert!(is_audio_file(Path::new("track.wv")));
624 assert!(is_audio_file(Path::new("track.wav")));
625 assert!(is_audio_file(Path::new("track.aiff")));
626 assert!(is_audio_file(Path::new("track.ape")));
627
628 assert!(!is_audio_file(Path::new("cover.jpg")));
629 assert!(!is_audio_file(Path::new("notes.txt")));
630 assert!(!is_audio_file(Path::new("playlist.m3u")));
631 assert!(!is_audio_file(Path::new("track.pdf")));
632 assert!(!is_audio_file(Path::new("noext")));
633 }
634
635 #[test]
636 fn test_is_audio_file_paths() {
637 assert!(is_audio_file(Path::new("/music/artist/album/01.flac")));
638 assert!(!is_audio_file(Path::new("/music/artist/album/cover.png")));
639 }
640
641 #[test]
642 fn test_read_metadata_nonexistent() {
643 let result = read_metadata(Path::new("/nonexistent/track.flac"));
644 assert!(result.is_err());
645 }
646
647 #[test]
648 fn test_codec_string() {
649 assert_eq!(codec_string(lofty::file::FileType::Flac), "FLAC");
650 assert_eq!(codec_string(lofty::file::FileType::Mpeg), "MP3");
651 assert_eq!(codec_string(lofty::file::FileType::Opus), "Opus");
652 assert_eq!(codec_string(lofty::file::FileType::Wav), "WAV");
653 }
654
655 use symphonia::core::meta::well_known::METADATA_ID_ID3V2;
658 use symphonia::core::meta::{MetadataBuilder, MetadataInfo, StandardTag, Tag};
659
660 const TEST_META_INFO: MetadataInfo = MetadataInfo {
661 metadata: METADATA_ID_ID3V2,
662 short_name: "id3v2",
663 long_name: "ID3v2",
664 };
665
666 fn make_revision(tags: &[StandardTag]) -> symphonia::core::meta::MetadataRevision {
667 let mut builder = MetadataBuilder::new(TEST_META_INFO);
668 for std in tags {
669 builder.add_tag(Tag::new_from_parts("", "", Some(std.clone())));
670 }
671 builder.build()
672 }
673
674 #[test]
675 fn test_probe_track_and_disc_numbers() {
676 let rev = make_revision(&[
677 StandardTag::TrackTitle("My Song".to_string().into()),
678 StandardTag::Artist("Artist".to_string().into()),
679 StandardTag::Album("Album".to_string().into()),
680 StandardTag::TrackNumber(3),
681 StandardTag::TrackTotal(12),
682 StandardTag::DiscNumber(2),
683 ]);
684 let meta = metadata_from_probe_result(&rev, "fallback");
685 assert_eq!(
686 meta.track_number,
687 Some(3),
688 "track number should come from the track number tag, not the total"
689 );
690 assert_eq!(meta.disc, Some(2));
691 }
692
693 #[test]
694 fn test_probe_original_date_fallback() {
695 let rev = make_revision(&[
697 StandardTag::TrackTitle("My Song".to_string().into()),
698 StandardTag::OriginalReleaseDate("1991".to_string().into()),
699 ]);
700 let meta = metadata_from_probe_result(&rev, "fallback");
701 assert_eq!(
702 meta.date,
703 Some("1991".to_string()),
704 "original release date should be used when the release date is missing"
705 );
706 }
707
708 #[test]
709 fn test_probe_original_date_not_used_when_date_present() {
710 let rev = make_revision(&[
712 StandardTag::ReleaseDate("2005".to_string().into()),
713 StandardTag::OriginalReleaseDate("1991".to_string().into()),
714 ]);
715 let meta = metadata_from_probe_result(&rev, "fallback");
716 assert_eq!(
717 meta.date,
718 Some("2005".to_string()),
719 "release date should take precedence over original release date"
720 );
721 }
722
723 #[test]
724 fn test_probe_empty_values_skipped() {
725 let rev = make_revision(&[
728 StandardTag::Artist(String::new().into()),
729 StandardTag::Album(String::new().into()),
730 StandardTag::Genre(String::new().into()),
731 ]);
732 let meta = metadata_from_probe_result(&rev, "Title");
733 assert_eq!(
735 meta.artist, "Unknown Artist",
736 "empty artist tag should fall back to 'Unknown Artist'"
737 );
738 assert_eq!(
739 meta.album, "Unknown Album",
740 "empty album tag should fall back to 'Unknown Album'"
741 );
742 assert_eq!(meta.genre, None, "empty genre tag should produce None");
743 }
744
745 #[test]
746 fn test_probe_defaults() {
747 let rev = make_revision(&[]);
750 let meta = metadata_from_probe_result(&rev, "Fallback Title");
751 assert_eq!(
752 meta.title, "Fallback Title",
753 "missing title should use fallback_title argument"
754 );
755 assert_eq!(
756 meta.artist, "Unknown Artist",
757 "missing artist should default to 'Unknown Artist'"
758 );
759 assert_eq!(
760 meta.album, "Unknown Album",
761 "missing album should default to 'Unknown Album'"
762 );
763 assert_eq!(meta.track_number, None);
764 assert_eq!(meta.date, None);
765 assert_eq!(meta.genre, None);
766 assert_eq!(meta.source, "streaming");
767 }
768
769 #[test]
770 fn test_mp4_codec_nonexistent_file_falls_back_to_aac() {
771 assert_eq!(mp4_codec(Path::new("/nonexistent/track.m4a")), "AAC");
772 }
773
774 #[test]
775 fn test_mp4_codec_non_mp4_file_falls_back_to_aac() {
776 let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
778 assert_eq!(mp4_codec(&manifest), "AAC");
779 }
780
781 #[test]
782 #[cfg(target_os = "macos")]
783 fn test_mp4_codec_real_alac_file() {
784 let alac_path = Path::new(
788 "/Volumes/Turtlehead/music/Valet Girls/(2017) PERENNIAL VICE [ALAC]/0101. Valet Girls - Tis the Season.m4a",
789 );
790 if !alac_path.exists() {
791 eprintln!("SKIP: ALAC test file not found (volume not mounted)");
792 return;
793 }
794 assert_eq!(
795 mp4_codec(alac_path),
796 "ALAC",
797 "real ALAC .m4a should be identified as ALAC, not AAC"
798 );
799 }
800}