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