Compare commits

...

19 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
truewhile 9ca1b28cb4 优化 2026-09-10 22:30:17 +08:00
truewhile 9effb1422b 优化 2026-09-10 22:06:28 +08:00
truewhile b855e00345 优化 2026-09-10 16:58:41 +08:00
truewhile 8f2551a1b6 优化排序 2026-09-09 22:24:45 +08:00
truewhile 18e6cb40fd 完善动漫特别内容分类与版本判定
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 22:03:52 +08:00
truewhile 92e693ccac 优化 2026-09-09 20:32:03 +08:00
truewhile 62206aa05a 优化 2026-09-09 19:26:22 +08:00
truewhile f9dd082159 优化动漫刮削 2026-09-09 17:36:11 +08:00
truewhile d489c1608e 优化strm多版本显示 2026-09-09 17:07:21 +08:00
truewhile 1ea29cca9e bug处理 2026-09-09 00:39:00 +08:00
129 changed files with 7217 additions and 680 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))
+35
View File
@@ -1,7 +1,9 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
@@ -359,6 +361,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
Auth: auth,
Media: service.NewMediaService(cfg, log, repos),
Stream: service.NewStreamService(cfg, log, repos, nil),
Subtitle: service.NewSubtitleService(cfg, log, repos),
Permissions: permissions,
}
if err := repos.User.Create(t.Context(), &model.User{
@@ -440,6 +443,38 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
}
}
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
resolveCalls := 0
svc.Subtitle.SetStrmPlayTargetResolver(func(context.Context, string) (*service.StrmPlayResult, error) {
resolveCalls++
return nil, errors.New("probe resolver called")
})
request := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "http://nas.local"+path, nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
if w := request("/api/media/media-1/subtitles"); w.Code != http.StatusOK {
t.Fatalf("direct subtitle status = %d body=%s", w.Code, w.Body.String())
}
if resolveCalls != 0 {
t.Fatalf("direct subtitle request resolved cloud media %d times, want 0", resolveCalls)
}
if w := request("/api/media/media-1/subtitles?include_embedded=true"); w.Code != http.StatusOK {
t.Fatalf("HLS subtitle status = %d body=%s", w.Code, w.Body.String())
}
if resolveCalls != 1 {
t.Fatalf("HLS subtitle request resolved cloud media %d times, want 1", resolveCalls)
}
}
func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.EmbyRemote = &service.EmbyRemoteService{}
@@ -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))
+23 -1
View File
@@ -16,7 +16,13 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
return
}
tracks, err := svc.Subtitle.Discover(c.Request.Context(), id)
var tracks []service.SubtitleTrack
var err error
if c.Query("include_embedded") == "true" {
tracks, err = svc.Subtitle.Discover(c.Request.Context(), id)
} else {
tracks, err = svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), id)
}
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -28,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) == "" {
+2 -2
View File
@@ -120,8 +120,8 @@ const (
)
var (
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`)
embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`)
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典|画像特典)$`)
embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外|特典|画像特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`)
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
)
+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
}
+31 -3
View File
@@ -55,6 +55,17 @@ func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media)
if err := q.Find(&rows).Error; err != nil || len(rows) == 0 {
return []model.Media{*m}
}
targetKey := e.mediaVersionKey(ctx, m)
filtered := rows[:0]
for i := range rows {
if e.mediaVersionKey(ctx, &rows[i]) == targetKey {
filtered = append(filtered, rows[i])
}
}
rows = filtered
if len(rows) == 0 {
return []model.Media{*m}
}
rows = e.collapseExactPathRows(rows)
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].ID == m.ID {
@@ -97,11 +108,28 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
if libraryGroup == "" {
libraryGroup = strings.TrimSpace(m.LibraryID)
}
kind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
season, episode = parsedSeason, parsedEpisode
}
}
kindKey := ""
if kind != "" {
kindKey = "|kind:" + kind
}
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
if stemKey := mediaVersionStemGroupKey(*m, libraryGroup); stemKey != "" {
return stemKey + kindKey
}
return libraryGroup + "|special-item:" + kind + "|id:" + m.ID
}
if m.TMDbID > 0 {
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d", libraryGroup, m.TMDbID, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s", libraryGroup, m.TMDbID, season, episode, kindKey)
}
if m.BangumiID > 0 {
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d", libraryGroup, m.BangumiID, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s", libraryGroup, m.BangumiID, season, episode, kindKey)
}
title := strings.ToLower(strings.TrimSpace(m.Title))
if title == "" {
@@ -110,7 +138,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
if title == "" {
return ""
}
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d", libraryGroup, title, m.Year, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s", libraryGroup, title, m.Year, season, episode, kindKey)
}
func preferMediaVersion(candidate, current model.Media) bool {
+2 -2
View File
@@ -220,7 +220,7 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
patterns := []string{
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/oad/%", "%/extra/%", "%/extras/%",
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/ovas/%", "%/oad/%", "%/oads/%", "%/ovd/%", "%/ovds/%", "%/ona/%", "%/onas/%", "%/extra/%", "%/extras/%", "%/bonus/%", "%/bonuses/%", "%/omake/%", "%/picture drama/%", "%/ncop/%", "%/nced/%",
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
}
@@ -243,7 +243,7 @@ func embyMediaPathLooksEpisodic(path string) bool {
return false
}
for _, marker := range []string{
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/",
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/ovas/", "/oad/", "/oads/", "/ovd/", "/ovds/", "/ona/", "/onas/", "/extra/", "/extras/", "/bonus/", "/bonuses/", "/omake/", "/picture drama/", "/ncop/", "/nced/",
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
} {
+1 -1
View File
@@ -19,7 +19,7 @@ const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_v2_done
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
// 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023)
var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)[\\/][^\\/]*$`)
var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典|画像特典)[\\/][^\\/]*$`)
// showDirFromEpisodePath 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。
// 若没有季文件夹, 则退而去掉文件名取其父目录。
+106 -6
View File
@@ -6,6 +6,11 @@
// 1x02 / 01x02
// EP02 / E02
// 第2集 / 第02集
// [01] / [001](字幕组方括号集号,如 [UHA-WINGS][…][01][BDRIP])
//
// The "1x02" pattern only matches when it is not embedded in a larger number,
// so pixel dimensions such as 1920x1080 / 3840x2160 are never mistaken for
// season×episode (the old pattern read those as S20E108 / S40E216).
//
// For bare episode markers such as "EP02", the parser also looks at parent
// folders like "Season 02" / "S02" / "第2季" before falling back to season 1.
@@ -14,6 +19,7 @@
package service
import (
"fmt"
"path/filepath"
"regexp"
"strconv"
@@ -22,21 +28,32 @@ import (
)
var (
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
// patNxE 匹配 1x02 这类季集写法的捕获组,同时用于从标题里剔除季集残留
// (ReplaceAllString),因此本身不带边界守卫。解析时改用 patNxEGuarded,
// 避免 "1920x1080" 被从中间匹配出 "20x108" 而误判成 S20E108。
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
patNxEGuarded = regexp.MustCompile(`(?:^|[^0-9])(\d{1,2})x(\d{1,3})(?:[^0-9]|$)`)
// patBracketEpisode 匹配字幕组常见的纯数字方括号集号,如 [01] / [012]。
// 限定 1-3 位,避免把 [2024] 这类年份当成集号;含字母的 [NCOP]/[1080p]
// 自然不匹配。
patBracketEpisode = regexp.MustCompile(`\[0*(\d{1,3})\]`)
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
patSpecialEpisode = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:ova|oad|ovd|ona|sp|special(?:[\s._-]*episode)?|extra|bonus|omake)[\s._-]*0*(\d{1,3})(?:[^0-9]|$)`)
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇|画像特典)$`)
patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`)
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
// patResolutionDims 匹配 1920x1080 / 3840×2160 这类像素尺寸。
patResolutionDims = regexp.MustCompile(`(?i)(\d{3,4})\s*[x×]\s*(\d{3,4})`)
)
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
@@ -52,11 +69,23 @@ func ParseEpisode(path string) (season, episode int) {
episode = mustAtoi(m[2])
return
}
if m := patNxE.FindStringSubmatch(name); len(m) == 3 {
if m := patNxEGuarded.FindStringSubmatch(name); len(m) == 3 {
season = mustAtoi(m[1])
episode = mustAtoi(m[2])
return
}
if m := patBracketEpisode.FindStringSubmatch(name); len(m) == 2 {
var found bool
season, found = seasonFromParents(path)
if !found {
season = 1
}
episode = mustAtoi(m[1])
return
}
if m := patSpecialEpisode.FindStringSubmatch(name); len(m) >= 2 {
return 0, mustAtoi(m[1])
}
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
var found bool
season, found = seasonFromParents(path)
@@ -94,6 +123,77 @@ func ParseEpisode(path string) (season, episode int) {
return 0, 0
}
// resolutionEpisodeArtifact returns the bogus (season, episode) pair the legacy
// `(\d{1,2})x(\d{1,3})` pattern would extract from a WxH pixel-dimension token
// in path — e.g. 1920x1080 -> (20, 108), 3840x2160 -> (40, 216). Reports ok=false
// when the name carries no such token.
//
// MeBox used to persist these pairs into sidecar NFOs, so on rescan the NFO
// would inject the wrong identity back even after the parser was fixed.
func resolutionEpisodeArtifact(path string) (season, episode int, ok bool) {
name := mediaSidecarBase(path)
if name == "" {
return 0, 0, false
}
dims := patResolutionDims.FindStringSubmatch(name)
if len(dims) < 3 {
return 0, 0, false
}
m := patNxE.FindStringSubmatch(dims[1] + "x" + dims[2])
if len(m) != 3 {
return 0, 0, false
}
return mustAtoi(m[1]), mustAtoi(m[2]), true
}
// dropResolutionArtifactEpisodeIdentity clears a season/episode pair (and the
// episode title generated from it) that only exists because a resolution token
// was once mistaken for an SxxExx marker.
func dropResolutionArtifactEpisodeIdentity(meta *LocalMetadata, mediaPath string) {
if meta == nil {
return
}
season, episode, ok := resolutionEpisodeArtifact(mediaPath)
if !ok || meta.SeasonNum != season || meta.EpisodeNum != episode {
return
}
// 只有当文件名本身也无法权威地解析出同一季集号时才判定为伪集号。
// 例如 Show.S20E108.1920x1080.mkv 的 S20E108 是真实标记,必须保留。
if parsedSeason, parsedEpisode := ParseEpisode(mediaPath); parsedEpisode > 0 &&
parsedSeason == meta.SeasonNum && parsedEpisode == meta.EpisodeNum {
return
}
meta.SeasonNum = 0
meta.EpisodeNum = 0
if isGeneratedEpisodeTitle(meta.EpisodeTitle, episode) {
meta.EpisodeTitle = ""
}
}
// isGeneratedEpisodeTitle reports whether title is the default "第 N 集" /
// "Episode N" form auto-written from an episode number rather than a real name.
func isGeneratedEpisodeTitle(title string, episode int) bool {
title = strings.TrimSpace(title)
if title == "" || episode <= 0 {
return false
}
for _, candidate := range []string{
fmt.Sprintf("第 %d 集", episode),
fmt.Sprintf("第%d集", episode),
fmt.Sprintf("第 %d 话", episode),
fmt.Sprintf("第%d话", episode),
fmt.Sprintf("第 %d 話", episode),
fmt.Sprintf("第%d話", episode),
fmt.Sprintf("Episode %d", episode),
fmt.Sprintf("EP%d", episode),
} {
if strings.EqualFold(title, candidate) {
return true
}
}
return false
}
// onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention
// to provider-style specials. For example, S01E00 becomes S00E01 and S02E00
// becomes S00E02. Normal episodes retain their parsed identity.
+64
View File
@@ -29,7 +29,20 @@ func TestParseEpisode(t *testing.T) {
{`剧集/Specials/剧集 - 02.mkv`, 0, 2},
{`剧集/特别篇/03.mkv`, 0, 3},
{`剧集/剧集 - S00E04.mkv`, 0, 4},
{`动漫/摇曳露营/OVA/Season 3 [OVA01 [1080p].mkv`, 0, 1},
{`动漫/示例/OAD/示例.OAD02.mkv`, 0, 2},
{`动漫/示例/OVD/示例-OVD03.mkv`, 0, 3},
{`动漫/示例/ONA/示例_ONA04.mkv`, 0, 4},
{"Movie.2020.1080p.mkv", 0, 0},
// 分辨率不能被当成季集号:1920x1080 曾匹配出 20x108。
{"Movie.2020.1920x1080.mkv", 0, 0},
{"1920x1080.mkv", 0, 0},
{"[Group][Show][02][3840x2160].mkv", 1, 2},
// 字幕组方括号集号。
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 1},
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][12][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 12},
// 方括号里的年份/分辨率不是集号。
{"[Group][Show][2024][1080p].mkv", 0, 0},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
@@ -88,3 +101,54 @@ func TestOnlineEpisodeIdentityFromPathMapsAnimeEpisodeZeroToSpecials(t *testing.
}
}
}
func TestResolutionEpisodeArtifact(t *testing.T) {
cases := []struct {
path string
wantSeason int
wantEpisode int
wantOK bool
}{
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 20, 108, true},
{"今永纱奈/20170601000150_2,00x_3840x2160_amq-13.strm", 40, 216, true},
{"Movie.2020.1080p.mkv", 0, 0, false},
{"Show.S01E02.mkv", 0, 0, false},
}
for _, tc := range cases {
season, episode, ok := resolutionEpisodeArtifact(tc.path)
if season != tc.wantSeason || episode != tc.wantEpisode || ok != tc.wantOK {
t.Errorf("resolutionEpisodeArtifact(%q) = (%d, %d, %v), want (%d, %d, %v)",
tc.path, season, episode, ok, tc.wantSeason, tc.wantEpisode, tc.wantOK)
}
}
}
func TestDropResolutionArtifactEpisodeIdentity(t *testing.T) {
// 分辨率伪集号被剔除,并从生成的「第 N 集」标题里清掉。
polluted := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108, EpisodeTitle: "第 108 集"}
dropResolutionArtifactEpisodeIdentity(polluted, "[G][Show][01][BDRIP 1920x1080 x].strm")
if polluted.SeasonNum != 0 || polluted.EpisodeNum != 0 || polluted.EpisodeTitle != "" {
t.Fatalf("resolution artifact not dropped: %+v", polluted)
}
// 真实单集号不受影响。
real := &LocalMetadata{SeasonNum: 1, EpisodeNum: 3, EpisodeTitle: "本地第三集"}
dropResolutionArtifactEpisodeIdentity(real, "Show/S01E03 1920x1080.mkv")
if real.SeasonNum != 1 || real.EpisodeNum != 3 || real.EpisodeTitle != "本地第三集" {
t.Fatalf("real episode identity was modified: %+v", real)
}
// 名称里没有分辨率时不改动任何值。
plain := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108}
dropResolutionArtifactEpisodeIdentity(plain, "Show/S20E108.mkv")
if plain.SeasonNum != 20 || plain.EpisodeNum != 108 {
t.Fatalf("unrelated identity was cleared: %+v", plain)
}
// 文件名里 S20E108 是真实标记时,即使同时含分辨率也必须保留。
legit := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108}
dropResolutionArtifactEpisodeIdentity(legit, "Show/Show.S20E108.1920x1080.mkv")
if legit.SeasonNum != 20 || legit.EpisodeNum != 108 {
t.Fatalf("legitimate S20E108 identity was cleared: %+v", legit)
}
}
+7
View File
@@ -78,6 +78,13 @@ func pathHintMetadata(raw string, seriesLike bool) (*LocalMetadata, mediaExterna
title, year := "", 0
if seriesLike {
title, year = CleanQuery(source)
} else if patTheatricalTitle.MatchString(raw) || patTheatricalFolder.MatchString(raw) {
base := pathBaseSlash(raw)
stem := mediaFileStem(base)
if stem == "" {
stem = strings.TrimSuffix(base, filepath.Ext(base))
}
title, year = CleanQuery(stem)
} else {
title, year = cloudSeriesTitleFromMediaPath(source)
if title == "" {
+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))
+6
View File
@@ -23,6 +23,9 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
}
meta := metadataFromDoc(doc, filepath.Dir(path), false)
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
// A sidecar written while resolution tokens were misread as SxxExx must not
// reintroduce the bogus season/episode on rescan.
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
return meta, nil
}
@@ -116,6 +119,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
} else {
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
}
// A sidecar written while resolution tokens were misread as SxxExx must not
// reintroduce the bogus season/episode on rescan.
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
return meta, nil
}
+61
View File
@@ -6,6 +6,48 @@ import (
"testing"
)
func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) {
// 刮削曾把分辨率 1920x1080 误读成 S20E108 并回写进边车 NFO;
// 重扫时必须忽略这个伪季集号,否则旧文件会把错误身份灌回 DB。
root := t.TempDir()
showDir := filepath.Join(root, "彼得·格里尔的贤者时间")
if err := os.MkdirAll(showDir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(showDir,
"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<?xml version="1.0" encoding="UTF-8"?>
<episodedetails>
<title>第 108 集</title>
<showtitle>彼得·格里尔的贤者时间</showtitle>
<season>20</season>
<episode>108</episode>
<tmdbid>99080</tmdbid>
</episodedetails>`), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, true)
if err != nil {
t.Fatal(err)
}
if got == nil {
t.Fatal("metadata is nil")
}
if got.SeasonNum != 0 || got.EpisodeNum != 0 {
t.Fatalf("resolution artifact season/episode not dropped: s=%d e=%d", got.SeasonNum, got.EpisodeNum)
}
if got.EpisodeTitle != "" {
t.Fatalf("generated episode title not dropped: %q", got.EpisodeTitle)
}
if got.Title != "彼得·格里尔的贤者时间" {
t.Fatalf("series title = %q, want 彼得·格里尔的贤者时间", got.Title)
}
}
func TestReadLocalMovieMetadata(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Inception.2010.mkv")
@@ -375,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)
}
}
+7
View File
@@ -24,6 +24,13 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
mediaType = lib.Type
}
}
// Keep manual scraping consistent with automatic scraping for theatrical
// features stored inside anime libraries. The UI may pass the library type
// ("anime"), which otherwise makes TMDb stop after a TV result and hide the
// actual movie candidate.
if mediaLooksLikeTheatricalFeature(media) {
mediaType = "movie"
}
mediaType = normalizeMediaType(mediaType, queries[0], "")
providers := manualSearchProviderSet(provider)
year := mediaYearHint(media)
+74
View File
@@ -313,6 +313,80 @@ func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
}
}
func TestManualSearchAnimeTheatricalPrefersTMDbMovie(t *testing.T) {
var paths []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/search/movie":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 635302,
"title": "鬼灭之刃 剧场版 无限列车篇",
"original_title": "劇場版「鬼滅の刃」無限列車編",
"release_date": "2020-10-16",
}},
})
case "/search/tv":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 85937,
"name": "鬼灭之刃",
"original_name": "鬼滅の刃",
"first_air_date": "2019-04-06",
}},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
log := zap.NewNop()
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
lib := model.Library{Name: "动漫", Path: `/media/anime`, Type: "anime", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "鬼灭之刃 剧场版 无限列车篇",
Path: `/media/anime/鬼灭之刃/鬼灭之刃 剧场版 无限列车篇.mkv`,
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
results, err := scraper.ManualSearch(t.Context(), &media, media.Title, "tmdb", "anime")
if err != nil {
t.Fatal(err)
}
if len(results) == 0 || results[0].TMDbID != 635302 || results[0].MediaType != "movie" {
t.Fatalf("manual theatrical results=%#v, paths=%v", results, paths)
}
if len(paths) == 0 || paths[0] != "/search/movie" {
t.Fatalf("TMDb search paths=%v, want movie first", paths)
}
for _, path := range paths {
if path == "/search/tv" {
t.Fatalf("manual theatrical search unexpectedly queried TV after finding movie: paths=%v", paths)
}
}
}
func TestManualSearchAllProvidersTMDbNumericIDTriesMovieAndTVNamespaces(t *testing.T) {
var paths []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+16 -16
View File
@@ -62,7 +62,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影")
isWestern := isWesternByMetadata || (!hasRegionMetadata && isWesternByCategory)
isUSAnime := hasAny(countries, "US")
hasAnimeText := containsAnyText(contentText, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
hasAnimeText := containsAnyText(contentText, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll", "剧场版", "劇場版", "动画电影", "動畫電影")
hasVarietyText := containsAnyText(contentText, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
hasDocumentaryText := containsAnyText(contentText, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
hasConcertText := containsAnyText(contentText, "演唱会", "音乐会", "concert", "live concert")
@@ -191,21 +191,21 @@ func normalizeMediaType(mediaType, title, category string) string {
return "adult"
case containsAnyText(raw, "综艺", "真人秀"):
return "variety"
case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"):
return "anime"
case containsAnyText(raw, "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", "泰剧") || classifierTVRE.MatchString(raw):
return "tv"
case containsAnyText(raw, "电影", "演唱会") || classifierMovieRE.MatchString(raw):
return "movie"
}
text := strings.ToLower(title + " " + category)
switch {
case strings.Contains(text, "adult") || strings.Contains(text, "nsfw") || strings.Contains(text, "成人") || strings.Contains(text, "番号") || strings.Contains(text, "jav") || strings.Contains(text, "9kg") || classifierJAVCodeRE.MatchString(strings.ToUpper(title+" "+category)):
return "adult"
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
return "variety"
case strings.Contains(text, "电影") || classifierMovieRE.MatchString(text):
return "movie"
case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影", "剧场版", "劇場版"):
return "anime"
case containsAnyText(raw, "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", "泰剧") || classifierTVRE.MatchString(raw):
return "tv"
case containsAnyText(raw, "电影", "演唱会", "剧场版", "劇場版") || classifierMovieRE.MatchString(raw):
return "movie"
}
text := strings.ToLower(title + " " + category)
switch {
case strings.Contains(text, "adult") || strings.Contains(text, "nsfw") || strings.Contains(text, "成人") || strings.Contains(text, "番号") || strings.Contains(text, "jav") || strings.Contains(text, "9kg") || classifierJAVCodeRE.MatchString(strings.ToUpper(title+" "+category)):
return "adult"
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
return "variety"
case containsAnyText(text, "电影", "剧场版", "劇場版") || classifierMovieRE.MatchString(text):
return "movie"
case classifierAnimeRE.MatchString(text) || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
return "anime"
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
+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 {
+5 -5
View File
@@ -9,7 +9,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)[\\/]`)
var genericMovieTitleRE = regexp.MustCompile(`(?i)^(?:cd\s*\d+|part\s*\d+|disc\s*\d+|disk\s*\d+|dvd\s*\d+|movie|film|video|main|feature|track\s*\d+|preview|sample|trailer|\d{3,4}p|4k|2160p|1080p|720p)$`)
@@ -123,9 +123,9 @@ var (
seriesIDRE = regexp.MustCompile(`(?i)\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*`)
seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`)
seriesSpacerRE = regexp.MustCompile(`[\s._-]+`)
seriesSeasonDirRE = regexp.MustCompile(`(?i)^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$`)
seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$`)
seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`)
seriesSeasonDirRE = regexp.MustCompile(`(?i)^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)$`)
seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|ovds?\s*\d*|onas?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*|picture[\s._-]*drama\s*\d*|ncop\s*\d*|nced\s*\d*)\s*[\]))】]?$`)
seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`)
)
func normalizeSeriesTitle(value string) string {
@@ -173,7 +173,7 @@ func seriesTitleFromMediaPath(path string) string {
if last := parts[len(parts)-1]; !seriesPathPartLooksLikeFile(last) && !seriesSeasonDirRE.MatchString(filepath.Base(last)) {
dirIndex = len(parts) - 1
}
for dirIndex >= 0 && seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) {
for dirIndex >= 0 && (seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) || isTheatricalFolder(parts[dirIndex])) {
dirIndex--
}
if dirIndex < 0 {
+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)
}
+92 -12
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{
{
@@ -404,6 +449,42 @@ func TestGroupMediaSeriesCardsKeepsMovieVersionsAsOneMovie(t *testing.T) {
}
}
func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
episode := model.Media{
Base: model.Base{ID: "episode"},
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`,
PosterURL: "/media/动漫/摇曳露营△ (2018)/剧场版/poster.jpg",
BackdropURL: "/media/动漫/摇曳露营△ (2018)/剧场版/background.jpg",
Overview: "剧场版简介",
TMDbID: 566466,
}
cards := groupMediaSeriesCards([]model.Media{episode, theatrical})
if len(cards) != 1 {
t.Fatalf("cards=%#v, want theatrical movie retained with TV series", cards)
}
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) {
movie := model.Media{
Base: model.Base{ID: "movie"},
@@ -467,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{})
@@ -512,4 +593,3 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
}
}
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"regexp"
"strings"
)
const (
mediaSpecialTheatrical = "theatrical"
mediaSpecialOVA = "ova"
mediaSpecialOAD = "oad"
mediaSpecialOVD = "ovd"
mediaSpecialONA = "ona"
mediaSpecialExtra = "extra"
mediaSpecialBonus = "bonus"
mediaSpecialOmake = "omake"
mediaSpecialPicture = "picture_drama"
mediaSpecialNCOP = "ncop"
mediaSpecialNCED = "nced"
mediaSpecialGeneric = "special"
)
var mediaSpecialKindPatterns = []struct {
kind string
re *regexp.Regexp
}{
{mediaSpecialOVA, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ova(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialOAD, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])oad(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialOVD, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ovd(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialONA, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ona(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialPicture, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:picture[\s._-]*drama|画像特典)(?:[^a-z0-9]|$)`)},
{mediaSpecialNCOP, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ncop(?:\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialNCED, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])nced(?:\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialExtra, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])extras?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialBonus, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])bonus(?:es)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialOmake, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])omake(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialGeneric, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:special(?:[\s._-]*episodes?)?|specials|sps?)(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇`)},
}
func mediaSpecialKind(path string) string {
if pathHasTheatricalFolder(path) || patTheatricalTitle.MatchString(path) {
return mediaSpecialTheatrical
}
for _, part := range strings.FieldsFunc(path, func(r rune) bool {
return r == '/' || r == '\\'
}) {
for _, pattern := range mediaSpecialKindPatterns {
if pattern.re.MatchString(part) {
return pattern.kind
}
}
}
return ""
}
+50 -10
View File
@@ -145,16 +145,32 @@ func mediaVersionGroupKey(m model.Media) string {
return fmt.Sprintf("embyremote:%s", m.ID)
}
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
if libKey == "" {
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
}
specialKind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
season, episode = parsedSeason, parsedEpisode
}
}
if season > 0 || episode > 0 {
kind := specialKind
if kind == "" {
kind = "episode"
}
switch {
case m.TMDbID > 0:
return fmt.Sprintf("episode:tmdb:%d:%d:%d", m.TMDbID, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("episode:%s:tmdb:%d:%d:%d", kind, m.TMDbID, season, episode)
case m.BangumiID > 0:
return fmt.Sprintf("episode:bangumi:%d:%d:%d", m.BangumiID, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("episode:%s:bangumi:%d:%d:%d", kind, m.BangumiID, season, episode)
case strings.TrimSpace(m.DoubanID) != "":
return fmt.Sprintf("episode:douban:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.DoubanID)), m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("episode:%s:douban:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.DoubanID)), season, episode)
case strings.TrimSpace(m.TheTVDBID) != "":
return fmt.Sprintf("episode:thetvdb:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.TheTVDBID)), m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("episode:%s:thetvdb:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.TheTVDBID)), season, episode)
}
title := firstNonEmpty(m.OriginalName, m.Title)
if title == "" {
@@ -166,37 +182,61 @@ func mediaVersionGroupKey(m model.Media) string {
}
return strings.Join([]string{
"episode",
strings.ToLower(strings.TrimSpace(m.LibraryID)),
kind,
libKey,
title,
fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum),
fmt.Sprintf("%d:%d", season, episode),
}, "|")
}
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
if libKey == "" {
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
if specialKind != "" && specialKind != mediaSpecialTheatrical {
return mediaVersionStemGroupKey(m, libKey)
}
switch {
case m.TMDbID > 0:
if libKey != "" {
if specialKind != "" {
return fmt.Sprintf("movie:%s:%s:tmdb:%d", specialKind, libKey, m.TMDbID)
}
return fmt.Sprintf("movie:%s:tmdb:%d", libKey, m.TMDbID)
}
if specialKind != "" {
return fmt.Sprintf("movie:%s:tmdb:%d", specialKind, m.TMDbID)
}
return fmt.Sprintf("tmdb:%d", m.TMDbID)
case m.BangumiID > 0:
if libKey != "" {
if specialKind != "" {
return fmt.Sprintf("movie:%s:%s:bangumi:%d", specialKind, libKey, m.BangumiID)
}
return fmt.Sprintf("movie:%s:bangumi:%d", libKey, m.BangumiID)
}
if specialKind != "" {
return fmt.Sprintf("movie:%s:bangumi:%d", specialKind, m.BangumiID)
}
return fmt.Sprintf("bangumi:%d", m.BangumiID)
case strings.TrimSpace(m.DoubanID) != "":
if libKey != "" {
if specialKind != "" {
return fmt.Sprintf("movie:%s:%s:douban:%s", specialKind, libKey, strings.ToLower(strings.TrimSpace(m.DoubanID)))
}
return fmt.Sprintf("movie:%s:douban:%s", libKey, strings.ToLower(strings.TrimSpace(m.DoubanID)))
}
if specialKind != "" {
return "movie:" + specialKind + ":douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID))
}
return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID))
case strings.TrimSpace(m.TheTVDBID) != "":
if libKey != "" {
if specialKind != "" {
return fmt.Sprintf("movie:%s:%s:thetvdb:%s", specialKind, libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID)))
}
return fmt.Sprintf("movie:%s:thetvdb:%s", libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID)))
}
if specialKind != "" {
return "movie:" + specialKind + ":thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
}
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
}
+103
View File
@@ -1,6 +1,7 @@
package service
import (
"fmt"
"strings"
"testing"
"time"
@@ -71,6 +72,108 @@ func TestGroupEpisodeVersionsForDisplayMergesAndKeepsEpisodeOrder(t *testing.T)
}
}
func TestGroupMediaVersionsSeparatesAnimeSpecialKindsAndNumbers(t *testing.T) {
rows := []model.Media{
{
Base: model.Base{ID: "movie"},
LibraryID: "anime",
Title: "摇曳露营△",
Path: "/media/动漫/摇曳露营△/摇曳露营△ 剧场版 (2022)/movie.strm",
TMDbID: 76075,
},
{
Base: model.Base{ID: "ova-1"},
LibraryID: "anime",
Title: "摇曳露营△",
Path: "/media/动漫/摇曳露营△/OVA/Yuru Camp OVA01.strm",
TMDbID: 76075,
},
{
Base: model.Base{ID: "ova-2"},
LibraryID: "anime",
Title: "摇曳露营△",
Path: "/media/动漫/摇曳露营△/OVA/Yuru Camp OVA02.strm",
TMDbID: 76075,
},
{
Base: model.Base{ID: "oad-1"},
LibraryID: "anime",
Title: "摇曳露营△",
Path: "/media/动漫/摇曳露营△/OAD/Yuru Camp OAD01.strm",
TMDbID: 76075,
},
}
grouped := groupMediaVersions(rows)
if len(grouped) != 4 {
t.Fatalf("grouped len = %d, want theatrical, OVA01, OVA02 and OAD01 separate: %#v", len(grouped), grouped)
}
for _, item := range grouped {
if len(item.Versions) > 1 {
t.Fatalf("unrelated anime extras were merged as versions: %#v", item.Versions)
}
}
}
func TestGroupMediaVersionsKeepsBracketNumberedEpisodesSeparate(t *testing.T) {
// UHA-WINGS 命名:[组名][标题][01][BDRIP 1920x1080 ...].strm。
// 曾因 1920x1080 被解析成 S20E108,12 集全部折叠成一集的多个版本。
rows := make([]model.Media, 0, 12)
for episode := 1; episode <= 12; episode++ {
rows = append(rows, model.Media{
Base: model.Base{ID: fmt.Sprintf("ep-%02d", episode)},
LibraryID: "anime",
Title: "彼得·格里尔的贤者时间",
Path: fmt.Sprintf(
"/media/影视库/动漫/彼得·格里尔的贤者时间/[UHA-WINGS][Peter Grill to Kenja no Jikan][%02d][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm",
episode),
TMDbID: 99080,
})
}
for i := range rows {
season, episode := ParseEpisode(rows[i].Path)
rows[i].SeasonNum = season
rows[i].EpisodeNum = episode
}
grouped := groupMediaVersions(rows)
if len(grouped) != 12 {
t.Fatalf("grouped len = %d, want 12 distinct episodes: %#v", len(grouped), grouped)
}
for _, item := range grouped {
if len(item.Versions) > 1 {
t.Fatalf("distinct episodes were folded into versions: %#v", item.Versions)
}
if item.SeasonNum != 1 || item.EpisodeNum < 1 || item.EpisodeNum > 12 {
t.Fatalf("unexpected episode identity s=%d e=%d", item.SeasonNum, item.EpisodeNum)
}
}
}
func TestGroupMediaVersionsMergesSameNumberedOVAEncodes(t *testing.T) {
rows := []model.Media{
{
Base: model.Base{ID: "ova-1-hd"},
LibraryID: "anime",
Path: "/media/动漫/示例/OVA/Show OVA01 1080p.mkv",
TMDbID: 123,
SizeBytes: 100,
},
{
Base: model.Base{ID: "ova-1-uhd"},
LibraryID: "anime",
Path: "/media/动漫/示例/OVA/Show OVA01 2160p.mkv",
TMDbID: 123,
SizeBytes: 200,
},
}
grouped := groupMediaVersions(rows)
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
t.Fatalf("same numbered OVA encodes should be versions: %#v", grouped)
}
}
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
hd := model.Media{
LibraryID: "movies",
@@ -55,7 +55,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
continue
}
}
match := o.scraper.lookup(ctx, lib, media, candidate, year)
match := o.scraper.lookup(ctx, lib, media, candidate, year, false)
if match != nil && strings.TrimSpace(match.Title) != "" {
if !organizeMetadataMatchTrusted(candidate, year, match) {
if cache != nil {
+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)
}
}
+73 -6
View File
@@ -5,8 +5,10 @@ import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -61,8 +63,9 @@ func NewRecognitionWordsService(log *zap.Logger, repo *repository.Container) *Re
}
func (s *RecognitionWordsService) Config(ctx context.Context) RecognitionWordsConfig {
cfg := recognitionWordsConfig(ctx, s.repo)
cfg.RuleCount = len(parseRecognitionWordRules(recognitionWordsCombinedText(cfg)))
cfg, rules := recognitionWordsRuntime(ctx, s.repo)
cfg = cloneRecognitionWordsConfig(cfg)
cfg.RuleCount = len(rules)
return cfg
}
@@ -80,7 +83,11 @@ func (s *RecognitionWordsService) SaveConfig(ctx context.Context, cfg Recognitio
if err != nil {
return err
}
return s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs))
if err := s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs)); err != nil {
return err
}
invalidateRecognitionWordsRuntime(s.repo)
return nil
}
func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWordsConfig, error) {
@@ -104,6 +111,7 @@ func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWo
if err := s.repo.Setting.Set(ctx, RecognitionWordsSyncedAtKey, now); err != nil {
return cfg, err
}
invalidateRecognitionWordsRuntime(s.repo)
return s.Config(ctx), nil
}
@@ -120,11 +128,10 @@ func (s *RecognitionWordsService) Test(ctx context.Context, input string) Recogn
}
func ApplyRecognitionWords(ctx context.Context, repo *repository.Container, raw string) string {
cfg := recognitionWordsConfig(ctx, repo)
if !cfg.Enabled {
cfg, rules := recognitionWordsRuntime(ctx, repo)
if !cfg.Enabled || len(rules) == 0 {
return raw
}
rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg))
return applyRecognitionWordRules(raw, rules)
}
@@ -189,9 +196,69 @@ func normalizeRecognitionWordURLs(values []string) []string {
type recognitionWordRule struct {
raw string
block string
blockRE *regexp.Regexp
replaceFrom string
replaceTo string
replaceRE *regexp.Regexp
offsetLeft string
offsetRight string
offsetExpr string
offsetRE *regexp.Regexp
}
// recognitionWordsRuntime caches the parsed rule set (with pre-compiled
// regexes) per repository. Reading five settings rows and parsing/recompiling
// the whole shared word list used to happen on every query candidate, which is
// the scraper's hottest path. Writes through SaveConfig/SyncShared invalidate
// the entry immediately; the TTL only bounds staleness for out-of-process edits.
type recognitionWordsRuntimeEntry struct {
repo *repository.Container
cfg RecognitionWordsConfig
rules []recognitionWordRule
expiresAt time.Time
}
const recognitionWordsRuntimeTTL = 30 * time.Second
var recognitionWordsRuntimeCache struct {
sync.RWMutex
entry recognitionWordsRuntimeEntry
valid bool
}
func recognitionWordsRuntime(ctx context.Context, repo *repository.Container) (RecognitionWordsConfig, []recognitionWordRule) {
now := time.Now()
recognitionWordsRuntimeCache.RLock()
cached := recognitionWordsRuntimeCache.entry
hit := recognitionWordsRuntimeCache.valid && cached.repo == repo && now.Before(cached.expiresAt)
recognitionWordsRuntimeCache.RUnlock()
if hit {
return cached.cfg, cached.rules
}
cfg := recognitionWordsConfig(ctx, repo)
rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg))
recognitionWordsRuntimeCache.Lock()
recognitionWordsRuntimeCache.entry = recognitionWordsRuntimeEntry{
repo: repo,
cfg: cfg,
rules: rules,
expiresAt: now.Add(recognitionWordsRuntimeTTL),
}
recognitionWordsRuntimeCache.valid = true
recognitionWordsRuntimeCache.Unlock()
return cfg, rules
}
func invalidateRecognitionWordsRuntime(repo *repository.Container) {
recognitionWordsRuntimeCache.Lock()
if recognitionWordsRuntimeCache.entry.repo == repo {
recognitionWordsRuntimeCache.valid = false
}
recognitionWordsRuntimeCache.Unlock()
}
func cloneRecognitionWordsConfig(cfg RecognitionWordsConfig) RecognitionWordsConfig {
cfg.SharedURLs = append([]string(nil), cfg.SharedURLs...)
return cfg
}
@@ -0,0 +1,49 @@
package service
import (
"testing"
"go.uber.org/zap"
)
// The rule set is cached process-wide, so a config write must invalidate it
// immediately; otherwise the admin would have to wait out the TTL before a
// saved word list takes effect.
func TestRecognitionWordsCacheInvalidatedBySaveConfig(t *testing.T) {
repos := newOrganizerTestRepo(t)
svc := NewRecognitionWordsService(zap.NewNop(), repos)
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
Enabled: true,
LocalText: "BADWORD => 好标题",
}); err != nil {
t.Fatal(err)
}
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "好标题" {
t.Fatalf("first apply = %q, want 好标题", got)
}
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
Enabled: true,
LocalText: "BADWORD => 新标题",
}); err != nil {
t.Fatal(err)
}
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "新标题" {
t.Fatalf("cached rules were not invalidated after SaveConfig: got %q, want 新标题", got)
}
}
func TestRecognitionWordsDisabledReturnsRawInput(t *testing.T) {
repos := newOrganizerTestRepo(t)
svc := NewRecognitionWordsService(zap.NewNop(), repos)
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
Enabled: false,
LocalText: "BADWORD => 好标题",
}); err != nil {
t.Fatal(err)
}
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "BADWORD" {
t.Fatalf("disabled recognition words changed input: got %q", got)
}
}
+59 -21
View File
@@ -31,6 +31,11 @@ func parseRecognitionWordRule(line string) recognitionWordRule {
case strings.Contains(part, "<>") && strings.Contains(part, ">>"):
beforeAfter := strings.SplitN(part, ">>", 2)
bounds := strings.SplitN(beforeAfter[0], "<>", 2)
// A malformed rule without "<>" would otherwise index past the
// slice; word lists are fetched from the network, so stay defensive.
if len(bounds) < 2 {
continue
}
rule.offsetLeft = strings.TrimSpace(bounds[0])
rule.offsetRight = strings.TrimSpace(bounds[1])
rule.offsetExpr = strings.TrimSpace(beforeAfter[1])
@@ -38,56 +43,89 @@ func parseRecognitionWordRule(line string) recognitionWordRule {
rule.block = part
}
}
compileRecognitionWordRule(&rule)
return rule
}
var recognitionReplacementRE = regexp.MustCompile(`\\([0-9]+)`)
func normalizeRecognitionReplacement(value string) string {
re := regexp.MustCompile(`\\([0-9]+)`)
return re.ReplaceAllString(value, "$$$1")
return recognitionReplacementRE.ReplaceAllString(value, "$$$1")
}
// compileRecognitionWordRule pre-compiles every pattern in a rule so the hot
// clean-query path never recompiles regexes per candidate.
func compileRecognitionWordRule(rule *recognitionWordRule) {
if rule == nil {
return
}
if rule.block != "" {
if re, err := regexp.Compile(rule.block); err == nil {
rule.blockRE = re
}
}
if rule.replaceFrom != "" {
if re, err := regexp.Compile(rule.replaceFrom); err == nil {
rule.replaceRE = re
}
}
if rule.offsetExpr != "" && (rule.offsetLeft != "" || rule.offsetRight != "") {
if re, err := compileRecognitionOffsetRE(rule.offsetLeft, rule.offsetRight); err == nil {
rule.offsetRE = re
}
}
}
func compileRecognitionOffsetRE(left, right string) (*regexp.Regexp, error) {
leftPattern := firstNonEmpty(left, `^`)
rightPattern := firstNonEmpty(right, `$`)
return regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`)
}
func applyRecognitionWordRules(raw string, rules []recognitionWordRule) string {
out := strings.TrimSpace(raw)
for _, rule := range rules {
if rule.block != "" {
out = applyRecognitionBlock(out, rule.block)
out = applyRecognitionBlock(out, rule)
}
if rule.replaceFrom != "" {
out = applyRecognitionReplace(out, rule.replaceFrom, rule.replaceTo)
out = applyRecognitionReplace(out, rule)
}
if rule.offsetLeft != "" || rule.offsetRight != "" {
out = applyRecognitionOffset(out, rule.offsetLeft, rule.offsetRight, rule.offsetExpr)
out = applyRecognitionOffset(out, rule)
}
}
return strings.Join(strings.Fields(out), " ")
}
func applyRecognitionBlock(raw, block string) string {
if re, err := regexp.Compile(block); err == nil {
return re.ReplaceAllString(raw, " ")
func applyRecognitionBlock(raw string, rule recognitionWordRule) string {
if rule.blockRE != nil {
return rule.blockRE.ReplaceAllString(raw, " ")
}
return strings.ReplaceAll(raw, block, " ")
return strings.ReplaceAll(raw, rule.block, " ")
}
func applyRecognitionReplace(raw, from, to string) string {
if re, err := regexp.Compile(from); err == nil {
return re.ReplaceAllString(raw, to)
func applyRecognitionReplace(raw string, rule recognitionWordRule) string {
if rule.replaceRE != nil {
return rule.replaceRE.ReplaceAllString(raw, rule.replaceTo)
}
return strings.ReplaceAll(raw, from, to)
return strings.ReplaceAll(raw, rule.replaceFrom, rule.replaceTo)
}
func applyRecognitionOffset(raw, left, right, expr string) string {
if strings.TrimSpace(expr) == "" {
func applyRecognitionOffset(raw string, rule recognitionWordRule) string {
if strings.TrimSpace(rule.offsetExpr) == "" {
return raw
}
leftPattern := firstNonEmpty(left, `^`)
rightPattern := firstNonEmpty(right, `$`)
re, err := regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`)
if err != nil {
return raw
re := rule.offsetRE
if re == nil {
compiled, err := compileRecognitionOffsetRE(rule.offsetLeft, rule.offsetRight)
if err != nil {
return raw
}
re = compiled
}
return re.ReplaceAllStringFunc(raw, func(match string) string {
return applyRecognitionOffsetMatch(re, match, expr)
return applyRecognitionOffsetMatch(re, match, rule.offsetExpr)
})
}
+9 -3
View File
@@ -93,7 +93,11 @@ func (s *ScannerService) readLocalScanMetadata(lib *model.Library, root *model.L
if root != nil && strings.TrimSpace(root.Path) != "" {
rootPath = root.Path
}
localMeta, err := ReadLocalMetadata(path, rootPath, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0)
seriesLike := librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0
if mediaLooksLikeTheatricalFeature(&model.Media{Path: path}) {
seriesLike = false
}
localMeta, err := ReadLocalMetadata(path, rootPath, seriesLike)
if err != nil {
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
}
@@ -196,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
@@ -248,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
}
@@ -69,6 +69,49 @@ func TestScanLibraryUsesLocalMetadata(t *testing.T) {
}
}
func TestScanAnimeTheatricalFolderUsesMovieNFO(t *testing.T) {
root := t.TempDir()
showDir := filepath.Join(root, "摇曳露营△ (2018)")
movieDir := filepath.Join(showDir, "摇曳露营△ 剧场版 (2022)")
if err := os.MkdirAll(movieDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(
`<tvshow><title>错误的剧集标题</title><tmdbid>76075</tmdbid><year>2018</year></tvshow>`,
), 0o644); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(movieDir, "Eiga.Yurukyan.2022.Bluray.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(
`<movie><title>摇曳露营△ 剧场版</title><tmdbid>566466</tmdbid><year>2022</year></movie>`,
), 0o644); err != nil {
t.Fatal(err)
}
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{})
repos := repository.New(db)
lib := model.Library{Name: "动漫", Path: root, Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
t.Fatal(err)
}
var media model.Media
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
t.Fatal(err)
}
if media.Title != "摇曳露营△ 剧场版" || media.TMDbID != 566466 || media.Year != 2022 {
t.Fatalf("theatrical movie metadata = title %q tmdb %d year %d", media.Title, media.TMDbID, media.Year)
}
}
func TestScanLibraryDoesNotMarkArtworkOnlyAsMatched(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
+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
}
+6 -1
View File
@@ -63,12 +63,17 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
}
}
// Same stale-negative problem as the library path: a single-item retry
// (queue task / manual rescrape) must get a fresh provider round-trip.
if options.RetryNoMatch && s != nil {
s.lookupCache.clearNegatives()
}
candidates := scrapeQueryCandidatesWithRecognition(ctx, s.repo, m, lib)
var query string
match := (*Match)(nil)
for _, candidate := range candidates {
query = candidate
candidateMatch := s.lookup(ctx, lib, m, candidate, year)
candidateMatch := s.lookup(ctx, lib, m, candidate, year, options.ForceRematch)
if candidateMatch == nil {
continue
}
@@ -0,0 +1,313 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func TestTheatricalTitleVariants(t *testing.T) {
tests := []struct {
input string
want []string
}{
{
input: "名侦探柯南 剧场版01 引爆摩天楼",
want: []string{"名侦探柯南 引爆摩天楼", "名侦探柯南 剧场版01 引爆摩天楼"},
},
{
input: "名侦探柯南 剧场版26 黑铁的鱼影",
want: []string{"名侦探柯南 黑铁的鱼影", "名侦探柯南 剧场版26 黑铁的鱼影"},
},
{
input: "鬼灭之刃 剧场版 无限列车篇",
want: []string{"鬼灭之刃 无限列车篇", "鬼灭之刃 剧场版 无限列车篇"},
},
{
input: "航海王 The Movie 黄金之城",
want: []string{"航海王 The Movie 黄金之城"},
},
{
input: "普通动漫 第01集",
want: []string{"普通动漫 第01集"},
},
}
for _, tc := range tests {
got := theatricalTitleVariants(tc.input)
if len(got) != len(tc.want) {
t.Fatalf("theatricalTitleVariants(%q) = %v, want %v", tc.input, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("theatricalTitleVariants(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i])
}
}
}
}
func TestMetadataMatchCompatibilityForTheatricalFeatures(t *testing.T) {
animeMatch := &Match{MediaType: "anime"}
if metadataMatchCompatibleWithType("movie", animeMatch) {
t.Fatal("ordinary movie lookups must not accept anime matches")
}
if !metadataMatchCompatibleWithTheatrical("movie", true, animeMatch) {
t.Fatal("theatrical movie lookup should accept an anime provider match")
}
if metadataMatchCompatibleWithTheatrical("movie", false, animeMatch) {
t.Fatal("non-theatrical movie lookup must not accept an anime provider match")
}
}
func TestMediaLooksLikeTheatricalFeatureIgnoresStaleEpisodeIdentity(t *testing.T) {
media := &model.Media{
Title: "未命名",
Path: `/media/anime/超人高校生们/剧场版/超人高校生们 剧场版.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
if !mediaLooksLikeTheatricalFeature(media) {
t.Fatal("theatrical path should override stale persisted season/episode fields")
}
namedFolder := &model.Media{
Title: "Eiga Yurukyan 2022 Bluray",
Path: `/media/anime/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
}
if !mediaLooksLikeTheatricalFeature(namedFolder) {
t.Fatal("a named theatrical folder with a year should be detected from the full path")
}
realEpisode := &model.Media{
Title: "剧场版制作幕后",
Path: `/media/anime/超人高校生们/Season 01/超人高校生们.S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
if mediaLooksLikeTheatricalFeature(realEpisode) {
t.Fatal("an actual episode marker in the path must remain episodic")
}
}
func TestScrapeQueryCandidatesForAnimeTheatricalMix(t *testing.T) {
lib := &model.Library{
Path: `/media/anime`,
Type: "anime",
}
// 1. 同目录下包含剧场版01
theatricalMedia := &model.Media{
Title: "名侦探柯南 剧场版01 引爆摩天楼",
Path: `/media/anime/名侦探柯南/名侦探柯南 剧场版01 引爆摩天楼.mkv`,
}
candidates := scrapeQueryCandidates(theatricalMedia, lib)
if len(candidates) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates for theatrical media")
}
if candidates[0] != "名侦探柯南 引爆摩天楼" {
t.Fatalf("first candidate for theatrical media = %q, want cleaned movie title '名侦探柯南 引爆摩天楼'; all=%v", candidates[0], candidates)
}
// 2. 剧场版放在以剧场版命名的子目录
subfolderTheatricalMedia := &model.Media{
Title: "无限列车篇",
Path: `/media/anime/鬼灭之刃/剧场版/无限列车篇.mkv`,
}
subCandidates := scrapeQueryCandidates(subfolderTheatricalMedia, lib)
if len(subCandidates) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates for subfolder theatrical media")
}
foundCombined := false
for _, c := range subCandidates {
if c == "鬼灭之刃 无限列车篇" {
foundCombined = true
break
}
}
if !foundCombined {
t.Fatalf("candidates for subfolder theatrical did not contain '鬼灭之刃 无限列车篇': %v", subCandidates)
}
// 3. 混合放置的普通剧集分集不应受剧场版影响
episodeMedia := &model.Media{
Title: "名侦探柯南 - S01E01",
Path: `/media/anime/名侦探柯南/名侦探柯南 - S01E01.mp4`,
SeasonNum: 1,
EpisodeNum: 1,
}
epCandidates := scrapeQueryCandidates(episodeMedia, lib)
if len(epCandidates) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates for episode media")
}
if epCandidates[0] != "名侦探柯南" {
t.Fatalf("first candidate for tv episode = %q, want series folder title '名侦探柯南'", epCandidates[0])
}
}
func TestEnrichOneAnimeMixedEpisodesAndTheatrical(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/search/tv":
q := r.URL.Query().Get("query")
if q == "鬼灭之刃" {
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 85937,
"name": "鬼灭之刃",
"original_name": "鬼滅の刃",
"overview": "大正时期、日本...",
"first_air_date": "2019-04-06",
}},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
case "/search/movie":
q := r.URL.Query().Get("query")
if q == "鬼灭之刃 无限列车篇" || q == "鬼灭之刃 剧场版 无限列车篇" {
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 635302,
"title": "鬼灭之刃 剧场版 无限列车篇",
"original_title": "劇場版「鬼滅の刃」無限列車編",
"overview": "在结束了蝴蝶屋的修业之后...",
"release_date": "2020-10-16",
}},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
case "/tv/85937":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 85937,
"name": "鬼灭之刃",
"original_name": "鬼滅の刃",
"first_air_date": "2019-04-06",
})
case "/movie/635302":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 635302,
"title": "鬼灭之刃 剧场版 无限列车篇",
"original_title": "劇場版「鬼滅の刃」無限列車編",
"release_date": "2020-10-16",
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
repos := newOrganizerTestRepo(t)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
log := zap.NewNop()
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
lib := model.Library{Name: "动漫", Path: `/media/anime`, Type: "anime", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
// 1. 同一目录下的 TV 剧集
tvEpisode := model.Media{
LibraryID: lib.ID,
Title: "鬼灭之刃 S01E01",
Path: `/media/anime/鬼灭之刃/Season 01/鬼灭之刃 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&tvEpisode).Error; err != nil {
t.Fatal(err)
}
// 2. 同一目录下的剧场版文件
movieMedia := model.Media{
LibraryID: lib.ID,
Title: "鬼灭之刃 剧场版 无限列车篇",
Path: `/media/anime/鬼灭之刃/鬼灭之刃 剧场版 无限列车篇.mkv`,
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&movieMedia).Error; err != nil {
t.Fatal(err)
}
// 刮削剧集
if err := scraper.EnrichOne(t.Context(), &tvEpisode); err != nil {
t.Fatalf("enrich tv episode: %v", err)
}
gotTV, err := repos.Media.FindByID(t.Context(), tvEpisode.ID)
if err != nil || gotTV == nil {
t.Fatalf("load tv episode: %v", err)
}
if gotTV.ScrapeStatus != "matched" || gotTV.TMDbID != 85937 {
t.Fatalf("tv episode scrape mismatch: status=%s, tmdb_id=%d", gotTV.ScrapeStatus, gotTV.TMDbID)
}
// 刮削剧场版
if err := scraper.EnrichOne(t.Context(), &movieMedia); err != nil {
t.Fatalf("enrich movie: %v", err)
}
gotMovie, err := repos.Media.FindByID(t.Context(), movieMedia.ID)
if err != nil || gotMovie == nil {
t.Fatalf("load movie: %v", err)
}
if gotMovie.ScrapeStatus != "matched" || gotMovie.TMDbID != 635302 {
t.Fatalf("theatrical movie scrape mismatch: status=%s, tmdb_id=%d, want 635302", gotMovie.ScrapeStatus, gotMovie.TMDbID)
}
if gotMovie.Title != "鬼灭之刃 剧场版 无限列车篇" {
t.Fatalf("theatrical movie title = %q, want '鬼灭之刃 剧场版 无限列车篇'", gotMovie.Title)
}
}
func TestClassifyAnimeTheatricalMovie(t *testing.T) {
categories := map[string]string{
"animation_movie": "动画电影",
"jp_anime": "日番",
"chinese_movie": "华语电影",
}
tests := []struct {
title string
mediaType string
want string
}{
{
title: "名侦探柯南 剧场版01 引爆摩天楼",
mediaType: "movie",
want: "动画电影",
},
{
title: "鬼灭之刃 剧场版 无限列车篇",
mediaType: "movie",
want: "动画电影",
},
{
title: "鬼灭之刃 S01E01",
mediaType: "tv",
want: "日番",
},
}
for _, tc := range tests {
input := mediaClassifyInput{
MediaType: tc.mediaType,
Title: tc.title,
Languages: []string{"JA"},
Countries: []string{"JP"},
Genres: []string{"Animation"},
}
got := classifyMediaCategory(input, categories)
if got != tc.want {
t.Fatalf("classifyMediaCategory(%q, %q) = %q, want %q", tc.title, tc.mediaType, got, tc.want)
}
}
}
@@ -8,6 +8,27 @@ import (
)
func (s *ScraperService) lookupAutomaticTMDb(ctx context.Context, kind, query string, year int) *Match {
if match := s.lookupAutomaticTMDbPrimary(ctx, kind, query, year); match != nil {
return match
}
fallbackKind := ""
normalized := normalizeOrganizeMediaType(kind)
if normalized == "anime" {
if isTVMetadataKind(kind) {
fallbackKind = "movie"
} else {
fallbackKind = "tv"
}
}
if fallbackKind != "" {
if fbMatch := s.lookupAutomaticTMDbPrimary(ctx, fallbackKind, query, year); fbMatch != nil {
return fbMatch
}
}
return nil
}
func (s *ScraperService) lookupAutomaticTMDbPrimary(ctx context.Context, kind, query string, year int) *Match {
var (
candidates []*Match
err error
@@ -175,8 +196,10 @@ func metadataMatchCompatibleWithType(expectedType string, match *Match) bool {
return true
}
switch expectedType {
case "tv", "anime", "variety":
case "tv", "variety":
return matchType == "tv" || matchType == "anime" || matchType == "variety"
case "anime":
return matchType == "anime" || matchType == "tv" || matchType == "movie" || matchType == "variety"
case "movie", "adult":
return matchType == "movie" || matchType == "adult"
default:
@@ -184,6 +207,16 @@ func metadataMatchCompatibleWithType(expectedType string, match *Match) bool {
}
}
func metadataMatchCompatibleWithTheatrical(expectedType string, isTheatrical bool, match *Match) bool {
if isTheatrical &&
normalizeOrganizeMediaType(expectedType) == "movie" &&
match != nil &&
normalizeOrganizeMediaType(match.MediaType) == "anime" {
return true
}
return metadataMatchCompatibleWithType(expectedType, match)
}
func queryNeedsEnglishTMDbFallback(query string) bool {
for _, r := range query {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
+40 -4
View File
@@ -13,7 +13,12 @@ import (
// lookup runs the provider chain after local NFO has been considered:
// TMDb -> Douban -> Bangumi -> TheTVDB. Douban and Bangumi do not require API
// keys; providers that are unavailable or return an error are skipped.
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *model.Media, query string, year int) *Match {
//
// Results are cached per (kind, theatrical, query, year) because every
// episode of a show produces the same candidate; bypassCache forces a fresh
// provider round-trip for user-triggered rematches but still refreshes the
// cache so later episodes reuse the corrected result.
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *model.Media, query string, year int, bypassCache bool) *Match {
kind := ""
if lib != nil {
kind = lib.Type
@@ -22,17 +27,41 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
// was previously polluted into S01E20x. Do not let the dirty path override
// that caller-supplied media type; TV/anime libraries still force TV below.
explicitEpisode := media != nil && (media.SeasonNum > 0 || media.EpisodeNum > 0)
if (normalizeOrganizeMediaType(kind) != "movie" || explicitEpisode) && mediaIsEpisodic(media, lib) {
isTheatrical := mediaLooksLikeTheatricalFeature(media)
if isTheatrical {
kind = "movie"
} else if (normalizeOrganizeMediaType(kind) != "movie" || explicitEpisode) && mediaIsEpisodic(media, lib) {
kind = "tv"
}
cacheKey := scrapeLookupCacheKey(kind, query, year, isTheatrical)
if !bypassCache {
if cached, ok := s.lookupCache.get(cacheKey); ok {
return cached
}
}
match := s.lookupUncached(ctx, kind, isTheatrical, query, year)
// Always refresh the cache, even on bypassed (forced) lookups, so a
// corrected rematch replaces the stale entry for later episodes.
s.lookupCache.set(cacheKey, match)
return match
}
func (s *ScraperService) lookupUncached(ctx context.Context, kind string, isTheatrical bool, query string, year int) *Match {
if s.tmdb != nil && s.tmdb.Enabled() {
if match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil {
match.Provider = "tmdb"
return match
}
if isTheatrical {
if match := s.lookupAutomaticTMDb(ctx, "tv", query, year); match != nil {
match.Provider = "tmdb"
return match
}
}
}
if s.douban != nil && s.douban.Enabled() {
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithTheatrical(kind, isTheatrical, m) {
m.Provider = "douban"
return m
} else if err != nil {
@@ -40,7 +69,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
}
}
if s.bangumi != nil && s.bangumi.Enabled() {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithTheatrical(kind, isTheatrical, m) {
m.Provider = "bangumi"
return m
} else if err != nil {
@@ -98,6 +127,13 @@ func (s *ScraperService) EnrichLibraryDetailed(ctx context.Context, libraryID st
func (s *ScraperService) EnrichLibraryDetailedWithOptions(ctx context.Context, libraryID string, options ScrapeOptions) (EnrichLibraryResult, error) {
result := EnrichLibraryResult{LibraryID: libraryID}
// A manual retry must not reuse stale negative cache entries from the
// previous run, otherwise "重新刮削" looks like it did nothing. Positives
// are kept so the rest of the season still dedupes; the first re-queried
// episode refreshes the entry for the rows behind it.
if options.RetryNoMatch && s != nil {
s.lookupCache.clearNegatives()
}
rows, err := s.scrapeCandidateRows(ctx, libraryID, options)
if err != nil {
return result, err
+126
View File
@@ -0,0 +1,126 @@
package service
import (
"strconv"
"strings"
"sync"
"time"
)
// A TV library scrapes one media row per episode, and every row in the same
// show computes the same query candidate (the series folder title). Without a
// cache each episode re-issues the identical TMDb/Douban/Bangumi/TheTVDB
// search, so a 100-episode season costs 100x the network calls it needs.
//
// The cache lives on the ScraperService instance (not a package global) so that
// two services with different provider configuration never share results. TTL
// bounds staleness for out-of-band edits; explicit invalidation is unnecessary
// because the key includes the effective media kind, theatrical flag, query
// and year. Negative entries use a shorter TTL so a failed query is retried
// sooner without manual intervention.
const (
scrapeLookupCacheTTL = 10 * time.Minute
scrapeLookupNegativeCacheTTL = 2 * time.Minute
scrapeLookupCacheMaxItems = 1024
)
type scrapeLookupCache struct {
mu sync.Mutex
entries map[string]scrapeLookupCacheEntry
}
type scrapeLookupCacheEntry struct {
match *Match
expiresAt time.Time
}
func newScrapeLookupCache() *scrapeLookupCache {
return &scrapeLookupCache{entries: map[string]scrapeLookupCacheEntry{}}
}
func scrapeLookupCacheKey(kind, query string, year int, isTheatrical bool) string {
theatrical := "0"
if isTheatrical {
theatrical = "1"
}
return strings.ToLower(strings.TrimSpace(kind)) + "|" +
theatrical + "|" +
strconv.Itoa(year) + "|" +
strings.ToLower(strings.TrimSpace(query))
}
// get reports whether the key is cached. A hit may carry a nil match, which
// means the provider chain already ran and found nothing (negative cache).
// Use clearNegative before a user-triggered retry so stale negative entries
// do not suppress the fresh provider round-trip.
func (c *scrapeLookupCache) get(key string) (*Match, bool) {
if c == nil || key == "" {
return nil, false
}
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
item, ok := c.entries[key]
if !ok {
return nil, false
}
if now.After(item.expiresAt) {
delete(c.entries, key)
return nil, false
}
return cloneMatch(item.match), true
}
func (c *scrapeLookupCache) set(key string, match *Match) {
if c == nil || key == "" {
return
}
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if len(c.entries) >= scrapeLookupCacheMaxItems {
for k, item := range c.entries {
if now.After(item.expiresAt) || len(c.entries) >= scrapeLookupCacheMaxItems {
delete(c.entries, k)
}
if len(c.entries) < scrapeLookupCacheMaxItems {
break
}
}
}
ttl := scrapeLookupCacheTTL
if match == nil {
ttl = scrapeLookupNegativeCacheTTL
}
c.entries[key] = scrapeLookupCacheEntry{match: cloneMatch(match), expiresAt: now.Add(ttl)}
}
// clearNegatives drops cached misses so a user-triggered retry gets a fresh
// provider round-trip instead of reusing a stale negative entry.
func (c *scrapeLookupCache) clearNegatives() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for k, item := range c.entries {
if item.match == nil {
delete(c.entries, k)
}
}
}
// cloneMatch returns an independent copy so callers that mutate the result
// (localized title preference, local metadata merge, fanart artwork) cannot
// corrupt the cached entry or leak state between media rows.
func cloneMatch(m *Match) *Match {
if m == nil {
return nil
}
out := *m
out.Languages = append([]string(nil), m.Languages...)
out.Countries = append([]string(nil), m.Countries...)
out.Genres = append([]string(nil), m.Genres...)
out.Aliases = append([]string(nil), m.Aliases...)
return &out
}
@@ -0,0 +1,165 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"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"
)
// Every episode of a show produces the same query candidate (the series folder
// title). Without the lookup cache each episode re-issues the identical search,
// so a whole season costs one request per episode. This asserts the provider is
// hit once and subsequent episodes reuse the cached match.
func TestEnrichLibraryReusesLookupCacheAcrossEpisodes(t *testing.T) {
var searchCalls int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/search/tv":
if r.URL.Query().Get("query") != "折腰" {
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
return
}
atomic.AddInt32(&searchCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 296753,
"name": "折腰",
"overview": "正确的剧集条目",
"poster_path": "/zheyao.jpg",
"first_air_date": "2025-05-13",
"origin_country": []string{"CN"},
}},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
cfg.Secrets.TMDbImageProxy = upstream.URL + "/images"
log := zap.NewNop()
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
lib := model.Library{Name: "OpenList · 刮削缓存测试库", Path: "cloud://openlist/scrape-lookup-cache", Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
const episodeCount = 4
for episode := 1; episode <= episodeCount; episode++ {
media := model.Media{
LibraryID: lib.ID,
Title: "折腰",
Path: "cloud://openlist/scrape-lookup-cache/折腰 (2025)/Season 1/折腰.S01E0" + string(rune('0'+episode)) + ".mkv",
SeasonNum: 1,
EpisodeNum: episode,
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
}
result, err := scraper.EnrichLibraryDetailedWithOptions(t.Context(), lib.ID, ScrapeOptions{})
if err != nil {
t.Fatal(err)
}
if result.Matched != episodeCount {
t.Fatalf("matched = %d, want %d", result.Matched, episodeCount)
}
if calls := atomic.LoadInt32(&searchCalls); calls != 1 {
t.Fatalf("tmdb /search/tv called %d times for %d episodes; want 1 (cache should dedupe identical queries)", calls, episodeCount)
}
}
// A cached match must be handed out as an independent copy: mutating one row's
// result (localized title preference, local metadata merge) must not bleed into
// the next row.
func TestScrapeLookupCacheReturnsIndependentCopies(t *testing.T) {
cache := newScrapeLookupCache()
original := &Match{
Title: "折腰",
TMDbID: 296753,
Genres: []string{"剧情"},
Countries: []string{"CN"},
Aliases: []string{"Zhe Yao"},
}
key := scrapeLookupCacheKey("tv", "折腰", 2025, false)
cache.set(key, original)
first, ok := cache.get(key)
if !ok || first == nil {
t.Fatal("expected a cache hit")
}
first.Title = "mutated"
first.Genres[0] = "mutated"
first.Aliases = append(first.Aliases, "extra")
second, ok := cache.get(key)
if !ok || second == nil {
t.Fatal("expected a second cache hit")
}
if second.Title != "折腰" || second.Genres[0] != "剧情" || len(second.Aliases) != 1 {
t.Fatalf("cached match was mutated through a returned copy: %+v", second)
}
}
// Negative results are cached too: a query that matched nothing must not be
// re-issued for every remaining episode of the same show.
func TestScrapeLookupCacheStoresNegativeResults(t *testing.T) {
cache := newScrapeLookupCache()
key := scrapeLookupCacheKey("tv", "no-such-show", 0, false)
cache.set(key, nil)
if _, ok := cache.get(key); !ok {
t.Fatal("negative result should be cached to avoid repeated provider calls")
}
}
// The theatrical flag is part of the key: a theatrical feature gets a tv
// fallback lookup, so its result must not be shared with (or returned for)
// the same query issued for a non-theatrical media row.
func TestScrapeLookupCacheKeySeparatesTheatrical(t *testing.T) {
plain := scrapeLookupCacheKey("movie", "query", 2024, false)
theatrical := scrapeLookupCacheKey("movie", "query", 2024, true)
if plain == theatrical {
t.Fatalf("theatrical flag must be part of the cache key: %q", plain)
}
}
// A manual "retry no match" run clears stale negative entries so the provider
// chain is actually re-queried instead of short-circuiting on the cached miss.
func TestScrapeLookupCacheClearNegativesKeepsPositives(t *testing.T) {
cache := newScrapeLookupCache()
negKey := scrapeLookupCacheKey("tv", "missing", 0, false)
posKey := scrapeLookupCacheKey("tv", "found", 0, false)
cache.set(negKey, nil)
cache.set(posKey, &Match{Title: "found"})
cache.clearNegatives()
if _, ok := cache.get(negKey); ok {
t.Fatal("negative entry should be cleared before a manual retry")
}
if got, ok := cache.get(posKey); !ok || got == nil || got.Title != "found" {
t.Fatalf("positive entry should survive clearNegatives: %+v", got)
}
}
+85 -20
View File
@@ -15,8 +15,42 @@ var (
episodeTitleQueryRE = regexp.MustCompile(`^\s*第\s*[0-9一二三四五六七八九十百零两]+\s*[集期话話](?:\s*[上下])?\s*[::].+`)
genericEpisodeWordsRE = regexp.MustCompile(`^\s*第\s*[集期话話]\s*$`)
episodeReleaseTitleTagRE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e\d{1,3}(?:[\s._-]|$)`)
patTheatricalTitle = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)`)
patTheatricalFolder = regexp.MustCompile(`(?i)[\\/][^\\/]*(?:剧场版|劇場版|動畫電影|动画电影|电影版|電影版)[^\\/]*[\\/]`)
theatricalNoiseRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版)\s*(?:第?\s*\d{1,3}\s*[部篇]?)?|电影版|電影版|动画电影|動畫電影`)
)
func theatricalTitleVariants(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if !theatricalNoiseRE.MatchString(raw) {
return []string{raw}
}
stripped := theatricalNoiseRE.ReplaceAllString(raw, " ")
stripped = strings.Join(strings.Fields(stripped), " ")
if stripped != "" && !strings.EqualFold(stripped, raw) {
return []string{stripped, raw}
}
return []string{raw}
}
func mediaLooksLikeTheatricalFeature(m *model.Media) bool {
if m == nil {
return false
}
// Trust an episode marker that is actually present in the path, but do not
// trust persisted season/episode fields here. Older scans could incorrectly
// assign those fields to a theatrical file, which would permanently prevent
// both manual re-scraping and separation from the TV series.
if season, ep := ParseEpisode(m.Path); season > 0 || ep > 0 {
return false
}
text := m.Title + " " + pathBaseSlash(m.Path)
return patTheatricalTitle.MatchString(text) || pathHasTheatricalFolder(m.Path)
}
func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) {
return CleanQuery(raw)
@@ -33,31 +67,59 @@ func scrapeQueryCandidatesWithNormalizer(m *model.Media, lib *model.Library, cle
seen := map[string]struct{}{}
var out []string
add := func(raw string) {
cleaned, _ := clean(raw)
if cleaned == "" {
cleaned = strings.TrimSpace(raw)
}
for _, candidate := range titleCandidates(cleaned) {
if unsafeAutomaticEpisodeQuery(candidate) {
continue
for _, variant := range theatricalTitleVariants(raw) {
cleaned, _ := clean(variant)
if cleaned == "" {
cleaned = strings.TrimSpace(variant)
}
key := strings.ToLower(candidate)
if _, ok := seen[key]; ok || candidate == "" {
continue
for _, candidate := range titleCandidates(cleaned) {
if unsafeAutomaticEpisodeQuery(candidate) {
continue
}
key := strings.ToLower(candidate)
if _, ok := seen[key]; ok || candidate == "" {
continue
}
seen[key] = struct{}{}
out = append(out, candidate)
}
seen[key] = struct{}{}
out = append(out, candidate)
}
}
episodic := mediaIsEpisodic(m, lib)
if lib != nil && episodic {
add(seriesFolderTitle(m.Path, lib.Path))
isTheatrical := mediaLooksLikeTheatricalFeature(m)
if isTheatrical {
add(m.Title)
add(m.Path)
if lib != nil {
seriesTitle := seriesFolderTitle(m.Path, lib.Path)
if seriesTitle != "" {
baseName := pathBaseSlash(m.Path)
stem := mediaFileStem(baseName)
if stem == "" {
stem = strings.TrimSuffix(baseName, filepath.Ext(baseName))
}
cleanStem, _ := clean(stem)
if cleanStem == "" {
cleanStem = stem
}
if !strings.Contains(strings.ToLower(cleanStem), strings.ToLower(seriesTitle)) {
add(seriesTitle + " " + cleanStem)
}
}
add(mediaFolderTitle(m.Path, lib.Path))
}
} else {
episodic := mediaIsEpisodic(m, lib)
if lib != nil && episodic {
add(seriesFolderTitle(m.Path, lib.Path))
}
if lib != nil {
add(mediaFolderTitle(m.Path, lib.Path))
}
add(m.Title)
add(m.Path)
}
if lib != nil {
add(mediaFolderTitle(m.Path, lib.Path))
}
add(m.Title)
add(m.Path)
if len(out) == 0 {
base := pathBaseSlash(m.Path)
out = append(out, strings.TrimSuffix(base, filepath.Ext(base)))
@@ -106,6 +168,9 @@ func containsCJK(s string) bool {
}
func mediaIsEpisodic(m *model.Media, lib *model.Library) bool {
if m != nil && mediaLooksLikeTheatricalFeature(m) {
return false
}
if m != nil && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
return true
}
+43 -5
View File
@@ -24,11 +24,15 @@ var noiseTokens = []string{
"hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux", "qhstudio",
// 流媒体平台 / 字幕组 / 国家版本(动漫常见)
"netflix", "nf", "amzn", "hulu", "disney", "max", "hbo",
// 注意:不要把同时是常见英文单词的标记放进来(如 max / web / judas),
// 否则 "Mad Max"、"Web Therapy" 这类正常标题会被误删。裸 "WEB" 发布标记
// 通常位于分辨率之后,由 releaseBoundary 截断规则处理;"WEB-DL" 则由
// multiWordNoise 单独匹配。
"netflix", "nf", "amzn", "hulu", "disney", "hbo",
"linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj",
"atvp", "appletv", "apple-tv", "tx", "txweb",
"crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease",
"erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv",
"erai-raws", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv",
"mweb", "ubweb", "hhweb", "adweb", "chdweb", "kurosawa", "qhstudio",
// 中文字幕标记
@@ -55,6 +59,20 @@ var releaseBoundaryTokenSet = map[string]struct{}{
"x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {},
}
// weakReleaseBoundaryTokenSet are release tags that double as plausible title
// words. Before any release signal has been seen they are kept as part of the
// title ("Mad Max", "Web Therapy"), so a title is never truncated to nothing;
// after a real signal they behave like any other tag.
var weakReleaseBoundaryTokenSet = map[string]struct{}{
"bd": {}, "dvd": {}, "web": {},
}
// releaseSignalToken marks the position of an extracted year. The year itself
// is not a title token, but its presence still proves the following tokens are
// release tags ("复仇者联盟4.2019.BD.1080p" must drop "BD"). A control rune is
// used so it can never collide with a real filename token.
const releaseSignalToken = "\u0001"
var dynamicReleaseBoundaryTokenRE = regexp.MustCompile(`(?i)^(?:\d{3,4}p|\d{2,3}fps)$`)
// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
@@ -86,7 +104,9 @@ func CleanQuery(raw string) (title string, year int) {
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
if v, err := strconv.Atoi(m[1]); err == nil {
year = v
lower = strings.ReplaceAll(lower, m[1], " ")
// Keep a positional marker: the year is not a title token, but its
// presence arms release-tag truncation for what follows.
lower = strings.ReplaceAll(lower, m[1], " "+releaseSignalToken+" ")
}
}
@@ -110,17 +130,35 @@ func CleanQuery(raw string) (title string, year int) {
}
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
//
// seenReleaseBoundary 只有在遇到分辨率/编码等强标记后才置位,置位后其余
// ASCII 词一律视为发布尾巴丢弃;releaseSignalled 则宽松得多,年份标记也会
// 置位,它只用来武装弱标记(bd/dvd/web)。这样 "Web Therapy" 不会被截空,
// 而 "2019.Avatar.1080p" 这种年份前置的标题也不会因为年份标记把后面的
// 真实标题词当成尾巴丢掉。
out := make([]string, 0, 8)
seenReleaseBoundary := false
releaseSignalled := false
for _, w := range strings.Fields(lower) {
if w == releaseSignalToken {
releaseSignalled = true
continue
}
if dynamicReleaseBoundaryTokenRE.MatchString(w) {
releaseSignalled = true
seenReleaseBoundary = true
continue
}
if _, ok := noiseTokenSet[w]; ok {
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
if _, weak := weakReleaseBoundaryTokenSet[w]; weak && !releaseSignalled {
// Ambiguous tag that is also a plausible title word; keep it
// until a real release signal proves we are past the title.
} else {
releaseSignalled = true
seenReleaseBoundary = true
continue
}
} else if _, ok := noiseTokenSet[w]; ok {
continue
}
if seenReleaseBoundary && isASCIIWord(w) {
@@ -0,0 +1,77 @@
package service
import "testing"
// CleanQuery must not delete words that are also ordinary English title words
// just because a release group reused them as a tag. Regression guard: "max"
// and "web" used to be unconditional noise/boundary tokens, which turned
// "Mad Max" into "mad" and "Web Therapy" into an empty query.
func TestCleanQueryKeepsCommonEnglishTitleWords(t *testing.T) {
cases := []struct {
in string
wantTitle string
wantYear int
}{
{"Mad.Max.1979.1080p.BluRay.mkv", "mad max", 1979},
{"Max.Payne.2008.1080p.WEB-DL.mkv", "max payne", 2008},
{"Web.Therapy.S01E01.1080p.WEB-DL.mkv", "web therapy", 0},
{"The.Web.2019.1080p.mkv", "the web", 2019},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
gotTitle, gotYear := CleanQuery(tc.in)
if gotTitle != tc.wantTitle || gotYear != tc.wantYear {
t.Errorf("CleanQuery(%q) = (%q, %d), want (%q, %d)",
tc.in, gotTitle, gotYear, tc.wantTitle, tc.wantYear)
}
})
}
}
// A title consisting only of an ambiguous tag plus a release tail must not be
// truncated to nothing; the tag stays so the query still has a chance.
func TestCleanQueryDoesNotTruncateAmbiguousTagToNothing(t *testing.T) {
for _, in := range []string{"Web.1080p.WEB-DL.mkv", "BD.720p.mkv"} {
title, _ := CleanQuery(in)
if title == "" {
t.Errorf("CleanQuery(%q) returned an empty title", in)
}
}
}
// A year-prefixed filename must keep its title: the year arms weak-tag
// truncation but must not discard the real title words that follow it.
func TestCleanQueryKeepsTitleAfterYearPrefix(t *testing.T) {
cases := map[string]string{
"2019.Avatar.1080p.BluRay.mkv": "avatar",
"2024.Dune.Part.Two.2160p.WEB.mkv": "dune part two",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
got, year := CleanQuery(in)
if got != want {
t.Errorf("CleanQuery(%q) = (%q, %d), want title %q", in, got, year, want)
}
})
}
}
// Release-tag truncation must still fire once a real release signal (an
// extracted year, resolution or codec) has been seen, so tags after it are
// dropped as before.
func TestCleanQueryStillDropsTagsAfterReleaseSignal(t *testing.T) {
cases := map[string]string{
"复仇者联盟4.2019.BD.1080p.mkv": "复仇者联盟4",
"The.Matrix.1999.1080p.WEB-DL.H265.mp4": "the matrix",
"Oppenheimer.2023.2160p.UHD.BluRay.mkv": "oppenheimer",
"Interstellar.2014.4k.hdr.dts.atmos.mkv": "interstellar",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
got, _ := CleanQuery(in)
if got != want {
t.Errorf("CleanQuery(%q) = %q, want %q", in, got, want)
}
})
}
}
+36 -4
View File
@@ -1,6 +1,11 @@
package service
import "strings"
import (
"regexp"
"strings"
)
var namedTheatricalFolderRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)`)
func mediaFolderTitle(mediaPath, libraryRoot string) string {
dir := parentSlashPath(mediaPath)
@@ -13,7 +18,7 @@ func mediaFolderTitle(mediaPath, libraryRoot string) string {
if base == "" || base == "." {
return ""
}
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) {
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
dir = parentSlashPath(dir)
continue
}
@@ -104,7 +109,7 @@ func parentSlashPath(value string) string {
func seriesFolderTitle(mediaPath, libraryRoot string) string {
dir := parentSlashPath(mediaPath)
if strictSeasonFolderMatched(pathBaseSlash(dir)) {
if strictSeasonFolderMatched(pathBaseSlash(dir)) || isTheatricalFolder(pathBaseSlash(dir)) {
dir = parentSlashPath(dir)
}
if root := comparableLibraryRoot(libraryRoot); root != "" && sameSlashPath(dir, root) {
@@ -114,12 +119,39 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string {
if base == "" || base == "." {
return ""
}
if isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) {
if isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
return ""
}
return base
}
func isTheatricalFolder(name string) bool {
key := strings.ToLower(strings.TrimSpace(name))
key = strings.Trim(key, `\/`)
if namedTheatricalFolderRE.MatchString(key) {
return true
}
switch key {
case "剧场版", "劇場版", "动画电影", "動畫電影", "特别篇", "特別篇",
"special", "specials", "sp", "ova", "ovas", "oad", "oads", "ovd", "ovds", "ona", "onas",
"extra", "extras", "bonus", "bonuses", "omake", "picture drama", "ncop", "nced", "画像特典":
return true
default:
return false
}
}
func pathHasTheatricalFolder(path string) bool {
for _, part := range strings.FieldsFunc(path, func(r rune) bool {
return r == '/' || r == '\\'
}) {
if isTheatricalFolder(part) && namedTheatricalFolderRE.MatchString(part) {
return true
}
}
return false
}
func libraryRootTitle(libraryRoot string) string {
base := ""
if info, ok := ParseCloudLibraryMount(libraryRoot); ok {
+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\电视剧\欧美剧`,
+5
View File
@@ -26,6 +26,10 @@ type ScraperService struct {
cache *RuntimeCacheService
images *ImageProxy
// Caches provider-chain results keyed by media kind/query/year. Per-instance
// so services with different provider config never share results.
lookupCache *scrapeLookupCache
// Serializes final sidecar replacement. Windows cannot rename over an
// existing file, and concurrent scrapes can target the same sidecar.
artworkWriteMu sync.Mutex
@@ -50,6 +54,7 @@ func NewScraperService(
return &ScraperService{
cfg: cfg, log: log, repo: repo,
tmdb: tmdb, bangumi: bangumi, thetvdb: thetvdb, fanart: fanart, adult: adultProvider, hub: hub,
lookupCache: newScrapeLookupCache(),
}
}
@@ -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,预告"
)
+223 -54
View File
@@ -46,25 +46,26 @@ type strmSyncState struct {
rec *model.StrmSyncRecord
syncType string
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
seenMetaTarget map[string]cloud.FileEntry
seenVideoTarget map[string]cloud.FileEntry
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
seenDir map[string]bool // 清洗后的目录相对路径 → 远端存在该目录(供整目录 prune)
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
seenMetaTarget map[string]cloud.FileEntry
seenVideoTarget map[string]cloud.FileEntry
// remoteVideos:prefer 模式下同名(去扩展名)视频候选列表,walk 结束后择优写盘
remoteVideos map[string][]remoteVideoCandidate
remoteVideos map[string][]remoteVideoCandidate
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
// recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队
recentDoneUploadSizes map[string]int64
pendingDownloads []*model.StrmDownloadTask
pendingUploads []*model.StrmUploadTask
dirCache sync.Map // dirID (string) -> relativePath (string)
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
dirCache sync.Map // dirID (string) -> relativePath (string)
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
}
@@ -281,6 +282,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
rec: rec,
syncType: rec.SyncType,
seenVideo: map[string]bool{},
seenDir: map[string]bool{"": true},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
@@ -535,36 +537,38 @@ func (st *strmSyncState) walkRemote() error {
if task.rel != "" {
rel = task.rel + "/" + cleanName
}
if entry.IsDir {
st.dirCache.Store(entry.ID, rel)
st.deferDirCacheSave(entry.ID, rel)
push(dirTask{id: entry.ID, rel: rel})
} else {
st.processRemoteFile(entry, rel)
}
if entry.IsDir {
st.markSeenDir(rel)
st.dirCache.Store(entry.ID, rel)
st.deferDirCacheSave(entry.ID, rel)
push(dirTask{id: entry.ID, rel: rel})
} else {
st.processRemoteFile(entry, rel)
}
walkMu.Lock()
pending--
if pending == 0 {
walkCond.Broadcast()
}
walkMu.Unlock()
}
}); err != nil {
cancel()
walkMu.Lock()
pending--
if pending == 0 {
walkCond.Broadcast()
}
walkMu.Unlock()
}
}()
}
wg.Wait()
st.flushDirCacheSave()
if firstErr != nil {
return firstErr
}
}); err != nil {
cancel()
}
}()
}
wg.Wait()
st.flushDirCacheSave()
if firstErr != nil {
return firstErr
}
return ctx.Err()
}
// processRemoteFile 分类处理远端文件:视频生成 STRM,元数据入下载队列。
func (st *strmSyncState) processRemoteFile(entry cloud.FileEntry, rel string) {
st.markSeenDir(filepath.ToSlash(filepath.Dir(rel)))
fileName := entry.Name
if st.isExcluded(fileName) {
return
@@ -608,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
@@ -698,16 +708,16 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
if pathCounts[item.Path] > 1 {
continue
}
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
}
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
}
}
}
// 2. 自适应分治拉取文件列表(单目录超 9500 时自动对子目录并发分治扁平化)
allFiles, err := st.fetch115FilesAdaptive(ctx, open115, rootCID)
if err != nil {
return err
}
// 2. 自适应分治拉取文件列表(单目录超 9500 时自动对子目录并发分治扁平化)
allFiles, err := st.fetch115FilesAdaptive(ctx, open115, rootCID)
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
@@ -1031,10 +1041,10 @@ func list115DirDirect(ctx context.Context, open115 *cloud115.OpenClient, cid str
// fetch115FilesAdaptive 采用自适应分治策略抓取 115 目录树下的全部文件:
// 115 开放平台扁平搜索对 offset+limit 有 10000 的最大深度限制。
// - 若子树文件总数 < 9500,直接使用全速扁平分页批量拉取;
// - 若子树文件总数 >= 9500(大库或超大分类目录),自动分治:仅单层列出该目录的直属子项(cur=1),
// 直属纯文件直接收集,直属子目录则派发为独立的子树任务继续递归探测与拉取;
// - 若超大单目录下无子目录或层级过深(>10层),安全回退到 errFallbackToWalkRemote。
// - 若子树文件总数 < 9500,直接使用全速扁平分页批量拉取;
// - 若子树文件总数 >= 9500(大库或超大分类目录),自动分治:仅单层列出该目录的直属子项(cur=1),
// 直属纯文件直接收集,直属子目录则派发为独立的子树任务继续递归探测与拉取;
// - 若超大单目录下无子目录或层级过深(>10层),安全回退到 errFallbackToWalkRemote。
func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *cloud115.OpenClient, rootCID string) ([]cloud115.RemoteFile, error) {
var (
allFiles []cloud115.RemoteFile
@@ -1637,6 +1647,10 @@ func (st *strmSyncState) walkLocalSource() error {
default:
}
if d.IsDir() {
rel, relErr := filepath.Rel(srcRoot, path)
if relErr == nil {
st.markSeenDir(filepath.ToSlash(rel))
}
return nil
}
rel, err := filepath.Rel(srcRoot, path)
@@ -1898,8 +1912,104 @@ func (st *strmSyncState) taskExists(kind, syncPathID, localPath string) bool {
return count > 0
}
// pruneLocal 清理本地多余 .strm(远端已不存在的视频),可选删除空目录。
// 元数据文件(nfo/图片/字幕等)一律保留:本地刮削结果不因网盘端缺失而被删除。
// markSeenDir 记录远端存在的目录及其全部祖先目录。
func (st *strmSyncState) markSeenDir(rel string) {
rel = strings.Trim(filepath.ToSlash(rel), "/")
if rel == "." {
rel = ""
}
st.mu.Lock()
if st.seenDir == nil {
st.seenDir = map[string]bool{"": true}
}
for {
st.seenDir[rel] = true
if rel == "" {
break
}
if idx := strings.LastIndexByte(rel, '/'); idx >= 0 {
rel = rel[:idx]
} else {
rel = ""
}
}
st.mu.Unlock()
}
// refreshUnseen115Dirs 补查本地存在、但 115 扁平文件列表未覆盖的目录。
// 扁平接口不返回空目录;逐层补查这些候选目录可以避免把远端仍存在的空目录误删。
func (st *strmSyncState) refreshUnseen115Dirs(localRoot string, dirs []string) error {
if st.p.Provider != model.StrmProvider115 || st.provider == nil {
return nil
}
dirIDs := map[string]string{"": strings.TrimSpace(st.p.RemotePath)}
if dirIDs[""] == "" {
dirIDs[""] = "0"
}
st.dirCache.Range(func(key, value any) bool {
id, idOK := key.(string)
rel, relOK := value.(string)
if idOK && relOK && id != "" {
dirIDs[cleanDirRel(rel)] = id
}
return true
})
liveIDs := map[string]string{"": dirIDs[""]}
listed := map[string]bool{}
sort.Strings(dirs)
for _, dir := range dirs {
rel, err := filepath.Rel(localRoot, dir)
if err != nil {
continue
}
rel = cleanDirRel(filepath.ToSlash(rel))
st.mu.Lock()
seen := st.seenDir[rel]
st.mu.Unlock()
if seen {
if id := dirIDs[rel]; id != "" {
liveIDs[rel] = id
}
continue
}
parentRel := ""
if idx := strings.LastIndexByte(rel, '/'); idx >= 0 {
parentRel = rel[:idx]
}
parentID := liveIDs[parentRel]
if parentID == "" {
continue // 父目录已确认不存在,子目录也必然是本地孤儿
}
if listed[parentRel] {
continue
}
entries, err := st.provider.List(st.ctx, parentID)
if err != nil {
return fmt.Errorf("核对 115 远端目录 %s 失败:%w", parentRel, err)
}
listed[parentRel] = true
for _, entry := range entries {
if !entry.IsDir {
continue
}
childRel := cleanEntryName(entry.Name, true)
if parentRel != "" {
childRel = parentRel + "/" + childRel
}
st.markSeenDir(childRel)
liveIDs[childRel] = entry.ID
dirIDs[childRel] = entry.ID
}
}
return nil
}
// pruneLocal 清理本地远端已不存在的内容:
// - 整个目录在远端不存在时,递归删除该本地目录(包括元数据);
// - 目录仍存在但视频已删除时,仅删除对应的 .strm,保留本地元数据;
// - DeleteDir 开启时,最后再清理其余空目录。
func (st *strmSyncState) pruneLocal() error {
// 增量同步保护:本次远端扫描不完整(目录详情解析失败 / 文件父路径降级)时,
// seenVideo 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地 .strm,
@@ -1911,6 +2021,7 @@ func (st *strmSyncState) pruneLocal() error {
}
localRoot := filepath.Clean(st.p.LocalPath)
var dirs []string
var strmFiles []string
err := filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
@@ -1933,13 +2044,74 @@ func (st *strmSyncState) pruneLocal() error {
}
rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel))
remove := false
if ext == ".strm" {
relSansExt := rel[:len(rel)-len(ext)]
strmFiles = append(strmFiles, path)
}
return nil
})
if err != nil {
return err
}
if err := st.refreshUnseen115Dirs(localRoot, dirs); err != nil {
return err
}
// 先从浅到深找出最上层孤儿目录;父目录已判定为孤儿时无需重复处理子目录。
sort.Strings(dirs)
orphanRoots := make([]string, 0)
for _, dir := range dirs {
rel, relErr := filepath.Rel(localRoot, dir)
if relErr != nil {
continue
}
rel = filepath.ToSlash(rel)
st.mu.Lock()
existsRemotely := st.seenDir[rel]
st.mu.Unlock()
if existsRemotely {
continue
}
underOrphan := false
for _, root := range orphanRoots {
childRel, childErr := filepath.Rel(root, dir)
if childErr == nil && childRel != ".." && !strings.HasPrefix(childRel, ".."+string(filepath.Separator)) {
underOrphan = true
break
}
}
if !underOrphan {
orphanRoots = append(orphanRoots, dir)
}
}
for _, dir := range orphanRoots {
var fileCount int64
_ = filepath.WalkDir(dir, func(_ string, d os.DirEntry, walkErr error) error {
if walkErr == nil && !d.IsDir() {
fileCount++
}
return nil
})
if err := os.RemoveAll(dir); err == nil {
st.mu.Lock()
remove = !st.seenVideo["v:"+relSansExt]
st.rec.Pruned += fileCount
st.mu.Unlock()
}
}
// 对仍存在于远端的目录,按原规则清理失去远端视频来源的单个 .strm。
for _, path := range strmFiles {
if _, err := os.Stat(path); err != nil {
continue // 已随孤儿目录递归删除
}
rel, relErr := filepath.Rel(localRoot, path)
if relErr != nil {
continue
}
rel = filepath.ToSlash(rel)
relSansExt := strings.TrimSuffix(rel, filepath.Ext(rel))
st.mu.Lock()
remove := !st.seenVideo["v:"+relSansExt]
st.mu.Unlock()
if remove {
if err := os.Remove(path); err == nil {
st.mu.Lock()
@@ -1947,11 +2119,8 @@ func (st *strmSyncState) pruneLocal() error {
st.mu.Unlock()
}
}
return nil
})
if err != nil {
return err
}
if st.cfg.DeleteDir {
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
for _, dir := range dirs {
+46 -10
View File
@@ -489,14 +489,12 @@ func taskNames(tasks []model.StrmUploadTask) []string {
return names
}
// TestPruneLocalKeepsLocalMeta 验证清理规则:远端已删除的视频 .strm 仍会被清理,
// 但本地元数据一律保留(即使开启"下载元数据"且未开启"上传元数据"、网盘端没有
// 该元数据,也不再删除本地刮削好的 nfo/图片/字幕)。
func TestPruneLocalKeepsLocalMeta(t *testing.T) {
// TestPruneLocalRemovesOrphanDirectory 验证远端目录不存在时,会递归删除整个本地
// 目录,包括其中的 strm、NFO、图片等文件。
func TestPruneLocalRemovesOrphanDirectory(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
// 阿凡达.strm(对应视频已被网盘删除 → 应清理)+ 阿凡达.nfo(网盘没有 → 保留)
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.strm"), "http://test.local:8096/x")
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.nfo"), "<local scraped meta/>")
writeFile(t, filepath.Join(localDir, "电影", "poster.jpg"), "local-poster")
@@ -518,10 +516,40 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenDir: map[string]bool{"": true},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 本次远端扫描既没有看到视频,也没有看到任何元数据
if err := st.pruneLocal(); err != nil {
t.Fatalf("pruneLocal failed: %v", err)
}
if _, err := os.Stat(filepath.Join(localDir, "电影")); !os.IsNotExist(err) {
t.Fatalf("orphan directory should be removed recursively, stat err = %v", err)
}
if st.rec.Pruned != 3 {
t.Fatalf("expected 3 pruned files, got %d", st.rec.Pruned)
}
}
// TestPruneLocalKeepsMetaInExistingDirectory 验证目录仍在远端时,只清理失去
// 视频来源的 strm,不删除本地刮削元数据。
func TestPruneLocalKeepsMetaInExistingDirectory(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.strm"), "http://test.local:8096/x")
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.nfo"), "<local scraped meta/>")
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: &model.StrmSyncPath{Base: model.Base{ID: "prune-existing-dir"}, LocalPath: localDir},
cfg: &strmPathConfig{},
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenDir: map[string]bool{"": true, "电影": true},
}
if err := st.pruneLocal(); err != nil {
t.Fatalf("pruneLocal failed: %v", err)
}
@@ -530,10 +558,7 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
t.Fatalf("orphan .strm should be pruned, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(localDir, "电影", "阿凡达.nfo")); err != nil {
t.Fatalf("local meta must be kept even when missing on remote: %v", err)
}
if _, err := os.Stat(filepath.Join(localDir, "电影", "poster.jpg")); err != nil {
t.Fatalf("local poster must be kept even when missing on remote: %v", err)
t.Fatalf("local meta in an existing remote directory must be kept: %v", err)
}
if st.rec.Pruned != 1 {
t.Fatalf("expected 1 pruned (strm only), got %d", st.rec.Pruned)
@@ -800,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 {
@@ -1088,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 {
@@ -1143,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)
}
@@ -1436,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 {
@@ -1651,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")
}
}
+101 -21
View File
@@ -94,28 +94,28 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
tracks, err := s.discover(ctx, mediaID)
cacheKey := "external:" + mediaID
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
return tracks, nil
}
tracks, err := s.discoverExternalUncached(ctx, mediaID)
if err != nil {
return nil, err
}
out := make([]SubtitleTrack, 0, len(tracks))
for _, track := range tracks {
if track.Source != "embedded" {
out = append(out, track)
}
}
return out, nil
s.rememberDiscovery(cacheKey, tracks)
return tracks, nil
}
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
if tracks, ok := s.cachedDiscovery(mediaID); ok {
cacheKey := "all:" + mediaID
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
return tracks, nil
}
tracks, err := s.discoverUncached(ctx, mediaID)
if err != nil {
return nil, err
}
s.rememberDiscovery(mediaID, tracks)
s.rememberDiscovery(cacheKey, tracks)
return tracks, nil
}
@@ -159,6 +159,30 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
if m == nil {
return nil, errors.New("media not found")
}
tracks := discoverExternalSubtitleTracks(m)
embedded, err := s.discoverEmbedded(ctx, m)
if err != nil {
if s.log != nil {
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
}
} else {
tracks = append(tracks, embedded...)
}
return tracks, nil
}
func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
return nil, errors.New("media not found")
}
return discoverExternalSubtitleTracks(m), nil
}
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
dir := filepath.Dir(m.Path)
bases := mediaSidecarBaseVariants(m.Path)
if len(bases) == 0 {
@@ -210,19 +234,11 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
Path: filepath.Join(c, e.Name()),
Codec: codec,
Source: "external",
Delivery: "webvtt",
Delivery: subtitleDeliveryForCodec(codec),
})
}
}
embedded, err := s.discoverEmbedded(ctx, m)
if err != nil {
if s.log != nil {
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
}
} else {
tracks = append(tracks, embedded...)
}
return tracks, nil
return tracks
}
type embeddedSubtitleProbe struct {
@@ -281,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 {
@@ -298,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"
}
@@ -448,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
@@ -456,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
@@ -464,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
}
+65 -1
View File
@@ -2,7 +2,9 @@ package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
@@ -48,6 +50,43 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
}
}
func TestDiscoverExternalOnlyDoesNotResolveOrProbeCloudMedia(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatal(err)
}
media := model.Media{
Title: "Cloud Media",
Path: "cloud://115/example/video.mkv",
STRMURL: "cloud://115/example/video.mkv",
}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
resolveCalls := 0
svc.SetStrmPlayTargetResolver(func(context.Context, string) (*StrmPlayResult, error) {
resolveCalls++
return nil, errors.New("resolver must not be called")
})
tracks, err := svc.DiscoverExternalOnly(t.Context(), media.ID)
if err != nil {
t.Fatal(err)
}
if resolveCalls != 0 {
t.Fatalf("STRM resolver called %d times, want 0", resolveCalls)
}
if len(tracks) != 0 {
t.Fatalf("len(tracks) = %d, want 0", len(tracks))
}
}
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
var probe embeddedSubtitleProbe
raw := []byte(`{"streams":[
@@ -61,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 {
@@ -69,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",
@@ -137,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),
+12 -3
View File
@@ -8,14 +8,16 @@ export interface SubtitleTrack {
url: string
codec: string
source: 'external' | 'embedded'
delivery: 'webvtt' | 'burn'
delivery: 'webvtt' | 'ass' | 'burn'
stream_index?: number
}
export const subtitlesAPI = {
list: (mediaId: string) =>
list: (mediaId: string, includeEmbedded = false) =>
api
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`)
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`, {
params: includeEmbedded ? { include_embedded: 'true' } : undefined,
})
.then((r) => r.data.tracks ?? []),
url: (mediaId: string, path: string) => {
@@ -24,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>
)
}
+3 -3
View File
@@ -44,8 +44,8 @@ export function ManualScrapeDialog({
if (!open || !media) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
<div className="flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
<div className="fixed inset-0 z-50 flex min-w-0 items-center justify-center overflow-hidden bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
<div className="flex min-w-0 max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
<ManualScrapeDialogHeader title={scopeLabel || media.title} targetCount={dialog.targetIds.length} onClose={onClose} />
<ManualScrapeSearchControls
@@ -60,7 +60,7 @@ export function ManualScrapeDialog({
onEpisodeArtworkChange={dialog.setIncludeEpisodeArtwork}
/>
<div className="flex-1 overflow-y-auto p-5">
<div className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-5">
<ManualScrapeCandidateList items={dialog.items} applyingKey={dialog.applyingKey} onApply={dialog.apply} />
</div>
</div>
@@ -20,14 +20,14 @@ export function ManualScrapeDialogHeader({
onClose: () => void
}) {
return (
<div className="flex items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
<div>
<div className="flex min-w-0 items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
<div className="min-w-0">
<h2 className="font-display text-xl font-bold text-ink-600">手动搜索刮削</h2>
<p className="mt-1 text-xs text-sand-500">
<p className="mt-1 truncate text-xs text-sand-500">
{title} · {targetCount > 1 ? `将应用到 ${targetCount} 个媒体` : '单个媒体'}
</p>
</div>
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
<button onClick={onClose} className="btn-ghost h-9 w-9 shrink-0 p-0" aria-label="关闭">
<X size={16} />
</button>
</div>
@@ -58,7 +58,7 @@ export function ManualScrapeSearchControls({
onEpisodeArtworkChange,
}: ManualScrapeSearchControlsProps) {
return (
<div className="grid gap-4 border-b border-sand-200 bg-sand-50/40 p-5">
<div className="grid min-w-0 gap-4 border-b border-sand-200 bg-sand-50/40 p-5">
<ManualScrapeQueryBar
query={query}
searching={searching}
@@ -170,7 +170,7 @@ export function ManualScrapeCandidateList({
}
return (
<div className="grid gap-3">
<div className="grid min-w-0 gap-3">
{items.map((item) => {
const key = candidateKey(item)
return (
@@ -199,7 +199,7 @@ function ManualScrapeCandidateRow({
onApply: (item: ManualScrapeCandidate) => void
}) {
return (
<div className="flex flex-col gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
<div className="flex min-w-0 max-w-full flex-col gap-4 overflow-hidden rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
@@ -208,14 +208,14 @@ function ManualScrapeCandidateRow({
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
{item.nsfw ? <span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-bold text-rose-600">成人</span> : null}
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="min-w-0 max-w-full flex-1 truncate font-semibold text-ink-600">{item.title}</h3>
<span className="shrink-0 rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
{item.nsfw ? <span className="shrink-0 rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-bold text-rose-600">成人</span> : null}
{item.year ? <span className="shrink-0 text-xs text-sand-500">{item.year}</span> : null}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
<p className="mt-2 text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
<p className="mt-2 break-words text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
</div>
<button onClick={() => onApply(item)} disabled={disabled} className="btn-outline h-10 w-full shrink-0 justify-center px-3 text-xs sm:w-auto sm:self-center">
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
+28 -13
View File
@@ -1,8 +1,8 @@
import { Layers } from 'lucide-react'
import { Cloud, Layers } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { Media } from '../types'
import { mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
type MediaVersionSwitcherProps = {
media: Media
@@ -31,14 +31,30 @@ export function MediaVersionSwitcher({
<div className="flex flex-wrap gap-2">
{versions.map((version) => {
const active = version.id === media.id
const isStrm = isStrmMedia(version)
const label = mediaVersionLabel(version)
const className = dark
const btnClass = dark
? active
? 'border-white/40 bg-white/20 text-white'
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15'
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
: active
? 'border-brand-500/40 bg-brand-50 text-[#b07d35]'
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
const content = (
<span className="inline-flex items-center gap-1.5">
{isStrm && (
<Cloud
size={12}
className={dark ? (active ? 'text-brand-300' : 'text-white/60') : (active ? 'text-brand-500' : 'text-sand-400')}
aria-label="云端 / STRM"
/>
)}
<span>{label}</span>
{!dark && active ? <span className="opacity-75">· 当前</span> : null}
</span>
)
if (mode === 'player') {
return (
<button
@@ -46,10 +62,10 @@ export function MediaVersionSwitcher({
type="button"
disabled={active}
onClick={() => onSelect?.(version)}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${className}`}
title={version.path}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${btnClass}`}
title={version.path || version.strm_url}
>
{label}
{content}
</button>
)
}
@@ -58,11 +74,10 @@ export function MediaVersionSwitcher({
key={version.id}
to={`/play/${version.id}`}
state={{ from: `/media/${media.id}` }}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${className}`}
title={version.path}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
title={version.path || version.strm_url}
>
{label}
{active ? ' · 当前' : ''}
{content}
</Link>
)
})}
+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>
)
}
}
+3 -3
View File
@@ -3,7 +3,7 @@ import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
import { imageURL } from '../api/client'
import type { Media } from '../types'
import { seriesTitleFromPath } from '../utils/groupSeries'
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
export type SeasonGroup = {
season: number
@@ -42,7 +42,7 @@ export function PlayerPlaylistPanel({
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
}
return Array.from(seasonsMap.entries())
.sort(([a], [b]) => a - b)
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
.map(([season, list]) => ({ season, episodes: list }))
}, [episodes])
@@ -128,7 +128,7 @@ export function PlayerPlaylistPanel({
: 'bg-white/5 text-white/70 hover:bg-white/10 hover:text-white'
}`}
>
<span>{season === 0 ? '特别篇' : `第 ${season} 季`}</span>
<span>{seasonLabel(season)}</span>
<span className="text-[10px] opacity-75">({sesEps.length})</span>
{isPlayingThisSeason && !isSelected && (
<span className="h-1.5 w-1.5 rounded-full bg-rose-400" />
+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>
)
}

Some files were not shown because too many files have changed in this diff Show More