From 1a98d623575d50561e40f24cbd527ddda374a6a4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 29 Jun 2026 13:25:16 +0200 Subject: [PATCH] Fix update of metadata for mp3 --- src/music/mp3.rs | 142 +++++++++++++++++++++++++++++++++++++++ src/origins/local/mod.rs | 71 +++++++++++--------- 2 files changed, 183 insertions(+), 30 deletions(-) diff --git a/src/music/mp3.rs b/src/music/mp3.rs index adadde4..83e97ed 100644 --- a/src/music/mp3.rs +++ b/src/music/mp3.rs @@ -110,6 +110,148 @@ fn parse_mp3_metadata(path: &Path) -> Option { Some(music_metadata) } +impl MusicMetadata { + /// Apply a freshly written ID3v2 tag and rebuild the in-memory header. + pub fn update_from_id3_data(&mut self, data: &[u8]) { + parse_id3_tag_frames(data, self); + self.header = build_id3v2_header(self); + } + + /// Apply a freshly written ID3v1 tag (128-byte "TAG" block) and rebuild + /// the in-memory ID3v2 header. ID3v1 is Latin-1, max 30 chars per field; + /// non-ASCII bytes are replaced with '?' since Latin-1 ≠ UTF-8. + pub fn update_from_id3v1_data(&mut self, data: &[u8]) { + if data.len() < 128 || &data[0..3] != b"TAG" { + return; + } + fn latin1_str(bytes: &[u8]) -> Option { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + if end == 0 { + return None; + } + Some( + bytes[..end] + .iter() + .map(|&b| if b < 0x80 { char::from(b) } else { '?' }) + .collect(), + ) + } + if let Some(t) = latin1_str(&data[3..33]) { + self.track_title = t; + } + if let Some(a) = latin1_str(&data[33..63]) { + self.artist = vec![a]; + } + if let Some(a) = latin1_str(&data[63..93]) { + self.album = a; + } + // ID3v1.1: byte 125 == 0 means byte 126 is the track number + if data[125] == 0 && data[126] != 0 { + self.track_number = data[126] as i32; + } + self.header = build_id3v2_header(self); + } +} + +fn decode_id3_text(data: &[u8]) -> String { + if data.is_empty() { + return String::new(); + } + match data[0] { + 0x03 => String::from_utf8_lossy(&data[1..]) + .trim_end_matches('\0') + .to_string(), + 0x00 => data[1..] + .iter() + .take_while(|&&b| b != 0) + .map(|&b| char::from(b)) + .collect(), + 0x01 | 0x02 => { + let raw = &data[1..]; + let (src, le) = if raw.len() >= 2 && raw[0] == 0xFF && raw[1] == 0xFE { + (&raw[2..], true) + } else if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF { + (&raw[2..], false) + } else { + (raw, true) + }; + let words: Vec = src + .chunks_exact(2) + .map(|c| { + if le { + u16::from_le_bytes([c[0], c[1]]) + } else { + u16::from_be_bytes([c[0], c[1]]) + } + }) + .take_while(|&w| w != 0) + .collect(); + String::from_utf16_lossy(&words) + } + _ => String::new(), + } +} + +fn parse_id3_tag_frames(data: &[u8], out: &mut MusicMetadata) { + if data.len() < 10 || &data[0..3] != b"ID3" { + return; + } + let version = data[3]; + if data[5] & 0x80 != 0 { + return; // unsynchronised — can't copy frames verbatim + } + let tag_end = (10 + syncsafe([data[6], data[7], data[8], data[9]]) as usize).min(data.len()); + + out.artist.clear(); + let mut offset = 10usize; + while offset + 10 <= tag_end { + if data[offset..offset + 4].iter().all(|&b| b == 0) { + break; // padding + } + let size = if version >= 4 { + syncsafe([ + data[offset + 4], + data[offset + 5], + data[offset + 6], + data[offset + 7], + ]) as usize + } else { + u32::from_be_bytes([ + data[offset + 4], + data[offset + 5], + data[offset + 6], + data[offset + 7], + ]) as usize + }; + if size == 0 || offset + 10 + size > tag_end { + break; + } + let body = &data[offset + 10..offset + 10 + size]; + match &data[offset..offset + 4] { + b"TIT2" => out.track_title = decode_id3_text(body), + b"TALB" => out.album = decode_id3_text(body), + b"TPE1" => { + let text = decode_id3_text(body); + out.artist = text + .split('\0') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + } + b"TRCK" => { + let text = decode_id3_text(body); + out.track_number = text + .split('/') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + } + _ => {} + } + offset += 10 + size; + } +} + /// MP3 encoder. Builds a fresh ID3v2.4 tag from the current (possibly /// overridden) tag fields. Preserved frames are not copied into the header; /// their lengths are counted so the tag's size field spans them. diff --git a/src/origins/local/mod.rs b/src/origins/local/mod.rs index 1a3ed95..d965cd3 100644 --- a/src/origins/local/mod.rs +++ b/src/origins/local/mod.rs @@ -176,38 +176,49 @@ impl Filesystem for LocalOrigin { return; } }; - let music_metadata = match &mut item.music_metadata { - Some(mm) if mm.vorbis_comment_length > 0 => mm, - _ => { - reply.written(written); - return; + + match &mut item.music_metadata { + // FLAC: intercept Vorbis comment block writes + Some(mm) if mm.vorbis_comment_length > 0 => { + let vc_data_offset = mm.vorbis_comment_offset; + let vc_hdr_offset = vc_data_offset - 4; + if write_start <= vc_hdr_offset && write_end >= vc_data_offset { + let hdr_from = (vc_hdr_offset - write_start) as usize; + let new_length = u32::from_be_bytes([ + 0, + data[hdr_from + 1], + data[hdr_from + 2], + data[hdr_from + 3], + ]) as u64; + let vc_data_end = vc_data_offset + new_length; + if write_end >= vc_data_end { + let from = (vc_data_offset - write_start) as usize; + let to = (vc_data_end - write_start) as usize; + mm.update_from_vorbis_comment_data(&data[from..to]); + Some(mm.clone()) + } else { + None + } + } else { + None + } } - }; - - let vc_data_offset = music_metadata.vorbis_comment_offset; - let vc_hdr_offset = vc_data_offset - 4; - - // Must cover the 4-byte block header to read the (possibly new) data length - if write_start <= vc_hdr_offset && write_end >= vc_data_offset { - let hdr_from = (vc_hdr_offset - write_start) as usize; - let new_length = u32::from_be_bytes([ - 0, - data[hdr_from + 1], - data[hdr_from + 2], - data[hdr_from + 3], - ]) as u64; - let vc_data_end = vc_data_offset + new_length; - - if write_end >= vc_data_end { - let from = (vc_data_offset - write_start) as usize; - let to = (vc_data_end - write_start) as usize; - music_metadata.update_from_vorbis_comment_data(&data[from..to]); - Some(music_metadata.clone()) - } else { - None + // MP3: intercept ID3v2 header writes (must start at offset 0) + Some(mm) + if !mm.header.is_empty() + && write_start == 0 + && data.len() >= 3 + && &data[0..3] == b"ID3" => + { + mm.update_from_id3_data(data); + Some(mm.clone()) } - } else { - None + // MP3: intercept ID3v1 fallback writes (128-byte "TAG" block) + Some(mm) if !mm.header.is_empty() && data.len() == 128 && &data[0..3] == b"TAG" => { + mm.update_from_id3v1_data(data); + Some(mm.clone()) + } + _ => None, } };