mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
170 lines
5.0 KiB
Go
170 lines
5.0 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/binary"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"unicode/utf16"
|
||
|
||
"github.com/glebarez/sqlite"
|
||
"go.uber.org/zap"
|
||
"golang.org/x/text/encoding/simplifiedchinese"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/truewhile/MeBox/internal/config"
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
"github.com/truewhile/MeBox/internal/repository"
|
||
)
|
||
|
||
const assFixture = "[Script Info]\n" +
|
||
"Title:Railgun 01 BD\n" +
|
||
"ScriptType:v4.00+\n" +
|
||
"\n" +
|
||
"[Events]\n" +
|
||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" +
|
||
"Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,放て!心に刻んだ夢を\n"
|
||
|
||
const srtFixture = "1\n" +
|
||
"00:00:01,000 --> 00:00:02,000\n" +
|
||
"只有我的超电磁炮\n" +
|
||
"\n"
|
||
|
||
func encodeUTF16(s string, bigEndian bool, withBOM bool) []byte {
|
||
units := utf16.Encode([]rune(s))
|
||
out := make([]byte, 0, len(units)*2+2)
|
||
if withBOM {
|
||
if bigEndian {
|
||
out = append(out, 0xFE, 0xFF)
|
||
} else {
|
||
out = append(out, 0xFF, 0xFE)
|
||
}
|
||
}
|
||
buf := make([]byte, 2)
|
||
for _, unit := range units {
|
||
if bigEndian {
|
||
binary.BigEndian.PutUint16(buf, unit)
|
||
} else {
|
||
binary.LittleEndian.PutUint16(buf, unit)
|
||
}
|
||
out = append(out, buf...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func encodeGBK(t *testing.T, s string) []byte {
|
||
t.Helper()
|
||
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(s))
|
||
if err != nil {
|
||
t.Fatalf("encode GBK: %v", err)
|
||
}
|
||
return encoded
|
||
}
|
||
|
||
// 这是本次线上问题的核心场景:字幕组给的 ASS 是带 BOM 的 UTF-16LE,
|
||
// 之前原样下发,浏览器按 UTF-8 解开后整篇是替换字符,一个字都渲染不出来。
|
||
func TestDecodeSubtitleTextNormalisesLegacyEncodings(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
raw []byte
|
||
}{
|
||
{"utf8", []byte(assFixture)},
|
||
{"utf8-bom", append([]byte{0xEF, 0xBB, 0xBF}, []byte(assFixture)...)},
|
||
{"utf16le-bom", encodeUTF16(assFixture, false, true)},
|
||
{"utf16be-bom", encodeUTF16(assFixture, true, true)},
|
||
{"utf16le-no-bom", encodeUTF16(assFixture, false, false)},
|
||
{"gb18030", encodeGBK(t, assFixture)},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := decodeSubtitleText(tc.raw)
|
||
if !strings.Contains(got, "[Script Info]") {
|
||
t.Fatalf("decoded text lost ASS header: %q", got)
|
||
}
|
||
if !strings.Contains(got, "Dialogue:") {
|
||
t.Fatalf("decoded text lost Dialogue line: %q", got)
|
||
}
|
||
if !strings.Contains(got, "放て!心に刻んだ夢を") {
|
||
t.Fatalf("decoded text lost non-ASCII content: %q", got)
|
||
}
|
||
if strings.ContainsRune(got, 0) || strings.ContainsRune(got, '\uFFFD') {
|
||
t.Fatalf("decoded text still contains NUL or replacement runes: %q", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestDecodeSubtitleTextKeepsUnknownBytes(t *testing.T) {
|
||
raw := []byte("not a subtitle at all: \x81\x82\x83\x84")
|
||
if got := decodeSubtitleText(raw); got != string(raw) {
|
||
t.Fatalf("decodeSubtitleText mangled non-subtitle bytes: %q", got)
|
||
}
|
||
}
|
||
|
||
// 接口层回归:外挂 UTF-16 字幕经 /subtitles/:id/ass(libass 路径)与
|
||
// /subtitles/:id(WebVTT 降级路径)下发时都必须是可解析的 UTF-8。
|
||
func TestSubtitleHandlersNormaliseUTF16Sidecar(t *testing.T) {
|
||
db, err := gorm.Open(sqlite.Open("file:subtitle-encoding?mode=memory&cache=shared"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
dir := t.TempDir()
|
||
videoPath := filepath.Join(dir, "Railgun S01E01.mkv")
|
||
assPath := filepath.Join(dir, "Railgun S01E01.ass")
|
||
srtPath := filepath.Join(dir, "Railgun S01E01.zh.srt")
|
||
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(assPath, encodeUTF16(assFixture, false, true), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(srtPath, encodeGBK(t, srtFixture), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
media := model.Media{Title: "Railgun", Path: videoPath}
|
||
if err := db.Create(&media).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
|
||
|
||
var buf bytes.Buffer
|
||
if err := svc.ServeASS(t.Context(), media.ID, assPath, &buf); err != nil {
|
||
t.Fatalf("ServeASS: %v", err)
|
||
}
|
||
ass := buf.String()
|
||
if !bytes.HasPrefix(buf.Bytes(), []byte("[Script Info]")) {
|
||
t.Fatalf("ServeASS output is not UTF-8 ASS: %q", ass)
|
||
}
|
||
if !strings.Contains(ass, "放て!心に刻んだ夢を") {
|
||
t.Fatalf("ServeASS output lost dialogue text: %q", ass)
|
||
}
|
||
|
||
buf.Reset()
|
||
if err := svc.Serve(t.Context(), media.ID, assPath, &buf); err != nil {
|
||
t.Fatalf("Serve(ass): %v", err)
|
||
}
|
||
vtt := buf.String()
|
||
if !strings.HasPrefix(vtt, "WEBVTT") || !strings.Contains(vtt, "-->") {
|
||
t.Fatalf("ASS->WebVTT fallback produced no cues: %q", vtt)
|
||
}
|
||
if !strings.Contains(vtt, "放て!心に刻んだ夢を") {
|
||
t.Fatalf("ASS->WebVTT fallback lost dialogue text: %q", vtt)
|
||
}
|
||
|
||
buf.Reset()
|
||
if err := svc.Serve(t.Context(), media.ID, srtPath, &buf); err != nil {
|
||
t.Fatalf("Serve(srt): %v", err)
|
||
}
|
||
vtt = buf.String()
|
||
if !strings.Contains(vtt, "只有我的超电磁炮") {
|
||
t.Fatalf("GBK SRT was not transcoded: %q", vtt)
|
||
}
|
||
}
|