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 lofty::read_from_path(path) {
55 Ok(tagged_file) => read_metadata_lofty(path, &tagged_file),
56 Err(e) => {
57 log::warn!(
58 "lofty failed for {}: {}; falling back to probe",
59 path.display(),
60 e
61 );
62 read_metadata_fallback(path)
63 }
64 }
65}
66
67fn read_metadata_lofty(
69 path: &Path,
70 tagged_file: &lofty::file::TaggedFile,
71) -> Result<TrackMeta, MetadataError> {
72 let properties = tagged_file.properties();
73 let duration_ms = properties.duration().as_millis() as i64;
74 let sample_rate = properties.sample_rate().map(|r| r as i32);
75 let bit_depth = properties.bit_depth().map(|b| b as i32);
76 let channels = properties.channels().map(|c| c as i32);
77 let bitrate = properties.audio_bitrate().map(|b| b as i32);
78
79 let tag = tagged_file
80 .primary_tag()
81 .or_else(|| tagged_file.first_tag());
82
83 let (title, artist, album_artist, album, date, disc, track_number, genre, label) =
84 if let Some(tag) = tag {
85 (
86 tag.title().map(|s| s.to_string()),
87 tag.artist().map(|s| s.to_string()),
88 tag.get_string(ItemKey::AlbumArtist).map(|s| s.to_string()),
89 tag.album().map(|s| s.to_string()),
90 tag.get_string(ItemKey::Year)
92 .or_else(|| tag.get_string(ItemKey::RecordingDate))
93 .map(|s| s.to_string()),
94 tag.disk().map(|d| d as i32),
95 tag.track().map(|t| t as i32),
96 tag.genre().map(|s| s.to_string()),
97 tag.get_string(ItemKey::Label).map(|s| s.to_string()),
98 )
99 } else {
100 (None, None, None, None, None, None, None, None, None)
101 };
102
103 let title = title.unwrap_or_else(|| {
104 path.file_stem()
105 .and_then(|s| s.to_str())
106 .unwrap_or("Unknown")
107 .to_string()
108 });
109 let artist = artist.unwrap_or_else(|| "Unknown Artist".to_string());
110 let album = album.unwrap_or_else(|| "Unknown Album".to_string());
111
112 let file_meta = fs::metadata(path)?;
113 let size_bytes = file_meta.len() as i64;
114 let mtime = file_meta
115 .modified()
116 .ok()
117 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
118 .map(|d| d.as_secs() as i64);
119
120 let codec = if tagged_file.file_type() == lofty::file::FileType::Mp4 {
121 mp4_codec(path)
122 } else {
123 codec_string(tagged_file.file_type()).to_string()
124 };
125
126 Ok(TrackMeta {
127 title,
128 artist,
129 album_artist,
130 album,
131 date,
132 disc,
133 track_number,
134 genre,
135 label,
136 duration_ms: Some(duration_ms),
137 codec: Some(codec),
138 sample_rate,
139 bit_depth,
140 channels,
141 bitrate,
142 size_bytes: Some(size_bytes),
143 mtime,
144 path: Some(path.to_string_lossy().to_string()),
145 source: "local".to_string(),
146 remote_id: None,
147 remote_url: None,
148 })
149}
150
151fn read_metadata_fallback(path: &Path) -> Result<TrackMeta, MetadataError> {
154 let file_meta = fs::metadata(path)?;
155 let size_bytes = file_meta.len() as i64;
156 let mtime = file_meta
157 .modified()
158 .ok()
159 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
160 .map(|d| d.as_secs() as i64);
161
162 let props = probe_symphonia(path);
164
165 let (title, artist, album) = probe_symphonia_tags(path);
168
169 let title = title.unwrap_or_else(|| {
170 path.file_stem()
171 .and_then(|s| s.to_str())
172 .unwrap_or("Unknown")
173 .to_string()
174 });
175 let artist = artist.unwrap_or_else(|| "Unknown Artist".to_string());
176 let album = album.unwrap_or_else(|| "Unknown Album".to_string());
177
178 Ok(TrackMeta {
179 title,
180 artist,
181 album_artist: None,
182 album,
183 date: None,
184 disc: None,
185 track_number: None,
186 genre: None,
187 label: None,
188 duration_ms: props.duration_ms,
189 codec: props.codec,
190 sample_rate: props.sample_rate,
191 bit_depth: props.bit_depth,
192 channels: props.channels,
193 bitrate: props.bitrate,
194 size_bytes: Some(size_bytes),
195 mtime,
196 path: Some(path.to_string_lossy().to_string()),
197 source: "local".to_string(),
198 remote_id: None,
199 remote_url: None,
200 })
201}
202
203struct SymphoniaProps {
205 duration_ms: Option<i64>,
206 sample_rate: Option<i32>,
207 bit_depth: Option<i32>,
208 channels: Option<i32>,
209 bitrate: Option<i32>,
210 codec: Option<String>,
211}
212
213fn probe_symphonia(path: &Path) -> SymphoniaProps {
215 use symphonia::core::codecs::audio::CODEC_ID_NULL_AUDIO;
216 use symphonia::core::formats::probe::Hint;
217 use symphonia::core::formats::{FormatOptions, TrackType};
218 use symphonia::core::io::MediaSourceStream;
219 use symphonia::core::meta::MetadataOptions;
220
221 let empty = SymphoniaProps {
222 duration_ms: None,
223 sample_rate: None,
224 bit_depth: None,
225 channels: None,
226 bitrate: None,
227 codec: None,
228 };
229
230 let file = match std::fs::File::open(path) {
231 Ok(f) => f,
232 Err(_) => return empty,
233 };
234 let mss = MediaSourceStream::new(Box::new(file), Default::default());
235 let mut hint = Hint::new();
236 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
237 hint.with_extension(ext);
238 }
239
240 let reader = match symphonia::default::get_probe().probe(
241 &hint,
242 mss,
243 FormatOptions::default(),
244 MetadataOptions::default(),
245 ) {
246 Ok(r) => r,
247 Err(_) => return empty,
248 };
249
250 let track = match reader.default_track(TrackType::Audio) {
251 Some(t) => t,
252 None => return empty,
253 };
254
255 let params = match track.codec_params.as_ref().and_then(|p| p.audio()) {
256 Some(p) => p,
257 None => return empty,
258 };
259 let sample_rate = params.sample_rate.map(|r| r as i32);
260 let bit_depth = params.bits_per_sample.map(|b| b as i32);
261 let channels = params.channels.as_ref().map(|c| c.count() as i32);
262
263 let duration_ms = params.sample_rate.and_then(|sr| {
264 let ms = crate::audio::buffer::track_duration_ms(&*reader, track, sr);
265 (ms > 0).then_some(ms as i64)
266 });
267
268 let bitrate = params.sample_rate.and_then(|sr| {
269 params.bits_per_sample.and_then(|bps| {
270 params
271 .channels
272 .as_ref()
273 .map(|ch| (sr as i32 * bps as i32 * ch.count() as i32) / 1000)
274 })
275 });
276
277 let codec = if params.codec != CODEC_ID_NULL_AUDIO {
278 Some(symphonia_codec_name(params.codec))
279 } else {
280 None
281 };
282
283 SymphoniaProps {
284 duration_ms,
285 sample_rate,
286 bit_depth,
287 channels,
288 bitrate,
289 codec,
290 }
291}
292
293fn symphonia_codec_name(codec: symphonia::core::codecs::audio::AudioCodecId) -> String {
295 use symphonia::core::codecs::audio::well_known as ids;
296 match codec {
297 ids::CODEC_ID_FLAC => "FLAC".to_string(),
298 ids::CODEC_ID_MP3 => "MP3".to_string(),
299 ids::CODEC_ID_AAC => "AAC".to_string(),
300 ids::CODEC_ID_ALAC => "ALAC".to_string(),
301 ids::CODEC_ID_VORBIS => "Vorbis".to_string(),
302 ids::CODEC_ID_OPUS => "Opus".to_string(),
303 ids::CODEC_ID_WAVPACK => "WavPack".to_string(),
304 ids::CODEC_ID_PCM_S16LE
305 | ids::CODEC_ID_PCM_S24LE
306 | ids::CODEC_ID_PCM_S32LE
307 | ids::CODEC_ID_PCM_F32LE
308 | ids::CODEC_ID_PCM_F64LE
309 | ids::CODEC_ID_PCM_S16BE
310 | ids::CODEC_ID_PCM_S24BE
311 | ids::CODEC_ID_PCM_S32BE
312 | ids::CODEC_ID_PCM_F32BE
313 | ids::CODEC_ID_PCM_F64BE
314 | ids::CODEC_ID_PCM_U8 => "PCM".to_string(),
315 _ => "Unknown".to_string(),
316 }
317}
318
319fn probe_symphonia_tags(path: &Path) -> (Option<String>, Option<String>, Option<String>) {
322 use symphonia::core::formats::FormatOptions;
323 use symphonia::core::formats::probe::Hint;
324 use symphonia::core::io::MediaSourceStream;
325 use symphonia::core::meta::{MetadataOptions, StandardTag};
326
327 let file = match std::fs::File::open(path) {
328 Ok(f) => f,
329 Err(_) => return (None, None, None),
330 };
331 let mss = MediaSourceStream::new(Box::new(file), Default::default());
332 let mut hint = Hint::new();
333 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
334 hint.with_extension(ext);
335 }
336
337 let mut reader = match symphonia::default::get_probe().probe(
338 &hint,
339 mss,
340 FormatOptions::default(),
341 MetadataOptions::default(),
342 ) {
343 Ok(r) => r,
344 Err(_) => return (None, None, None),
345 };
346
347 let mut title = None;
348 let mut artist = None;
349 let mut album = None;
350
351 let mut log = reader.metadata();
355 loop {
356 if let Some(rev) = log.current() {
357 for tag in &rev.media.tags {
358 match &tag.std {
359 Some(StandardTag::TrackTitle(v)) if title.is_none() => {
360 title = Some(v.to_string())
361 }
362 Some(StandardTag::Artist(v)) if artist.is_none() => {
363 artist = Some(v.to_string())
364 }
365 Some(StandardTag::Album(v)) if album.is_none() => album = Some(v.to_string()),
366 _ => {}
367 }
368 }
369 }
370 if log.pop().is_none() {
371 break;
372 }
373 }
374
375 (title, artist, album)
376}
377
378fn mp4_codec(path: &Path) -> String {
381 let file = match std::fs::File::open(path) {
382 Ok(f) => f,
383 Err(_) => return "AAC".to_string(),
384 };
385 let mut reader = std::io::BufReader::new(file);
386 match Mp4File::read_from(&mut reader, ParseOptions::new()) {
387 Ok(mp4) => match mp4.properties().codec() {
388 Some(Mp4Codec::ALAC) => "ALAC".to_string(),
389 Some(Mp4Codec::MP3) => "MP3".to_string(),
390 Some(Mp4Codec::FLAC) => "FLAC".to_string(),
391 _ => "AAC".to_string(),
392 },
393 Err(_) => "AAC".to_string(),
394 }
395}
396
397pub fn codec_string(ft: lofty::file::FileType) -> &'static str {
399 match ft {
400 lofty::file::FileType::Flac => "FLAC",
401 lofty::file::FileType::Mpeg => "MP3",
402 lofty::file::FileType::Mp4 => "AAC",
403 lofty::file::FileType::Opus => "Opus",
404 lofty::file::FileType::Vorbis => "Vorbis",
405 lofty::file::FileType::WavPack => "WavPack",
406 lofty::file::FileType::Wav => "WAV",
407 lofty::file::FileType::Aiff => "AIFF",
408 lofty::file::FileType::Ape => "APE",
409 _ => "Unknown",
410 }
411}
412
413pub fn extract_cover_art(path: &Path) -> Option<Vec<u8>> {
418 let tagged_file = lofty::read_from_path(path).ok()?;
419 let tag = tagged_file
420 .primary_tag()
421 .or_else(|| tagged_file.first_tag())?;
422
423 let pictures = tag.pictures();
425 let pic = pictures
426 .iter()
427 .find(|p| p.pic_type() == lofty::picture::PictureType::CoverFront && !is_tiff(p.data()))
428 .or_else(|| pictures.iter().find(|p| !is_tiff(p.data())))?;
429
430 Some(pic.data().to_vec())
431}
432
433fn is_tiff(data: &[u8]) -> bool {
435 data.len() >= 4
436 && ((data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00)
437 || (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A))
438}
439
440pub fn metadata_from_probe_result(meta: &MetadataRevision, fallback_title: &str) -> TrackMeta {
449 let mut title: Option<String> = None;
450 let mut artist: Option<String> = None;
451 let mut album_artist: Option<String> = None;
452 let mut album: Option<String> = None;
453 let mut date: Option<String> = None;
454 let mut disc: Option<i32> = None;
455 let mut track_number: Option<i32> = None;
456 let mut genre: Option<String> = None;
457 let mut label: Option<String> = None;
458
459 let set_text = |slot: &mut Option<String>, value: &str| {
462 if !value.is_empty() {
463 *slot = Some(value.to_string());
464 }
465 };
466
467 for tag in &meta.media.tags {
468 let Some(std) = &tag.std else { continue };
469 match std {
470 StandardTag::TrackTitle(v) => set_text(&mut title, v),
471 StandardTag::Artist(v) => set_text(&mut artist, v),
472 StandardTag::AlbumArtist(v) => set_text(&mut album_artist, v),
473 StandardTag::Album(v) => set_text(&mut album, v),
474 StandardTag::ReleaseDate(v) | StandardTag::RecordingDate(v) => set_text(&mut date, v),
475 StandardTag::ReleaseYear(y) | StandardTag::RecordingYear(y) if date.is_none() => {
476 date = Some(y.to_string())
477 }
478 StandardTag::OriginalReleaseDate(v) | StandardTag::OriginalRecordingDate(v)
479 if date.is_none() =>
480 {
481 set_text(&mut date, v)
482 }
483 StandardTag::OriginalReleaseYear(y) | StandardTag::OriginalRecordingYear(y)
484 if date.is_none() =>
485 {
486 date = Some(y.to_string())
487 }
488 StandardTag::TrackNumber(n) => track_number = Some(*n as i32),
489 StandardTag::DiscNumber(n) => disc = Some(*n as i32),
490 StandardTag::Genre(v) => set_text(&mut genre, v),
491 StandardTag::Label(v) => set_text(&mut label, v),
492 _ => {}
493 }
494 }
495
496 TrackMeta {
497 title: title.unwrap_or_else(|| fallback_title.to_string()),
498 artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
499 album_artist,
500 album: album.unwrap_or_else(|| "Unknown Album".to_string()),
501 date,
502 disc,
503 track_number,
504 genre,
505 label,
506 duration_ms: None,
507 codec: None,
508 sample_rate: None,
509 bit_depth: None,
510 channels: None,
511 bitrate: None,
512 size_bytes: None,
513 mtime: None,
514 path: None,
515 source: "streaming".to_string(),
516 remote_id: None,
517 remote_url: None,
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn test_is_audio_file() {
527 assert!(is_audio_file(Path::new("track.flac")));
528 assert!(is_audio_file(Path::new("track.FLAC")));
529 assert!(is_audio_file(Path::new("track.mp3")));
530 assert!(is_audio_file(Path::new("track.m4a")));
531 assert!(is_audio_file(Path::new("track.ogg")));
532 assert!(is_audio_file(Path::new("track.opus")));
533 assert!(is_audio_file(Path::new("track.wv")));
534 assert!(is_audio_file(Path::new("track.wav")));
535 assert!(is_audio_file(Path::new("track.aiff")));
536 assert!(is_audio_file(Path::new("track.ape")));
537
538 assert!(!is_audio_file(Path::new("cover.jpg")));
539 assert!(!is_audio_file(Path::new("notes.txt")));
540 assert!(!is_audio_file(Path::new("playlist.m3u")));
541 assert!(!is_audio_file(Path::new("track.pdf")));
542 assert!(!is_audio_file(Path::new("noext")));
543 }
544
545 #[test]
546 fn test_is_audio_file_paths() {
547 assert!(is_audio_file(Path::new("/music/artist/album/01.flac")));
548 assert!(!is_audio_file(Path::new("/music/artist/album/cover.png")));
549 }
550
551 #[test]
552 fn test_read_metadata_nonexistent() {
553 let result = read_metadata(Path::new("/nonexistent/track.flac"));
554 assert!(result.is_err());
555 }
556
557 #[test]
558 fn test_codec_string() {
559 assert_eq!(codec_string(lofty::file::FileType::Flac), "FLAC");
560 assert_eq!(codec_string(lofty::file::FileType::Mpeg), "MP3");
561 assert_eq!(codec_string(lofty::file::FileType::Opus), "Opus");
562 assert_eq!(codec_string(lofty::file::FileType::Wav), "WAV");
563 }
564
565 use symphonia::core::meta::well_known::METADATA_ID_ID3V2;
568 use symphonia::core::meta::{MetadataBuilder, MetadataInfo, StandardTag, Tag};
569
570 const TEST_META_INFO: MetadataInfo = MetadataInfo {
571 metadata: METADATA_ID_ID3V2,
572 short_name: "id3v2",
573 long_name: "ID3v2",
574 };
575
576 fn make_revision(tags: &[StandardTag]) -> symphonia::core::meta::MetadataRevision {
577 let mut builder = MetadataBuilder::new(TEST_META_INFO);
578 for std in tags {
579 builder.add_tag(Tag::new_from_parts("", "", Some(std.clone())));
580 }
581 builder.build()
582 }
583
584 #[test]
585 fn test_probe_track_and_disc_numbers() {
586 let rev = make_revision(&[
587 StandardTag::TrackTitle("My Song".to_string().into()),
588 StandardTag::Artist("Artist".to_string().into()),
589 StandardTag::Album("Album".to_string().into()),
590 StandardTag::TrackNumber(3),
591 StandardTag::TrackTotal(12),
592 StandardTag::DiscNumber(2),
593 ]);
594 let meta = metadata_from_probe_result(&rev, "fallback");
595 assert_eq!(
596 meta.track_number,
597 Some(3),
598 "track number should come from the track number tag, not the total"
599 );
600 assert_eq!(meta.disc, Some(2));
601 }
602
603 #[test]
604 fn test_probe_original_date_fallback() {
605 let rev = make_revision(&[
607 StandardTag::TrackTitle("My Song".to_string().into()),
608 StandardTag::OriginalReleaseDate("1991".to_string().into()),
609 ]);
610 let meta = metadata_from_probe_result(&rev, "fallback");
611 assert_eq!(
612 meta.date,
613 Some("1991".to_string()),
614 "original release date should be used when the release date is missing"
615 );
616 }
617
618 #[test]
619 fn test_probe_original_date_not_used_when_date_present() {
620 let rev = make_revision(&[
622 StandardTag::ReleaseDate("2005".to_string().into()),
623 StandardTag::OriginalReleaseDate("1991".to_string().into()),
624 ]);
625 let meta = metadata_from_probe_result(&rev, "fallback");
626 assert_eq!(
627 meta.date,
628 Some("2005".to_string()),
629 "release date should take precedence over original release date"
630 );
631 }
632
633 #[test]
634 fn test_probe_empty_values_skipped() {
635 let rev = make_revision(&[
638 StandardTag::Artist(String::new().into()),
639 StandardTag::Album(String::new().into()),
640 StandardTag::Genre(String::new().into()),
641 ]);
642 let meta = metadata_from_probe_result(&rev, "Title");
643 assert_eq!(
645 meta.artist, "Unknown Artist",
646 "empty artist tag should fall back to 'Unknown Artist'"
647 );
648 assert_eq!(
649 meta.album, "Unknown Album",
650 "empty album tag should fall back to 'Unknown Album'"
651 );
652 assert_eq!(meta.genre, None, "empty genre tag should produce None");
653 }
654
655 #[test]
656 fn test_probe_defaults() {
657 let rev = make_revision(&[]);
660 let meta = metadata_from_probe_result(&rev, "Fallback Title");
661 assert_eq!(
662 meta.title, "Fallback Title",
663 "missing title should use fallback_title argument"
664 );
665 assert_eq!(
666 meta.artist, "Unknown Artist",
667 "missing artist should default to 'Unknown Artist'"
668 );
669 assert_eq!(
670 meta.album, "Unknown Album",
671 "missing album should default to 'Unknown Album'"
672 );
673 assert_eq!(meta.track_number, None);
674 assert_eq!(meta.date, None);
675 assert_eq!(meta.genre, None);
676 assert_eq!(meta.source, "streaming");
677 }
678
679 #[test]
680 fn test_mp4_codec_nonexistent_file_falls_back_to_aac() {
681 assert_eq!(mp4_codec(Path::new("/nonexistent/track.m4a")), "AAC");
682 }
683
684 #[test]
685 fn test_mp4_codec_non_mp4_file_falls_back_to_aac() {
686 let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
688 assert_eq!(mp4_codec(&manifest), "AAC");
689 }
690
691 #[test]
692 #[cfg(target_os = "macos")]
693 fn test_mp4_codec_real_alac_file() {
694 let alac_path = Path::new(
698 "/Volumes/Turtlehead/music/Valet Girls/(2017) PERENNIAL VICE [ALAC]/0101. Valet Girls - Tis the Season.m4a",
699 );
700 if !alac_path.exists() {
701 eprintln!("SKIP: ALAC test file not found (volume not mounted)");
702 return;
703 }
704 assert_eq!(
705 mp4_codec(alac_path),
706 "ALAC",
707 "real ALAC .m4a should be identified as ALAC, not AAC"
708 );
709 }
710}