Compare commits

...

21 Commits

Author SHA1 Message Date
truewhile 41fe75e136 支持 Emby 演职人员头像代理并优化媒体系列搜索 2026-09-12 16:07:16 +08:00
truewhile e65accf2cd Optimize Emby caching and image resize concurrency 2026-09-12 12:46:30 +08:00
truewhile f54ef2228c Merge pull request #32 from truewhile/cursor/fix-emby-hero-backdrop
修复 Emby 大海报在剧集图缓存被淘汰后显示占位图
2026-09-12 11:47:38 +08:00
truewhile 3c2bfb14ca 修复 Emby 大海报在剧集图缓存被淘汰后显示占位图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 11:43:51 +08:00
truewhile 8f64f961ac 缓存弹幕抓取结果并合并并发请求 2026-09-12 11:02:24 +08:00
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
138 changed files with 7863 additions and 690 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 -1
View File
@@ -20,6 +20,7 @@ require (
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
@@ -88,7 +89,6 @@ require (
golang.org/x/arch v0.25.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
+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)
}
}
+76 -13
View File
@@ -29,27 +29,90 @@ var embyPlaceholderPNG = []byte{
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
clearEmbyImageNoStoreHeaders(c)
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
req := c.Request.WithContext(ctx)
id := c.Param("id")
imgType := strings.ToLower(c.Param("type"))
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
if err != nil || raw == "" {
embyServePlaceholderImage(c)
embyServeImage(c, svc, c.Param("id"), c.Param("type"), false)
}
}
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
embyServeImage(c, svc, c.Param("name"), c.Param("type"), true)
}
}
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType string, person bool) {
clearEmbyImageNoStoreHeaders(c)
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
req := c.Request.WithContext(ctx)
if svc == nil || svc.Emby == nil {
embyServePlaceholderImage(c)
return
}
var raw string
var err error
if person {
raw, err = svc.Emby.PersonImageURL(ctx, id, imageType)
} else {
raw, err = svc.Emby.ImageURL(ctx, id, imageType)
}
if err != nil || raw == "" {
embyServePlaceholderImage(c)
return
}
if svc.ImageProxy == nil {
embyServePlaceholderImage(c)
return
}
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
embyServePlaceholderImage(c)
}
}
// 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
}
if svc.ImageProxy == nil {
embyServePlaceholderImage(c)
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(ctx, c.Writer, req, raw); err != nil {
embyServePlaceholderImage(c)
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")
+25
View File
@@ -115,12 +115,31 @@ 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))
// 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。
grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc))
grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc))
grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc))
grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(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 +221,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))
+33
View File
@@ -563,6 +563,39 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return remoteItems
}
if c.DefaultQuery("group_series", "0") != "0" {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(50000)
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
limit = 50
}
if len(all) > limit {
all = all[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
+83
View File
@@ -447,6 +447,89 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
}
}
func TestSearchMediaGroupsSeriesBeforeLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
now := time.Now()
rows := []model.Media{
{
Base: model.Base{ID: "dbkai-ep-1", CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now.Add(-2 * time.Minute)},
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E01.mkv",
SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709,
},
{
Base: model.Base{ID: "dbkai-ep-2", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute)},
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E02.mkv",
SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709,
},
{
Base: model.Base{ID: "dbkai-ep-3", CreatedAt: now, UpdatedAt: now},
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E03.mkv",
SeasonNum: 1, EpisodeNum: 3, TMDbID: 61709,
},
{
Base: model.Base{ID: "db-movie", CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now.Add(-3 * time.Minute)},
LibraryID: lib.ID, Title: "龙珠超:布罗利", Path: "/media/anime/龙珠超:布罗利 (2018)/龙珠超:布罗利.mkv",
TMDbID: 503314,
},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "user")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠&limit=2&group_series=1", nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var res struct {
Items []model.Media `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if len(res.Items) != 2 {
t.Fatalf("expected one representative per series after limit, got %d: %#v", len(res.Items), res.Items)
}
seenSeries := false
seenMovie := false
for _, item := range res.Items {
switch item.TMDbID {
case 61709:
seenSeries = true
if item.EpisodeNum != 1 {
t.Fatalf("series representative episode=%d, want first episode", item.EpisodeNum)
}
case 503314:
seenMovie = true
}
}
if !seenSeries || !seenMovie {
t.Fatalf("expected one Dragon Ball series and one movie, got %#v", res.Items)
}
}
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
gin.SetMode(gin.TestMode)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -54,6 +54,7 @@ func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container)
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
authed.GET("/subtitles/:id/ass", serveASSSubtitleHandler(svc))
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
}
@@ -12,6 +12,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
authed.GET("/danmaku/:id", getDanmakuHandler(svc))
authed.GET("/danmaku/config", getDanmakuConfigHandler(svc))
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
authed.GET("/watch-history", historyListHandler(svc))
authed.GET("/watch-history/stats", historyStatsHandler(svc))
+16
View File
@@ -34,6 +34,22 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
}
}
func serveASSSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"})
return
}
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
if err := svc.Subtitle.ServeASS(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
}
}
func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
+7
View File
@@ -28,6 +28,13 @@ type User struct {
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// DanmakuMergeSources 是网页播放器的弹幕偏好:开启后,同一集的多个弹幕
// 来源会被合并并按「时间 + 内容」去重后一起展示。按用户存储,避免一个
// 用户的开关影响其他人。
DanmakuMergeSources bool `gorm:"default:false" json:"danmaku_merge_sources"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
+86 -26
View File
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"path/filepath"
"strings"
"gorm.io/gorm"
@@ -10,6 +11,18 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// MediaUpsertItem carries a media row and optional sibling paths from an
// earlier keep_ext naming mode. An alias is only used when the exact new path
// does not exist in the database; in that case the existing row is migrated to
// the new path so scraped metadata survives renames such as:
//
// foo.strm -> foo.mkv.strm
// foo.mkv.strm -> foo.strm
type MediaUpsertItem struct {
Media *model.Media
AliasPaths []string
}
// Upsert inserts or updates a media row keyed by Path (unique index).
//
// 重要:当一条行已经存在时,scanner 重扫只应该刷新文件级元数据
@@ -22,20 +35,14 @@ import (
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
// 永远捞不到数据。
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
var indexIDs []string
err := withSQLiteBusyRetry(ctx, func() error {
id, uerr := r.upsertWithDB(ctx, r.db, m)
if uerr != nil {
return uerr
}
indexIDs = append(indexIDs[:0], id)
return nil
})
if err != nil {
return err
}
r.indexByIDBestEffort(ctx, indexIDs)
return nil
return r.UpsertWithAliases(ctx, m, nil)
}
// UpsertWithAliases is Upsert with explicit, filesystem-verified STRM sibling
// aliases. It preserves the old row's ID, CreatedAt and scraped metadata while
// moving it to the current path.
func (r *MediaRepository) UpsertWithAliases(ctx context.Context, m *model.Media, aliasPaths []string) error {
return r.UpsertBatchWithAliases(ctx, []MediaUpsertItem{{Media: m, AliasPaths: aliasPaths}})
}
// UpsertBatch 在单个事务里逐条执行 Upsert:扫描一批只提交(fsync)一次,
@@ -46,6 +53,17 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
// 事务内会把 SQLite 写锁挂起在网络 IO 上,且批内用非事务连接回读只能
// 拿到提交前的旧版本数据,把陈旧内容写进索引。
func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media) error {
mapped := make([]MediaUpsertItem, 0, len(items))
for _, item := range items {
if item != nil {
mapped = append(mapped, MediaUpsertItem{Media: item})
}
}
return r.UpsertBatchWithAliases(ctx, mapped)
}
// UpsertBatchWithAliases runs alias-aware upserts in one transaction.
func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []MediaUpsertItem) error {
if len(items) == 0 {
return nil
}
@@ -53,11 +71,11 @@ func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media)
err := withSQLiteBusyRetry(ctx, func() error {
indexIDs = indexIDs[:0]
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, m := range items {
if m == nil {
for _, item := range items {
if item.Media == nil {
continue
}
id, err := r.upsertWithDB(ctx, tx, m)
id, err := r.upsertWithDB(ctx, tx, item.Media, item.AliasPaths)
if err != nil {
return err
}
@@ -87,9 +105,9 @@ func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string)
}
}
// upsertWithDB 落库(新建或更新),返回需要重建索引的媒体 ID(无则空串)。
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media) (string, error) {
existing, created, err := r.findOrCreateMediaByPath(ctx, db, m)
// upsertWithDB 落库(新建、更新或从旧路径迁移),返回需要重建索引的媒体 ID。
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (string, error) {
existing, created, adopted, err := r.findOrCreateMediaByPath(ctx, db, m, aliasPaths)
if err != nil {
return "", err
}
@@ -98,6 +116,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
}
updates := mediaUpsertUpdates(existing, *m)
if adopted {
updates["path"] = m.Path
if existing.DeletedAt.Valid {
updates["deleted_at"] = nil
}
}
if len(updates) == 0 {
*m = existing
return "", nil
@@ -107,31 +131,67 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return "", err
}
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
if adopted {
existing.Path = m.Path
existing.DeletedAt = gorm.DeletedAt{}
}
*m = existing
return existing.ID, nil
}
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media) (model.Media, bool, error) {
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) {
var existing model.Media
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if alias, aliasErr := r.findMediaByAlias(ctx, db, m.Path, aliasPaths); aliasErr == nil {
return alias, false, true, nil
} else if !errors.Is(aliasErr, gorm.ErrRecordNotFound) {
return model.Media{}, false, false, aliasErr
}
// 新行:保证 scrape_status 走 GORM default:pending(即留空让数据库填)。
if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending"
}
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
return *m, true, nil
return *m, true, false, nil
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
return model.Media{}, false, createErr
return model.Media{}, false, false, createErr
} else {
// 并发插入竞态:重查已命中既有行,直接走更新分支。
return existing, false, nil
return existing, false, false, nil
}
}
if err != nil {
return model.Media{}, false, err
return model.Media{}, false, false, err
}
return existing, false, nil
return existing, false, false, nil
}
func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, currentPath string, aliasPaths []string) (model.Media, error) {
aliases := make([]string, 0, len(aliasPaths))
seen := make(map[string]struct{}, len(aliasPaths))
for _, alias := range aliasPaths {
alias = filepath.Clean(strings.TrimSpace(alias))
if alias == "" || alias == "." || alias == filepath.Clean(currentPath) {
continue
}
if _, ok := seen[alias]; ok {
continue
}
seen[alias] = struct{}{}
aliases = append(aliases, alias)
}
if len(aliases) == 0 {
return model.Media{}, gorm.ErrRecordNotFound
}
var existing model.Media
err := db.WithContext(ctx).Unscoped().
Where("path IN ?", aliases).
Order("CASE WHEN scrape_status = 'matched' THEN 0 ELSE 1 END ASC, " +
"CASE WHEN COALESCE(poster_url, '') <> '' OR COALESCE(overview, '') <> '' THEN 0 ELSE 1 END ASC, " +
"CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END ASC, updated_at DESC, created_at DESC").
First(&existing).Error
return existing, err
}
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any {
@@ -0,0 +1,140 @@
package service
import (
"testing"
)
func TestDanmakuEpisodeNumber(t *testing.T) {
cases := []struct {
in string
want string
}{
{"第1话 裏切りの大空", "1"},
{"第18话 自己相似的两性同体-Fractal Androgynous-", "18"},
{"第11话", "11"},
{"【dandan&animeko】 第29集 那我们走吧", "29"},
{"第 202 集", "202"},
{"第1.5话 特别篇", "1.5"},
{"正片", ""},
{"", ""},
}
for _, tc := range cases {
if got := danmakuEpisodeNumber(tc.in); got != tc.want {
t.Errorf("danmakuEpisodeNumber(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestDanmakuEpisodeSubtitle(t *testing.T) {
cases := []struct {
in string
want string
}{
{"第1话 裏切りの大空", "裏切りの大空"},
{"第18话 自己相似的两性同体-Fractal Androgynous-", "自己相似的两性同体-Fractal Androgynous-"},
{"第11话", ""},
{"【dandan&animeko】 第29集 那我们走吧", "那我们走吧"},
{"正片", ""},
}
for _, tc := range cases {
if got := danmakuEpisodeSubtitle(tc.in); got != tc.want {
t.Errorf("danmakuEpisodeSubtitle(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// 刮削信息匹配:优先使用本地已刮削的年份、集数和集标题,不能再被同名续作
// 或缺少集标题的平台源抢走。
func TestMatchScrapedDanmakuEpisodesUsesYearAndEpisodeTitle(t *testing.T) {
candidates := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "命运石之门 0(2018)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{
{EpisodeID: 10299, EpisodeTitle: "【dandan&animeko】 第1话 零化域的缺失之环-Absolute Zero-"},
}},
{AnimeID: 2, AnimeTitle: "命运石之门(2011)【TV动画】from dandan&animeko", Episodes: []DanmakuEpisode{
{EpisodeID: 10322, EpisodeTitle: "【dandan&animeko】 第1话 始与终的序章-Turning Point-"},
}},
{AnimeID: 3, AnimeTitle: "命运石之门(2011)【动漫】from 360", Episodes: []DanmakuEpisode{
{EpisodeID: 10218, EpisodeTitle: "【qq】 第1集"},
}},
}
got := matchScrapedDanmakuEpisodes(candidates, "命运石之门", 2011, "1", "起始与终结的序章")
if len(got) != 1 {
t.Fatalf("matched %d anime, want 1: %#v", len(got), got)
}
if got[0].AnimeID != 2 || len(got[0].Episodes) != 1 || got[0].Episodes[0].EpisodeID != 10322 {
t.Fatalf("picked wrong source: %#v", got)
}
}
// 真实数据形态:官方 match 给出的剧名+集数在配置源里会命中多季/多版本,
// 且顺序不可靠。必须靠副标题选中正确的那一集。
func TestPickDanmakuEpisodeIDDisambiguatesBySubtitle(t *testing.T) {
// 实测样本:官方 match「命运石之门 第18话 自己相似的两性同体-…」,
// 配置源首条却是《命运石之门 0》的同一集号。
candidates := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "命运石之门 0(2018)", Episodes: []DanmakuEpisode{
{EpisodeID: 11072, EpisodeTitle: "【dandan&animeko】 第18话 并进对称的牵牛星-Translational"},
}},
{AnimeID: 2, AnimeTitle: "命运石之门(2011)", Episodes: []DanmakuEpisode{
{EpisodeID: 11095, EpisodeTitle: "【dandan&animeko】 第18话 自己相似的两性同体-Fractal Androgynous-"},
}},
}
got, ok := pickDanmakuEpisodeID(candidates, "18", "第18话 自己相似的两性同体-Fractal Androgynous-")
if !ok {
t.Fatal("expected a confident match")
}
if got != 11095 {
t.Fatalf("picked episodeId %d, want 11095 (first candidate is a different season)", got)
}
}
// 目标带副标题但没有任何候选的副标题对得上时,必须放弃而不是退回第一条,
// 否则会给用户播放另一部番的弹幕。
func TestPickDanmakuEpisodeIDRejectsWhenSubtitleNotFound(t *testing.T) {
candidates := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "某番 第一季", Episodes: []DanmakuEpisode{
{EpisodeID: 111, EpisodeTitle: "第3话 完全不同的标题"},
}},
}
if got, ok := pickDanmakuEpisodeID(candidates, "3", "第3话 期望的标题"); ok {
t.Fatalf("expected no match, got episodeId %d", got)
}
}
// 目标没有副标题(如「第11话」)时,退而要求集数一致;集数对不上同样放弃。
func TestPickDanmakuEpisodeIDNumberOnlyFallback(t *testing.T) {
candidates := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "86 第二季", Episodes: []DanmakuEpisode{
// 该源对第二季采用绝对集号,第 11 集记作第 22 话。
{EpisodeID: 11113, EpisodeTitle: "第22话 辛"},
}},
}
if got, ok := pickDanmakuEpisodeID(candidates, "11", "第11话"); ok {
t.Fatalf("episode number mismatch must be rejected, got episodeId %d", got)
}
same := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "某番", Episodes: []DanmakuEpisode{
{EpisodeID: 222, EpisodeTitle: "第11话"},
}},
}
got, ok := pickDanmakuEpisodeID(same, "11", "第11话")
if !ok || got != 222 {
t.Fatalf("pickDanmakuEpisodeID = (%d,%v), want (222,true)", got, ok)
}
}
func TestPickDanmakuEpisodeIDSkipsInvalidIDs(t *testing.T) {
candidates := []DanmakuAnime{
{AnimeID: 1, AnimeTitle: "某番", Episodes: []DanmakuEpisode{
{EpisodeID: 0, EpisodeTitle: "第1话 目标"},
{EpisodeID: -5, EpisodeTitle: "第1话 目标"},
{EpisodeID: 333, EpisodeTitle: "第1话 目标"},
}},
}
got, ok := pickDanmakuEpisodeID(candidates, "1", "第1话 目标")
if !ok || got != 333 {
t.Fatalf("pickDanmakuEpisodeID = (%d,%v), want (333,true)", got, ok)
}
}
+426 -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 → 拉弹幕。
@@ -446,3 +824,38 @@ func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
require.Contains(t, res.Raw, "降级搜索弹幕")
}
// 即使刮削元数据完整,也应先走准确率最高的 hash 层。
func TestDanmakuFetchPrefersHashOverCompleteMetadata(t *testing.T) {
videoPath, wantHash := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
var seen string
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"官方测试动画","episodeTitle":"第1话"}]}`,
`<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">hash命中</d></i>`,
&seen)
overrideDanmakuOfficialBase(t, official.URL)
source := newDanmakuSourceServerWithSearch(t,
`{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"官方测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, source.URL()))
m := model.Media{
Title: "测试动画",
Path: videoPath,
SizeBytes: 123,
EpisodeNum: 1,
EpisodeTitle: "起始与终结的序章",
Year: 2011,
}
m.ID = "hash-first"
require.NoError(t, svc.repo.DB.Create(&m).Error)
res, err := svc.Fetch(ctx, "hash-first", "", "")
require.NoError(t, err)
require.Equal(t, "hash", res.MatchMode)
require.NotEmpty(t, res.Raw)
require.Contains(t, seen, wantHash)
}
+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)
}
+726 -36
View File
@@ -13,10 +13,14 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"golang.org/x/sync/singleflight"
"go.uber.org/zap"
@@ -57,6 +61,14 @@ type DanmakuRenderConfig struct {
Opacity string `json:"opacity"`
FontSize string `json:"font_size"`
Area string `json:"area"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
MergeSources bool `json:"merge_sources"`
}
// DanmakuFetchOptions 承载单次抓取的调用方偏好。
type DanmakuFetchOptions struct {
// MergeSources 为真时,同一集的多个来源会被合并去重后一起返回。
MergeSources bool
}
// DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream
@@ -71,13 +83,21 @@ type DanmakuRenderConfig struct {
// metadata so the player UI can display which episode was loaded.
type DanmakuFetchResult struct {
DanmakuRenderConfig
SourceType string `json:"source_type"`
Raw string `json:"raw,omitempty"`
Candidates []DanmakuAnime `json:"candidates,omitempty"`
SourceType string `json:"source_type"`
Raw string `json:"raw,omitempty"`
Candidates []DanmakuAnime `json:"candidates,omitempty"`
// Alternatives 是「同一集的其它可选来源」。与 Candidates 语义不同:
// Candidates 表示自动匹配不唯一、必须由用户选择后才能加载弹幕;
// Alternatives 表示弹幕已经自动加载好了,这里额外提供同集的其它来源
// (LogVar 聚合了多个视频网站,同一集常有多个库)供用户随时切换,
// 不必再手动搜索一遍。
Alternatives []DanmakuAnime `json:"alternatives,omitempty"`
AnimeTitle string `json:"anime_title,omitempty"`
EpisodeTitle string `json:"episode_title,omitempty"`
EpisodeID int64 `json:"episode_id,omitempty"`
MatchMode string `json:"match_mode,omitempty"`
// MergedSources 表示本次结果由多少个来源合并而成(未合并时为 0)。
MergedSources int `json:"merged_sources,omitempty"`
}
// DanmakuAnime is one search hit (an anime) with its episode list, mirroring
@@ -118,6 +138,12 @@ type DanmakuService struct {
hashCacheMu sync.Mutex
hashCache map[string]string // stamp → 16MB-prefix MD5
// resultCache 缓存整条弹幕抓取结果,避免同一集重复播放时重走
// 「16MB 哈希 + 上游搜索 + 评论拉取」这条高延迟链路。
resultCacheMu sync.Mutex
resultCache map[string]danmakuResultCacheEntry
fetchGroup singleflight.Group
}
func danmakuHTTPClient() *http.Client {
@@ -129,10 +155,11 @@ func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuServ
log = zap.NewNop()
}
return &DanmakuService{
log: log,
repo: repo,
client: danmakuHTTPClient(),
hashCache: make(map[string]string),
log: log,
repo: repo,
client: danmakuHTTPClient(),
hashCache: make(map[string]string),
resultCache: make(map[string]danmakuResultCacheEntry),
}
}
@@ -152,6 +179,121 @@ func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolv
}
}
// 弹幕抓取结果缓存:同一集在 TTL 内重复播放时直接返回,避免重复执行
// 「16MB 前置哈希 → 上游搜索 → 评论拉取」这条高延迟链路。弹幕库内容变化
// 很慢,而用户通常会在短时间内反复切集/回看,因此 TTL 取 6 小时。
const (
danmakuResultCacheTTL = 6 * time.Hour
danmakuResultCacheMaxEntries = 64
// 候选列表由上游搜索决定,相对稳定但可能随源更新,缓存 10 分钟。
danmakuResultCacheCandidateTTL = 10 * time.Minute
// 空结果可能只是上游临时抖动,只短暂缓存,避免长时间看不到弹幕。
danmakuResultCacheEmptyTTL = time.Minute
)
type danmakuResultCacheEntry struct {
result *DanmakuFetchResult
expiresAt time.Time
storedAt time.Time
}
func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge bool) string {
return strings.Join([]string{
strings.TrimSpace(source),
mediaID,
strings.TrimSpace(keyword),
strings.TrimSpace(episodeID),
strconv.FormatBool(merge),
}, "\x00")
}
func (s *DanmakuService) resultCacheGet(key string) (*DanmakuFetchResult, bool) {
if s == nil || key == "" {
return nil, false
}
now := time.Now()
s.resultCacheMu.Lock()
defer s.resultCacheMu.Unlock()
entry, ok := s.resultCache[key]
if !ok {
return nil, false
}
if now.After(entry.expiresAt) {
delete(s.resultCache, key)
return nil, false
}
return cloneDanmakuFetchResult(entry.result), true
}
func (s *DanmakuService) resultCachePut(key string, result *DanmakuFetchResult) {
if s == nil || key == "" || result == nil {
return
}
now := time.Now()
s.resultCacheMu.Lock()
defer s.resultCacheMu.Unlock()
if s.resultCache == nil {
s.resultCache = make(map[string]danmakuResultCacheEntry)
}
ttl := danmakuResultCacheTTLFor(result)
if ttl <= 0 {
return
}
if _, exists := s.resultCache[key]; !exists && len(s.resultCache) >= danmakuResultCacheMaxEntries {
oldestKey := ""
var oldest time.Time
for k, entry := range s.resultCache {
if oldestKey == "" || entry.storedAt.Before(oldest) {
oldestKey, oldest = k, entry.storedAt
}
}
delete(s.resultCache, oldestKey)
}
s.resultCache[key] = danmakuResultCacheEntry{
result: cloneDanmakuFetchResult(result),
expiresAt: now.Add(ttl),
storedAt: now,
}
}
// danmakuResultCacheTTLFor 按结果完整性选择缓存时长:拿到弹幕正文才值得
// 长缓存;只有候选列表时短缓存;空结果只缓存一分钟。
func danmakuResultCacheTTLFor(result *DanmakuFetchResult) time.Duration {
if result == nil {
return 0
}
if strings.TrimSpace(result.Raw) != "" {
return danmakuResultCacheTTL
}
if len(result.Candidates) > 0 {
return danmakuResultCacheCandidateTTL
}
return danmakuResultCacheEmptyTTL
}
// cloneDanmakuFetchResult 深拷贝切片字段,避免缓存命中后调用方修改共享数据。
func cloneDanmakuFetchResult(in *DanmakuFetchResult) *DanmakuFetchResult {
if in == nil {
return nil
}
out := *in
out.Candidates = cloneDanmakuAnimeList(in.Candidates)
out.Alternatives = cloneDanmakuAnimeList(in.Alternatives)
return &out
}
func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
if in == nil {
return nil
}
out := make([]DanmakuAnime, len(in))
for i, anime := range in {
out[i] = anime
out[i].Episodes = append([]DanmakuEpisode(nil), anime.Episodes...)
}
return out
}
// Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{
@@ -177,6 +319,41 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
return cfg
}
// ConfigForUser 在全局渲染设置之外附加当前用户的个性化偏好。
func (s *DanmakuService) ConfigForUser(ctx context.Context, userID string) DanmakuRenderConfig {
cfg := s.Config(ctx)
cfg.MergeSources = s.MergeSourcesEnabled(ctx, userID)
return cfg
}
// MergeSourcesEnabled 返回该用户的弹幕合并偏好,读取失败时回退为关闭。
func (s *DanmakuService) MergeSourcesEnabled(ctx context.Context, userID string) bool {
if s == nil || s.repo == nil || s.repo.User == nil {
return false
}
userID = strings.TrimSpace(userID)
if userID == "" {
return false
}
user, err := s.repo.User.FindByID(ctx, userID)
if err != nil || user == nil {
return false
}
return user.DanmakuMergeSources
}
// SetMergeSources 持久化该用户的弹幕合并偏好。
func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error {
if s == nil || s.repo == nil || s.repo.User == nil {
return errors.New("danmaku settings unavailable")
}
userID = strings.TrimSpace(userID)
if userID == "" {
return errors.New("missing user")
}
return s.repo.User.UpdateFields(ctx, userID, map[string]any{"danmaku_merge_sources": enabled})
}
// Fetch retrieves danmaku for the given media. keyword overrides the
// media-derived search term (empty = use the video's own name); pass it from
// the player when the user searches for a custom title. episodeID forces a
@@ -186,19 +363,60 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
// 1. match: MD5 of the first 16MB of the video (local file read directly,
// .strm resolved to a direct link and range-fetched) → /api/v2/match
// against the official endpoint, which yields the episode library id.
// 2. search by the playing file's name + episode number.
// 3. current auto-identification (original name → title → file name + episode,
// 2. scraped metadata: title + episode number + episode title, filtered by
// release year and subtitle to disambiguate same-name seasons.
// 3. search by the playing file's name + episode number.
// 4. current auto-identification (original name → title → file name + episode,
// single hit used, several hits returned as candidates for the player).
// 4. manual: the player picks from the returned candidates (episodeID /
// 5. manual: the player picks from the returned candidates (episodeID /
// keyword override).
//
// Comments are always fetched from the configured source first (when set)
// and fall back to the official endpoint on failure; identification itself
// and fall back to the official endpoint on failure. Only hash identification
// always goes to the official endpoint.
//
// When danmaku is disabled the result carries Enabled=false so the player can
// silently skip rendering.
func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID string) (*DanmakuFetchResult, error) {
return s.FetchWithOptions(ctx, mediaID, keyword, episodeID, DanmakuFetchOptions{})
}
// FetchWithOptions 是 Fetch 的带偏好版本。结果按「配置源 + 媒体 + 关键词 +
// 指定集 + 合并开关」缓存,并用 singleflight 合并并发请求,避免同一集被重复抓取。
func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
if s == nil {
return nil, errors.New("danmaku service unavailable")
}
cfg := s.Config(ctx)
if !cfg.Enabled {
return &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}, nil
}
key := danmakuResultCacheKey(cfg.Source, mediaID, keyword, episodeID, opts.MergeSources)
if cached, ok := s.resultCacheGet(key); ok {
return cached, nil
}
value, err, _ := s.fetchGroup.Do(key, func() (any, error) {
// 等待期间可能已有同一 key 的请求写入缓存。
if cached, ok := s.resultCacheGet(key); ok {
return cached, nil
}
res, err := s.fetchWithOptionsUncached(ctx, mediaID, keyword, episodeID, opts)
if err != nil {
// 与原实现一致:失败时仍把已填充的渲染配置/匹配信息交给调用方。
return res, err
}
s.resultCachePut(key, res)
return res, nil
})
res, _ := value.(*DanmakuFetchResult)
if err != nil {
return cloneDanmakuFetchResult(res), err
}
return cloneDanmakuFetchResult(res), nil
}
// fetchWithOptionsUncached 是未命中缓存时执行的原始抓取流程。
func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"}
if !res.Enabled {
return res, nil
@@ -234,6 +452,12 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
}
target := ""
// targetBase 是 target 所属的源。各源的 episodeId 空间互相独立,必须用
// 产生该 ID 的源去请求弹幕,否则会拿到 404;默认沿用「配置源优先」行为。
targetBase := configured
// hashOfficialID 记录 hash 层的官方 episodeId。仅当配置源重定位成功时
// 才填,用于「配置源该集无弹幕」时回官方兜底。
hashOfficialID := int64(0)
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
@@ -250,21 +474,59 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
if err != nil {
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
} else if len(matches) > 0 {
target = fmt.Sprintf("%d", matches[0].EpisodeID)
res.AnimeTitle = matches[0].AnimeTitle
res.EpisodeTitle = matches[0].EpisodeTitle
res.EpisodeID = matches[0].EpisodeID
match := matches[0]
res.AnimeTitle = match.AnimeTitle
res.EpisodeTitle = match.EpisodeTitle
res.EpisodeID = match.EpisodeID
res.MatchMode = "hash"
// match 返回的是官方 ID 空间的 episodeId,直接拿去问第三方源
// 只会 404(实测各源 ID 空间独立)。先用官方给到的剧名+集数
// 在配置源里重定位到它自己的 episodeId;定位不到就整条走官方。
if configured != "" && !sameDanmakuBase(configured, official) {
if matched, ok := s.lookupConfiguredEpisodes(ctx, configured, match); ok {
configuredID := firstDanmakuEpisodeID(matched)
target = strconv.FormatInt(configuredID, 10)
targetBase = configured
res.EpisodeID = configuredID
hashOfficialID = match.EpisodeID
// 同集有多个来源时全部带上,供面板里直接切换。
if len(matched) > 1 {
res.Alternatives = matched
}
}
}
if target == "" {
target = strconv.FormatInt(match.EpisodeID, 10)
targetBase = official
}
}
}
}
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
// 2) hash 未命中时,优先使用刮削后的剧名、集数和集标题匹配。
// 该层能解决官方 hash 库未收录、但本地已经刮削出准确季度和单集标题的情况。
if target == "" && !manualKeyword && media != nil {
if matched, base, ok := s.lookupScrapedEpisodes(ctx, configured, official, media); ok {
configuredID := firstDanmakuEpisodeID(matched)
target = strconv.FormatInt(configuredID, 10)
targetBase = base
res.AnimeTitle = matched[0].AnimeTitle
res.EpisodeTitle = matched[0].Episodes[0].EpisodeTitle
res.EpisodeID = configuredID
res.MatchMode = "metadata"
if len(matched) > 1 {
res.Alternatives = matched
}
}
}
// 3) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 4 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
if candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, fileName, term.episode); err == nil &&
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
targetBase = base
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
@@ -273,10 +535,10 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
}
}
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 4) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 多结果返回候选列表交给播放器(歧义处理)。
if target == "" {
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, term.name, term.episode)
if err != nil {
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
return res, err
@@ -289,21 +551,159 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
return res, errors.New("no danmaku library found for this video")
}
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
targetBase = base
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "search"
}
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
raw, st, err := s.fetchCommentWithFallback(ctx, targetBase, official, target)
if err != nil {
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
// 第三方目录里存在该集,不代表它真的收录了弹幕(实测部分条目返回
// count=0)。这种「拿到空库」的情况要用官方 episodeId 再试一次,否则
// 重定位后反而会静默变成无弹幕 —— 旧写法是靠官方 ID 撞 404 才走到官方
// 兜底的,改用重定位就必须显式补上这一步。
if hashOfficialID != 0 && danmakuCommentCount(raw) == 0 {
officialTarget := strconv.FormatInt(hashOfficialID, 10)
if officialRaw, officialType, officialErr := s.fetchCommentFromBase(ctx, official, officialTarget); officialErr == nil &&
danmakuCommentCount(officialRaw) > 0 {
raw, st = officialRaw, officialType
res.EpisodeID = hashOfficialID
} else if officialErr != nil {
s.log.Debug("danmaku official fallback for empty configured library failed",
zap.String("episode_id", officialTarget), zap.Error(officialErr))
}
}
// 合并多来源:仅在上游确实返回了多个同集来源时才有意义。
if opts.MergeSources && len(res.Alternatives) > 1 {
if mergedRaw, mergedCount, ok := s.mergeAlternativeSources(ctx, targetBase, res.Alternatives, res.EpisodeID, raw); ok {
raw, st = mergedRaw, "json"
res.MergedSources = mergedCount
}
}
res.Raw, res.SourceType = raw, st
return res, nil
}
// mergeAlternativeSources 并发抓取同一集的多个来源,按「时间 + 内容」去重后
// 合并成单个载荷。已有载荷(existingRaw)会被复用,避免重复请求。
// 返回合并后的载荷、实际参与合并的来源数与是否成功。
func (s *DanmakuService) mergeAlternativeSources(ctx context.Context, base string, alternatives []DanmakuAnime, existingID int64, existingRaw string) (string, int, bool) {
ids := make([]int64, 0, len(alternatives)+1)
seen := map[int64]bool{}
if existingID > 0 {
seen[existingID] = true
ids = append(ids, existingID)
}
for _, anime := range alternatives {
for _, ep := range anime.Episodes {
if ep.EpisodeID <= 0 || seen[ep.EpisodeID] {
continue
}
seen[ep.EpisodeID] = true
ids = append(ids, ep.EpisodeID)
if len(ids) >= danmakuMergeMaxSources {
break
}
}
if len(ids) >= danmakuMergeMaxSources {
break
}
}
// 少于两个来源时无需合并。
if len(ids) < 2 {
return "", 0, false
}
var (
mu sync.Mutex
collected [][]danmakuComment
wg sync.WaitGroup
sem = make(chan struct{}, danmakuMergeConcurrency)
)
collect := func(raw string) {
comments := parseDanmakuComments(raw)
if len(comments) == 0 {
return
}
mu.Lock()
collected = append(collected, comments)
mu.Unlock()
}
for _, id := range ids {
if id == existingID && existingRaw != "" {
collect(existingRaw)
continue
}
wg.Add(1)
go func(id int64) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
body, _, err := s.fetchCommentFromBase(ctx, base, strconv.FormatInt(id, 10))
if err != nil {
// 单个来源失败不影响整体合并,静默跳过。
return
}
collect(body)
}(id)
}
wg.Wait()
if len(collected) < 2 {
return "", 0, false
}
merged := mergeDanmakuComments(collected)
if len(merged) == 0 {
return "", 0, false
}
encoded := encodeDanmakuComments(merged)
if encoded == "" {
return "", 0, false
}
return encoded, len(collected), true
}
// fetchCommentFromBase 从单个源拉取弹幕,不做任何回退。
func (s *DanmakuService) fetchCommentFromBase(ctx context.Context, base, target string) (raw, sourceType string, err error) {
raw, err = s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
if err != nil {
return "", "auto", err
}
return raw, detectDanmakuSourceType(raw), nil
}
// danmakuCommentCount 估算弹幕条数,用于判断某个源是否真的返回了内容。
// 同时兼容 dandanplay JSON 与 Bilibili XML 两种载荷。
func danmakuCommentCount(raw string) int {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return 0
}
switch {
case strings.HasPrefix(trimmed, "{"):
var payload struct {
Count int `json:"count"`
Comments []json.RawMessage `json:"comments"`
}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return 0
}
if payload.Count > 0 {
return payload.Count
}
return len(payload.Comments)
case strings.HasPrefix(trimmed, "<"):
return strings.Count(trimmed, "<d ")
default:
return 0
}
}
// detectDanmakuSourceType guesses the comment payload format from its body.
// The dandanplay protocol historically returned Bilibili-style XML, but newer
// and self-hosted implementations return the dandanplay JSON shape
@@ -724,7 +1124,10 @@ type danmakuMatch struct {
// matchOfficial identifies the video via POST /api/v2/match on the official
// endpoint (always official, signed with the app credentials). Returns the
// candidate list; empty means nothing matched.
// candidate list only when the upstream explicitly reports a confident match;
// empty means nothing matched. The API may return fuzzy suggestions together
// with isMatched=false; those are not safe to auto-select because their first
// item can belong to an unrelated anime.
//
// fileName must be URL-escaped: the official API rejects raw non-ASCII file
// names with errorCode 2 (verified against the live API — QueryEscape's
@@ -774,13 +1177,14 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
}
var out struct {
Success bool `json:"success"`
Matches []danmakuMatch `json:"matches"`
Success bool `json:"success"`
IsMatched bool `json:"isMatched"`
Matches []danmakuMatch `json:"matches"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("danmaku match returned invalid JSON: %w", err)
}
if !out.Success {
if !out.Success || !out.IsMatched {
return nil, nil
}
return out.Matches, nil
@@ -794,13 +1198,14 @@ func sameDanmakuBase(a, b string) bool {
return errA == nil && errB == nil && strings.EqualFold(ua.Host, ub.Host)
}
// fetchCommentWithFallback fetches a comment library from the configured
// source first (when set and different from official), falling back to the
// official endpoint on failure.
func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, configured, official, target string) (raw, sourceType string, err error) {
// fetchCommentWithFallback fetches a comment library from primary first (when
// set and different from official), falling back to the official endpoint on
// failure. primary must be the source that issued target: episode ids are only
// meaningful inside the source that produced them.
func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, primary, official, target string) (raw, sourceType string, err error) {
var bases []string
if configured != "" && !sameDanmakuBase(configured, official) {
bases = append(bases, configured)
if primary != "" && !sameDanmakuBase(primary, official) {
bases = append(bases, primary)
}
bases = append(bases, official)
var lastErr error
@@ -817,16 +1222,301 @@ func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, configure
return "", "auto", lastErr
}
// searchCandidatesWithFallback searches the configured source first (when
// set and different from official), falling back to the official endpoint on
// failure.
func (s *DanmakuService) searchCandidatesWithFallback(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, error) {
// searchCandidatesWithSource searches the configured source first (when set
// and different from official), falling back to the official endpoint on
// failure. It also reports which base produced the hits, because the caller
// must fetch comments from that same base — episode ids are source-local.
func (s *DanmakuService) searchCandidatesWithSource(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, string, error) {
if configured != "" && !sameDanmakuBase(configured, official) {
candidates, err := s.searchCandidates(ctx, configured, name, episode)
if err == nil {
return candidates, nil
return candidates, configured, nil
}
s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", configured), zap.Error(err))
}
return s.searchCandidates(ctx, official, name, episode)
candidates, err := s.searchCandidates(ctx, official, name, episode)
return candidates, official, err
}
// danmakuEpisodeNumberRE 从「第N话 / 第N話 / 第N集」里取出集数。各源标题格式
// 不一(有的只有集数、有的带副标题),正则只认集数标记本身。
var danmakuEpisodeNumberRE = regexp.MustCompile(`第\s*(\d+(?:\.\d+)?)\s*[话話集]`)
// danmakuTitleYearRE 匹配搜索源标题里的年份,例如「命运石之门(2011)」。
var danmakuTitleYearRE = regexp.MustCompile(`\((?:19|20)\d{2}\)`)
// danmakuEpisodeNumber 返回标题中的集数,取不到时返回空串。
func danmakuEpisodeNumber(title string) string {
m := danmakuEpisodeNumberRE.FindStringSubmatch(title)
if len(m) < 2 {
return ""
}
return m[1]
}
// danmakuEpisodeSubtitle 返回「第N话」之后的副标题,用于区分同名前作/续作。
func danmakuEpisodeSubtitle(title string) string {
loc := danmakuEpisodeNumberRE.FindStringIndex(title)
if loc == nil {
return ""
}
return strings.TrimSpace(title[loc[1]:])
}
// normalizeDanmakuText 去掉标点与空白,便于跨源比对副标题。注意它只做字符
// 归一化,不剥离集数标记 —— 调用方传入的可能已经是剥离后的副标题。
func normalizeDanmakuText(s string) []rune {
out := make([]rune, 0, 32)
for _, r := range strings.ToLower(s) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
out = append(out, r)
}
}
return out
}
// danmakuTitleYear 返回标题中显式标注的年份;没有年份时返回 0。
func danmakuTitleYear(title string) int {
m := danmakuTitleYearRE.FindString(title)
if len(m) != 6 {
return 0
}
year, _ := strconv.Atoi(m[1:5])
return year
}
// danmakuSubtitleMatches 判断两个源的副标题是否指向同一集。
//
// 完全一致直接判定相同,且不设长度门槛 —— 中文副标题常常只有两三个字
// (「辛」「序曲」),但它们本身就是很强的标识。只有在做包含/前缀这类模糊
// 比对时才要求足够长度,避免短串误配到别的集。同一集在不同源的副标题长度
// 也可能不同(一侧带 "-Fractal Androgynous-" 之类后缀),故保留模糊分支。
func danmakuSubtitleMatches(candidateTitle, subtitle string) bool {
// candidateTitle 是完整标题,subtitle 已经剥离过集数标记,两者处理方式不同。
a := normalizeDanmakuText(danmakuEpisodeSubtitle(candidateTitle))
b := normalizeDanmakuText(subtitle)
if len(a) == 0 || len(b) == 0 {
return false
}
if string(a) == string(b) {
return true
}
const fuzzyMinRunes = 4
if len(a) < fuzzyMinRunes || len(b) < fuzzyMinRunes {
return false
}
if strings.Contains(string(a), string(b)) || strings.Contains(string(b), string(a)) {
return true
}
if danmakuRuneSimilarity(a, b) >= 0.7 {
return true
}
n := len(a)
if len(b) < n {
n = len(b)
}
if n > 12 {
n = 12
}
return string(a[:n]) == string(b[:n])
}
// danmakuRuneSimilarity 返回两个字符串的 LCS 相似度,分母取较短长度。
// 弹幕源的日文/英文副标题常比刮削标题更长,因此不能直接用整体编辑距离;
// 例如「起始与终结的序章」和「始与终的序章-Turning Point-」应被视为同一集。
func danmakuRuneSimilarity(a, b []rune) float64 {
if len(a) == 0 || len(b) == 0 {
return 0
}
prev := make([]int, len(b)+1)
for _, ra := range a {
cur := make([]int, len(b)+1)
for j, rb := range b {
if ra == rb {
cur[j+1] = prev[j] + 1
} else if cur[j] > prev[j+1] {
cur[j+1] = cur[j]
} else {
cur[j+1] = prev[j+1]
}
}
prev = cur
}
minLen := len(a)
if len(b) < minLen {
minLen = len(b)
}
return float64(prev[len(b)]) / float64(minLen)
}
// lookupConfiguredEpisodes 用官方 match 给出的「剧名 + 集数」在配置源里重新
// 定位同一集,返回配置源里所有指向该集的结果。
//
// 必须重定位:各源 episodeId 空间互相独立,官方 ID(如 135500001)在第三方
// 源上只是一个不存在的编号,直接请求必然 404。官方 match 返回的 animeTitle /
// episodeTitle 正好提供了跨源检索所需的剧名、集数与副标题。
//
// 返回列表而非单条:LogVar 这类源聚合了多个视频网站,同一集常有多个库,
// 调用方取第一条自动加载,其余作为可切换来源交给用户。
func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configured string, m danmakuMatch) ([]DanmakuAnime, bool) {
episodeNum := danmakuEpisodeNumber(m.EpisodeTitle)
if episodeNum == "" || strings.TrimSpace(m.AnimeTitle) == "" {
return nil, false
}
candidates, err := s.searchCandidates(ctx, configured, m.AnimeTitle, episodeNum)
if err != nil {
s.log.Debug("danmaku configured lookup failed",
zap.String("source", configured),
zap.String("anime", m.AnimeTitle),
zap.Error(err))
return nil, false
}
matched := matchDanmakuEpisodes(candidates, episodeNum, m.EpisodeTitle)
if len(matched) == 0 {
return nil, false
}
return matched, true
}
// lookupScrapedEpisodes uses already-scraped media metadata to locate the
// correct library when hash identification missed. Unlike the generic title
// search, this path requires an explicit episode-title match and, when known,
// a matching release year. That prevents Steins;Gate (2011) from being
// confused with Steins;Gate 0 (2018) or other same-name entries.
func (s *DanmakuService) lookupScrapedEpisodes(ctx context.Context, configured, official string, media *model.Media) ([]DanmakuAnime, string, bool) {
title, episodeNum, episodeTitle, ok := scrapedDanmakuMetadata(media)
if !ok {
return nil, "", false
}
candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, title, episodeNum)
if err != nil {
s.log.Debug("danmaku scraped metadata lookup failed",
zap.String("title", title),
zap.String("episode", episodeNum),
zap.Error(err))
return nil, "", false
}
matched := matchScrapedDanmakuEpisodes(candidates, title, media.Year, episodeNum, episodeTitle)
if len(matched) == 0 {
return nil, "", false
}
return matched, base, true
}
// scrapedDanmakuMetadata returns the query fields only when the media has
// enough scraped information to identify a specific episode.
func scrapedDanmakuMetadata(media *model.Media) (title, episodeNum, episodeTitle string, ok bool) {
if media == nil {
return "", "", "", false
}
title = strings.TrimSpace(media.Title)
if title == "" {
title = strings.TrimSpace(media.OriginalName)
}
episodeTitle = strings.TrimSpace(media.EpisodeTitle)
if title == "" || media.EpisodeNum <= 0 || episodeTitle == "" {
return "", "", "", false
}
return title, strconv.Itoa(media.EpisodeNum), episodeTitle, true
}
// matchScrapedDanmakuEpisodes filters title search hits by release year and
// episode subtitle. The returned list keeps the source grouping so multiple
// libraries for the same episode remain switchable in the player.
func matchScrapedDanmakuEpisodes(candidates []DanmakuAnime, title string, year int, episodeNum, episodeTitle string) []DanmakuAnime {
out := make([]DanmakuAnime, 0, len(candidates))
for _, anime := range candidates {
if !danmakuAnimeTitleMatches(anime.AnimeTitle, title, year) {
continue
}
hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle)
if len(hits) == 0 {
continue
}
anime.Episodes = hits
out = append(out, anime)
}
return out
}
// danmakuAnimeTitleMatches checks the scraped title and, when present, the
// candidate's year. A candidate with a conflicting explicit year is rejected
// even if its title contains the scraped title.
func danmakuAnimeTitleMatches(animeTitle, mediaTitle string, year int) bool {
candidate := string(normalizeDanmakuText(animeTitle))
target := string(normalizeDanmakuText(mediaTitle))
if candidate == "" || target == "" || !strings.Contains(candidate, target) {
return false
}
if year > 0 {
if candidateYear := danmakuTitleYear(animeTitle); candidateYear > 0 && candidateYear != year {
return false
}
}
return true
}
// firstDanmakuEpisodeID 返回匹配列表里的第一条 episodeId,作为自动选中的库。
func firstDanmakuEpisodeID(matched []DanmakuAnime) int64 {
for _, anime := range matched {
for _, ep := range anime.Episodes {
if ep.EpisodeID > 0 {
return ep.EpisodeID
}
}
}
return 0
}
// pickDanmakuEpisodeID 从配置源的搜索结果里挑出与目标集最匹配的一条。
//
// 搜索按「剧名+集数」返回,但同名不同季/不同版本会同时命中,且顺序不保证
// 正确:实测「命运石之门 第18话」首条是《命运石之门 0(2018)》、《战区88 OVA
// 第1话》首条是《战区88(2004) TV》,两者内容都不对,只有副标题能区分。因此:
// - 目标带副标题时,必须找到副标题一致的候选,否则放弃(宁可回退官方,
// 也不能给用户放错番的弹幕);
// - 目标没有副标题(如「第11话」)时,退而要求集数一致。
func pickDanmakuEpisodeID(candidates []DanmakuAnime, episodeNum, episodeTitle string) (int64, bool) {
if id := firstDanmakuEpisodeID(matchDanmakuEpisodes(candidates, episodeNum, episodeTitle)); id != 0 {
return id, true
}
return 0, false
}
// matchDanmakuEpisodes 在搜索结果里筛出所有指向目标集的结果,保留原有的番剧
// 分组结构(只留下命中的集数),因此调用方既能取第一条自动加载,也能把整个
// 列表作为可切换来源展示。
func matchDanmakuEpisodes(candidates []DanmakuAnime, episodeNum, episodeTitle string) []DanmakuAnime {
out := make([]DanmakuAnime, 0, len(candidates))
for _, anime := range candidates {
hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle)
if len(hits) == 0 {
continue
}
anime.Episodes = hits
out = append(out, anime)
}
return out
}
// matchDanmakuEpisodeHits 返回同一番剧里指向目标集的弹幕库。
func matchDanmakuEpisodeHits(episodes []DanmakuEpisode, episodeNum, episodeTitle string) []DanmakuEpisode {
subtitle := danmakuEpisodeSubtitle(episodeTitle)
// 刮削后的 episode_title 通常只保存副标题本身(例如「起始与终结的序章」),
// 不带「第1话」前缀。此时把它整体作为副标题参与比对。
if subtitle == "" && danmakuEpisodeNumber(episodeTitle) == "" {
subtitle = strings.TrimSpace(episodeTitle)
}
hits := make([]DanmakuEpisode, 0, len(episodes))
for _, ep := range episodes {
if ep.EpisodeID <= 0 || danmakuEpisodeNumber(ep.EpisodeTitle) != episodeNum {
continue
}
// 目标带副标题时必须副标题一致;没有副标题时仅凭集数匹配。
if subtitle != "" && !danmakuSubtitleMatches(ep.EpisodeTitle, subtitle) {
continue
}
hits = append(hits, ep)
}
return hits
}
+80 -1
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"github.com/glebarez/sqlite"
@@ -22,7 +24,8 @@ func newDanmakuTestService(t *testing.T) *DanmakuService {
// 独立临时文件库,避免测试间通过共享内存库串数据。
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "danmaku-test.db")), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}))
// 需要 users 表:弹幕合并偏好按用户存储在 user 行上。
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}, &model.User{}))
repos := repository.New(db)
t.Cleanup(func() {
sqlDB, err := db.DB()
@@ -325,3 +328,79 @@ func TestDanmakuFetchDetectsJSONSource(t *testing.T) {
require.Equal(t, "xml", res2.SourceType)
require.Contains(t, res2.Raw, "弹幕A")
}
// 同一集在缓存 TTL 内重复请求时不应再访问上游(搜索和评论都只发一次)。
func TestDanmakuFetchCachesRepeatedRequests(t *testing.T) {
var searchCalls, commentCalls int32
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&searchCalls, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&commentCalls, 1)
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">缓存测试</d></i>`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
seedDanmakuMedia(t, svc, "cache-media", "测试动画", "", 1)
first, err := svc.Fetch(ctx, "cache-media", "", "")
require.NoError(t, err)
require.NotEmpty(t, first.Raw)
second, err := svc.Fetch(ctx, "cache-media", "", "")
require.NoError(t, err)
require.Equal(t, first.Raw, second.Raw)
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
require.EqualValues(t, 1, atomic.LoadInt32(&commentCalls))
}
// 并发的同一集请求应被 singleflight 合并,上游只被访问一次。
func TestDanmakuFetchCoalescesConcurrentRequests(t *testing.T) {
var searchCalls int32
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&searchCalls, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">并发测试</d></i>`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
svc := newDanmakuTestService(t)
ctx := context.Background()
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
seedDanmakuMedia(t, svc, "concurrent-media", "测试动画", "", 1)
const workers = 8
start := make(chan struct{})
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, err := svc.Fetch(ctx, "concurrent-media", "", "")
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
require.NoError(t, err)
}
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
}
+86 -8
View File
@@ -32,17 +32,14 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
}
return backdrop
}
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
if strings.HasPrefix(id, embyVirtualSeasonPrefix) || strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
return raw, nil
}
return "", nil
}
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
return raw, nil
}
return "", nil
// Latest JSON can outlive (or be served after) the in-memory artwork
// map. Rebuild from the library instead of handing the client a 1x1
// placeholder that it then caches as a successful image.
return e.resolveVirtualArtwork(ctx, id, imageType, pick)
}
m, err := e.repo.Media.FindByID(ctx, id)
if err == nil && m != nil {
@@ -71,6 +68,87 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
return "", nil
}
func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType string, pick func(primary, backdrop string) string) (string, error) {
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
season, ok, err := e.findSeasonGroup(ctx, id, "")
if err != nil || !ok {
return "", err
}
return pick(season.Series.PosterURL, season.Series.BackdropURL), nil
}
series, ok, err := e.findSeriesGroup(ctx, id, "")
if err != nil || !ok {
return "", err
}
return pick(series.PosterURL, series.BackdropURL), nil
}
// PersonImageURL resolves a person avatar from either a disguised remote ID
// or a person display name captured from a remote item's People field.
func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType string) (string, error) {
idOrName = strings.TrimSpace(idOrName)
if idOrName == "" || e == nil {
return "", nil
}
if IsEmbyRemoteID(idOrName) {
return e.ImageURL(ctx, idOrName, imageType)
}
if e.remote != nil {
if raw, ok := e.remote.ResolveRemotePersonImageURL(ctx, idOrName, imageType); ok {
return raw, nil
}
}
return e.ImageURL(ctx, idOrName, imageType)
}
// 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) == "" {
+24 -2
View File
@@ -61,6 +61,9 @@ type EmbyService struct {
libraryCoverMu sync.Mutex
libraryCoverCache map[string]embyArtworkCacheEntry
peopleMu sync.RWMutex
peopleCache map[string]embyPeopleCacheEntry
}
// NewEmbyService is the constructor.
@@ -117,11 +120,23 @@ const (
embyVirtualCacheTTL = 10 * time.Minute
embyVisibilityCacheTTL = 30 * time.Second
embySeriesGroupingLimit = maxMediaSearchLimit
// Virtual artwork used to be wiped entirely once the in-memory map crossed
// a few thousand entries. A homepage refresh asks Latest for every library
// at once, so that wipe dropped the series the client was about to paint.
// Caps are sized for that fan-out; overflow evicts the oldest entries only.
embyVirtualSeriesCap = 8000
embyVirtualSeasonCap = 16000
embyVirtualArtworkCap = 24000
// Clients that already cached the 1x1 placeholder treat a stable tag as
// immutable. Virtual ids are the ones that served that placeholder, so
// only those tags get a suffix that forces a refetch.
embyVirtualPrimaryTagSuffix = "-p2"
embyVirtualBackdropTagSuffix = "-bd2"
)
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}.*$`)
)
@@ -131,6 +146,13 @@ type embyVisibilityCacheEntry struct {
expiresAt time.Time
}
// embyPeopleCacheEntry avoids re-statting/decoding the same NFO for every
// list refresh. TV clients commonly request the same posters/items repeatedly.
type embyPeopleCacheEntry struct {
people []map[string]any
expiresAt time.Time
}
// Items paginates media in Emby's hierarchy. Episodic libraries are exposed as
// Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every
// episode as a separate movie card. 带 embyremote~ 前缀的 ParentID / 搜索自动
+20
View File
@@ -3,12 +3,24 @@ package service
import (
"context"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
"gorm.io/gorm"
)
func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string]any, error) {
cacheKey := e.embyItemsCacheKey("counts-v1", ItemsParams{UserID: userID})
var cached embyCountsCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
return map[string]any{
"MovieCount": cached.MovieCount,
"SeriesCount": int(cached.SeriesCount),
"EpisodeCount": cached.EpisodeCount,
"ItemCount": cached.ItemCount,
}, nil
}
base := func() *gorm.DB {
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
return e.applyUserMediaVisibility(ctx, q, userID)
@@ -34,6 +46,14 @@ func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string
return nil, err
}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyCountsCacheValue{
MovieCount: movieCount,
SeriesCount: int64(seriesCount),
EpisodeCount: episodeCount,
ItemCount: itemCount,
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
}
return map[string]any{
"MovieCount": movieCount,
"SeriesCount": seriesCount,
+29 -5
View File
@@ -9,13 +9,22 @@ import (
)
type embyItemsCacheValue struct {
Items []map[string]any `json:"items"`
TotalRecordCount int64 `json:"total_record_count"`
StartIndex int `json:"start_index"`
Items []map[string]any `json:"items"`
TotalRecordCount int64 `json:"total_record_count"`
StartIndex int `json:"start_index"`
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
}
type embyLatestCacheValue struct {
Items []map[string]any `json:"items"`
Items []map[string]any `json:"items"`
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
}
type embyCountsCacheValue struct {
MovieCount int64 `json:"movie_count"`
SeriesCount int64 `json:"series_count"`
EpisodeCount int64 `json:"episode_count"`
ItemCount int64 `json:"item_count"`
}
func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
@@ -43,10 +52,25 @@ func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
}
func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) string {
sum := sha256.Sum256([]byte(strings.Join([]string{"latest", userID, parentID, strconv.Itoa(limit)}, "|")))
// v2: payload tags for virtual artwork changed so clients drop cached placeholders.
sum := sha256.Sum256([]byte(strings.Join([]string{"latest-v2", userID, parentID, strconv.Itoa(limit)}, "|")))
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
+57 -9
View File
@@ -132,15 +132,16 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
var cached embyLatestCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
e.rememberArtworkRefs(cached.Artwork)
return cached.Items, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
q = e.applyUserMediaVisibility(ctx, q, userID)
if parentID != "" {
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
out, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
out, artwork, 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, Artwork: artwork}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
}
return out, err
}
@@ -166,12 +167,12 @@ 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
}
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, error) {
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
@@ -180,7 +181,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
q = e.applyUserMediaVisibility(ctx, q, userID)
var rows []model.Media
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return nil, err
return nil, nil, err
}
groups := e.seriesGroupsFromMedia(ctx, rows)
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
@@ -191,7 +192,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
for _, group := range groups {
items = append(items, e.seriesPayload(group))
}
return items, nil
return items, e.artworkRefsForSeriesGroups(groups), nil
}
// ResumeItems 列出有未完成播放进度的媒体。
@@ -519,11 +520,11 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
if seriesID != "" {
if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok {
if sEntry.posterURL != "" {
item["SeriesPrimaryImageTag"] = seriesID
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(seriesID, embyVirtualPrimaryTagSuffix)
}
if len(backdropTags) == 0 && sEntry.backdropURL != "" {
if len(backdropTags) == 0 && (sEntry.backdropURL != "" || sEntry.posterURL != "") {
item["ParentBackdropItemId"] = seriesID
item["ParentBackdropImageTags"] = []string{seriesID + "-bd"}
item["ParentBackdropImageTags"] = []string{embyVirtualImageTag(seriesID, embyVirtualBackdropTagSuffix)}
}
}
}
@@ -537,6 +538,14 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
if m == nil || strings.TrimSpace(m.Path) == "" {
return []map[string]any{}
}
cacheKey := strings.TrimSpace(m.ID)
if cacheKey == "" {
cacheKey = strings.ToLower(filepath.Clean(m.Path))
}
if people, ok := e.cachedMediaPeople(cacheKey); ok {
return people
}
dir := filepath.Dir(m.Path)
candidates := make([]string, 0, 6)
seenPath := map[string]struct{}{}
@@ -609,9 +618,48 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
}
}
}
e.rememberMediaPeople(cacheKey, people)
return people
}
func (e *EmbyService) cachedMediaPeople(key string) ([]map[string]any, bool) {
if e == nil || strings.TrimSpace(key) == "" {
return nil, false
}
now := time.Now()
e.peopleMu.RLock()
entry, ok := e.peopleCache[key]
e.peopleMu.RUnlock()
if !ok || now.After(entry.expiresAt) {
if ok {
e.peopleMu.Lock()
delete(e.peopleCache, key)
e.peopleMu.Unlock()
}
return nil, false
}
out := make([]map[string]any, len(entry.people))
copy(out, entry.people)
return out, true
}
func (e *EmbyService) rememberMediaPeople(key string, people []map[string]any) {
if e == nil || strings.TrimSpace(key) == "" {
return
}
e.peopleMu.Lock()
defer e.peopleMu.Unlock()
if e.peopleCache == nil || len(e.peopleCache) > 8000 {
e.peopleCache = make(map[string]embyPeopleCacheEntry, 128)
}
stored := make([]map[string]any, len(people))
copy(stored, people)
e.peopleCache[key] = embyPeopleCacheEntry{
people: stored,
expiresAt: time.Now().Add(embyVirtualCacheTTL),
}
}
func embyPersonID(name, roleType string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
return "person-" + hex.EncodeToString(sum[:8])
+27 -6
View File
@@ -75,9 +75,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
orderIncludesDirection = false
case "premieredate", "productionyear":
order = mediaReleaseOrderSQL(desc)
case "datecreated", "datelastmediaadded", "datelastcontentadded":
order = "media.created_at"
orderIncludesDirection = false
case "datecreated", "datelastmediaadded", "datelastcontentadded":
order = "media.created_at"
orderIncludesDirection = false
case "dateplayed":
order = "resume.watched_at"
orderIncludesDirection = false
@@ -225,6 +225,17 @@ func (e *EmbyService) collapseMediaVersionRows(ctx context.Context, rows []model
}
func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID string, p ItemsParams) (map[string]any, error) {
cacheKey := e.embyItemsCacheKey("series-items-v1", p)
var cached embyItemsCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
e.rememberArtworkRefs(cached.Artwork)
return map[string]any{
"Items": cached.Items,
"TotalRecordCount": int(cached.TotalRecordCount),
"StartIndex": cached.StartIndex,
}, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
if libraryID != "" {
@@ -246,9 +257,19 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
groups := e.seriesGroupsFromMedia(ctx, rows)
sortSeriesGroups(groups, p)
total := len(groups)
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
for _, group := range pageSlice(groups, p.StartIndex, p.Limit) {
pageGroups := pageSlice(groups, p.StartIndex, p.Limit)
items := make([]map[string]any, 0, len(pageGroups))
for _, group := range pageGroups {
items = append(items, e.seriesPayload(group))
}
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
Items: items,
TotalRecordCount: int64(total),
StartIndex: p.StartIndex,
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
}, time.Duration(e.mediaCacheTTLSeconds())*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 {
+66 -16
View File
@@ -35,6 +35,17 @@ func (e *EmbyService) movieLibraryHasEpisodicContent(ctx context.Context, librar
// 与 mediaItems 的区别: 后者会把剧集结构行当散装 Episode 漏出;这里改为聚合成
// Series,从根本上消除「电影库里整部剧被拆成单集」的现象。
func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
cacheKey := e.embyItemsCacheKey("movie-library-items-v1", p)
var cached embyItemsCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
e.rememberArtworkRefs(cached.Artwork)
return map[string]any{
"Items": cached.Items,
"TotalRecordCount": int(cached.TotalRecordCount),
"StartIndex": cached.StartIndex,
}, nil
}
libIDs := e.mergedLibraryIDs(ctx, p.ParentID)
apply := func(q *gorm.DB) *gorm.DB {
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
@@ -78,33 +89,72 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
if err := movieQ.Find(&movieRows).Error; err != nil {
return nil, err
}
movieItems, err := e.payloadsForMedia(ctx, movieRows, p.UserID)
if err != nil {
return nil, err
}
// 先按版本去重,再参与排序。这里不立即构建 payload:大电影库可能有
// 数万行,而客户端一页通常只要几十条,提前构建会触发大量 NFO / 数据
// 查询并把响应时间浪费在用户根本看不到的条目上。
movieRows = e.collapseMediaVersionRows(ctx, movieRows)
// 合并: Series 卡片 + Movie 项, 统一按首播/上映日期倒序。
type entry struct {
sortAt time.Time
payload map[string]any
sortAt time.Time
media *model.Media
group *embySeriesGroup
}
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
for _, g := range seriesGroups {
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(g), payload: e.seriesPayload(g)})
entries := make([]entry, 0, len(seriesGroups)+len(movieRows))
for i := range seriesGroups {
group := &seriesGroups[i]
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(*group), group: group})
}
for _, item := range movieItems {
entries = append(entries, entry{sortAt: embyPayloadReleaseSortTime(item), payload: item})
for i := range movieRows {
media := &movieRows[i]
entries = append(entries, entry{sortAt: embyMediaReleaseSortTime(*media), media: media})
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].sortAt.After(entries[j].sortAt)
})
total := len(entries)
paged := pageSlice(entries, p.StartIndex, p.Limit)
items := make([]map[string]any, 0, len(paged))
pageMovies := make([]model.Media, 0, len(paged))
for _, en := range paged {
items = append(items, en.payload)
if en.media != nil {
pageMovies = append(pageMovies, *en.media)
}
}
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
moviePayloads, err := e.payloadsForMedia(ctx, pageMovies, p.UserID)
if err != nil {
return nil, err
}
payloadByID := make(map[string]map[string]any, len(moviePayloads))
for _, item := range moviePayloads {
if id, ok := item["Id"].(string); ok {
payloadByID[id] = item
}
}
items := make([]map[string]any, 0, len(paged))
pageGroups := make([]embySeriesGroup, 0, len(paged))
for _, en := range paged {
switch {
case en.group != nil:
pageGroups = append(pageGroups, *en.group)
items = append(items, e.seriesPayload(*en.group))
case en.media != nil:
if item := payloadByID[en.media.ID]; item != nil {
items = append(items, item)
}
}
}
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
Items: items,
TotalRecordCount: int64(total),
StartIndex: p.StartIndex,
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
}
return out, nil
}
// embyPayloadCreatedAt 从 item payload 里取 DateCreated(time.Time),用于合并排序。
@@ -220,7 +270,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 +293,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/",
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
} {
+83 -1
View File
@@ -76,6 +76,14 @@ type EmbyRemoteService struct {
http *http.Client
stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout
cache *RuntimeCacheService
personMu sync.RWMutex
personImages map[string]embyRemotePersonImageRef
}
type embyRemotePersonImageRef struct {
accountID string
remoteID string
}
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
@@ -802,10 +810,12 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
return nil, err
}
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
q := url.Values{"Fields": {"Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating"}}
var out map[string]any
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
return nil, err
}
r.rememberRemotePeople(mount, out)
RewriteEmbyRemoteIDs(out, mount.ID)
return out, nil
}
@@ -959,6 +969,78 @@ func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.Strm
"?api_key=" + url.QueryEscape(cfg.Token), nil
}
// rememberRemotePeople 记录远程人物名称到远程人物 ID 的映射,供旧式
// /Persons/{Name}/Images/{Type} 图片请求回源。客户端详情页通常先取条目详情,
// 此时 People 中的名称和 ID 已同时拿到,因此无需额外搜索远程人物。
func (r *EmbyRemoteService) rememberRemotePeople(mount *model.EmbyMount, payload map[string]any) {
if r == nil || mount == nil || strings.TrimSpace(mount.AccountID) == "" || payload == nil {
return
}
people := remotePeopleMaps(payload["People"])
if len(people) == 0 {
return
}
r.personMu.Lock()
defer r.personMu.Unlock()
if r.personImages == nil || len(r.personImages) > 20000 {
r.personImages = make(map[string]embyRemotePersonImageRef, 256)
}
for _, person := range people {
name := strings.TrimSpace(remoteItemString(person, "Name"))
remoteID := strings.TrimSpace(remoteItemString(person, "Id"))
if name == "" || remoteID == "" || IsEmbyRemoteID(remoteID) {
continue
}
r.personImages[strings.ToLower(name)] = embyRemotePersonImageRef{
accountID: mount.AccountID,
remoteID: remoteID,
}
}
}
// ResolveRemotePersonImageURL 按人物名称解析其远程头像地址。
func (r *EmbyRemoteService) ResolveRemotePersonImageURL(ctx context.Context, name, imageType string) (string, bool) {
if r == nil {
return "", false
}
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
return "", false
}
r.personMu.RLock()
ref, ok := r.personImages[key]
r.personMu.RUnlock()
if !ok {
return "", false
}
acct := r.AccountByID(ctx, ref.accountID)
if acct == nil {
return "", false
}
raw, err := r.RemoteImageURL(ctx, acct, ref.remoteID, imageType)
if err != nil || strings.TrimSpace(raw) == "" {
return "", false
}
return raw, true
}
func remotePeopleMaps(value any) []map[string]any {
switch typed := value.(type) {
case []map[string]any:
return typed
case []any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if person, ok := item.(map[string]any); ok {
out = append(out, person)
}
}
return out
default:
return nil
}
}
// ─── 播放代理 ─────────────────────────────────────────────────────────────────
// ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。
+5
View File
@@ -106,4 +106,9 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
if items, ok := m["Items"]; ok {
RewriteEmbyRemoteIDs(items, accountID)
}
// 人物条目同样以 Id 回指 /Items/{Id}/Images/...。若不递归重写,客户端会
// 把远程演员 ID 当成本地 ID,头像最终只能命中占位图。
if people, ok := m["People"]; ok {
RewriteEmbyRemoteIDs(people, accountID)
}
}
+15
View File
@@ -55,6 +55,14 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
"Items": []any{
map[string]any{"Id": "item-2", "ParentId": "folder-2"},
},
"People": []any{
map[string]any{
"Id": "person-1",
"Name": "演员甲",
"Type": "Actor",
"PrimaryImageTag": "person-tag-1",
},
},
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
"MediaSources": []any{
map[string]any{
@@ -90,6 +98,13 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
if nested["Id"] != "embyremote~acct-1~item-2" {
t.Fatalf("nested Id = %v", nested["Id"])
}
person := payload["People"].([]any)[0].(map[string]any)
if person["Id"] != "embyremote~acct-1~person-1" {
t.Fatalf("person Id = %v", person["Id"])
}
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
t.Fatalf("person display fields changed: %#v", person)
}
// MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。
ms := payload["MediaSources"].([]any)[0].(map[string]any)
@@ -0,0 +1,82 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestRemoteItemRequestsPeopleAndRewritesPersonIDs(t *testing.T) {
var requestedFields string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/Users/user-1/Items/item-1") {
http.NotFound(w, r)
return
}
requestedFields = r.URL.Query().Get("Fields")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "item-1",
"Name": "测试电影",
"People": []map[string]any{
{
"Id": "person-1",
"Name": "演员甲",
"Type": "Actor",
"PrimaryImageTag": "person-tag-1",
},
},
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
"remote_user_id": "user-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-people"},
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatal(err)
}
mount := &model.EmbyMount{Base: model.Base{ID: "mount-people"}, AccountID: acct.ID}
out, err := svc.RemoteItem(t.Context(), mount, acct, "item-1")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(requestedFields, "People") {
t.Fatalf("RemoteItem Fields = %q, want People", requestedFields)
}
people, ok := out["People"].([]any)
if !ok || len(people) != 1 {
t.Fatalf("People = %#v, want one person", out["People"])
}
person := people[0].(map[string]any)
if got := person["Id"]; got != EncodeEmbyRemoteID("mount-people", "person-1") {
t.Fatalf("person Id = %v, want encoded remote id", got)
}
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
t.Fatalf("person display fields changed: %#v", person)
}
raw, ok := svc.ResolveRemotePersonImageURL(t.Context(), "演员甲", "Primary")
if !ok || !strings.Contains(raw, "/Items/person-1/Images/primary") {
t.Fatalf("resolved person image URL = %q, ok=%v", raw, ok)
}
}
+123 -5
View File
@@ -1,6 +1,7 @@
package service
import (
"sort"
"strings"
"time"
)
@@ -37,11 +38,7 @@ func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
if e.virtualArtwork == nil {
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
}
if len(e.virtualSeries) > 2000 || len(e.virtualSeasons) > 5000 || len(e.virtualArtwork) > 7000 {
e.virtualSeries = make(map[string]embySeriesCacheEntry)
e.virtualSeasons = make(map[string]embySeasonCacheEntry)
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
}
e.trimVirtualCachesLocked(time.Now())
e.virtualSeries[group.ID] = embySeriesCacheEntry{group: group, expiresAt: expiresAt}
e.virtualArtwork[group.ID] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
e.virtualArtwork[group.ID+"-bd"] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
@@ -65,6 +62,7 @@ func (e *EmbyService) rememberSeasonGroup(season embySeasonGroup) {
if e.virtualArtwork == nil {
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
}
e.trimVirtualCachesLocked(time.Now())
e.virtualSeasons[season.ID] = embySeasonCacheEntry{season: season, expiresAt: expiresAt}
e.virtualArtwork[season.ID] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
e.virtualArtwork[season.ID+"-bd"] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
@@ -135,3 +133,123 @@ func (e *EmbyService) cachedArtworkURL(id, imageType string) (string, bool) {
}
return entry.backdrop, entry.backdrop != ""
}
// embyArtworkRef is the poster/backdrop pair stored next to a Latest payload
// so a JSON-cache hit can refill the in-memory artwork map before the client
// asks for the image.
type embyArtworkRef struct {
Primary string `json:"primary,omitempty"`
Backdrop string `json:"backdrop,omitempty"`
}
func (e *EmbyService) artworkRefsForSeriesGroups(groups []embySeriesGroup) map[string]embyArtworkRef {
if e == nil || len(groups) == 0 {
return nil
}
refs := make(map[string]embyArtworkRef, len(groups)*2)
for _, group := range groups {
if group.PosterURL == "" && group.BackdropURL == "" {
continue
}
ref := embyArtworkRef{Primary: group.PosterURL, Backdrop: group.BackdropURL}
refs[group.ID] = ref
for _, season := range e.seasonsForSeries(group) {
refs[season.ID] = ref
}
}
if len(refs) == 0 {
return nil
}
return refs
}
func (e *EmbyService) rememberArtworkRefs(refs map[string]embyArtworkRef) {
if e == nil || len(refs) == 0 {
return
}
expiresAt := time.Now().Add(embyVirtualCacheTTL)
e.virtualMu.Lock()
defer e.virtualMu.Unlock()
if e.virtualArtwork == nil {
e.virtualArtwork = make(map[string]embyArtworkCacheEntry, len(refs))
}
e.trimVirtualArtworkLocked(time.Now())
for id, ref := range refs {
if strings.TrimSpace(id) == "" || (ref.Primary == "" && ref.Backdrop == "") {
continue
}
e.virtualArtwork[id] = embyArtworkCacheEntry{primary: ref.Primary, backdrop: ref.Backdrop, expiresAt: expiresAt}
}
}
func (e *EmbyService) trimVirtualCachesLocked(now time.Time) {
e.trimVirtualSeriesLocked(now)
e.trimVirtualSeasonsLocked(now)
e.trimVirtualArtworkLocked(now)
}
func (e *EmbyService) trimVirtualSeriesLocked(now time.Time) {
for id, entry := range e.virtualSeries {
if now.After(entry.expiresAt) {
delete(e.virtualSeries, id)
}
}
evictOldest(e.virtualSeries, embyVirtualSeriesCap, func(entry embySeriesCacheEntry) time.Time {
return entry.expiresAt
})
}
func (e *EmbyService) trimVirtualSeasonsLocked(now time.Time) {
for id, entry := range e.virtualSeasons {
if now.After(entry.expiresAt) {
delete(e.virtualSeasons, id)
}
}
evictOldest(e.virtualSeasons, embyVirtualSeasonCap, func(entry embySeasonCacheEntry) time.Time {
return entry.expiresAt
})
}
func (e *EmbyService) trimVirtualArtworkLocked(now time.Time) {
for id, entry := range e.virtualArtwork {
if now.After(entry.expiresAt) {
delete(e.virtualArtwork, id)
}
}
evictOldest(e.virtualArtwork, embyVirtualArtworkCap, func(entry embyArtworkCacheEntry) time.Time {
return entry.expiresAt
})
}
// evictOldest drops the soonest-expiring entries until the map is under cap.
// It must not replace the map: a homepage refresh remembers many series at
// once, and wiping the whole cache made the just-advertised backdrops 404
// into a 1x1 placeholder.
func evictOldest[T any](items map[string]T, cap int, expiresAt func(T) time.Time) {
excess := len(items) - cap
if cap <= 0 || excess <= 0 {
return
}
type pair struct {
id string
at time.Time
}
ordered := make([]pair, 0, len(items))
for id, entry := range items {
ordered = append(ordered, pair{id: id, at: expiresAt(entry)})
}
sort.Slice(ordered, func(i, j int) bool { return ordered[i].at.Before(ordered[j].at) })
if excess > len(ordered) {
excess = len(ordered)
}
for i := 0; i < excess; i++ {
delete(items, ordered[i].id)
}
}
func embyVirtualImageTag(id, suffix string) string {
if strings.HasPrefix(id, embyVirtualSeriesPrefix) || strings.HasPrefix(id, embyVirtualSeasonPrefix) {
return id + suffix
}
return id
}
+113 -6
View File
@@ -296,6 +296,71 @@ func TestEmbyVirtualSeriesArtworkUsesListCache(t *testing.T) {
}
}
func TestEmbyVirtualSeriesArtworkRebuildsAfterMemoryDrop(t *testing.T) {
svc := newTestEmbyService(t)
svc.cache = NewRuntimeCacheService(nil, nil)
lib := model.Library{Name: "番剧", Path: `/media/anime`, Type: "anime", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
media := model.Media{
Base: model.Base{ID: "ep-hero"},
LibraryID: lib.ID,
Title: "树海之魔",
Path: `/media/anime/树海之魔/Season 01/树海之魔 - S01E01.mkv`,
PosterURL: `/poster.jpg`,
BackdropURL: `/backdrop.jpg`,
SeasonNum: 1,
EpisodeNum: 1,
}
if err := svc.repo.DB.Create(&media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
items, err := svc.LatestItems(t.Context(), "", lib.ID, 5)
if err != nil {
t.Fatalf("latest items: %v", err)
}
if len(items) != 1 {
t.Fatalf("latest len = %d, want 1", len(items))
}
seriesID, _ := items[0]["Id"].(string)
tags, _ := items[0]["BackdropImageTags"].([]string)
if seriesID == "" || len(tags) != 1 || tags[0] != seriesID+embyVirtualBackdropTagSuffix {
t.Fatalf("hero item should advertise a cache-busted backdrop tag, got id=%q tags=%#v", seriesID, items[0]["BackdropImageTags"])
}
svc.virtualMu.Lock()
svc.virtualArtwork = nil
svc.virtualSeries = nil
svc.virtualSeasons = nil
svc.virtualMu.Unlock()
backdrop, err := svc.ImageURL(t.Context(), seriesID, "Backdrop")
if err != nil {
t.Fatalf("backdrop after memory drop: %v", err)
}
if backdrop != "/backdrop.jpg" {
t.Fatalf("backdrop = %q, want rebuilt backdrop", backdrop)
}
svc.virtualMu.Lock()
svc.virtualArtwork = nil
svc.virtualMu.Unlock()
if _, err := svc.LatestItems(t.Context(), "", lib.ID, 5); err != nil {
t.Fatalf("cached latest: %v", err)
}
cancelled, cancel := context.WithCancel(t.Context())
cancel()
backdrop, err = svc.ImageURL(cancelled, seriesID, "Backdrop")
if err != nil {
t.Fatalf("backdrop from rewarmed cache: %v", err)
}
if backdrop != "/backdrop.jpg" {
t.Fatalf("rewarmed backdrop = %q, want cached backdrop", backdrop)
}
}
func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "OpenList · 国漫", Path: `cloud://openlist/国漫`, Type: "anime", Enabled: true}
@@ -429,13 +494,13 @@ func TestInferSeriesNameFromPath(t *testing.T) {
want: "间谍过家家",
},
}
for _, tc := range tests {
got := inferSeriesNameFromPath(tc.path)
if got != tc.want {
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
}
}
for _, tc := range tests {
got := inferSeriesNameFromPath(tc.path)
if got != tc.want {
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) {
svc := newTestEmbyService(t)
@@ -513,3 +578,45 @@ func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) {
t.Fatalf("DateLastMediaAdded = %v, want %v", items[0]["DateLastMediaAdded"], tNew)
}
}
func TestEmbySeriesLibraryListUsesRuntimeCache(t *testing.T) {
svc := newTestEmbyService(t)
svc.cache = NewRuntimeCacheService(nil, nil)
lib := model.Library{Name: "番剧", Path: `/media/anime`, Type: "anime", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 1; i <= 2; i++ {
media := model.Media{
Base: model.Base{ID: fmt.Sprintf("cache-ep-%d", i)},
LibraryID: lib.ID,
Title: "缓存测试番",
Path: fmt.Sprintf(`/media/anime/缓存测试番/Season 01/缓存测试番.S01E%02d.mkv`, i),
SeasonNum: 1,
EpisodeNum: i,
}
if err := svc.repo.DB.Create(&media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
first, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 20})
if err != nil {
t.Fatalf("first items call: %v", err)
}
if first["TotalRecordCount"] != 1 {
t.Fatalf("first series total = %#v, want 1", first["TotalRecordCount"])
}
if err := svc.repo.DB.Unscoped().Where("library_id = ?", lib.ID).Delete(&model.Media{}).Error; err != nil {
t.Fatalf("delete media: %v", err)
}
second, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 20})
if err != nil {
t.Fatalf("second items call: %v", err)
}
items, _ := second["Items"].([]map[string]any)
if second["TotalRecordCount"] != 1 || len(items) != 1 {
t.Fatalf("cached series list = %#v, want the first response", second)
}
}
+28 -28
View File
@@ -5,28 +5,28 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
imageTags := map[string]string{}
backdropTags := []string{}
if group.PosterURL != "" {
imageTags["Primary"] = group.ID
imageTags["Primary"] = embyVirtualImageTag(group.ID, embyVirtualPrimaryTagSuffix)
}
if group.BackdropURL != "" {
backdropTags = append(backdropTags, group.ID+"-bd")
if group.BackdropURL != "" || group.PosterURL != "" {
backdropTags = append(backdropTags, embyVirtualImageTag(group.ID, embyVirtualBackdropTagSuffix))
}
lastMediaAdded := group.DateLastMediaAdded
if lastMediaAdded.IsZero() {
lastMediaAdded = group.CreatedAt
}
item := map[string]any{
"Id": group.ID,
"Name": group.Name,
"ServerId": embyServerID,
"Type": "Series",
"MediaType": "Video",
"IsFolder": true,
"ParentId": group.LibraryID,
"ProductionYear": group.Year,
"Overview": group.Overview,
"CommunityRating": group.Rating,
"RecursiveItemCount": len(group.Episodes),
"ChildCount": len(e.seasonsForSeries(group)),
lastMediaAdded := group.DateLastMediaAdded
if lastMediaAdded.IsZero() {
lastMediaAdded = group.CreatedAt
}
item := map[string]any{
"Id": group.ID,
"Name": group.Name,
"ServerId": embyServerID,
"Type": "Series",
"MediaType": "Video",
"IsFolder": true,
"ParentId": group.LibraryID,
"ProductionYear": group.Year,
"Overview": group.Overview,
"CommunityRating": group.Rating,
"RecursiveItemCount": len(group.Episodes),
"ChildCount": len(e.seasonsForSeries(group)),
"DateCreated": group.CreatedAt,
"DateLastMediaAdded": lastMediaAdded,
"ImageTags": imageTags,
@@ -39,7 +39,7 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
"UserData": emptyUserData(),
}
if group.PosterURL != "" {
item["PrimaryImageTag"] = group.ID
item["PrimaryImageTag"] = embyVirtualImageTag(group.ID, embyVirtualPrimaryTagSuffix)
}
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
item["PremiereDate"] = premiered
@@ -52,10 +52,10 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
imageTags := map[string]string{}
backdropTags := []string{}
if season.Series.PosterURL != "" {
imageTags["Primary"] = season.ID
imageTags["Primary"] = embyVirtualImageTag(season.ID, embyVirtualPrimaryTagSuffix)
}
if season.Series.BackdropURL != "" {
backdropTags = append(backdropTags, season.ID+"-bd")
if season.Series.BackdropURL != "" || season.Series.PosterURL != "" {
backdropTags = append(backdropTags, embyVirtualImageTag(season.ID, embyVirtualBackdropTagSuffix))
}
item := map[string]any{
"Id": season.ID,
@@ -75,12 +75,12 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
"UserData": emptyUserData(),
}
if season.Series.PosterURL != "" {
item["PrimaryImageTag"] = season.ID
item["SeriesPrimaryImageTag"] = season.Series.ID
item["PrimaryImageTag"] = embyVirtualImageTag(season.ID, embyVirtualPrimaryTagSuffix)
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(season.Series.ID, embyVirtualPrimaryTagSuffix)
}
if season.Series.BackdropURL != "" {
if season.Series.BackdropURL != "" || season.Series.PosterURL != "" {
item["ParentBackdropItemId"] = season.Series.ID
item["ParentBackdropImageTags"] = []string{season.Series.ID + "-bd"}
item["ParentBackdropImageTags"] = []string{embyVirtualImageTag(season.Series.ID, embyVirtualBackdropTagSuffix)}
}
return item
}
+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)
}
}
+23
View File
@@ -17,6 +17,7 @@ import (
"net"
"net/http"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
@@ -35,6 +36,12 @@ type ImageProxy struct {
cacheDir string
mu sync.Mutex
// resizeSem bounds concurrent decode/resize jobs. Emby TV clients request
// poster grids in bursts; letting every request decode a source image at
// once causes CPU and memory spikes that make the whole UI feel sluggish.
resizeSemMu sync.Mutex
resizeSem chan struct{}
// libraryRootsFn returns the configured media library roots so that
// sidecar poster/artwork files stored alongside media (under arbitrary
// per-library paths) are allowed by isAllowedLocalPath. It is provided
@@ -55,6 +62,7 @@ type ImageProxy struct {
const (
imageBrowserCacheControl = "public, max-age=2592000, immutable"
imagePlaceholderCacheControl = "no-store"
imageMaxResizeConcurrency = 4
)
// NewImageProxy is the constructor.
@@ -64,6 +72,7 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
log: log,
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
}
proxy.resizeSem = make(chan struct{}, imageResizeConcurrency())
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
// from image.tmdb.org via their HTTP proxy without extra config. On
@@ -181,6 +190,20 @@ func (p *ImageProxy) isAllowedRemoteHost(host string) bool {
return p.allowedHostsCache[host]
}
// imageResizeConcurrency keeps decode/resize concurrency within the number
// of CPU threads the process is allowed to use, capped to avoid large
// temporary RGBA buffers on tiny hosts.
func imageResizeConcurrency() int {
n := runtime.GOMAXPROCS(0)
if n < 1 {
n = 1
}
if n > imageMaxResizeConcurrency {
n = imageMaxResizeConcurrency
}
return n
}
// Prune removes oldest cached images until disk usage is within the configured limit.
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
+55 -9
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
@@ -55,26 +56,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 +90,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 +107,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,13 +125,48 @@ 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.
// 只读取文件头判断缓存是否可用。旧实现每次命中远程图片缓存都会把整个
// 原图读进内存再丢弃,电视端批量加载海报时会产生大量无意义的磁盘 I/O。
file, err := os.Open(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
if err != nil {
return
}
ctype := detectContentType(data)
if len(data) > 0 && isImageContentType(ctype) && !isTransparentPlaceholderData(data) {
stat, err := file.Stat()
if err != nil || stat.IsDir() || stat.Size() <= 0 {
_ = file.Close()
_ = os.Remove(cachePath)
_ = os.Remove(failPath)
return
}
headerSize := 512
if stat.Size() < int64(headerSize) {
headerSize = int(stat.Size())
}
header := make([]byte, headerSize)
n, readErr := io.ReadFull(file, header)
_ = file.Close()
if readErr != nil && readErr != io.ErrUnexpectedEOF {
_ = os.Remove(cachePath)
_ = os.Remove(failPath)
return
}
header = header[:n]
// A transparent placeholder is exactly 67 bytes; checking the header alone
// is enough for the normal image cache entries (they are much larger but
// detectContentType only inspects the same leading 512 bytes anyway).
// Close the handle before deleting: Windows refuses to delete an open file.
if n > 0 && isImageContentType(detectContentType(header)) &&
!(n == len(transparent1x1PNG) && bytes.Equal(header, transparent1x1PNG)) {
return
}
_ = os.Remove(cachePath)
+306
View File
@@ -0,0 +1,306 @@
package service
import (
"bytes"
"context"
"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")
}
// acquireResizeSlot bounds CPU-heavy decode/resize work. Returning false means
// the caller should fall back to the original image instead of blocking after
// the request has already been canceled.
func (p *ImageProxy) acquireResizeSlot(ctx context.Context) (func(), bool) {
if p == nil {
return func() {}, true
}
p.resizeSemMu.Lock()
if p.resizeSem == nil {
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
}
sem := p.resizeSem
p.resizeSemMu.Unlock()
select {
case sem <- struct{}{}:
return func() { <-sem }, true
case <-ctx.Done():
return nil, false
}
}
// 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
}
// 缓存命中必须发生在读原图和解码之前。否则电视端每次刷新海报墙都会
// 把已经是缩略图缓存的原图重新解码、缩放一遍,造成明显的 CPU 抖动。
key := o.resizeCacheKey(srcPath, stat)
cachePath := p.resizeCachePath(key)
if serveCachedImageFile(w, r, key, cachePath) {
return true
}
release, ok := p.acquireResizeSlot(r.Context())
if !ok {
return false
}
defer release()
// 等待并发槽期间,别的请求可能已经生成了同一张缩略图。
if serveCachedImageFile(w, r, key, cachePath) {
return true
}
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)
}
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))
}
+276
View File
@@ -0,0 +1,276 @@
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")
}
}
func TestServeResizedFromFileUsesCacheBeforeDecodingSource(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"
original := encodeTestPNG(t, 529, 911, 255)
if err := os.WriteFile(src, original, 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 first call to be served")
}
stat, err := os.Stat(src)
if err != nil {
t.Fatalf("stat source: %v", err)
}
// Same size and mtime keep the resize cache key stable, but the source is
// now invalid image data. A correct implementation serves the cached
// thumbnail before reading/decoding the source again.
broken := bytes.Repeat([]byte{0}, len(original))
if err := os.WriteFile(src, broken, 0o644); err != nil {
t.Fatalf("overwrite source: %v", err)
}
if err := os.Chtimes(src, stat.ModTime(), stat.ModTime()); err != nil {
t.Fatalf("restore mtime: %v", err)
}
second := httptest.NewRecorder()
if !proxy.serveResizedFromFile(second, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
t.Fatal("expected second call to be served from resize cache")
}
if !bytes.Equal(first.Body.Bytes(), second.Body.Bytes()) {
t.Fatal("expected cached thumbnail to be reused without decoding the source")
}
}
+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) {
+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
}
+12
View File
@@ -32,6 +32,18 @@ func mediaReleaseOrderSQL(desc bool) string {
return fmt.Sprintf("media.release_date %s, media.year %s, media.created_at %s, media.id %s", dir, dir, dir, dir)
}
// embyMediaReleaseSortTime matches the original payload-based ordering used by
// the Emby movie-library merge: release date, then year, then created_at.
func embyMediaReleaseSortTime(media model.Media) time.Time {
if t, ok := embyPremiereDate(media.ReleaseDate); ok {
return t
}
if media.Year > 0 {
return time.Date(media.Year, time.December, 31, 0, 0, 0, 0, time.UTC)
}
return media.CreatedAt
}
func embyPremiereDate(value string) (time.Time, bool) {
value = normalizeReleaseDate(value)
if value == "" {
+38 -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,43 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
return cards
}
// GroupMediaSeriesItems folds episode-level rows into one representative media
// row per series. It is intended for search surfaces where applying a small
// limit before series grouping would otherwise return several episodes from
// the same show.
func GroupMediaSeriesItems(items []model.Media) []model.Media {
cards := groupMediaSeriesCards(items)
if len(cards) == 0 {
return []model.Media{}
}
out := make([]model.Media, 0, len(cards))
for _, card := range cards {
out = append(out, card.Rep)
}
return out
}
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
}
@@ -66,6 +66,36 @@ func TestMetadataMatchCompatibilityForTheatricalFeatures(t *testing.T) {
}
}
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`,
+28 -1
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
@@ -28,6 +33,21 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
} 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"
@@ -107,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)
}
}
+6 -5
View File
@@ -16,7 +16,7 @@ var (
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)[\\/](?:剧场版|劇場版|動畫電影|动画电影)[\\/]`)
patTheatricalFolder = regexp.MustCompile(`(?i)[\\/][^\\/]*(?:剧场版|劇場版|動畫電影|动画电影|电影版|電影版)[^\\/]*[\\/]`)
theatricalNoiseRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版)\s*(?:第?\s*\d{1,3}\s*[部篇]?)?|电影版|電影版|动画电影|動畫電影`)
)
@@ -40,14 +40,15 @@ func mediaLooksLikeTheatricalFeature(m *model.Media) bool {
if m == nil {
return false
}
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
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) || patTheatricalFolder.MatchString(m.Path)
return patTheatricalTitle.MatchString(text) || pathHasTheatricalFolder(m.Path)
}
func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
+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)
}
})
}
}
+27 -6
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,10 +18,10 @@ func mediaFolderTitle(mediaPath, libraryRoot string) string {
if base == "" || base == "." {
return ""
}
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
dir = parentSlashPath(dir)
continue
}
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
dir = parentSlashPath(dir)
continue
}
if isMediaCollectionFolder(base) {
dir = parentSlashPath(dir)
continue
@@ -123,14 +128,30 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string {
func isTheatricalFolder(name string) bool {
key := strings.ToLower(strings.TrimSpace(name))
key = strings.Trim(key, `\/`)
if namedTheatricalFolderRE.MatchString(key) {
return true
}
switch key {
case "剧场版", "劇場版", "动画电影", "動畫電影", "特别篇", "特別篇", "specials", "sp", "ova", "oad":
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")
}
}
+66 -2
View File
@@ -234,7 +234,7 @@ func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
Path: filepath.Join(c, e.Name()),
Codec: codec,
Source: "external",
Delivery: "webvtt",
Delivery: subtitleDeliveryForCodec(codec),
})
}
}
@@ -297,6 +297,18 @@ func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Med
return subtitleTracksFromProbe(probe), nil
}
// subtitleDeliveryForCodec selects the browser rendering path for a text
// subtitle codec. ASS/SSA keep their original bytes and are rendered by
// libass in the web player; other text formats are converted to WebVTT.
func subtitleDeliveryForCodec(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "ass", "ssa":
return "ass"
default:
return "webvtt"
}
}
func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
tracks := make([]SubtitleTrack, 0, len(probe.Streams))
for _, stream := range probe.Streams {
@@ -314,7 +326,7 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
} else if stream.Disposition.Default != 0 {
label += "(默认)"
}
delivery := "webvtt"
delivery := subtitleDeliveryForCodec(codec)
if imageSubtitleCodecs[codec] {
delivery = "burn"
}
@@ -464,6 +476,9 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
if err != nil || m == nil {
return errors.New("media not found")
}
if strings.HasPrefix(sub, "embedded:") {
return errors.New("embedded subtitle is not available in raw mode")
}
abs, err := filepath.Abs(sub)
if err != nil {
return err
@@ -472,6 +487,9 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
if !pathWithin(abs, mediaDir) {
return fmt.Errorf("path escape")
}
if _, ok := extToCodec[strings.ToLower(filepath.Ext(abs))]; !ok {
return errors.New("unsupported subtitle format")
}
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
if err != nil {
return err
@@ -480,3 +498,49 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
_, err = io.Copy(w, f)
return err
}
// ServeASS returns an ASS/SSA stream suitable for libass-wasm. External ASS
// files are sent unchanged; embedded ASS/SSA tracks are remuxed to ASS by
// ffmpeg. This keeps fonts, positioning and typesetting data available to the
// browser renderer instead of flattening the track through assToVTT first.
func (s *SubtitleService) ServeASS(ctx context.Context, mediaID, sub string, w io.Writer) error {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
}
if strings.HasPrefix(sub, "embedded:") {
index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:"))
if err != nil || index < 0 {
return errors.New("invalid embedded subtitle")
}
return s.serveEmbeddedASS(ctx, m, index, w)
}
switch strings.ToLower(filepath.Ext(sub)) {
case ".ass", ".ssa":
return s.ServeRaw(ctx, mediaID, sub, w)
default:
return errors.New("subtitle is not ASS/SSA")
}
}
func (s *SubtitleService) serveEmbeddedASS(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
input, err := s.resolveInput(ctx, media)
if err != nil {
return err
}
bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
if err != nil {
return err
}
args := []string{"-hide_banner", "-loglevel", "error"}
args = append(args, ffmpegHTTPInputArgs(input)...)
args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-c:s", "ass", "-f", "ass", "-")
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
cmd.Stdout = w
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("extract embedded ASS subtitle: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
+26 -1
View File
@@ -100,7 +100,7 @@ func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
}
if tracks[0].Delivery != "webvtt" || tracks[0].Path != "embedded:2" {
if tracks[0].Delivery != "ass" || tracks[0].Path != "embedded:2" {
t.Fatalf("text track = %#v", tracks[0])
}
if tracks[1].Delivery != "burn" || tracks[1].StreamIndex != 4 {
@@ -108,6 +108,18 @@ func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
}
}
func TestSubtitleDeliveryClassifiesASSForLibass(t *testing.T) {
if got := subtitleDeliveryForCodec("ass"); got != "ass" {
t.Fatalf("ass delivery = %q, want ass", got)
}
if got := subtitleDeliveryForCodec("ssa"); got != "ass" {
t.Fatalf("ssa delivery = %q, want ass", got)
}
if got := subtitleDeliveryForCodec("subrip"); got != "webvtt" {
t.Fatalf("subrip delivery = %q, want webvtt", got)
}
}
func TestNormaliseTimecode(t *testing.T) {
cases := map[string]string{
"0:00:01": "00:00:01",
@@ -176,4 +188,17 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
if got := buf.String(); got != raw {
t.Fatalf("ServeRaw returned %q, want raw %q", got, raw)
}
buf.Reset()
if err := svc.ServeASS(t.Context(), media.ID, subPath, &buf); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != raw {
t.Fatalf("ServeASS returned %q, want raw ASS %q", got, raw)
}
buf.Reset()
if err := svc.ServeRaw(t.Context(), media.ID, videoPath, &buf); err == nil {
t.Fatal("ServeRaw accepted a non-subtitle file")
}
}
+45
View File
@@ -13,7 +13,9 @@
"danmu": "^0.20.0",
"framer-motion": "^12.38.0",
"hls.js": "^1.5.11",
"jassub": "^2.5.16",
"lucide-react": "^0.379.0",
"opencc-js": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1752,6 +1754,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/abslink": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/abslink/-/abslink-1.2.2.tgz",
"integrity": "sha512-HCOItvzORPjMzddCmEpbH8+W1VM3yjH+CR7DLtxlxFKX/jz5twJbm7KLfblO7eKLnXINLah8Hk2ZAJqxXeSkOg==",
"license": "Apache-2.0"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3107,6 +3115,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/jassub": {
"version": "2.5.16",
"resolved": "https://registry.npmjs.org/jassub/-/jassub-2.5.16.tgz",
"integrity": "sha512-n4vjXfraf3o8k3/5TK6DhCYVcbQ/VqhVOgwqLd3l0xfMtsN4fpE/hPq2hPaGagBTOtJS9yHwRoRp3mmchQ9XVg==",
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0",
"dependencies": {
"abslink": "^1.2.2",
"lfa-ponyfill": "^1.1.0",
"rvfc-polyfill": "^1.0.8",
"throughput": "^1.0.2"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -3194,6 +3214,13 @@
"node": ">= 0.8.0"
}
},
"node_modules/lfa-ponyfill": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/lfa-ponyfill/-/lfa-ponyfill-1.1.1.tgz",
"integrity": "sha512-Had4tsGEHtIj58MRtf6SlubNDltxinxMSdxIn1GdwrWbye8TD+pZ+D6qKETnJ9+vQOfG9q/Ds4WUPskhyAMp5Q==",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -3427,6 +3454,12 @@
"node": ">= 6"
}
},
"node_modules/opencc-js": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz",
"integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==",
"license": "MIT AND Apache-2.0"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4025,6 +4058,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rvfc-polyfill": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/rvfc-polyfill/-/rvfc-polyfill-1.0.8.tgz",
"integrity": "sha512-uA+0wwTkZ4OT8v45pfDfH+7Yq8mY6MvNngiF5Sq6VBgjJsvsfgt7Q18cyZqZjfAhW9rhkgXPX0cW0R9uw7yElA==",
"license": "GPL-3.0"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -4212,6 +4251,12 @@
"node": ">=0.8"
}
},
"node_modules/throughput": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/throughput/-/throughput-1.0.2.tgz",
"integrity": "sha512-jvK1ZXuhsggjb3qYQjMiU/AVYYiTeqT5thWvYR2yuy2LGM84P5MSSyAinwHahGsdBYKR9m9HncVR/3f3nFKkxg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+2
View File
@@ -15,7 +15,9 @@
"danmu": "^0.20.0",
"framer-motion": "^12.38.0",
"hls.js": "^1.5.11",
"jassub": "^2.5.16",
"lucide-react": "^0.379.0",
"opencc-js": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+25 -3
View File
@@ -27,20 +27,32 @@ export interface DanmakuFetchResult {
font_size: string
area: string
raw?: string
/** Number of sources merged into `raw`; absent/0 means not merged. */
merged_sources?: number
candidates?: DanmakuAnime[]
/**
* Same episode, other sources. Unlike `candidates` (which means "pick one
* before anything loads"), `alternatives` arrives together with a loaded
* library: the backend already auto-picked one and offers the rest so the
* user can switch without re-searching. Aggregating sources such as LogVar
* return several libraries for the same episode.
*/
alternatives?: DanmakuAnime[]
anime_title?: string
episode_title?: string
episode_id?: number
match_mode?: 'hash' | 'filename' | 'search' | 'manual' | string
match_mode?: 'hash' | 'filename' | 'metadata' | 'search' | 'manual' | string
}
export interface DanmakuLoadedInfo {
animeTitle?: string
episodeTitle?: string
episodeId?: number | string
matchMode?: 'hash' | 'filename' | 'search' | 'manual' | string
matchMode?: 'hash' | 'filename' | 'metadata' | 'search' | 'manual' | string
totalCount: number
sourceType?: 'auto' | 'xml' | 'json'
/** Number of sources merged into the loaded comments (0 = not merged). */
mergedSources?: number
}
export type DanmakuFetchOptions = {
@@ -73,6 +85,16 @@ export const danmakuAPI = {
opacity: string
font_size: string
area: string
/** Per-user preference: merge the same episode's multiple sources. */
merge_sources: boolean
}>('/danmaku/config')
.then((r) => r.data),
}
// updateSettings persists per-user danmaku preferences (survives reload).
updateSettings: (settings: { mergeSources: boolean }) =>
api
.put<{ merge_sources: boolean }>('/danmaku/settings', {
merge_sources: settings.mergeSources,
})
.then((r) => r.data),
}
+12 -3
View File
@@ -193,10 +193,18 @@ export const mediaAPI = {
recent: (limit = 24) =>
api.get<SeriesCard[]>('/media/recent', { params: { limit } }).then((r) => r.data),
search: (q: string, limit = 50) =>
api.get<MediaSearchPage>('/media', { params: { q, limit } }).then((r) => r.data),
search: (q: string, limit = 50, options?: { groupSeries?: boolean }) =>
api
.get<MediaSearchPage>('/media', {
params: {
q,
limit,
...(options?.groupSeries ? { group_series: 1 } : {}),
},
})
.then((r) => r.data),
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean; groupSeries?: boolean }) =>
api
.get<MediaSearchPage>('/media', {
params: {
@@ -204,6 +212,7 @@ export const mediaAPI = {
page,
page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined,
...(options?.groupSeries ? { group_series: 1 } : {}),
},
timeout: LONG_REQUEST_TIMEOUT,
})
+3
View File
@@ -2,12 +2,15 @@ import { api } from './client'
import type { User } from '../types'
export const profileAPI = {
get: () => api.get<User>('/me').then((r) => r.data),
update: (patch: {
username?: string
nickname?: string
email?: string
avatar_url?: string
hide_adult?: boolean
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
password?: string
}) =>
api.patch<User>('/me', patch).then((r) => r.data),
+8 -1
View File
@@ -8,7 +8,7 @@ export interface SubtitleTrack {
url: string
codec: string
source: 'external' | 'embedded'
delivery: 'webvtt' | 'burn'
delivery: 'webvtt' | 'ass' | 'burn'
stream_index?: number
}
@@ -26,4 +26,11 @@ export const subtitlesAPI = {
path,
)}&token=${encodeURIComponent(token)}`
},
assUrl: (mediaId: string, path: string) => {
const token = useAuthStore.getState().token ?? ''
return `/api/subtitles/${encodeURIComponent(mediaId)}/ass?path=${encodeURIComponent(
path,
)}&token=${encodeURIComponent(token)}`
},
}
@@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useRef } from 'react'
import JASSUB from 'jassub'
import jassubWorkerUrl from 'jassub/dist/worker/worker.js?worker&url'
import jassubWasmUrl from 'jassub/dist/wasm/jassub-worker.wasm?url'
import jassubModernWasmUrl from 'jassub/dist/wasm/jassub-worker-modern.wasm?url'
import notoSansSCUrl from '../assets/fonts/noto-sans-sc-regular.woff2?url'
import {
loadSubtitleChineseConverter,
type SubtitleChineseMode,
} from '../utils/subtitleChinese'
import { convertASSContent } from '../utils/subtitleASS'
type AssSubtitleStageProps = {
videoRef: React.RefObject<HTMLVideoElement>
source: string
timeOffset?: number
chineseMode?: SubtitleChineseMode
onReady?: () => void
onError?: (error: unknown) => void
}
/**
* libass-wasm subtitle layer for external and embedded ASS/SSA tracks. JASSUB
* owns its canvas and attaches it next to the video, so this component itself
* renders no visible DOM nodes.
*/
export function AssSubtitleStage({
videoRef,
source,
timeOffset = 0,
chineseMode = 'original',
onReady,
onError,
}: AssSubtitleStageProps) {
const instanceRef = useRef<JASSUB | null>(null)
const timeOffsetRef = useRef(timeOffset)
const callbacksRef = useRef({ onReady, onError })
useEffect(() => {
callbacksRef.current = { onReady, onError }
}, [onReady, onError])
useEffect(() => {
const video = videoRef.current
if (!video || !source) return
let cancelled = false
let instance: JASSUB | null = null
const controller = new AbortController()
const load = async () => {
try {
const [response, converter] = await Promise.all([
fetch(source, { signal: controller.signal }),
loadSubtitleChineseConverter(chineseMode).catch(() => (text: string) => text),
])
if (!response.ok) throw new Error(`subtitle request failed: ${response.status}`)
const body = await response.text()
if (!body.trim()) throw new Error('empty subtitle payload')
if (cancelled) return
instance = new JASSUB({
video,
subContent: convertASSContent(body, converter),
timeOffset: timeOffsetRef.current,
workerUrl: jassubWorkerUrl,
wasmUrl: jassubWasmUrl,
modernWasmUrl: jassubModernWasmUrl,
fonts: [notoSansSCUrl],
defaultFont: 'Noto Sans SC',
queryFonts: 'local',
prescaleFactor: 1,
})
instanceRef.current = instance
await instance.ready
if (cancelled) {
void instance.destroy().catch(() => undefined)
return
}
callbacksRef.current.onReady?.()
} catch (error: unknown) {
if (cancelled) return
if (error instanceof DOMException && error.name === 'AbortError') return
callbacksRef.current.onError?.(error)
}
}
void load()
return () => {
cancelled = true
controller.abort()
instanceRef.current = null
if (instance) void instance.destroy().catch(() => undefined)
}
// timeOffset is applied live below. Recreating the full libass worker on a
// seek offset change would be unnecessarily expensive.
}, [source, chineseMode, videoRef])
useEffect(() => {
timeOffsetRef.current = timeOffset
if (instanceRef.current) instanceRef.current.timeOffset = timeOffset
}, [timeOffset])
return null
}

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