Files
MeBox/internal/service/danmaku_match_test.go
truewhile b0fe40142a Rebrand MMTL to MeBox (name, logo, Docker image) (#17)
* Rebrand MMTL to MeBox across codebase and assets

Rename the project display name, Go module path, environment variable
prefix (MEBOX_*), Docker image references, and UI branding from MMTL/mmtl
to MeBox/mebox. Replace logo assets with the new MeBox icon and keep
legacy SQLite migration support for existing mmtl.db deployments.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix logo icons: use cube-only crop without truncated text

Previous icon generation cropped too much of the source image, including
partial MeBox wordmark text that was cut off in square icon containers.
Regenerate logo-64/192/512, favicon, and SVG from cube-only region.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 16:26:28 +08:00

449 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
)
// overrideDanmakuOfficialBase points the "official" endpoint (match + fallback)
// at a local server for the duration of a test.
func overrideDanmakuOfficialBase(t *testing.T, base string) {
t.Helper()
old := danmakuOfficialBase
danmakuOfficialBase = base
t.Cleanup(func() { danmakuOfficialBase = old })
}
// writeDanmakuTestVideo writes a deterministic <16MB video-ish file and
// returns its path and the expected dandanplay hash (MD5 of the whole file,
// since the file is smaller than the 16MB prefix).
func writeDanmakuTestVideo(t *testing.T, name string) (path, wantHash string) {
t.Helper()
content := bytes.Repeat([]byte("MeBox-danmaku-hash-test-0123456789"), 500)
path = filepath.Join(t.TempDir(), name)
require.NoError(t, os.WriteFile(path, content, 0o644))
sum := md5.Sum(content)
return path, hex.EncodeToString(sum[:])
}
// danmakuOfficialServer serves /api/v2/match (with the given payload) and a
// comment library for the matched episode.
func danmakuOfficialServer(t *testing.T, matchBody, commentBody string, seen *string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
if seen != nil {
*seen = string(b)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, matchBody)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, commentBody)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// seedDanmakuVideoMedia inserts a media row with a real path (for the hash
// layer) and optional episode number.
func seedDanmakuVideoMedia(t *testing.T, svc *DanmakuService, id, title, path string, size int64, episode int) {
t.Helper()
m := model.Media{Title: title, Path: path, SizeBytes: size, EpisodeNum: episode}
m.ID = id
require.NoError(t, svc.repo.DB.Create(&m).Error)
}
// 第 1 层:本地文件直接算 hash → 官方 /api/v2/match → 命中后拉弹幕。
func TestDanmakuFetchHashMatchLayer(t *testing.T) {
videoPath, wantHash := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
var seen string
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画","episodeTitle":"第1话"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕Hash命中</d></i>`,
&seen)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
seedDanmakuVideoMedia(t, svc, "mH", "测试动画", videoPath, 32000, 1)
res, err := svc.Fetch(ctx, "mH", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "xml", res.SourceType)
require.Contains(t, res.Raw, "弹幕Hash命中")
require.Empty(t, res.Candidates)
require.Equal(t, "测试动画", res.AnimeTitle)
require.Equal(t, "第1话", res.EpisodeTitle)
require.Equal(t, int64(25484), res.EpisodeID)
require.Equal(t, "hash", res.MatchMode)
// match 请求体:文件名去扩展名并 URL 转义(官方接口要求,实测验证)、
// hash、大小、matchMode 齐全。
require.Contains(t, seen, `"fileName":"`+url.QueryEscape("测试动画.第01话")+`"`)
require.Contains(t, seen, `"fileHash":"`+wantHash+`"`)
require.Contains(t, seen, `"fileSize":32000`)
require.Contains(t, seen, `"matchMode":"hashAndFileName"`)
}
// 第 1 层拉弹幕:配置了自定义源时优先自定义源,失败才回退官方。
func TestDanmakuFetchHashMatchUsesConfiguredSourceFirst(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
cfgSrv := newDanmakuSourceServer(t) // /api/v2/comment/25484 → 弹幕A
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕B官方</d></i>`,
nil)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
seedDanmakuVideoMedia(t, svc, "mC", "测试动画", videoPath, 32000, 0)
res, err := svc.Fetch(ctx, "mC", "", "")
require.NoError(t, err)
// 配置源优先:弹幕来自自定义源而非官方。
require.Contains(t, res.Raw, "弹幕A")
require.NotContains(t, res.Raw, "弹幕B官方")
}
func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
// 配置源:搜索正常,但弹幕接口 500。
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
cfgSrv := httptest.NewServer(mux)
t.Cleanup(cfgSrv.Close)
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕官方兜底</d></i>`,
nil)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
seedDanmakuVideoMedia(t, svc, "mF", "测试动画", videoPath, 32000, 0)
res, err := svc.Fetch(ctx, "mF", "", "")
require.NoError(t, err)
require.Contains(t, res.Raw, "弹幕官方兜底")
}
// 第 1 层未命中(matches 为空)→ 第 2 层按文件名+集数搜索。
func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
cfgSrv := newDanmakuSourceServer(t) // 搜索 + 弹幕A
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":false,"matches":[]}`,
`<i></i>`, nil)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
seedDanmakuVideoMedia(t, svc, "mM", "刮削标题", videoPath, 32000, 1)
res, err := svc.Fetch(ctx, "mM", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Contains(t, res.Raw, "弹幕A")
// 第 2 层命中:搜索请求按文件名进行。
require.Contains(t, cfgSrv.lastSearch, "anime=")
}
// strm:通过解析出的直链 Range 拉 16MB 前缀算 hash → match → 拉弹幕。
// range server 模拟 115 CDN 防盗链:UA 不匹配直接 403(真实部署中
// 直链绑定换取时的 UA,不带绑定 UA 拉取会失败,见 pan115_openapi.go)。
func TestDanmakuFetchStrmHashViaDirectLink(t *testing.T) {
content := bytes.Repeat([]byte("strm-video-bytes-0123456789"), 400)
sum := md5.Sum(content)
wantHash := hex.EncodeToString(sum[:])
var gotRange, gotUA string
rangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRange = r.Header.Get("Range")
gotUA = r.Header.Get("User-Agent")
if gotUA != "bound-ua-115" {
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(content)
}))
t.Cleanup(rangeSrv.Close)
var seen string
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"远程动画"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕Strm命中</d></i>`,
&seen)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
var gotProvider string
svc.SetStrmResolver(func(_ context.Context, provider string, _ url.Values) (*StrmPlayResult, error) {
gotProvider = provider
// 播放链路 302 直链 + 绑定的 UA 头(防服务端直连 403)。
return &StrmPlayResult{
RedirectURL: rangeSrv.URL,
Link: &cloud.DirectLink{URL: rangeSrv.URL, Headers: map[string]string{"User-Agent": "bound-ua-115"}},
}, nil
})
ctx := context.Background()
strmPath := filepath.Join(t.TempDir(), "远程动画.第01话.strm")
require.NoError(t, os.WriteFile(strmPath, []byte("http://example.invalid/api/strm/play/115/video.mkv?acct=1&pickcode=abc\n"), 0o644))
seedDanmakuVideoMedia(t, svc, "mS", "远程动画", strmPath, 64, 0)
// STRMURL 需要显式写回(扫库时才解析)。
var media model.Media
require.NoError(t, svc.repo.DB.First(&media, "id = ?", "mS").Error)
media.STRMURL = "/api/strm/play/115/video.mkv?acct=1&pickcode=abc"
require.NoError(t, svc.repo.DB.Save(&media).Error)
res, err := svc.Fetch(ctx, "mS", "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Contains(t, res.Raw, "弹幕Strm命中")
require.Equal(t, "115", gotProvider)
require.Contains(t, gotRange, "bytes=0-")
require.Equal(t, "bound-ua-115", gotUA) // 防盗链 UA 必须透传
require.Contains(t, seen, `"fileHash":"`+wantHash+`"`)
require.Contains(t, seen, `"fileName":"`+url.QueryEscape("远程动画.第01话")+`"`)
// strm 的 SizeBytes 是文本大小,不参与 match。
require.Contains(t, seen, `"fileSize":0`)
}
// match 接口 fileName 语义:去扩展名;strm 文件名含视频扩展名时剥两层。
func TestDanmakuMatchFileName(t *testing.T) {
cases := []struct{ in, want string }{
{"/lib/某番剧.第01话.mkv", "某番剧.第01话"},
{"/lib/某番剧.第01话.strm", "某番剧.第01话"},
{"/lib/movie.mkv.strm", "movie"},
{"plain", "plain"},
}
for _, c := range cases {
require.Equal(t, c.want, danmakuMatchFileName(c.in), "path=%s", c.in)
}
}
// hashLocalFile:本地视频直接读盘算前 16MB MD5,且第二次走缓存。
func TestDanmakuHashLocalFile(t *testing.T) {
videoPath, wantHash := writeDanmakuTestVideo(t, "hashme.mkv")
svc := newDanmakuTestService(t)
got, ok := svc.hashLocalFile(videoPath)
require.True(t, ok)
require.Equal(t, wantHash, got)
got2, ok := svc.hashLocalFile(videoPath)
require.True(t, ok)
require.Equal(t, wantHash, got2)
missing, ok := svc.hashLocalFile(filepath.Join(t.TempDir(), "nope.mkv"))
require.False(t, ok)
require.Empty(t, missing)
}
// 配置源与官方同源时,回退不重复请求同一台服务器(bases 只含一份)。
func TestDanmakuSameBase(t *testing.T) {
require.True(t, sameDanmakuBase("https://api.dandanplay.net", "https://api.dandanplay.net"))
require.False(t, sameDanmakuBase("https://api.dandanplay.net", "https://dm.example.com"))
require.False(t, sameDanmakuBase("", "https://api.dandanplay.net"))
}
// fetchCommentWithFallback:配置源与官方同源时不重复请求;
// 全失败时带出最后一跳错误。
func TestDanmakuFetchCommentWithFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
svc := newDanmakuTestService(t)
ctx := context.Background()
raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
require.Error(t, err)
require.Empty(t, raw)
require.Equal(t, "auto", st)
}
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
// 官方服务同时提供 match 和 search:
// match 会返回 episodeId=25484(动画A)
// search 会根据关键词返回 episodeId=99999(动画B)
mux := http.NewServeMux()
var matchCalled bool
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
matchCalled = true
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`)
})
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
})
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
})
official := httptest.NewServer(mux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1)
// 1) 默认自动识别:命中 Hash 识别
resAuto, err := svc.Fetch(ctx, "mManual", "", "")
require.NoError(t, err)
require.True(t, matchCalled)
require.Equal(t, "hash", resAuto.MatchMode)
require.Equal(t, int64(25484), resAuto.EpisodeID)
require.Contains(t, resAuto.Raw, "自动识别弹幕")
// 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B
resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "")
require.NoError(t, err)
require.Equal(t, "search", resManual.MatchMode)
require.Equal(t, int64(99999), resManual.EpisodeID)
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
require.Contains(t, resManual.Raw, "手动搜索弹幕")
}
// Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。
func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) {
content := bytes.Repeat([]byte("emby-remote-video-bytes-9876543210"), 300)
sum := md5.Sum(content)
wantHash := hex.EncodeToString(sum[:])
var gotRange string
var rangeHits int
rangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rangeHits++
gotRange = r.Header.Get("Range")
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(content)
}))
t.Cleanup(rangeSrv.Close)
var seen string
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":2001,"animeTitle":"芙莉莲","episodeTitle":"第1话"}]}`,
`<?xml version="1.0"?><i><d p="1.2,1,16777215,user1">Emby远程弹幕命中</d></i>`,
&seen)
overrideDanmakuOfficialBase(t, official.URL)
remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-456")
svc := newDanmakuTestService(t)
svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) {
require.Equal(t, remoteMediaID, encodedID)
return &model.Media{
Base: model.Base{ID: remoteMediaID},
Title: "葬送的芙莉莲",
EpisodeTitle: "第1话",
EpisodeNum: 1,
Path: "/mnt/emby/anime/Frieren/S01E01.mkv",
SizeBytes: int64(len(content)),
DurationSec: 1400,
}, rangeSrv.URL, nil
})
ctx := context.Background()
res, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "hash", res.MatchMode)
require.Equal(t, int64(25484), res.EpisodeID)
require.Equal(t, "芙莉莲", res.AnimeTitle)
require.Contains(t, res.Raw, "Emby远程弹幕命中")
require.Contains(t, gotRange, "bytes=0-")
require.Contains(t, seen, `"fileHash":"`+wantHash+`"`)
require.Contains(t, seen, `"fileName":"`+url.QueryEscape("S01E01")+`"`)
require.Equal(t, 1, rangeHits)
// 第二次拉取验证 hashCache 命中,不重复请求 rangeSrv
res2, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.Equal(t, "hash", res2.MatchMode)
require.Equal(t, 1, rangeHits)
}
// Emby 远程直链拉取失败时(如网络异常),能平滑降级走番剧原名/标题关键词搜索。
func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// 文件名搜索 ep01 时无结果,模拟文件名未匹配
if r.URL.Query().Get("anime") == "ep01" {
fmt.Fprint(w, `{"hasMore":false,"animes":[]}`)
return
}
// 降级到番剧名搜索命中
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":3001,"animeTitle":"降级搜索番剧","episodes":[{"episodeId":7799,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/7799", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.8,1,16777215,user1">降级搜索弹幕</d></i>`)
})
official := httptest.NewServer(mux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-789")
svc := newDanmakuTestService(t)
// 返回一个不存在的流服务地址模拟 Range 拉取失败
svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) {
return &model.Media{
Base: model.Base{ID: remoteMediaID},
Title: "降级搜索番剧",
EpisodeNum: 1,
Path: "/mnt/emby/anime/fallback/ep01.mkv",
DurationSec: 1200,
}, "http://127.0.0.1:1/invalid-stream", nil
})
ctx := context.Background()
res, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "search", res.MatchMode)
require.Equal(t, int64(7799), res.EpisodeID)
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
require.Contains(t, res.Raw, "降级搜索弹幕")
}