Commit Diff


commit - c48439d62522304863233832e3e47f3e79ee79f1
commit + fb2b2da90482b058e04b4bc97f5f4684db247c25
blob - 1077c7c24adcced912e621d9a0733d8163a2b7f1
blob + de775fb02eb1f796790bb0a37957abbbb9176510
--- src/audio_processing.rs
+++ src/audio_processing.rs
@@ -251,7 +251,10 @@ mod tests {
         // A flat chroma has zero variance, so correlation is defined as 0.
         let v = ndarray::arr1(&[2.0; CHROMA_BINS]);
         let (mean, stddev) = *MAJOR_PROFILE_STATS;
-        assert_eq!(pearson_correlation(&v, &MAJOR_ROTATIONS[0], mean, stddev), 0.0);
+        assert_eq!(
+            pearson_correlation(&v, &MAJOR_ROTATIONS[0], mean, stddev),
+            0.0
+        );
     }
 
     #[test]
blob - cbf0262be8cfcc237989c5e8c1ef0153f915c8d6
blob + 78b15a03638e0d3f6c0be7944515113de4dde540
--- src/db.rs
+++ src/db.rs
@@ -515,7 +515,10 @@ mod tests {
             key: Some("A Minor".to_string()),
             timestamp: Some(1700),
         };
-        assert_eq!(full.to_string(), "Aphex Twin - Xtal | BPM: 120.0 | Key: A Minor");
+        assert_eq!(
+            full.to_string(),
+            "Aphex Twin - Xtal | BPM: 120.0 | Key: A Minor"
+        );
 
         let empty = TrackReport {
             artist: None,
@@ -573,7 +576,10 @@ mod tests {
 
         let hits = db.find_by_artist("aphex", 0).unwrap();
         assert_eq!(hits.len(), 2);
-        assert!(hits.iter().all(|(_, r)| r.artist.as_deref() == Some("Aphex Twin")));
+        assert!(
+            hits.iter()
+                .all(|(_, r)| r.artist.as_deref() == Some("Aphex Twin"))
+        );
 
         assert_eq!(db.find_by_artist("APHEX TWIN", 0).unwrap().len(), 2);
         assert!(db.find_by_artist("nobody", 0).unwrap().is_empty());
@@ -709,13 +715,23 @@ mod tests {
         db.save(&k2, &r2).unwrap();
 
         // Match on the primary key / artist.
-        assert_eq!(db.search(&Regex::new("^Aphex").unwrap(), 0).unwrap().len(), 1);
+        assert_eq!(
+            db.search(&Regex::new("^Aphex").unwrap(), 0).unwrap().len(),
+            1
+        );
         // Match on track title.
-        assert_eq!(db.search(&Regex::new("Roygbiv").unwrap(), 0).unwrap().len(), 1);
+        assert_eq!(
+            db.search(&Regex::new("Roygbiv").unwrap(), 0).unwrap().len(),
+            1
+        );
         // Match on formatted BPM (one decimal place).
         assert_eq!(db.search(&Regex::new("95.5").unwrap(), 0).unwrap().len(), 1);
         // No match.
-        assert!(db.search(&Regex::new("zzz").unwrap(), 0).unwrap().is_empty());
+        assert!(
+            db.search(&Regex::new("zzz").unwrap(), 0)
+                .unwrap()
+                .is_empty()
+        );
 
         // Limit caps the number of results.
         let both = db.search(&Regex::new(".").unwrap(), 1).unwrap();
blob - cfe14004f404fc46b2b375ed936b3854940147e7
blob + 622057dfe054da84a927f84a6be99322a0039ccb
--- src/file_io.rs
+++ src/file_io.rs
@@ -1,10 +1,14 @@
-use anyhow::Result;
-use lofty::config::WriteOptions;
+use anyhow::{Context, Result};
+use lofty::config::{ParseOptions, WriteOptions};
 use lofty::prelude::*;
 use lofty::probe::Probe;
 use lofty::tag::{ItemKey, Tag};
 use std::path::Path;
 
+const IFF_FORM_SIZE: usize = 4;
+const IFF_HEADER_LEN: usize = 8;
+const IFF_CONTAINER_HEADER: usize = 12;
+
 use crate::audio_processing::{calculate_bpm, calculate_key};
 use crate::types::TrackInfo;
 use bliss_audio::decoder::Decoder as DecoderTrait;
@@ -60,7 +64,11 @@ pub fn process_file(path: &Path, write_tags: bool, for
 }
 
 fn read_tags(path: &Path) -> (FileMetadata, Option<(f32, String)>) {
-    match Probe::open(path).and_then(Probe::read) {
+    let options = ParseOptions::new().read_properties(false);
+    match Probe::open(path)
+        .map(|probe| probe.options(options))
+        .and_then(Probe::read)
+    {
         Ok(tagged_file) => {
             let tag = tagged_file
                 .primary_tag()
@@ -77,15 +85,18 @@ fn read_tags(path: &Path) -> (FileMetadata, Option<(f3
                 track: t.get_string(ItemKey::TrackTitle).map(String::from),
             };
 
-            let existing_tags = t
-                .get_string(ItemKey::Bpm)
-                .zip(t.get_string(ItemKey::InitialKey))
-                .and_then(|(bpm_str, key)| {
-                    bpm_str
-                        .parse::<f32>()
-                        .ok()
-                        .map(|bpm| (bpm, key.to_string()))
-                });
+            let bpm_str = t
+                .get_string(ItemKey::IntegerBpm)
+                .or_else(|| t.get_string(ItemKey::Bpm));
+            let existing_tags =
+                bpm_str
+                    .zip(t.get_string(ItemKey::InitialKey))
+                    .and_then(|(bpm_str, key)| {
+                        bpm_str
+                            .parse::<f32>()
+                            .ok()
+                            .map(|bpm| (bpm, key.to_string()))
+                    });
 
             (metadata, existing_tags)
         }
@@ -94,41 +105,178 @@ fn read_tags(path: &Path) -> (FileMetadata, Option<(f3
 }
 
 /// Write BPM and key metadata to a file.
-///
-/// # Errors
-///
-/// Returns an error if the file cannot be opened, read, or written.
-///
-/// # Panics
-///
-/// Panics if there are no tags and a new tag cannot be created.
 pub fn write_metadata(path: &Path, bpm: f32, key: &str) -> Result<()> {
-    let mut tagged_file = Probe::open(path)?.read()?;
-    let tag = match tagged_file.primary_tag_mut() {
-        Some(primary_tag) => primary_tag,
-        None => {
-            if let Some(first_tag) = tagged_file.first_tag_mut() {
-                first_tag
-            } else {
-                let tag_type = tagged_file.primary_tag_type();
-                tagged_file.insert_tag(Tag::new(tag_type));
-                tagged_file
-                    .primary_tag_mut()
-                    .expect("Failed to create new tag")
-            }
-        }
+    let is_aiff = path
+        .extension()
+        .and_then(|e| e.to_str())
+        .map(|e| {
+            matches!(
+                e.to_ascii_lowercase().as_str(),
+                "aiff" | "aif" | "aifc" | "afc"
+            )
+        })
+        .unwrap_or(false);
+
+    if is_aiff {
+        write_aiff_id3(path, bpm, key)
+    } else {
+        write_lofty(path, bpm, key)
+    }
+}
+
+fn write_lofty(path: &Path, bpm: f32, key: &str) -> Result<()> {
+    let mut tagged_file = Probe::open(path)?
+        .options(ParseOptions::new().read_properties(false))
+        .read()?;
+
+    let tag = if let Some(primary_tag) = tagged_file.primary_tag_mut() {
+        primary_tag
+    } else {
+        tagged_file.insert_tag(Tag::new(tagged_file.file_type().primary_tag_type()));
+        tagged_file
+            .primary_tag_mut()
+            .expect("failed to create new tag")
     };
 
-    tag.insert_text(ItemKey::Bpm, bpm.round().to_string());
+    tag.insert_text(ItemKey::IntegerBpm, bpm.round().to_string());
     tag.insert_text(ItemKey::InitialKey, key.to_string());
     if let Some(tkey) = ItemKey::from_key(tag.tag_type(), "TKEY") {
         tag.insert_text(tkey, key.to_string());
     }
 
-    tag.save_to_path(path, WriteOptions::default())?;
+    tagged_file.save_to_path(path, WriteOptions::default())?;
     Ok(())
 }
 
+fn write_aiff_id3(path: &Path, bpm: f32, key: &str) -> Result<()> {
+    let bpm_str = bpm.round().to_string();
+    let id3 = id3_tag(&bpm_str, key);
+
+    let mut file_bytes =
+        std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
+
+    if file_bytes.len() < IFF_CONTAINER_HEADER || &file_bytes[..IFF_FORM_SIZE] != b"FORM" {
+        anyhow::bail!("not a valid IFF/AIFF file");
+    }
+
+    for fourcc in [b"ID3 ", b"id3 "] {
+        strip_chunks(&mut file_bytes, fourcc);
+    }
+
+    let pos = comm_end(&file_bytes).unwrap_or(IFF_CONTAINER_HEADER);
+    let chunk = id3_chunk(&id3);
+
+    let new_size = u32::try_from(file_bytes.len() - IFF_CONTAINER_HEADER + chunk.len())
+        .with_context(|| "AIFF file too large")?;
+    file_bytes.splice(pos..pos, chunk);
+    file_bytes[4..8].copy_from_slice(&new_size.to_be_bytes());
+
+    std::fs::write(path, file_bytes)
+        .with_context(|| format!("failed to write {}", path.display()))?;
+
+    Ok(())
+}
+
+fn pad(size: usize) -> usize {
+    size + (size % 2)
+}
+
+fn id3_chunk(id3: &[u8]) -> Vec<u8> {
+    let mut chunk = Vec::with_capacity(IFF_HEADER_LEN + pad(id3.len()));
+    chunk.extend_from_slice(b"ID3 ");
+    chunk.extend_from_slice(
+        &u32::try_from(id3.len())
+            .expect("ID3 tag too large")
+            .to_be_bytes(),
+    );
+    chunk.extend_from_slice(id3);
+    if pad(id3.len()) != id3.len() {
+        chunk.push(0);
+    }
+    chunk
+}
+
+fn id3_tag(bpm: &str, key: &str) -> Vec<u8> {
+    const ID3_VERSION_MAJOR: u8 = 3;
+    const ID3_VERSION_MINOR: u8 = 0;
+    const ID3_FLAGS: u8 = 0;
+
+    let bpm_frame = text_frame("TBPM", bpm);
+    let key_frame = text_frame("TKEY", key);
+    let size = syncsafe(bpm_frame.len() + key_frame.len());
+
+    let mut tag = Vec::with_capacity(10 + bpm_frame.len() + key_frame.len());
+    tag.extend_from_slice(b"ID3");
+    tag.push(ID3_VERSION_MAJOR);
+    tag.push(ID3_VERSION_MINOR);
+    tag.push(ID3_FLAGS);
+    tag.extend_from_slice(&size.to_be_bytes());
+    tag.extend_from_slice(&bpm_frame);
+    tag.extend_from_slice(&key_frame);
+    tag
+}
+
+fn text_frame(id: &str, value: &str) -> Vec<u8> {
+    const TEXT_FLAGS: [u8; 2] = [0, 0];
+    const TEXT_ENCODING_LATIN1: u8 = 0;
+
+    let mut frame = Vec::with_capacity(10 + value.len());
+    frame.extend_from_slice(id.as_bytes());
+    frame.extend_from_slice(&((1 + value.len()) as u32).to_be_bytes());
+    frame.extend_from_slice(&TEXT_FLAGS);
+    frame.push(TEXT_ENCODING_LATIN1);
+    frame.extend_from_slice(value.as_bytes());
+    frame
+}
+
+fn syncsafe(n: usize) -> u32 {
+    (((n & 0x0FE00000) << 3) | ((n & 0x001FC000) << 2) | ((n & 0x00003F80) << 1) | (n & 0x0000007F))
+        as u32
+}
+
+fn comm_end(bytes: &[u8]) -> Option<usize> {
+    for (off, fourcc, size) in iff_chunks(bytes) {
+        if fourcc == b"COMM" {
+            return Some(off + IFF_HEADER_LEN + pad(size));
+        }
+    }
+    None
+}
+
+fn strip_chunks(bytes: &mut Vec<u8>, fourcc: &[u8; 4]) {
+    let ranges: Vec<_> = iff_chunks(bytes)
+        .filter(|(_, id, _)| *id == fourcc)
+        .map(|(off, _, size)| (off, off + IFF_HEADER_LEN + pad(size)))
+        .collect();
+    for (s, e) in ranges.into_iter().rev() {
+        bytes.drain(s..e);
+    }
+}
+
+fn iff_chunks(bytes: &[u8]) -> impl Iterator<Item = (usize, &[u8; 4], usize)> + use<'_> {
+    let total = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
+    let mut off = IFF_CONTAINER_HEADER;
+    std::iter::from_fn(move || {
+        if off + IFF_HEADER_LEN > bytes.len() || off - IFF_CONTAINER_HEADER >= total {
+            return None;
+        }
+        let id: &[u8; 4] = bytes[off..off + 4].try_into().ok()?;
+        let size = u32::from_be_bytes([
+            bytes[off + 4],
+            bytes[off + 5],
+            bytes[off + 6],
+            bytes[off + 7],
+        ]) as usize;
+        let nxt = off + IFF_HEADER_LEN + pad(size);
+        if nxt <= off {
+            return None;
+        }
+        let cur = (off, id, size);
+        off = nxt;
+        Some(cur)
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -139,7 +287,12 @@ mod tests {
             artist: Some("Aphex Twin".to_string()),
             track: Some("Xtal".to_string()),
         };
-        let info = build_track_info(Path::new("/music/xtal.flac"), metadata, 120.5, "A Minor".to_string());
+        let info = build_track_info(
+            Path::new("/music/xtal.flac"),
+            metadata,
+            120.5,
+            "A Minor".to_string(),
+        );
 
         assert_eq!(info.path, Path::new("/music/xtal.flac"));
         assert_eq!(info.artist.as_deref(), Some("Aphex Twin"));
blob - 13f638d6a5925110b089e9f72a3b9c807a9482e9
blob + fa043d2e77b4c38e39ac5a9e7b4c2c1ce0cbd812
--- src/utils.rs
+++ src/utils.rs
@@ -82,7 +82,10 @@ mod tests {
             "Aphex Twin - Xtal"
         );
         assert_eq!(format_primary_key(None, Some("Xtal")), "Unknown - Xtal");
-        assert_eq!(format_primary_key(Some("Aphex Twin"), None), "Aphex Twin - Unknown");
+        assert_eq!(
+            format_primary_key(Some("Aphex Twin"), None),
+            "Aphex Twin - Unknown"
+        );
         assert_eq!(format_primary_key(None, None), "Unknown - Unknown");
     }
 }