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] = &[
30 "flac", "mp3", "m4a", "aac", "ogg", "opus", "wav", "aiff", "aif", "alac",
31];
32
33pub 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
40pub fn read_metadata(path: &Path) -> Result<TrackMeta, MetadataError> {
45 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 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
77fn 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
92fn 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 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
117fn read_metadata_lofty(
119 path: &Path,
120 tagged_file: &lofty::file::TaggedFile,
121) -> Result<TrackMeta, MetadataError> {
122 let properties = tagged_file.properties();
123 let duration_ms = properties.duration().as_millis() as i64;
124 let sample_rate = properties.sample_rate().map(|r| r as i32);
125 let bit_depth = properties.bit_depth().map(|b| b as i32);
126 let channels = properties.channels().map(|c| c as i32);
127 let bitrate = properties.audio_bitrate().map(|b| b as i32);
128
129 let tag = tagged_file
130 .primary_tag()
131 .or_else(|| tagged_file.first_tag());
132
133 let (title, artist, album_artist, album, date, disc, track_number, genre, label) =
134 if let Some(tag) = tag {
135 (
136 tag.title().map(|s| s.to_string()),
137 tag.artist().map(|s| s.to_string()),
138 tag.get_string(ItemKey::AlbumArtist).map(|s| s.to_string()),
139 tag.album().map(|s| s.to_string()),
140 tag.get_string(ItemKey::Year)
142 .or_else(|| tag.get_string(ItemKey::RecordingDate))
143 .map(|s| s.to_string()),
144 tag.disk().map(|d| d as i32),
145 tag.track().map(|t| t as i32),
146 tag.genre().map(|s| s.to_string()),
147 tag.get_string(ItemKey::Label).map(|s| s.to_string()),
148 )
149 } else {
150 (None, None, None, None, None, None, None, None, None)
151 };
152
153 let title = title.unwrap_or_else(|| {
154 path.file_stem()
155 .and_then(|s| s.to_str())
156 .unwrap_or("Unknown")
157 .to_string()
158 });
159 let artist = artist.unwrap_or_else(|| "Unknown Artist".to_string());
160 let album = album.unwrap_or_else(|| "Unknown Album".to_string());
161
162 let file_meta = fs::metadata(path)?;
163 let size_bytes = file_meta.len() as i64;
164 let mtime = file_meta
165 .modified()
166 .ok()
167 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
168 .map(|d| d.as_secs() as i64);
169
170 let codec = if tagged_file.file_type() == lofty::file::FileType::Mp4 {
171 mp4_codec(path)
172 } else {
173 codec_string(tagged_file.file_type()).to_string()
174 };
175
176 Ok(TrackMeta {
177 title,
178 artist,
179 album_artist,
180 album,
181 date,
182 disc,
183 track_number,
184 genre,
185 label,
186 duration_ms: Some(duration_ms),
187 codec: Some(codec),
188 sample_rate,
189 bit_depth,
190 channels,
191 bitrate,
192 size_bytes: Some(size_bytes),
193 mtime,
194 path: Some(path.to_string_lossy().to_string()),
195 source: "local".to_string(),
196 remote_id: None,
197 album_remote_id: None,
198 artist_remote_id: None,
199 mbid: None,
200 remote_url: None,
201 album_added_at: mtime.and_then(iso8601_utc),
202 })
203}
204
205fn read_metadata_fallback(path: &Path) -> Result<TrackMeta, MetadataError> {
208 let file_meta = fs::metadata(path)?;
209 let size_bytes = file_meta.len() as i64;
210 let mtime = file_meta
211 .modified()
212 .ok()
213 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
214 .map(|d| d.as_secs() as i64);
215
216 let props = probe_symphonia(path);
218
219 let tags = probe_symphonia_tags(path);
227
228 let title = tags.title.unwrap_or_else(|| {
229 path.file_stem()
230 .and_then(|s| s.to_str())
231 .unwrap_or("Unknown")
232 .to_string()
233 });
234 let artist = tags.artist.unwrap_or_else(|| "Unknown Artist".to_string());
235 let album = tags.album.unwrap_or_else(|| "Unknown Album".to_string());
236
237 Ok(TrackMeta {
238 title,
239 artist,
240 album_artist: tags.album_artist,
241 album,
242 date: tags.date,
243 disc: tags.disc,
244 track_number: tags.track_number,
245 genre: tags.genre,
246 label: None,
247 duration_ms: props.duration_ms,
248 codec: props.codec,
249 sample_rate: props.sample_rate,
250 bit_depth: props.bit_depth,
251 channels: props.channels,
252 bitrate: props.bitrate,
253 size_bytes: Some(size_bytes),
254 mtime,
255 path: Some(path.to_string_lossy().to_string()),
256 source: "local".to_string(),
257 remote_id: None,
258 album_remote_id: None,
259 artist_remote_id: None,
260 mbid: None,
261 remote_url: None,
262 album_added_at: mtime.and_then(iso8601_utc),
263 })
264}
265
266fn iso8601_utc(unix_secs: i64) -> Option<String> {
270 chrono::DateTime::from_timestamp(unix_secs, 0)
271 .map(|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string())
272}
273
274struct SymphoniaProps {
276 duration_ms: Option<i64>,
277 sample_rate: Option<i32>,
278 bit_depth: Option<i32>,
279 channels: Option<i32>,
280 bitrate: Option<i32>,
281 codec: Option<String>,
282}
283
284fn probe_symphonia(path: &Path) -> SymphoniaProps {
286 use symphonia::core::codecs::audio::CODEC_ID_NULL_AUDIO;
287 use symphonia::core::formats::probe::Hint;
288 use symphonia::core::formats::{FormatOptions, TrackType};
289 use symphonia::core::io::MediaSourceStream;
290 use symphonia::core::meta::MetadataOptions;
291
292 let empty = SymphoniaProps {
293 duration_ms: None,
294 sample_rate: None,
295 bit_depth: None,
296 channels: None,
297 bitrate: None,
298 codec: None,
299 };
300
301 let file = match std::fs::File::open(path) {
302 Ok(f) => f,
303 Err(_) => return empty,
304 };
305 let mss = MediaSourceStream::new(Box::new(file), Default::default());
306 let mut hint = Hint::new();
307 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
308 hint.with_extension(ext);
309 }
310
311 let reader = match symphonia::default::get_probe().probe(
312 &hint,
313 mss,
314 FormatOptions::default(),
315 MetadataOptions::default(),
316 ) {
317 Ok(r) => r,
318 Err(_) => return empty,
319 };
320
321 let track = match reader.default_track(TrackType::Audio) {
322 Some(t) => t,
323 None => return empty,
324 };
325
326 let params = match track.codec_params.as_ref().and_then(|p| p.audio()) {
327 Some(p) => p,
328 None => return empty,
329 };
330 let sample_rate = params.sample_rate.map(|r| r as i32);
331 let bit_depth = params.bits_per_sample.map(|b| b as i32);
332 let channels = params.channels.as_ref().map(|c| c.count() as i32);
333
334 let duration_ms = params.sample_rate.and_then(|sr| {
335 let ms = crate::audio::buffer::track_duration_ms(&*reader, track, sr);
336 (ms > 0).then_some(ms as i64)
337 });
338
339 let bitrate = params.sample_rate.and_then(|sr| {
340 params.bits_per_sample.and_then(|bps| {
341 params
342 .channels
343 .as_ref()
344 .map(|ch| (sr as i32 * bps as i32 * ch.count() as i32) / 1000)
345 })
346 });
347
348 let codec = if params.codec != CODEC_ID_NULL_AUDIO {
349 Some(symphonia_codec_name(params.codec))
350 } else {
351 None
352 };
353
354 SymphoniaProps {
355 duration_ms,
356 sample_rate,
357 bit_depth,
358 channels,
359 bitrate,
360 codec,
361 }
362}
363
364fn symphonia_codec_name(codec: symphonia::core::codecs::audio::AudioCodecId) -> String {
366 use symphonia::core::codecs::audio::well_known as ids;
367 match codec {
368 ids::CODEC_ID_FLAC => "FLAC".to_string(),
369 ids::CODEC_ID_MP3 => "MP3".to_string(),
370 ids::CODEC_ID_AAC => "AAC".to_string(),
371 ids::CODEC_ID_ALAC => "ALAC".to_string(),
372 ids::CODEC_ID_VORBIS => "Vorbis".to_string(),
373 ids::CODEC_ID_OPUS => "Opus".to_string(),
374 ids::CODEC_ID_PCM_S16LE
375 | ids::CODEC_ID_PCM_S24LE
376 | ids::CODEC_ID_PCM_S32LE
377 | ids::CODEC_ID_PCM_F32LE
378 | ids::CODEC_ID_PCM_F64LE
379 | ids::CODEC_ID_PCM_S16BE
380 | ids::CODEC_ID_PCM_S24BE
381 | ids::CODEC_ID_PCM_S32BE
382 | ids::CODEC_ID_PCM_F32BE
383 | ids::CODEC_ID_PCM_F64BE
384 | ids::CODEC_ID_PCM_U8 => "PCM".to_string(),
385 _ => "Unknown".to_string(),
386 }
387}
388
389#[derive(Default)]
393struct SymphoniaTags {
394 title: Option<String>,
395 artist: Option<String>,
396 album_artist: Option<String>,
397 album: Option<String>,
398 date: Option<String>,
399 track_number: Option<i32>,
400 disc: Option<i32>,
401 genre: Option<String>,
402}
403
404fn probe_symphonia_tags(path: &Path) -> SymphoniaTags {
405 use symphonia::core::formats::FormatOptions;
406 use symphonia::core::formats::probe::Hint;
407 use symphonia::core::io::MediaSourceStream;
408 use symphonia::core::meta::{MetadataOptions, StandardTag};
409
410 let file = match std::fs::File::open(path) {
411 Ok(f) => f,
412 Err(_) => return SymphoniaTags::default(),
413 };
414 let mss = MediaSourceStream::new(Box::new(file), Default::default());
415 let mut hint = Hint::new();
416 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
417 hint.with_extension(ext);
418 }
419
420 let mut reader = match symphonia::default::get_probe().probe(
421 &hint,
422 mss,
423 FormatOptions::default(),
424 MetadataOptions::default(),
425 ) {
426 Ok(r) => r,
427 Err(_) => return SymphoniaTags::default(),
428 };
429
430 let mut tags = SymphoniaTags::default();
431
432 let mut log = reader.metadata();
441 loop {
442 if let Some(rev) = log.current() {
443 for tag in &rev.media.tags {
444 match &tag.std {
445 Some(StandardTag::TrackTitle(v)) => tags.title = Some(v.to_string()),
446 Some(StandardTag::Artist(v)) => tags.artist = Some(v.to_string()),
447 Some(StandardTag::AlbumArtist(v)) => tags.album_artist = Some(v.to_string()),
448 Some(StandardTag::Album(v)) => tags.album = Some(v.to_string()),
449 Some(StandardTag::Genre(v)) => tags.genre = Some(v.to_string()),
450 Some(StandardTag::RecordingDate(v)) => tags.date = Some(v.to_string()),
451 Some(StandardTag::RecordingYear(v)) => tags.date = Some(v.to_string()),
452 Some(StandardTag::TrackNumber(v)) => tags.track_number = Some(*v as i32),
453 Some(StandardTag::DiscNumber(v)) => tags.disc = Some(*v as i32),
454 _ => {}
455 }
456 }
457 }
458 if log.pop().is_none() {
459 break;
460 }
461 }
462
463 tags
464}
465
466fn mp4_codec(path: &Path) -> String {
469 let file = match std::fs::File::open(path) {
470 Ok(f) => f,
471 Err(_) => return "AAC".to_string(),
472 };
473 let mut reader = std::io::BufReader::new(file);
474 match Mp4File::read_from(&mut reader, ParseOptions::new()) {
475 Ok(mp4) => match mp4.properties().codec() {
476 Some(Mp4Codec::ALAC) => "ALAC".to_string(),
477 Some(Mp4Codec::MP3) => "MP3".to_string(),
478 Some(Mp4Codec::FLAC) => "FLAC".to_string(),
479 _ => "AAC".to_string(),
480 },
481 Err(_) => "AAC".to_string(),
482 }
483}
484
485pub fn codec_string(ft: lofty::file::FileType) -> &'static str {
487 match ft {
488 lofty::file::FileType::Flac => "FLAC",
489 lofty::file::FileType::Mpeg => "MP3",
490 lofty::file::FileType::Mp4 => "AAC",
491 lofty::file::FileType::Opus => "Opus",
492 lofty::file::FileType::Vorbis => "Vorbis",
493 lofty::file::FileType::Wav => "WAV",
494 lofty::file::FileType::Aiff => "AIFF",
495 _ => "Unknown",
496 }
497}
498
499pub fn extract_cover_art(path: &Path) -> Option<Vec<u8>> {
504 raise_allocation_limit();
505 let tagged_file = lofty::read_from_path(path).ok()?;
506 let tag = tagged_file
507 .primary_tag()
508 .or_else(|| tagged_file.first_tag())?;
509
510 let pictures = tag.pictures();
512 let pic = pictures
513 .iter()
514 .find(|p| p.pic_type() == lofty::picture::PictureType::CoverFront && !is_tiff(p.data()))
515 .or_else(|| pictures.iter().find(|p| !is_tiff(p.data())))?;
516
517 Some(pic.data().to_vec())
518}
519
520fn is_tiff(data: &[u8]) -> bool {
522 data.len() >= 4
523 && ((data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00)
524 || (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A))
525}
526
527pub fn metadata_from_probe_result(meta: &MetadataRevision, fallback_title: &str) -> TrackMeta {
536 let mut title: Option<String> = None;
537 let mut artist: Option<String> = None;
538 let mut album_artist: Option<String> = None;
539 let mut album: Option<String> = None;
540 let mut date: Option<String> = None;
541 let mut disc: Option<i32> = None;
542 let mut track_number: Option<i32> = None;
543 let mut genre: Option<String> = None;
544 let mut label: Option<String> = None;
545
546 let set_text = |slot: &mut Option<String>, value: &str| {
549 if !value.is_empty() {
550 *slot = Some(value.to_string());
551 }
552 };
553
554 for tag in &meta.media.tags {
555 let Some(std) = &tag.std else { continue };
556 match std {
557 StandardTag::TrackTitle(v) => set_text(&mut title, v),
558 StandardTag::Artist(v) => set_text(&mut artist, v),
559 StandardTag::AlbumArtist(v) => set_text(&mut album_artist, v),
560 StandardTag::Album(v) => set_text(&mut album, v),
561 StandardTag::ReleaseDate(v) | StandardTag::RecordingDate(v) => set_text(&mut date, v),
562 StandardTag::ReleaseYear(y) | StandardTag::RecordingYear(y) if date.is_none() => {
563 date = Some(y.to_string())
564 }
565 StandardTag::OriginalReleaseDate(v) | StandardTag::OriginalRecordingDate(v)
566 if date.is_none() =>
567 {
568 set_text(&mut date, v)
569 }
570 StandardTag::OriginalReleaseYear(y) | StandardTag::OriginalRecordingYear(y)
571 if date.is_none() =>
572 {
573 date = Some(y.to_string())
574 }
575 StandardTag::TrackNumber(n) => track_number = Some(*n as i32),
576 StandardTag::DiscNumber(n) => disc = Some(*n as i32),
577 StandardTag::Genre(v) => set_text(&mut genre, v),
578 StandardTag::Label(v) => set_text(&mut label, v),
579 _ => {}
580 }
581 }
582
583 TrackMeta {
584 title: title.unwrap_or_else(|| fallback_title.to_string()),
585 artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
586 album_artist,
587 album: album.unwrap_or_else(|| "Unknown Album".to_string()),
588 date,
589 disc,
590 track_number,
591 genre,
592 label,
593 duration_ms: None,
594 codec: None,
595 sample_rate: None,
596 bit_depth: None,
597 channels: None,
598 bitrate: None,
599 size_bytes: None,
600 mtime: None,
601 path: None,
602 source: "streaming".to_string(),
603 remote_id: None,
604 album_remote_id: None,
605 artist_remote_id: None,
606 mbid: None,
607 remote_url: None,
608 album_added_at: None,
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615
616 #[test]
622 fn id3v2_beats_id3v1() {
623 let dir = tempfile::tempdir().unwrap();
624 let path = dir.path().join("both.mp3");
625 crate::test_utils::generate_mp3_with_both_tags(
626 &path,
627 "Golden Skans (David E Sugar Remix)",
628 "Golden Skans (David E Sugar R",
629 );
630
631 let tags = probe_symphonia_tags(&path);
632 assert_eq!(
633 tags.title.as_deref(),
634 Some("Golden Skans (David E Sugar Remix)")
635 );
636 assert_eq!(tags.track_number, Some(7));
639 }
640
641 #[test]
642 fn test_is_audio_file() {
643 assert!(is_audio_file(Path::new("track.flac")));
644 assert!(is_audio_file(Path::new("track.FLAC")));
645 assert!(is_audio_file(Path::new("track.mp3")));
646 assert!(is_audio_file(Path::new("track.m4a")));
647 assert!(is_audio_file(Path::new("track.ogg")));
648 assert!(is_audio_file(Path::new("track.opus")));
649 assert!(is_audio_file(Path::new("track.wav")));
650 assert!(is_audio_file(Path::new("track.aiff")));
651
652 assert!(!is_audio_file(Path::new("track.wv")));
655 assert!(!is_audio_file(Path::new("track.ape")));
656
657 assert!(!is_audio_file(Path::new("cover.jpg")));
658 assert!(!is_audio_file(Path::new("notes.txt")));
659 assert!(!is_audio_file(Path::new("playlist.m3u")));
660 assert!(!is_audio_file(Path::new("track.pdf")));
661 assert!(!is_audio_file(Path::new("noext")));
662 }
663
664 #[test]
665 fn test_is_audio_file_paths() {
666 assert!(is_audio_file(Path::new("/music/artist/album/01.flac")));
667 assert!(!is_audio_file(Path::new("/music/artist/album/cover.png")));
668 }
669
670 #[test]
671 fn test_read_metadata_nonexistent() {
672 let result = read_metadata(Path::new("/nonexistent/track.flac"));
673 assert!(result.is_err());
674 }
675
676 #[test]
677 fn test_codec_string() {
678 assert_eq!(codec_string(lofty::file::FileType::Flac), "FLAC");
679 assert_eq!(codec_string(lofty::file::FileType::Mpeg), "MP3");
680 assert_eq!(codec_string(lofty::file::FileType::Opus), "Opus");
681 assert_eq!(codec_string(lofty::file::FileType::Wav), "WAV");
682 }
683
684 use symphonia::core::meta::well_known::METADATA_ID_ID3V2;
687 use symphonia::core::meta::{MetadataBuilder, MetadataInfo, StandardTag, Tag};
688
689 const TEST_META_INFO: MetadataInfo = MetadataInfo {
690 metadata: METADATA_ID_ID3V2,
691 short_name: "id3v2",
692 long_name: "ID3v2",
693 };
694
695 fn make_revision(tags: &[StandardTag]) -> symphonia::core::meta::MetadataRevision {
696 let mut builder = MetadataBuilder::new(TEST_META_INFO);
697 for std in tags {
698 builder.add_tag(Tag::new_from_parts("", "", Some(std.clone())));
699 }
700 builder.build()
701 }
702
703 #[test]
704 fn test_probe_track_and_disc_numbers() {
705 let rev = make_revision(&[
706 StandardTag::TrackTitle("My Song".to_string().into()),
707 StandardTag::Artist("Artist".to_string().into()),
708 StandardTag::Album("Album".to_string().into()),
709 StandardTag::TrackNumber(3),
710 StandardTag::TrackTotal(12),
711 StandardTag::DiscNumber(2),
712 ]);
713 let meta = metadata_from_probe_result(&rev, "fallback");
714 assert_eq!(
715 meta.track_number,
716 Some(3),
717 "track number should come from the track number tag, not the total"
718 );
719 assert_eq!(meta.disc, Some(2));
720 }
721
722 #[test]
723 fn test_probe_original_date_fallback() {
724 let rev = make_revision(&[
726 StandardTag::TrackTitle("My Song".to_string().into()),
727 StandardTag::OriginalReleaseDate("1991".to_string().into()),
728 ]);
729 let meta = metadata_from_probe_result(&rev, "fallback");
730 assert_eq!(
731 meta.date,
732 Some("1991".to_string()),
733 "original release date should be used when the release date is missing"
734 );
735 }
736
737 #[test]
738 fn test_probe_original_date_not_used_when_date_present() {
739 let rev = make_revision(&[
741 StandardTag::ReleaseDate("2005".to_string().into()),
742 StandardTag::OriginalReleaseDate("1991".to_string().into()),
743 ]);
744 let meta = metadata_from_probe_result(&rev, "fallback");
745 assert_eq!(
746 meta.date,
747 Some("2005".to_string()),
748 "release date should take precedence over original release date"
749 );
750 }
751
752 #[test]
753 fn test_probe_empty_values_skipped() {
754 let rev = make_revision(&[
757 StandardTag::Artist(String::new().into()),
758 StandardTag::Album(String::new().into()),
759 StandardTag::Genre(String::new().into()),
760 ]);
761 let meta = metadata_from_probe_result(&rev, "Title");
762 assert_eq!(
764 meta.artist, "Unknown Artist",
765 "empty artist tag should fall back to 'Unknown Artist'"
766 );
767 assert_eq!(
768 meta.album, "Unknown Album",
769 "empty album tag should fall back to 'Unknown Album'"
770 );
771 assert_eq!(meta.genre, None, "empty genre tag should produce None");
772 }
773
774 #[test]
775 fn test_probe_defaults() {
776 let rev = make_revision(&[]);
779 let meta = metadata_from_probe_result(&rev, "Fallback Title");
780 assert_eq!(
781 meta.title, "Fallback Title",
782 "missing title should use fallback_title argument"
783 );
784 assert_eq!(
785 meta.artist, "Unknown Artist",
786 "missing artist should default to 'Unknown Artist'"
787 );
788 assert_eq!(
789 meta.album, "Unknown Album",
790 "missing album should default to 'Unknown Album'"
791 );
792 assert_eq!(meta.track_number, None);
793 assert_eq!(meta.date, None);
794 assert_eq!(meta.genre, None);
795 assert_eq!(meta.source, "streaming");
796 }
797
798 #[test]
799 fn test_mp4_codec_nonexistent_file_falls_back_to_aac() {
800 assert_eq!(mp4_codec(Path::new("/nonexistent/track.m4a")), "AAC");
801 }
802
803 #[test]
804 fn test_mp4_codec_non_mp4_file_falls_back_to_aac() {
805 let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
807 assert_eq!(mp4_codec(&manifest), "AAC");
808 }
809
810 #[test]
811 #[cfg(target_os = "macos")]
812 fn test_mp4_codec_real_alac_file() {
813 let alac_path = Path::new(
817 "/Volumes/Turtlehead/music/Valet Girls/(2017) PERENNIAL VICE [ALAC]/0101. Valet Girls - Tis the Season.m4a",
818 );
819 if !alac_path.exists() {
820 eprintln!("SKIP: ALAC test file not found (volume not mounted)");
821 return;
822 }
823 assert_eq!(
824 mp4_codec(alac_path),
825 "ALAC",
826 "real ALAC .m4a should be identified as ALAC, not AAC"
827 );
828 }
829
830 #[test]
834 fn holding_the_pictures_back_changes_nothing_lofty_parses() {
835 let dir = tempfile::tempdir().unwrap();
836 let art: Vec<u8> = (0..40_000u32).map(|i| (i % 251) as u8 + 1).collect();
837
838 for version in [2, 3, 4] {
839 let path = dir.path().join(format!("v2{version}.mp3"));
840 crate::test_utils::generate_mp3_with_picture(
841 &path,
842 version,
843 "Golden Skans",
844 "Klaxons",
845 &art,
846 );
847
848 let held_back = read_tagged_file(&path).unwrap();
849 let plain = lofty::probe::Probe::open(&path)
850 .unwrap()
851 .options(ParseOptions::new().read_cover_art(false))
852 .read()
853 .unwrap();
854
855 let tags = |f: &lofty::file::TaggedFile| {
856 let tag = f.primary_tag().or_else(|| f.first_tag()).unwrap();
857 (
858 tag.title().map(|s| s.to_string()),
859 tag.artist().map(|s| s.to_string()),
860 )
861 };
862 assert_eq!(tags(&held_back), tags(&plain), "v2.{version}");
863 assert_eq!(
864 tags(&held_back),
865 (Some("Golden Skans".into()), Some("Klaxons".into())),
866 "v2.{version}: the frame after the picture must survive intact"
867 );
868 assert_eq!(
869 held_back.properties().duration(),
870 plain.properties().duration(),
871 "v2.{version}"
872 );
873
874 assert_eq!(extract_cover_art(&path), Some(art.clone()), "v2.{version}");
876 }
877 }
878}