From 96a554b4b58804f5afe443a341856c665173d7ea Mon Sep 17 00:00:00 2001
From: truewhile <62226914+truewhile@users.noreply.github.com>
Date: Tue, 25 Aug 2026 00:26:32 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BC=B9=E5=B9=95=E8=AF=86?=
=?UTF-8?q?=E5=88=AB=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
优化弹幕识别逻辑
---
internal/handler/emby_subtitle_routes_test.go | 4 +-
internal/service/danmaku_credentials.go | 65 +++
internal/service/danmaku_credentials_test.go | 84 +++
internal/service/danmaku_match_test.go | 278 ++++++++++
internal/service/danmaku_service.go | 483 ++++++++++++++++--
internal/service/danmaku_service_test.go | 7 +
internal/service/emby_subtitle_test.go | 3 +-
internal/service/service_builder.go | 4 +-
internal/service/subtitle.go | 189 ++++++-
internal/service/subtitle_test.go | 5 +-
web/src/index.css | 18 +
web/src/pages/settingsGroupDanmaku.ts | 14 +
12 files changed, 1103 insertions(+), 51 deletions(-)
create mode 100644 internal/service/danmaku_credentials.go
create mode 100644 internal/service/danmaku_credentials_test.go
create mode 100644 internal/service/danmaku_match_test.go
diff --git a/internal/handler/emby_subtitle_routes_test.go b/internal/handler/emby_subtitle_routes_test.go
index 7533578..82bf05d 100644
--- a/internal/handler/emby_subtitle_routes_test.go
+++ b/internal/handler/emby_subtitle_routes_test.go
@@ -75,7 +75,7 @@ func TestEmbySubtitleOfficialRouteServesRawASS(t *testing.T) {
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetSubtitleService(
- service.NewSubtitleService(zap.NewNop(), repos),
+ service.NewSubtitleService(&config.Config{}, zap.NewNop(), repos),
),
})
@@ -150,7 +150,7 @@ func TestEmbySubtitleDeliveryUrlGetsToken(t *testing.T) {
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetSubtitleService(
- service.NewSubtitleService(zap.NewNop(), repos),
+ service.NewSubtitleService(&config.Config{}, zap.NewNop(), repos),
),
})
diff --git a/internal/service/danmaku_credentials.go b/internal/service/danmaku_credentials.go
new file mode 100644
index 0000000..6b66984
--- /dev/null
+++ b/internal/service/danmaku_credentials.go
@@ -0,0 +1,65 @@
+// Package service — 内置 dandanplay 应用凭据(签名认证回退用)。
+//
+// 弹弹play 开放 API(https://doc.dandanplay.com/open/)要求所有请求
+// 携带应用认证。官方推荐客户端应用使用「签名验证模式」:
+//
+// 请求头 X-AppId + X-Timestamp + X-Signature
+// X-Signature = base64(sha256(AppId + Timestamp + Path + AppSecret))
+//
+// Timestamp 为 UTC 秒级 Unix 时间戳,Path 为不含域名/查询参数的请求路径。
+// 该模式里 AppSecret 只存在于服务端本地,网络上传输的只有绑定
+// 时间戳与路径的签名,无法重放到其它请求上——这是「隐藏密钥」真正
+// 有效的部分。
+//
+// 为了让用户开箱即用,这里内置了一组项目自用凭据作为回退(在
+// DevCenter 申请)。管理员可在「弹幕」设置页填写自己的 AppId /
+// AppSecret 覆盖内置凭据。
+//
+// 混淆说明(重要):开源项目无法真正隐藏随二进制分发的密钥,XOR
+// 混淆只能挡住直接扫源码/复制常量这种程度的提取,挡不住反编译内存
+// 取证。因此内置凭据只作为回退,不建议过度依赖它承载大流量。
+package service
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "strconv"
+)
+
+// dandanplayObfuscationKey 是解开内置凭据的 XOR 混淆密钥。它与密文
+// 同处一个二进制,仅作提取门槛,不作安全边界。
+const dandanplayObfuscationKey = "MMTL-Danmaku#2026!v2"
+
+// 内置回退凭据(XOR 混淆后的 hex 编码)。
+const (
+ danmakuEmbeddedAppIDHex = "352c24755f7c115d0a52"
+ danmakuEmbeddedAppKeyHex = "74390d751832375917150d4717586578586620652a05043d5e320f0d0b120d38"
+)
+
+// danmakuEmbeddedCredentials 解出内置回退凭据。
+func danmakuEmbeddedCredentials() (appID, appKey string) {
+ return xorDecode(danmakuEmbeddedAppIDHex), xorDecode(danmakuEmbeddedAppKeyHex)
+}
+
+// xorDecode 用 dandanplayObfuscationKey 逐字节解开 hex 密文。
+func xorDecode(hexStr string) string {
+ raw, err := hex.DecodeString(hexStr)
+ if err != nil {
+ return ""
+ }
+ key := []byte(dandanplayObfuscationKey)
+ out := make([]byte, len(raw))
+ for i, b := range raw {
+ out[i] = b ^ key[i%len(key)]
+ }
+ return string(out)
+}
+
+// dandanplaySignature 计算开放 API 请求签名:
+// base64(sha256(AppId + Timestamp + Path + AppSecret))。
+// path 只含请求路径(不含域名与查询参数、小写、不 URL 编码)。
+func dandanplaySignature(appID, appSecret string, ts int64, path string) string {
+ sum := sha256.Sum256([]byte(appID + strconv.FormatInt(ts, 10) + path + appSecret))
+ return base64.StdEncoding.EncodeToString(sum[:])
+}
\ No newline at end of file
diff --git a/internal/service/danmaku_credentials_test.go b/internal/service/danmaku_credentials_test.go
new file mode 100644
index 0000000..05eca79
--- /dev/null
+++ b/internal/service/danmaku_credentials_test.go
@@ -0,0 +1,84 @@
+package service
+
+import (
+ "context"
+ "encoding/hex"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// 内置回退凭据必须能解出有效的 AppId(AppId 非机密),且与密文
+// XOR 回环一致(AppKey 以回环校验完整性,避免把明文密钥再抄一遍)。
+func TestDanmakuEmbeddedCredentials(t *testing.T) {
+ appID, appKey := danmakuEmbeddedCredentials()
+ require.Equal(t, "xap9r8p3g3", appID)
+ require.NotEmpty(t, appKey)
+ require.GreaterOrEqual(t, len(appKey), 24)
+
+ // 回环:解出的明文再用同一混淆密钥 XOR 后必须还原出原密文,
+ // 否则说明密文与密钥失配(改了一边忘了另一边)。
+ require.Equal(t, danmakuEmbeddedAppIDHex, xorEncode(appID, dandanplayObfuscationKey))
+ require.Equal(t, danmakuEmbeddedAppKeyHex, xorEncode(appKey, dandanplayObfuscationKey))
+}
+
+// xorEncode 是 xorDecode 的逆操作(测试辅助,与生产实现同规则)。
+func xorEncode(plain, key string) string {
+ out := make([]byte, len(plain))
+ for i := range plain {
+ out[i] = plain[i] ^ key[i%len(key)]
+ }
+ return hex.EncodeToString(out)
+}
+
+// 签名向量:算法 base64(sha256(AppId+Timestamp+Path+AppSecret)),
+// 用固定时间戳与路径交叉验证实现与文档一致(向量由独立脚本生成)。
+func TestDandanplaySignatureVectors(t *testing.T) {
+ appID, appKey := danmakuEmbeddedCredentials()
+ const ts = int64(1700000000)
+ vectors := map[string]string{
+ "/api/v2/comment/25484": "p3OJPfcsm0aFUUXzUTIoKA3vo9fUUtpZRV7/fqX0t0Y=",
+ "/api/v2/search/episodes": "x9Wr1tPWmeXAT8UeRK2eut9NRofOPsbp5qEl/uqXHC0=",
+ }
+ for path, want := range vectors {
+ require.Equal(t, want, dandanplaySignature(appID, appKey, ts, path), "path=%s", path)
+ }
+}
+
+// 凭据解析:官方域名 + 未配置 → 内置回退;配置了 → 用户凭据优先;
+// 只配一半 → 回退内置;第三方源一律不携带凭据。
+func TestDanmakuCredentialsSelection(t *testing.T) {
+ svc := newDanmakuTestService(t)
+ ctx := context.Background()
+ official := "https://api.dandanplay.net/api/v2/comment/25484?withRelated=true"
+ thirdParty := "https://dm.example.com/api/v2/comment/25484"
+
+ embedID, embedKey := danmakuEmbeddedCredentials()
+
+ // 1) 官方域名、未配置:内置回退。
+ id, key, ok := svc.danmakuCredentials(ctx, official)
+ require.True(t, ok)
+ require.Equal(t, embedID, id)
+ require.Equal(t, embedKey, key)
+
+ // 2) 官方域名、配置完整:用户凭据覆盖内置。
+ require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuAppIDKey, "my-app-id"))
+ require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuAppKeyKey, "my-app-key"))
+ id, key, ok = svc.danmakuCredentials(ctx, official)
+ require.True(t, ok)
+ require.Equal(t, "my-app-id", id)
+ require.Equal(t, "my-app-key", key)
+
+ // 3) 只配一个:视为不完整,回退内置。
+ require.NoError(t, svc.repo.Setting.Delete(ctx, DanmakuAppKeyKey))
+ id, key, ok = svc.danmakuCredentials(ctx, official)
+ require.True(t, ok)
+ require.Equal(t, embedID, id)
+ require.Equal(t, embedKey, key)
+
+ // 4) 第三方源:即使配了凭据也不发送(内置凭据更不能外泄)。
+ id, key, ok = svc.danmakuCredentials(ctx, thirdParty)
+ require.False(t, ok)
+ require.Empty(t, id)
+ require.Empty(t, key)
+}
\ No newline at end of file
diff --git a/internal/service/danmaku_match_test.go b/internal/service/danmaku_match_test.go
new file mode 100644
index 0000000..8eefba9
--- /dev/null
+++ b/internal/service/danmaku_match_test.go
@@ -0,0 +1,278 @@
+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/ShukeBta/MMTL/internal/model"
+)
+
+// 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("MMTL-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话"}]}`,
+ `弹幕Hash命中`,
+ &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)
+
+ // 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":"测试动画"}]}`,
+ `弹幕B官方`,
+ 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":"测试动画"}]}`,
+ `弹幕官方兜底`,
+ 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":[]}`,
+ ``, 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 → 拉弹幕。
+func TestDanmakuFetchStrmHashViaDirectLink(t *testing.T) {
+ content := bytes.Repeat([]byte("strm-video-bytes-0123456789"), 400)
+ sum := md5.Sum(content)
+ wantHash := hex.EncodeToString(sum[:])
+
+ var gotRange string
+ rangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ 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":1001,"animeTitle":"远程动画"}]}`,
+ `弹幕Strm命中`,
+ &seen)
+ overrideDanmakuOfficialBase(t, official.URL)
+
+ svc := newDanmakuTestService(t)
+ var gotProvider string
+ svc.SetStrmResolver(func(_ context.Context, provider string, _ url.Values) (*StrmPlayResult, error) {
+ gotProvider = provider
+ return &StrmPlayResult{RedirectURL: rangeSrv.URL}, 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.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)
+}
\ No newline at end of file
diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go
index a62e46b..db9636a 100644
--- a/internal/service/danmaku_service.go
+++ b/internal/service/danmaku_service.go
@@ -1,20 +1,26 @@
package service
import (
+ "bytes"
"context"
+ "crypto/md5"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
+ "os"
"path/filepath"
"strconv"
"strings"
+ "sync"
"time"
"go.uber.org/zap"
+ "github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
)
@@ -27,6 +33,11 @@ const (
DanmakuOpacityKey = "danmaku.opacity"
DanmakuFontSizeKey = "danmaku.font_size"
DanmakuAreaKey = "danmaku.area"
+ // DanmakuAppIDKey / DanmakuAppKeyKey hold the optional dandanplay
+ // DevCenter application credentials. When both are set they override the
+ // built-in fallback pair (see danmaku_credentials.go).
+ DanmakuAppIDKey = "danmaku.app_id"
+ DanmakuAppKeyKey = "danmaku.app_key"
)
// DanmakuDefaultSource is the official dandanplay endpoint used when the
@@ -34,6 +45,11 @@ const (
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
const DanmakuDefaultSource = "https://api.dandanplay.net"
+// danmakuOfficialBase is where identification (/api/v2/match) and the
+// comment/search fallback always go, regardless of the configured source.
+// A package var (not a const) so tests can point it at a local server.
+var danmakuOfficialBase = DanmakuDefaultSource
+
// DanmakuRenderConfig carries the renderer knobs to the web player.
type DanmakuRenderConfig struct {
Enabled bool `json:"enabled"`
@@ -73,12 +89,21 @@ type DanmakuEpisode struct {
}
// DanmakuService fetches danmaku for a media item through the dandanplay
-// protocol: search for an episode id by the video's name, then fetch the
-// comment library XML. The React player parses and renders it.
+// protocol: match by 16MB-prefix hash, then search for an episode id by the
+// video's name, then fetch the comment library XML. The React player parses
+// and renders it.
type DanmakuService struct {
log *zap.Logger
repo *repository.Container
client *http.Client
+
+ // strmResolve resolves a .strm play indirection into a fetchable target
+ // (local path / redirect URL / proxied link). Wired by the builder to
+ // StrmService.ResolvePlay; nil means strm sources are skipped.
+ strmResolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)
+
+ hashCacheMu sync.Mutex
+ hashCache map[string]string // stamp → 16MB-prefix MD5
}
func danmakuHTTPClient() *http.Client {
@@ -89,7 +114,20 @@ func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuServ
if log == nil {
log = zap.NewNop()
}
- return &DanmakuService{log: log, repo: repo, client: danmakuHTTPClient()}
+ return &DanmakuService{
+ log: log,
+ repo: repo,
+ client: danmakuHTTPClient(),
+ hashCache: make(map[string]string),
+ }
+}
+
+// SetStrmResolver wires the strm play resolver used to fetch cloud video
+// bytes for hash computation.
+func (s *DanmakuService) SetStrmResolver(resolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)) {
+ if s != nil {
+ s.strmResolve = resolve
+ }
}
// Config reads danmaku settings from the runtime settings table.
@@ -121,14 +159,20 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
// media-derived search term (empty = use the video's own name); pass it from
// the player when the user searches for a custom title. episodeID forces a
// specific danmaku library chosen by the user (from a previous disambiguation
-// response); empty means auto-resolution. The danmaku library is resolved
-// through the dandanplay protocol:
+// response); empty means auto-resolution through the dandanplay protocol:
//
-// 1. search episodes by title (+ season/episode number)
-// 2a. exactly one hit → fetch that episode's comment library
-// 2b. several hits → return candidates (Raw empty) so the player asks the user
-// 2c. explicit episodeID → fetch it directly
-// 3. fetch the comment library XML
+// 1. match: MD5 of the first 16MB of the video (local file read directly,
+// .strm resolved to a direct link and range-fetched) → /api/v2/match
+// against the official endpoint, which yields the episode library id.
+// 2. search by the playing file's name + episode number.
+// 3. current auto-identification (original name → title → file name + episode,
+// single hit used, several hits returned as candidates for the player).
+// 4. manual: the player picks from the returned candidates (episodeID /
+// keyword override).
+//
+// Comments are always fetched from the configured source first (when set)
+// and fall back to the official endpoint on failure; identification itself
+// always goes to the official endpoint.
//
// When danmaku is disabled the result carries Enabled=false so the player can
// silently skip rendering.
@@ -137,33 +181,68 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
if !res.Enabled {
return res, nil
}
+ configured := strings.TrimRight(strings.TrimSpace(res.Source), "/")
+ official := danmakuOfficialBase
- base := strings.TrimRight(strings.TrimSpace(res.Source), "/")
- if base == "" {
- base = DanmakuDefaultSource
- }
-
- target := strings.TrimSpace(episodeID)
- if target == "" {
- // 名称与集数优先来自媒体(original_name → title → 文件名),
- // 手动搜索关键词时仍沿用当前媒体的集数(同一部番剧同名搜索)。
- term, err := s.searchTerms(ctx, mediaID)
+ // 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
+ if target := strings.TrimSpace(episodeID); target != "" {
+ raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
+ s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
- if kw := strings.TrimSpace(keyword); kw != "" {
- term.name = kw
- }
- if strings.TrimSpace(term.name) == "" {
- return res, nil
- }
+ res.Raw, res.SourceType = raw, st
+ return res, nil
+ }
- candidates, err := s.searchCandidates(ctx, base, term.name, term.episode)
+ term, media, err := s.searchTerms(ctx, mediaID)
+ if err != nil {
+ return res, err
+ }
+ manualKeyword := strings.TrimSpace(keyword) != ""
+ if kw := strings.TrimSpace(keyword); kw != "" {
+ term.name = kw
+ }
+ if strings.TrimSpace(term.name) == "" {
+ return res, nil
+ }
+
+ target := ""
+
+ // 1) hash 识别:始终走官方 /api/v2/match。
+ if media != nil && media.Path != "" {
+ if hash, ok := s.mediaHash(ctx, media); ok {
+ fileSize := media.SizeBytes
+ if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
+ fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
+ }
+ matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec)
+ if err != nil {
+ s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
+ } else if len(matches) > 0 {
+ target = fmt.Sprintf("%d", matches[0].EpisodeID)
+ }
+ }
+ }
+
+ // 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
+ if target == "" && !manualKeyword && media != nil && media.Path != "" {
+ if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
+ if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
+ len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
+ target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
+ }
+ }
+ }
+
+ // 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
+ // 多结果返回候选列表交给播放器(歧义处理)。
+ if target == "" {
+ candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
if err != nil {
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
return res, err
}
- // 多结果歧义:把候选交回播放器让用户选择(disambiguation)。
if len(candidates) != 1 {
res.Candidates = candidates
return res, nil
@@ -174,13 +253,12 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
}
- raw, err := s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
+ raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
- res.Raw = raw
- res.SourceType = detectDanmakuSourceType(raw)
+ res.Raw, res.SourceType = raw, st
return res, nil
}
@@ -207,19 +285,19 @@ type danmakuSearchTerms struct {
}
// searchTerms resolves the name and episode number used to look up the
-// dandanplay library. Episode 0 (movies / unknown) is left empty so the
-// search does not filter by episode.
-func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, error) {
+// dandanplay library (and the media row for hash identification). Episode 0
+// (movies / unknown) is left empty so the search does not filter by episode.
+func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, *model.Media, error) {
var term danmakuSearchTerms
if s == nil || s.repo == nil || s.repo.Media == nil {
- return term, errors.New("media repository unavailable")
+ return term, nil, errors.New("media repository unavailable")
}
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
- return term, err
+ return term, nil, err
}
if m == nil {
- return term, errors.New("media not found")
+ return term, nil, errors.New("media not found")
}
if name := strings.TrimSpace(m.OriginalName); name != "" {
term.name = name
@@ -231,7 +309,7 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma
if m.EpisodeNum > 0 {
term.episode = strconv.Itoa(m.EpisodeNum)
}
- return term, nil
+ return term, m, nil
}
// searchCandidates returns every anime hit for a name via the dandanplay
@@ -298,6 +376,17 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow
}
req.Header.Set("User-Agent", "MMTL/danmaku (+https://github.com/ShukeBta/MMTL)")
req.Header.Set("Accept", "application/json, application/xml, */*")
+ if appID, appKey, ok := s.danmakuCredentials(ctx, sourceURL); ok {
+ // 签名认证:base64(sha256(AppId+Timestamp+Path+Secret)),密钥不出服务器。
+ ts := time.Now().Unix()
+ path := "/"
+ if u, err := url.Parse(sourceURL); err == nil && u.Path != "" {
+ path = u.Path
+ }
+ req.Header.Set("X-AppId", appID)
+ req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10))
+ req.Header.Set("X-Signature", dandanplaySignature(appID, appKey, ts, path))
+ }
resp, err := client.Do(req)
if err != nil {
return "", err
@@ -312,3 +401,321 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow
}
return string(body), nil
}
+
+// danmakuCredentials resolves the application credentials for the official
+// dandanplay API. Admin-configured values (danmaku.app_id / danmaku.app_key)
+// win; otherwise the built-in obfuscated fallback pair is used. Returns
+// ok=false for any other host so credentials — including the built-in pair —
+// are never sent to third-party dandanplay protocol mirrors. The "official"
+// host follows danmakuOfficialBase (overridable in tests).
+func (s *DanmakuService) danmakuCredentials(ctx context.Context, sourceURL string) (appID, appKey string, ok bool) {
+ u, err := url.Parse(sourceURL)
+ if err != nil {
+ return "", "", false
+ }
+ official, err := url.Parse(danmakuOfficialBase)
+ if err != nil || !strings.EqualFold(u.Hostname(), official.Hostname()) {
+ return "", "", false
+ }
+ var id, key string
+ if s != nil && s.repo != nil && s.repo.Setting != nil {
+ id, _ = s.repo.Setting.Get(ctx, DanmakuAppIDKey)
+ key, _ = s.repo.Setting.Get(ctx, DanmakuAppKeyKey)
+ }
+ id, key = strings.TrimSpace(id), strings.TrimSpace(key)
+ if id != "" && key != "" {
+ return id, key, true
+ }
+ if id != "" || key != "" {
+ s.log.Warn("danmaku credentials incomplete, using built-in fallback",
+ zap.Bool("has_app_id", id != ""), zap.Bool("has_app_key", key != ""))
+ }
+ embedID, embedKey := danmakuEmbeddedCredentials()
+ return embedID, embedKey, true
+}
+
+// danmakuHashPrefixBytes 是 dandanplay match 规格要求的前 16MB 数据。
+const danmakuHashPrefixBytes = 16 << 20
+
+const danmakuHashCacheMax = 256
+
+func (s *DanmakuService) hashCacheGet(stamp string) (string, bool) {
+ s.hashCacheMu.Lock()
+ defer s.hashCacheMu.Unlock()
+ h, ok := s.hashCache[stamp]
+ return h, ok
+}
+
+func (s *DanmakuService) hashCachePut(stamp, hash string) {
+ s.hashCacheMu.Lock()
+ defer s.hashCacheMu.Unlock()
+ if len(s.hashCache) >= danmakuHashCacheMax {
+ // 简单淘汰:满了整体清空;哈希只用于重复播放时的缓存命中。
+ s.hashCache = make(map[string]string)
+ }
+ s.hashCache[stamp] = hash
+}
+
+// danmakuMatchFileName derives the /api/v2/match fileName: base name without
+// the final extension. .strm items are covered too — MMTL strm files drop the
+// video extension ("xxx.strm") while pre-existing ones may keep it
+// ("xxx.mkv.strm") — so a second strip removes a real video extension only
+// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
+func danmakuMatchFileName(path string) string {
+ base := filepath.Base(path)
+ if ext := filepath.Ext(base); ext != "" {
+ base = strings.TrimSuffix(base, ext)
+ }
+ if second := strings.ToLower(filepath.Ext(base)); second != "" {
+ if _, ok := videoExtensions[second]; ok && second != ".strm" {
+ base = strings.TrimSuffix(base, filepath.Ext(base))
+ }
+ }
+ return base
+}
+
+// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
+// video). Local videos are hashed straight from disk; .strm indirections are
+// resolved (local path / direct link) and only the 16MB prefix is downloaded.
+func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (string, bool) {
+ if media == nil || media.Path == "" {
+ return "", false
+ }
+ if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
+ target := media.STRMURL
+ if target == "" {
+ parsed, err := readLocalSTRMTarget(media.Path)
+ if err != nil || parsed == "" {
+ return "", false
+ }
+ target = parsed
+ }
+ return s.hashStrmTarget(ctx, target)
+ }
+ return s.hashLocalFile(media.Path)
+}
+
+// hashLocalFile computes the MD5 of the first 16MB of a local video, cached
+// by path+size+mtime so repeated danmaku loads skip the disk read.
+func (s *DanmakuService) hashLocalFile(path string) (string, bool) {
+ info, err := os.Stat(path)
+ if err != nil || info.IsDir() {
+ return "", false
+ }
+ stamp := fmt.Sprintf("f|%s|%d|%d", path, info.Size(), info.ModTime().UnixNano())
+ if h, ok := s.hashCacheGet(stamp); ok {
+ return h, true
+ }
+ f, err := os.Open(path) // #nosec G304 -- path 来自已入库的媒体行
+ if err != nil {
+ return "", false
+ }
+ defer f.Close()
+ h := md5.New()
+ if _, err := io.Copy(h, io.LimitReader(f, danmakuHashPrefixBytes)); err != nil {
+ return "", false
+ }
+ hash := hex.EncodeToString(h.Sum(nil))
+ s.hashCachePut(stamp, hash)
+ return hash, true
+}
+
+// hashStrmTarget computes the video hash behind a .strm indirection:
+// MMTL-internal /api/strm/play URLs are resolved through strmResolve (local
+// path read directly, cloud links range-fetched); plain http(s) links are
+// fetched directly. Only the 16MB prefix is ever downloaded.
+func (s *DanmakuService) hashStrmTarget(ctx context.Context, raw string) (string, bool) {
+ if h, ok := s.hashCacheGet("s|" + raw); ok {
+ return h, true
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "", false
+ }
+ var (
+ src *StrmPlayResult
+ body io.ReadCloser
+ )
+ switch {
+ case strings.HasPrefix(u.Path, "/api/strm/play/"):
+ // /api/strm/play/{provider}/video{ext}?acct=..&pickcode=..
+ segs := strings.Split(strings.TrimPrefix(u.Path, "/api/strm/play/"), "/")
+ if len(segs) < 2 || s.strmResolve == nil {
+ return "", false
+ }
+ src, err = s.strmResolve(ctx, segs[0], u.Query())
+ if err != nil || src == nil {
+ return "", false
+ }
+ case u.Scheme == "http" || u.Scheme == "https":
+ src = &StrmPlayResult{RedirectURL: raw}
+ default:
+ // webdav/alist 等协议无法直接用标准 HTTP 拉取,交给搜索层兜底。
+ return "", false
+ }
+ switch {
+ case src.LocalPath != "":
+ return s.hashLocalFile(src.LocalPath)
+ case src.RedirectURL != "":
+ body, err = s.openRangeBody(ctx, src.RedirectURL, nil)
+ case src.Link != nil && src.Link.URL != "":
+ body, err = s.openRangeBody(ctx, src.Link.URL, src.Link.Headers)
+ default:
+ return "", false
+ }
+ if err != nil || body == nil {
+ return "", false
+ }
+ defer body.Close()
+ h := md5.New()
+ if _, err := io.Copy(h, io.LimitReader(body, danmakuHashPrefixBytes)); err != nil {
+ return "", false
+ }
+ hash := hex.EncodeToString(h.Sum(nil))
+ s.hashCachePut("s|"+raw, hash)
+ return hash, true
+}
+
+// openRangeBody issues a Range request for the 16MB video prefix. Range is a
+// suggestion — servers that ignore it are capped by the caller's LimitReader.
+func (s *DanmakuService) openRangeBody(ctx context.Context, target string, headers map[string]string) (io.ReadCloser, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", "MMTL/danmaku (+https://github.com/ShukeBta/MMTL)")
+ req.Header.Set("Range", fmt.Sprintf("bytes=0-%d", danmakuHashPrefixBytes-1))
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ client := s.client
+ if client == nil {
+ client = danmakuHTTPClient()
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
+ resp.Body.Close()
+ return nil, fmt.Errorf("hash range fetch returned HTTP %d", resp.StatusCode)
+ }
+ return resp.Body, nil
+}
+
+// danmakuMatch mirrors one hit of the /api/v2/match response.
+type danmakuMatch struct {
+ EpisodeID int64 `json:"episodeId"`
+ AnimeID int64 `json:"animeId"`
+ AnimeTitle string `json:"animeTitle"`
+ EpisodeTitle string `json:"episodeTitle"`
+}
+
+// matchOfficial identifies the video via POST /api/v2/match on the official
+// endpoint (always official, signed with the app credentials). Returns the
+// candidate list; empty means nothing matched.
+//
+// fileName must be URL-escaped: the official API rejects raw non-ASCII file
+// names with errorCode 2 (verified against the live API — QueryEscape's
+// percent-encoding with "+" for space is accepted).
+func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash string, fileSize int64, durationSec int) ([]danmakuMatch, error) {
+ appID, appKey, ok := s.danmakuCredentials(ctx, danmakuOfficialBase)
+ if !ok {
+ return nil, errors.New("danmaku credentials unavailable")
+ }
+ payload := struct {
+ FileName string `json:"fileName"`
+ FileHash string `json:"fileHash"`
+ FileSize int64 `json:"fileSize"`
+ VideoDuration int `json:"videoDuration"`
+ MatchMode string `json:"matchMode"`
+ }{
+ FileName: url.QueryEscape(fileName),
+ FileHash: fileHash,
+ FileSize: fileSize,
+ VideoDuration: durationSec,
+ MatchMode: "hashAndFileName",
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ path := "/api/v2/match"
+ ts := time.Now().Unix()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, danmakuOfficialBase+path, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-AppId", appID)
+ req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10))
+ req.Header.Set("X-Signature", dandanplaySignature(appID, appKey, ts, path))
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
+ }
+ var out struct {
+ Success bool `json:"success"`
+ Matches []danmakuMatch `json:"matches"`
+ }
+ if err := json.Unmarshal(raw, &out); err != nil {
+ return nil, fmt.Errorf("danmaku match returned invalid JSON: %w", err)
+ }
+ if !out.Success {
+ return nil, nil
+ }
+ return out.Matches, nil
+}
+
+// sameDanmakuBase reports whether two source bases point at the same origin
+// (host, including port) so the fallback does not call the same server twice.
+func sameDanmakuBase(a, b string) bool {
+ ua, errA := url.Parse(a)
+ ub, errB := url.Parse(b)
+ return errA == nil && errB == nil && strings.EqualFold(ua.Host, ub.Host)
+}
+
+// fetchCommentWithFallback fetches a comment library from the configured
+// source first (when set and different from official), falling back to the
+// official endpoint on failure.
+func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, configured, official, target string) (raw, sourceType string, err error) {
+ var bases []string
+ if configured != "" && !sameDanmakuBase(configured, official) {
+ bases = append(bases, configured)
+ }
+ bases = append(bases, official)
+ var lastErr error
+ for i, base := range bases {
+ raw, err = s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
+ if err == nil {
+ return raw, detectDanmakuSourceType(raw), nil
+ }
+ lastErr = err
+ if i < len(bases)-1 {
+ s.log.Warn("danmaku comment fetch failed on configured source, falling back to official", zap.String("source", base), zap.Error(err))
+ }
+ }
+ return "", "auto", lastErr
+}
+
+// searchCandidatesWithFallback searches the configured source first (when
+// set and different from official), falling back to the official endpoint on
+// failure.
+func (s *DanmakuService) searchCandidatesWithFallback(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, error) {
+ if configured != "" && !sameDanmakuBase(configured, official) {
+ candidates, err := s.searchCandidates(ctx, configured, name, episode)
+ if err == nil {
+ return candidates, nil
+ }
+ s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", configured), zap.Error(err))
+ }
+ return s.searchCandidates(ctx, official, name, episode)
+}
diff --git a/internal/service/danmaku_service_test.go b/internal/service/danmaku_service_test.go
index 0309941..c3b3aa3 100644
--- a/internal/service/danmaku_service_test.go
+++ b/internal/service/danmaku_service_test.go
@@ -205,6 +205,13 @@ func TestDanmakuFetchHandlesSearch404(t *testing.T) {
srv := httptest.NewServer(mux)
defer srv.Close()
+ // 配置源 404 会回退官方,官方同样 404 才能稳定复现错误。
+ official := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer official.Close()
+ overrideDanmakuOfficialBase(t, official.URL)
+
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
diff --git a/internal/service/emby_subtitle_test.go b/internal/service/emby_subtitle_test.go
index e76d40d..b5b23e1 100644
--- a/internal/service/emby_subtitle_test.go
+++ b/internal/service/emby_subtitle_test.go
@@ -9,6 +9,7 @@ import (
"go.uber.org/zap"
+ "github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
)
@@ -50,7 +51,7 @@ func writeTempVideoWithSubtitle(t *testing.T, svc *EmbyService, lib *model.Libra
func newTestSubtitleService(t *testing.T, svc *EmbyService) *SubtitleService {
t.Helper()
- return NewSubtitleService(zap.NewNop(), svc.repo)
+ return NewSubtitleService(&config.Config{}, zap.NewNop(), svc.repo)
}
func TestEmbyMediaStreamsAttachSameNameSubtitle(t *testing.T) {
diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go
index 5152685..fbe7c9d 100644
--- a/internal/service/service_builder.go
+++ b/internal/service/service_builder.go
@@ -107,10 +107,12 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
b.c.Playback = NewPlaybackService(b.log, b.repos)
- b.c.Subtitle = NewSubtitleService(b.log, b.repos)
+ b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
b.c.Profile = NewProfileService(b.log, b.repos)
b.c.Audit = NewAuditService(b.log, b.repos)
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
+ // 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
+ b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
}
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go
index 6b6f870..6c06e7f 100644
--- a/internal/service/subtitle.go
+++ b/internal/service/subtitle.go
@@ -1,8 +1,8 @@
// Package service — subtitle handling.
//
-// SubtitleService finds external subtitle files next to a media file and
-// converts SRT to WebVTT on the fly so the browser