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, StandardTagKey};
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::LoftyError),
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::CODEC_TYPE_NULL;
216 use symphonia::core::formats::FormatOptions;
217 use symphonia::core::io::MediaSourceStream;
218 use symphonia::core::meta::MetadataOptions;
219 use symphonia::core::probe::Hint;
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 probed = match symphonia::default::get_probe().format(
241 &hint,
242 mss,
243 &FormatOptions::default(),
244 &MetadataOptions::default(),
245 ) {
246 Ok(p) => p,
247 Err(_) => return empty,
248 };
249
250 let track = match probed.format.default_track() {
251 Some(t) => t,
252 None => return empty,
253 };
254
255 let params = &track.codec_params;
256 let sample_rate = params.sample_rate.map(|r| r as i32);
257 let bit_depth = params.bits_per_sample.map(|b| b as i32);
258 let channels = params.channels.map(|c| c.count() as i32);
259
260 let duration_ms = params.n_frames.and_then(|frames| {
261 params
262 .sample_rate
263 .map(|sr| (frames as f64 / sr as f64 * 1000.0) as i64)
264 });
265
266 let bitrate = params.sample_rate.and_then(|sr| {
267 params.bits_per_sample.and_then(|bps| {
268 params
269 .channels
270 .map(|ch| (sr as i32 * bps as i32 * ch.count() as i32) / 1000)
271 })
272 });
273
274 let codec = if params.codec != CODEC_TYPE_NULL {
275 Some(symphonia_codec_name(params.codec))
276 } else {
277 None
278 };
279
280 SymphoniaProps {
281 duration_ms,
282 sample_rate,
283 bit_depth,
284 channels,
285 bitrate,
286 codec,
287 }
288}
289
290fn symphonia_codec_name(codec: symphonia::core::codecs::CodecType) -> String {
292 use symphonia::core::codecs;
293 match codec {
294 codecs::CODEC_TYPE_FLAC => "FLAC".to_string(),
295 codecs::CODEC_TYPE_MP3 => "MP3".to_string(),
296 codecs::CODEC_TYPE_AAC => "AAC".to_string(),
297 codecs::CODEC_TYPE_ALAC => "ALAC".to_string(),
298 codecs::CODEC_TYPE_VORBIS => "Vorbis".to_string(),
299 codecs::CODEC_TYPE_OPUS => "Opus".to_string(),
300 codecs::CODEC_TYPE_WAVPACK => "WavPack".to_string(),
301 codecs::CODEC_TYPE_PCM_S16LE
302 | codecs::CODEC_TYPE_PCM_S24LE
303 | codecs::CODEC_TYPE_PCM_S32LE
304 | codecs::CODEC_TYPE_PCM_F32LE
305 | codecs::CODEC_TYPE_PCM_F64LE
306 | codecs::CODEC_TYPE_PCM_S16BE
307 | codecs::CODEC_TYPE_PCM_S24BE
308 | codecs::CODEC_TYPE_PCM_S32BE
309 | codecs::CODEC_TYPE_PCM_F32BE
310 | codecs::CODEC_TYPE_PCM_F64BE
311 | codecs::CODEC_TYPE_PCM_U8 => "PCM".to_string(),
312 _ => "Unknown".to_string(),
313 }
314}
315
316fn probe_symphonia_tags(path: &Path) -> (Option<String>, Option<String>, Option<String>) {
319 use symphonia::core::formats::FormatOptions;
320 use symphonia::core::io::MediaSourceStream;
321 use symphonia::core::meta::{MetadataOptions, StandardTagKey};
322 use symphonia::core::probe::Hint;
323
324 let file = match std::fs::File::open(path) {
325 Ok(f) => f,
326 Err(_) => return (None, None, None),
327 };
328 let mss = MediaSourceStream::new(Box::new(file), Default::default());
329 let mut hint = Hint::new();
330 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
331 hint.with_extension(ext);
332 }
333
334 let mut probed = match symphonia::default::get_probe().format(
335 &hint,
336 mss,
337 &FormatOptions::default(),
338 &MetadataOptions::default(),
339 ) {
340 Ok(p) => p,
341 Err(_) => return (None, None, None),
342 };
343
344 let mut title = None;
345 let mut artist = None;
346 let mut album = None;
347
348 if let Some(md) = probed.metadata.get()
350 && let Some(rev) = md.current()
351 {
352 for tag in rev.tags() {
353 match tag.std_key {
354 Some(StandardTagKey::TrackTitle) => title = Some(tag.value.to_string()),
355 Some(StandardTagKey::Artist) => artist = Some(tag.value.to_string()),
356 Some(StandardTagKey::Album) => album = Some(tag.value.to_string()),
357 _ => {}
358 }
359 }
360 }
361
362 if let Some(md) = probed.format.metadata().current() {
364 for tag in md.tags() {
365 match tag.std_key {
366 Some(StandardTagKey::TrackTitle) if title.is_none() => {
367 title = Some(tag.value.to_string())
368 }
369 Some(StandardTagKey::Artist) if artist.is_none() => {
370 artist = Some(tag.value.to_string())
371 }
372 Some(StandardTagKey::Album) if album.is_none() => {
373 album = Some(tag.value.to_string())
374 }
375 _ => {}
376 }
377 }
378 }
379
380 (title, artist, album)
381}
382
383fn mp4_codec(path: &Path) -> String {
386 let file = match std::fs::File::open(path) {
387 Ok(f) => f,
388 Err(_) => return "AAC".to_string(),
389 };
390 let mut reader = std::io::BufReader::new(file);
391 match Mp4File::read_from(&mut reader, ParseOptions::new()) {
392 Ok(mp4) => match mp4.properties().codec() {
393 Mp4Codec::ALAC => "ALAC".to_string(),
394 Mp4Codec::MP3 => "MP3".to_string(),
395 Mp4Codec::FLAC => "FLAC".to_string(),
396 _ => "AAC".to_string(),
397 },
398 Err(_) => "AAC".to_string(),
399 }
400}
401
402pub fn codec_string(ft: lofty::file::FileType) -> &'static str {
404 match ft {
405 lofty::file::FileType::Flac => "FLAC",
406 lofty::file::FileType::Mpeg => "MP3",
407 lofty::file::FileType::Mp4 => "AAC",
408 lofty::file::FileType::Opus => "Opus",
409 lofty::file::FileType::Vorbis => "Vorbis",
410 lofty::file::FileType::WavPack => "WavPack",
411 lofty::file::FileType::Wav => "WAV",
412 lofty::file::FileType::Aiff => "AIFF",
413 lofty::file::FileType::Ape => "APE",
414 _ => "Unknown",
415 }
416}
417
418pub fn extract_cover_art(path: &Path) -> Option<Vec<u8>> {
423 let tagged_file = lofty::read_from_path(path).ok()?;
424 let tag = tagged_file
425 .primary_tag()
426 .or_else(|| tagged_file.first_tag())?;
427
428 let pictures = tag.pictures();
430 let pic = pictures
431 .iter()
432 .find(|p| p.pic_type() == lofty::picture::PictureType::CoverFront && !is_tiff(p.data()))
433 .or_else(|| pictures.iter().find(|p| !is_tiff(p.data())))?;
434
435 Some(pic.data().to_vec())
436}
437
438fn is_tiff(data: &[u8]) -> bool {
440 data.len() >= 4
441 && ((data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00)
442 || (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A))
443}
444
445pub fn metadata_from_probe_result(meta: &MetadataRevision, fallback_title: &str) -> TrackMeta {
454 let mut title: Option<String> = None;
455 let mut artist: Option<String> = None;
456 let mut album_artist: Option<String> = None;
457 let mut album: Option<String> = None;
458 let mut date: Option<String> = None;
459 let mut disc: Option<i32> = None;
460 let mut track_number: Option<i32> = None;
461 let mut genre: Option<String> = None;
462 let mut label: Option<String> = None;
463
464 for tag in meta.tags() {
465 let Some(std_key) = tag.std_key else { continue };
466 let value = tag.value.to_string();
467 if value.is_empty() {
468 continue;
469 }
470 match std_key {
471 StandardTagKey::TrackTitle => title = Some(value),
472 StandardTagKey::Artist => artist = Some(value),
473 StandardTagKey::AlbumArtist => album_artist = Some(value),
474 StandardTagKey::Album => album = Some(value),
475 StandardTagKey::Date => date = Some(value),
476 StandardTagKey::OriginalDate => {
477 if date.is_none() {
478 date = Some(value);
479 }
480 }
481 StandardTagKey::TrackNumber => {
482 track_number = value.split('/').next().and_then(|s| s.trim().parse().ok());
483 }
484 StandardTagKey::DiscNumber => {
485 disc = value.split('/').next().and_then(|s| s.trim().parse().ok());
486 }
487 StandardTagKey::Genre => genre = Some(value),
488 StandardTagKey::Label => label = Some(value),
489 _ => {}
490 }
491 }
492
493 TrackMeta {
494 title: title.unwrap_or_else(|| fallback_title.to_string()),
495 artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()),
496 album_artist,
497 album: album.unwrap_or_else(|| "Unknown Album".to_string()),
498 date,
499 disc,
500 track_number,
501 genre,
502 label,
503 duration_ms: None,
504 codec: None,
505 sample_rate: None,
506 bit_depth: None,
507 channels: None,
508 bitrate: None,
509 size_bytes: None,
510 mtime: None,
511 path: None,
512 source: "streaming".to_string(),
513 remote_id: None,
514 remote_url: None,
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn test_is_audio_file() {
524 assert!(is_audio_file(Path::new("track.flac")));
525 assert!(is_audio_file(Path::new("track.FLAC")));
526 assert!(is_audio_file(Path::new("track.mp3")));
527 assert!(is_audio_file(Path::new("track.m4a")));
528 assert!(is_audio_file(Path::new("track.ogg")));
529 assert!(is_audio_file(Path::new("track.opus")));
530 assert!(is_audio_file(Path::new("track.wv")));
531 assert!(is_audio_file(Path::new("track.wav")));
532 assert!(is_audio_file(Path::new("track.aiff")));
533 assert!(is_audio_file(Path::new("track.ape")));
534
535 assert!(!is_audio_file(Path::new("cover.jpg")));
536 assert!(!is_audio_file(Path::new("notes.txt")));
537 assert!(!is_audio_file(Path::new("playlist.m3u")));
538 assert!(!is_audio_file(Path::new("track.pdf")));
539 assert!(!is_audio_file(Path::new("noext")));
540 }
541
542 #[test]
543 fn test_is_audio_file_paths() {
544 assert!(is_audio_file(Path::new("/music/artist/album/01.flac")));
545 assert!(!is_audio_file(Path::new("/music/artist/album/cover.png")));
546 }
547
548 #[test]
549 fn test_read_metadata_nonexistent() {
550 let result = read_metadata(Path::new("/nonexistent/track.flac"));
551 assert!(result.is_err());
552 }
553
554 #[test]
555 fn test_codec_string() {
556 assert_eq!(codec_string(lofty::file::FileType::Flac), "FLAC");
557 assert_eq!(codec_string(lofty::file::FileType::Mpeg), "MP3");
558 assert_eq!(codec_string(lofty::file::FileType::Opus), "Opus");
559 assert_eq!(codec_string(lofty::file::FileType::Wav), "WAV");
560 }
561
562 use symphonia::core::meta::{MetadataBuilder, StandardTagKey, Tag, Value};
569
570 fn make_revision(tags: &[(StandardTagKey, &str)]) -> symphonia::core::meta::MetadataRevision {
571 let mut builder = MetadataBuilder::new();
572 for (key, value) in tags {
573 builder.add_tag(Tag::new(Some(*key), "", Value::String(value.to_string())));
574 }
575 builder.metadata()
576 }
577
578 #[test]
579 fn test_probe_track_number_slash_format() {
580 let rev = make_revision(&[
583 (StandardTagKey::TrackTitle, "My Song"),
584 (StandardTagKey::Artist, "Artist"),
585 (StandardTagKey::Album, "Album"),
586 (StandardTagKey::TrackNumber, "3/12"),
587 ]);
588 let meta = metadata_from_probe_result(&rev, "fallback");
589 assert_eq!(
590 meta.track_number,
591 Some(3),
592 "slash-format track number should parse to the first component"
593 );
594 }
595
596 #[test]
597 fn test_probe_original_date_fallback() {
598 let rev = make_revision(&[
600 (StandardTagKey::TrackTitle, "My Song"),
601 (StandardTagKey::OriginalDate, "1991"),
602 ]);
603 let meta = metadata_from_probe_result(&rev, "fallback");
604 assert_eq!(
605 meta.date,
606 Some("1991".to_string()),
607 "OriginalDate should be used when Date is missing"
608 );
609 }
610
611 #[test]
612 fn test_probe_original_date_not_used_when_date_present() {
613 let rev = make_revision(&[
615 (StandardTagKey::Date, "2005"),
616 (StandardTagKey::OriginalDate, "1991"),
617 ]);
618 let meta = metadata_from_probe_result(&rev, "fallback");
619 assert_eq!(
620 meta.date,
621 Some("2005".to_string()),
622 "Date should take precedence over OriginalDate"
623 );
624 }
625
626 #[test]
627 fn test_probe_empty_values_skipped() {
628 let rev = make_revision(&[
631 (StandardTagKey::Artist, ""),
632 (StandardTagKey::Album, ""),
633 (StandardTagKey::Genre, ""),
634 ]);
635 let meta = metadata_from_probe_result(&rev, "Title");
636 assert_eq!(
638 meta.artist, "Unknown Artist",
639 "empty artist tag should fall back to 'Unknown Artist'"
640 );
641 assert_eq!(
642 meta.album, "Unknown Album",
643 "empty album tag should fall back to 'Unknown Album'"
644 );
645 assert_eq!(meta.genre, None, "empty genre tag should produce None");
646 }
647
648 #[test]
649 fn test_probe_defaults() {
650 let rev = make_revision(&[]);
653 let meta = metadata_from_probe_result(&rev, "Fallback Title");
654 assert_eq!(
655 meta.title, "Fallback Title",
656 "missing title should use fallback_title argument"
657 );
658 assert_eq!(
659 meta.artist, "Unknown Artist",
660 "missing artist should default to 'Unknown Artist'"
661 );
662 assert_eq!(
663 meta.album, "Unknown Album",
664 "missing album should default to 'Unknown Album'"
665 );
666 assert_eq!(meta.track_number, None);
667 assert_eq!(meta.date, None);
668 assert_eq!(meta.genre, None);
669 assert_eq!(meta.source, "streaming");
670 }
671
672 #[test]
673 fn test_mp4_codec_nonexistent_file_falls_back_to_aac() {
674 assert_eq!(mp4_codec(Path::new("/nonexistent/track.m4a")), "AAC");
675 }
676
677 #[test]
678 fn test_mp4_codec_non_mp4_file_falls_back_to_aac() {
679 let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
681 assert_eq!(mp4_codec(&manifest), "AAC");
682 }
683
684 #[test]
685 #[cfg(target_os = "macos")]
686 fn test_mp4_codec_real_alac_file() {
687 let alac_path = Path::new(
691 "/Volumes/Turtlehead/music/Valet Girls/(2017) PERENNIAL VICE [ALAC]/0101. Valet Girls - Tis the Season.m4a",
692 );
693 if !alac_path.exists() {
694 eprintln!("SKIP: ALAC test file not found (volume not mounted)");
695 return;
696 }
697 assert_eq!(
698 mp4_codec(alac_path),
699 "ALAC",
700 "real ALAC .m4a should be identified as ALAC, not AAC"
701 );
702 }
703}