mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26:38 +08:00
fix legacy library scan and empty pages
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user