diff --git a/native/audio-engine/src/lib.rs b/native/audio-engine/src/lib.rs index 76b8431c..b8cfeca9 100644 --- a/native/audio-engine/src/lib.rs +++ b/native/audio-engine/src/lib.rs @@ -983,3 +983,49 @@ pub async fn write_track_tags( .await .map_err(|e| Error::from_reason(format!("标签写入任务失败: {e}"))) } + +/// 归一化键名:转小写并过滤非英文字母与数字(去除空格、下划线、连字符等标点) +pub fn normalize_tag_key(key: &str) -> String { + key.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::() + .to_ascii_lowercase() +} + +/// 判断一个归一化后的键名是否为可能的歌词字段 +pub fn is_lyric_field_key(norm_key: &str) -> bool { + let prefixes = [ + "unsyncedlyrics", + "syncedlyrics", + "lyrics", + "uslt", + "sylt", + "lyric", + ]; + + for prefix in &prefixes { + if norm_key.starts_with(prefix) { + return true; + } + } + + false +} + +/// 获取歌词字段优先级: +/// - 2 (高优先级):用于同步歌词(如 syncedlyrics, sylt, lyrics) +/// - 1 (低优先级):用于非同步歌词(如 unsyncedlyrics, uslt, lyric) +/// - 0 (无效):非歌词字段 +pub fn get_lyric_priority(norm_key: &str) -> u8 { + if !is_lyric_field_key(norm_key) { + return 0; + } + if norm_key.starts_with("lyrics") || norm_key.starts_with("syncedlyrics") || norm_key.starts_with("sylt") { + return 2; + } + if norm_key.starts_with("unsyncedlyrics") || norm_key.starts_with("uslt") || norm_key.starts_with("lyric") { + return 1; + } + 1 +} + diff --git a/native/audio-engine/src/metadata.rs b/native/audio-engine/src/metadata.rs index 0acb128e..96a04729 100644 --- a/native/audio-engine/src/metadata.rs +++ b/native/audio-engine/src/metadata.rs @@ -46,14 +46,6 @@ pub fn extract_stream_info(info: &SourceAudioInfo) -> StreamInfo { } } -/// 大小写不敏感查找:原 ffmpeg-next 的 Dictionary::get 默认 case-insensitive, -/// 而 ffmpeg_audio 把 dict 转成普通 HashMap 后丢了这个语义,这里补回来 -fn dict_get<'a>(dict: &'a HashMap, key: &str) -> Option<&'a str> { - dict.iter() - .find(|(k, _)| k.eq_ignore_ascii_case(key)) - .map(|(_, v)| v.as_str()) -} - /// 从容器 metadata 提取常见 tag pub fn extract_tags(dict: &HashMap) -> Tags { let title = dict_get(dict, "title").map(ToString::to_string); @@ -72,6 +64,23 @@ pub fn extract_tags(dict: &HashMap) -> Tags { } } +/// 大小写不敏感查找:原 ffmpeg-next 的 Dictionary::get 默认 case-insensitive, +/// 而 ffmpeg_audio 把 dict 转成普通 HashMap 后丢了这个语义,这里补回来 +fn dict_get<'a>(dict: &'a HashMap, key: &str) -> Option<&'a str> { + let target = crate::normalize_tag_key(key); + dict.iter() + .find(|(k, _)| crate::normalize_tag_key(k) == target) + .map(|(_, v)| v.as_str()) +} + +/// 从容器 metadata 提取内嵌歌词(兼容 LYRICS、UNSYNCED LYRICS、SYNCED LYRICS、USLT、SYLT 等标签,支持各类语言代码后缀如 -ENG) +pub fn extract_embedded_lyric(dict: &HashMap) -> Option { + dict.iter() + .filter(|(k, v)| !v.is_empty() && crate::is_lyric_field_key(&crate::normalize_tag_key(k))) + .max_by_key(|(k, _)| crate::get_lyric_priority(&crate::normalize_tag_key(k))) + .map(|(_, v)| v.to_string()) +} + /// 从容器 metadata 提取 ReplayGain / R128 增益值(dB) /// /// 按优先级尝试:R128_TRACK_GAIN → replaygain_track_gain → album 版本 @@ -160,14 +169,6 @@ pub fn generate_cover_thumbnail( Ok(()) } -/// 从容器 metadata 提取内嵌歌词 -pub fn extract_embedded_lyric(dict: &HashMap) -> Option { - dict_get(dict, "lyrics") - .or_else(|| dict_get(dict, "UNSYNCEDLYRICS")) - .map(ToString::to_string) - .filter(|s| !s.is_empty()) -} - /// 查找同目录下的所有歌词文件 pub fn find_all_external_lyrics(source: &str) -> Vec { let source_path = Path::new(source); diff --git a/native/audio-engine/src/tag_editor.rs b/native/audio-engine/src/tag_editor.rs index 9ecba363..9fd89c10 100644 --- a/native/audio-engine/src/tag_editor.rs +++ b/native/audio-engine/src/tag_editor.rs @@ -90,10 +90,7 @@ pub fn read_tags(path: &str) -> Result { genre: tag.genre().map(|v| v.into_owned()), track_number: tag.track(), disc_number: tag.disk(), - lyrics: tag - .get_string(ItemKey::UnsyncLyrics) - .or_else(|| tag.get_string(ItemKey::Lyrics)) - .map(str::to_string), + lyrics: read_lyrics_from_tag(tag), has_cover: !tag.pictures().is_empty(), }) } @@ -106,6 +103,33 @@ fn read_year(tag: &Tag) -> Option { .and_then(|raw| raw.parse::().ok()) } +/// 从 tag 读取歌词,优先使用 ItemKey,回退时对未知/自定义 key 进行归一化匹配(如 "UNSYNCED LYRICS") +fn read_lyrics_from_tag(tag: &Tag) -> Option { + if let Some(lyrics) = tag + .get_string(ItemKey::UnsyncLyrics) + .or_else(|| tag.get_string(ItemKey::Lyrics)) + { + return Some(lyrics.to_string()); + } + + // 遍历所有 items 寻找最优歌词标签 + tag.items() + .filter_map(|item| { + let key_str = format!("{:?}", item.key()); + let norm = crate::normalize_tag_key(&key_str); + if crate::is_lyric_field_key(&norm) { + if let Some(val) = item.value().text() { + if !val.is_empty() { + return Some((val.to_string(), crate::get_lyric_priority(&norm))); + } + } + } + None + }) + .max_by_key(|(_, priority)| *priority) + .map(|(val, _)| val) +} + /// 文本字段语义:None 不动,空串清除,非空覆盖 fn apply_text(tag: &mut Tag, key: ItemKey, value: &Option) { match value { @@ -255,6 +279,40 @@ mod tests { path } + #[test] + fn read_lyrics_from_custom_unsynced_lyrics_key() { + let path = temp_wav("custom_unsynced_lyrics.wav"); + let mut tagged = open_tagged(&path).unwrap(); + let tag_type = editing_tag_type(tagged.file_type()); + + // 保证 tag 存在,WAV 空文件没有 ID3v2 标签段,需要先 insert + if tagged.tag(tag_type).is_none() { + tagged.insert_tag(Tag::new(tag_type)); + } + let tag = tagged.tag_mut(tag_type).unwrap(); + + // 写入歌词标签项 + tag.insert_text(ItemKey::UnsyncLyrics, "[00:01.23]测试歌词内容".into()); + tagged.save_to_path(&path, WriteOptions::default()).unwrap(); + + let tags = read_tags(&path.to_string_lossy()).unwrap(); + assert_eq!(tags.lyrics.as_deref(), Some("[00:01.23]测试歌词内容")); + } + + #[test] + fn test_is_lyric_field_key() { + use crate::is_lyric_field_key; + // 合法的各种歌词标签变体 + assert!(is_lyric_field_key("lyrics")); + assert!(is_lyric_field_key("unsyncedlyrics")); + assert!(is_lyric_field_key("syncedlyrics")); + assert!(is_lyric_field_key("lyricseng")); // 带语言后缀如 ENG + assert!(is_lyric_field_key("unsyncedlyricszho")); // 带语言后缀如 ZHO + assert!(is_lyric_field_key("uslt")); + assert!(is_lyric_field_key("sylt")); + assert!(is_lyric_field_key("lyric")); + } + #[test] fn roundtrip_all_fields() { let path = temp_wav("roundtrip.wav");