Compare commits

..

9 Commits

Author SHA1 Message Date
truewhile 6797394c5b Serve font assets with caching and test missing-font handling 2026-09-12 10:33:51 +08:00
truewhile e71d9d61d1 优先使用刮削元数据匹配弹幕剧集 2026-09-12 10:27:55 +08:00
truewhile 1ff60d5641 完善应用功能与界面交互 2026-09-12 01:00:19 +08:00
truewhile 3f361631d0 修复搜索下拉框异步响应导致的意外展开 2026-09-11 21:30:30 +08:00
truewhile cfe0c0cf28 Preserve media metadata across STRM path renames 2026-09-11 20:45:54 +08:00
truewhile d6355d5582 Keep TV episodes as mixed-series representatives 2026-09-11 19:52:49 +08:00
truewhile 0f06bdf929 支持 ASS 字幕原样渲染与字幕显示设置 2026-09-11 19:35:08 +08:00
truewhile e840cbd2c9 处理bug 2026-09-11 15:35:02 +08:00
truewhile 2bb3610da7 添加繁体转简体,简体转繁体功能 2026-09-11 15:14:24 +08:00
84 changed files with 4682 additions and 396 deletions
+1
View File
@@ -14,6 +14,7 @@ verify-cache/
verify-media/
verify-downloads/
.codex-*
.codex/
.tmp/
.tmp_*
.tmp-deploy-*
+1
View File
@@ -66,6 +66,7 @@ config.yaml
.tmp-live-backups/
.tmp-*
.codex-*
.codex/
downloads/
media/
*.pid
+29
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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", "")
+5
View File
@@ -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 {
+37 -2
View File
@@ -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})
}
}
+135
View File
@@ -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)
}
}
+43
View File
@@ -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")
+18
View File
@@ -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))
+16
View File
@@ -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")
+7
View File
@@ -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.
+86 -26
View File
@@ -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)
}
}
+391 -13
View File
@@ -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 → 拉弹幕。
+220
View File
@@ -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)
}
+125
View File
@@ -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)
}
+564 -32
View File
@@ -13,10 +13,12 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"go.uber.org/zap"
@@ -57,6 +59,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 +81,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
@@ -177,6 +195,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 +239,26 @@ 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 的带偏好版本。
func (s *DanmakuService) FetchWithOptions(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 +294,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 +316,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 +377,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 +393,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 +966,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 +1019,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 +1040,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 +1064,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
}
+2 -1
View File
@@ -22,7 +22,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()
+48
View File
@@ -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) == "" {
+14
View File
@@ -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
+2 -2
View File
@@ -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
}
+25 -6
View File
@@ -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 {
+269
View File
@@ -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))
}
+232
View File
@@ -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")
}
}
+9 -6
View File
@@ -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))
+19
View File
@@ -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)
}
}
+101
View File
@@ -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
}
+22 -9
View File
@@ -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(&current)
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 {
+6 -3
View File
@@ -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)
}
+71 -16
View File
@@ -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{})
+6 -2
View File
@@ -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)
+16 -6
View File
@@ -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 {
+60
View File
@@ -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)
}
}
+4 -2
View File
@@ -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
}
+99 -58
View File
@@ -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
+53 -11
View File
@@ -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) {
+4
View File
@@ -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
}
+11
View File
@@ -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 != "" {
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
+1 -1
View File
@@ -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,预告"
)
+6
View File
@@ -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
+11
View File
@@ -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")
}
}
+66 -2
View File
@@ -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
}
+26 -1
View File
@@ -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")
}
}
+45
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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),
}
+3
View File
@@ -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 -1
View File
@@ -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.
+107
View File
@@ -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
}
+8
View File
@@ -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,
}
+17 -7
View File
@@ -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"
>
+3 -1
View File
@@ -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>
)
}
+109 -10
View File
@@ -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>
+103 -1
View File
@@ -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>
)
}
}
+2 -34
View File
@@ -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>
)
}
+42 -1
View File
@@ -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 时统一清除,不会堆积。
+6 -6
View File
@@ -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
View File
@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import {
+2 -2
View File
@@ -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`
+144 -3
View File
@@ -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}
+248 -70
View File
@@ -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}
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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',
}
+2 -19
View File
@@ -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,
+19
View File
@@ -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" />
}
+1 -1
View File
@@ -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()
+1
View File
@@ -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[]
+18 -7
View File
@@ -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 = ''
+32
View File
@@ -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')
}
+30
View File
@@ -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
}
+99
View File
@@ -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)',
}
}
+94
View File
@@ -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
}
+5
View File
@@ -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,