feat: 字幕轨名称可读化

外挂字幕原来直接拿文件名的语言后缀当名称(chs、chs[BilibiliID_danmu]),
内嵌字幕没有 title 标签时也是裸语言码(chi)。改成统一映射成中文名:
chi/zh -> 中文,chs -> 简体中文,jpn -> 日本語,并把弹幕转存的
[BilibiliID_danmu] 文件标注为「弹幕」,避免和对白字幕混淆。

认不出来的标记保持原样(含 und),Emby 客户端的「字幕 (ASS)」兜底不变。
This commit is contained in:
truewhile
2026-10-18 17:01:08 +08:00
parent 91667c400c
commit 4f19e2210e
2 changed files with 181 additions and 2 deletions
+81 -2
View File
@@ -182,6 +182,77 @@ func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID
return discoverExternalSubtitleTracks(m), nil return discoverExternalSubtitleTracks(m), nil
} }
// subtitleLanguageNames 把文件名后缀/容器里的语言标记折成可读名称。
//
// 外挂字幕只能从文件名拿到语言(`movie.chs.ass` → `chs`),直接展示给用户
// 就是「chs」这种看不懂的值;内嵌轨没有 title 标签时同理。认不出来的标记
// 原样保留,交给调用方(例如 Emby 的「字幕 (ASS)」兜底)处理。
var subtitleLanguageNames = map[string]string{
"zh": "中文",
"chi": "中文",
"zho": "中文",
"cn": "中文",
"chs": "简体中文",
"sc": "简体中文",
"zh-cn": "简体中文",
"zh-hans": "简体中文",
"zh-sg": "简体中文",
"cht": "繁体中文",
"tc": "繁体中文",
"zh-tw": "繁体中文",
"zh-hk": "繁体中文",
"zh-mo": "繁体中文",
"zh-hant": "繁体中文",
"jpn": "日本語",
"ja": "日本語",
"jp": "日本語",
"eng": "English",
"en": "English",
"kor": "한국어",
"ko": "한국어",
"yue": "粤语",
}
// subtitleLanguageName 返回语言标记的可读名称,认不出来时返回空串。
func subtitleLanguageName(raw string) string {
tag := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "_", "-")))
if tag == "" {
return ""
}
if name, ok := subtitleLanguageNames[tag]; ok {
return name
}
// `chs[bilibiliid_danmu]`、`chs.ass` 这类带附加信息的标记先取前缀。
head := tag
if i := strings.IndexAny(head, "[.@"); i > 0 {
head = strings.TrimSpace(head[:i])
}
return subtitleLanguageNames[head]
}
// subtitleDisplayLabel 把语言标记换成可读名称;认不出来时保留原值(含 und)。
func subtitleDisplayLabel(raw string) string {
if name := subtitleLanguageName(raw); name != "" {
return name
}
return strings.TrimSpace(raw)
}
// isDanmakuSubtitle 报告字幕文件是否是弹幕转存(`xxx[BilibiliID_danmu].ass`)。
// 这类轨道是飘过的弹幕而不是对白字幕,名称里要标出来,避免和对白字幕混淆。
func isDanmakuSubtitle(name string) bool {
lower := strings.ToLower(name)
return strings.Contains(lower, "danmu") ||
strings.Contains(lower, "danmaku") ||
strings.Contains(name, "弹幕")
}
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack { func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
dir := filepath.Dir(m.Path) dir := filepath.Dir(m.Path)
bases := mediaSidecarBaseVariants(m.Path) bases := mediaSidecarBaseVariants(m.Path)
@@ -228,9 +299,13 @@ func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
matchedBase = bases[0] matchedBase = bases[0]
} }
lang := detectLang(fullName, matchedBase) lang := detectLang(fullName, matchedBase)
label := subtitleDisplayLabel(lang)
if isDanmakuSubtitle(e.Name()) {
label += " · 弹幕"
}
tracks = append(tracks, SubtitleTrack{ tracks = append(tracks, SubtitleTrack{
Lang: lang, Lang: lang,
Label: lang, Label: label,
Path: filepath.Join(c, e.Name()), Path: filepath.Join(c, e.Name()),
Codec: codec, Codec: codec,
Source: "external", Source: "external",
@@ -319,7 +394,11 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
} }
label := strings.TrimSpace(stream.Tags.Title) label := strings.TrimSpace(stream.Tags.Title)
if label == "" { if label == "" {
label = lang // 没有 title 标签时用可读语言名(chi → 中文)代替裸语言码。
label = subtitleDisplayLabel(lang)
}
if label == "" {
label = "字幕"
} }
if stream.Disposition.Forced != 0 { if stream.Disposition.Forced != 0 {
label += "(强制)" label += "(强制)"
+100
View File
@@ -202,3 +202,103 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
t.Fatal("ServeRaw accepted a non-subtitle file") t.Fatal("ServeRaw accepted a non-subtitle file")
} }
} }
func TestSubtitleLanguageNameMapsCommonTags(t *testing.T) {
cases := map[string]string{
"chs": "简体中文",
"CHS": "简体中文",
"zh-Hans": "简体中文",
"cht": "繁体中文",
"chi": "中文",
"zh": "中文",
"jpn": "日本語",
"eng": "English",
"kor": "한국어",
"chs[bilibiliid_danmu]": "简体中文",
"und": "",
"": "",
}
for raw, want := range cases {
if got := subtitleLanguageName(raw); got != want {
t.Errorf("subtitleLanguageName(%q) = %q, want %q", raw, got, want)
}
}
}
func TestSubtitleDisplayLabelKeepsUnknownTags(t *testing.T) {
// 认不出来的标记原样返回:Emby 的 DisplayTitle 依赖 `und` 走「字幕 (ASS)」兜底。
if got := subtitleDisplayLabel("und"); got != "und" {
t.Fatalf("subtitleDisplayLabel(und) = %q, want und", got)
}
if got := subtitleDisplayLabel("weird"); got != "weird" {
t.Fatalf("subtitleDisplayLabel(weird) = %q, want weird", got)
}
}
func TestIsDanmakuSubtitle(t *testing.T) {
if !isDanmakuSubtitle("S01E01.chs[BilibiliID_danmu].ass") {
t.Fatal("danmaku sidecar was not detected")
}
if !isDanmakuSubtitle("弹幕.ass") {
t.Fatal("Chinese danmaku name was not detected")
}
if isDanmakuSubtitle("S01E01.chs.ass") {
t.Fatal("normal subtitle was misdetected as danmaku")
}
}
func TestDiscoverExternalSubtitleLabelsAreReadable(t *testing.T) {
dir := t.TempDir()
videoPath := filepath.Join(dir, "S01E01.mkv")
files := map[string]string{
"S01E01.chs.ass": "[Script Info]\n",
"S01E01.chs[BilibiliID_danmu].ass": "[Script Info]\n",
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
m := &model.Media{Path: videoPath}
tracks := discoverExternalSubtitleTracks(m)
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2: %#v", len(tracks), tracks)
}
labels := map[string]string{}
for _, track := range tracks {
labels[filepath.Base(track.Path)] = track.Label
}
if got := labels["S01E01.chs.ass"]; got != "简体中文" {
t.Fatalf("chs.ass label = %q, want 简体中文", got)
}
if got := labels["S01E01.chs[BilibiliID_danmu].ass"]; got != "简体中文 · 弹幕" {
t.Fatalf("danmaku label = %q, want 简体中文 · 弹幕", got)
}
}
func TestEmbeddedSubtitleProbeUsesReadableLanguage(t *testing.T) {
var probe embeddedSubtitleProbe
raw := []byte(`{"streams":[
{"index":2,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"chi"},"disposition":{"default":1}},
{"index":3,"codec_name":"subrip","tags":{"language":"jpn"}},
{"index":4,"codec_name":"subrip","tags":{"language":"chi","title":"简日双语"}}
]}`)
if err := json.Unmarshal(raw, &probe); err != nil {
t.Fatal(err)
}
tracks := subtitleTracksFromProbe(probe)
if len(tracks) != 3 {
t.Fatalf("len(tracks) = %d, want 3", len(tracks))
}
if got := tracks[0].Label; got != "中文(默认)(内嵌·图片)" {
t.Fatalf("bitmap label = %q", got)
}
if got := tracks[1].Label; got != "日本語(内嵌)" {
t.Fatalf("text label = %q", got)
}
// 容器自带 title 时优先用 title。
if got := tracks[2].Label; got != "简日双语(内嵌)" {
t.Fatalf("titled label = %q", got)
}
}