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