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