fix legacy library scan and empty pages

This commit is contained in:
ShukeBta
2026-07-01 22:51:33 +08:00
parent e18f73ed3c
commit 0493823b99
17 changed files with 536 additions and 33 deletions
+6
View File
@@ -142,6 +142,9 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
if items == nil {
items = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
@@ -155,6 +158,9 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
if items == nil {
items = []service.MediaItem{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
+55
View File
@@ -423,3 +423,58 @@ func requestLibrarySeriesEpisodes(t *testing.T, svc *service.Container, path, li
}
return payload
}
// 空库的 media / series 列表必须返回 "items":[](而不是 Go nil slice 序列化出的 null)。
// 前端 [].concat(null) 会得到 [null],随后在渲染期解引用 null 崩溃,导致「空库点进去
// 报错且无法返回」。这条测试钉死该 JSON 契约,防止再退化。
func TestEmptyLibraryListsReturnEmptyArraysNotNull(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.Library{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
movie := model.Library{Name: "空电影库", Path: "/media/empty-movie", Type: "movie", Enabled: true}
tv := model.Library{Name: "空剧集库", Path: "/media/empty-tv", Type: "tv", Enabled: true}
for _, lib := range []*model.Library{&movie, &tv} {
if err := repos.Library.Create(t.Context(), lib); err != nil {
t.Fatal(err)
}
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
}
cases := []struct {
name string
path string
lib string
handler gin.HandlerFunc
}{
{"media", "/api/libraries/" + movie.ID + "/media", movie.ID, listMediaHandler(svc)},
{"series", "/api/libraries/" + tv.ID + "/series", tv.ID, listLibrarySeriesHandler(svc)},
}
for _, tc := range cases {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "user")
c.Params = gin.Params{{Key: "id", Value: tc.lib}}
c.Request = httptest.NewRequest(http.MethodGet, tc.path, nil)
tc.handler(c)
if w.Code != http.StatusOK {
t.Fatalf("%s: status = %d body=%s", tc.name, w.Code, w.Body.String())
}
body := w.Body.String()
if strings.Contains(body, `"items":null`) {
t.Fatalf("%s: empty library returned items:null (crashes frontend): %s", tc.name, body)
}
if !strings.Contains(body, `"items":[]`) {
t.Fatalf("%s: expected items:[] for empty library, got %s", tc.name, body)
}
}
}
+6 -1
View File
@@ -92,8 +92,13 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
if end > len(items) {
end = len(items)
}
pageItems := items[start:end]
if pageItems == nil {
// 非 nil 空切片,避免空库返回 "items": null 触发前端崩溃。
pageItems = []service.SeriesCard{}
}
c.JSON(http.StatusOK, gin.H{
"items": items[start:end],
"items": pageItems,
"total": total,
"page": page,
"page_size": size,
+27 -7
View File
@@ -75,29 +75,49 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai
}
func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) (model.Library, bool) {
// Issue #61: an auto-category assignment is authoritative. The media was explicitly
// categorized into this library even though its physical (cloud) path may still live
// under the source scan directory (e.g. cloud://cloud115/云下载/...). Resolving by path
// here would wrongly redirect the media back to the source cloud library, so resolve it
// from the owning library instead.
if own, ok := r.byID[media.LibraryID]; ok && CloudLibraryAutoCategory(own) {
return r.autoCategoryDisplayLibrary(own), true
}
if lib, ok := r.bestPathDisplayLibrary(media); ok {
return lib, true
}
if lib, ok := r.displayByID[media.LibraryID]; ok {
return lib, true
}
own, hasOwn := r.byID[media.LibraryID]
if hasOwn {
if own, hasOwn := r.byID[media.LibraryID]; hasOwn {
if key, ok := CloudLibraryMergeKey(own); ok {
if lib, exists := r.displayByMergeKey[key]; exists {
return lib, true
}
}
if CloudLibraryAutoCategory(own) {
if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok {
return lib, true
}
}
return own, true
}
return model.Library{}, false
}
// autoCategoryDisplayLibrary resolves the visible library that should represent an
// auto-category library: the library itself when it is displayed standalone, otherwise
// the sibling it was merged into, otherwise the root cloud library it was split from.
func (r mediaDisplayLibraryResolver) autoCategoryDisplayLibrary(own model.Library) model.Library {
if lib, ok := r.displayByID[own.ID]; ok {
return lib
}
if key, ok := CloudLibraryMergeKey(own); ok {
if lib, exists := r.displayByMergeKey[key]; exists {
return lib
}
}
if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok {
return lib
}
return own
}
func (r mediaDisplayLibraryResolver) rootCloudDisplayLibraryForAutoCategory(auto model.Library) (model.Library, bool) {
info, ok := ParseCloudLibraryMount(auto.Path)
if !ok {
@@ -0,0 +1,64 @@
package service
import (
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// TestAttachLibraryMetadataAutoCategoryKeepsOwnDisplayLibrary reproduces issue #61.
//
// A movie scanned from a source cloud library ("115 云下载") is auto-categorized into a
// separate auto-category library ("成人"): its library_id points to the auto-category
// library, but its physical cloud path still lives under the source scan directory
// (cloud://cloud115/云下载/...). Display resolution must attribute the media to the
// library it is actually browsed under (the auto-category "成人" library), not path-match
// it back to the source cloud library — otherwise the detail page "返回媒体库" button
// jumps to the wrong library.
func TestAttachLibraryMetadataAutoCategoryKeepsOwnDisplayLibrary(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{})
repos := repository.New(db)
source := model.Library{Name: "115 云下载", Path: "cloud://cloud115/云下载", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &source); err != nil {
t.Fatal(err)
}
autoPath := BuildCloudAutoCategoryLibraryPathWithScanDir("cloud115", "成人/成人", "成人")
adult := model.Library{Name: "成人", Path: autoPath, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &adult); err != nil {
t.Fatal(err)
}
if !CloudLibraryAutoCategory(adult) {
t.Fatalf("adult library should be auto-category, got path %q", adult.Path)
}
mediaPath := "cloud://cloud115/云下载/Some.Movie.2024/Some.Movie.2024.mp4"
if err := repos.Media.Upsert(t.Context(), &model.Media{
LibraryID: adult.ID,
Title: "Some Movie",
Path: mediaPath,
}); err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
items := []model.Media{{LibraryID: adult.ID, Title: "Some Movie", Path: mediaPath}}
svc.attachLibraryMetadata(t.Context(), items)
got := items[0]
if got.DisplayLibraryID != adult.ID {
t.Fatalf("display_library_id = %s (%s), want auto-category library %s (成人)",
got.DisplayLibraryID, got.DisplayLibraryName, adult.ID)
}
if got.DisplayLibraryName != "成人" {
t.Fatalf("display_library_name = %q, want 成人", got.DisplayLibraryName)
}
if got.LibraryName != "成人" {
t.Fatalf("library_name = %q, want 成人 (not the source cloud library)", got.LibraryName)
}
}
+61
View File
@@ -39,6 +39,14 @@ func resolveAccessibleLibraryPath(path string) (string, error) {
return filepath.Clean(candidate), nil
}
}
// 兜底:旧库把宿主机绝对路径整段写进来时 /media 出现在路径中段(moviepilot 布局),
// 常规候选覆盖不到。只在这里按 isAccessibleDir 校验后采用,避免污染不校验存在性的
// 目的地解析(resolveMappedDestinationPath)。见 embeddedContainerMarkerCandidates。
for _, candidate := range embeddedContainerMarkerCandidates(input) {
if isAccessibleDir(candidate) {
return filepath.Clean(candidate), nil
}
}
abs, err := filepath.Abs(input)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
@@ -46,6 +54,52 @@ func resolveAccessibleLibraryPath(path string) (string, error) {
return "", fmt.Errorf("path is not an accessible directory: %s", abs)
}
// embeddedContainerMarkerCandidates 处理旧库把宿主机绝对路径整段写进来的情况:
// /media 或 /downloads 出现在路径中段(如 moviepilot 的 /vol1/.../media/电视剧/国产剧),
// 取最后一个该段之后的尾巴拼到容器目录(默认 /media、/downloads,可用 *_CONTAINER_DIR 覆盖)。
//
// 这个启发式偏激进,只能在**按存在性校验**的读/扫描解析里使用;绝不能并入
// mappedPathCandidates——resolveMappedDestinationPath 不校验存在性、会返回首个候选,
// 那样会把形如 <tmp>/media/... 的合法目的地错误重写到容器根,破坏整理/硬链接。
func embeddedContainerMarkerCandidates(input string) []string {
normalized := cleanPathForVolumeMapping(input)
markerPath := pathAfterWindowsDrivePrefix(normalized)
var candidates []string
for _, marker := range []struct {
part string
container string
}{
{part: "/media", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
{part: "/downloads", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
} {
part := strings.TrimRight(marker.part, "/")
container := strings.TrimRight(filepath.ToSlash(marker.container), "/")
if idx := strings.LastIndex(markerPath, part+"/"); idx > 0 {
candidates = append(candidates, filepath.Clean(filepath.FromSlash(container+markerPath[idx+len(part):])))
}
}
return candidates
}
// describeUnresolvedLibraryPath 把「路径解析失败」变成可操作的诊断信息:列出尝试过的
// 候选路径与当前的宿主机→容器映射状态。旧库存的是宿主机路径,在新版/容器内常因为缺少
// MEDIASTATION_MEDIA_DIR 映射而扫不出媒体——这条诊断帮助用户直接定位到底哪一步断了。
// 仅用于日志与扫描错误提示,不改变解析逻辑本身。
func describeUnresolvedLibraryPath(rawPath string) string {
rawPath = strings.TrimSpace(rawPath)
candidates := mappedPathCandidates(rawPath)
mediaHost := strings.TrimSpace(os.Getenv("MEDIASTATION_MEDIA_DIR"))
mediaContainer := envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")
var b strings.Builder
fmt.Fprintf(&b, "媒体库路径无法解析为可访问目录:%s(已尝试候选:%s)", rawPath, strings.Join(candidates, " | "))
if mediaHost == "" {
b.WriteString(";未配置 MEDIASTATION_MEDIA_DIR / MEDIASTATION_MEDIA_CONTAINER_DIR。若为 Docker 部署且此库为旧宿主机路径,请设置这两个变量把宿主机路径映射到容器内路径,并确认对应 volume 已挂载。")
} else {
fmt.Fprintf(&b, ";当前映射 %s → %s。请确认该库路径位于此宿主机目录下,或补充对应的 volume 与路径映射。", mediaHost, mediaContainer)
}
return b.String()
}
func resolveAccessibleMappedPath(path string) (string, os.FileInfo, error) {
input := strings.TrimSpace(path)
if input == "" {
@@ -57,6 +111,13 @@ func resolveAccessibleMappedPath(path string) (string, os.FileInfo, error) {
return filepath.Clean(candidate), info, nil
}
}
// 兜底同 resolveAccessibleLibraryPath:中段 /media|/downloads 的旧宿主机路径,
// 仅在 os.Stat 校验存在后采用。
for _, candidate := range embeddedContainerMarkerCandidates(input) {
if info, err := os.Stat(candidate); err == nil {
return filepath.Clean(candidate), info, nil
}
}
abs, err := filepath.Abs(input)
if err != nil {
return "", nil, fmt.Errorf("invalid path: %w", err)
+50
View File
@@ -106,6 +106,56 @@ func TestResolveAccessibleLibraryPathMapsRelativeDockerMediaMarker(t *testing.T)
}
}
// 旧库把宿主机绝对路径整段写进来时(moviepilot 布局:/vol1/.../media/电视剧/国产剧),
// /media 出现在路径中段而非开头;解析必须能把最后一个 /media 段之后的尾巴映射到容器媒体
// 目录,否则新版容器只挂了 /media 就永远扫不出这类旧库。钉死该行为,防止再退化。
func TestResolveAccessibleLibraryPathMapsEmbeddedHostMediaMarker(t *testing.T) {
root := t.TempDir()
containerRoot := filepath.Join(root, "container", "media")
containerLibrary := filepath.Join(containerRoot, "电视剧", "国产剧")
if err := os.MkdirAll(containerLibrary, 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerRoot)
got, err := resolveAccessibleLibraryPath("/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧")
if err != nil {
t.Fatalf("resolveAccessibleLibraryPath() error = %v", err)
}
if got != filepath.Clean(containerLibrary) {
t.Fatalf("resolveAccessibleLibraryPath() = %q, want %q", got, filepath.Clean(containerLibrary))
}
}
func TestResolveAccessibleMappedPathMapsEmbeddedHostDownloadMarker(t *testing.T) {
root := t.TempDir()
containerDownloads := filepath.Join(root, "container", "downloads")
containerItem := filepath.Join(containerDownloads, "国产剧")
if err := os.MkdirAll(containerItem, 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", containerDownloads)
got, _, err := resolveAccessibleMappedPath("/vol1/1000/Docker/qbittorrent/downloads/国产剧")
if err != nil {
t.Fatalf("resolveAccessibleMappedPath() error = %v", err)
}
if got != filepath.Clean(containerItem) {
t.Fatalf("resolveAccessibleMappedPath() = %q, want %q", got, filepath.Clean(containerItem))
}
}
// 中段 marker 启发式绝不能污染目的地解析:resolveMappedDestinationPath 不校验存在性、
// 会返回首个候选,一旦启发式并进来,就会把形如 <tmp>/media/... 的合法整理目的地错误
// 重写到容器根 /media(曾导致 organizer 跨盘 hardlink 失败)。钉死该边界。
func TestResolveMappedDestinationPathIgnoresEmbeddedMediaMarker(t *testing.T) {
root := t.TempDir()
dst := filepath.Join(root, "001", "media", "电视剧", "国产剧", "Some Show")
if got := resolveMappedDestinationPath(dst); got != filepath.Clean(dst) {
t.Fatalf("resolveMappedDestinationPath() = %q, want %q (embedded /media must not remap destinations)", got, filepath.Clean(dst))
}
}
func TestInferLibraryKindFromCategoryPathOverridesMovieDefault(t *testing.T) {
for _, tc := range []struct {
name string
+3 -1
View File
@@ -29,7 +29,9 @@ func normalizeGroupedMediaPage(page, pageSize int) (int, int) {
func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem {
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
if len(items) == 0 {
return nil
// 返回非 nil 空切片:nil 会被 JSON 序列化成 "items": null,
// 前端 concat(null) 会得到 [null] 并在渲染期崩溃(空库进入白屏)。
return []MediaItem{}
}
start := (page - 1) * pageSize
if start >= len(items) {
+9 -2
View File
@@ -94,11 +94,18 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
for i := range roots {
root := roots[i]
if err := s.resolveLocalLibraryRootPath(ctx, lib, &root); err != nil {
addScanError(res, root.Path, err)
s.log.Warn("library root scan skipped",
// 优雅降级:路径不可达时只跳过该 root,并保留该库已入库的旧媒体(下方 prune 不会执行),
// 同时输出可操作诊断(候选路径 + 宿主机/容器映射状态),方便定位旧库在容器内扫不出媒体的原因。
diag := describeUnresolvedLibraryPath(root.Path)
addScanError(res, "", errors.New(diag))
s.log.Warn("library root scan skipped; existing media preserved",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("path", root.Path),
zap.Strings("candidates", mappedPathCandidates(root.Path)),
zap.String("media_dir_env", os.Getenv("MEDIASTATION_MEDIA_DIR")),
zap.String("media_container_env", envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")),
zap.String("diagnostic", diag),
zap.Error(err))
if scanErr == nil {
scanErr = err