virittaa

audio metadata analyzer
Log | Files | Refs | README | LICENSE

file_io.rs (4979B)


      1 use anyhow::Result;
      2 use lofty::config::WriteOptions;
      3 use lofty::prelude::*;
      4 use lofty::probe::Probe;
      5 use lofty::tag::{ItemKey, Tag};
      6 use std::path::Path;
      7 
      8 use crate::audio_processing::{calculate_bpm, calculate_key};
      9 use crate::types::TrackInfo;
     10 use bliss_audio::decoder::Decoder as DecoderTrait;
     11 use bliss_audio::decoder::ffmpeg::FFmpegDecoder as Decoder;
     12 
     13 #[derive(Debug, Default)]
     14 pub struct FileMetadata {
     15     pub artist: Option<String>,
     16     pub track: Option<String>,
     17 }
     18 
     19 fn build_track_info(path: &Path, metadata: FileMetadata, bpm: f32, key: String) -> TrackInfo {
     20     TrackInfo {
     21         path: path.to_path_buf(),
     22         artist: metadata.artist,
     23         track: metadata.track,
     24         bpm,
     25         key,
     26     }
     27 }
     28 
     29 /// Process a single file, detecting BPM and key.
     30 ///
     31 /// # Errors
     32 ///
     33 /// Returns an error string if the file cannot be decoded or key detection fails.
     34 pub fn process_file(path: &Path, write_tags: bool, force: bool) -> Result<TrackInfo, String> {
     35     let (metadata, existing_tags) = read_tags(path);
     36 
     37     if !force && let Some((bpm, key)) = existing_tags {
     38         println!(
     39             "File: {}, BPM: {:.1}, Key: {} (existing tags)",
     40             path.display(),
     41             bpm,
     42             key
     43         );
     44         return Ok(build_track_info(path, metadata, bpm, key));
     45     }
     46 
     47     let song = Decoder::decode(path).map_err(|e| format!("Error decoding: {e}"))?;
     48     let samples = &song.sample_array;
     49 
     50     let bpm = calculate_bpm(samples);
     51     let key = calculate_key(samples).map_err(|e| format!("Error calculating key: {e}"))?;
     52 
     53     println!("File: {}, BPM: {bpm:.1}, Key: {key}", path.display());
     54 
     55     if write_tags && let Err(e) = write_metadata(path, bpm, &key) {
     56         eprintln!("Error writing metadata for {}: {e}", path.display());
     57     }
     58 
     59     Ok(build_track_info(path, metadata, bpm, key))
     60 }
     61 
     62 fn read_tags(path: &Path) -> (FileMetadata, Option<(f32, String)>) {
     63     match Probe::open(path).and_then(Probe::read) {
     64         Ok(tagged_file) => {
     65             let tag = tagged_file
     66                 .primary_tag()
     67                 .or_else(|| tagged_file.first_tag());
     68             let Some(t) = tag else {
     69                 return (FileMetadata::default(), None);
     70             };
     71 
     72             let metadata = FileMetadata {
     73                 artist: t
     74                     .get_string(ItemKey::TrackArtist)
     75                     .or_else(|| t.get_string(ItemKey::AlbumArtist))
     76                     .map(String::from),
     77                 track: t.get_string(ItemKey::TrackTitle).map(String::from),
     78             };
     79 
     80             let existing_tags = t
     81                 .get_string(ItemKey::Bpm)
     82                 .zip(t.get_string(ItemKey::InitialKey))
     83                 .and_then(|(bpm_str, key)| {
     84                     bpm_str
     85                         .parse::<f32>()
     86                         .ok()
     87                         .map(|bpm| (bpm, key.to_string()))
     88                 });
     89 
     90             (metadata, existing_tags)
     91         }
     92         Err(_) => (FileMetadata::default(), None),
     93     }
     94 }
     95 
     96 /// Write BPM and key metadata to a file.
     97 ///
     98 /// # Errors
     99 ///
    100 /// Returns an error if the file cannot be opened, read, or written.
    101 ///
    102 /// # Panics
    103 ///
    104 /// Panics if there are no tags and a new tag cannot be created.
    105 pub fn write_metadata(path: &Path, bpm: f32, key: &str) -> Result<()> {
    106     let mut tagged_file = Probe::open(path)?.read()?;
    107     let tag = match tagged_file.primary_tag_mut() {
    108         Some(primary_tag) => primary_tag,
    109         None => {
    110             if let Some(first_tag) = tagged_file.first_tag_mut() {
    111                 first_tag
    112             } else {
    113                 let tag_type = tagged_file.primary_tag_type();
    114                 tagged_file.insert_tag(Tag::new(tag_type));
    115                 tagged_file
    116                     .primary_tag_mut()
    117                     .expect("Failed to create new tag")
    118             }
    119         }
    120     };
    121 
    122     tag.insert_text(ItemKey::Bpm, bpm.round().to_string());
    123     tag.insert_text(ItemKey::InitialKey, key.to_string());
    124     if let Some(tkey) = ItemKey::from_key(tag.tag_type(), "TKEY") {
    125         tag.insert_text(tkey, key.to_string());
    126     }
    127 
    128     tag.save_to_path(path, WriteOptions::default())?;
    129     Ok(())
    130 }
    131 
    132 #[cfg(test)]
    133 mod tests {
    134     use super::*;
    135 
    136     #[test]
    137     fn build_track_info_carries_metadata_and_analysis() {
    138         let metadata = FileMetadata {
    139             artist: Some("Aphex Twin".to_string()),
    140             track: Some("Xtal".to_string()),
    141         };
    142         let info = build_track_info(Path::new("/music/xtal.flac"), metadata, 120.5, "A Minor".to_string());
    143 
    144         assert_eq!(info.path, Path::new("/music/xtal.flac"));
    145         assert_eq!(info.artist.as_deref(), Some("Aphex Twin"));
    146         assert_eq!(info.track.as_deref(), Some("Xtal"));
    147         assert!((info.bpm - 120.5).abs() < f32::EPSILON);
    148         assert_eq!(info.key, "A Minor");
    149     }
    150 
    151     #[test]
    152     fn file_metadata_defaults_to_none() {
    153         let md = FileMetadata::default();
    154         assert!(md.artist.is_none());
    155         assert!(md.track.is_none());
    156     }
    157 }