Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions native/audio-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>()
.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
}

33 changes: 17 additions & 16 deletions native/audio-engine/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>, 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<String, String>) -> Tags {
let title = dict_get(dict, "title").map(ToString::to_string);
Expand All @@ -72,6 +64,23 @@ pub fn extract_tags(dict: &HashMap<String, String>) -> Tags {
}
}

/// 大小写不敏感查找:原 ffmpeg-next 的 Dictionary::get 默认 case-insensitive,
/// 而 ffmpeg_audio 把 dict 转成普通 HashMap 后丢了这个语义,这里补回来
fn dict_get<'a>(dict: &'a HashMap<String, String>, 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<String, String>) -> Option<String> {
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 版本
Expand Down Expand Up @@ -160,14 +169,6 @@ pub fn generate_cover_thumbnail(
Ok(())
}

/// 从容器 metadata 提取内嵌歌词
pub fn extract_embedded_lyric(dict: &HashMap<String, String>) -> Option<String> {
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<ExternalLyric> {
let source_path = Path::new(source);
Expand Down
66 changes: 62 additions & 4 deletions native/audio-engine/src/tag_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,7 @@ pub fn read_tags(path: &str) -> Result<TrackTags> {
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(),
})
}
Expand All @@ -106,6 +103,33 @@ fn read_year(tag: &Tag) -> Option<u32> {
.and_then(|raw| raw.parse::<u32>().ok())
}

/// 从 tag 读取歌词,优先使用 ItemKey,回退时对未知/自定义 key 进行归一化匹配(如 "UNSYNCED LYRICS")
fn read_lyrics_from_tag(tag: &Tag) -> Option<String> {
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<String>) {
match value {
Expand Down Expand Up @@ -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");
Expand Down