mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f64f961ac | |||
| 6797394c5b | |||
| e71d9d61d1 | |||
| 1ff60d5641 | |||
| 3f361631d0 | |||
| cfe0c0cf28 | |||
| d6355d5582 | |||
| 0f06bdf929 | |||
| e840cbd2c9 | |||
| 2bb3610da7 |
@@ -14,6 +14,7 @@ verify-cache/
|
||||
verify-media/
|
||||
verify-downloads/
|
||||
.codex-*
|
||||
.codex/
|
||||
.tmp/
|
||||
.tmp_*
|
||||
.tmp-deploy-*
|
||||
|
||||
@@ -66,6 +66,7 @@ config.yaml
|
||||
.tmp-live-backups/
|
||||
.tmp-*
|
||||
.codex-*
|
||||
.codex/
|
||||
downloads/
|
||||
media/
|
||||
*.pid
|
||||
|
||||
@@ -76,6 +76,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "fonts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -85,6 +88,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "fonts", "geist-400.woff2"), []byte("wOF2-test-font"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "brand", "mebox-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -105,6 +111,29 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
t.Fatalf("asset Cache-Control = %q, want immutable", got)
|
||||
}
|
||||
|
||||
fontReq := httptest.NewRequest(http.MethodGet, "/fonts/geist-400.woff2", nil)
|
||||
fontResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(fontResp, fontReq)
|
||||
if fontResp.Code != http.StatusOK {
|
||||
t.Fatalf("font status = %d, want 200", fontResp.Code)
|
||||
}
|
||||
if got := fontResp.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=86400") {
|
||||
t.Fatalf("font Cache-Control = %q, want max-age=86400", got)
|
||||
}
|
||||
if got := fontResp.Body.String(); got != "wOF2-test-font" {
|
||||
t.Fatalf("font body = %q, want wOF2-test-font", got)
|
||||
}
|
||||
|
||||
missingFontReq := httptest.NewRequest(http.MethodGet, "/fonts/missing.woff2", nil)
|
||||
missingFontResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(missingFontResp, missingFontReq)
|
||||
if missingFontResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing font status = %d, want 404", missingFontResp.Code)
|
||||
}
|
||||
if strings.Contains(missingFontResp.Body.String(), "index") {
|
||||
t.Fatalf("missing font should not serve SPA index: %q", missingFontResp.Body.String())
|
||||
}
|
||||
|
||||
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mebox-logo.svg", nil)
|
||||
brandResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(brandResp, brandReq)
|
||||
|
||||
+12
-5
@@ -59,6 +59,13 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
c.Next()
|
||||
})
|
||||
assets.GET("/*filepath", serveFSDir(root, "assets"))
|
||||
fonts := r.Group("/fonts")
|
||||
fonts.Use(middleware.GzipStatic())
|
||||
fonts.Use(func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Next()
|
||||
})
|
||||
fonts.GET("/*filepath", serveFSDir(root, "fonts"))
|
||||
brand := r.Group("/brand")
|
||||
brand.Use(func(c *gin.Context) {
|
||||
setNoCacheHeaders(c)
|
||||
@@ -70,11 +77,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
r.GET(rootFile, serveFSFile(root, name))
|
||||
r.HEAD(rootFile, serveFSFile(root, name))
|
||||
}
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if shouldBypassSPAFallback(path) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -20,6 +20,7 @@ require (
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -88,7 +89,6 @@ require (
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
|
||||
@@ -48,6 +48,7 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
v.SetDefault("cache.redis_prefix", "mebox")
|
||||
v.SetDefault("cache.media_ttl_seconds", 90)
|
||||
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
|
||||
|
||||
v.SetDefault("search.backend", "")
|
||||
v.SetDefault("search.opensearch_url", "")
|
||||
|
||||
@@ -124,6 +124,11 @@ type CacheConfig struct {
|
||||
RedisURL string `mapstructure:"redis_url"`
|
||||
RedisPrefix string `mapstructure:"redis_prefix"`
|
||||
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
|
||||
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
|
||||
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
|
||||
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
|
||||
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
|
||||
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
|
||||
}
|
||||
|
||||
type SearchConfig struct {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -14,7 +15,13 @@ import (
|
||||
// specific danmaku library chosen by the user after a disambiguation.
|
||||
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
|
||||
uid := currentUserID(c)
|
||||
// 弹幕合并偏好按用户存储:这里读取后作为本次抓取的选项传入。
|
||||
opts := service.DanmakuFetchOptions{
|
||||
MergeSources: svc.Danmaku.MergeSourcesEnabled(c.Request.Context(), uid),
|
||||
}
|
||||
res, err := svc.Danmaku.FetchWithOptions(
|
||||
c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -28,6 +35,34 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// admin privileges.
|
||||
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
|
||||
c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c)))
|
||||
}
|
||||
}
|
||||
|
||||
// updateDanmakuSettingsHandler 持久化当前用户的弹幕偏好。目前只有合并开关,
|
||||
// 落在 user 表上(与字幕简繁偏好同样按用户存储)。
|
||||
func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := currentUserID(c)
|
||||
if strings.TrimSpace(uid) == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
MergeSources *bool `json:"merge_sources"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if req.MergeSources == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "merge_sources is required"})
|
||||
return
|
||||
}
|
||||
if err := svc.Danmaku.SetMergeSources(c.Request.Context(), uid, *req.MergeSources); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"merge_sources": *req.MergeSources})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newDanmakuSettingsContext 构造带登录用户的最小 gin 上下文。
|
||||
func newDanmakuSettingsContext(t *testing.T, svc *service.Container, method, path, body string, userID string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
if userID != "" {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
}
|
||||
return c, w
|
||||
}
|
||||
|
||||
func newDanmakuSettingsService(t *testing.T) *service.Container {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}, &model.Media{}, &model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
user := model.User{Username: "settings-user", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
user.ID = "user-1"
|
||||
if err := repos.User.Create(t.Context(), &user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return &service.Container{
|
||||
Repo: repos,
|
||||
Danmaku: service.NewDanmakuService(zap.NewNop(), repos),
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsMergeSources(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"merge_sources":true}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.MergeSources {
|
||||
t.Fatal("response should echo merge_sources=true")
|
||||
}
|
||||
// 落库校验:重新读取应为 true。
|
||||
if !svc.Danmaku.MergeSourcesEnabled(t.Context(), "user-1") {
|
||||
t.Fatal("merge preference was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsMissingField(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", `{}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRequiresAuthentication(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"merge_sources":true}`, "")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// config 接口应把当前用户的合并偏好带出去,供面板初始化。
|
||||
func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var cfg struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if cfg.MergeSources {
|
||||
t.Fatal("default merge preference should be false")
|
||||
}
|
||||
|
||||
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c2)
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !cfg.MergeSources {
|
||||
t.Fatal("config should reflect the persisted merge preference")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newEmbyCompatTestRouter 构造一个挂载了完整 Emby 路由表的测试引擎。
|
||||
func newEmbyCompatTestRouter(t *testing.T, secret string) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func TestEmbyAdditionalPartsReturnsEmptyArray(t *testing.T) {
|
||||
const secret = "test-secret"
|
||||
router := newEmbyCompatTestRouter(t, secret)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Videos/msgo-series-abc/AdditionalParts", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
// 必须命中 AdditionalParts 静态路由并返回空数组,而不是被 /Videos/:id/:seg
|
||||
// 的 HLS 兜底路由接走返回空 404。
|
||||
var payload []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
if len(payload) != 0 {
|
||||
t.Fatalf("expected an empty array, got %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImagesWithoutTypeReturnsArray(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
// 不带 Type 的图片清单接口是公开路由,不要求 token,与带 Type 的
|
||||
// 图片字节流一致(客户端缓存 URL 时会丢 token)。
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Items/unknown-item/Images", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct == "" || ct[:16] != "application/json" {
|
||||
t.Fatalf("content type = %q, want application/json", ct)
|
||||
}
|
||||
var payload []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImagesLowerCaseRouteIsRegistered(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/items/unknown-item/images", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyUserImageWithoutAvatarReturnsCacheableNotFound(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 用户没有头像时 Emby 同样返回 404,但响应必须可缓存,否则客户端会
|
||||
// 在每次进入设置页时重复请求(线上曾观察到每分钟一次的重试)。
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=86400" {
|
||||
t.Fatalf("Cache-Control = %q, want the cacheable directive", cc)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,49 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// embyItemImagesHandler 处理不带 Type 的 GET /Items/{Id}/Images,返回图片
|
||||
// 清单(Emby 的 ImageInfo 数组)。客户端据此决定详情页加载哪些图。
|
||||
func embyItemImagesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if svc == nil || svc.Emby == nil || id == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
infos := svc.Emby.ImageInfos(c.Request.Context(), id)
|
||||
if infos == nil {
|
||||
infos = []map[string]any{}
|
||||
}
|
||||
c.JSON(http.StatusOK, infos)
|
||||
}
|
||||
}
|
||||
|
||||
// embyUserImageHandler 处理 /Users/{UserId}/Images/{Type}。Emby 对未设置
|
||||
// 头像的用户同样返回 404,但响应必须带缓存头,否则客户端每次进入设置页
|
||||
// 都会重复请求同一个空头像(日志中曾观察到每分钟重试)。
|
||||
func embyUserImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := strings.TrimSpace(c.Param("userId"))
|
||||
raw := ""
|
||||
if svc != nil && svc.Emby != nil && uid != "" {
|
||||
raw = svc.Emby.UserAvatarURL(c.Request.Context(), uid)
|
||||
}
|
||||
if raw == "" || svc == nil || svc.ImageProxy == nil {
|
||||
embyMissingAvatar(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil {
|
||||
embyMissingAvatar(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// embyMissingAvatar 以 Emby 语义返回"该用户没有头像",并允许客户端长期缓存。
|
||||
func embyMissingAvatar(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Cache-Control")
|
||||
c.Writer.Header().Del("Pragma")
|
||||
|
||||
@@ -115,12 +115,24 @@ func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, jwtSecret string, svc
|
||||
|
||||
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||
//
|
||||
// 不带 Type 的 /Items/{Id}/Images 返回图片清单(ImageInfo 数组),与下面
|
||||
// 带 Type 的图片字节流是不同接口,必须单独注册,否则会落到 NoRoute 并
|
||||
// 返回 text/plain 的 404。
|
||||
grp.GET("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||
grp.GET("/items/:id/images", embyItemImagesHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
|
||||
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.GET("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyGetRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
@@ -202,6 +214,12 @@ func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix strin
|
||||
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
|
||||
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
|
||||
// Emby 官方接口:附加片段清单。MeBox 不提供附加片段,但必须返回空数组
|
||||
// 而不是 404 —— 部分客户端(RodelPlayer)在详情页无条件请求它,404 会
|
||||
// 让它们把条目判定为不完整。必须注册成静态段,否则会被
|
||||
// /Videos/:id/:seg 的 HLS 兜底路由抢先匹配并返回空 404。
|
||||
auth.GET("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||
auth.HEAD("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
|
||||
@@ -54,6 +54,7 @@ func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container)
|
||||
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
|
||||
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
||||
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
|
||||
authed.GET("/subtitles/:id/ass", serveASSSubtitleHandler(svc))
|
||||
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
|
||||
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
|
||||
|
||||
authed.GET("/danmaku/:id", getDanmakuHandler(svc))
|
||||
authed.GET("/danmaku/config", getDanmakuConfigHandler(svc))
|
||||
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
|
||||
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
|
||||
@@ -34,6 +34,22 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func serveASSSubtitleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
if err := svc.Subtitle.ServeASS(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
|
||||
@@ -28,6 +28,13 @@ type User struct {
|
||||
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
||||
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
||||
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// DanmakuMergeSources 是网页播放器的弹幕偏好:开启后,同一集的多个弹幕
|
||||
// 来源会被合并并按「时间 + 内容」去重后一起展示。按用户存储,避免一个
|
||||
// 用户的开关影响其他人。
|
||||
DanmakuMergeSources bool `gorm:"default:false" json:"danmaku_merge_sources"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
// expires. When set and in the past, the account is treated as expired
|
||||
// (login blocked) until an admin or a redemption code renews it.
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -10,6 +11,18 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// MediaUpsertItem carries a media row and optional sibling paths from an
|
||||
// earlier keep_ext naming mode. An alias is only used when the exact new path
|
||||
// does not exist in the database; in that case the existing row is migrated to
|
||||
// the new path so scraped metadata survives renames such as:
|
||||
//
|
||||
// foo.strm -> foo.mkv.strm
|
||||
// foo.mkv.strm -> foo.strm
|
||||
type MediaUpsertItem struct {
|
||||
Media *model.Media
|
||||
AliasPaths []string
|
||||
}
|
||||
|
||||
// Upsert inserts or updates a media row keyed by Path (unique index).
|
||||
//
|
||||
// 重要:当一条行已经存在时,scanner 重扫只应该刷新文件级元数据
|
||||
@@ -22,20 +35,14 @@ import (
|
||||
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
|
||||
// 永远捞不到数据。
|
||||
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
|
||||
var indexIDs []string
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
id, uerr := r.upsertWithDB(ctx, r.db, m)
|
||||
if uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
indexIDs = append(indexIDs[:0], id)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.indexByIDBestEffort(ctx, indexIDs)
|
||||
return nil
|
||||
return r.UpsertWithAliases(ctx, m, nil)
|
||||
}
|
||||
|
||||
// UpsertWithAliases is Upsert with explicit, filesystem-verified STRM sibling
|
||||
// aliases. It preserves the old row's ID, CreatedAt and scraped metadata while
|
||||
// moving it to the current path.
|
||||
func (r *MediaRepository) UpsertWithAliases(ctx context.Context, m *model.Media, aliasPaths []string) error {
|
||||
return r.UpsertBatchWithAliases(ctx, []MediaUpsertItem{{Media: m, AliasPaths: aliasPaths}})
|
||||
}
|
||||
|
||||
// UpsertBatch 在单个事务里逐条执行 Upsert:扫描一批只提交(fsync)一次,
|
||||
@@ -46,6 +53,17 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
|
||||
// 事务内会把 SQLite 写锁挂起在网络 IO 上,且批内用非事务连接回读只能
|
||||
// 拿到提交前的旧版本数据,把陈旧内容写进索引。
|
||||
func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media) error {
|
||||
mapped := make([]MediaUpsertItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != nil {
|
||||
mapped = append(mapped, MediaUpsertItem{Media: item})
|
||||
}
|
||||
}
|
||||
return r.UpsertBatchWithAliases(ctx, mapped)
|
||||
}
|
||||
|
||||
// UpsertBatchWithAliases runs alias-aware upserts in one transaction.
|
||||
func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []MediaUpsertItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -53,11 +71,11 @@ func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media)
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
indexIDs = indexIDs[:0]
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, m := range items {
|
||||
if m == nil {
|
||||
for _, item := range items {
|
||||
if item.Media == nil {
|
||||
continue
|
||||
}
|
||||
id, err := r.upsertWithDB(ctx, tx, m)
|
||||
id, err := r.upsertWithDB(ctx, tx, item.Media, item.AliasPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,9 +105,9 @@ func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string)
|
||||
}
|
||||
}
|
||||
|
||||
// upsertWithDB 落库(新建或更新),返回需要重建索引的媒体 ID(无则空串)。
|
||||
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media) (string, error) {
|
||||
existing, created, err := r.findOrCreateMediaByPath(ctx, db, m)
|
||||
// upsertWithDB 落库(新建、更新或从旧路径迁移),返回需要重建索引的媒体 ID。
|
||||
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (string, error) {
|
||||
existing, created, adopted, err := r.findOrCreateMediaByPath(ctx, db, m, aliasPaths)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -98,6 +116,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
|
||||
}
|
||||
|
||||
updates := mediaUpsertUpdates(existing, *m)
|
||||
if adopted {
|
||||
updates["path"] = m.Path
|
||||
if existing.DeletedAt.Valid {
|
||||
updates["deleted_at"] = nil
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
*m = existing
|
||||
return "", nil
|
||||
@@ -107,31 +131,67 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
|
||||
return "", err
|
||||
}
|
||||
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
|
||||
if adopted {
|
||||
existing.Path = m.Path
|
||||
existing.DeletedAt = gorm.DeletedAt{}
|
||||
}
|
||||
*m = existing
|
||||
return existing.ID, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media) (model.Media, bool, error) {
|
||||
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) {
|
||||
var existing model.Media
|
||||
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if alias, aliasErr := r.findMediaByAlias(ctx, db, m.Path, aliasPaths); aliasErr == nil {
|
||||
return alias, false, true, nil
|
||||
} else if !errors.Is(aliasErr, gorm.ErrRecordNotFound) {
|
||||
return model.Media{}, false, false, aliasErr
|
||||
}
|
||||
// 新行:保证 scrape_status 走 GORM default:pending(即留空让数据库填)。
|
||||
if m.ScrapeStatus == "" {
|
||||
m.ScrapeStatus = "pending"
|
||||
}
|
||||
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
|
||||
return *m, true, nil
|
||||
return *m, true, false, nil
|
||||
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
|
||||
return model.Media{}, false, createErr
|
||||
return model.Media{}, false, false, createErr
|
||||
} else {
|
||||
// 并发插入竞态:重查已命中既有行,直接走更新分支。
|
||||
return existing, false, nil
|
||||
return existing, false, false, nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return model.Media{}, false, err
|
||||
return model.Media{}, false, false, err
|
||||
}
|
||||
return existing, false, nil
|
||||
return existing, false, false, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, currentPath string, aliasPaths []string) (model.Media, error) {
|
||||
aliases := make([]string, 0, len(aliasPaths))
|
||||
seen := make(map[string]struct{}, len(aliasPaths))
|
||||
for _, alias := range aliasPaths {
|
||||
alias = filepath.Clean(strings.TrimSpace(alias))
|
||||
if alias == "" || alias == "." || alias == filepath.Clean(currentPath) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[alias]; ok {
|
||||
continue
|
||||
}
|
||||
seen[alias] = struct{}{}
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
if len(aliases) == 0 {
|
||||
return model.Media{}, gorm.ErrRecordNotFound
|
||||
}
|
||||
var existing model.Media
|
||||
err := db.WithContext(ctx).Unscoped().
|
||||
Where("path IN ?", aliases).
|
||||
Order("CASE WHEN scrape_status = 'matched' THEN 0 ELSE 1 END ASC, " +
|
||||
"CASE WHEN COALESCE(poster_url, '') <> '' OR COALESCE(overview, '') <> '' THEN 0 ELSE 1 END ASC, " +
|
||||
"CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END ASC, updated_at DESC, created_at DESC").
|
||||
First(&existing).Error
|
||||
return existing, err
|
||||
}
|
||||
|
||||
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDanmakuEpisodeNumber(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"第1话 裏切りの大空", "1"},
|
||||
{"第18话 自己相似的两性同体-Fractal Androgynous-", "18"},
|
||||
{"第11话", "11"},
|
||||
{"【dandan&animeko】 第29集 那我们走吧", "29"},
|
||||
{"第 202 集", "202"},
|
||||
{"第1.5话 特别篇", "1.5"},
|
||||
{"正片", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := danmakuEpisodeNumber(tc.in); got != tc.want {
|
||||
t.Errorf("danmakuEpisodeNumber(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDanmakuEpisodeSubtitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"第1话 裏切りの大空", "裏切りの大空"},
|
||||
{"第18话 自己相似的两性同体-Fractal Androgynous-", "自己相似的两性同体-Fractal Androgynous-"},
|
||||
{"第11话", ""},
|
||||
{"【dandan&animeko】 第29集 那我们走吧", "那我们走吧"},
|
||||
{"正片", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := danmakuEpisodeSubtitle(tc.in); got != tc.want {
|
||||
t.Errorf("danmakuEpisodeSubtitle(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 刮削信息匹配:优先使用本地已刮削的年份、集数和集标题,不能再被同名续作
|
||||
// 或缺少集标题的平台源抢走。
|
||||
func TestMatchScrapedDanmakuEpisodesUsesYearAndEpisodeTitle(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "命运石之门 0(2018)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 10299, EpisodeTitle: "【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"},
|
||||
}},
|
||||
{AnimeID: 2, AnimeTitle: "命运石之门(2011)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 10322, EpisodeTitle: "【dandan&animeko】 第1话 始与终的序章-Turning Point-"},
|
||||
}},
|
||||
{AnimeID: 3, AnimeTitle: "命运石之门(2011)【动漫】from 360", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 10218, EpisodeTitle: "【qq】 第1集"},
|
||||
}},
|
||||
}
|
||||
|
||||
got := matchScrapedDanmakuEpisodes(candidates, "命运石之门", 2011, "1", "起始与终结的序章")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("matched %d anime, want 1: %#v", len(got), got)
|
||||
}
|
||||
if got[0].AnimeID != 2 || len(got[0].Episodes) != 1 || got[0].Episodes[0].EpisodeID != 10322 {
|
||||
t.Fatalf("picked wrong source: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 真实数据形态:官方 match 给出的剧名+集数在配置源里会命中多季/多版本,
|
||||
// 且顺序不可靠。必须靠副标题选中正确的那一集。
|
||||
func TestPickDanmakuEpisodeIDDisambiguatesBySubtitle(t *testing.T) {
|
||||
// 实测样本:官方 match「命运石之门 第18话 自己相似的两性同体-…」,
|
||||
// 配置源首条却是《命运石之门 0》的同一集号。
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "命运石之门 0(2018)", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 11072, EpisodeTitle: "【dandan&animeko】 第18话 并进对称的牵牛星-Translational"},
|
||||
}},
|
||||
{AnimeID: 2, AnimeTitle: "命运石之门(2011)", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 11095, EpisodeTitle: "【dandan&animeko】 第18话 自己相似的两性同体-Fractal Androgynous-"},
|
||||
}},
|
||||
}
|
||||
got, ok := pickDanmakuEpisodeID(candidates, "18", "第18话 自己相似的两性同体-Fractal Androgynous-")
|
||||
if !ok {
|
||||
t.Fatal("expected a confident match")
|
||||
}
|
||||
if got != 11095 {
|
||||
t.Fatalf("picked episodeId %d, want 11095 (first candidate is a different season)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 目标带副标题但没有任何候选的副标题对得上时,必须放弃而不是退回第一条,
|
||||
// 否则会给用户播放另一部番的弹幕。
|
||||
func TestPickDanmakuEpisodeIDRejectsWhenSubtitleNotFound(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "某番 第一季", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 111, EpisodeTitle: "第3话 完全不同的标题"},
|
||||
}},
|
||||
}
|
||||
if got, ok := pickDanmakuEpisodeID(candidates, "3", "第3话 期望的标题"); ok {
|
||||
t.Fatalf("expected no match, got episodeId %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 目标没有副标题(如「第11话」)时,退而要求集数一致;集数对不上同样放弃。
|
||||
func TestPickDanmakuEpisodeIDNumberOnlyFallback(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "86 第二季", Episodes: []DanmakuEpisode{
|
||||
// 该源对第二季采用绝对集号,第 11 集记作第 22 话。
|
||||
{EpisodeID: 11113, EpisodeTitle: "第22话 辛"},
|
||||
}},
|
||||
}
|
||||
if got, ok := pickDanmakuEpisodeID(candidates, "11", "第11话"); ok {
|
||||
t.Fatalf("episode number mismatch must be rejected, got episodeId %d", got)
|
||||
}
|
||||
|
||||
same := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "某番", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 222, EpisodeTitle: "第11话"},
|
||||
}},
|
||||
}
|
||||
got, ok := pickDanmakuEpisodeID(same, "11", "第11话")
|
||||
if !ok || got != 222 {
|
||||
t.Fatalf("pickDanmakuEpisodeID = (%d,%v), want (222,true)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDanmakuEpisodeIDSkipsInvalidIDs(t *testing.T) {
|
||||
candidates := []DanmakuAnime{
|
||||
{AnimeID: 1, AnimeTitle: "某番", Episodes: []DanmakuEpisode{
|
||||
{EpisodeID: 0, EpisodeTitle: "第1话 目标"},
|
||||
{EpisodeID: -5, EpisodeTitle: "第1话 目标"},
|
||||
{EpisodeID: 333, EpisodeTitle: "第1话 目标"},
|
||||
}},
|
||||
}
|
||||
got, ok := pickDanmakuEpisodeID(candidates, "1", "第1话 目标")
|
||||
if !ok || got != 333 {
|
||||
t.Fatalf("pickDanmakuEpisodeID = (%d,%v), want (333,true)", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -105,27 +105,359 @@ func TestDanmakuFetchHashMatchLayer(t *testing.T) {
|
||||
require.Contains(t, seen, `"matchMode":"hashAndFileName"`)
|
||||
}
|
||||
|
||||
// 第 1 层拉弹幕:配置了自定义源时优先自定义源,失败才回退官方。
|
||||
func TestDanmakuFetchHashMatchUsesConfiguredSourceFirst(t *testing.T) {
|
||||
// 第 1 层拉弹幕:官方 match 给出的 episodeId 属于官方 ID 空间,不能直接拿去
|
||||
// 请求第三方源(真实源上只会 404)。必须用官方给到的剧名+集数在配置源里重定位
|
||||
// 到配置源自己的 episodeId,再用它拉弹幕。
|
||||
func TestDanmakuFetchHashMatchRemapsEpisodeIDToConfiguredSource(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)
|
||||
// 配置源使用自己的 ID 空间:官方是 90001,配置源是 25484。
|
||||
// 副标题一致,用于跨源确认是同一集。
|
||||
const subtitle = "测试副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.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话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕A</d></i>`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"测试动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕B官方</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
|
||||
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)
|
||||
// 配置源优先:弹幕来自自定义源而非官方。
|
||||
// 弹幕取自配置源,且用的是重定位后的 ID。
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.NotContains(t, res.Raw, "弹幕B官方")
|
||||
require.Equal(t, int64(25484), res.EpisodeID)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
}
|
||||
|
||||
// 配置源能定位到该集,但返回的是空弹幕库(count=0)时必须回官方兜底:
|
||||
// 第三方目录里有条目不代表真的收录了弹幕。
|
||||
func TestDanmakuFetchHashMatchFallsBackWhenConfiguredLibraryIsEmpty(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
const subtitle = "测试副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.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话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
// 该集在配置源上存在,但弹幕为空。
|
||||
cfgMux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":0,"comments":[]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"测试动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">官方兜底弹幕</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mEmpty", "测试动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mEmpty", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "官方兜底弹幕")
|
||||
require.Equal(t, int64(90001), res.EpisodeID)
|
||||
}
|
||||
|
||||
// 同一集在配置源里命中多个来源时:自动加载第一条,其余作为可切换来源返回,
|
||||
// 让用户能在面板里直接切换,而不必重新搜索。
|
||||
func TestDanmakuFetchHashMatchReturnsAlternatives(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "多来源动画.第01话.mkv")
|
||||
|
||||
const subtitle = "同一个副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 三个来源都指向同一集(副标题与集数一致),模拟 LogVar 聚合多站。
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1,"animeTitle":"多来源动画 from dandan","episodes":[{"episodeId":101,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":2,"animeTitle":"多来源动画 from bilibili","episodes":[{"episodeId":102,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":3,"animeTitle":"多来源动画 from qq","episodes":[{"episodeId":103,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/101", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">首选来源弹幕</d></i>`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":9,"animeTitle":"多来源动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">官方弹幕</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mAlt", "多来源动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mAlt", "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 自动加载第一个来源(沿用既有取值逻辑)。
|
||||
require.Contains(t, res.Raw, "首选来源弹幕")
|
||||
require.Equal(t, int64(101), res.EpisodeID)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
|
||||
// 三个来源全部作为可切换列表返回。
|
||||
require.Len(t, res.Alternatives, 3)
|
||||
var ids []int64
|
||||
for _, a := range res.Alternatives {
|
||||
for _, e := range a.Episodes {
|
||||
ids = append(ids, e.EpisodeID)
|
||||
}
|
||||
}
|
||||
require.Equal(t, []int64{101, 102, 103}, ids)
|
||||
|
||||
// Candidates 的语义必须保持不变(这里不是"必须选择"),否则前端会停止自动加载。
|
||||
require.Empty(t, res.Candidates)
|
||||
}
|
||||
|
||||
// 只有一个来源时不应产生 alternatives,避免面板出现无意义的单条列表。
|
||||
func TestDanmakuFetchHashMatchNoAlternativesForSingleSource(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "单来源动画.第01话.mkv")
|
||||
|
||||
const subtitle = "唯一副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.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":1,"animeTitle":"单来源动画","episodes":[{"episodeId":201,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/201", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">唯一来源弹幕</d></i>`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90002,"animeId":9,"animeTitle":"单来源动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mOne", "单来源动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mOne", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "唯一来源弹幕")
|
||||
require.Empty(t, res.Alternatives)
|
||||
}
|
||||
|
||||
// 开启合并后:同一集的多个来源被合并,重复弹幕(时间+内容相同)只保留一条。
|
||||
func TestDanmakuFetchMergeSourcesCombinesAndDeduplicates(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "合并动画.第01话.mkv")
|
||||
|
||||
const subtitle = "同一副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.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":1,"animeTitle":"来源A","episodes":[{"episodeId":301,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":2,"animeTitle":"来源B","episodes":[{"episodeId":302,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
// A 与 B 各有一条重复弹幕(1.0 秒「重复弹幕」)和各自独有的一条。
|
||||
cfgMux.HandleFunc("/api/v2/comment/301", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":2,"comments":[`+
|
||||
`{"p":"1.00,1,16777215,u1","m":"重复弹幕"},`+
|
||||
`{"p":"2.00,1,16777215,u1","m":"只在A"}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/302", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":2,"comments":[`+
|
||||
`{"p":"1.00,1,16777215,u2","m":"重复弹幕"},`+
|
||||
`{"p":"3.00,1,16777215,u2","m":"只在B"}]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90003,"animeId":9,"animeTitle":"合并动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mMerge", "合并动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.FetchWithOptions(ctx, "mMerge", "", "", DanmakuFetchOptions{MergeSources: true})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, res.MergedSources, "expected both sources to be merged")
|
||||
|
||||
merged := parseDanmakuComments(res.Raw)
|
||||
require.Len(t, merged, 3, "duplicate comment must collapse: got %#v", merged)
|
||||
require.Equal(t, "重复弹幕", merged[0].Text)
|
||||
require.Equal(t, 1.0, merged[0].TimeSec)
|
||||
require.Equal(t, "只在A", merged[1].Text)
|
||||
require.Equal(t, "只在B", merged[2].Text)
|
||||
// 合并结果用 JSON 输出,前端据此选择解析分支。
|
||||
require.Equal(t, "json", res.SourceType)
|
||||
}
|
||||
|
||||
// 未开启合并时,行为与原来一致:只加载自动选中的那一个来源。
|
||||
func TestDanmakuFetchWithoutMergeLoadsSingleSource(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "不合并动画.第01话.mkv")
|
||||
|
||||
const subtitle = "同一副标题"
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.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":1,"animeTitle":"来源A","episodes":[{"episodeId":401,"episodeTitle":"第1话 `+subtitle+`"}]},`+
|
||||
`{"animeId":2,"animeTitle":"来源B","episodes":[{"episodeId":402,"episodeTitle":"第1话 `+subtitle+`"}]}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/401", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":1,"comments":[{"p":"1.00,1,16777215,u1","m":"只在A"}]}`)
|
||||
})
|
||||
cfgMux.HandleFunc("/api/v2/comment/402", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":1,"comments":[{"p":"1.00,1,16777215,u2","m":"只在B"}]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90004,"animeId":9,"animeTitle":"不合并动画","episodeTitle":"第1话 `+subtitle+`"}]}`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mNoMerge", "不合并动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mNoMerge", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, res.MergedSources)
|
||||
require.Equal(t, int64(401), res.EpisodeID)
|
||||
// 只应有自动选中来源的弹幕。
|
||||
require.Contains(t, res.Raw, "只在A")
|
||||
require.NotContains(t, res.Raw, "只在B")
|
||||
// 两个来源仍然作为可切换项返回(合并开关不影响候选列表)。
|
||||
require.Len(t, res.Alternatives, 2)
|
||||
}
|
||||
|
||||
// 合并偏好按用户持久化。
|
||||
func TestDanmakuMergeSourcesPreferencePersistsPerUser(t *testing.T) {
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userA := model.User{Username: "merge-user-a", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
userA.ID = "user-a"
|
||||
userB := model.User{Username: "merge-user-b", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
userB.ID = "user-b"
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &userA))
|
||||
require.NoError(t, svc.repo.User.Create(ctx, &userB))
|
||||
|
||||
require.False(t, svc.MergeSourcesEnabled(ctx, "user-a"))
|
||||
require.NoError(t, svc.SetMergeSources(ctx, "user-a", true))
|
||||
require.True(t, svc.MergeSourcesEnabled(ctx, "user-a"))
|
||||
// 另一个用户不受影响。
|
||||
require.False(t, svc.MergeSourcesEnabled(ctx, "user-b"))
|
||||
|
||||
// 重新读取确认已落库,且 ConfigForUser 会带出该偏好。
|
||||
cfg := svc.ConfigForUser(ctx, "user-a")
|
||||
require.True(t, cfg.MergeSources)
|
||||
require.False(t, svc.ConfigForUser(ctx, "user-b").MergeSources)
|
||||
}
|
||||
|
||||
// 配置源搜不到对应剧集时必须回退官方:用官方自身的 episodeId 请求官方,
|
||||
// 而不是拿官方 ID 去撞配置源。
|
||||
func TestDanmakuFetchHashMatchFallsBackToOfficialWhenConfiguredHasNoMatch(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "冷门动画.第01话.mkv")
|
||||
|
||||
cfgMux := http.NewServeMux()
|
||||
cfgMux.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":[]}`)
|
||||
})
|
||||
cfgSrv := httptest.NewServer(cfgMux)
|
||||
t.Cleanup(cfgSrv.Close)
|
||||
|
||||
officialMux := http.NewServeMux()
|
||||
officialMux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":90001,"animeId":1001,"animeTitle":"冷门动画","episodeTitle":"第1话 无人知晓"}]}`)
|
||||
})
|
||||
officialMux.HandleFunc("/api/v2/comment/90001", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">弹幕来自官方</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(officialMux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL))
|
||||
seedDanmakuVideoMedia(t, svc, "mNoMatch", "冷门动画", videoPath, 32000, 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mNoMatch", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Raw, "弹幕来自官方")
|
||||
require.Equal(t, int64(90001), res.EpisodeID)
|
||||
}
|
||||
|
||||
func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
|
||||
@@ -159,13 +491,14 @@ func TestDanmakuFetchHashMatchConfiguredFailsFallsBackOfficial(t *testing.T) {
|
||||
require.Contains(t, res.Raw, "弹幕官方兜底")
|
||||
}
|
||||
|
||||
// 第 1 层未命中(matches 为空)→ 第 2 层按文件名+集数搜索。
|
||||
// 第 1 层未命中时,即使官方附带模糊候选,也不能把第一条当作精准匹配;
|
||||
// 应继续走第 2 层按文件名+集数搜索。
|
||||
func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||
|
||||
cfgSrv := newDanmakuSourceServer(t) // 搜索 + 弹幕A
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":false,"matches":[]}`,
|
||||
`{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`,
|
||||
`<i></i>`, nil)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
@@ -178,8 +511,53 @@ func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
// 第 2 层命中:搜索请求按文件名进行。
|
||||
require.Contains(t, cfgSrv.lastSearch, "anime=")
|
||||
require.Equal(t, "filename", res.MatchMode)
|
||||
require.Equal(t, "测试动画", res.AnimeTitle)
|
||||
// 第 2 层命中:搜索请求按文件名进行,而不是误用官方第一条模糊候选。
|
||||
query, err := url.ParseQuery(cfgSrv.lastSearch)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, query.Get("anime"), "测试动画.第01话")
|
||||
require.NotContains(t, query.Get("anime"), "91天")
|
||||
}
|
||||
|
||||
// hash 未精确命中时,若本地已有刮削的剧名、年份、集数和集标题,应先用这些
|
||||
// 元数据锁定正确来源,而不是直接进入大量候选的手工选择。
|
||||
func TestDanmakuFetchHashMissUsesScrapedMetadata(t *testing.T) {
|
||||
videoPath, _ := writeDanmakuTestVideo(t, "local-release-name.mkv")
|
||||
cfgSrv := newDanmakuSourceServerWithSearch(t,
|
||||
`{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1,"animeTitle":"命运石之门 0(2018)【TV动画】from dandan&animeko","episodes":[{"episodeId":120140001,"episodeTitle":"【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"}]},`+
|
||||
`{"animeId":2,"animeTitle":"命运石之门(2011)【TV动画】from dandan&animeko","episodes":[{"episodeId":25484,"episodeTitle":"【dandan&animeko】 第1话 始与终的序章-Turning Point-"}]},`+
|
||||
`{"animeId":3,"animeTitle":"命运石之门(2011)【动漫】from 360","episodes":[{"episodeId":120140002,"episodeTitle":"【qq】 第1集"}]}`+
|
||||
`]}`)
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":false,"matches":[{"episodeId":120140001,"animeId":12014,"animeTitle":"91天","episodeTitle":"第1话 杀人之夜"}]}`,
|
||||
`<i></i>`, nil)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, cfgSrv.URL()))
|
||||
media := model.Media{
|
||||
Title: "命运石之门",
|
||||
Year: 2011,
|
||||
EpisodeNum: 1,
|
||||
EpisodeTitle: "起始与终结的序章",
|
||||
Path: videoPath,
|
||||
SizeBytes: 32000,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
media.ID = "mScrapedMeta"
|
||||
require.NoError(t, svc.repo.DB.Create(&media).Error)
|
||||
|
||||
res, err := svc.Fetch(ctx, media.ID, "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Equal(t, "metadata", res.MatchMode)
|
||||
require.Equal(t, "命运石之门(2011)【TV动画】from dandan&animeko", res.AnimeTitle)
|
||||
require.Equal(t, int64(25484), res.EpisodeID)
|
||||
require.Empty(t, res.Candidates)
|
||||
}
|
||||
|
||||
// strm:通过解析出的直链 Range 拉 16MB 前缀算 hash → match → 拉弹幕。
|
||||
@@ -446,3 +824,38 @@ func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
|
||||
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
|
||||
require.Contains(t, res.Raw, "降级搜索弹幕")
|
||||
}
|
||||
|
||||
// 即使刮削元数据完整,也应先走准确率最高的 hash 层。
|
||||
func TestDanmakuFetchPrefersHashOverCompleteMetadata(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)
|
||||
|
||||
source := newDanmakuSourceServerWithSearch(t,
|
||||
`{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"官方测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, source.URL()))
|
||||
|
||||
m := model.Media{
|
||||
Title: "测试动画",
|
||||
Path: videoPath,
|
||||
SizeBytes: 123,
|
||||
EpisodeNum: 1,
|
||||
EpisodeTitle: "起始与终结的序章",
|
||||
Year: 2011,
|
||||
}
|
||||
m.ID = "hash-first"
|
||||
require.NoError(t, svc.repo.DB.Create(&m).Error)
|
||||
|
||||
res, err := svc.Fetch(ctx, "hash-first", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
require.NotEmpty(t, res.Raw)
|
||||
require.Contains(t, seen, wantHash)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 弹幕合并:同一集在聚合源(如 LogVar)里常有多个库,开启合并后把这些库的
|
||||
// 弹幕一起去重后展示,而不是只显示其中一个。
|
||||
const (
|
||||
// danmakuMergeMaxSources 限制一次合并涉及的来源数量,避免把一次播放
|
||||
// 变成几十个上游请求。
|
||||
danmakuMergeMaxSources = 10
|
||||
// danmakuMergeConcurrency 限制并发抓取数,降低触发上游限流(429)的概率。
|
||||
danmakuMergeConcurrency = 4
|
||||
// danmakuMergeTimeToleranceSec 是判定「同一时间点」的容差。同一条弹幕在
|
||||
// 不同源之间可能因精度处理差上零点几秒,用容差比对;文本仍要求完全一致,
|
||||
// 因此不会把内容不同的弹幕误合。
|
||||
danmakuMergeTimeToleranceSec = 0.5
|
||||
)
|
||||
|
||||
// danmakuComment 是合并用的归一化弹幕。
|
||||
type danmakuComment struct {
|
||||
TimeSec float64
|
||||
Mode int
|
||||
Color int
|
||||
Text string
|
||||
}
|
||||
|
||||
// parseDanmakuComments 把上游载荷解析成归一化弹幕列表,兼容 dandanplay
|
||||
// JSON({comments:[{p,m}]})与 Bilibili XML(<d p="...">text</d>)两种格式。
|
||||
// 无法识别的载荷返回空列表,调用方据此跳过该来源。
|
||||
func parseDanmakuComments(raw string) []danmakuComment {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||||
if comments := parseDanmakuCommentsJSON(trimmed); len(comments) > 0 {
|
||||
return comments
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
return parseDanmakuCommentsXML(trimmed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDanmakuCommentsJSON(raw string) []danmakuComment {
|
||||
// 兼容 {comments:[...]} 与裸数组两种形态。
|
||||
var payload struct {
|
||||
Comments []struct {
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
// 少数自建源直接给结构化字段。
|
||||
Time *float64 `json:"time"`
|
||||
Text string `json:"text"`
|
||||
Mode *int `json:"mode"`
|
||||
Color *int `json:"color"`
|
||||
} `json:"comments"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
var bare []struct {
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
Time *float64 `json:"time"`
|
||||
Text string `json:"text"`
|
||||
Mode *int `json:"mode"`
|
||||
Color *int `json:"color"`
|
||||
}
|
||||
if err2 := json.Unmarshal([]byte(raw), &bare); err2 != nil {
|
||||
return nil
|
||||
}
|
||||
payload.Comments = bare
|
||||
}
|
||||
out := make([]danmakuComment, 0, len(payload.Comments))
|
||||
for _, item := range payload.Comments {
|
||||
comment, ok := buildDanmakuComment(item.P, item.M, item.Time, item.Text, item.Mode, item.Color)
|
||||
if ok {
|
||||
out = append(out, comment)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseDanmakuCommentsXML(raw string) []danmakuComment {
|
||||
var doc struct {
|
||||
Items []struct {
|
||||
P string `xml:"p,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
} `xml:"d"`
|
||||
}
|
||||
if err := xml.Unmarshal([]byte(raw), &doc); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]danmakuComment, 0, len(doc.Items))
|
||||
for _, item := range doc.Items {
|
||||
comment, ok := buildDanmakuComment(item.P, item.Text, nil, "", nil, nil)
|
||||
if ok {
|
||||
out = append(out, comment)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildDanmakuComment 从 p 串或结构化字段构造一条弹幕。p 串格式为
|
||||
// "time,mode,color,user"(dandanplay 四段式)。
|
||||
func buildDanmakuComment(p, text string, timeSec *float64, plainText string, mode, color *int) (danmakuComment, bool) {
|
||||
body := strings.TrimSpace(text)
|
||||
if body == "" {
|
||||
body = strings.TrimSpace(plainText)
|
||||
}
|
||||
if body == "" {
|
||||
return danmakuComment{}, false
|
||||
}
|
||||
comment := danmakuComment{Text: body, Mode: 1}
|
||||
if timeSec != nil {
|
||||
comment.TimeSec = *timeSec
|
||||
}
|
||||
if mode != nil && *mode > 0 {
|
||||
comment.Mode = *mode
|
||||
}
|
||||
if color != nil {
|
||||
comment.Color = *color
|
||||
}
|
||||
if fields := strings.Split(p, ","); len(fields) >= 1 {
|
||||
if t, err := strconv.ParseFloat(strings.TrimSpace(fields[0]), 64); err == nil {
|
||||
comment.TimeSec = t
|
||||
}
|
||||
if len(fields) >= 2 {
|
||||
if m, err := strconv.Atoi(strings.TrimSpace(fields[1])); err == nil && m > 0 {
|
||||
comment.Mode = m
|
||||
}
|
||||
}
|
||||
// 颜色所在位置取决于格式,用段数区分(与前端 parseBilibiliXml 的判定
|
||||
// 一致):Bilibili 的 p 是 "time,mode,fontSize,color,..."(>=5 段,
|
||||
// 颜色在第 4 段);dandanplay 的 p 是 "time,mode,color,userId"(4 段,
|
||||
// 颜色在第 3 段)。不区分会把字号当成颜色。
|
||||
colorIndex := 2
|
||||
if len(fields) >= 5 {
|
||||
colorIndex = 3
|
||||
}
|
||||
if len(fields) > colorIndex {
|
||||
if c, err := strconv.Atoi(strings.TrimSpace(fields[colorIndex])); err == nil {
|
||||
comment.Color = c
|
||||
}
|
||||
}
|
||||
}
|
||||
if math.IsNaN(comment.TimeSec) || math.IsInf(comment.TimeSec, 0) || comment.TimeSec < 0 {
|
||||
return danmakuComment{}, false
|
||||
}
|
||||
// 无颜色信息时用白色,与前端默认一致。
|
||||
if comment.Color <= 0 {
|
||||
comment.Color = 16777215
|
||||
}
|
||||
return comment, true
|
||||
}
|
||||
|
||||
// mergeDanmakuComments 合并多组弹幕并按「时间 + 内容」去重。
|
||||
//
|
||||
// 判定重复的条件:文本完全一致,且时间差在 danmakuMergeTimeToleranceSec 以内。
|
||||
// 之所以同时要求文本一致,是因为容差本身不足以区分内容;之所以需要容差,
|
||||
// 是因为同一条弹幕在不同来源间可能因精度处理差上零点几秒。
|
||||
func mergeDanmakuComments(sets [][]danmakuComment) []danmakuComment {
|
||||
merged := make([]danmakuComment, 0, 512)
|
||||
// keptTimes[文本] = 已保留的该文本时间列表,用于就近比对。
|
||||
keptTimes := make(map[string][]float64)
|
||||
for _, set := range sets {
|
||||
for _, comment := range set {
|
||||
times := keptTimes[comment.Text]
|
||||
duplicate := false
|
||||
for _, kept := range times {
|
||||
if math.Abs(kept-comment.TimeSec) <= danmakuMergeTimeToleranceSec {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if duplicate {
|
||||
continue
|
||||
}
|
||||
keptTimes[comment.Text] = append(times, comment.TimeSec)
|
||||
merged = append(merged, comment)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(merged, func(i, j int) bool { return merged[i].TimeSec < merged[j].TimeSec })
|
||||
return merged
|
||||
}
|
||||
|
||||
// encodeDanmakuComments 把合并结果编码成 dandanplay JSON,前端 parseDanmaku
|
||||
// 已支持该格式({comments:[{p,m}]})。
|
||||
func encodeDanmakuComments(comments []danmakuComment) string {
|
||||
type item struct {
|
||||
Cid int `json:"cid"`
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
T int `json:"t"`
|
||||
}
|
||||
payload := struct {
|
||||
Count int `json:"count"`
|
||||
Comments []item `json:"comments"`
|
||||
}{Count: len(comments), Comments: make([]item, 0, len(comments))}
|
||||
for i, comment := range comments {
|
||||
payload.Comments = append(payload.Comments, item{
|
||||
Cid: i + 1,
|
||||
P: fmt.Sprintf("%.2f,%d,%d,merged", comment.TimeSec, comment.Mode, comment.Color),
|
||||
M: comment.Text,
|
||||
T: int(comment.TimeSec),
|
||||
})
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseDanmakuCommentsSupportsDandanplayJson(t *testing.T) {
|
||||
raw := `{"count":2,"comments":[` +
|
||||
`{"cid":1,"p":"1.50,1,16777215,userA","m":"第一条"},` +
|
||||
`{"cid":2,"p":"2.00,5,16711680,userB","m":"顶部红字"}]}`
|
||||
comments := parseDanmakuComments(raw)
|
||||
require.Len(t, comments, 2)
|
||||
require.Equal(t, 1.5, comments[0].TimeSec)
|
||||
require.Equal(t, 1, comments[0].Mode)
|
||||
require.Equal(t, 16777215, comments[0].Color)
|
||||
require.Equal(t, "第一条", comments[0].Text)
|
||||
// 第 5 种模式是顶部弹幕,颜色 16711680 = 0xFF0000。
|
||||
require.Equal(t, 5, comments[1].Mode)
|
||||
require.Equal(t, 16711680, comments[1].Color)
|
||||
}
|
||||
|
||||
func TestParseDanmakuCommentsSupportsBilibiliXml(t *testing.T) {
|
||||
raw := `<?xml version="1.0" encoding="UTF-8"?><i>` +
|
||||
`<d p="3.25,1,25,16777215,1700000000,0,abc,def">来自 XML</d>` +
|
||||
`</i>`
|
||||
comments := parseDanmakuComments(raw)
|
||||
require.Len(t, comments, 1)
|
||||
require.Equal(t, 3.25, comments[0].TimeSec)
|
||||
require.Equal(t, "来自 XML", comments[0].Text)
|
||||
// Bilibili 的 5 段式里第 3 段是字号、第 4 段才是颜色,必须取第 4 段。
|
||||
require.Equal(t, 16777215, comments[0].Color)
|
||||
}
|
||||
|
||||
func TestParseDanmakuCommentsReturnsNilForUnsupportedPayload(t *testing.T) {
|
||||
require.Nil(t, parseDanmakuComments(""))
|
||||
require.Nil(t, parseDanmakuComments("not a danmaku payload"))
|
||||
}
|
||||
|
||||
// 同一时间 + 同一内容视为重复,只保留一条。
|
||||
func TestMergeDanmakuCommentsDeduplicatesByTimeAndText(t *testing.T) {
|
||||
setA := []danmakuComment{
|
||||
{TimeSec: 1.0, Mode: 1, Color: 16777215, Text: "哈哈"},
|
||||
{TimeSec: 5.0, Mode: 1, Color: 16777215, Text: "只有A有"},
|
||||
}
|
||||
setB := []danmakuComment{
|
||||
{TimeSec: 1.0, Mode: 1, Color: 16777215, Text: "哈哈"}, // 与 A 完全重复
|
||||
{TimeSec: 5.2, Mode: 1, Color: 16777215, Text: "只有B有"},
|
||||
}
|
||||
merged := mergeDanmakuComments([][]danmakuComment{setA, setB})
|
||||
require.Len(t, merged, 3)
|
||||
require.Equal(t, 1.0, merged[0].TimeSec)
|
||||
require.Equal(t, "哈哈", merged[0].Text)
|
||||
require.Equal(t, "只有A有", merged[1].Text)
|
||||
require.Equal(t, "只有B有", merged[2].Text)
|
||||
}
|
||||
|
||||
// 时间差在容差内且文本一致时也判为重复(跨源可能有零点几秒的精度差)。
|
||||
func TestMergeDanmakuCommentsDeduplicatesWithinTolerance(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 10.0, Mode: 1, Text: "2333"}},
|
||||
{{TimeSec: 10.3, Mode: 1, Text: "2333"}},
|
||||
})
|
||||
require.Len(t, merged, 1)
|
||||
require.Equal(t, 10.0, merged[0].TimeSec)
|
||||
}
|
||||
|
||||
// 内容相同但时间相距较远时是两条独立弹幕,不能合并。
|
||||
func TestMergeDanmakuCommentsKeepsSameTextAtDifferentTimes(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 1.0, Mode: 1, Text: "前方高能"}},
|
||||
{{TimeSec: 30.0, Mode: 1, Text: "前方高能"}},
|
||||
})
|
||||
require.Len(t, merged, 2)
|
||||
}
|
||||
|
||||
// 时间相同但内容不同也不能合并。
|
||||
func TestMergeDanmakuCommentsKeepsDifferentTextAtSameTime(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 2.0, Mode: 1, Text: "AAA"}},
|
||||
{{TimeSec: 2.0, Mode: 1, Text: "BBB"}},
|
||||
})
|
||||
require.Len(t, merged, 2)
|
||||
}
|
||||
|
||||
func TestMergeDanmakuCommentsSortsByTime(t *testing.T) {
|
||||
merged := mergeDanmakuComments([][]danmakuComment{
|
||||
{{TimeSec: 9.0, Mode: 1, Text: "后"}},
|
||||
{{TimeSec: 1.0, Mode: 1, Text: "前"}},
|
||||
})
|
||||
require.Len(t, merged, 2)
|
||||
require.Equal(t, 1.0, merged[0].TimeSec)
|
||||
require.Equal(t, 9.0, merged[1].TimeSec)
|
||||
}
|
||||
|
||||
// 编码结果必须能被前端的 dandanplay JSON 分支解析(p + m 两个字符串字段)。
|
||||
func TestEncodeDanmakuCommentsProducesDandanplayShape(t *testing.T) {
|
||||
encoded := encodeDanmakuComments([]danmakuComment{
|
||||
{TimeSec: 1.5, Mode: 5, Color: 16711680, Text: "顶部"},
|
||||
})
|
||||
var payload struct {
|
||||
Count int `json:"count"`
|
||||
Comments []struct {
|
||||
Cid int `json:"cid"`
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
T int `json:"t"`
|
||||
} `json:"comments"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(encoded), &payload))
|
||||
require.Equal(t, 1, payload.Count)
|
||||
require.Len(t, payload.Comments, 1)
|
||||
require.Equal(t, "1.50,5,16711680,merged", payload.Comments[0].P)
|
||||
require.Equal(t, "顶部", payload.Comments[0].M)
|
||||
|
||||
// 回环:编码后的载荷应能再次解析出一致的弹幕。
|
||||
roundTrip := parseDanmakuComments(encoded)
|
||||
require.Len(t, roundTrip, 1)
|
||||
require.Equal(t, 1.5, roundTrip[0].TimeSec)
|
||||
require.Equal(t, 5, roundTrip[0].Mode)
|
||||
require.Equal(t, 16711680, roundTrip[0].Color)
|
||||
require.Equal(t, "顶部", roundTrip[0].Text)
|
||||
}
|
||||
@@ -13,10 +13,14 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -57,6 +61,14 @@ type DanmakuRenderConfig struct {
|
||||
Opacity string `json:"opacity"`
|
||||
FontSize string `json:"font_size"`
|
||||
Area string `json:"area"`
|
||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
|
||||
// DanmakuFetchOptions 承载单次抓取的调用方偏好。
|
||||
type DanmakuFetchOptions struct {
|
||||
// MergeSources 为真时,同一集的多个来源会被合并去重后一起返回。
|
||||
MergeSources bool
|
||||
}
|
||||
|
||||
// DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream
|
||||
@@ -71,13 +83,21 @@ type DanmakuRenderConfig struct {
|
||||
// metadata so the player UI can display which episode was loaded.
|
||||
type DanmakuFetchResult struct {
|
||||
DanmakuRenderConfig
|
||||
SourceType string `json:"source_type"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
Candidates []DanmakuAnime `json:"candidates,omitempty"`
|
||||
SourceType string `json:"source_type"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
Candidates []DanmakuAnime `json:"candidates,omitempty"`
|
||||
// Alternatives 是「同一集的其它可选来源」。与 Candidates 语义不同:
|
||||
// Candidates 表示自动匹配不唯一、必须由用户选择后才能加载弹幕;
|
||||
// Alternatives 表示弹幕已经自动加载好了,这里额外提供同集的其它来源
|
||||
// (LogVar 聚合了多个视频网站,同一集常有多个库)供用户随时切换,
|
||||
// 不必再手动搜索一遍。
|
||||
Alternatives []DanmakuAnime `json:"alternatives,omitempty"`
|
||||
AnimeTitle string `json:"anime_title,omitempty"`
|
||||
EpisodeTitle string `json:"episode_title,omitempty"`
|
||||
EpisodeID int64 `json:"episode_id,omitempty"`
|
||||
MatchMode string `json:"match_mode,omitempty"`
|
||||
// MergedSources 表示本次结果由多少个来源合并而成(未合并时为 0)。
|
||||
MergedSources int `json:"merged_sources,omitempty"`
|
||||
}
|
||||
|
||||
// DanmakuAnime is one search hit (an anime) with its episode list, mirroring
|
||||
@@ -118,6 +138,12 @@ type DanmakuService struct {
|
||||
|
||||
hashCacheMu sync.Mutex
|
||||
hashCache map[string]string // stamp → 16MB-prefix MD5
|
||||
|
||||
// resultCache 缓存整条弹幕抓取结果,避免同一集重复播放时重走
|
||||
// 「16MB 哈希 + 上游搜索 + 评论拉取」这条高延迟链路。
|
||||
resultCacheMu sync.Mutex
|
||||
resultCache map[string]danmakuResultCacheEntry
|
||||
fetchGroup singleflight.Group
|
||||
}
|
||||
|
||||
func danmakuHTTPClient() *http.Client {
|
||||
@@ -129,10 +155,11 @@ func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuServ
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &DanmakuService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
client: danmakuHTTPClient(),
|
||||
hashCache: make(map[string]string),
|
||||
log: log,
|
||||
repo: repo,
|
||||
client: danmakuHTTPClient(),
|
||||
hashCache: make(map[string]string),
|
||||
resultCache: make(map[string]danmakuResultCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +179,121 @@ func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolv
|
||||
}
|
||||
}
|
||||
|
||||
// 弹幕抓取结果缓存:同一集在 TTL 内重复播放时直接返回,避免重复执行
|
||||
// 「16MB 前置哈希 → 上游搜索 → 评论拉取」这条高延迟链路。弹幕库内容变化
|
||||
// 很慢,而用户通常会在短时间内反复切集/回看,因此 TTL 取 6 小时。
|
||||
const (
|
||||
danmakuResultCacheTTL = 6 * time.Hour
|
||||
danmakuResultCacheMaxEntries = 64
|
||||
// 候选列表由上游搜索决定,相对稳定但可能随源更新,缓存 10 分钟。
|
||||
danmakuResultCacheCandidateTTL = 10 * time.Minute
|
||||
// 空结果可能只是上游临时抖动,只短暂缓存,避免长时间看不到弹幕。
|
||||
danmakuResultCacheEmptyTTL = time.Minute
|
||||
)
|
||||
|
||||
type danmakuResultCacheEntry struct {
|
||||
result *DanmakuFetchResult
|
||||
expiresAt time.Time
|
||||
storedAt time.Time
|
||||
}
|
||||
|
||||
func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge bool) string {
|
||||
return strings.Join([]string{
|
||||
strings.TrimSpace(source),
|
||||
mediaID,
|
||||
strings.TrimSpace(keyword),
|
||||
strings.TrimSpace(episodeID),
|
||||
strconv.FormatBool(merge),
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
func (s *DanmakuService) resultCacheGet(key string) (*DanmakuFetchResult, bool) {
|
||||
if s == nil || key == "" {
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now()
|
||||
s.resultCacheMu.Lock()
|
||||
defer s.resultCacheMu.Unlock()
|
||||
entry, ok := s.resultCache[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(s.resultCache, key)
|
||||
return nil, false
|
||||
}
|
||||
return cloneDanmakuFetchResult(entry.result), true
|
||||
}
|
||||
|
||||
func (s *DanmakuService) resultCachePut(key string, result *DanmakuFetchResult) {
|
||||
if s == nil || key == "" || result == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
s.resultCacheMu.Lock()
|
||||
defer s.resultCacheMu.Unlock()
|
||||
if s.resultCache == nil {
|
||||
s.resultCache = make(map[string]danmakuResultCacheEntry)
|
||||
}
|
||||
ttl := danmakuResultCacheTTLFor(result)
|
||||
if ttl <= 0 {
|
||||
return
|
||||
}
|
||||
if _, exists := s.resultCache[key]; !exists && len(s.resultCache) >= danmakuResultCacheMaxEntries {
|
||||
oldestKey := ""
|
||||
var oldest time.Time
|
||||
for k, entry := range s.resultCache {
|
||||
if oldestKey == "" || entry.storedAt.Before(oldest) {
|
||||
oldestKey, oldest = k, entry.storedAt
|
||||
}
|
||||
}
|
||||
delete(s.resultCache, oldestKey)
|
||||
}
|
||||
s.resultCache[key] = danmakuResultCacheEntry{
|
||||
result: cloneDanmakuFetchResult(result),
|
||||
expiresAt: now.Add(ttl),
|
||||
storedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// danmakuResultCacheTTLFor 按结果完整性选择缓存时长:拿到弹幕正文才值得
|
||||
// 长缓存;只有候选列表时短缓存;空结果只缓存一分钟。
|
||||
func danmakuResultCacheTTLFor(result *DanmakuFetchResult) time.Duration {
|
||||
if result == nil {
|
||||
return 0
|
||||
}
|
||||
if strings.TrimSpace(result.Raw) != "" {
|
||||
return danmakuResultCacheTTL
|
||||
}
|
||||
if len(result.Candidates) > 0 {
|
||||
return danmakuResultCacheCandidateTTL
|
||||
}
|
||||
return danmakuResultCacheEmptyTTL
|
||||
}
|
||||
|
||||
// cloneDanmakuFetchResult 深拷贝切片字段,避免缓存命中后调用方修改共享数据。
|
||||
func cloneDanmakuFetchResult(in *DanmakuFetchResult) *DanmakuFetchResult {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.Candidates = cloneDanmakuAnimeList(in.Candidates)
|
||||
out.Alternatives = cloneDanmakuAnimeList(in.Alternatives)
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]DanmakuAnime, len(in))
|
||||
for i, anime := range in {
|
||||
out[i] = anime
|
||||
out[i].Episodes = append([]DanmakuEpisode(nil), anime.Episodes...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Config reads danmaku settings from the runtime settings table.
|
||||
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
cfg := DanmakuRenderConfig{
|
||||
@@ -177,6 +319,41 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ConfigForUser 在全局渲染设置之外附加当前用户的个性化偏好。
|
||||
func (s *DanmakuService) ConfigForUser(ctx context.Context, userID string) DanmakuRenderConfig {
|
||||
cfg := s.Config(ctx)
|
||||
cfg.MergeSources = s.MergeSourcesEnabled(ctx, userID)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// MergeSourcesEnabled 返回该用户的弹幕合并偏好,读取失败时回退为关闭。
|
||||
func (s *DanmakuService) MergeSourcesEnabled(ctx context.Context, userID string) bool {
|
||||
if s == nil || s.repo == nil || s.repo.User == nil {
|
||||
return false
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return false
|
||||
}
|
||||
user, err := s.repo.User.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return false
|
||||
}
|
||||
return user.DanmakuMergeSources
|
||||
}
|
||||
|
||||
// SetMergeSources 持久化该用户的弹幕合并偏好。
|
||||
func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error {
|
||||
if s == nil || s.repo == nil || s.repo.User == nil {
|
||||
return errors.New("danmaku settings unavailable")
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return errors.New("missing user")
|
||||
}
|
||||
return s.repo.User.UpdateFields(ctx, userID, map[string]any{"danmaku_merge_sources": enabled})
|
||||
}
|
||||
|
||||
// Fetch retrieves danmaku for the given media. keyword overrides the
|
||||
// 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
|
||||
@@ -186,19 +363,60 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
// 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,
|
||||
// 2. scraped metadata: title + episode number + episode title, filtered by
|
||||
// release year and subtitle to disambiguate same-name seasons.
|
||||
// 3. search by the playing file's name + episode number.
|
||||
// 4. 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 /
|
||||
// 5. 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
|
||||
// and fall back to the official endpoint on failure. Only hash identification
|
||||
// always goes to the official endpoint.
|
||||
//
|
||||
// When danmaku is disabled the result carries Enabled=false so the player can
|
||||
// silently skip rendering.
|
||||
func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID string) (*DanmakuFetchResult, error) {
|
||||
return s.FetchWithOptions(ctx, mediaID, keyword, episodeID, DanmakuFetchOptions{})
|
||||
}
|
||||
|
||||
// FetchWithOptions 是 Fetch 的带偏好版本。结果按「配置源 + 媒体 + 关键词 +
|
||||
// 指定集 + 合并开关」缓存,并用 singleflight 合并并发请求,避免同一集被重复抓取。
|
||||
func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("danmaku service unavailable")
|
||||
}
|
||||
cfg := s.Config(ctx)
|
||||
if !cfg.Enabled {
|
||||
return &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}, nil
|
||||
}
|
||||
key := danmakuResultCacheKey(cfg.Source, mediaID, keyword, episodeID, opts.MergeSources)
|
||||
if cached, ok := s.resultCacheGet(key); ok {
|
||||
return cached, nil
|
||||
}
|
||||
value, err, _ := s.fetchGroup.Do(key, func() (any, error) {
|
||||
// 等待期间可能已有同一 key 的请求写入缓存。
|
||||
if cached, ok := s.resultCacheGet(key); ok {
|
||||
return cached, nil
|
||||
}
|
||||
res, err := s.fetchWithOptionsUncached(ctx, mediaID, keyword, episodeID, opts)
|
||||
if err != nil {
|
||||
// 与原实现一致:失败时仍把已填充的渲染配置/匹配信息交给调用方。
|
||||
return res, err
|
||||
}
|
||||
s.resultCachePut(key, res)
|
||||
return res, nil
|
||||
})
|
||||
res, _ := value.(*DanmakuFetchResult)
|
||||
if err != nil {
|
||||
return cloneDanmakuFetchResult(res), err
|
||||
}
|
||||
return cloneDanmakuFetchResult(res), nil
|
||||
}
|
||||
|
||||
// fetchWithOptionsUncached 是未命中缓存时执行的原始抓取流程。
|
||||
func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
|
||||
res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"}
|
||||
if !res.Enabled {
|
||||
return res, nil
|
||||
@@ -234,6 +452,12 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
}
|
||||
|
||||
target := ""
|
||||
// targetBase 是 target 所属的源。各源的 episodeId 空间互相独立,必须用
|
||||
// 产生该 ID 的源去请求弹幕,否则会拿到 404;默认沿用「配置源优先」行为。
|
||||
targetBase := configured
|
||||
// hashOfficialID 记录 hash 层的官方 episodeId。仅当配置源重定位成功时
|
||||
// 才填,用于「配置源该集无弹幕」时回官方兜底。
|
||||
hashOfficialID := int64(0)
|
||||
|
||||
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
|
||||
@@ -250,21 +474,59 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
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)
|
||||
res.AnimeTitle = matches[0].AnimeTitle
|
||||
res.EpisodeTitle = matches[0].EpisodeTitle
|
||||
res.EpisodeID = matches[0].EpisodeID
|
||||
match := matches[0]
|
||||
res.AnimeTitle = match.AnimeTitle
|
||||
res.EpisodeTitle = match.EpisodeTitle
|
||||
res.EpisodeID = match.EpisodeID
|
||||
res.MatchMode = "hash"
|
||||
// match 返回的是官方 ID 空间的 episodeId,直接拿去问第三方源
|
||||
// 只会 404(实测各源 ID 空间独立)。先用官方给到的剧名+集数
|
||||
// 在配置源里重定位到它自己的 episodeId;定位不到就整条走官方。
|
||||
if configured != "" && !sameDanmakuBase(configured, official) {
|
||||
if matched, ok := s.lookupConfiguredEpisodes(ctx, configured, match); ok {
|
||||
configuredID := firstDanmakuEpisodeID(matched)
|
||||
target = strconv.FormatInt(configuredID, 10)
|
||||
targetBase = configured
|
||||
res.EpisodeID = configuredID
|
||||
hashOfficialID = match.EpisodeID
|
||||
// 同集有多个来源时全部带上,供面板里直接切换。
|
||||
if len(matched) > 1 {
|
||||
res.Alternatives = matched
|
||||
}
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
target = strconv.FormatInt(match.EpisodeID, 10)
|
||||
targetBase = official
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
// 2) hash 未命中时,优先使用刮削后的剧名、集数和集标题匹配。
|
||||
// 该层能解决官方 hash 库未收录、但本地已经刮削出准确季度和单集标题的情况。
|
||||
if target == "" && !manualKeyword && media != nil {
|
||||
if matched, base, ok := s.lookupScrapedEpisodes(ctx, configured, official, media); ok {
|
||||
configuredID := firstDanmakuEpisodeID(matched)
|
||||
target = strconv.FormatInt(configuredID, 10)
|
||||
targetBase = base
|
||||
res.AnimeTitle = matched[0].AnimeTitle
|
||||
res.EpisodeTitle = matched[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = configuredID
|
||||
res.MatchMode = "metadata"
|
||||
if len(matched) > 1 {
|
||||
res.Alternatives = matched
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 4 层)。
|
||||
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 &&
|
||||
if candidates, base, err := s.searchCandidatesWithSource(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)
|
||||
targetBase = base
|
||||
res.AnimeTitle = candidates[0].AnimeTitle
|
||||
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
|
||||
@@ -273,10 +535,10 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
|
||||
// 4) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
|
||||
// 多结果返回候选列表交给播放器(歧义处理)。
|
||||
if target == "" {
|
||||
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
|
||||
candidates, base, err := s.searchCandidatesWithSource(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
|
||||
@@ -289,21 +551,159 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
return res, errors.New("no danmaku library found for this video")
|
||||
}
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
targetBase = base
|
||||
res.AnimeTitle = candidates[0].AnimeTitle
|
||||
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
|
||||
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
|
||||
res.MatchMode = "search"
|
||||
}
|
||||
|
||||
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
|
||||
raw, st, err := s.fetchCommentWithFallback(ctx, targetBase, 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
|
||||
}
|
||||
// 第三方目录里存在该集,不代表它真的收录了弹幕(实测部分条目返回
|
||||
// count=0)。这种「拿到空库」的情况要用官方 episodeId 再试一次,否则
|
||||
// 重定位后反而会静默变成无弹幕 —— 旧写法是靠官方 ID 撞 404 才走到官方
|
||||
// 兜底的,改用重定位就必须显式补上这一步。
|
||||
if hashOfficialID != 0 && danmakuCommentCount(raw) == 0 {
|
||||
officialTarget := strconv.FormatInt(hashOfficialID, 10)
|
||||
if officialRaw, officialType, officialErr := s.fetchCommentFromBase(ctx, official, officialTarget); officialErr == nil &&
|
||||
danmakuCommentCount(officialRaw) > 0 {
|
||||
raw, st = officialRaw, officialType
|
||||
res.EpisodeID = hashOfficialID
|
||||
} else if officialErr != nil {
|
||||
s.log.Debug("danmaku official fallback for empty configured library failed",
|
||||
zap.String("episode_id", officialTarget), zap.Error(officialErr))
|
||||
}
|
||||
}
|
||||
// 合并多来源:仅在上游确实返回了多个同集来源时才有意义。
|
||||
if opts.MergeSources && len(res.Alternatives) > 1 {
|
||||
if mergedRaw, mergedCount, ok := s.mergeAlternativeSources(ctx, targetBase, res.Alternatives, res.EpisodeID, raw); ok {
|
||||
raw, st = mergedRaw, "json"
|
||||
res.MergedSources = mergedCount
|
||||
}
|
||||
}
|
||||
res.Raw, res.SourceType = raw, st
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// mergeAlternativeSources 并发抓取同一集的多个来源,按「时间 + 内容」去重后
|
||||
// 合并成单个载荷。已有载荷(existingRaw)会被复用,避免重复请求。
|
||||
// 返回合并后的载荷、实际参与合并的来源数与是否成功。
|
||||
func (s *DanmakuService) mergeAlternativeSources(ctx context.Context, base string, alternatives []DanmakuAnime, existingID int64, existingRaw string) (string, int, bool) {
|
||||
ids := make([]int64, 0, len(alternatives)+1)
|
||||
seen := map[int64]bool{}
|
||||
if existingID > 0 {
|
||||
seen[existingID] = true
|
||||
ids = append(ids, existingID)
|
||||
}
|
||||
for _, anime := range alternatives {
|
||||
for _, ep := range anime.Episodes {
|
||||
if ep.EpisodeID <= 0 || seen[ep.EpisodeID] {
|
||||
continue
|
||||
}
|
||||
seen[ep.EpisodeID] = true
|
||||
ids = append(ids, ep.EpisodeID)
|
||||
if len(ids) >= danmakuMergeMaxSources {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(ids) >= danmakuMergeMaxSources {
|
||||
break
|
||||
}
|
||||
}
|
||||
// 少于两个来源时无需合并。
|
||||
if len(ids) < 2 {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
collected [][]danmakuComment
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, danmakuMergeConcurrency)
|
||||
)
|
||||
collect := func(raw string) {
|
||||
comments := parseDanmakuComments(raw)
|
||||
if len(comments) == 0 {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
collected = append(collected, comments)
|
||||
mu.Unlock()
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == existingID && existingRaw != "" {
|
||||
collect(existingRaw)
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(id int64) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
body, _, err := s.fetchCommentFromBase(ctx, base, strconv.FormatInt(id, 10))
|
||||
if err != nil {
|
||||
// 单个来源失败不影响整体合并,静默跳过。
|
||||
return
|
||||
}
|
||||
collect(body)
|
||||
}(id)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(collected) < 2 {
|
||||
return "", 0, false
|
||||
}
|
||||
merged := mergeDanmakuComments(collected)
|
||||
if len(merged) == 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
encoded := encodeDanmakuComments(merged)
|
||||
if encoded == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
return encoded, len(collected), true
|
||||
}
|
||||
|
||||
// fetchCommentFromBase 从单个源拉取弹幕,不做任何回退。
|
||||
func (s *DanmakuService) fetchCommentFromBase(ctx context.Context, base, target string) (raw, sourceType string, err error) {
|
||||
raw, err = s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
|
||||
if err != nil {
|
||||
return "", "auto", err
|
||||
}
|
||||
return raw, detectDanmakuSourceType(raw), nil
|
||||
}
|
||||
|
||||
// danmakuCommentCount 估算弹幕条数,用于判断某个源是否真的返回了内容。
|
||||
// 同时兼容 dandanplay JSON 与 Bilibili XML 两种载荷。
|
||||
func danmakuCommentCount(raw string) int {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "{"):
|
||||
var payload struct {
|
||||
Count int `json:"count"`
|
||||
Comments []json.RawMessage `json:"comments"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return 0
|
||||
}
|
||||
if payload.Count > 0 {
|
||||
return payload.Count
|
||||
}
|
||||
return len(payload.Comments)
|
||||
case strings.HasPrefix(trimmed, "<"):
|
||||
return strings.Count(trimmed, "<d ")
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// detectDanmakuSourceType guesses the comment payload format from its body.
|
||||
// The dandanplay protocol historically returned Bilibili-style XML, but newer
|
||||
// and self-hosted implementations return the dandanplay JSON shape
|
||||
@@ -724,7 +1124,10 @@ type danmakuMatch struct {
|
||||
|
||||
// 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.
|
||||
// candidate list only when the upstream explicitly reports a confident match;
|
||||
// empty means nothing matched. The API may return fuzzy suggestions together
|
||||
// with isMatched=false; those are not safe to auto-select because their first
|
||||
// item can belong to an unrelated anime.
|
||||
//
|
||||
// fileName must be URL-escaped: the official API rejects raw non-ASCII file
|
||||
// names with errorCode 2 (verified against the live API — QueryEscape's
|
||||
@@ -774,13 +1177,14 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s
|
||||
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Success bool `json:"success"`
|
||||
Matches []danmakuMatch `json:"matches"`
|
||||
Success bool `json:"success"`
|
||||
IsMatched bool `json:"isMatched"`
|
||||
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 {
|
||||
if !out.Success || !out.IsMatched {
|
||||
return nil, nil
|
||||
}
|
||||
return out.Matches, nil
|
||||
@@ -794,13 +1198,14 @@ func sameDanmakuBase(a, b string) bool {
|
||||
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) {
|
||||
// fetchCommentWithFallback fetches a comment library from primary first (when
|
||||
// set and different from official), falling back to the official endpoint on
|
||||
// failure. primary must be the source that issued target: episode ids are only
|
||||
// meaningful inside the source that produced them.
|
||||
func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, primary, official, target string) (raw, sourceType string, err error) {
|
||||
var bases []string
|
||||
if configured != "" && !sameDanmakuBase(configured, official) {
|
||||
bases = append(bases, configured)
|
||||
if primary != "" && !sameDanmakuBase(primary, official) {
|
||||
bases = append(bases, primary)
|
||||
}
|
||||
bases = append(bases, official)
|
||||
var lastErr error
|
||||
@@ -817,16 +1222,301 @@ func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, configure
|
||||
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) {
|
||||
// searchCandidatesWithSource searches the configured source first (when set
|
||||
// and different from official), falling back to the official endpoint on
|
||||
// failure. It also reports which base produced the hits, because the caller
|
||||
// must fetch comments from that same base — episode ids are source-local.
|
||||
func (s *DanmakuService) searchCandidatesWithSource(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, string, error) {
|
||||
if configured != "" && !sameDanmakuBase(configured, official) {
|
||||
candidates, err := s.searchCandidates(ctx, configured, name, episode)
|
||||
if err == nil {
|
||||
return candidates, nil
|
||||
return candidates, configured, 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)
|
||||
candidates, err := s.searchCandidates(ctx, official, name, episode)
|
||||
return candidates, official, err
|
||||
}
|
||||
|
||||
// danmakuEpisodeNumberRE 从「第N话 / 第N話 / 第N集」里取出集数。各源标题格式
|
||||
// 不一(有的只有集数、有的带副标题),正则只认集数标记本身。
|
||||
var danmakuEpisodeNumberRE = regexp.MustCompile(`第\s*(\d+(?:\.\d+)?)\s*[话話集]`)
|
||||
|
||||
// danmakuTitleYearRE 匹配搜索源标题里的年份,例如「命运石之门(2011)」。
|
||||
var danmakuTitleYearRE = regexp.MustCompile(`\((?:19|20)\d{2}\)`)
|
||||
|
||||
// danmakuEpisodeNumber 返回标题中的集数,取不到时返回空串。
|
||||
func danmakuEpisodeNumber(title string) string {
|
||||
m := danmakuEpisodeNumberRE.FindStringSubmatch(title)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// danmakuEpisodeSubtitle 返回「第N话」之后的副标题,用于区分同名前作/续作。
|
||||
func danmakuEpisodeSubtitle(title string) string {
|
||||
loc := danmakuEpisodeNumberRE.FindStringIndex(title)
|
||||
if loc == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(title[loc[1]:])
|
||||
}
|
||||
|
||||
// normalizeDanmakuText 去掉标点与空白,便于跨源比对副标题。注意它只做字符
|
||||
// 归一化,不剥离集数标记 —— 调用方传入的可能已经是剥离后的副标题。
|
||||
func normalizeDanmakuText(s string) []rune {
|
||||
out := make([]rune, 0, 32)
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// danmakuTitleYear 返回标题中显式标注的年份;没有年份时返回 0。
|
||||
func danmakuTitleYear(title string) int {
|
||||
m := danmakuTitleYearRE.FindString(title)
|
||||
if len(m) != 6 {
|
||||
return 0
|
||||
}
|
||||
year, _ := strconv.Atoi(m[1:5])
|
||||
return year
|
||||
}
|
||||
|
||||
// danmakuSubtitleMatches 判断两个源的副标题是否指向同一集。
|
||||
//
|
||||
// 完全一致直接判定相同,且不设长度门槛 —— 中文副标题常常只有两三个字
|
||||
// (「辛」「序曲」),但它们本身就是很强的标识。只有在做包含/前缀这类模糊
|
||||
// 比对时才要求足够长度,避免短串误配到别的集。同一集在不同源的副标题长度
|
||||
// 也可能不同(一侧带 "-Fractal Androgynous-" 之类后缀),故保留模糊分支。
|
||||
func danmakuSubtitleMatches(candidateTitle, subtitle string) bool {
|
||||
// candidateTitle 是完整标题,subtitle 已经剥离过集数标记,两者处理方式不同。
|
||||
a := normalizeDanmakuText(danmakuEpisodeSubtitle(candidateTitle))
|
||||
b := normalizeDanmakuText(subtitle)
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
if string(a) == string(b) {
|
||||
return true
|
||||
}
|
||||
const fuzzyMinRunes = 4
|
||||
if len(a) < fuzzyMinRunes || len(b) < fuzzyMinRunes {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(string(a), string(b)) || strings.Contains(string(b), string(a)) {
|
||||
return true
|
||||
}
|
||||
if danmakuRuneSimilarity(a, b) >= 0.7 {
|
||||
return true
|
||||
}
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
if n > 12 {
|
||||
n = 12
|
||||
}
|
||||
return string(a[:n]) == string(b[:n])
|
||||
}
|
||||
|
||||
// danmakuRuneSimilarity 返回两个字符串的 LCS 相似度,分母取较短长度。
|
||||
// 弹幕源的日文/英文副标题常比刮削标题更长,因此不能直接用整体编辑距离;
|
||||
// 例如「起始与终结的序章」和「始与终的序章-Turning Point-」应被视为同一集。
|
||||
func danmakuRuneSimilarity(a, b []rune) float64 {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
prev := make([]int, len(b)+1)
|
||||
for _, ra := range a {
|
||||
cur := make([]int, len(b)+1)
|
||||
for j, rb := range b {
|
||||
if ra == rb {
|
||||
cur[j+1] = prev[j] + 1
|
||||
} else if cur[j] > prev[j+1] {
|
||||
cur[j+1] = cur[j]
|
||||
} else {
|
||||
cur[j+1] = prev[j+1]
|
||||
}
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
minLen := len(a)
|
||||
if len(b) < minLen {
|
||||
minLen = len(b)
|
||||
}
|
||||
return float64(prev[len(b)]) / float64(minLen)
|
||||
}
|
||||
|
||||
// lookupConfiguredEpisodes 用官方 match 给出的「剧名 + 集数」在配置源里重新
|
||||
// 定位同一集,返回配置源里所有指向该集的结果。
|
||||
//
|
||||
// 必须重定位:各源 episodeId 空间互相独立,官方 ID(如 135500001)在第三方
|
||||
// 源上只是一个不存在的编号,直接请求必然 404。官方 match 返回的 animeTitle /
|
||||
// episodeTitle 正好提供了跨源检索所需的剧名、集数与副标题。
|
||||
//
|
||||
// 返回列表而非单条:LogVar 这类源聚合了多个视频网站,同一集常有多个库,
|
||||
// 调用方取第一条自动加载,其余作为可切换来源交给用户。
|
||||
func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configured string, m danmakuMatch) ([]DanmakuAnime, bool) {
|
||||
episodeNum := danmakuEpisodeNumber(m.EpisodeTitle)
|
||||
if episodeNum == "" || strings.TrimSpace(m.AnimeTitle) == "" {
|
||||
return nil, false
|
||||
}
|
||||
candidates, err := s.searchCandidates(ctx, configured, m.AnimeTitle, episodeNum)
|
||||
if err != nil {
|
||||
s.log.Debug("danmaku configured lookup failed",
|
||||
zap.String("source", configured),
|
||||
zap.String("anime", m.AnimeTitle),
|
||||
zap.Error(err))
|
||||
return nil, false
|
||||
}
|
||||
matched := matchDanmakuEpisodes(candidates, episodeNum, m.EpisodeTitle)
|
||||
if len(matched) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return matched, true
|
||||
}
|
||||
|
||||
// lookupScrapedEpisodes uses already-scraped media metadata to locate the
|
||||
// correct library when hash identification missed. Unlike the generic title
|
||||
// search, this path requires an explicit episode-title match and, when known,
|
||||
// a matching release year. That prevents Steins;Gate (2011) from being
|
||||
// confused with Steins;Gate 0 (2018) or other same-name entries.
|
||||
func (s *DanmakuService) lookupScrapedEpisodes(ctx context.Context, configured, official string, media *model.Media) ([]DanmakuAnime, string, bool) {
|
||||
title, episodeNum, episodeTitle, ok := scrapedDanmakuMetadata(media)
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, title, episodeNum)
|
||||
if err != nil {
|
||||
s.log.Debug("danmaku scraped metadata lookup failed",
|
||||
zap.String("title", title),
|
||||
zap.String("episode", episodeNum),
|
||||
zap.Error(err))
|
||||
return nil, "", false
|
||||
}
|
||||
matched := matchScrapedDanmakuEpisodes(candidates, title, media.Year, episodeNum, episodeTitle)
|
||||
if len(matched) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
return matched, base, true
|
||||
}
|
||||
|
||||
// scrapedDanmakuMetadata returns the query fields only when the media has
|
||||
// enough scraped information to identify a specific episode.
|
||||
func scrapedDanmakuMetadata(media *model.Media) (title, episodeNum, episodeTitle string, ok bool) {
|
||||
if media == nil {
|
||||
return "", "", "", false
|
||||
}
|
||||
title = strings.TrimSpace(media.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(media.OriginalName)
|
||||
}
|
||||
episodeTitle = strings.TrimSpace(media.EpisodeTitle)
|
||||
if title == "" || media.EpisodeNum <= 0 || episodeTitle == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
return title, strconv.Itoa(media.EpisodeNum), episodeTitle, true
|
||||
}
|
||||
|
||||
// matchScrapedDanmakuEpisodes filters title search hits by release year and
|
||||
// episode subtitle. The returned list keeps the source grouping so multiple
|
||||
// libraries for the same episode remain switchable in the player.
|
||||
func matchScrapedDanmakuEpisodes(candidates []DanmakuAnime, title string, year int, episodeNum, episodeTitle string) []DanmakuAnime {
|
||||
out := make([]DanmakuAnime, 0, len(candidates))
|
||||
for _, anime := range candidates {
|
||||
if !danmakuAnimeTitleMatches(anime.AnimeTitle, title, year) {
|
||||
continue
|
||||
}
|
||||
hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle)
|
||||
if len(hits) == 0 {
|
||||
continue
|
||||
}
|
||||
anime.Episodes = hits
|
||||
out = append(out, anime)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// danmakuAnimeTitleMatches checks the scraped title and, when present, the
|
||||
// candidate's year. A candidate with a conflicting explicit year is rejected
|
||||
// even if its title contains the scraped title.
|
||||
func danmakuAnimeTitleMatches(animeTitle, mediaTitle string, year int) bool {
|
||||
candidate := string(normalizeDanmakuText(animeTitle))
|
||||
target := string(normalizeDanmakuText(mediaTitle))
|
||||
if candidate == "" || target == "" || !strings.Contains(candidate, target) {
|
||||
return false
|
||||
}
|
||||
if year > 0 {
|
||||
if candidateYear := danmakuTitleYear(animeTitle); candidateYear > 0 && candidateYear != year {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// firstDanmakuEpisodeID 返回匹配列表里的第一条 episodeId,作为自动选中的库。
|
||||
func firstDanmakuEpisodeID(matched []DanmakuAnime) int64 {
|
||||
for _, anime := range matched {
|
||||
for _, ep := range anime.Episodes {
|
||||
if ep.EpisodeID > 0 {
|
||||
return ep.EpisodeID
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// pickDanmakuEpisodeID 从配置源的搜索结果里挑出与目标集最匹配的一条。
|
||||
//
|
||||
// 搜索按「剧名+集数」返回,但同名不同季/不同版本会同时命中,且顺序不保证
|
||||
// 正确:实测「命运石之门 第18话」首条是《命运石之门 0(2018)》、《战区88 OVA
|
||||
// 第1话》首条是《战区88(2004) TV》,两者内容都不对,只有副标题能区分。因此:
|
||||
// - 目标带副标题时,必须找到副标题一致的候选,否则放弃(宁可回退官方,
|
||||
// 也不能给用户放错番的弹幕);
|
||||
// - 目标没有副标题(如「第11话」)时,退而要求集数一致。
|
||||
func pickDanmakuEpisodeID(candidates []DanmakuAnime, episodeNum, episodeTitle string) (int64, bool) {
|
||||
if id := firstDanmakuEpisodeID(matchDanmakuEpisodes(candidates, episodeNum, episodeTitle)); id != 0 {
|
||||
return id, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// matchDanmakuEpisodes 在搜索结果里筛出所有指向目标集的结果,保留原有的番剧
|
||||
// 分组结构(只留下命中的集数),因此调用方既能取第一条自动加载,也能把整个
|
||||
// 列表作为可切换来源展示。
|
||||
func matchDanmakuEpisodes(candidates []DanmakuAnime, episodeNum, episodeTitle string) []DanmakuAnime {
|
||||
out := make([]DanmakuAnime, 0, len(candidates))
|
||||
for _, anime := range candidates {
|
||||
hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle)
|
||||
if len(hits) == 0 {
|
||||
continue
|
||||
}
|
||||
anime.Episodes = hits
|
||||
out = append(out, anime)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchDanmakuEpisodeHits 返回同一番剧里指向目标集的弹幕库。
|
||||
func matchDanmakuEpisodeHits(episodes []DanmakuEpisode, episodeNum, episodeTitle string) []DanmakuEpisode {
|
||||
subtitle := danmakuEpisodeSubtitle(episodeTitle)
|
||||
// 刮削后的 episode_title 通常只保存副标题本身(例如「起始与终结的序章」),
|
||||
// 不带「第1话」前缀。此时把它整体作为副标题参与比对。
|
||||
if subtitle == "" && danmakuEpisodeNumber(episodeTitle) == "" {
|
||||
subtitle = strings.TrimSpace(episodeTitle)
|
||||
}
|
||||
hits := make([]DanmakuEpisode, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if ep.EpisodeID <= 0 || danmakuEpisodeNumber(ep.EpisodeTitle) != episodeNum {
|
||||
continue
|
||||
}
|
||||
// 目标带副标题时必须副标题一致;没有副标题时仅凭集数匹配。
|
||||
if subtitle != "" && !danmakuSubtitleMatches(ep.EpisodeTitle, subtitle) {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, ep)
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -22,7 +24,8 @@ func newDanmakuTestService(t *testing.T) *DanmakuService {
|
||||
// 独立临时文件库,避免测试间通过共享内存库串数据。
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "danmaku-test.db")), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}))
|
||||
// 需要 users 表:弹幕合并偏好按用户存储在 user 行上。
|
||||
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}, &model.User{}))
|
||||
repos := repository.New(db)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
@@ -325,3 +328,79 @@ func TestDanmakuFetchDetectsJSONSource(t *testing.T) {
|
||||
require.Equal(t, "xml", res2.SourceType)
|
||||
require.Contains(t, res2.Raw, "弹幕A")
|
||||
}
|
||||
|
||||
// 同一集在缓存 TTL 内重复请求时不应再访问上游(搜索和评论都只发一次)。
|
||||
func TestDanmakuFetchCachesRepeatedRequests(t *testing.T) {
|
||||
var searchCalls, commentCalls int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&searchCalls, 1)
|
||||
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) {
|
||||
atomic.AddInt32(&commentCalls, 1)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">缓存测试</d></i>`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
|
||||
seedDanmakuMedia(t, svc, "cache-media", "测试动画", "", 1)
|
||||
|
||||
first, err := svc.Fetch(ctx, "cache-media", "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, first.Raw)
|
||||
|
||||
second, err := svc.Fetch(ctx, "cache-media", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.Raw, second.Raw)
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&commentCalls))
|
||||
}
|
||||
|
||||
// 并发的同一集请求应被 singleflight 合并,上游只被访问一次。
|
||||
func TestDanmakuFetchCoalescesConcurrentRequests(t *testing.T) {
|
||||
var searchCalls int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&searchCalls, 1)
|
||||
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.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">并发测试</d></i>`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
|
||||
seedDanmakuMedia(t, svc, "concurrent-media", "测试动画", "", 1)
|
||||
|
||||
const workers = 8
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := svc.Fetch(ctx, "concurrent-media", "", "")
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
|
||||
}
|
||||
|
||||
@@ -71,6 +71,54 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox
|
||||
// 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到
|
||||
// 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。
|
||||
var imageInfoTypes = []string{"Primary", "Backdrop"}
|
||||
|
||||
// ImageInfos 返回条目的图片清单,对应 Emby 的 GET /Items/{Id}/Images。
|
||||
// 客户端用它在详情页决定要加载哪些图;缺失该接口会落到 404,部分客户端
|
||||
// 因此把条目当成"无图"而放弃渲染海报。
|
||||
func (e *EmbyService) ImageInfos(ctx context.Context, id string) []map[string]any {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, 2)
|
||||
seen := map[string]bool{}
|
||||
for _, imageType := range imageInfoTypes {
|
||||
raw, err := e.ImageURL(ctx, id, imageType)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
// 非 Backdrop 类型在缺图时会回退到主图,去重避免同一张图重复出现。
|
||||
if raw == "" || seen[raw] {
|
||||
continue
|
||||
}
|
||||
seen[raw] = true
|
||||
out = append(out, map[string]any{
|
||||
"ImageType": imageType,
|
||||
"ImageIndex": 0,
|
||||
"ImageTag": id,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UserAvatarURL 返回用户头像的来源地址;用户未设置头像时返回空串。
|
||||
func (e *EmbyService) UserAvatarURL(ctx context.Context, userID string) string {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" || e == nil || e.repo == nil || e.repo.User == nil {
|
||||
return ""
|
||||
}
|
||||
user, err := e.repo.User.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(user.AvatarURL)
|
||||
}
|
||||
|
||||
// cachedLibraryCover returns a previously resolved library cover URL within TTL.
|
||||
func (e *EmbyService) cachedLibraryCover(id string) (string, bool) {
|
||||
if e == nil || strings.TrimSpace(id) == "" {
|
||||
|
||||
@@ -47,6 +47,20 @@ func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) str
|
||||
return "media:emby:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// defaultEmbyLatestCacheTTLSeconds 是 Emby「最新添加」缓存的兜底时长。
|
||||
const defaultEmbyLatestCacheTTLSeconds = 300
|
||||
|
||||
// embyLatestCacheTTLSeconds 返回「最新添加」列表的缓存时长。它刻意比通用
|
||||
// 媒体缓存更长:客户端刷新首页时会同时请求全部媒体库的 Latest(生产环境
|
||||
// 观察到 73 个并发),缓存一旦集中过期,这批请求会同时穿透并各自重建
|
||||
// payload。延长后稳态下几乎全部命中缓存,冷启动频率也随之下降。
|
||||
func (e *EmbyService) embyLatestCacheTTLSeconds() int {
|
||||
if e == nil || e.cfg == nil || e.cfg.Cache.EmbyLatestTTLSeconds < 1 {
|
||||
return defaultEmbyLatestCacheTTLSeconds
|
||||
}
|
||||
return e.cfg.Cache.EmbyLatestTTLSeconds
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaCacheTTLSeconds() int {
|
||||
if e == nil || e.cfg == nil || e.cfg.Cache.MediaTTLSeconds < 1 {
|
||||
return 90
|
||||
|
||||
@@ -140,7 +140,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
||||
out, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
||||
if err == nil && e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
return nil, err
|
||||
}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -55,26 +55,32 @@ func (p *ImageProxy) RemoveFailed(raw string) error {
|
||||
// Serve writes the requested image to w. Caller is expected to validate
|
||||
// the JWT before invoking it.
|
||||
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
// Emby 客户端用 maxWidth / maxHeight / quality 请求缩略图。不解析这些
|
||||
// 参数就会把多兆字节的原图发给客户端,移动端往往在下载中途超时。
|
||||
opts := parseImageResizeOptions(r)
|
||||
if isLocalImagePath(raw) {
|
||||
return p.serveLocalImage(w, r, raw)
|
||||
return p.serveLocalImage(w, r, raw, opts)
|
||||
}
|
||||
return p.serveRemoteImage(ctx, w, r, raw)
|
||||
return p.serveRemoteImage(ctx, w, r, raw, opts)
|
||||
}
|
||||
|
||||
func (p *ImageProxy) serveLocalImage(w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
func (p *ImageProxy) serveLocalImage(w http.ResponseWriter, r *http.Request, raw string, opts imageResizeOptions) error {
|
||||
path := filepath.Clean(raw)
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil || !p.isAllowedLocalPath(abs) {
|
||||
servePlaceholder(w)
|
||||
return nil
|
||||
}
|
||||
if opts.active() && p.serveResizedFromFile(w, r, abs, opts) {
|
||||
return nil
|
||||
}
|
||||
if !serveImageFile(w, r, filepath.Base(abs), abs, imageBrowserCacheControl) {
|
||||
servePlaceholder(w)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string, opts imageResizeOptions) error {
|
||||
u, err := p.validateURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -83,14 +89,14 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
key, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
|
||||
forceRefresh := r.URL.Query().Get("refresh") != ""
|
||||
p.removeUnusableImageCache(cachePath, failPath)
|
||||
if !forceRefresh && serveCachedImageFile(w, r, key, cachePath) {
|
||||
if !forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
|
||||
return nil
|
||||
}
|
||||
// No negative caching: a previously failed fetch is retried on every
|
||||
// subsequent request, so the image recovers as soon as upstream does.
|
||||
data, ctype, contentLength, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
|
||||
if err != nil {
|
||||
if forceRefresh && serveCachedImageFile(w, r, key, cachePath) {
|
||||
if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
@@ -100,6 +106,10 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 上游原图已落盘,缩放结果复用同一条缓存流水线。
|
||||
if opts.active() && p.serveResizedFromFile(w, r, cachePath, opts) {
|
||||
return nil
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
if contentLength != "" {
|
||||
w.Header().Set("Content-Length", contentLength)
|
||||
@@ -114,6 +124,15 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveCachedImage 在远程原图已缓存时提供服务。请求带缩放参数时优先命中
|
||||
// 缩放缓存,未命中则从已缓存的原图生成一份;缩放不可用时退回原图直出。
|
||||
func (p *ImageProxy) serveCachedImage(w http.ResponseWriter, r *http.Request, key, cachePath string, opts imageResizeOptions) bool {
|
||||
if opts.active() && p.serveResizedFromFile(w, r, cachePath, opts) {
|
||||
return true
|
||||
}
|
||||
return serveCachedImageFile(w, r, key, cachePath)
|
||||
}
|
||||
|
||||
func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
|
||||
data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
_ "golang.org/x/image/bmp" // register BMP decoder for .bmp/.tbn sidecar art
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp" // register WebP decoder for poster art
|
||||
)
|
||||
|
||||
const (
|
||||
imageResizeDefaultQuality = 90
|
||||
imageResizeMinQuality = 1
|
||||
imageResizeMaxQuality = 100
|
||||
|
||||
// imageResizeMaxSourcePixels 限制参与缩放的原图像素总量。解码一张
|
||||
// N 像素的图在内存中约需 4N 字节;没有上限时,一张异常的超大图
|
||||
// 就能在多张并发缩略图请求下打爆小内存主机。超过该上限时直接
|
||||
// 回退为原图直出,宁可不缩放也不冒 OOM 风险。
|
||||
imageResizeMaxSourcePixels = 30_000_000
|
||||
|
||||
// imageResizeCacheSubdir 存放缩放结果,与远程原图缓存分开放,
|
||||
// 便于单独清理且不与原始字节流缓存互相覆盖。
|
||||
imageResizeCacheSubdir = "resized"
|
||||
)
|
||||
|
||||
// imageResizeOptions 描述客户端通过图片 URL 查询参数请求的目标尺寸。
|
||||
// Emby / Infuse / Yamby / RodelPlayer 等客户端普遍使用
|
||||
// maxWidth / maxHeight / quality,也有客户端使用 width / height。
|
||||
type imageResizeOptions struct {
|
||||
MaxWidth int
|
||||
MaxHeight int
|
||||
Quality int
|
||||
}
|
||||
|
||||
// active 报告是否需要缩放。没有任何尺寸参数时返回 false,调用方保持
|
||||
// 原有的原图直出路径(支持 Range / ETag,行为完全不变)。
|
||||
func (o imageResizeOptions) active() bool {
|
||||
return o.MaxWidth > 0 || o.MaxHeight > 0
|
||||
}
|
||||
|
||||
// encodingQuality 返回生效的 JPEG 编码质量,缺省 90。
|
||||
func (o imageResizeOptions) encodingQuality() int {
|
||||
q := o.Quality
|
||||
if q < imageResizeMinQuality || q > imageResizeMaxQuality {
|
||||
return imageResizeDefaultQuality
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// parseImageResizeOptions 从请求查询串解析缩放参数。Emby 客户端对参数名
|
||||
// 大小写不敏感,这里逐项做 EqualFold 匹配。
|
||||
func parseImageResizeOptions(r *http.Request) imageResizeOptions {
|
||||
if r == nil || r.URL == nil {
|
||||
return imageResizeOptions{}
|
||||
}
|
||||
q := r.URL.Query()
|
||||
return imageResizeOptions{
|
||||
MaxWidth: firstPositiveQueryInt(q, "maxWidth", "width"),
|
||||
MaxHeight: firstPositiveQueryInt(q, "maxHeight", "height"),
|
||||
Quality: firstPositiveQueryInt(q, "quality"),
|
||||
}
|
||||
}
|
||||
|
||||
// firstPositiveQueryInt 按顺序返回第一个能解析为正数的查询参数。
|
||||
func firstPositiveQueryInt(q url.Values, names ...string) int {
|
||||
for _, name := range names {
|
||||
for key, values := range q {
|
||||
if !strings.EqualFold(key, name) {
|
||||
continue
|
||||
}
|
||||
for _, raw := range values {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
// Emby 客户端偶发传入 "400.0" 这类浮点字面量。
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// fitSize 按等比缩放把 srcW x srcH 装进 maxW/maxH 边界,且从不放大。
|
||||
func fitSize(srcW, srcH, maxW, maxH int) (int, int) {
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return srcW, srcH
|
||||
}
|
||||
scale := 1.0
|
||||
if maxW > 0 && srcW > maxW {
|
||||
scale = math.Min(scale, float64(maxW)/float64(srcW))
|
||||
}
|
||||
if maxH > 0 && srcH > maxH {
|
||||
scale = math.Min(scale, float64(maxH)/float64(srcH))
|
||||
}
|
||||
if scale >= 1 {
|
||||
return srcW, srcH
|
||||
}
|
||||
dstW := int(math.Round(float64(srcW) * scale))
|
||||
dstH := int(math.Round(float64(srcH) * scale))
|
||||
if dstW < 1 {
|
||||
dstW = 1
|
||||
}
|
||||
if dstH < 1 {
|
||||
dstH = 1
|
||||
}
|
||||
return dstW, dstH
|
||||
}
|
||||
|
||||
var errImageResizeTooLarge = errors.New("image source exceeds resize pixel budget")
|
||||
|
||||
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示原图
|
||||
// 本身已满足目标尺寸,调用方应直接输出原始字节(不重复编码、不损失质量)。
|
||||
func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype string, unchanged bool, err error) {
|
||||
if len(data) == 0 || !o.active() {
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return nil, "", false, errors.New("invalid image dimensions")
|
||||
}
|
||||
// 先用 DecodeConfig 判断是否需要解码整图:图已够小就零成本返回原字节。
|
||||
dstW, dstH := fitSize(cfg.Width, cfg.Height, o.MaxWidth, o.MaxHeight)
|
||||
if dstW == cfg.Width && dstH == cfg.Height {
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
if int64(cfg.Width)*int64(cfg.Height) > imageResizeMaxSourcePixels {
|
||||
return nil, "", false, errImageResizeTooLarge
|
||||
}
|
||||
|
||||
src, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
|
||||
|
||||
// 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。
|
||||
// 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明
|
||||
// 像素后统一转 JPEG —— 客户端本来就只按缩略图显示。
|
||||
if strings.EqualFold(format, "png") && !isOpaqueImage(dst) {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, dst); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
return buf.Bytes(), "image/png", false, nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: o.encodingQuality()}); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
return buf.Bytes(), "image/jpeg", false, nil
|
||||
}
|
||||
|
||||
// isOpaqueImage 逐像素确认图像不含透明像素。
|
||||
func isOpaqueImage(img *image.RGBA) bool {
|
||||
bounds := img.Bounds()
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
if _, _, _, a := img.At(x, y).RGBA(); a != 0xffff {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resizeCacheKey 生成缩放结果的缓存键,覆盖源文件身份(路径 + 大小 +
|
||||
// 修改时间)与全部影响输出的参数,源文件被替换后不会命中陈旧缩略图。
|
||||
func (o imageResizeOptions) resizeCacheKey(sourceID string, stat os.FileInfo) string {
|
||||
h := sha256.New()
|
||||
_, _ = fmt.Fprintf(h, "v1|%s|%dx%d|q%d", sourceID, o.MaxWidth, o.MaxHeight, o.encodingQuality())
|
||||
if stat != nil {
|
||||
_, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func (p *ImageProxy) resizeCachePath(key string) string {
|
||||
return filepath.Join(p.cacheDir, imageResizeCacheSubdir, key+".img")
|
||||
}
|
||||
|
||||
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
|
||||
// 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。
|
||||
// 返回 false 表示缩放不可用,调用方应回退到原图直出。
|
||||
func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request, srcPath string, o imageResizeOptions) bool {
|
||||
stat, err := os.Stat(srcPath)
|
||||
if err != nil || stat.IsDir() || stat.Size() <= 0 {
|
||||
return false
|
||||
}
|
||||
data, err := os.ReadFile(srcPath) // #nosec G304 -- srcPath comes from an allowed local path or a SHA-derived cache path.
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
out, ctype, unchanged, err := resizeImageData(data, o)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if unchanged {
|
||||
// 原图已经在目标尺寸内:直接流式输出,保留 ETag / Range 语义。
|
||||
return serveImageFile(w, r, filepath.Base(srcPath), srcPath, imageBrowserCacheControl)
|
||||
}
|
||||
|
||||
key := o.resizeCacheKey(srcPath, stat)
|
||||
cachePath := p.resizeCachePath(key)
|
||||
if serveCachedImageFile(w, r, key, cachePath) {
|
||||
return true
|
||||
}
|
||||
p.writeResizeCache(cachePath, out)
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Cache-Control", imageBrowserCacheControl)
|
||||
http.ServeContent(w, r, key, stat.ModTime(), bytes.NewReader(out))
|
||||
return true
|
||||
}
|
||||
|
||||
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
|
||||
func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) {
|
||||
dir := filepath.Dir(cachePath)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
p.warn("imageproxy: resize cache mkdir failed", err)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
tmp, err := os.CreateTemp(dir, "resized-*.tmp")
|
||||
if err != nil {
|
||||
p.warn("imageproxy: resize cache temp failed", err)
|
||||
return
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
return
|
||||
}
|
||||
_ = tmp.Close()
|
||||
if err := os.Rename(tmp.Name(), cachePath); err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ImageProxy) warn(msg string, err error) {
|
||||
if p == nil || p.log == nil {
|
||||
return
|
||||
}
|
||||
p.log.Warn(msg, zap.Error(err))
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// encodeTestPNG 生成一张结构规则、易于压缩的测试用 PNG。
|
||||
func encodeTestPNG(t *testing.T, w, h int, alpha uint8) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x % 16 * 16), G: uint8(y % 16 * 16), B: 200, A: alpha})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode test png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsReadsEmbyParams(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/emby/Items/x/Images/Primary?maxWidth=400&maxHeight=600&quality=80", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.MaxWidth != 400 || o.MaxHeight != 600 || o.Quality != 80 {
|
||||
t.Fatalf("unexpected options: %+v", o)
|
||||
}
|
||||
if !o.active() {
|
||||
t.Fatal("expected options to be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsIsCaseInsensitive(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/x?MaxWidth=250&QUALITY=70", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.MaxWidth != 250 {
|
||||
t.Fatalf("MaxWidth = %d, want 250", o.MaxWidth)
|
||||
}
|
||||
if o.Quality != 70 {
|
||||
t.Fatalf("Quality = %d, want 70", o.Quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsAcceptsWidthAndHeightAliases(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/x?width=320&height=180", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.MaxWidth != 320 || o.MaxHeight != 180 {
|
||||
t.Fatalf("unexpected options: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageResizeOptionsInactiveWithoutDimensions(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/x?quality=90&tag=abc", nil)
|
||||
o := parseImageResizeOptions(r)
|
||||
if o.active() {
|
||||
t.Fatalf("expected inactive options, got %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitSizePreservesAspectAndNeverUpscales(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
srcW, srcH, maxW, maxH int
|
||||
wantW, wantH int
|
||||
}{
|
||||
{"max-width-only", 529, 911, 400, 0, 400, 689},
|
||||
{"max-height-only", 529, 911, 0, 500, 290, 500},
|
||||
{"never-upscales", 529, 911, 2000, 2000, 529, 911},
|
||||
{"both-bounds", 1000, 500, 400, 400, 400, 200},
|
||||
{"exact-size", 400, 600, 400, 600, 400, 600},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotW, gotH := fitSize(tc.srcW, tc.srcH, tc.maxW, tc.maxH)
|
||||
if gotW != tc.wantW || gotH != tc.wantH {
|
||||
t.Fatalf("fitSize(%d,%d,%d,%d) = %dx%d, want %dx%d",
|
||||
tc.srcW, tc.srcH, tc.maxW, tc.maxH, gotW, gotH, tc.wantW, tc.wantH)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataScalesDownAndReencodesAsJPEG(t *testing.T) {
|
||||
data := encodeTestPNG(t, 529, 911, 255)
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, Quality: 90})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if unchanged {
|
||||
t.Fatal("expected the image to be resized")
|
||||
}
|
||||
if ctype != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", ctype)
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode resized: %v", err)
|
||||
}
|
||||
if cfg.Width != 400 || cfg.Height != 689 {
|
||||
t.Fatalf("resized to %dx%d, want 400x689", cfg.Width, cfg.Height)
|
||||
}
|
||||
if format != "jpeg" {
|
||||
t.Fatalf("format = %q, want jpeg", format)
|
||||
}
|
||||
// 这里只断言"重新编码生效且输出可解码"。合成图的压缩率不代表真实海报
|
||||
// (规则色块 PNG 极小,而 JPEG 压规则图案反而更大);真实海报的体积
|
||||
// 收益在服务器上用线上素材实测。
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected a non-empty resized image")
|
||||
}
|
||||
if len(out) == len(data) {
|
||||
t.Fatal("expected the resized image to be re-encoded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataLeavesImagesWithinBoundsUntouched(t *testing.T) {
|
||||
data := encodeTestPNG(t, 200, 300, 255)
|
||||
out, _, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if !unchanged {
|
||||
t.Fatal("expected an image already within bounds to be returned unchanged")
|
||||
}
|
||||
if !bytes.Equal(out, data) {
|
||||
t.Fatal("expected the original bytes to be returned verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataKeepsTransparencyAsPNG(t *testing.T) {
|
||||
data := encodeTestPNG(t, 600, 900, 128)
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 300, Quality: 90})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if unchanged {
|
||||
t.Fatal("expected the image to be resized")
|
||||
}
|
||||
if ctype != "image/png" {
|
||||
t.Fatalf("content type = %q, want image/png so transparency is preserved", ctype)
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode resized: %v", err)
|
||||
}
|
||||
if format != "png" {
|
||||
t.Fatalf("format = %q, want png", format)
|
||||
}
|
||||
if cfg.Width != 300 || cfg.Height != 450 {
|
||||
t.Fatalf("resized to %dx%d, want 300x450", cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageResizeOptionsCacheKeyVariesWithParametersAndSource(t *testing.T) {
|
||||
stat, err := os.Stat("image_resize_test.go")
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
base := imageResizeOptions{MaxWidth: 400, Quality: 90}
|
||||
if base.resizeCacheKey("a", stat) != base.resizeCacheKey("a", stat) {
|
||||
t.Fatal("cache key must be stable for identical inputs")
|
||||
}
|
||||
if base.resizeCacheKey("a", stat) == base.resizeCacheKey("b", stat) {
|
||||
t.Fatal("cache key must differ between sources")
|
||||
}
|
||||
if base.resizeCacheKey("a", stat) == (imageResizeOptions{MaxWidth: 200, Quality: 90}).resizeCacheKey("a", stat) {
|
||||
t.Fatal("cache key must differ when max width changes")
|
||||
}
|
||||
if base.resizeCacheKey("a", stat) == (imageResizeOptions{MaxWidth: 400, Quality: 60}).resizeCacheKey("a", stat) {
|
||||
t.Fatal("cache key must differ when quality changes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageResizeOptionsEncodingQualityFallsBackToDefault(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in int
|
||||
want int
|
||||
}{
|
||||
{0, imageResizeDefaultQuality},
|
||||
{-5, imageResizeDefaultQuality},
|
||||
{500, imageResizeDefaultQuality},
|
||||
{1, 1},
|
||||
{100, 100},
|
||||
{75, 75},
|
||||
} {
|
||||
if got := (imageResizeOptions{Quality: tc.in}).encodingQuality(); got != tc.want {
|
||||
t.Fatalf("encodingQuality(%d) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileCachesScaledResult(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mediaDir := dir + string(os.PathSeparator) + "media"
|
||||
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
src := mediaDir + string(os.PathSeparator) + "poster.png"
|
||||
if err := os.WriteFile(src, encodeTestPNG(t, 529, 911, 255), 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
opts := imageResizeOptions{MaxWidth: 400, Quality: 90}
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(first, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
|
||||
t.Fatal("expected serveResizedFromFile to handle the request")
|
||||
}
|
||||
if got := first.Header().Get("Content-Type"); got != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", got)
|
||||
}
|
||||
|
||||
// 第二次请求应命中磁盘缓存,返回与首次完全相同的字节。
|
||||
second := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(second, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
|
||||
t.Fatal("expected second call to be served")
|
||||
}
|
||||
if !bytes.Equal(first.Body.Bytes(), second.Body.Bytes()) {
|
||||
t.Fatal("expected the cached scaled image to be reused")
|
||||
}
|
||||
if len(first.Body.Bytes()) == 0 {
|
||||
t.Fatal("expected a non-empty body")
|
||||
}
|
||||
}
|
||||
@@ -74,12 +74,15 @@ func localPosterCandidates(mediaPath string) []string {
|
||||
add(base + "-cover")
|
||||
add(base + ".cover")
|
||||
add(base)
|
||||
}
|
||||
for _, name := range []string{"poster", "folder", "cover", "movie", "show"} {
|
||||
add(name)
|
||||
}
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
add(base + "-thumb")
|
||||
add(base + ".thumb")
|
||||
}
|
||||
for _, name := range []string{"poster", "folder", "cover", "movie", "show", "thumb"} {
|
||||
add(name)
|
||||
}
|
||||
add("thumb")
|
||||
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
|
||||
}
|
||||
|
||||
@@ -155,7 +158,7 @@ func firstExistingImage(dir string, names ...string) string {
|
||||
return ""
|
||||
}
|
||||
for _, name := range names {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img"} {
|
||||
path := filepath.Join(dir, name+ext)
|
||||
if fileExists(path) {
|
||||
return filepath.Clean(path)
|
||||
@@ -218,7 +221,7 @@ func firstExistingPosterImage(dir string, names ...string) string {
|
||||
if isRejectedPosterName(name) {
|
||||
continue
|
||||
}
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img"} {
|
||||
path := filepath.Join(dir, name+ext)
|
||||
if fileExists(path) && likelyPosterImage(path) {
|
||||
return filepath.Clean(path)
|
||||
@@ -237,7 +240,7 @@ func firstAdultLooseImage(dir, kind string) string {
|
||||
fallback := []string{}
|
||||
for _, path := range matches {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" && ext != ".gif" && ext != ".bmp" && ext != ".tbn" {
|
||||
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" && ext != ".gif" && ext != ".bmp" && ext != ".tbn" && ext != ".img" {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), ext))
|
||||
|
||||
@@ -417,3 +417,22 @@ func TestReadLocalMetadataArtworkFallbackOnGarbageShowNFO(t *testing.T) {
|
||||
t.Fatalf("expected episode artwork fallback, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalMetadataFindsLegacyImgPoster(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "Jigokuraku.S01E14.mkv.strm")
|
||||
poster := filepath.Join(root, "Jigokuraku.S01E14-poster.img")
|
||||
if err := os.WriteFile(mediaPath, []byte("https://example.test/video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(poster, testJPEG, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.PosterURL != poster {
|
||||
t.Fatalf("PosterURL = %q, want legacy .img poster %q", got.PosterURL, poster)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// strmPathAliases returns sibling STRM names that can represent the same media
|
||||
// item when keep_ext is toggled on or off.
|
||||
//
|
||||
// Both directions are supported:
|
||||
//
|
||||
// foo.strm <-> foo.mkv.strm
|
||||
// foo.strm <-> foo.mp4.strm
|
||||
//
|
||||
// The caller must still verify that the aliases are no longer present on disk.
|
||||
// keep_ext intentionally allows foo.mkv.strm and foo.mp4.strm to coexist as
|
||||
// two real versions, so an existing sibling must never be absorbed.
|
||||
func strmPathAliases(path string) []string {
|
||||
clean := filepath.Clean(strings.TrimSpace(path))
|
||||
if clean == "" || clean == "." || !strings.EqualFold(filepath.Ext(clean), ".strm") {
|
||||
return nil
|
||||
}
|
||||
base := mediaSidecarBase(clean)
|
||||
if base == "" {
|
||||
return nil
|
||||
}
|
||||
name := filepath.Base(clean)
|
||||
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
lastExt := strings.ToLower(filepath.Ext(stem))
|
||||
_, hasVideoExt := videoExtensions[lastExt]
|
||||
hasVideoExt = hasVideoExt && !strings.EqualFold(lastExt, ".strm")
|
||||
|
||||
dir := filepath.Dir(clean)
|
||||
out := make([]string, 0, len(videoExtensions)+1)
|
||||
seen := make(map[string]struct{}, len(videoExtensions)+1)
|
||||
add := func(candidate string) {
|
||||
candidate = filepath.Clean(candidate)
|
||||
if candidate == "" || candidate == "." || samePath(candidate, clean) {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(candidate)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
if hasVideoExt {
|
||||
// Current keep_ext shape: the only legacy shape is the stripped name.
|
||||
add(filepath.Join(dir, base+".strm"))
|
||||
return out
|
||||
}
|
||||
// Stripped shape: an old keep_ext file may use any configured video ext.
|
||||
exts := make([]string, 0, len(videoExtensions))
|
||||
for ext := range videoExtensions {
|
||||
if strings.EqualFold(ext, ".strm") {
|
||||
continue
|
||||
}
|
||||
exts = append(exts, strings.ToLower(ext))
|
||||
}
|
||||
sort.Strings(exts)
|
||||
for _, ext := range exts {
|
||||
add(filepath.Join(dir, base+ext+".strm"))
|
||||
if upper := strings.ToUpper(ext); upper != ext {
|
||||
add(filepath.Join(dir, base+upper+".strm"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// missingSTRMPathAliases keeps only aliases which no longer exist on disk.
|
||||
// This is the key protection for keep_ext=true multi-version libraries: an
|
||||
// existing foo.mp4.strm is a real second version, not a rename tombstone.
|
||||
func missingSTRMPathAliases(path string) []string {
|
||||
aliases := strmPathAliases(path)
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(aliases))
|
||||
for _, alias := range aliases {
|
||||
if _, err := os.Lstat(alias); err == nil {
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
out = append(out, alias)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func liveSTRMPathAlias(path string) bool {
|
||||
for _, alias := range strmPathAliases(path) {
|
||||
if info, err := os.Stat(alias); err == nil && !info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -301,16 +301,8 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
||||
if betterSeriesLinkMedia(item, card.LinkMedia) {
|
||||
card.LinkMedia = item
|
||||
}
|
||||
currentArtwork := seriesArtworkScore(item)
|
||||
representativeArtwork := seriesArtworkScore(card.Rep)
|
||||
if currentArtwork > representativeArtwork {
|
||||
if betterSeriesRepresentative(item, card.Rep) {
|
||||
card.Rep = item
|
||||
} else if currentArtwork == representativeArtwork {
|
||||
cur := item.SeasonNum*10000 + item.EpisodeNum
|
||||
rep := card.Rep.SeasonNum*10000 + card.Rep.EpisodeNum
|
||||
if cur > 0 && (rep == 0 || cur < rep) {
|
||||
card.Rep = item
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -335,6 +327,27 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
||||
return cards
|
||||
}
|
||||
|
||||
func betterSeriesRepresentative(candidate, current model.Media) bool {
|
||||
// A theatrical feature can have local poster.jpg/background.jpg files and
|
||||
// therefore a higher artwork score than its TV episodes. Keep the TV row as
|
||||
// the visible identity of a mixed series card so a movie cannot hijack the
|
||||
// series title, overview, and artwork.
|
||||
candidateTheatrical := mediaLooksLikeTheatricalFeature(&candidate)
|
||||
currentTheatrical := mediaLooksLikeTheatricalFeature(¤t)
|
||||
if candidateTheatrical != currentTheatrical {
|
||||
return !candidateTheatrical
|
||||
}
|
||||
|
||||
currentArtwork := seriesArtworkScore(candidate)
|
||||
representativeArtwork := seriesArtworkScore(current)
|
||||
if currentArtwork != representativeArtwork {
|
||||
return currentArtwork > representativeArtwork
|
||||
}
|
||||
cur := candidate.SeasonNum*10000 + candidate.EpisodeNum
|
||||
rep := current.SeasonNum*10000 + current.EpisodeNum
|
||||
return cur > 0 && (rep == 0 || cur < rep)
|
||||
}
|
||||
|
||||
func seriesMediaTime(media model.Media) time.Time {
|
||||
if releaseDate := strings.TrimSpace(media.ReleaseDate); releaseDate != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", releaseDate); err == nil {
|
||||
|
||||
@@ -67,17 +67,20 @@ func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver {
|
||||
|
||||
func (r mediaSeriesKeyResolver) key(media model.Media) string {
|
||||
if mediaLooksEpisodicForGrouping(media) {
|
||||
if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
}
|
||||
if pathKey := mediaSeriesRawKey(media); strings.HasPrefix(pathKey, "library-path") {
|
||||
if titleKey := r.pathTitles[pathKey]; titleKey != "" {
|
||||
return compactSeriesKey(titleKey)
|
||||
}
|
||||
// A series directory is the strongest identity for mixed rows:
|
||||
// main episodes and specials (CM/NCOP/PV/OVA) may be scraped to
|
||||
// slightly different titles, but they still belong to one show.
|
||||
if r.pathCounts[pathKey] > 1 {
|
||||
return compactSeriesKey(pathKey)
|
||||
}
|
||||
}
|
||||
if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
}
|
||||
if key := repeatedSeriesExternalKey(media); key != "" && r.externalCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ func TestListRecentSeriesCardsCountsAllEpisodesInSeries(t *testing.T) {
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("recent cards = %#v, want one series card", cards)
|
||||
}
|
||||
if cards[0].Count != 40 {
|
||||
t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count)
|
||||
}
|
||||
expectedLastAdded := now.Add(40 * time.Minute)
|
||||
if cards[0].LastAddedAt == nil || !cards[0].LastAddedAt.Equal(expectedLastAdded) {
|
||||
t.Fatalf("recent series LastAddedAt = %v, want %v", cards[0].LastAddedAt, expectedLastAdded)
|
||||
}
|
||||
if cards[0].Count != 40 {
|
||||
t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count)
|
||||
}
|
||||
expectedLastAdded := now.Add(40 * time.Minute)
|
||||
if cards[0].LastAddedAt == nil || !cards[0].LastAddedAt.Equal(expectedLastAdded) {
|
||||
t.Fatalf("recent series LastAddedAt = %v, want %v", cards[0].LastAddedAt, expectedLastAdded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) {
|
||||
main := model.Media{
|
||||
@@ -377,6 +377,51 @@ func TestGroupMediaSeriesCardsMergesPollutedEpisodeFoldersBySharedShowID(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsKeepsMixedTitlesInSameEpisodicDirectoryTogether(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "main-1"},
|
||||
LibraryID: "anime",
|
||||
Title: "住在拔作岛上的我应该如何是好?",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][01][AVC_2×FLAC].mkv.strm`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "main-2"},
|
||||
LibraryID: "anime",
|
||||
Title: "住在拔作岛上的我应该如何是好?",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][02][AVC_2×FLAC].mkv.strm`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "special-1"},
|
||||
LibraryID: "anime",
|
||||
Title: "nukitashi",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][CM_01][AVC_FLAC].mkv.strm`,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "special-2"},
|
||||
LibraryID: "anime",
|
||||
Title: "nukitashi",
|
||||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][PV_01][AVC_FLAC].mkv.strm`,
|
||||
ScrapeStatus: "matched",
|
||||
},
|
||||
}
|
||||
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("cards=%#v, want main episodes and specials in the same directory folded into one card", cards)
|
||||
}
|
||||
if cards[0].Count != 4 {
|
||||
t.Fatalf("series count=%d, want 4 items", cards[0].Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsKeepsMovieVersionsAsOneMovie(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{
|
||||
@@ -410,15 +455,19 @@ func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△",
|
||||
Path: `/media/动漫/摇曳露营△ (2018)/Season 01/摇曳露营△.S01E01.mkv`,
|
||||
PosterURL: "https://image.tmdb.org/t/p/w500/episode.jpg",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
theatrical := model.Media{
|
||||
Base: model.Base{ID: "theatrical"},
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△ 剧场版",
|
||||
Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
|
||||
TMDbID: 566466,
|
||||
Base: model.Base{ID: "theatrical"},
|
||||
LibraryID: "anime",
|
||||
Title: "摇曳露营△ 剧场版",
|
||||
Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
|
||||
PosterURL: "/media/动漫/摇曳露营△ (2018)/剧场版/poster.jpg",
|
||||
BackdropURL: "/media/动漫/摇曳露营△ (2018)/剧场版/background.jpg",
|
||||
Overview: "剧场版简介",
|
||||
TMDbID: 566466,
|
||||
}
|
||||
|
||||
cards := groupMediaSeriesCards([]model.Media{episode, theatrical})
|
||||
@@ -428,6 +477,12 @@ func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
|
||||
if cards[0].Count != 2 {
|
||||
t.Fatalf("series card count=%d, want TV episode plus theatrical movie", cards[0].Count)
|
||||
}
|
||||
if cards[0].Rep.ID != episode.ID {
|
||||
t.Fatalf("series representative=%q, want TV episode %q", cards[0].Rep.ID, episode.ID)
|
||||
}
|
||||
if cards[0].Rep.Title != episode.Title {
|
||||
t.Fatalf("series representative title=%q, want %q", cards[0].Rep.Title, episode.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) {
|
||||
@@ -493,11 +548,11 @@ func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory
|
||||
{LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`},
|
||||
{LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`},
|
||||
}
|
||||
cdCards := groupMediaSeriesCards(cdItems)
|
||||
if len(cdCards) != 1 {
|
||||
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
|
||||
}
|
||||
cdCards := groupMediaSeriesCards(cdItems)
|
||||
if len(cdCards) != 1 {
|
||||
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
|
||||
@@ -43,7 +43,7 @@ var artworkSidecarSuffixes = []string{
|
||||
// artworkSidecarExtensions are the image extensions a sidecar may use. Both
|
||||
// "base-poster.jpg" (bare separator) and "base.poster.jpg" (dotted separator)
|
||||
// rely on suffix matching, so we probe the common image extensions.
|
||||
var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"}
|
||||
var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img"}
|
||||
|
||||
// transferSidecarArtwork moves/copies/links the scraped poster/backdrop
|
||||
// sidecar files alongside its media using the same transfer mode, mirroring
|
||||
@@ -84,6 +84,7 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
|
||||
if dstBase == "" {
|
||||
dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia))
|
||||
}
|
||||
srcAdultCode := AdultCodeFromMediaPath(srcMedia)
|
||||
for _, src := range sources {
|
||||
name := filepath.Base(src)
|
||||
// Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem.
|
||||
@@ -97,7 +98,10 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
|
||||
}
|
||||
}
|
||||
dstName := name
|
||||
if suffix != "" && dstBase != "" {
|
||||
if srcAdultCode != "" && strings.HasPrefix(strings.ToLower(name), strings.ToLower(srcAdultCode)) {
|
||||
// Adult sidecars are keyed by code (e.g. ADN-188-poster.jpg) and should
|
||||
// follow the media without being renamed to the display title stem.
|
||||
} else if suffix != "" && dstBase != "" {
|
||||
dstName = dstBase + suffix
|
||||
}
|
||||
dst := filepath.Join(dstDir, dstName)
|
||||
|
||||
@@ -27,12 +27,13 @@ func NewProfileService(log *zap.Logger, repo *repository.Container) *ProfileServ
|
||||
// ProfileUpdate is the patch object accepted by UpdateProfile. Empty
|
||||
// fields are ignored so the same payload can be reused across screens.
|
||||
type ProfileUpdate struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
HideAdult *bool `json:"hide_adult,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
HideAdult *bool `json:"hide_adult,omitempty"`
|
||||
SubtitleChineseMode *string `json:"subtitle_chinese_mode,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProfile applies a non-credential patch to the user.
|
||||
@@ -73,6 +74,15 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch
|
||||
if patch.HideAdult != nil {
|
||||
updates["hide_adult"] = *patch.HideAdult
|
||||
}
|
||||
if patch.SubtitleChineseMode != nil {
|
||||
mode := strings.ToLower(strings.TrimSpace(*patch.SubtitleChineseMode))
|
||||
switch mode {
|
||||
case "original", "simplified", "traditional":
|
||||
updates["subtitle_chinese_mode"] = mode
|
||||
default:
|
||||
return nil, errors.New("subtitle_chinese_mode must be original, simplified, or traditional")
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||
Updates(updates).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestProfileSubtitleChineseModePersistsAndValidates(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
user := &model.User{
|
||||
Username: "subtitle-viewer",
|
||||
PasswordHash: "hash",
|
||||
Role: "user",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
traditional := "traditional"
|
||||
updated, err := svc.UpdateProfile(t.Context(), user.ID, ProfileUpdate{
|
||||
SubtitleChineseMode: &traditional,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.SubtitleChineseMode != traditional {
|
||||
t.Fatalf("subtitle mode = %q, want %q", updated.SubtitleChineseMode, traditional)
|
||||
}
|
||||
|
||||
invalid := "automatic"
|
||||
if _, err := svc.UpdateProfile(t.Context(), user.ID, ProfileUpdate{
|
||||
SubtitleChineseMode: &invalid,
|
||||
}); err == nil {
|
||||
t.Fatal("invalid subtitle mode should be rejected")
|
||||
}
|
||||
|
||||
reloaded, err := repos.User.FindByID(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SubtitleChineseMode != traditional {
|
||||
t.Fatalf("invalid update changed subtitle mode to %q", reloaded.SubtitleChineseMode)
|
||||
}
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func (s *ScannerService) writeLocalScanMedia(in localScanWriteInput) {
|
||||
in.writeBatch.AddWithAfter(in.path, in.media, in.after)
|
||||
return
|
||||
}
|
||||
if err := s.repo.Media.Upsert(in.ctx, in.media); err != nil {
|
||||
if err := s.repo.Media.UpsertWithAliases(in.ctx, in.media, missingSTRMPathAliases(in.path)); err != nil {
|
||||
addScanError(in.res, in.path, err)
|
||||
s.log.Warn("upsert media failed", zap.String("path", in.path), zap.Error(err))
|
||||
return
|
||||
@@ -252,8 +252,10 @@ func (s *ScannerService) duplicateByFileID(ctx context.Context, fileID, path str
|
||||
}
|
||||
|
||||
func (s *ScannerService) mediaPathExists(ctx context.Context, path string) bool {
|
||||
paths := []string{path}
|
||||
paths = append(paths, missingSTRMPathAliases(path)...)
|
||||
var count int64
|
||||
err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Media{}).
|
||||
Where("path = ?", path).Count(&count).Error
|
||||
Where("path IN ?", paths).Count(&count).Error
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
type localMediaWriteBatch struct {
|
||||
@@ -18,9 +19,10 @@ type localMediaWriteBatch struct {
|
||||
}
|
||||
|
||||
type localMediaWriteItem struct {
|
||||
path string
|
||||
media *model.Media
|
||||
after func()
|
||||
path string
|
||||
media *model.Media
|
||||
aliases []string
|
||||
after func()
|
||||
}
|
||||
|
||||
func newLocalMediaWriteBatch(scanner *ScannerService, ctx context.Context, res *ScanResult, limit int) *localMediaWriteBatch {
|
||||
@@ -41,7 +43,12 @@ func (b *localMediaWriteBatch) AddWithAfter(path string, media *model.Media, aft
|
||||
if media.ScrapeStatus == "" {
|
||||
media.ScrapeStatus = "pending"
|
||||
}
|
||||
b.items = append(b.items, localMediaWriteItem{path: path, media: media, after: after})
|
||||
b.items = append(b.items, localMediaWriteItem{
|
||||
path: path,
|
||||
media: media,
|
||||
aliases: missingSTRMPathAliases(path),
|
||||
after: after,
|
||||
})
|
||||
if len(b.items) >= b.limit {
|
||||
b.Flush()
|
||||
}
|
||||
@@ -53,39 +60,35 @@ func (b *localMediaWriteBatch) Flush() {
|
||||
}
|
||||
items := b.items
|
||||
b.items = nil
|
||||
media := make([]model.Media, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.media != nil {
|
||||
media = append(media, *item.media)
|
||||
}
|
||||
}
|
||||
if len(media) == 0 {
|
||||
return
|
||||
}
|
||||
existingPaths := b.existingPaths(items)
|
||||
upsertItems := make([]*model.Media, 0, len(items))
|
||||
upsertAfter := make([]func(), 0, len(items))
|
||||
|
||||
existingPaths, lookupOK := b.existingPathOrAliasSet(items)
|
||||
upsertItems := make([]localMediaWriteItem, 0, len(items))
|
||||
createItems := make([]localMediaWriteItem, 0, len(items))
|
||||
createMedia := make([]model.Media, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.media == nil {
|
||||
continue
|
||||
}
|
||||
if existingPaths[filepath.Clean(item.media.Path)] {
|
||||
// 已存在行:攒起来在一个事务里逐条 upsert(一批一次提交)。
|
||||
after := item.after
|
||||
upsertItems = append(upsertItems, item.media)
|
||||
upsertAfter = append(upsertAfter, after)
|
||||
// If the alias lookup failed, route through UpsertWithAliases instead of
|
||||
// direct-create. That is slower but cannot create a duplicate row.
|
||||
if !lookupOK || mediaPathOrAliasExists(item.media.Path, item.aliases, existingPaths) {
|
||||
upsertItems = append(upsertItems, item)
|
||||
continue
|
||||
}
|
||||
createItems = append(createItems, item)
|
||||
createMedia = append(createMedia, *item.media)
|
||||
}
|
||||
b.flushUpserts(items, upsertItems, upsertAfter)
|
||||
if len(createMedia) == 0 {
|
||||
|
||||
b.flushUpserts(upsertItems)
|
||||
if len(createItems) == 0 {
|
||||
b.publish()
|
||||
return
|
||||
}
|
||||
|
||||
createMedia := make([]model.Media, 0, len(createItems))
|
||||
for _, item := range createItems {
|
||||
if item.media != nil {
|
||||
createMedia = append(createMedia, *item.media)
|
||||
}
|
||||
}
|
||||
if err := b.scanner.repo.DB.WithContext(b.ctx).CreateInBatches(&createMedia, b.limit).Error; err == nil {
|
||||
b.res.Added += len(createMedia)
|
||||
for _, item := range createItems {
|
||||
@@ -96,12 +99,13 @@ func (b *localMediaWriteBatch) Flush() {
|
||||
b.publish()
|
||||
return
|
||||
}
|
||||
|
||||
for _, item := range createItems {
|
||||
if item.media == nil {
|
||||
continue
|
||||
}
|
||||
wasExisting := b.mediaPathExists(item.media.Path)
|
||||
if err := b.scanner.repo.Media.Upsert(b.ctx, item.media); err != nil {
|
||||
if err := b.scanner.repo.Media.UpsertWithAliases(b.ctx, item.media, item.aliases); err != nil {
|
||||
addScanError(b.res, item.path, err)
|
||||
b.scanner.log.Warn("upsert media failed", zap.String("path", item.path), zap.Error(err))
|
||||
continue
|
||||
@@ -118,47 +122,90 @@ func (b *localMediaWriteBatch) Flush() {
|
||||
b.publish()
|
||||
}
|
||||
|
||||
func (b *localMediaWriteBatch) existingPaths(items []localMediaWriteItem) map[string]bool {
|
||||
// existingPathOrAliasSet loads exact and alias paths in bounded query chunks. Aliases
|
||||
// have already been filtered against the filesystem by AddWithAfter, so an
|
||||
// existing keep_ext sibling is never treated as a rename.
|
||||
func (b *localMediaWriteBatch) existingPathOrAliasSet(items []localMediaWriteItem) (map[string]bool, bool) {
|
||||
out := map[string]bool{}
|
||||
if b == nil || b.scanner == nil || b.scanner.repo == nil || b.scanner.repo.DB == nil || len(items) == 0 {
|
||||
return out
|
||||
return out, false
|
||||
}
|
||||
seen := make(map[string]struct{}, len(items)*2)
|
||||
paths := make([]string, 0, len(items)*2)
|
||||
add := func(path string) {
|
||||
path = filepath.Clean(path)
|
||||
if path == "" || path == "." {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
return
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
paths := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.media == nil || item.media.Path == "" {
|
||||
continue
|
||||
}
|
||||
paths = append(paths, item.media.Path)
|
||||
add(item.media.Path)
|
||||
for _, alias := range item.aliases {
|
||||
add(alias)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return out
|
||||
return out, true
|
||||
}
|
||||
var rows []string
|
||||
if err := b.scanner.repo.DB.WithContext(b.ctx).
|
||||
Unscoped().
|
||||
Model(&model.Media{}).
|
||||
Where("path IN ?", paths).
|
||||
Pluck("path", &rows).Error; err != nil {
|
||||
b.scanner.log.Debug("load existing media paths for scan batch failed", zap.Error(err))
|
||||
return out
|
||||
// Keep SQL variables below the legacy SQLite limit (999). A batch can
|
||||
// contain 100 STRM rows and every row has many sibling aliases.
|
||||
const pathLookupChunk = 400
|
||||
for start := 0; start < len(paths); start += pathLookupChunk {
|
||||
end := start + pathLookupChunk
|
||||
if end > len(paths) {
|
||||
end = len(paths)
|
||||
}
|
||||
var rows []string
|
||||
if err := b.scanner.repo.DB.WithContext(b.ctx).
|
||||
Unscoped().
|
||||
Model(&model.Media{}).
|
||||
Where("path IN ?", paths[start:end]).
|
||||
Pluck("path", &rows).Error; err != nil {
|
||||
b.scanner.log.Debug("load existing media paths for scan batch failed", zap.Error(err))
|
||||
return nil, false
|
||||
}
|
||||
for _, path := range rows {
|
||||
out[filepath.Clean(path)] = true
|
||||
}
|
||||
}
|
||||
for _, path := range rows {
|
||||
out[filepath.Clean(path)] = true
|
||||
}
|
||||
return out
|
||||
return out, true
|
||||
}
|
||||
|
||||
// flushUpserts 把已存在行的 upsert 攒成一个事务(一次提交/一组 fsync)。
|
||||
// 整批失败(如单条数据触发约束)时退回逐条 Upsert,只丢真正坏的那几条。
|
||||
func (b *localMediaWriteBatch) flushUpserts(allItems []localMediaWriteItem, upsertItems []*model.Media, upsertAfter []func()) {
|
||||
func mediaPathOrAliasExists(path string, aliases []string, existing map[string]bool) bool {
|
||||
if existing[filepath.Clean(path)] {
|
||||
return true
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if existing[filepath.Clean(alias)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// flushUpserts 把已存在或可迁移路径的行攒成一个事务(一次提交/一组 fsync)。
|
||||
// 整批失败(如单条数据触发约束)时退回逐条 upsert,只丢真正坏的那几条。
|
||||
func (b *localMediaWriteBatch) flushUpserts(upsertItems []localMediaWriteItem) {
|
||||
if len(upsertItems) == 0 {
|
||||
return
|
||||
}
|
||||
if err := b.scanner.repo.Media.UpsertBatch(b.ctx, upsertItems); err == nil {
|
||||
batchItems := make([]repository.MediaUpsertItem, 0, len(upsertItems))
|
||||
for _, item := range upsertItems {
|
||||
batchItems = append(batchItems, repository.MediaUpsertItem{Media: item.media, AliasPaths: item.aliases})
|
||||
}
|
||||
if err := b.scanner.repo.Media.UpsertBatchWithAliases(b.ctx, batchItems); err == nil {
|
||||
b.res.Updated += len(upsertItems)
|
||||
for _, after := range upsertAfter {
|
||||
if after != nil {
|
||||
after()
|
||||
for _, item := range upsertItems {
|
||||
if item.after != nil {
|
||||
item.after()
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -166,13 +213,7 @@ func (b *localMediaWriteBatch) flushUpserts(allItems []localMediaWriteItem, upse
|
||||
b.scanner.log.Warn("batch upsert failed; falling back to per-item upsert",
|
||||
zap.Int("items", len(upsertItems)))
|
||||
}
|
||||
// 兜底:按原始顺序找回每个条目的 path/after(两个切片同序但可能含 nil)。
|
||||
idx := 0
|
||||
for _, item := range allItems {
|
||||
if item.media == nil || idx >= len(upsertItems) || upsertItems[idx] != item.media {
|
||||
continue
|
||||
}
|
||||
idx++
|
||||
for _, item := range upsertItems {
|
||||
b.upsertExistingItem(item)
|
||||
}
|
||||
}
|
||||
@@ -181,7 +222,7 @@ func (b *localMediaWriteBatch) upsertExistingItem(item localMediaWriteItem) {
|
||||
if item.media == nil {
|
||||
return
|
||||
}
|
||||
if err := b.scanner.repo.Media.Upsert(b.ctx, item.media); err != nil {
|
||||
if err := b.scanner.repo.Media.UpsertWithAliases(b.ctx, item.media, item.aliases); err != nil {
|
||||
addScanError(b.res, item.path, err)
|
||||
b.scanner.log.Warn("upsert media failed", zap.String("path", item.path), zap.Error(err))
|
||||
return
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// RemovePath 物理删除磁盘上已不存在的媒体记录。
|
||||
// RemovePath 移除磁盘上已不存在的媒体记录。普通的删除仍做物理删除;
|
||||
// 可确认是 STRM 扩展名改名的路径则先保留软删除墓碑,供后续 ingest 继承元数据。
|
||||
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return 0, nil // still exists; nothing to remove
|
||||
@@ -30,24 +32,64 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
softIDs := make([]string, 0, len(rows))
|
||||
hardIDs := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
// LIKE 里的 % _ 是通配符(候选集只会偏大),用 Go 前缀精确过滤,
|
||||
// 避免对含 % / _ 的路径误删。
|
||||
if row.Path == path || strings.HasPrefix(filepath.Clean(row.Path), prefix) {
|
||||
ids = append(ids, row.ID)
|
||||
if row.Path != path && !strings.HasPrefix(filepath.Clean(row.Path), prefix) {
|
||||
continue
|
||||
}
|
||||
// A vanished STRM with a live sibling (foo.strm <-> foo.mkv.strm)
|
||||
// is a rename. Keep a soft-deleted tombstone so the later ingest can
|
||||
// adopt its ID and scraped metadata even if fsnotify delivers the
|
||||
// remove event before the create event. A true deletion stays hard.
|
||||
if row.Path == path && liveSTRMPathAlias(path) {
|
||||
softIDs = append(softIDs, row.ID)
|
||||
continue
|
||||
}
|
||||
hardIDs = append(hardIDs, row.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
var removed int64
|
||||
if len(softIDs) > 0 {
|
||||
res := s.repo.DB.WithContext(ctx).
|
||||
Where("id IN ?", softIDs).
|
||||
Delete(&model.Media{})
|
||||
if res.Error != nil {
|
||||
return removed, res.Error
|
||||
}
|
||||
removed += res.RowsAffected
|
||||
}
|
||||
res := s.repo.DB.WithContext(ctx).Unscoped().
|
||||
Where("id IN ?", ids).
|
||||
Delete(&model.Media{})
|
||||
if res.Error == nil && res.RowsAffected > 0 {
|
||||
if len(hardIDs) > 0 {
|
||||
res := s.repo.DB.WithContext(ctx).Unscoped().
|
||||
Where("id IN ?", hardIDs).
|
||||
Delete(&model.Media{})
|
||||
if res.Error != nil {
|
||||
return removed, res.Error
|
||||
}
|
||||
removed += res.RowsAffected
|
||||
}
|
||||
if removed > 0 {
|
||||
s.invalidateMediaCache(ctx)
|
||||
}
|
||||
return res.RowsAffected, res.Error
|
||||
if len(softIDs) > 0 {
|
||||
s.purgeExpiredSTRMTombstones(ctx)
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
const strmRenameTombstoneRetention = 7 * 24 * time.Hour
|
||||
|
||||
// purgeExpiredSTRMTombstones keeps the soft-delete grace period bounded.
|
||||
// It is deliberately restricted to deleted media rows under a STRM alias
|
||||
// workflow; normal deletions are still hard-deleted immediately.
|
||||
func (s *ScannerService) purgeExpiredSTRMTombstones(ctx context.Context) {
|
||||
cutoff := time.Now().Add(-strmRenameTombstoneRetention)
|
||||
if err := s.repo.DB.WithContext(ctx).Unscoped().
|
||||
Where("deleted_at IS NOT NULL AND deleted_at < ? AND path LIKE ?", cutoff, "%.strm").
|
||||
Delete(&model.Media{}).Error; err != nil && s.log != nil {
|
||||
s.log.Warn("purge expired STRM rename tombstones failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string, seen map[string]struct{}) (int64, error) {
|
||||
|
||||
@@ -117,6 +117,10 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Flush alias-aware upserts before pruning missing paths. This lets a
|
||||
// foo.strm <-> foo.mkv.strm rename migrate the existing row (including
|
||||
// scraped metadata) instead of deleting it as "old path missing".
|
||||
writeBatch.Flush()
|
||||
scannedRoots++
|
||||
removed, err := s.pruneMissingMediaForRoot(ctx, lib.ID, root.ID, root.Path, seen)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestSTRMPathAliasesCoverBothKeepExtDirections(t *testing.T) {
|
||||
toKeepExt := strmPathAliases(filepath.Join(t.TempDir(), "Show.S01E01.strm"))
|
||||
if !containsPathAlias(toKeepExt, "Show.S01E01.mkv.strm") {
|
||||
t.Fatalf("missing keep_ext alias: %#v", toKeepExt)
|
||||
}
|
||||
fromKeepExt := strmPathAliases(filepath.Join(t.TempDir(), "Show.S01E01.mkv.strm"))
|
||||
if !containsPathAlias(fromKeepExt, "Show.S01E01.strm") {
|
||||
t.Fatalf("missing stripped alias: %#v", fromKeepExt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSTRMPathAliasesKeepsRealKeepExtSiblingVersions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
current := filepath.Join(dir, "Show.S01E01.mkv.strm")
|
||||
sibling := filepath.Join(dir, "Show.S01E01.mp4.strm")
|
||||
writeFileContent(t, current, "https://example.test/mkv")
|
||||
writeFileContent(t, sibling, "https://example.test/mp4")
|
||||
|
||||
aliases := missingSTRMPathAliases(current)
|
||||
if containsPathAlias(aliases, sibling) {
|
||||
t.Fatalf("existing keep_ext sibling must remain a separate version: %#v", aliases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLibraryMigratesSTRMKeepExtRename(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
oldName string
|
||||
newName string
|
||||
}{
|
||||
{name: "add video extension", oldName: "Show.S01E01.strm", newName: "Show.S01E01.mkv.strm"},
|
||||
{name: "remove video extension", oldName: "Show.S01E01.mkv.strm", newName: "Show.S01E01.strm"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sc, repos := newScannerTestEnv(t)
|
||||
root := t.TempDir()
|
||||
lib := model.Library{Name: "Anime", Path: root, Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldPath := filepath.Join(root, tc.oldName)
|
||||
newPath := filepath.Join(root, tc.newName)
|
||||
writeFileContent(t, oldPath, "https://example.test/video")
|
||||
if _, err := sc.IngestPath(t.Context(), lib.ID, oldPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var before model.Media
|
||||
if err := repos.DB.First(&before, "path = ?", oldPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Model(&model.Media{}).Where("id = ?", before.ID).Updates(map[string]any{
|
||||
"title": "地狱乐",
|
||||
"original_name": "Jigokuraku",
|
||||
"overview": "preserved overview",
|
||||
"poster_url": "Jigokuraku-poster.jpg",
|
||||
"backdrop_url": "Jigokuraku-backdrop.jpg",
|
||||
"year": 2023,
|
||||
"rating": 8.6,
|
||||
"tm_db_id": 12345,
|
||||
"bangumi_id": 67890,
|
||||
"genres": "Action,Adventure",
|
||||
"scrape_status": "matched",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := sc.ScanLibrary(t.Context(), lib.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.ErrorCount != 0 {
|
||||
t.Fatalf("scan errors: %#v", res.Errors)
|
||||
}
|
||||
if got := countMedia(t, repos); got != 1 {
|
||||
t.Fatalf("media count = %d, want 1", got)
|
||||
}
|
||||
|
||||
var after model.Media
|
||||
if err := repos.DB.First(&after, "id = ?", before.ID).Error; err != nil {
|
||||
t.Fatalf("old media row was not preserved: %v", err)
|
||||
}
|
||||
if after.Path != newPath {
|
||||
t.Fatalf("path = %q, want %q", after.Path, newPath)
|
||||
}
|
||||
if after.Title != "地狱乐" || after.OriginalName != "Jigokuraku" || after.Overview != "preserved overview" {
|
||||
t.Fatalf("scraped identity metadata was lost: %#v", after)
|
||||
}
|
||||
if after.PosterURL != "Jigokuraku-poster.jpg" || after.BackdropURL != "Jigokuraku-backdrop.jpg" {
|
||||
t.Fatalf("artwork was lost: poster=%q backdrop=%q", after.PosterURL, after.BackdropURL)
|
||||
}
|
||||
if after.TMDbID != 12345 || after.BangumiID != 67890 || after.ScrapeStatus != "matched" {
|
||||
t.Fatalf("scrape IDs/status changed: tmdb=%d bgm=%d status=%q", after.TMDbID, after.BangumiID, after.ScrapeStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestPathAdoptsSoftDeletedSTRMRenameTombstone(t *testing.T) {
|
||||
sc, repos := newScannerTestEnv(t)
|
||||
root := t.TempDir()
|
||||
lib := model.Library{Name: "Anime", Path: root, Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldPath := filepath.Join(root, "Show.S01E01.mkv.strm")
|
||||
newPath := filepath.Join(root, "Show.S01E01.strm")
|
||||
writeFileContent(t, oldPath, "https://example.test/video")
|
||||
if _, err := sc.IngestPath(t.Context(), lib.ID, oldPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var before model.Media
|
||||
if err := repos.DB.First(&before, "path = ?", oldPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Model(&model.Media{}).Where("id = ?", before.ID).Updates(map[string]any{
|
||||
"title": "拔作岛",
|
||||
"overview": "kept through remove-before-create",
|
||||
"poster_url": "nukitashi-poster.webp",
|
||||
"tm_db_id": 222,
|
||||
"scrape_status": "matched",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Simulate fsnotify delivering Remove(old) before Create(new).
|
||||
if removed, err := sc.RemovePath(t.Context(), oldPath); err != nil || removed != 1 {
|
||||
t.Fatalf("RemovePath() removed=%d err=%v, want 1", removed, err)
|
||||
}
|
||||
var tombstone model.Media
|
||||
if err := repos.DB.Unscoped().First(&tombstone, "id = ?", before.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tombstone.DeletedAt.Valid {
|
||||
t.Fatal("rename tombstone should be soft-deleted")
|
||||
}
|
||||
if got := countMedia(t, repos); got != 0 {
|
||||
t.Fatalf("active media count = %d, want 0 before create event", got)
|
||||
}
|
||||
|
||||
if _, err := sc.IngestPath(t.Context(), lib.ID, newPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var after model.Media
|
||||
if err := repos.DB.First(&after, "id = ?", before.ID).Error; err != nil {
|
||||
t.Fatalf("soft-deleted row was not restored: %v", err)
|
||||
}
|
||||
if after.Path != newPath || after.Title != "拔作岛" || after.Overview != "kept through remove-before-create" {
|
||||
t.Fatalf("rename metadata was not adopted: %#v", after)
|
||||
}
|
||||
if after.TMDbID != 222 || after.ScrapeStatus != "matched" {
|
||||
t.Fatalf("scrape metadata changed: tmdb=%d status=%q", after.TMDbID, after.ScrapeStatus)
|
||||
}
|
||||
if got := countMedia(t, repos); got != 1 {
|
||||
t.Fatalf("media count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovePathStillHardDeletesTrueDeletionWithoutAlias(t *testing.T) {
|
||||
sc, repos := newScannerTestEnv(t)
|
||||
root := t.TempDir()
|
||||
lib := model.Library{Name: "Anime", Path: root, Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "Deleted.S01E01.mkv.strm")
|
||||
writeFileContent(t, path, "https://example.test/video")
|
||||
if _, err := sc.IngestPath(t.Context(), lib.ID, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed, err := sc.RemovePath(t.Context(), path); err != nil || removed != 1 {
|
||||
t.Fatalf("RemovePath() removed=%d err=%v, want 1", removed, err)
|
||||
}
|
||||
var count int64
|
||||
if err := repos.DB.Unscoped().Model(&model.Media{}).Where("path = ?", path).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("true deletion left %d tombstone row(s)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func containsPathAlias(paths []string, name string) bool {
|
||||
for _, path := range paths {
|
||||
if strings.EqualFold(filepath.Base(path), filepath.Base(name)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -48,6 +48,17 @@ func TestCleanQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanQueryIgnoresKeepExtSTRMSuffix(t *testing.T) {
|
||||
plain := filepath.Join("/media/anime", "Jigokuraku 2023 S01E14.strm")
|
||||
keepExt := filepath.Join("/media/anime", "Jigokuraku 2023 S01E14.mkv.strm")
|
||||
plainTitle, plainYear := CleanQuery(plain)
|
||||
keepTitle, keepYear := CleanQuery(keepExt)
|
||||
if plainTitle != keepTitle || plainYear != keepYear {
|
||||
t.Fatalf("keep_ext changed scrape identity: plain=(%q,%d) keep_ext=(%q,%d)",
|
||||
plainTitle, plainYear, keepTitle, keepYear)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeQueryCandidatesCleanDirtySeriesFolder(t *testing.T) {
|
||||
lib := &model.Library{
|
||||
Path: `F:\media\电视剧\欧美剧`,
|
||||
|
||||
@@ -46,14 +46,21 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
|
||||
}
|
||||
isAdult := shouldCropAdultPoster(refreshed, lib)
|
||||
artworkUpdates := map[string]any{}
|
||||
shouldPersistLocalArtworkURL := func(raw string) bool {
|
||||
return isAdult || !isHTTPish(raw)
|
||||
}
|
||||
if refreshed.PosterURL != "" {
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult); dst != "" {
|
||||
artworkUpdates["poster_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
if shouldPersistLocalArtworkURL(refreshed.PosterURL) {
|
||||
artworkUpdates["poster_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
}
|
||||
if refreshed.BackdropURL != "" {
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false); dst != "" {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
if shouldPersistLocalArtworkURL(refreshed.BackdropURL) {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
} else if isAdult && refreshed.PosterURL != "" {
|
||||
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
|
||||
|
||||
@@ -49,7 +49,7 @@ const (
|
||||
|
||||
const (
|
||||
StrmDefaultVideoExt = "mkv,mp4,avi,rmvb,rm,mov,ts,wmv,flv,m4v,iso,mpg,mpeg,webm"
|
||||
StrmDefaultMetaExt = "nfo,jpg,jpeg,png,srt,ass,ssa,sub,txt,bmp,webp"
|
||||
StrmDefaultMetaExt = "nfo,jpg,jpeg,png,srt,ass,ssa,sub,txt,bmp,webp,img"
|
||||
StrmDefaultExclude = "sample,trailer,预告"
|
||||
)
|
||||
|
||||
|
||||
@@ -612,6 +612,12 @@ func (st *strmSyncState) isVideoExt(ext string, size int64) bool {
|
||||
}
|
||||
|
||||
func (st *strmSyncState) isMetaExt(ext string) bool {
|
||||
// Legacy MeBox installs may have persisted strm.meta_ext without img.
|
||||
// .img is emitted by the artwork writer as a fallback image container, so
|
||||
// keep accepting existing sidecars without requiring a settings migration.
|
||||
if strings.EqualFold(ext, ".img") {
|
||||
return true
|
||||
}
|
||||
for _, e := range st.cfg.MetaExt {
|
||||
if "."+e == ext {
|
||||
return true
|
||||
|
||||
@@ -825,6 +825,7 @@ func TestWalkRemoteConcurrent(t *testing.T) {
|
||||
if err := st.walkRemote(); err != nil {
|
||||
t.Fatalf("walkRemote failed: %v", err)
|
||||
}
|
||||
st.flushPreferredVideos()
|
||||
|
||||
for _, dir := range []string{"root", "a", "a1", "b"} {
|
||||
if provider.listed[dir] != 1 {
|
||||
@@ -1113,6 +1114,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
|
||||
if err := st.walk115Flat(oc); err != nil {
|
||||
t.Fatalf("walk115Flat: %v", err)
|
||||
}
|
||||
st.flushPreferredVideos()
|
||||
|
||||
// 3 个视频的 strm 全部生成
|
||||
if st.rec.NewStrm != 3 {
|
||||
@@ -1168,6 +1170,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
|
||||
if err := st2.walk115Flat(oc); err != nil {
|
||||
t.Fatalf("incremental walk115Flat: %v", err)
|
||||
}
|
||||
st2.flushPreferredVideos()
|
||||
if infoCalls != 0 {
|
||||
t.Fatalf("incremental sync should reuse dir cache, got %d get_info calls", infoCalls)
|
||||
}
|
||||
@@ -1461,6 +1464,7 @@ func TestWalk115AdaptiveHierarchicalFlatScan(t *testing.T) {
|
||||
if err := st.walk115Flat(oc); err != nil {
|
||||
t.Fatalf("walk115Flat adaptive failed: %v", err)
|
||||
}
|
||||
st.flushPreferredVideos()
|
||||
|
||||
// 1个根目录视频 + 2个Heyzo视频 + 1个S1视频 = 共4个视频成功生成 .strm
|
||||
if st.rec.NewStrm != 4 {
|
||||
@@ -1676,3 +1680,10 @@ func TestHandleVideoKeepExtWritesAllVersions(t *testing.T) {
|
||||
t.Fatalf("NewStrm=%d want 2", st.rec.NewStrm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrmSyncTreatsLegacyImgAsMetadata(t *testing.T) {
|
||||
st := &strmSyncState{cfg: &strmPathConfig{MetaExt: csvSplit("nfo,jpg")}}
|
||||
if !st.isMetaExt(".img") {
|
||||
t.Fatal("legacy .img sidecars must remain metadata even when an old setting omits img")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
||||
Path: filepath.Join(c, e.Name()),
|
||||
Codec: codec,
|
||||
Source: "external",
|
||||
Delivery: "webvtt",
|
||||
Delivery: subtitleDeliveryForCodec(codec),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,18 @@ func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Med
|
||||
return subtitleTracksFromProbe(probe), nil
|
||||
}
|
||||
|
||||
// subtitleDeliveryForCodec selects the browser rendering path for a text
|
||||
// subtitle codec. ASS/SSA keep their original bytes and are rendered by
|
||||
// libass in the web player; other text formats are converted to WebVTT.
|
||||
func subtitleDeliveryForCodec(codec string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(codec)) {
|
||||
case "ass", "ssa":
|
||||
return "ass"
|
||||
default:
|
||||
return "webvtt"
|
||||
}
|
||||
}
|
||||
|
||||
func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
|
||||
tracks := make([]SubtitleTrack, 0, len(probe.Streams))
|
||||
for _, stream := range probe.Streams {
|
||||
@@ -314,7 +326,7 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
|
||||
} else if stream.Disposition.Default != 0 {
|
||||
label += "(默认)"
|
||||
}
|
||||
delivery := "webvtt"
|
||||
delivery := subtitleDeliveryForCodec(codec)
|
||||
if imageSubtitleCodecs[codec] {
|
||||
delivery = "burn"
|
||||
}
|
||||
@@ -464,6 +476,9 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
if strings.HasPrefix(sub, "embedded:") {
|
||||
return errors.New("embedded subtitle is not available in raw mode")
|
||||
}
|
||||
abs, err := filepath.Abs(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -472,6 +487,9 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
|
||||
if !pathWithin(abs, mediaDir) {
|
||||
return fmt.Errorf("path escape")
|
||||
}
|
||||
if _, ok := extToCodec[strings.ToLower(filepath.Ext(abs))]; !ok {
|
||||
return errors.New("unsupported subtitle format")
|
||||
}
|
||||
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -480,3 +498,49 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
|
||||
// ServeASS returns an ASS/SSA stream suitable for libass-wasm. External ASS
|
||||
// files are sent unchanged; embedded ASS/SSA tracks are remuxed to ASS by
|
||||
// ffmpeg. This keeps fonts, positioning and typesetting data available to the
|
||||
// browser renderer instead of flattening the track through assToVTT first.
|
||||
func (s *SubtitleService) ServeASS(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:") {
|
||||
index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:"))
|
||||
if err != nil || index < 0 {
|
||||
return errors.New("invalid embedded subtitle")
|
||||
}
|
||||
return s.serveEmbeddedASS(ctx, m, index, w)
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(sub)) {
|
||||
case ".ass", ".ssa":
|
||||
return s.ServeRaw(ctx, mediaID, sub, w)
|
||||
default:
|
||||
return errors.New("subtitle is not ASS/SSA")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubtitleService) serveEmbeddedASS(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
|
||||
input, err := s.resolveInput(ctx, media)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := []string{"-hide_banner", "-loglevel", "error"}
|
||||
args = append(args, ffmpegHTTPInputArgs(input)...)
|
||||
args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-c:s", "ass", "-f", "ass", "-")
|
||||
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
|
||||
cmd.Stdout = w
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("extract embedded ASS subtitle: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
|
||||
}
|
||||
if tracks[0].Delivery != "webvtt" || tracks[0].Path != "embedded:2" {
|
||||
if tracks[0].Delivery != "ass" || tracks[0].Path != "embedded:2" {
|
||||
t.Fatalf("text track = %#v", tracks[0])
|
||||
}
|
||||
if tracks[1].Delivery != "burn" || tracks[1].StreamIndex != 4 {
|
||||
@@ -108,6 +108,18 @@ func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleDeliveryClassifiesASSForLibass(t *testing.T) {
|
||||
if got := subtitleDeliveryForCodec("ass"); got != "ass" {
|
||||
t.Fatalf("ass delivery = %q, want ass", got)
|
||||
}
|
||||
if got := subtitleDeliveryForCodec("ssa"); got != "ass" {
|
||||
t.Fatalf("ssa delivery = %q, want ass", got)
|
||||
}
|
||||
if got := subtitleDeliveryForCodec("subrip"); got != "webvtt" {
|
||||
t.Fatalf("subrip delivery = %q, want webvtt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseTimecode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"0:00:01": "00:00:01",
|
||||
@@ -176,4 +188,17 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
|
||||
if got := buf.String(); got != raw {
|
||||
t.Fatalf("ServeRaw returned %q, want raw %q", got, raw)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := svc.ServeASS(t.Context(), media.ID, subPath, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := buf.String(); got != raw {
|
||||
t.Fatalf("ServeASS returned %q, want raw ASS %q", got, raw)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := svc.ServeRaw(t.Context(), media.ID, videoPath, &buf); err == nil {
|
||||
t.Fatal("ServeRaw accepted a non-subtitle file")
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+45
@@ -13,7 +13,9 @@
|
||||
"danmu": "^0.20.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"jassub": "^2.5.16",
|
||||
"lucide-react": "^0.379.0",
|
||||
"opencc-js": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -1752,6 +1754,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abslink": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/abslink/-/abslink-1.2.2.tgz",
|
||||
"integrity": "sha512-HCOItvzORPjMzddCmEpbH8+W1VM3yjH+CR7DLtxlxFKX/jz5twJbm7KLfblO7eKLnXINLah8Hk2ZAJqxXeSkOg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -3107,6 +3115,18 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jassub": {
|
||||
"version": "2.5.16",
|
||||
"resolved": "https://registry.npmjs.org/jassub/-/jassub-2.5.16.tgz",
|
||||
"integrity": "sha512-n4vjXfraf3o8k3/5TK6DhCYVcbQ/VqhVOgwqLd3l0xfMtsN4fpE/hPq2hPaGagBTOtJS9yHwRoRp3mmchQ9XVg==",
|
||||
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0",
|
||||
"dependencies": {
|
||||
"abslink": "^1.2.2",
|
||||
"lfa-ponyfill": "^1.1.0",
|
||||
"rvfc-polyfill": "^1.0.8",
|
||||
"throughput": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -3194,6 +3214,13 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lfa-ponyfill": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lfa-ponyfill/-/lfa-ponyfill-1.1.1.tgz",
|
||||
"integrity": "sha512-Had4tsGEHtIj58MRtf6SlubNDltxinxMSdxIn1GdwrWbye8TD+pZ+D6qKETnJ9+vQOfG9q/Ds4WUPskhyAMp5Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -3427,6 +3454,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/opencc-js": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz",
|
||||
"integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==",
|
||||
"license": "MIT AND Apache-2.0"
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -4025,6 +4058,12 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rvfc-polyfill": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/rvfc-polyfill/-/rvfc-polyfill-1.0.8.tgz",
|
||||
"integrity": "sha512-uA+0wwTkZ4OT8v45pfDfH+7Yq8mY6MvNngiF5Sq6VBgjJsvsfgt7Q18cyZqZjfAhW9rhkgXPX0cW0R9uw7yElA==",
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -4212,6 +4251,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/throughput": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/throughput/-/throughput-1.0.2.tgz",
|
||||
"integrity": "sha512-jvK1ZXuhsggjb3qYQjMiU/AVYYiTeqT5thWvYR2yuy2LGM84P5MSSyAinwHahGsdBYKR9m9HncVR/3f3nFKkxg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"danmu": "^0.20.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"jassub": "^2.5.16",
|
||||
"lucide-react": "^0.379.0",
|
||||
"opencc-js": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
+25
-3
@@ -27,20 +27,32 @@ export interface DanmakuFetchResult {
|
||||
font_size: string
|
||||
area: string
|
||||
raw?: string
|
||||
/** Number of sources merged into `raw`; absent/0 means not merged. */
|
||||
merged_sources?: number
|
||||
candidates?: DanmakuAnime[]
|
||||
/**
|
||||
* Same episode, other sources. Unlike `candidates` (which means "pick one
|
||||
* before anything loads"), `alternatives` arrives together with a loaded
|
||||
* library: the backend already auto-picked one and offers the rest so the
|
||||
* user can switch without re-searching. Aggregating sources such as LogVar
|
||||
* return several libraries for the same episode.
|
||||
*/
|
||||
alternatives?: DanmakuAnime[]
|
||||
anime_title?: string
|
||||
episode_title?: string
|
||||
episode_id?: number
|
||||
match_mode?: 'hash' | 'filename' | 'search' | 'manual' | string
|
||||
match_mode?: 'hash' | 'filename' | 'metadata' | 'search' | 'manual' | string
|
||||
}
|
||||
|
||||
export interface DanmakuLoadedInfo {
|
||||
animeTitle?: string
|
||||
episodeTitle?: string
|
||||
episodeId?: number | string
|
||||
matchMode?: 'hash' | 'filename' | 'search' | 'manual' | string
|
||||
matchMode?: 'hash' | 'filename' | 'metadata' | 'search' | 'manual' | string
|
||||
totalCount: number
|
||||
sourceType?: 'auto' | 'xml' | 'json'
|
||||
/** Number of sources merged into the loaded comments (0 = not merged). */
|
||||
mergedSources?: number
|
||||
}
|
||||
|
||||
export type DanmakuFetchOptions = {
|
||||
@@ -73,6 +85,16 @@ export const danmakuAPI = {
|
||||
opacity: string
|
||||
font_size: string
|
||||
area: string
|
||||
/** Per-user preference: merge the same episode's multiple sources. */
|
||||
merge_sources: boolean
|
||||
}>('/danmaku/config')
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
// updateSettings persists per-user danmaku preferences (survives reload).
|
||||
updateSettings: (settings: { mergeSources: boolean }) =>
|
||||
api
|
||||
.put<{ merge_sources: boolean }>('/danmaku/settings', {
|
||||
merge_sources: settings.mergeSources,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ import { api } from './client'
|
||||
import type { User } from '../types'
|
||||
|
||||
export const profileAPI = {
|
||||
get: () => api.get<User>('/me').then((r) => r.data),
|
||||
|
||||
update: (patch: {
|
||||
username?: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
hide_adult?: boolean
|
||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||
password?: string
|
||||
}) =>
|
||||
api.patch<User>('/me', patch).then((r) => r.data),
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface SubtitleTrack {
|
||||
url: string
|
||||
codec: string
|
||||
source: 'external' | 'embedded'
|
||||
delivery: 'webvtt' | 'burn'
|
||||
delivery: 'webvtt' | 'ass' | 'burn'
|
||||
stream_index?: number
|
||||
}
|
||||
|
||||
@@ -26,4 +26,11 @@ export const subtitlesAPI = {
|
||||
path,
|
||||
)}&token=${encodeURIComponent(token)}`
|
||||
},
|
||||
|
||||
assUrl: (mediaId: string, path: string) => {
|
||||
const token = useAuthStore.getState().token ?? ''
|
||||
return `/api/subtitles/${encodeURIComponent(mediaId)}/ass?path=${encodeURIComponent(
|
||||
path,
|
||||
)}&token=${encodeURIComponent(token)}`
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import JASSUB from 'jassub'
|
||||
import jassubWorkerUrl from 'jassub/dist/worker/worker.js?worker&url'
|
||||
import jassubWasmUrl from 'jassub/dist/wasm/jassub-worker.wasm?url'
|
||||
import jassubModernWasmUrl from 'jassub/dist/wasm/jassub-worker-modern.wasm?url'
|
||||
import notoSansSCUrl from '../assets/fonts/noto-sans-sc-regular.woff2?url'
|
||||
|
||||
import {
|
||||
loadSubtitleChineseConverter,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import { convertASSContent } from '../utils/subtitleASS'
|
||||
|
||||
type AssSubtitleStageProps = {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
source: string
|
||||
timeOffset?: number
|
||||
chineseMode?: SubtitleChineseMode
|
||||
onReady?: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* libass-wasm subtitle layer for external and embedded ASS/SSA tracks. JASSUB
|
||||
* owns its canvas and attaches it next to the video, so this component itself
|
||||
* renders no visible DOM nodes.
|
||||
*/
|
||||
export function AssSubtitleStage({
|
||||
videoRef,
|
||||
source,
|
||||
timeOffset = 0,
|
||||
chineseMode = 'original',
|
||||
onReady,
|
||||
onError,
|
||||
}: AssSubtitleStageProps) {
|
||||
const instanceRef = useRef<JASSUB | null>(null)
|
||||
const timeOffsetRef = useRef(timeOffset)
|
||||
const callbacksRef = useRef({ onReady, onError })
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onReady, onError }
|
||||
}, [onReady, onError])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || !source) return
|
||||
|
||||
let cancelled = false
|
||||
let instance: JASSUB | null = null
|
||||
const controller = new AbortController()
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [response, converter] = await Promise.all([
|
||||
fetch(source, { signal: controller.signal }),
|
||||
loadSubtitleChineseConverter(chineseMode).catch(() => (text: string) => text),
|
||||
])
|
||||
if (!response.ok) throw new Error(`subtitle request failed: ${response.status}`)
|
||||
const body = await response.text()
|
||||
if (!body.trim()) throw new Error('empty subtitle payload')
|
||||
if (cancelled) return
|
||||
|
||||
instance = new JASSUB({
|
||||
video,
|
||||
subContent: convertASSContent(body, converter),
|
||||
timeOffset: timeOffsetRef.current,
|
||||
workerUrl: jassubWorkerUrl,
|
||||
wasmUrl: jassubWasmUrl,
|
||||
modernWasmUrl: jassubModernWasmUrl,
|
||||
fonts: [notoSansSCUrl],
|
||||
defaultFont: 'Noto Sans SC',
|
||||
queryFonts: 'local',
|
||||
prescaleFactor: 1,
|
||||
})
|
||||
instanceRef.current = instance
|
||||
await instance.ready
|
||||
if (cancelled) {
|
||||
void instance.destroy().catch(() => undefined)
|
||||
return
|
||||
}
|
||||
callbacksRef.current.onReady?.()
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return
|
||||
callbacksRef.current.onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
instanceRef.current = null
|
||||
if (instance) void instance.destroy().catch(() => undefined)
|
||||
}
|
||||
// timeOffset is applied live below. Recreating the full libass worker on a
|
||||
// seek offset change would be unnecessarily expensive.
|
||||
}, [source, chineseMode, videoRef])
|
||||
|
||||
useEffect(() => {
|
||||
timeOffsetRef.current = timeOffset
|
||||
if (instanceRef.current) instanceRef.current.timeOffset = timeOffset
|
||||
}, [timeOffset])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -34,6 +34,8 @@ type DanmakuStageProps = {
|
||||
onLoaded?: (info: DanmakuLoadedInfo | null) => void
|
||||
/** Called when multiple anime matched and the user must pick one. */
|
||||
onCandidates?: (candidates: DanmakuAnime[]) => void
|
||||
/** Called after a successful load with other libraries for the same episode. */
|
||||
onAlternatives?: (alternatives: DanmakuAnime[]) => void
|
||||
}
|
||||
|
||||
// Average of the engine's durationRange (ms). Used to compute how far a
|
||||
@@ -55,6 +57,7 @@ export function DanmakuStage({
|
||||
searchTrigger = 0,
|
||||
onLoaded,
|
||||
onCandidates,
|
||||
onAlternatives,
|
||||
}: DanmakuStageProps) {
|
||||
const holderRef = useRef<HTMLDivElement>(null)
|
||||
const managerRef = useRef<Manager<Comment> | null>(null)
|
||||
@@ -143,10 +146,13 @@ export function DanmakuStage({
|
||||
if (res.candidates && res.candidates.length > 0) {
|
||||
// 多番剧命中:交回播放器展示候选让用户选择(disambiguation)。
|
||||
comments = []
|
||||
onAlternatives?.([])
|
||||
onCandidates?.(res.candidates)
|
||||
return
|
||||
}
|
||||
if (res.enabled) {
|
||||
// 弹幕已自动加载;若同一集还有其它来源,一并交回播放器供随时切换。
|
||||
onAlternatives?.(res.alternatives ?? [])
|
||||
comments = parseDanmaku(res.raw || '', res.source_type)
|
||||
.filter((c) => Number.isFinite(c.time) && c.time >= 0)
|
||||
.sort((a, b) => a.time - b.time)
|
||||
@@ -158,9 +164,11 @@ export function DanmakuStage({
|
||||
matchMode: res.match_mode,
|
||||
totalCount: comments.length,
|
||||
sourceType: res.source_type,
|
||||
mergedSources: res.merged_sources ?? 0,
|
||||
}
|
||||
} else {
|
||||
comments = []
|
||||
onAlternatives?.([])
|
||||
loadedInfo = {
|
||||
totalCount: 0,
|
||||
}
|
||||
|
||||
@@ -114,16 +114,24 @@ function LayoutHeaderSearch() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [results, setResults] = useState<Media[]>([])
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isOpenRef = useRef(false)
|
||||
// 递增序号守卫:快速连续输入时丢弃过期响应
|
||||
const searchSeqRef = useRef(0)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const setSearchOpen = (open: boolean) => {
|
||||
isOpenRef.current = open
|
||||
setIsOpen(open)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
searchSeqRef.current += 1
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
isOpenRef.current = false
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,7 +142,7 @@ function LayoutHeaderSearch() {
|
||||
const res = await mediaAPI.search(trimmed, 8)
|
||||
if (seq !== searchSeqRef.current) return
|
||||
setResults(res.items || [])
|
||||
setIsOpen(true)
|
||||
if (isOpenRef.current) setIsOpen(true)
|
||||
} catch {
|
||||
// 请求失败时保留旧结果,避免网络抖动清空下拉
|
||||
} finally {
|
||||
@@ -148,6 +156,7 @@ function LayoutHeaderSearch() {
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
isOpenRef.current = false
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
@@ -156,14 +165,14 @@ function LayoutHeaderSearch() {
|
||||
}, [])
|
||||
|
||||
const handleSelect = (item: Media) => {
|
||||
setIsOpen(false)
|
||||
setSearchOpen(false)
|
||||
setQuery('')
|
||||
navigate(favouriteMediaLink(item))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
setSearchOpen(false)
|
||||
} else if (e.key === 'Enter' && results.length > 0) {
|
||||
handleSelect(results[0])
|
||||
}
|
||||
@@ -180,11 +189,12 @@ function LayoutHeaderSearch() {
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (e.target.value.trim()) setIsOpen(true)
|
||||
const nextQuery = e.target.value
|
||||
setQuery(nextQuery)
|
||||
setSearchOpen(Boolean(nextQuery.trim()))
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (results.length > 0) setIsOpen(true)
|
||||
if (query.trim()) setSearchOpen(true)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索媒体…"
|
||||
@@ -198,7 +208,7 @@ function LayoutHeaderSearch() {
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setIsOpen(false)
|
||||
setSearchOpen(false)
|
||||
}}
|
||||
className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg"
|
||||
>
|
||||
|
||||
@@ -55,7 +55,9 @@ export function LayoutUserMenu({
|
||||
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)
|
||||
const [isOtpOpen, setIsOtpOpen] = useState(false)
|
||||
|
||||
onCloseRef.current = onClose
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose
|
||||
}, [onClose])
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { AdminLibraryPanel } from '../pages/AdminLibraryPanel'
|
||||
|
||||
export function ManageLibrariesDialogView({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 sm:p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[calc(100dvh-1rem)] sm:h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl sm:rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h3 className="font-display text-base sm:text-lg font-bold text-ink-600">管理媒体库</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<AdminLibraryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import type { SubtitleTrack } from '../api/subtitles'
|
||||
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
|
||||
import {
|
||||
SUBTITLE_POSITION_OPTIONS,
|
||||
SUBTITLE_STYLE_OPTIONS,
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
|
||||
// PlayerControls — custom bottom control bar replacing the native <video
|
||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||
@@ -36,6 +43,12 @@ type PlayerControlsProps = {
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
onSelectSubtitle: (index: number) => void
|
||||
subtitleChineseMode: SubtitleChineseMode
|
||||
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
|
||||
subtitlePosition: SubtitlePosition
|
||||
onSubtitlePositionChange: (position: SubtitlePosition) => void
|
||||
subtitleStyle: SubtitleStylePreset
|
||||
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
@@ -63,6 +76,12 @@ export function PlayerControls({
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
subtitleChineseMode,
|
||||
onSubtitleChineseModeChange,
|
||||
subtitlePosition,
|
||||
onSubtitlePositionChange,
|
||||
subtitleStyle,
|
||||
onSubtitleStyleChange,
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
@@ -79,11 +98,11 @@ export function PlayerControls({
|
||||
streamOffset = 0,
|
||||
onSeekAbsolute,
|
||||
}: PlayerControlsProps) {
|
||||
const video = () => videoRef.current
|
||||
const container = () =>
|
||||
const video = useCallback(() => videoRef.current, [videoRef])
|
||||
const container = useCallback(() =>
|
||||
videoRef.current?.closest<HTMLElement>('[data-player-stage]') ??
|
||||
videoRef.current?.parentElement ??
|
||||
null
|
||||
null, [videoRef])
|
||||
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
@@ -250,15 +269,14 @@ export function PlayerControls({
|
||||
el.removeEventListener('leavepictureinpicture', syncPip)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoRef, knownDuration, streamOffset, onUiVisibleChange])
|
||||
}, [container, video, knownDuration, streamOffset, onUiVisibleChange])
|
||||
|
||||
// Keep the scrubber max in sync when metadata duration arrives after mount.
|
||||
useEffect(() => {
|
||||
const el = video()
|
||||
const streamDur = el && Number.isFinite(el.duration) ? el.duration : 0
|
||||
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
|
||||
}, [knownDuration, streamOffset])
|
||||
}, [knownDuration, streamOffset, video])
|
||||
|
||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||
useEffect(() => {
|
||||
@@ -272,7 +290,7 @@ export function PlayerControls({
|
||||
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
|
||||
}
|
||||
}
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange])
|
||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
@@ -355,6 +373,12 @@ export function PlayerControls({
|
||||
document.pictureInPictureEnabled
|
||||
|
||||
const displayTime = isScrubbing && scrubValue !== null ? scrubValue : currentTime
|
||||
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const canConvertSelectedSubtitle =
|
||||
selectedSubtitle?.source === 'external' &&
|
||||
(selectedSubtitle.delivery === 'webvtt' || selectedSubtitle.delivery === 'ass')
|
||||
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
|
||||
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -448,7 +472,7 @@ export function PlayerControls({
|
||||
)}
|
||||
</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">
|
||||
<div className="absolute bottom-11 right-0 z-30 max-h-[70vh] min-w-44 overflow-y-auto rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -474,10 +498,85 @@ export function PlayerControls({
|
||||
}`}
|
||||
title={track.label || track.lang}
|
||||
>
|
||||
<span className="truncate">{track.label || track.lang || `字幕 ${index + 1}`}</span>
|
||||
<span className="truncate">
|
||||
{track.label || track.lang || `字幕 ${index + 1}`}
|
||||
{track.delivery === 'ass' ? ' · ASS' : ''}
|
||||
</span>
|
||||
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
|
||||
</button>
|
||||
))}
|
||||
{canAdjustSelectedSubtitle && (
|
||||
<div className="mt-1 border-t border-white/10 pt-1">
|
||||
<p className="px-3 py-1 text-[10px] text-white/45">字幕位置</p>
|
||||
<div className="flex flex-wrap gap-1 px-2 pb-1">
|
||||
{SUBTITLE_POSITION_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onSubtitlePositionChange(value)}
|
||||
className={`rounded-md px-2 py-1 text-[10px] transition ${
|
||||
subtitlePosition === value
|
||||
? 'bg-rose-500/20 text-rose-300'
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="px-3 py-1 text-[10px] text-white/45">字幕样式</p>
|
||||
<div className="flex flex-wrap gap-1 px-2 pb-1">
|
||||
{SUBTITLE_STYLE_OPTIONS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onSubtitleStyleChange(value)}
|
||||
className={`rounded-md px-2 py-1 text-[10px] transition ${
|
||||
subtitleStyle === value
|
||||
? 'bg-rose-500/20 text-rose-300'
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{usesOriginalASS && (
|
||||
<p className="mx-2 mt-1 border-t border-white/10 px-1 pt-2 text-[10px] leading-relaxed text-white/45">
|
||||
ASS/SSA 使用字幕文件自带的样式与位置,不做统一覆盖。
|
||||
</p>
|
||||
)}
|
||||
{canConvertSelectedSubtitle && (
|
||||
<div className="mt-1 border-t border-white/10 pt-1">
|
||||
<p className="px-3 py-1 text-[10px] text-white/45">外挂字幕简繁转换</p>
|
||||
{(
|
||||
[
|
||||
['original', '保持原文'],
|
||||
['simplified', '转换为简体'],
|
||||
['traditional', '转换为繁体'],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSubtitleChineseModeChange(mode)
|
||||
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 ${
|
||||
subtitleChineseMode === mode ? 'text-rose-400' : 'text-white/85'
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{subtitleChineseMode === mode && (
|
||||
<span className="ml-auto text-rose-400">●</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,16 @@ type PlayerDanmakuPanelProps = {
|
||||
onFontSizeChange: (v: number) => void
|
||||
/** Multiple anime matched — user must pick one. */
|
||||
candidates: DanmakuAnime[]
|
||||
/**
|
||||
* Other libraries holding this same episode. Danmaku is already loaded from
|
||||
* one of them; these exist so the user can switch sources on the spot.
|
||||
*/
|
||||
alternatives: DanmakuAnime[]
|
||||
/** Per-user preference: merge the same episode's sources into one list. */
|
||||
mergeSources: boolean
|
||||
onMergeSourcesChange: (v: boolean) => void
|
||||
/** True while the merge preference is being persisted. */
|
||||
mergeSaving?: boolean
|
||||
/** Human-readable label of the currently selected library. */
|
||||
selectedSource?: string
|
||||
/** Title used by auto-matching (e.g. anime title, media title or filename). */
|
||||
@@ -48,6 +58,10 @@ export function PlayerDanmakuPanel({
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
candidates,
|
||||
alternatives,
|
||||
mergeSources,
|
||||
onMergeSourcesChange,
|
||||
mergeSaving = false,
|
||||
selectedSource,
|
||||
autoMatchTitle,
|
||||
danmakuInfo,
|
||||
@@ -95,6 +109,12 @@ export function PlayerDanmakuPanel({
|
||||
<Tag size={10} /> 文件名匹配
|
||||
</span>
|
||||
)
|
||||
case 'metadata':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded border border-cyan-500/30 bg-cyan-500/15 px-1.5 py-0.5 text-[10px] font-medium text-cyan-300">
|
||||
<Search size={10} /> 刮削信息匹配
|
||||
</span>
|
||||
)
|
||||
case 'search':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded border border-violet-500/30 bg-violet-500/15 px-1.5 py-0.5 text-[10px] font-medium text-violet-300">
|
||||
@@ -114,6 +134,11 @@ export function PlayerDanmakuPanel({
|
||||
|
||||
const isCustomOrManual = Boolean(search || selectedSource || danmakuInfo?.matchMode === 'manual')
|
||||
|
||||
// 候选来源摊平成「番剧 + 集」的一维列表:每个来源通常只含命中的那一集。
|
||||
const alternativeRows = alternatives.flatMap((anime) =>
|
||||
anime.episodes.map((ep) => ({ anime, ep })),
|
||||
)
|
||||
|
||||
return (
|
||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||
<div
|
||||
@@ -286,6 +311,83 @@ export function PlayerDanmakuPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 同集其它来源:弹幕已自动加载,这里直接切换即可 */}
|
||||
{enabled && alternatives.length > 0 && (
|
||||
<div className="mb-4 rounded-xl border border-sky-400/25 bg-sky-400/5 p-2.5">
|
||||
<div className="mb-1.5 px-1 text-xs font-medium text-sky-200">
|
||||
同集其它来源({alternativeRows.length})
|
||||
</div>
|
||||
<div className="mb-1.5 px-1 text-[10px] leading-relaxed text-white/45">
|
||||
当前已自动加载一个来源,点其它条目可直接切换,无需重新搜索。
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto pr-1">
|
||||
{alternativeRows.map(({ anime, ep }, i) => {
|
||||
const current = String(danmakuInfo?.episodeId ?? '') === String(ep.episodeId)
|
||||
return (
|
||||
<button
|
||||
key={`${anime.animeId}-${ep.episodeId}-${i}`}
|
||||
onClick={() => onSelectEpisode(ep.episodeId, anime.animeTitle, ep.episodeTitle)}
|
||||
className={
|
||||
'mb-1 flex w-full items-start gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition ' +
|
||||
(current
|
||||
? 'bg-sky-500/20 text-white'
|
||||
: 'text-white/70 hover:bg-sky-500/15 hover:text-white')
|
||||
}
|
||||
title={`切换到《${anime.animeTitle}》的《${ep.episodeTitle}》`}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{current ? (
|
||||
<Check size={12} className="text-sky-300" />
|
||||
) : (
|
||||
<Film size={12} className="text-white/35" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{anime.animeTitle || `来源 ${i + 1}`}</span>
|
||||
{ep.episodeTitle && (
|
||||
<span className="mt-0.5 block truncate text-[10px] text-white/50">
|
||||
{ep.episodeTitle}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{current && (
|
||||
<span className="mt-0.5 shrink-0 text-[10px] text-sky-300">当前</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合并多来源:仅在确实存在多个同集来源时才出现,避免无意义的开关 */}
|
||||
{enabled && alternativeRows.length > 1 && (
|
||||
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-400/5 p-2.5">
|
||||
<label className="flex cursor-pointer items-start justify-between gap-2">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium text-emerald-200">合并多来源弹幕</span>
|
||||
<span className="mt-0.5 block text-[10px] leading-relaxed text-white/45">
|
||||
把以上来源的弹幕合并,并按时间与内容去重后一起显示。该设置会保存到账号。
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 pt-0.5">
|
||||
{mergeSaving && <Loader2 size={11} className="animate-spin text-emerald-300" />}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mergeSources}
|
||||
onChange={(e) => onMergeSourcesChange(e.target.checked)}
|
||||
className="h-4 w-4 accent-emerald-500"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
{mergeSources && danmakuInfo?.mergedSources ? (
|
||||
<div className="mt-1.5 border-t border-white/10 pt-1.5 text-[10px] text-emerald-300/80">
|
||||
已合并 {danmakuInfo.mergedSources} 个来源
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 屏幕占比(显示区域) */}
|
||||
<SliderRow
|
||||
label="屏幕占比"
|
||||
@@ -373,4 +475,4 @@ function EpisodeRow({
|
||||
<span className="min-w-0 flex-1 truncate">{episode.episodeTitle}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { AdminLibraryPanel } from '../pages/AdminLibraryPanel'
|
||||
import { ManageLibrariesDialogView } from './ManageLibrariesDialogView'
|
||||
|
||||
export function openManageLibrariesDialog(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -15,37 +14,6 @@ export function openManageLibrariesDialog(): Promise<void> {
|
||||
resolve()
|
||||
}
|
||||
|
||||
root.render(<ManageLibrariesDialog onClose={close} />)
|
||||
root.render(<ManageLibrariesDialogView onClose={close} />)
|
||||
})
|
||||
}
|
||||
|
||||
function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 sm:p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[calc(100dvh-1rem)] sm:h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl sm:rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h3 className="font-display text-base sm:text-lg font-bold text-ink-600">管理媒体库</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<AdminLibraryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,32 @@ import { useAuthStore } from '../stores/auth'
|
||||
const FAST_RECONNECT_ATTEMPTS = 5
|
||||
const SLOW_RECONNECT_INTERVAL = 60_000
|
||||
|
||||
// token 过期前 30 秒即视为失效,给刷新留出余量。
|
||||
const TOKEN_EXPIRY_SKEW_MS = 30_000
|
||||
|
||||
// decodeJwtExpiryMs 解析 JWT 的 exp 声明并换算成毫秒时间戳。
|
||||
// 解析失败(非 JWT / 结构异常)返回 null,调用方据此放行重连。
|
||||
function decodeJwtExpiryMs(token: string): number | null {
|
||||
const segment = token.split('.')[1]
|
||||
if (!segment) return null
|
||||
try {
|
||||
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
|
||||
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
|
||||
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// isTokenExpired 判断 token 是否已过期或即将过期。
|
||||
function isTokenExpired(token: string): boolean {
|
||||
const expiry = decodeJwtExpiryMs(token)
|
||||
if (expiry === null) return false
|
||||
return Date.now() >= expiry - TOKEN_EXPIRY_SKEW_MS
|
||||
}
|
||||
|
||||
// useWebSocket opens a single connection to /api/ws and dispatches every
|
||||
// message to the supplied handler. Auto-reconnects with back-off while the
|
||||
// auth token is present; after the fast retries are exhausted it keeps a
|
||||
@@ -15,7 +41,10 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
const ref = useRef<WebSocket | null>(null)
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const onEventRef = useRef(onEvent)
|
||||
onEventRef.current = onEvent
|
||||
|
||||
useEffect(() => {
|
||||
onEventRef.current = onEvent
|
||||
}, [onEvent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
@@ -25,6 +54,13 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
|
||||
const open = () => {
|
||||
if (closed) return
|
||||
// 过期的 token 握手必然 401。此前这里会以 60s 间隔无限重试,服务端
|
||||
// 日志里表现为每分钟一条 401。改为先走刷新流程:成功会更新 token 并
|
||||
// 让本 effect 重建连接,失败则清空会话停止重连。
|
||||
if (isTokenExpired(token)) {
|
||||
void useAuthStore.getState().tokenRefresh()
|
||||
return
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const url = `${proto}//${window.location.host}/api/ws?token=${encodeURIComponent(token)}`
|
||||
const ws = new WebSocket(url)
|
||||
@@ -44,6 +80,11 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (closed) return
|
||||
// token 过期时不要继续退避重试,交给刷新流程处理。
|
||||
if (isTokenExpired(token)) {
|
||||
void useAuthStore.getState().tokenRefresh()
|
||||
return
|
||||
}
|
||||
reconnectAttempts += 1
|
||||
// 快速阶段保持原有线性退避,之后固定 60s 慢速重试;
|
||||
// timer 始终只有一个在途,cleanup 时统一清除,不会堆积。
|
||||
|
||||
@@ -28,7 +28,7 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
const [browsingRoot, setBrowsingRoot] = useState<{ libraryID: string; root: LibraryRoot; initialPath?: string } | null>(null)
|
||||
const [addingRootLib, setAddingRootLib] = useState<Library | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const dragOverId = useRef<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
|
||||
const handleSelectRootPath = (selectedPath: string) => {
|
||||
if (browsingRoot) {
|
||||
@@ -59,7 +59,7 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
|
||||
const handleDrop = (e: DragEvent, overId: string) => {
|
||||
e.preventDefault()
|
||||
dragOverId.current = null
|
||||
setDragOverId(null)
|
||||
setDraggingId(null)
|
||||
if (draggingId && overId !== draggingId) {
|
||||
handleReorder(draggingId, overId)
|
||||
@@ -90,19 +90,19 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
onBrowseRoot={(root) => setBrowsingRoot({ libraryID: library.id, root, initialPath: root.path })}
|
||||
onOpenAddRoot={() => setAddingRootLib(library)}
|
||||
dragging={draggingId === library.id}
|
||||
dragOver={dragOverId.current === library.id}
|
||||
dragOver={dragOverId === library.id}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
dragOverId.current = null
|
||||
setDragOverId(null)
|
||||
setDraggingId(library.id)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverId.current !== library.id) dragOverId.current = library.id
|
||||
if (dragOverId !== library.id) setDragOverId(library.id)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragOverId.current = null
|
||||
setDragOverId(null)
|
||||
setDraggingId(null)
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, library.id)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Database, HardDrive, PieChart, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { storageAPI, type StorageBreakdown } from '../api/storage'
|
||||
|
||||
export function fmtBytes(n: number): string {
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let v = n
|
||||
@@ -15,7 +15,7 @@ export function fmtBytes(n: number): string {
|
||||
return `${v.toFixed(2)} ${u[i]}`
|
||||
}
|
||||
|
||||
export function fmtHours(seconds: number): string {
|
||||
function fmtHours(seconds: number): string {
|
||||
if (!seconds) return '—'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
return `${h.toLocaleString()} h`
|
||||
|
||||
@@ -9,9 +9,23 @@ import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/da
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
normalizeSubtitleChineseMode,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import {
|
||||
loadSubtitlePosition,
|
||||
loadSubtitleStyle,
|
||||
saveSubtitlePosition,
|
||||
saveSubtitleStyle,
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
||||
import { classifyDirectPlayError } from './directPlayError'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
@@ -51,6 +65,17 @@ export function PlayerPage() {
|
||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
||||
const authUser = useAuthStore((state) => state.user)
|
||||
const setAuthUser = useAuthStore((state) => state.setUser)
|
||||
const [subtitleChineseMode, setSubtitleChineseMode] =
|
||||
useState<SubtitleChineseMode>(() =>
|
||||
normalizeSubtitleChineseMode(authUser?.subtitle_chinese_mode),
|
||||
)
|
||||
const [subtitlePosition, setSubtitlePosition] = useState<SubtitlePosition>(loadSubtitlePosition)
|
||||
const [subtitleStyle, setSubtitleStyle] = useState<SubtitleStylePreset>(loadSubtitleStyle)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
||||
@@ -73,10 +98,15 @@ export function PlayerPage() {
|
||||
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState<number | string | null>(null)
|
||||
// 自动匹配歧义(多番剧命中)时的候选列表。
|
||||
const [danmakuCandidates, setDanmakuCandidates] = useState<DanmakuAnime[]>([])
|
||||
// 同一集的其它可选来源(弹幕已自动加载,用户可随时切换,无需重新搜索)。
|
||||
const [danmakuAlternatives, setDanmakuAlternatives] = useState<DanmakuAnime[]>([])
|
||||
// 已加载弹幕的元数据信息(番剧名、单集名、条数、匹配模式等)。
|
||||
const [danmakuInfo, setDanmakuInfo] = useState<DanmakuLoadedInfo | null>(null)
|
||||
// 用户当前选定的弹幕来源描述(面板中展示)。
|
||||
const [danmakuSelectedSource, setDanmakuSelectedSource] = useState('')
|
||||
// 弹幕合并偏好:从 /danmaku/config 读取(按用户落库),切换后写回并重新抓取。
|
||||
const [danmakuMergeSources, setDanmakuMergeSources] = useState(false)
|
||||
const [danmakuMergeSaving, setDanmakuMergeSaving] = useState(false)
|
||||
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
|
||||
const [danmakuArea, setDanmakuArea] = useState(1)
|
||||
@@ -112,6 +142,24 @@ export function PlayerPage() {
|
||||
.finally(() => setDirectOnlyKnown(true))
|
||||
}, [])
|
||||
|
||||
// 每次进入播放器都从数据库刷新用户的字幕转换偏好;本地登录缓存只用作首屏初值。
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
profileAPI
|
||||
.get()
|
||||
.then((user) => {
|
||||
if (cancelled || subtitlePreferenceTouchedRef.current) return
|
||||
const savedMode = normalizeSubtitleChineseMode(user.subtitle_chinese_mode)
|
||||
persistedSubtitleChineseModeRef.current = savedMode
|
||||
setSubtitleChineseMode(savedMode)
|
||||
setAuthUser(user)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [setAuthUser])
|
||||
|
||||
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
|
||||
useEffect(() => {
|
||||
danmakuAPI
|
||||
@@ -125,11 +173,45 @@ export function PlayerPage() {
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 读取弹幕配置(含按用户存储的合并偏好),初始化面板。
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
danmakuAPI
|
||||
.config()
|
||||
.then((cfg) => {
|
||||
if (cancelled) return
|
||||
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
||||
})
|
||||
.catch(() => {
|
||||
// 配置读取失败时保持默认(不合并),不影响播放。
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 切换合并开关:先落库,成功后再重新抓取,避免与后端读到的偏好不一致。
|
||||
const danmakuChangeMergeSources = useCallback((next: boolean) => {
|
||||
setDanmakuMergeSaving(true)
|
||||
danmakuAPI
|
||||
.updateSettings({ mergeSources: next })
|
||||
.then(() => {
|
||||
setDanmakuMergeSources(next)
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
})
|
||||
.catch(() => {
|
||||
// 保存失败时保持原值,用户可重试。
|
||||
})
|
||||
.finally(() => setDanmakuMergeSaving(false))
|
||||
}, [])
|
||||
|
||||
// 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。
|
||||
// loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。
|
||||
const searchDanmaku = useCallback((kw: string) => {
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuAlternatives([])
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuInfo(null)
|
||||
setDanmakuSearch(kw || null)
|
||||
@@ -141,6 +223,11 @@ export function PlayerPage() {
|
||||
setDanmakuInfo(info)
|
||||
}, [])
|
||||
|
||||
// 同一集的其它来源:弹幕已自动加载好,这里只更新可切换列表。
|
||||
const danmakuGotAlternatives = useCallback((alternatives: DanmakuAnime[]) => {
|
||||
setDanmakuAlternatives(alternatives)
|
||||
}, [])
|
||||
|
||||
// 多番剧命中(disambiguation):展示候选让用户选择。
|
||||
const danmakuGotCandidates = useCallback((candidates: DanmakuAnime[]) => {
|
||||
setDanmakuCandidates(candidates)
|
||||
@@ -154,6 +241,8 @@ export function PlayerPage() {
|
||||
const danmakuSelectEpisode = useCallback((episodeId: number, animeTitle: string, episodeTitle: string) => {
|
||||
setDanmakuEpisodeId(episodeId)
|
||||
setDanmakuCandidates([])
|
||||
// 刻意不清空 danmakuAlternatives:切到别的来源后仍要能继续切换回去,
|
||||
// 否则用户每次都得重新搜一遍。
|
||||
setDanmakuSearching(true)
|
||||
// 展示当前所选来源(面板标题处可见)。
|
||||
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
|
||||
@@ -164,6 +253,7 @@ export function PlayerPage() {
|
||||
const danmakuResetAuto = useCallback(() => {
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuAlternatives([])
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearch(null)
|
||||
setDanmakuSelectedSource('')
|
||||
@@ -178,6 +268,7 @@ export function PlayerPage() {
|
||||
setHlsStartSec(0)
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuAlternatives([])
|
||||
setDanmakuSearch(null)
|
||||
setDanmakuSelectedSource('')
|
||||
setDanmakuInfo(null)
|
||||
@@ -278,7 +369,10 @@ export function PlayerPage() {
|
||||
// Chromium 会把被中断的首次加载报告成播放错误并误触发 HLS 回退。
|
||||
const activeBurnedSubtitleStream = mode === 'hls' ? burnedSubtitleStream : undefined
|
||||
const mediaRef = useRef(media)
|
||||
mediaRef.current = media
|
||||
useEffect(() => {
|
||||
mediaRef.current = media
|
||||
}, [media])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaId || !ref.current) return
|
||||
const currentMedia = mediaRef.current
|
||||
@@ -465,9 +559,11 @@ export function PlayerPage() {
|
||||
|
||||
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
|
||||
const hlsStartSecRef = useRef(hlsStartSec)
|
||||
hlsStartSecRef.current = hlsStartSec
|
||||
const modeRef = useRef(mode)
|
||||
modeRef.current = mode
|
||||
useEffect(() => {
|
||||
hlsStartSecRef.current = hlsStartSec
|
||||
modeRef.current = mode
|
||||
}, [hlsStartSec, mode])
|
||||
|
||||
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
|
||||
useEffect(() => {
|
||||
@@ -761,6 +857,30 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
|
||||
|
||||
const changeSubtitleChineseMode = useCallback(
|
||||
(nextMode: SubtitleChineseMode) => {
|
||||
subtitlePreferenceTouchedRef.current = true
|
||||
setSubtitleChineseMode(nextMode)
|
||||
|
||||
const save = subtitlePreferenceSaveQueueRef.current.then(async () => {
|
||||
const user = await profileAPI.update({ subtitle_chinese_mode: nextMode })
|
||||
persistedSubtitleChineseModeRef.current = nextMode
|
||||
setAuthUser(user)
|
||||
toast.success('字幕转换偏好已保存,后续播放将自动沿用')
|
||||
})
|
||||
subtitlePreferenceSaveQueueRef.current = save.catch(() => undefined)
|
||||
void save.catch(() => {
|
||||
setSubtitleChineseMode((currentMode) =>
|
||||
currentMode === nextMode
|
||||
? persistedSubtitleChineseModeRef.current
|
||||
: currentMode,
|
||||
)
|
||||
toast.error('字幕转换偏好保存失败,已恢复上次设置')
|
||||
})
|
||||
},
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
const video = ref.current
|
||||
if (mode !== 'direct') {
|
||||
@@ -816,6 +936,16 @@ export function PlayerPage() {
|
||||
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
|
||||
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
|
||||
|
||||
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
|
||||
setSubtitlePosition(nextPosition)
|
||||
saveSubtitlePosition(nextPosition)
|
||||
}, [])
|
||||
|
||||
const changeSubtitleStyle = useCallback((nextStyle: SubtitleStylePreset) => {
|
||||
setSubtitleStyle(nextStyle)
|
||||
saveSubtitleStyle(nextStyle)
|
||||
}, [])
|
||||
|
||||
const danmakuAutoTitle =
|
||||
danmakuInfo?.animeTitle ||
|
||||
media?.original_name?.trim() ||
|
||||
@@ -846,6 +976,12 @@ export function PlayerPage() {
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={selectSubtitle}
|
||||
subtitleChineseMode={subtitleChineseMode}
|
||||
onSubtitleChineseModeChange={changeSubtitleChineseMode}
|
||||
subtitlePosition={subtitlePosition}
|
||||
onSubtitlePositionChange={changeSubtitlePosition}
|
||||
subtitleStyle={subtitleStyle}
|
||||
onSubtitleStyleChange={changeSubtitleStyle}
|
||||
videoRef={ref}
|
||||
onVideoError={handleVideoError}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
@@ -859,6 +995,7 @@ export function PlayerPage() {
|
||||
onToggleDanmaku={toggleDanmakuOpen}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
onDanmakuCandidates={danmakuGotCandidates}
|
||||
onDanmakuAlternatives={danmakuGotAlternatives}
|
||||
hasPrevEpisode={Boolean(prevEpisode)}
|
||||
hasNextEpisode={Boolean(nextEpisode)}
|
||||
onPrevEpisode={handlePrevEpisode}
|
||||
@@ -896,6 +1033,10 @@ export function PlayerPage() {
|
||||
fontSize={danmakuFontSize}
|
||||
onFontSizeChange={setDanmakuFontSize}
|
||||
candidates={danmakuCandidates}
|
||||
alternatives={danmakuAlternatives}
|
||||
mergeSources={danmakuMergeSources}
|
||||
onMergeSourcesChange={danmakuChangeMergeSources}
|
||||
mergeSaving={danmakuMergeSaving}
|
||||
selectedSource={danmakuSelectedSource}
|
||||
autoMatchTitle={danmakuAutoTitle}
|
||||
danmakuInfo={danmakuInfo}
|
||||
|
||||
@@ -1,58 +1,133 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { PointerEvent, ReactNode, RefObject } from 'react'
|
||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import type { Media } from '../types'
|
||||
import {
|
||||
loadSubtitleChineseConverter,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import { AssSubtitleStage } from '../components/AssSubtitleStage'
|
||||
import { DanmakuStage } from '../components/DanmakuStage'
|
||||
import { PlayerControls } from '../components/PlayerControls'
|
||||
import {
|
||||
subtitleTextStyle,
|
||||
type SubtitlePosition,
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||
|
||||
type SubtitleCue = {
|
||||
startTime: number
|
||||
endTime: number
|
||||
type SubtitleRenderGroup = {
|
||||
key: string
|
||||
text: string
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
function parseVTTTimestamp(value: string): number {
|
||||
const parts = value.trim().replace(',', '.').split(':')
|
||||
if (parts.length !== 2 && parts.length !== 3) return Number.NaN
|
||||
const seconds = Number(parts.pop())
|
||||
const minutes = Number(parts.pop())
|
||||
const hours = parts.length > 0 ? Number(parts.pop()) : 0
|
||||
if (![hours, minutes, seconds].every(Number.isFinite)) return Number.NaN
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
|
||||
const seen = new Set<string>()
|
||||
const unique: SubtitleCue[] = []
|
||||
for (const cue of cues) {
|
||||
const key = `${cue.startTime}\u0000${cue.endTime}\u0000${JSON.stringify(cue.settings)}\u0000${cue.text}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
unique.push(cue)
|
||||
}
|
||||
// WebVTT can contain more than two simultaneous tracks after ASS conversion.
|
||||
// Keep the last two visual blocks, matching the previous bilingual behavior.
|
||||
return unique.slice(-2)
|
||||
}
|
||||
|
||||
function parseWebVTTCues(body: string): SubtitleCue[] {
|
||||
const blocks = body
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
const cues: SubtitleCue[] = []
|
||||
function parsePercent(value: string | undefined): number | null {
|
||||
if (!value || !value.endsWith('%')) return null
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? Math.min(100, Math.max(0, parsed)) : null
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n')
|
||||
const timingIndex = lines.findIndex((line) => line.includes('-->'))
|
||||
if (timingIndex < 0) continue
|
||||
function cueLineBottom(value: string | undefined): number | null {
|
||||
const percent = parsePercent(value)
|
||||
if (percent !== null) return Math.min(92, Math.max(2, 100 - percent))
|
||||
if (value === undefined) return null
|
||||
const line = Number.parseFloat(value)
|
||||
if (!Number.isFinite(line)) return null
|
||||
if (line < 0) return Math.min(92, Math.max(2, 6 - Math.abs(line) * 2))
|
||||
return Math.min(92, Math.max(2, 7 + line * 8))
|
||||
}
|
||||
|
||||
const [rawStart, rawEnd] = lines[timingIndex].split('-->', 2)
|
||||
const startTime = parseVTTTimestamp(rawStart)
|
||||
const endTime = parseVTTTimestamp(rawEnd.trim().split(/\s+/, 1)[0])
|
||||
const text = lines.slice(timingIndex + 1).join('\n').trim()
|
||||
if (Number.isFinite(startTime) && Number.isFinite(endTime) && endTime >= startTime && text) {
|
||||
cues.push({ startTime, endTime, text })
|
||||
}
|
||||
function cueTextAlign(cue: SubtitleCue): CSSProperties['textAlign'] {
|
||||
const align = cue.settings.align
|
||||
if (align === 'left' || align === 'start') return 'left'
|
||||
if (align === 'right' || align === 'end') return 'right'
|
||||
return 'center'
|
||||
}
|
||||
|
||||
function cuePlacementStyle(cue: SubtitleCue, position: SubtitlePosition): CSSProperties {
|
||||
const style: CSSProperties = {
|
||||
maxWidth: `${Math.min(cue.settings.size ?? 92, 94)}%`,
|
||||
textAlign: cueTextAlign(cue),
|
||||
}
|
||||
|
||||
return cues
|
||||
if (cue.settings.vertical) {
|
||||
style.writingMode = cue.settings.vertical === 'lr' ? 'vertical-lr' : 'vertical-rl'
|
||||
}
|
||||
|
||||
if (position !== 'auto') {
|
||||
style.left = '50%'
|
||||
style.textAlign = 'center'
|
||||
if (position === 'bottom') {
|
||||
style.bottom = '5%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
} else if (position === 'lower') {
|
||||
style.bottom = '13%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
} else if (position === 'middle') {
|
||||
style.top = '50%'
|
||||
style.transform = 'translate(-50%, -50%)'
|
||||
} else {
|
||||
style.top = '7%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
const explicitPosition = cue.settings.position
|
||||
const align = cue.settings.align
|
||||
if (explicitPosition !== undefined) {
|
||||
if (align === 'end' || align === 'right') {
|
||||
style.right = `${100 - explicitPosition}%`
|
||||
} else {
|
||||
style.left = `${explicitPosition}%`
|
||||
if (align !== 'start' && align !== 'left') style.transform = 'translateX(-50%)'
|
||||
}
|
||||
} else {
|
||||
style.left = '50%'
|
||||
style.transform = 'translateX(-50%)'
|
||||
}
|
||||
|
||||
const bottom = cueLineBottom(cue.settings.line)
|
||||
if (bottom !== null) style.bottom = `${bottom}%`
|
||||
else style.bottom = '7%'
|
||||
return style
|
||||
}
|
||||
|
||||
function uniqueSubtitleTexts(texts: string[]): string[] {
|
||||
const unique = [...new Set(texts.map((text) => text.trim()).filter(Boolean))]
|
||||
// ponytail: WebVTT cannot preserve ASS layers/positions. Keep at most two
|
||||
// simultaneous blocks for bilingual subtitles; use libass if full ASS
|
||||
// typesetting support is added later.
|
||||
return unique.slice(-2)
|
||||
function buildSubtitleRenderGroups(
|
||||
cues: SubtitleCue[],
|
||||
position: SubtitlePosition,
|
||||
): SubtitleRenderGroup[] {
|
||||
const groups = new Map<string, SubtitleRenderGroup>()
|
||||
for (const cue of cues) {
|
||||
const style = cuePlacementStyle(cue, position)
|
||||
const key = JSON.stringify(style)
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
if (!existing.text.split('\n').includes(cue.text)) {
|
||||
existing.text += `\n${cue.text}`
|
||||
}
|
||||
continue
|
||||
}
|
||||
groups.set(key, { key, text: cue.text, style })
|
||||
}
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
type PlayerVideoStageProps = {
|
||||
@@ -64,6 +139,12 @@ type PlayerVideoStageProps = {
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
onSelectSubtitle: (index: number) => void
|
||||
subtitleChineseMode: SubtitleChineseMode
|
||||
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
|
||||
subtitlePosition: SubtitlePosition
|
||||
onSubtitlePositionChange: (position: SubtitlePosition) => void
|
||||
subtitleStyle: SubtitleStylePreset
|
||||
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
onVideoError: () => void
|
||||
danmakuEnabled: boolean
|
||||
@@ -77,6 +158,7 @@ type PlayerVideoStageProps = {
|
||||
onToggleDanmaku: () => void
|
||||
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
|
||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||
onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
|
||||
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
danmakuPanel: ReactNode
|
||||
/** Playlist drawer / panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
@@ -102,6 +184,12 @@ export function PlayerVideoStage({
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
subtitleChineseMode,
|
||||
onSubtitleChineseModeChange,
|
||||
subtitlePosition,
|
||||
onSubtitlePositionChange,
|
||||
subtitleStyle,
|
||||
onSubtitleStyleChange,
|
||||
videoRef,
|
||||
onVideoError,
|
||||
danmakuEnabled,
|
||||
@@ -115,6 +203,7 @@ export function PlayerVideoStage({
|
||||
onToggleDanmaku,
|
||||
onDanmakuLoaded,
|
||||
onDanmakuCandidates,
|
||||
onDanmakuAlternatives,
|
||||
danmakuPanel,
|
||||
playlistPanel,
|
||||
hasPrevEpisode,
|
||||
@@ -136,7 +225,9 @@ export function PlayerVideoStage({
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
const revealControlsOnlyRef = useRef(false)
|
||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||
const [activeCueText, setActiveCueText] = useState<string>('')
|
||||
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
|
||||
const [subtitleChineseConverter, setSubtitleChineseConverter] =
|
||||
useState<(text: string) => string>(() => (text: string) => text)
|
||||
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
|
||||
// <track>.track.cues;独立时间轴不受 MediaSource 重挂载和轨道 mode 切换影响。
|
||||
const [subtitleTimeline, setSubtitleTimeline] = useState<{
|
||||
@@ -145,6 +236,40 @@ export function PlayerVideoStage({
|
||||
} | null>(null)
|
||||
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
|
||||
const [tracksArmed, setTracksArmed] = useState(false)
|
||||
// libass/WASM is optional at runtime. If it fails, ASS tracks fall back to
|
||||
// the server's WebVTT conversion so playback still has visible subtitles.
|
||||
const [assFallbackPath, setAssFallbackPath] = useState<string | null>(null)
|
||||
const activeSubtitleTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
setAssFallbackPath(null)
|
||||
}, [media?.id, activeSubtitleTrack?.path, subs])
|
||||
|
||||
useEffect(() => {
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
let cancelled = false
|
||||
|
||||
setSubtitleChineseConverter(() => (text: string) => text)
|
||||
if (
|
||||
subtitleChineseMode === 'original' ||
|
||||
selectedTrack?.source !== 'external' ||
|
||||
selectedTrack.delivery !== 'webvtt'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void loadSubtitleChineseConverter(subtitleChineseMode)
|
||||
.then((converter) => {
|
||||
if (!cancelled) setSubtitleChineseConverter(() => converter)
|
||||
})
|
||||
.catch(() => {
|
||||
// 字典分包加载失败时保留原文,避免影响字幕正常显示。
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [subs, subtitleIndex, subtitleChineseMode])
|
||||
|
||||
useEffect(() => {
|
||||
setTracksArmed(false)
|
||||
@@ -165,7 +290,10 @@ export function PlayerVideoStage({
|
||||
|
||||
useEffect(() => {
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
if (!media || subtitleIndex < 0 || !selectedTrack || selectedTrack.delivery !== 'webvtt') {
|
||||
const useWebVTT =
|
||||
selectedTrack?.delivery === 'webvtt' ||
|
||||
(selectedTrack?.delivery === 'ass' && assFallbackPath === selectedTrack.path)
|
||||
if (!media || subtitleIndex < 0 || !selectedTrack || !useWebVTT) {
|
||||
setSubtitleTimeline(null)
|
||||
return
|
||||
}
|
||||
@@ -191,7 +319,7 @@ export function PlayerVideoStage({
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [media, subs, subtitleIndex])
|
||||
}, [assFallbackPath, media, subs, subtitleIndex])
|
||||
|
||||
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
|
||||
useEffect(() => {
|
||||
@@ -267,21 +395,22 @@ export function PlayerVideoStage({
|
||||
subs.length === 0 ||
|
||||
subtitleIndex < 0 ||
|
||||
!selectedTrack ||
|
||||
selectedTrack.delivery === 'burn'
|
||||
(selectedTrack.delivery !== 'webvtt' &&
|
||||
!(selectedTrack.delivery === 'ass' && assFallbackPath === selectedTrack.path))
|
||||
) {
|
||||
setActiveCueText('')
|
||||
setActiveCues([])
|
||||
return
|
||||
}
|
||||
|
||||
const updateCue = () => {
|
||||
const absoluteTime = video.currentTime + (streamOffset ?? 0)
|
||||
if (subtitleTimeline?.path === selectedTrack.path) {
|
||||
const texts = uniqueSubtitleTexts(
|
||||
subtitleTimeline.cues
|
||||
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
||||
.map((cue) => cue.text),
|
||||
)
|
||||
setActiveCueText(texts.join('\n'))
|
||||
const cues = uniqueSubtitleCues(
|
||||
subtitleTimeline.cues.filter(
|
||||
(cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime,
|
||||
),
|
||||
).map((cue) => ({ ...cue, text: subtitleChineseConverter(cue.text) }))
|
||||
setActiveCues(cues)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -290,17 +419,19 @@ export function PlayerVideoStage({
|
||||
)
|
||||
const tt = selectedEl?.track
|
||||
if (!tt) {
|
||||
setActiveCueText('')
|
||||
setActiveCues([])
|
||||
return
|
||||
}
|
||||
|
||||
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
|
||||
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
|
||||
const texts: string[] = []
|
||||
const cues: SubtitleCue[] = []
|
||||
if ((!streamOffset || streamOffset <= 0.05) && tt.activeCues && tt.activeCues.length > 0) {
|
||||
for (let i = 0; i < tt.activeCues.length; i++) {
|
||||
const cue = tt.activeCues[i] as VTTCue
|
||||
if (cue && cue.text) texts.push(cue.text)
|
||||
if (cue && cue.text) {
|
||||
cues.push({ startTime: cue.startTime, endTime: cue.endTime, text: cue.text, settings: {} })
|
||||
}
|
||||
}
|
||||
} else if (tt.cues && tt.cues.length > 0) {
|
||||
for (let i = 0; i < tt.cues.length; i++) {
|
||||
@@ -311,11 +442,16 @@ export function PlayerVideoStage({
|
||||
absoluteTime <= cue.endTime &&
|
||||
cue.text
|
||||
) {
|
||||
texts.push(cue.text)
|
||||
cues.push({ startTime: cue.startTime, endTime: cue.endTime, text: cue.text, settings: {} })
|
||||
}
|
||||
}
|
||||
}
|
||||
setActiveCueText(uniqueSubtitleTexts(texts).join('\n'))
|
||||
setActiveCues(
|
||||
uniqueSubtitleCues(cues).map((cue) => ({
|
||||
...cue,
|
||||
text: subtitleChineseConverter(cue.text),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
@@ -370,7 +506,17 @@ export function PlayerVideoStage({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline, tracksArmed])
|
||||
}, [
|
||||
subtitleIndex,
|
||||
subs,
|
||||
videoRef,
|
||||
media,
|
||||
streamOffset,
|
||||
subtitleTimeline,
|
||||
tracksArmed,
|
||||
subtitleChineseConverter,
|
||||
assFallbackPath,
|
||||
])
|
||||
|
||||
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
||||
const isWiderThanStage =
|
||||
@@ -378,6 +524,7 @@ export function PlayerVideoStage({
|
||||
? videoRatio > stageRect.width / stageRect.height
|
||||
: true
|
||||
|
||||
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const wrapperStyle = videoRatio
|
||||
? {
|
||||
aspectRatio: `${videoRatio}`,
|
||||
@@ -414,8 +561,11 @@ export function PlayerVideoStage({
|
||||
onError={onVideoError}
|
||||
>
|
||||
{tracksArmed &&
|
||||
subs.map((track, index) =>
|
||||
track.delivery === 'burn' ? null : (
|
||||
subs.map((track, index) => {
|
||||
const renderAsFallback =
|
||||
track.delivery === 'ass' && assFallbackPath === track.path
|
||||
if (track.delivery !== 'webvtt' && !renderAsFallback) return null
|
||||
return (
|
||||
<track
|
||||
key={track.path}
|
||||
data-subtitle-index={index}
|
||||
@@ -424,8 +574,8 @@ export function PlayerVideoStage({
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</video>
|
||||
<DanmakuStage
|
||||
key={media.id}
|
||||
@@ -440,20 +590,42 @@ export function PlayerVideoStage({
|
||||
searchTrigger={danmakuSearchTrigger}
|
||||
onLoaded={onDanmakuLoaded}
|
||||
onCandidates={onDanmakuCandidates}
|
||||
onAlternatives={onDanmakuAlternatives}
|
||||
/>
|
||||
{/* 自定义沉浸式字幕层:纯透明背景 + 柔和阴影,完全消除浏览器原生黑框 */}
|
||||
{activeCueText ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-4 sm:bottom-6 md:bottom-8 z-10 flex justify-center text-center px-4">
|
||||
<span
|
||||
className="inline-block max-w-[92%] whitespace-pre-line text-center font-sans font-medium text-white text-base sm:text-lg md:text-xl lg:text-2xl select-none"
|
||||
style={{
|
||||
textShadow:
|
||||
'0 1px 3px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.85), 0 0 16px rgba(0, 0, 0, 0.65)',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{activeCueText}
|
||||
</span>
|
||||
{tracksArmed && media && assTrack && assFallbackPath !== assTrack.path ? (
|
||||
<AssSubtitleStage
|
||||
key={`${media.id}:${assTrack.path}`}
|
||||
videoRef={videoRef}
|
||||
source={subtitlesAPI.assUrl(media.id, assTrack.path)}
|
||||
timeOffset={streamOffset}
|
||||
chineseMode={assTrack.source === 'external' ? subtitleChineseMode : 'original'}
|
||||
onError={(error) => {
|
||||
console.warn('libass subtitle rendering failed; falling back to WebVTT', error)
|
||||
setAssFallbackPath(assTrack.path)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* SRT/VTT 自定义字幕层:按 cue 位置对齐,并支持用户样式覆盖。 */}
|
||||
{activeCues.length > 0 ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-10">
|
||||
{buildSubtitleRenderGroups(activeCues, subtitlePosition).map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
className="absolute flex px-4"
|
||||
style={group.style}
|
||||
>
|
||||
<span
|
||||
className="inline-block whitespace-pre-line font-sans text-white select-none"
|
||||
style={{
|
||||
...subtitleTextStyle(subtitleStyle),
|
||||
fontSize: 'clamp(16px, 2.1vw, 30px)',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{group.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -464,6 +636,12 @@ export function PlayerVideoStage({
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
subtitleChineseMode={subtitleChineseMode}
|
||||
onSubtitleChineseModeChange={onSubtitleChineseModeChange}
|
||||
subtitlePosition={subtitlePosition}
|
||||
onSubtitlePositionChange={onSubtitlePositionChange}
|
||||
subtitleStyle={subtitleStyle}
|
||||
onSubtitleStyleChange={onSubtitleStyleChange}
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
|
||||
@@ -23,7 +23,8 @@ import { imageURL } from '../api/client'
|
||||
import { scraperAPI } from '../api/scraper'
|
||||
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
|
||||
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
|
||||
import { PROVIDER_LABELS, ScrapeDetailModal } from './scraper-queue/ScrapeDetailModal'
|
||||
import { ScrapeDetailModal } from './scraper-queue/ScrapeDetailModal'
|
||||
import { PROVIDER_LABELS } from './scraper-queue/scrapeLabels'
|
||||
import { copyToClipboard, useTaskSelection } from './queue-shared'
|
||||
|
||||
const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
|
||||
@@ -196,9 +197,8 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
}
|
||||
|
||||
// Filter and search
|
||||
const tasks = snapshot?.tasks ?? []
|
||||
const filteredTasks = useMemo(() => {
|
||||
let list = tasks
|
||||
let list = snapshot?.tasks ?? []
|
||||
if (filter !== 'all') {
|
||||
list = list.filter((t) => t.status === filter)
|
||||
}
|
||||
@@ -214,7 +214,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [tasks, filter, search])
|
||||
}, [snapshot?.tasks, filter, search])
|
||||
|
||||
const counts = snapshot?.counts
|
||||
const pendingCount = counts?.pending ?? 0
|
||||
|
||||
@@ -21,7 +21,8 @@ import { strmAPI } from '../api/strm'
|
||||
import type { StrmQueueSnapshot, StrmTask, StrmTaskStatus } from '../types/strm'
|
||||
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
||||
import { apiErrorMessage, formatBytes, formatTime, taskStatusMeta } from './StrmManagePage'
|
||||
import { TaskDetailModal, getFileIcon } from './strm-queue/TaskDetailModal'
|
||||
import { TaskDetailModal } from './strm-queue/TaskDetailModal'
|
||||
import { getFileIcon } from './strm-queue/taskFileIcon'
|
||||
import { copyToClipboard, useTaskSelection } from './queue-shared'
|
||||
|
||||
const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
|
||||
@@ -190,9 +191,8 @@ export function StrmQueuePanel({
|
||||
}
|
||||
|
||||
// Filter and search tasks in memory
|
||||
const tasks = snapshot?.tasks ?? []
|
||||
const filteredTasks = useMemo(() => {
|
||||
let list = tasks
|
||||
let list = snapshot?.tasks ?? []
|
||||
if (filter !== 'all') {
|
||||
list = list.filter((t) => t.status === filter)
|
||||
}
|
||||
@@ -207,7 +207,7 @@ export function StrmQueuePanel({
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [tasks, filter, search])
|
||||
}, [snapshot?.tasks, filter, search])
|
||||
|
||||
const counts = snapshot?.counts
|
||||
const pendingCount = counts?.pending ?? 0
|
||||
|
||||
@@ -4,21 +4,7 @@ import { AlertCircle, Ban, Copy, ExternalLink, RefreshCw, Sparkles, Trash2, X }
|
||||
import { imageURL } from '../../api/client'
|
||||
import type { ScrapeTask } from '../../types/scraper'
|
||||
import { formatTime, taskStatusMeta } from '../StrmManagePage'
|
||||
|
||||
export const PROVIDER_LABELS: Record<string, string> = {
|
||||
tmdb: 'TheMovieDB',
|
||||
douban: '豆瓣 Douban',
|
||||
bangumi: 'Bangumi 番组计划',
|
||||
thetvdb: 'TheTVDB',
|
||||
metatube: 'MetaTube',
|
||||
}
|
||||
|
||||
export const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '剧集',
|
||||
anime: '动漫',
|
||||
adult: 'Adult',
|
||||
}
|
||||
import { PROVIDER_LABELS, TYPE_LABELS } from './scrapeLabels'
|
||||
|
||||
export function ScrapeDetailModal({
|
||||
task,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const PROVIDER_LABELS: Record<string, string> = {
|
||||
tmdb: 'TheMovieDB',
|
||||
douban: '豆瓣 Douban',
|
||||
bangumi: 'Bangumi 番组计划',
|
||||
thetvdb: 'TheTVDB',
|
||||
metatube: 'MetaTube',
|
||||
}
|
||||
|
||||
export const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '剧集',
|
||||
anime: '动漫',
|
||||
adult: 'Adult',
|
||||
}
|
||||
@@ -1,26 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertCircle, Ban, Copy, FileText, Film, Image as ImageIcon, File, MessageSquare, RefreshCw, Trash2, X } from 'lucide-react'
|
||||
import { AlertCircle, Ban, Copy, RefreshCw, Trash2, X } from 'lucide-react'
|
||||
|
||||
import type { StrmTask } from '../../types/strm'
|
||||
import { STRM_PROVIDER_LABELS } from '../../types/strm'
|
||||
import { formatBytes, formatTime, taskStatusMeta } from '../StrmManagePage'
|
||||
|
||||
export function getFileIcon(filename: string): ReactNode {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (['nfo', 'txt', 'xml', 'json'].includes(ext)) {
|
||||
return <FileText size={15} className="text-amber-500 shrink-0" />
|
||||
}
|
||||
if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) {
|
||||
return <ImageIcon size={15} className="text-blue-500 shrink-0" />
|
||||
}
|
||||
if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) {
|
||||
return <MessageSquare size={15} className="text-purple-500 shrink-0" />
|
||||
}
|
||||
if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) {
|
||||
return <Film size={15} className="text-emerald-500 shrink-0" />
|
||||
}
|
||||
return <File size={15} className="text-gray-400 shrink-0" />
|
||||
}
|
||||
import { getFileIcon } from './taskFileIcon'
|
||||
|
||||
export function TaskDetailModal({
|
||||
task,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { File, FileText, Film, Image as ImageIcon, MessageSquare } from 'lucide-react'
|
||||
|
||||
export function getFileIcon(filename: string): ReactNode {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (['nfo', 'txt', 'xml', 'json'].includes(ext)) {
|
||||
return <FileText size={15} className="text-amber-500 shrink-0" />
|
||||
}
|
||||
if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) {
|
||||
return <ImageIcon size={15} className="text-blue-500 shrink-0" />
|
||||
}
|
||||
if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) {
|
||||
return <MessageSquare size={15} className="text-purple-500 shrink-0" />
|
||||
}
|
||||
if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) {
|
||||
return <Film size={15} className="text-emerald-500 shrink-0" />
|
||||
}
|
||||
return <File size={15} className="text-gray-400 shrink-0" />
|
||||
}
|
||||
@@ -180,7 +180,7 @@ function useLibraryActions(refresh: () => Promise<void>) {
|
||||
}
|
||||
|
||||
const toggleCarouselLibrary = async (library: Library) => {
|
||||
const next = !Boolean(library.carousel_enabled)
|
||||
const next = !library.carousel_enabled
|
||||
await libraryAPI.update(library.id, { carousel_enabled: next })
|
||||
toast.success(next ? `「${library.name}」已加入首页轮播` : `「${library.name}」已移出首页轮播`)
|
||||
await refresh()
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface User {
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
hide_adult?: boolean
|
||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
allowed_library_ids?: string[]
|
||||
|
||||
@@ -480,20 +480,31 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
|
||||
if (betterSeriesLinkMedia(m, g.linkMedia)) {
|
||||
g.linkMedia = m
|
||||
}
|
||||
const currentArtwork = artworkScore(m)
|
||||
const representativeArtwork = artworkScore(g.rep)
|
||||
if (currentArtwork > representativeArtwork) {
|
||||
if (betterSeriesRepresentative(m, g.rep)) {
|
||||
g.rep = m
|
||||
} else if (currentArtwork === representativeArtwork) {
|
||||
const cur = (m.season_num ?? 0) * 10000 + (m.episode_num ?? 0)
|
||||
const rep = (g.rep.season_num ?? 0) * 10000 + (g.rep.episode_num ?? 0)
|
||||
if (cur > 0 && (rep === 0 || cur < rep)) g.rep = m
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
}
|
||||
|
||||
function betterSeriesRepresentative(candidate: Media, current: Media): boolean {
|
||||
// A theatrical feature can have local poster/background artwork that scores
|
||||
// higher than the TV poster. Keep the TV row as the series-card identity so
|
||||
// the movie cannot replace the whole series title and overview.
|
||||
const candidateTheatrical = isTheatricalFeature(candidate)
|
||||
const currentTheatrical = isTheatricalFeature(current)
|
||||
if (candidateTheatrical !== currentTheatrical) return !candidateTheatrical
|
||||
|
||||
const candidateArtwork = artworkScore(candidate)
|
||||
const currentArtwork = artworkScore(current)
|
||||
if (candidateArtwork !== currentArtwork) return candidateArtwork > currentArtwork
|
||||
|
||||
const cur = (candidate.season_num ?? 0) * 10000 + (candidate.episode_num ?? 0)
|
||||
const rep = (current.season_num ?? 0) * 10000 + (current.episode_num ?? 0)
|
||||
return cur > 0 && (rep === 0 || cur < rep)
|
||||
}
|
||||
|
||||
function repeatedSeriesExternalRawKey(media: Media): string {
|
||||
if (!isEpisodeLike(media) && !pathLooksEpisodic(media)) return ''
|
||||
let identity = ''
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export type SubtitleChineseConverter = (text: string) => string
|
||||
|
||||
function commaIndexAtField(line: string, fieldCount: number): number {
|
||||
let index = -1
|
||||
for (let field = 0; field < fieldCount; field += 1) {
|
||||
index = line.indexOf(',', index + 1)
|
||||
if (index < 0) return -1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert only the Text column of ASS/SSA Dialogue lines. ASS styling tags,
|
||||
* drawings, timing and positioning metadata are bytes-for-bytes preserved so
|
||||
* the event can still be rendered by libass after a Chinese conversion.
|
||||
*/
|
||||
export function convertASSContent(
|
||||
content: string,
|
||||
convert: SubtitleChineseConverter,
|
||||
): string {
|
||||
return content
|
||||
.replace(/^\uFEFF/, '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
const lower = line.toLowerCase()
|
||||
if (!lower.startsWith('dialogue:')) return line
|
||||
const textIndex = commaIndexAtField(line, 9)
|
||||
if (textIndex < 0) return line
|
||||
return `${line.slice(0, textIndex + 1)}${convert(line.slice(textIndex + 1))}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type SubtitleChineseMode = 'original' | 'simplified' | 'traditional'
|
||||
|
||||
type ChineseConverter = (text: string) => string
|
||||
|
||||
const identity: ChineseConverter = (text) => text
|
||||
|
||||
export function normalizeSubtitleChineseMode(value: unknown): SubtitleChineseMode {
|
||||
return value === 'simplified' || value === 'traditional' ? value : 'original'
|
||||
}
|
||||
|
||||
let simplifiedConverter: Promise<ChineseConverter> | undefined
|
||||
let traditionalConverter: Promise<ChineseConverter> | undefined
|
||||
|
||||
export function loadSubtitleChineseConverter(
|
||||
mode: SubtitleChineseMode,
|
||||
): Promise<ChineseConverter> {
|
||||
if (mode === 'original') return Promise.resolve(identity)
|
||||
|
||||
if (mode === 'simplified') {
|
||||
simplifiedConverter ??= import('opencc-js/t2cn').then(({ Converter }) =>
|
||||
Converter({ from: 't', to: 'cn' }),
|
||||
)
|
||||
return simplifiedConverter
|
||||
}
|
||||
|
||||
traditionalConverter ??= import('opencc-js/cn2t').then(({ Converter }) =>
|
||||
Converter({ from: 'cn', to: 'tw' }),
|
||||
)
|
||||
return traditionalConverter
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
export type SubtitlePosition = 'auto' | 'bottom' | 'lower' | 'middle' | 'top'
|
||||
export type SubtitleStylePreset = 'shadow' | 'outline' | 'box'
|
||||
|
||||
export const SUBTITLE_POSITION_OPTIONS: Array<[SubtitlePosition, string]> = [
|
||||
['auto', '默认位置'],
|
||||
['bottom', '贴底'],
|
||||
['lower', '下方'],
|
||||
['middle', '居中'],
|
||||
['top', '顶部'],
|
||||
]
|
||||
|
||||
export const SUBTITLE_STYLE_OPTIONS: Array<[SubtitleStylePreset, string]> = [
|
||||
['shadow', '柔和阴影'],
|
||||
['outline', '黑边描边'],
|
||||
['box', '半透明底衬'],
|
||||
]
|
||||
|
||||
const POSITION_KEY = 'mebox.subtitle.position'
|
||||
const STYLE_KEY = 'mebox.subtitle.style'
|
||||
|
||||
export function normalizeSubtitlePosition(value: unknown): SubtitlePosition {
|
||||
return value === 'bottom' || value === 'lower' || value === 'middle' || value === 'top'
|
||||
? value
|
||||
: 'auto'
|
||||
}
|
||||
|
||||
export function normalizeSubtitleStyle(value: unknown): SubtitleStylePreset {
|
||||
return value === 'outline' || value === 'box' ? value : 'shadow'
|
||||
}
|
||||
|
||||
export function loadSubtitlePosition(): SubtitlePosition {
|
||||
if (typeof window === 'undefined') return 'auto'
|
||||
try {
|
||||
return normalizeSubtitlePosition(window.localStorage.getItem(POSITION_KEY))
|
||||
} catch {
|
||||
return 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSubtitleStyle(): SubtitleStylePreset {
|
||||
if (typeof window === 'undefined') return 'shadow'
|
||||
try {
|
||||
return normalizeSubtitleStyle(window.localStorage.getItem(STYLE_KEY))
|
||||
} catch {
|
||||
return 'shadow'
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSubtitlePosition(position: SubtitlePosition): void {
|
||||
try {
|
||||
window.localStorage.setItem(POSITION_KEY, position)
|
||||
} catch {
|
||||
// localStorage may be unavailable in private mode; the player still works.
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSubtitleStyle(style: SubtitleStylePreset): void {
|
||||
try {
|
||||
window.localStorage.setItem(STYLE_KEY, style)
|
||||
} catch {
|
||||
// localStorage may be unavailable in private mode; the player still works.
|
||||
}
|
||||
}
|
||||
|
||||
export function subtitleTextStyle(style: SubtitleStylePreset): CSSProperties {
|
||||
if (style === 'outline') {
|
||||
return {
|
||||
fontWeight: 700,
|
||||
textShadow: [
|
||||
'-1.5px 0 #000',
|
||||
'1.5px 0 #000',
|
||||
'0 -1.5px #000',
|
||||
'0 1.5px #000',
|
||||
'-1px -1px #000',
|
||||
'1px -1px #000',
|
||||
'-1px 1px #000',
|
||||
'1px 1px #000',
|
||||
].join(', '),
|
||||
}
|
||||
}
|
||||
if (style === 'box') {
|
||||
return {
|
||||
fontWeight: 600,
|
||||
background: 'rgba(0, 0, 0, 0.72)',
|
||||
padding: '0.16em 0.5em',
|
||||
borderRadius: '0.24em',
|
||||
boxDecorationBreak: 'clone',
|
||||
WebkitBoxDecorationBreak: 'clone',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.85)',
|
||||
}
|
||||
}
|
||||
return {
|
||||
fontWeight: 500,
|
||||
textShadow:
|
||||
'0 1px 3px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.85), 0 0 16px rgba(0, 0, 0, 0.65)',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export type SubtitleCueAlignment = 'start' | 'center' | 'end' | 'left' | 'right'
|
||||
export type SubtitleCueVertical = 'rl' | 'lr'
|
||||
export type SubtitleCuePositionAlignment = SubtitleCueAlignment | 'line-left' | 'line-right' | 'auto'
|
||||
|
||||
export type SubtitleCueSettings = {
|
||||
align?: SubtitleCueAlignment
|
||||
line?: string
|
||||
lineAlign?: SubtitleCueAlignment
|
||||
position?: number
|
||||
positionAlign?: SubtitleCuePositionAlignment
|
||||
size?: number
|
||||
vertical?: SubtitleCueVertical
|
||||
}
|
||||
|
||||
export type SubtitleCue = {
|
||||
startTime: number
|
||||
endTime: number
|
||||
text: string
|
||||
settings: SubtitleCueSettings
|
||||
}
|
||||
|
||||
function parseVTTTimestamp(value: string): number {
|
||||
const parts = value.trim().replace(',', '.').split(':')
|
||||
if (parts.length !== 2 && parts.length !== 3) return Number.NaN
|
||||
const seconds = Number(parts.pop())
|
||||
const minutes = Number(parts.pop())
|
||||
const hours = parts.length > 0 ? Number(parts.pop()) : 0
|
||||
if (![hours, minutes, seconds].every(Number.isFinite)) return Number.NaN
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
function parseCueSettings(tokens: string[]): SubtitleCueSettings {
|
||||
const settings: SubtitleCueSettings = {}
|
||||
for (const token of tokens) {
|
||||
const colon = token.indexOf(':')
|
||||
if (colon <= 0) continue
|
||||
const key = token.slice(0, colon).toLowerCase()
|
||||
const value = token.slice(colon + 1)
|
||||
if (key === 'align') {
|
||||
if (['start', 'center', 'end', 'left', 'right'].includes(value)) {
|
||||
settings.align = value as SubtitleCueAlignment
|
||||
}
|
||||
} else if (key === 'line') {
|
||||
settings.line = value
|
||||
} else if (key === 'linealign') {
|
||||
if (['start', 'center', 'end'].includes(value)) {
|
||||
settings.lineAlign = value as SubtitleCueAlignment
|
||||
}
|
||||
} else if (key === 'position') {
|
||||
const position = Number.parseFloat(value)
|
||||
if (Number.isFinite(position)) settings.position = Math.min(100, Math.max(0, position))
|
||||
} else if (key === 'positionalign') {
|
||||
if (['start', 'center', 'end', 'line-left', 'line-right', 'auto'].includes(value)) {
|
||||
settings.positionAlign = value as SubtitleCuePositionAlignment
|
||||
}
|
||||
} else if (key === 'size') {
|
||||
const size = Number.parseFloat(value)
|
||||
if (Number.isFinite(size)) settings.size = Math.min(100, Math.max(0, size))
|
||||
} else if (key === 'vertical') {
|
||||
if (value === 'rl' || value === 'lr') settings.vertical = value
|
||||
}
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
export function parseWebVTTCues(body: string): SubtitleCue[] {
|
||||
const blocks = body
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
const cues: SubtitleCue[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n')
|
||||
const timingIndex = lines.findIndex((line) => line.includes('-->'))
|
||||
if (timingIndex < 0) continue
|
||||
|
||||
const [rawStart, rawRest] = lines[timingIndex].split('-->', 2)
|
||||
const rest = rawRest.trim().split(/\s+/)
|
||||
const startTime = parseVTTTimestamp(rawStart)
|
||||
const endTime = parseVTTTimestamp(rest[0] || '')
|
||||
const text = lines.slice(timingIndex + 1).join('\n').trim()
|
||||
if (Number.isFinite(startTime) && Number.isFinite(endTime) && endTime >= startTime && text) {
|
||||
cues.push({
|
||||
startTime,
|
||||
endTime,
|
||||
text,
|
||||
settings: parseCueSettings(rest.slice(1)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return cues
|
||||
}
|
||||
@@ -21,6 +21,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
// JASSUB ships its libass worker as an ES module. Vite's default IIFE
|
||||
// worker output cannot split that worker's own imports.
|
||||
format: 'es',
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
|
||||
Reference in New Issue
Block a user