mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5251c1e4e | |||
| 96a554b4b5 | |||
| 3bd441c288 | |||
| c514fb360e | |||
| c0c1412f4a | |||
| 360f9c173b | |||
| 60b11bc2bc | |||
| ac546f1197 | |||
| 93c7487c96 | |||
| 2e8b1cb7a1 | |||
| cdb1564826 | |||
| 90c10137e9 | |||
| 20ceef1fcf | |||
| 43fdfe8202 |
@@ -40,15 +40,15 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
|
||||
t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
|
||||
}
|
||||
if cfg.Cache.RedisPrefix != "mmtl" {
|
||||
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
|
||||
}
|
||||
if cfg.Cache.MediaTTLSeconds != 15 {
|
||||
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
|
||||
}
|
||||
if cfg.Search.Index != "mmtl_media" {
|
||||
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
|
||||
}
|
||||
if cfg.Cache.RedisPrefix != "mmtl" {
|
||||
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
|
||||
}
|
||||
if cfg.Cache.MediaTTLSeconds != 15 {
|
||||
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
|
||||
}
|
||||
if cfg.Search.Index != "mmtl_media" {
|
||||
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
|
||||
}
|
||||
if cfg.Database.MaxIdleConns != defaultDatabaseMaxIdleConns {
|
||||
t.Fatalf("expected default MaxIdleConns %d, got %d", defaultDatabaseMaxIdleConns, cfg.Database.MaxIdleConns)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ func (h *ApiConfigHandler) TestApiConfig(c *gin.Context) {
|
||||
_ = h.svc.ApiConfig.UpdateTestResult(c.Request.Context(), provider, result)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": gin.H{
|
||||
"result": result,
|
||||
|
||||
@@ -30,4 +30,4 @@ func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,4 +144,4 @@ func TestEmbyLoginWithAuthorizationHeaderToken(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Authorization-token request = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -23,16 +23,16 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
||||
routes[route.Method+" "+route.Path] = true
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"GET /api/me",
|
||||
"GET /api/auth/permissions",
|
||||
"GET /api/libraries",
|
||||
"GET /api/media",
|
||||
"GET /api/stream/:id",
|
||||
"GET /api/storage",
|
||||
"GET /api/watch-history",
|
||||
"GET /api/playback/:id/info",
|
||||
} {
|
||||
for _, want := range []string{
|
||||
"GET /api/me",
|
||||
"GET /api/auth/permissions",
|
||||
"GET /api/libraries",
|
||||
"GET /api/media",
|
||||
"GET /api/stream/:id",
|
||||
"GET /api/storage",
|
||||
"GET /api/watch-history",
|
||||
"GET /api/playback/:id/info",
|
||||
} {
|
||||
if !routes[want] {
|
||||
t.Fatalf("%s route is not registered", want)
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ func statsUserHandler(svc *service.Container) gin.HandlerFunc {
|
||||
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
|
||||
Where("user_id = ?", uid).Count(&total).Error
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": uid,
|
||||
"watched_ms": watched,
|
||||
"plays": total,
|
||||
"user_id": uid,
|
||||
"watched_ms": watched,
|
||||
"plays": total,
|
||||
"watched_hours": float64(watched) / 1000.0 / 3600.0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -410,7 +410,11 @@ func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func strmPlayHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
provider := strings.TrimSpace(c.Param("provider"))
|
||||
result, err := svc.Strm.ResolvePlay(c.Request.Context(), provider, url.Values(c.Request.URL.Query()))
|
||||
q := url.Values(c.Request.URL.Query())
|
||||
if userAgent := c.GetHeader("User-Agent"); userAgent != "" {
|
||||
q.Set("__ua", userAgent)
|
||||
}
|
||||
result, err := svc.Strm.ResolvePlay(c.Request.Context(), provider, q)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStrmPlayNotFound) {
|
||||
c.Status(http.StatusNotFound)
|
||||
|
||||
@@ -58,12 +58,12 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"groups": []gin.H{
|
||||
{
|
||||
"key": "general",
|
||||
"label": "常规",
|
||||
"items": []gin.H{
|
||||
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
|
||||
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
|
||||
{
|
||||
"key": "general",
|
||||
"label": "常规",
|
||||
"items": []gin.H{
|
||||
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
|
||||
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
|
||||
{"key": "transcode.hw_accel", "type": "select", "label": "硬件编码器"},
|
||||
{"key": "transcode.hw_enabled", "type": "toggle", "label": "启用硬件加速"},
|
||||
{"key": "transcode.max_jobs", "type": "number", "label": "最大并发"},
|
||||
@@ -120,10 +120,10 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
{"key": "qbittorrent.password", "type": "text"},
|
||||
{"key": "qbittorrent.savepath", "type": "text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "system-update",
|
||||
"label": "系统更新",
|
||||
},
|
||||
{
|
||||
"key": "system-update",
|
||||
"label": "系统更新",
|
||||
"items": []gin.H{
|
||||
{"key": "system.update.image", "type": "text", "label": "应用镜像"},
|
||||
{"key": "system.update.compose_dir", "type": "text", "label": "Docker Compose 安装目录"},
|
||||
|
||||
@@ -7,4 +7,4 @@ func finishHTTPTask(task *service.TaskHandle, err error, stage, message string,
|
||||
return
|
||||
}
|
||||
task.Finish(err, service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +42,14 @@ func EmbyAuthRequired(secret string) gin.HandlerFunc {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil || !parsed.Valid || claims.UserID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"Code": 40101,
|
||||
"Message": "Invalid token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if err != nil || !parsed.Valid || claims.UserID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"Code": 40101,
|
||||
"Message": "Invalid token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(EmbyCtxUserID, claims.UserID)
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
|
||||
@@ -106,13 +106,13 @@ func TestAuthRequiredSyncsAccessTokenCookieFromBearer(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired(secret))
|
||||
router.GET("/api/test-auth-cookie", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
router := gin.New()
|
||||
router.Use(AuthRequired(secret))
|
||||
router.GET("/api/test-auth-cookie", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test-auth-cookie", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test-auth-cookie", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
@@ -39,7 +39,7 @@ func PredefinedProviders() []ApiProvider {
|
||||
{ID: "openai", Name: "OpenAI", Description: "GPT 系列模型", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "deepseek", Name: "DeepSeek", Description: "DeepSeek 大模型", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "siliconflow", Name: "SiliconFlow", Description: "AI 模型聚合 API", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "adult", Name: "Adult / 番号", Description: "JavDB/JavBus 成人内容元数据与 Cookie 凭据", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "metatube", Name: "MetaTube", Description: "MetaTube Server 番号元数据后端服务", HasAPIKey: true, HasBaseURL: true},
|
||||
}
|
||||
{ID: "adult", Name: "Adult / 番号", Description: "JavDB/JavBus 成人内容元数据与 Cookie 凭据", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "metatube", Name: "MetaTube", Description: "MetaTube Server 番号元数据后端服务", HasAPIKey: true, HasBaseURL: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type UserPermission struct {
|
||||
UserID string `gorm:"uniqueIndex;size:36;not null" json:"user_id"`
|
||||
|
||||
// 默认开启(6项)- Basic
|
||||
CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
|
||||
CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
|
||||
CanPlayMedia bool `gorm:"default:true" json:"can_play_media"`
|
||||
CanCast bool `gorm:"default:true" json:"can_cast"`
|
||||
CanExternalPlayer bool `gorm:"default:true" json:"can_external_player"`
|
||||
@@ -24,18 +24,18 @@ type UserPermission struct {
|
||||
CanViewHistory bool `gorm:"default:true" json:"can_view_history"`
|
||||
|
||||
// 默认关闭(13项)- Advanced
|
||||
CanEditMedia bool `gorm:"default:false" json:"can_edit_media"`
|
||||
CanRescrape bool `gorm:"default:false" json:"can_rescrape"`
|
||||
CanUseAI bool `gorm:"default:false" json:"can_use_ai"`
|
||||
CanCaptureFrames bool `gorm:"default:false" json:"can_capture_frames"`
|
||||
CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"`
|
||||
CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"`
|
||||
CanManageSites bool `gorm:"default:false" json:"can_manage_sites"`
|
||||
CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"`
|
||||
CanManageUsers bool `gorm:"default:false" json:"can_manage_users"`
|
||||
CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
|
||||
CanManageStrm bool `gorm:"default:false" json:"can_manage_strm"`
|
||||
CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
|
||||
CanEditMedia bool `gorm:"default:false" json:"can_edit_media"`
|
||||
CanRescrape bool `gorm:"default:false" json:"can_rescrape"`
|
||||
CanUseAI bool `gorm:"default:false" json:"can_use_ai"`
|
||||
CanCaptureFrames bool `gorm:"default:false" json:"can_capture_frames"`
|
||||
CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"`
|
||||
CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"`
|
||||
CanManageSites bool `gorm:"default:false" json:"can_manage_sites"`
|
||||
CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"`
|
||||
CanManageUsers bool `gorm:"default:false" json:"can_manage_users"`
|
||||
CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
|
||||
CanManageStrm bool `gorm:"default:false" json:"can_manage_strm"`
|
||||
CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -52,26 +52,26 @@ func (p *UserPermission) BeforeCreate(_ *gorm.DB) error {
|
||||
// NewDefaultPermission 创建带有默认权限的 UserPermission。
|
||||
func NewDefaultPermission(userID string) *UserPermission {
|
||||
return &UserPermission{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
CanViewDashboard: true,
|
||||
CanPlayMedia: true,
|
||||
CanCast: true,
|
||||
CanExternalPlayer: true,
|
||||
CanFavorite: true,
|
||||
CanViewHistory: true,
|
||||
CanEditMedia: false,
|
||||
CanRescrape: false,
|
||||
CanUseAI: false,
|
||||
CanCaptureFrames: false,
|
||||
CanManageDownloads: false,
|
||||
CanManageSubscriptions: false,
|
||||
CanManageSites: false,
|
||||
CanUseAIAssistant: false,
|
||||
CanManageUsers: false,
|
||||
CanManageFiles: false,
|
||||
CanManageStrm: false,
|
||||
CanAccessSettings: false,
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
CanViewDashboard: true,
|
||||
CanPlayMedia: true,
|
||||
CanCast: true,
|
||||
CanExternalPlayer: true,
|
||||
CanFavorite: true,
|
||||
CanViewHistory: true,
|
||||
CanEditMedia: false,
|
||||
CanRescrape: false,
|
||||
CanUseAI: false,
|
||||
CanCaptureFrames: false,
|
||||
CanManageDownloads: false,
|
||||
CanManageSubscriptions: false,
|
||||
CanManageSites: false,
|
||||
CanUseAIAssistant: false,
|
||||
CanManageUsers: false,
|
||||
CanManageFiles: false,
|
||||
CanManageStrm: false,
|
||||
CanAccessSettings: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,13 +88,13 @@ func (p *UserPermission) PermissionMap() map[string]bool {
|
||||
"can_rescrape": p.CanRescrape,
|
||||
"can_use_ai": p.CanUseAI,
|
||||
"can_capture_frames": p.CanCaptureFrames,
|
||||
"can_manage_downloads": p.CanManageDownloads,
|
||||
"can_manage_subscriptions": p.CanManageSubscriptions,
|
||||
"can_manage_downloads": p.CanManageDownloads,
|
||||
"can_manage_subscriptions": p.CanManageSubscriptions,
|
||||
"can_manage_sites": p.CanManageSites,
|
||||
"can_use_ai_assistant": p.CanUseAIAssistant,
|
||||
"can_use_ai_assistant": p.CanUseAIAssistant,
|
||||
"can_manage_users": p.CanManageUsers,
|
||||
"can_manage_files": p.CanManageFiles,
|
||||
"can_manage_strm": p.CanManageStrm,
|
||||
"can_access_settings": p.CanAccessSettings,
|
||||
"can_access_settings": p.CanAccessSettings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,6 @@ func (r *StorageConfigRepository) Upsert(ctx context.Context, c *model.StorageCo
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
// Delete removes a storage config by ID.
|
||||
func (r *StorageConfigRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StorageConfig{}).Error
|
||||
|
||||
@@ -77,7 +77,11 @@ func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntr
|
||||
}
|
||||
|
||||
func (p *openAPI115Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
|
||||
url, err := p.c.GetDownloadURL(ctx, fileRef)
|
||||
return p.ResolveWithUA(ctx, fileRef, "")
|
||||
}
|
||||
|
||||
func (p *openAPI115Provider) ResolveWithUA(ctx context.Context, fileRef, ua string) (*DirectLink, error) {
|
||||
url, err := p.c.GetDownloadURLWithUA(ctx, fileRef, ua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,10 @@ type OpenClient struct {
|
||||
AccessToken string
|
||||
RefreshTokenStr string
|
||||
executor *QueueExecutor
|
||||
|
||||
// tokenMu 保护令牌刷新:业务请求中途 access_token 失效时自动刷新重试,
|
||||
// 多 goroutine(同步列表 + 下载队列)并发下只允许一次刷新进行。
|
||||
tokenMu sync.Mutex
|
||||
}
|
||||
|
||||
// default115HTTPClient 创建带有防 405 重定向保护的 http.Client。
|
||||
@@ -90,11 +95,15 @@ type RespBase struct {
|
||||
}
|
||||
|
||||
// doJSON 执行 HTTP 请求并解析为统一响应;带 AccessToken(access=true 时)。
|
||||
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int) (*RespBase, error) {
|
||||
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int, uas ...string) (*RespBase, error) {
|
||||
executor := c.executor
|
||||
if executor == nil {
|
||||
executor = GetGlobalExecutor()
|
||||
}
|
||||
ua := ""
|
||||
if len(uas) > 0 {
|
||||
ua = uas[0]
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
@@ -103,7 +112,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := c.buildRequest(ctx, method, rawURL, form, access)
|
||||
req, err := c.buildRequestWithUA(ctx, method, rawURL, form, access, ua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -171,9 +180,18 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
return &base, lastErr
|
||||
}
|
||||
|
||||
// Token 失效不重试
|
||||
// Token 失效(access_token 过期,如长时间同步中途过期):自动用
|
||||
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
|
||||
// 避免长时间同步因 token 过期而整体失败。
|
||||
if isTokenCode(base.Code) {
|
||||
return &base, nil
|
||||
if access && c.tryRefreshTokenLocked() {
|
||||
continue
|
||||
}
|
||||
if access {
|
||||
// 刷新失败(或已刷新仍失败)时返回明确错误
|
||||
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115: access_token 校验失败且刷新未成功")
|
||||
}
|
||||
return &base, lastErr
|
||||
}
|
||||
|
||||
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115 接口调用失败")
|
||||
@@ -187,6 +205,10 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
}
|
||||
|
||||
func (c *OpenClient) buildRequest(ctx context.Context, method, rawURL string, form map[string]string, access bool) (*http.Request, error) {
|
||||
return c.buildRequestWithUA(ctx, method, rawURL, form, access, "")
|
||||
}
|
||||
|
||||
func (c *OpenClient) buildRequestWithUA(ctx context.Context, method, rawURL string, form map[string]string, access bool, ua string) (*http.Request, error) {
|
||||
method = strings.ToUpper(method)
|
||||
var body io.Reader
|
||||
if method == http.MethodPost && len(form) > 0 {
|
||||
@@ -211,7 +233,11 @@ func (c *OpenClient) buildRequest(ctx context.Context, method, rawURL string, fo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", DefaultUA)
|
||||
targetUA := DefaultUA
|
||||
if strings.TrimSpace(ua) != "" {
|
||||
targetUA = strings.TrimSpace(ua)
|
||||
}
|
||||
req.Header.Set("User-Agent", targetUA)
|
||||
if method == http.MethodPost && len(form) > 0 {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
@@ -226,6 +252,27 @@ func (c *OpenClient) doAuthJSON(ctx context.Context, method, rawURL string, form
|
||||
return c.doJSON(ctx, method, rawURL, form, true, retries)
|
||||
}
|
||||
|
||||
// doAuthJSONWithUA 带自定义 User-Agent 的业务请求(换取直链等防盗链接口使用)。
|
||||
func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string, form map[string]string, retries int, ua string) (*RespBase, error) {
|
||||
return c.doJSON(ctx, method, rawURL, form, true, retries, ua)
|
||||
}
|
||||
|
||||
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
|
||||
// 应使用内存中的新 token 重试原请求)。refresh_token 已失效时也会清空内存 token。
|
||||
func (c *OpenClient) tryRefreshTokenLocked() bool {
|
||||
c.tokenMu.Lock()
|
||||
defer c.tokenMu.Unlock()
|
||||
token, err := c.RefreshToken(c.RefreshTokenStr)
|
||||
if err != nil {
|
||||
if IsRefreshTokenDead(err) {
|
||||
c.SetAuthToken("", "")
|
||||
}
|
||||
return false
|
||||
}
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
return true
|
||||
}
|
||||
|
||||
// IsThrottleCode 判断是否为限流错误码。
|
||||
func IsThrottleCode(code int) bool {
|
||||
return code == RequestMaxLimitCode || code == RequestRateLimitCode
|
||||
|
||||
@@ -127,11 +127,17 @@ type downloadURLData struct {
|
||||
// GetDownloadURL 获取下载直链(pickcode)。命中缓存直接返回,
|
||||
// 避免对同一文件反复换取直链触发 115 风控。
|
||||
func (c *OpenClient) GetDownloadURL(ctx context.Context, pickCode string) (string, error) {
|
||||
if cached := GetDownloadURLCache(pickCode); cached != "" {
|
||||
return c.GetDownloadURLWithUA(ctx, pickCode, "")
|
||||
}
|
||||
|
||||
// GetDownloadURLWithUA 支持按调用方/播放器 User-Agent 换取对应的 115 CDN 直链(用于 115 防盗链白名单校验)。
|
||||
func (c *OpenClient) GetDownloadURLWithUA(ctx context.Context, pickCode, ua string) (string, error) {
|
||||
ua = strings.TrimSpace(ua)
|
||||
if cached := GetDownloadURLCache(pickCode, ua); cached != "" {
|
||||
return cached, nil
|
||||
}
|
||||
params := map[string]string{"pick_code": pickCode}
|
||||
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1)
|
||||
resp, err := c.doAuthJSONWithUA(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1, ua)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -143,7 +149,7 @@ func (c *OpenClient) GetDownloadURL(ctx context.Context, pickCode string) (strin
|
||||
if first.URL.URL == "" {
|
||||
return "", fmt.Errorf("115: 下载地址为空(文件可能未上传完成或已被删除)")
|
||||
}
|
||||
SetDownloadURLCache(pickCode, first.URL.URL)
|
||||
SetDownloadURLCache(pickCode, first.URL.URL, ua)
|
||||
return first.URL.URL, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -28,17 +29,30 @@ var (
|
||||
urlCache = map[string]urlCacheEntry{}
|
||||
)
|
||||
|
||||
// urlCacheKey 构造缓存键名(pickCode + UA,实现直链防盗链按客户端 UA 独立缓存)。
|
||||
func urlCacheKey(pickCode, ua string) string {
|
||||
if ua == "" {
|
||||
return pickCode
|
||||
}
|
||||
return pickCode + "@" + ua
|
||||
}
|
||||
|
||||
// GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
|
||||
func GetDownloadURLCache(pickCode string) string {
|
||||
func GetDownloadURLCache(pickCode string, uas ...string) string {
|
||||
if pickCode == "" {
|
||||
return ""
|
||||
}
|
||||
ua := ""
|
||||
if len(uas) > 0 {
|
||||
ua = uas[0]
|
||||
}
|
||||
key := urlCacheKey(pickCode, ua)
|
||||
urlCacheMu.Lock()
|
||||
defer urlCacheMu.Unlock()
|
||||
entry, ok := urlCache[pickCode]
|
||||
entry, ok := urlCache[key]
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
if ok {
|
||||
delete(urlCache, pickCode)
|
||||
delete(urlCache, key)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -46,10 +60,14 @@ func GetDownloadURLCache(pickCode string) string {
|
||||
}
|
||||
|
||||
// SetDownloadURLCache 写入直链缓存。
|
||||
func SetDownloadURLCache(pickCode, url string) {
|
||||
func SetDownloadURLCache(pickCode string, url string, uas ...string) {
|
||||
if pickCode == "" || url == "" {
|
||||
return
|
||||
}
|
||||
ua := ""
|
||||
if len(uas) > 0 {
|
||||
ua = uas[0]
|
||||
}
|
||||
urlCacheMu.Lock()
|
||||
defer urlCacheMu.Unlock()
|
||||
if len(urlCache) >= maxCachedURLs {
|
||||
@@ -60,15 +78,20 @@ func SetDownloadURLCache(pickCode, url string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
urlCache[pickCode] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
|
||||
key := urlCacheKey(pickCode, ua)
|
||||
urlCache[key] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
|
||||
}
|
||||
|
||||
// ClearDownloadURLCache 删除指定 pickcode 的缓存(下载得到非 2xx 时调用)。
|
||||
// ClearDownloadURLCache 删除指定 pickcode 的所有缓存(下载得到非 2xx 时调用)。
|
||||
func ClearDownloadURLCache(pickCode string) {
|
||||
if pickCode == "" {
|
||||
return
|
||||
}
|
||||
urlCacheMu.Lock()
|
||||
defer urlCacheMu.Unlock()
|
||||
delete(urlCache, pickCode)
|
||||
for k := range urlCache {
|
||||
if k == pickCode || strings.HasPrefix(k, pickCode+"@") {
|
||||
delete(urlCache, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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话"}]}`,
|
||||
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕Hash命中</d></i>`,
|
||||
&seen)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
seedDanmakuVideoMedia(t, svc, "mH", "测试动画", videoPath, 32000, 1)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mH", "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Equal(t, "xml", res.SourceType)
|
||||
require.Contains(t, res.Raw, "弹幕Hash命中")
|
||||
require.Empty(t, res.Candidates)
|
||||
|
||||
// match 请求体:文件名去扩展名并 URL 转义(官方接口要求,实测验证)、
|
||||
// hash、大小、matchMode 齐全。
|
||||
require.Contains(t, seen, `"fileName":"`+url.QueryEscape("测试动画.第01话")+`"`)
|
||||
require.Contains(t, seen, `"fileHash":"`+wantHash+`"`)
|
||||
require.Contains(t, seen, `"fileSize":32000`)
|
||||
require.Contains(t, seen, `"matchMode":"hashAndFileName"`)
|
||||
}
|
||||
|
||||
// 第 1 层拉弹幕:配置了自定义源时优先自定义源,失败才回退官方。
|
||||
func TestDanmakuFetchHashMatchUsesConfiguredSourceFirst(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
cfgSrv := newDanmakuSourceServer(t) // /api/v2/comment/25484 → 弹幕A
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画"}]}`,
|
||||
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕B官方</d></i>`,
|
||||
nil)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
|
||||
seedDanmakuVideoMedia(t, svc, "mC", "测试动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mC", "", "")
|
||||
require.NoError(t, err)
|
||||
// 配置源优先:弹幕来自自定义源而非官方。
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.NotContains(t, res.Raw, "弹幕B官方")
|
||||
}
|
||||
|
||||
func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
// 配置源:搜索正常,但弹幕接口 500。
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(mux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"测试动画"}]}`,
|
||||
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕官方兜底</d></i>`,
|
||||
nil)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mF", "测试动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mF", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "弹幕官方兜底")
|
||||
}
|
||||
|
||||
// 第 1 层未命中(matches 为空)→ 第 2 层按文件名+集数搜索。
|
||||
func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
cfgSrv := newDanmakuSourceServer(t) // 搜索 + 弹幕A
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":false,"matches":[]}`,
|
||||
`<i></i>`, nil)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
|
||||
seedDanmakuVideoMedia(t, svc, "mM", "刮削标题", videoPath, 32000, 1)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mM", "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
// 第 2 层命中:搜索请求按文件名进行。
|
||||
require.Contains(t, cfgSrv.lastSearch, "anime=")
|
||||
}
|
||||
|
||||
// strm:通过解析出的直链 Range 拉 16MB 前缀算 hash → match → 拉弹幕。
|
||||
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":"远程动画"}]}`,
|
||||
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕Strm命中</d></i>`,
|
||||
&seen)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
var gotProvider string
|
||||
svc.SetStrmResolver(func(_ context.Context, provider string, _ url.Values) (*StrmPlayResult, error) {
|
||||
gotProvider = provider
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -86,6 +86,21 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
|
||||
return STRMPlaybackEnabled(ctx, repo)
|
||||
}
|
||||
|
||||
// isStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
|
||||
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
|
||||
func isStrmMediaRow(m *model.Media) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(m.STRMURL) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(m.Container), "strm") {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm")
|
||||
}
|
||||
|
||||
func isHTTPPlaybackTarget(raw string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil || !u.IsAbs() {
|
||||
|
||||
@@ -66,7 +66,15 @@ func (s *StrmService) resolveCloudPlay(ctx context.Context, provider string, q u
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
link, err := p.Resolve(ctx, ref)
|
||||
var link *cloud.DirectLink
|
||||
ua := q.Get("__ua")
|
||||
if uaProvider, ok := p.(interface {
|
||||
ResolveWithUA(ctx context.Context, fileRef, ua string) (*cloud.DirectLink, error)
|
||||
}); ok && ua != "" {
|
||||
link, err = uaProvider.ResolveWithUA(ctx, ref, ua)
|
||||
} else {
|
||||
link, err = p.Resolve(ctx, ref)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -229,7 +230,14 @@ func (st *strmSyncState) run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// walkRemote 广度优先遍历网盘目录树。
|
||||
// strmScanWorkers 远端目录树并发遍历的 worker 数。115 开放平台有全局
|
||||
// 令牌桶限流(QPS/QPM/QPH),并发请求自动排队,不会触发风控;并发让
|
||||
// 多个目录列表请求的网络往返彼此重叠,大幅缩短大目录树同步耗时。
|
||||
const strmScanWorkers = 8
|
||||
|
||||
// walkRemote 并发广度优先遍历网盘目录树。
|
||||
// 多个 worker 并行执行 List(受全局 115 令牌桶限流约束),子目录动态
|
||||
// 入队;任一目录失败则取消其余 worker 并返回错误(与旧串行版语义一致)。
|
||||
func (st *strmSyncState) walkRemote() error {
|
||||
root := strings.TrimSpace(st.p.RemotePath)
|
||||
if root == "" {
|
||||
@@ -239,33 +247,78 @@ func (st *strmSyncState) walkRemote() error {
|
||||
id string
|
||||
rel string
|
||||
}
|
||||
queue := []dirTask{{id: root, rel: ""}}
|
||||
for len(queue) > 0 {
|
||||
select {
|
||||
case <-st.ctx.Done():
|
||||
return st.ctx.Err()
|
||||
default:
|
||||
}
|
||||
task := queue[0]
|
||||
queue = queue[1:]
|
||||
entries, err := st.provider.List(st.ctx, task.id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
cleanName := cleanEntryName(entry.Name, entry.IsDir)
|
||||
rel := cleanName
|
||||
if task.rel != "" {
|
||||
rel = task.rel + "/" + cleanName
|
||||
|
||||
ctx, cancel := context.WithCancel(st.ctx)
|
||||
defer cancel()
|
||||
|
||||
queue := make(chan dirTask, 512)
|
||||
var pending atomic.Int64
|
||||
|
||||
// 根目录入队
|
||||
pending.Add(1)
|
||||
queue <- dirTask{id: root, rel: ""}
|
||||
|
||||
// 当队列中所有目录都被消费(pending 归零)或出错时关闭 channel,
|
||||
// 让 worker 全部退出。
|
||||
go func() {
|
||||
for {
|
||||
if ctx.Err() != nil || pending.Load() == 0 {
|
||||
close(queue)
|
||||
return
|
||||
}
|
||||
if entry.IsDir {
|
||||
queue = append(queue, dirTask{id: entry.ID, rel: rel})
|
||||
continue
|
||||
}
|
||||
st.processRemoteFile(entry, rel)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
errMu sync.Mutex
|
||||
firstErr error
|
||||
)
|
||||
for i := 0; i < strmScanWorkers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for task := range queue {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
entries, err := st.provider.List(ctx, task.id)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
|
||||
}
|
||||
errMu.Unlock()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
cleanName := cleanEntryName(entry.Name, entry.IsDir)
|
||||
rel := cleanName
|
||||
if task.rel != "" {
|
||||
rel = task.rel + "/" + cleanName
|
||||
}
|
||||
if entry.IsDir {
|
||||
pending.Add(1)
|
||||
select {
|
||||
case queue <- dirTask{id: entry.ID, rel: rel}:
|
||||
case <-ctx.Done():
|
||||
pending.Add(-1)
|
||||
}
|
||||
} else {
|
||||
st.processRemoteFile(entry, rel)
|
||||
}
|
||||
}
|
||||
pending.Add(-1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
return firstErr
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// processRemoteFile 分类处理远端文件:视频生成 STRM,元数据入下载队列。
|
||||
@@ -340,7 +393,8 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
}
|
||||
content, err := st.strmContent(entry, rel, ext)
|
||||
if err != nil {
|
||||
st.rec.Message = err.Error()
|
||||
// 并发 worker 下 rec.Message 无锁写会有数据竞争,这里仅记录日志;
|
||||
// 最终同步结果的 message 由 finishSync 统一填充。
|
||||
st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service/cloud"
|
||||
)
|
||||
|
||||
// testStrmService 构建带内存库的 StrmService。
|
||||
@@ -340,3 +341,108 @@ func TestScanLocalMetaForUpload(t *testing.T) {
|
||||
t.Fatalf("expected still 1 upload task after dedup, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
|
||||
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
|
||||
type fakeRemoteProvider struct {
|
||||
listed map[string]int
|
||||
}
|
||||
|
||||
func (f *fakeRemoteProvider) Type() string { return "fake" }
|
||||
func (f *fakeRemoteProvider) Ping(context.Context) error { return nil }
|
||||
func (f *fakeRemoteProvider) Resolve(context.Context, string) (*cloud.DirectLink, error) {
|
||||
return &cloud.DirectLink{URL: "http://cdn/x.mkv"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRemoteProvider) List(_ context.Context, dirID string) ([]cloud.FileEntry, error) {
|
||||
if f.listed == nil {
|
||||
f.listed = map[string]int{}
|
||||
}
|
||||
f.listed[dirID]++
|
||||
switch dirID {
|
||||
case "root":
|
||||
return []cloud.FileEntry{
|
||||
{ID: "a", Name: "动漫", IsDir: true},
|
||||
{ID: "b", Name: "电影", IsDir: true},
|
||||
{ID: "f1", Name: "孤儿视频.mkv", Size: 100},
|
||||
}, nil
|
||||
case "a":
|
||||
return []cloud.FileEntry{
|
||||
{ID: "a1", Name: "番剧", IsDir: true},
|
||||
{ID: "fa1", Name: "第01集.mkv", Size: 200},
|
||||
}, nil
|
||||
case "a1":
|
||||
return []cloud.FileEntry{
|
||||
{ID: "fa11", Name: "第01集.mkv", Size: 300},
|
||||
{ID: "fa12", Name: "第02集.mkv", Size: 300},
|
||||
}, nil
|
||||
case "b":
|
||||
return []cloud.FileEntry{
|
||||
{ID: "fb1", Name: "电影A.mkv", Size: 400},
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalkRemoteConcurrent 验证并发目录遍历:所有目录均被列出、所有文件
|
||||
// 均被处理(strm 生成 / 元数据入队),且不重复。
|
||||
func TestWalkRemoteConcurrent(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
localDir := t.TempDir()
|
||||
|
||||
acct := &model.StrmAccount{
|
||||
Name: "fake",
|
||||
Provider: "cloud115",
|
||||
Config: "{}",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &model.StrmSyncPath{
|
||||
Base: model.Base{ID: "walk-path"},
|
||||
AccountID: acct.ID,
|
||||
Provider: model.StrmProvider115,
|
||||
RemotePath: "root",
|
||||
LocalPath: localDir,
|
||||
}
|
||||
|
||||
provider := &fakeRemoteProvider{listed: map[string]int{}}
|
||||
st := &strmSyncState{
|
||||
s: svc,
|
||||
ctx: context.Background(),
|
||||
p: p,
|
||||
provider: provider,
|
||||
cfg: &strmPathConfig{VideoExt: []string{"mkv"}, MetaExt: []string{"nfo"}, AddPath: 1, DownloadMeta: false},
|
||||
rec: &model.StrmSyncRecord{},
|
||||
seenVideo: map[string]bool{},
|
||||
seenMeta: map[string]bool{},
|
||||
remoteMeta: map[string]int64{},
|
||||
}
|
||||
if err := st.walkRemote(); err != nil {
|
||||
t.Fatalf("walkRemote failed: %v", err)
|
||||
}
|
||||
|
||||
for _, dir := range []string{"root", "a", "a1", "b"} {
|
||||
if provider.listed[dir] != 1 {
|
||||
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, provider.listed[dir])
|
||||
}
|
||||
}
|
||||
|
||||
// 5 个视频文件应生成 5 个 .strm:孤儿视频.mkv / a目录第01集 /
|
||||
// 番剧第01集+第02集 / 电影A(递归统计,含子目录)
|
||||
strmCount := 0
|
||||
walkErr := filepath.WalkDir(localDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() && strings.HasSuffix(d.Name(), ".strm") {
|
||||
strmCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
t.Fatal(walkErr)
|
||||
}
|
||||
if strmCount != 5 {
|
||||
t.Errorf("生成的 .strm 数量 = %d,期望 5", strmCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <track> 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 <track> 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:<stream-index>"; 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:<index>") 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -101,6 +101,12 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
if m == nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
// .strm 媒体(STRMURL 或 container=strm / *.strm 路径)的内容是远程
|
||||
// 直链文本,ffmpeg 无法读取,转码必然失败且白白消耗资源。直接拒绝
|
||||
// 转码,迫使播放器走 /api/stream 302 直连播放。
|
||||
if isStrmMediaRow(m) {
|
||||
return "", ErrTranscodeDisabled
|
||||
}
|
||||
if _, err := os.Stat(m.Path); err != nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
Maximize,
|
||||
MessageSquareText,
|
||||
Minimize,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import type { SubtitleTrack } from '../api/subtitles'
|
||||
|
||||
// PlayerControls — custom bottom control bar replacing the native <video
|
||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||
@@ -17,8 +20,7 @@ import {
|
||||
//
|
||||
// Native keyboard shortcuts (space / arrows) still work because they are
|
||||
// element-level defaults on <video>. Subtitles from <track> elements keep
|
||||
// rendering (the default track shows as before); only the track picker UI is
|
||||
// not re-implemented here.
|
||||
// rendering; the CC button opens a track picker (关闭 / 各轨道).
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!Number.isFinite(s) || s < 0) s = 0
|
||||
@@ -29,6 +31,10 @@ function formatTime(s: number): string {
|
||||
|
||||
type PlayerControlsProps = {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
subs: SubtitleTrack[]
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
onSelectSubtitle: (index: number) => void
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
@@ -36,6 +42,9 @@ type PlayerControlsProps = {
|
||||
|
||||
export function PlayerControls({
|
||||
videoRef,
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
@@ -51,8 +60,22 @@ export function PlayerControls({
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [pip, setPip] = useState(false)
|
||||
const [uiVisible, setUiVisible] = useState(true)
|
||||
const [subtitleMenuOpen, setSubtitleMenuOpen] = useState(false)
|
||||
const subtitleMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 点击控制栏外部时关闭字幕菜单
|
||||
useEffect(() => {
|
||||
if (!subtitleMenuOpen) return
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
|
||||
setSubtitleMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
return () => document.removeEventListener('mousedown', onDocClick)
|
||||
}, [subtitleMenuOpen])
|
||||
|
||||
// 播放时 3 秒无操作自动隐藏控制栏;暂停时保持显示。监听挂在视频容器上,
|
||||
// 控制栏隐藏(pointer-events-none)后移动鼠标仍能重新唤起。
|
||||
useEffect(() => {
|
||||
@@ -217,6 +240,55 @@ export function PlayerControls({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{subs.length > 0 && (
|
||||
<div className="relative" ref={subtitleMenuRef}>
|
||||
<button
|
||||
onClick={() => setSubtitleMenuOpen((v) => !v)}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title="字幕"
|
||||
>
|
||||
{subtitleIndex >= 0 ? (
|
||||
<Captions size={18} className="text-rose-400" />
|
||||
) : (
|
||||
<CaptionsOff size={18} className="text-white/70" />
|
||||
)}
|
||||
</button>
|
||||
{subtitleMenuOpen && (
|
||||
<div className="absolute bottom-11 right-0 z-30 min-w-36 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectSubtitle(-1)
|
||||
setSubtitleMenuOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
|
||||
subtitleIndex < 0 ? 'text-rose-400' : 'text-white/85'
|
||||
}`}
|
||||
>
|
||||
关闭字幕
|
||||
</button>
|
||||
{subs.map((track, index) => (
|
||||
<button
|
||||
key={track.path}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectSubtitle(index)
|
||||
setSubtitleMenuOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 truncate rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
|
||||
subtitleIndex === index ? 'text-rose-400' : 'text-white/85'
|
||||
}`}
|
||||
title={track.label || track.lang}
|
||||
>
|
||||
<span className="truncate">{track.label || track.lang || `字幕 ${index + 1}`}</span>
|
||||
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onToggleDanmaku}
|
||||
className={
|
||||
|
||||
Vendored
+18
@@ -326,3 +326,21 @@ body {
|
||||
background-color: var(--app-brand-emphasis) !important;
|
||||
color: var(--app-brand-text) !important;
|
||||
}
|
||||
|
||||
/* ── 播放器字幕(WebVTT <track>)──
|
||||
去掉浏览器默认黑底,用文字阴影保证亮画面下的可读性;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -59,8 +59,26 @@ export function HomePage() {
|
||||
})
|
||||
|
||||
// Fetch media items for all libraries in parallel
|
||||
const isSeriesType = (type?: string) => type === 'tv' || type === 'anime' || type === 'variety'
|
||||
const results = await Promise.allSettled(
|
||||
libs.map(async (lib) => {
|
||||
// 剧集类媒体库(tv/anime/variety):后端 /series 已按剧聚合,
|
||||
// 首页若用 episode 级 /media 的前 30 行再 groupSeries,同一部剧的
|
||||
// 多集会折叠成 1 张卡,导致整行只显示 1 个条目。
|
||||
// 改用 /series 分页拉取全部聚合后的剧集卡片。
|
||||
if (isSeriesType(lib.type)) {
|
||||
const cards: SeriesCard[] = []
|
||||
let total = 0
|
||||
const pageSize = 200
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const data = await libraryAPI.listSeries(lib.id, page, pageSize)
|
||||
const pageItems = asArray<SeriesCard>(data?.items)
|
||||
cards.push(...pageItems)
|
||||
total = data?.total ?? cards.length
|
||||
if (cards.length >= total || pageItems.length < pageSize) break
|
||||
}
|
||||
return { id: lib.id, cards, items: [], total }
|
||||
}
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 30)
|
||||
const items = asArray<Media>(page?.items)
|
||||
const cards = groupSeries(items)
|
||||
|
||||
@@ -27,6 +27,23 @@ import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
//
|
||||
// External subtitles next to the source file are auto-discovered and
|
||||
// attached as <track> elements.
|
||||
const SUBTITLE_STORAGE_KEY = 'mmtl.subtitle'
|
||||
|
||||
// 初始字幕偏好:localStorage 记录上次选择的轨道(-1=关闭);没有偏好时
|
||||
// 默认 0(自动加载第一条字幕)。
|
||||
function initialSubtitleIndex(): number {
|
||||
try {
|
||||
const saved = localStorage.getItem(SUBTITLE_STORAGE_KEY)
|
||||
if (saved !== null && saved !== '') {
|
||||
const n = parseInt(saved, 10)
|
||||
if (Number.isFinite(n)) return n
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function PlayerPage() {
|
||||
const { id = '' } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
@@ -40,6 +57,7 @@ export function PlayerPage() {
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(initialSubtitleIndex)
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||
@@ -156,7 +174,13 @@ export function PlayerPage() {
|
||||
})
|
||||
subtitlesAPI
|
||||
.list(id)
|
||||
.then((tracks) => setSubs(tracks ?? []))
|
||||
.then((tracks) => {
|
||||
const list = tracks ?? []
|
||||
setSubs(list)
|
||||
// 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同),
|
||||
// 越界时回退到第一条;无字幕则关闭。
|
||||
setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur))
|
||||
})
|
||||
.catch(() => setSubs([]))
|
||||
}, [id, params, directOnly])
|
||||
|
||||
@@ -246,6 +270,16 @@ export function PlayerPage() {
|
||||
setParams(params, { replace: true })
|
||||
}, [mode, params, setParams])
|
||||
|
||||
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
|
||||
const selectSubtitle = useCallback((index: number) => {
|
||||
setSubtitleIndex(index)
|
||||
try {
|
||||
localStorage.setItem(SUBTITLE_STORAGE_KEY, String(index))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
@@ -281,6 +315,8 @@ export function PlayerPage() {
|
||||
media={media}
|
||||
playerError={playerError}
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={selectSubtitle}
|
||||
videoRef={ref}
|
||||
onVideoError={handleVideoError}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
@@ -10,6 +11,9 @@ type PlayerVideoStageProps = {
|
||||
media: Media | null
|
||||
playerError: string
|
||||
subs: SubtitleTrack[]
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
onSelectSubtitle: (index: number) => void
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
onVideoError: () => void
|
||||
danmakuEnabled: boolean
|
||||
@@ -30,6 +34,8 @@ export function PlayerVideoStage({
|
||||
media,
|
||||
playerError,
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
videoRef,
|
||||
onVideoError,
|
||||
danmakuEnabled,
|
||||
@@ -58,6 +64,23 @@ export function PlayerVideoStage({
|
||||
else void stage.requestFullscreen?.()
|
||||
}
|
||||
|
||||
// 把用户选择的字幕轨道应用到 <video> 的 textTracks(跨浏览器显式设置
|
||||
// mode;<track default> 只影响初始值,部分浏览器不会自动显示)。
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || subs.length === 0) return
|
||||
const apply = () => {
|
||||
const tracks = video.textTracks
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
tracks[i].mode = i === subtitleIndex ? 'showing' : 'disabled'
|
||||
}
|
||||
}
|
||||
apply()
|
||||
// 轨道元数据就绪后再应用一次,确保字幕真正可见
|
||||
video.addEventListener('loadedmetadata', apply)
|
||||
return () => video.removeEventListener('loadedmetadata', apply)
|
||||
}, [subtitleIndex, subs, videoRef])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
@@ -73,14 +96,13 @@ export function PlayerVideoStage({
|
||||
className="relative z-0 max-h-screen w-full max-w-[1600px] bg-black"
|
||||
onError={onVideoError}
|
||||
>
|
||||
{subs.map((track, index) => (
|
||||
{subs.map((track) => (
|
||||
<track
|
||||
key={track.path}
|
||||
kind="subtitles"
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
default={index === 0}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
@@ -98,6 +120,9 @@ export function PlayerVideoStage({
|
||||
/>
|
||||
<PlayerControls
|
||||
videoRef={videoRef}
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
|
||||
@@ -12,6 +12,10 @@ export function pickPlayerMode(media: Media): PlayerMode {
|
||||
|
||||
export function needsTranscodeForBrowser(media: Media): boolean {
|
||||
const container = (media.container ?? '').toLowerCase()
|
||||
// .strm 媒体内容是远程直链(服务端 302 到播放 CDN 或反向代理),
|
||||
// 浏览器直接播放该远程流即可,转码无意义且必然失败(ffmpeg 无法读取
|
||||
// 文本 strm),恒走 direct play。
|
||||
if (container.includes('strm') || String(media.strm_url ?? '').trim() !== '') return false
|
||||
const videoCodec = (media.video_codec ?? '').toLowerCase()
|
||||
const audioCodec = (media.audio_codec ?? '').toLowerCase()
|
||||
const containerOK = directContainers.some((item) => container.includes(item))
|
||||
|
||||
@@ -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: '弹幕透明度',
|
||||
|
||||
Reference in New Issue
Block a user