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