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 element can -// load them directly. +// SubtitleService finds external subtitle files next to a media file AND +// embedded text subtitle tracks inside the media container, exposing both as +// WebVTT so the browser element can load them directly. // // External-subtitle discovery rules (matching the legacy Python defaults): // @@ -12,20 +12,29 @@ // ?lang=zh / ?lang=en. // // Supported extensions: .srt, .ass, .ssa, .vtt. +// +// Embedded subtitles are probed with ffprobe and exposed as +// path "embedded:"; the browser endpoint extracts the stream +// via ffmpeg into a cached .vtt file. package service import ( "context" + "encoding/json" "errors" "fmt" "io" "os" + "os/exec" "path/filepath" "regexp" + "strconv" "strings" + "time" "go.uber.org/zap" + "github.com/ShukeBta/MMTL/internal/config" "github.com/ShukeBta/MMTL/internal/repository" ) @@ -33,11 +42,12 @@ import ( type SubtitleService struct { log *zap.Logger repo *repository.Container + cfg *config.Config } // NewSubtitleService is the constructor. -func NewSubtitleService(log *zap.Logger, repo *repository.Container) *SubtitleService { - return &SubtitleService{log: log, repo: repo} +func NewSubtitleService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *SubtitleService { + return &SubtitleService{log: log, repo: repo, cfg: cfg} } // SubtitleTrack describes one external subtitle file. @@ -108,9 +118,94 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti }) } } + + // 容器内嵌文本字幕轨(MKV/MP4 等封装内的字幕流):本地真实文件才可 + // 探测提取;cloud:// 与 .strm 媒体跳过。探测失败静默忽略(无 ffprobe + // 或没有字幕流都属正常)。 + if embedded, ok := s.discoverEmbeddedTracks(ctx, m.Path); ok { + tracks = append(tracks, embedded...) + } return tracks, nil } +// embeddedCodecOK 只暴露可提取为 WebVTT 的文本字幕编解码器;位图字幕 +// (PGS/DVDSUB/DVBSUB)浏览器无法渲染,跳过。 +func embeddedCodecOK(codec string) bool { + switch strings.ToLower(codec) { + case "subrip", "srt", "mov_text", "text", "webvtt", "ass", "ssa", "ttml", "sami": + return true + default: + return false + } +} + +// ffprobeSubtitleStream 是 ffprobe -show_streams 输出的字幕流字段。 +type ffprobeSubtitleStream struct { + Index int `json:"index"` + Codec string `json:"codec_name"` + Tags map[string]string `json:"tags"` +} + +type ffprobeSubtitleContainer struct { + Streams []ffprobeSubtitleStream `json:"streams"` +} + +// discoverEmbeddedTracks 用 ffprobe 探测媒体容器内的文本字幕轨。 +// 返回 (tracks, ok):ok=false 表示该媒体不适用(非本地文件/ffprobe 不可用)。 +func (s *SubtitleService) discoverEmbeddedTracks(ctx context.Context, mediaPath string) ([]SubtitleTrack, bool) { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mediaPath)), "cloud://") || + strings.HasSuffix(strings.ToLower(strings.TrimSpace(mediaPath)), ".strm") { + return nil, false + } + bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe") + if err != nil { + return nil, false + } + if _, err := os.Stat(mediaPath); err != nil { + return nil, false + } + + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, bin, // #nosec G204 -- bin resolved by resolveLocalExecutable; args are fixed probes. + "-v", "error", + "-select_streams", "s", + "-show_entries", "stream=index,codec_name:stream_tags=language,title", + "-of", "json", + "--", mediaPath, + ) + out, err := cmd.Output() + if err != nil { + return nil, false + } + var container ffprobeSubtitleContainer + if err := json.Unmarshal(out, &container); err != nil { + return nil, false + } + + tracks := make([]SubtitleTrack, 0, len(container.Streams)) + for _, stream := range container.Streams { + if !embeddedCodecOK(stream.Codec) { + continue + } + lang := strings.ToLower(strings.TrimSpace(stream.Tags["language"])) + if lang == "" { + lang = "und" + } + label := stream.Tags["title"] + if label == "" { + label = lang + } + tracks = append(tracks, SubtitleTrack{ + Lang: lang, + Label: "内置字幕 · " + label, + Path: "embedded:" + strconv.Itoa(stream.Index), + Codec: stream.Codec, + }) + } + return tracks, true +} + // langTag matches the .zh / .zh-cn / .chs language sub-extensions. var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`) @@ -127,13 +222,18 @@ func detectLang(name, base string) string { } // Serve writes the subtitle file as WebVTT (.vtt). SRT/SSA files are -// converted minimally on the fly. Returns ErrSubtitleNotFound when the -// path is rejected (path traversal / not in the media directory). +// converted minimally on the fly; embedded container tracks (path +// "embedded:") are extracted via ffmpeg into a cached .vtt. +// Returns ErrSubtitleNotFound when the path is rejected (path traversal / +// not in the media directory). func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.Writer) error { m, err := s.repo.Media.FindByID(ctx, mediaID) if err != nil || m == nil { return errors.New("media not found") } + if strings.HasPrefix(sub, "embedded:") { + return s.ServeEmbeddedToVTT(ctx, m.Path, sub, w) + } abs, err := filepath.Abs(sub) if err != nil { return err @@ -166,6 +266,81 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W return err } +// embeddedSubtitleCachePath 内嵌字幕提取后的 WebVTT 缓存路径 +// (按媒体路径哈希 + 轨道号定位,跨媒体互不干扰)。 +func (s *SubtitleService) embeddedSubtitleCachePath(mediaPath string, idx int) string { + hash := fmt.Sprintf("%x", fnvHash(mediaPath)) + return filepath.Join(s.cfg.Cache.CacheDir, "subs", hash, fmt.Sprintf("s%d.vtt", idx)) +} + +// ServeEmbeddedToVTT 把容器内第 idx 个字幕轨提取为 WebVTT 输出。 +// 提取结果缓存在 cache 目录,媒体文件更新(mtime 变化)后自动重新提取。 +func (s *SubtitleService) ServeEmbeddedToVTT(ctx context.Context, mediaPath, streamRef string, w io.Writer) error { + idx, err := strconv.Atoi(strings.TrimPrefix(streamRef, "embedded:")) + if err != nil || idx < 0 { + return errors.New("invalid embedded subtitle index") + } + ffmpegBin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg") + if err != nil { + return fmt.Errorf("ffmpeg 不可用,无法提取内嵌字幕:%w", err) + } + info, err := os.Stat(mediaPath) + if err != nil { + return errors.New("media file not found") + } + + cachePath := s.embeddedSubtitleCachePath(mediaPath, idx) + + if cached, statErr := os.Stat(cachePath); statErr == nil && !info.ModTime().After(cached.ModTime()) { + f, openErr := os.Open(cachePath) // #nosec G304 -- cachePath is generated under the cache dir. + if openErr == nil { + defer f.Close() + _, copyErr := io.Copy(w, f) + return copyErr + } + } + + // 缓存未命中或媒体已更新:ffmpeg 提取到临时文件后原子改名。 + if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil { + return err + } + tmp := cachePath + ".tmp" + extractCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + cmd := exec.CommandContext(extractCtx, ffmpegBin, // #nosec G204 -- bin resolved by resolveLocalExecutable; args fixed extraction. + "-v", "error", "-y", + "-i", mediaPath, + "-map", "0:s:"+strconv.Itoa(idx), + "-f", "webvtt", + tmp, + ) + if out, runErr := cmd.CombinedOutput(); runErr != nil { + _ = os.Remove(tmp) + return fmt.Errorf("提取内嵌字幕失败(轨道 %d,可能为位图字幕或轨道无效):%s", idx, strings.TrimSpace(string(out))) + } + if err := os.Rename(tmp, cachePath); err != nil { + _ = os.Remove(tmp) + return err + } + f, err := os.Open(cachePath) // #nosec G304 -- cachePath is generated under the cache dir. + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err +} + +// fnvHash 简单 32 位 FNV-1a 哈希,用于生成稳定的缓存子目录名。 +func fnvHash(s string) uint32 { + var h uint32 = 2166136261 + for i := 0; i < len(s); i++ { + h ^= uint32(s[i]) + h *= 16777619 + } + return h +} + // ServeRaw writes the subtitle file in its original format without any // WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS, // subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the diff --git a/internal/service/subtitle_test.go b/internal/service/subtitle_test.go index f86000e..1643cae 100644 --- a/internal/service/subtitle_test.go +++ b/internal/service/subtitle_test.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" + "github.com/ShukeBta/MMTL/internal/config" "github.com/ShukeBta/MMTL/internal/model" "github.com/ShukeBta/MMTL/internal/repository" ) @@ -32,7 +33,7 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) { t.Fatal(err) } - svc := NewSubtitleService(zap.NewNop(), repository.New(db)) + svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db)) tracks, err := svc.Discover(t.Context(), media.ID) if err != nil { t.Fatal(err) @@ -85,7 +86,7 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) { t.Fatal(err) } - svc := NewSubtitleService(zap.NewNop(), repository.New(db)) + svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db)) var buf bytes.Buffer if err := svc.ServeRaw(t.Context(), media.ID, subPath, &buf); err != nil { t.Fatal(err) diff --git a/web/src/index.css b/web/src/index.css index a0dcf9e..54ca919 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -326,3 +326,21 @@ body { background-color: var(--app-brand-emphasis) !important; color: var(--app-brand-text) !important; } + +/* ── 播放器字幕(WebVTT )── + 去掉浏览器默认黑底,用文字阴影保证亮画面下的可读性; + font 简写携带 line-height 收紧两行字幕的行距(默认行距偏大)。 */ +video::cue { + background-color: transparent; + color: #fff; + font: + 500 1.15em/1.35 "PingFang SC", + "Microsoft YaHei", + "Noto Sans CJK SC", + "Source Han Sans SC", + sans-serif; + text-shadow: + 0 1px 3px rgba(0, 0, 0, 0.9), + 0 0 8px rgba(0, 0, 0, 0.55), + 0 0 16px rgba(0, 0, 0, 0.35); +} diff --git a/web/src/pages/settingsGroupDanmaku.ts b/web/src/pages/settingsGroupDanmaku.ts index fb255b2..86bcff7 100644 --- a/web/src/pages/settingsGroupDanmaku.ts +++ b/web/src/pages/settingsGroupDanmaku.ts @@ -22,6 +22,20 @@ export const danmakuSettingsGroup: SettingGroup = { hint: '留空使用官方 https://api.dandanplay.net。可填写自建或第三方符合 dandanplay 协议的服务地址(含 /api/v2/search/episodes 搜索与 /api/v2/comment/:id 弹幕接口)。播放时按视频名称搜索番剧并拉取 Bilibili 格式 XML 弹幕。', placeholder: 'https://api.dandanplay.net', }, + { + key: 'danmaku.app_id', + label: 'AppId(弹弹play 开放 API)', + type: 'text', + hint: '弹弹play DevCenter 申请的应用 ID(https://doc.dandanplay.com/open/)。官方接口要求应用认证,留空使用内置凭据(签名认证,开箱即用);填写自己的 AppId/AppKey 可覆盖内置凭据。仅对官方 api.dandanplay.net 生效,第三方协议源不会携带凭据。', + placeholder: '在 DevCenter 申请的应用 ID', + }, + { + key: 'danmaku.app_key', + label: 'AppKey(弹弹play 应用密钥)', + type: 'text', + hint: '与 AppId 配套的 AppSecret,只保存在服务器上用于计算请求签名(base64(sha256(AppId+Timestamp+Path+Secret))),不会下发到播放器。', + placeholder: '在 DevCenter 申请的应用密钥', + }, { key: 'danmaku.opacity', label: '弹幕透明度',