From 7ebeddfa388a53fb5d6889d8416e142801885bf9 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:24:24 +0800 Subject: [PATCH] fix docker media path normalization --- internal/service/cloud_mount_filter_test.go | 39 ++++++++++ internal/service/filemanager_roots.go | 4 +- internal/service/media_display_library.go | 26 ++++++- internal/service/media_paths.go | 41 +++++++++-- internal/service/media_test.go | 38 ++++++++++ internal/service/organizer.go | 2 +- .../service/organizer_directory_libraries.go | 7 +- internal/service/organizer_media.go | 2 +- internal/service/organizer_paths.go | 4 ++ .../service/organizer_reclassify_scanned.go | 2 +- internal/service/service.go | 9 ++- internal/service/service_builder.go | 4 +- internal/service/service_library_normalize.go | 72 +++++++++++++++++++ .../service/service_library_normalize_test.go | 54 ++++++++++++++ 14 files changed, 283 insertions(+), 21 deletions(-) create mode 100644 internal/service/service_library_normalize_test.go diff --git a/internal/service/cloud_mount_filter_test.go b/internal/service/cloud_mount_filter_test.go index 7aa270f..569ea3e 100644 --- a/internal/service/cloud_mount_filter_test.go +++ b/internal/service/cloud_mount_filter_test.go @@ -1,6 +1,7 @@ package service import ( + "path/filepath" "slices" "strings" "testing" @@ -352,6 +353,44 @@ func TestListMediaVisibleUsesSpecificCloudChildLibraryAsDisplayTarget(t *testing } } +func TestGetMediaUsesMappedLocalCategoryLibraryAsDisplayTarget(t *testing.T) { + containerRoot := filepath.Join(t.TempDir(), "media") + t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerRoot) + + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + parent := model.Library{Name: "电视剧", Path: containerRoot, Type: "tv", Enabled: true} + child := model.Library{Name: "国产剧", Path: filepath.Join("media", "电视剧", "国产剧"), Type: "tv", Enabled: true} + for _, lib := range []*model.Library{&parent, &child} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + mediaPath := filepath.Join(containerRoot, "电视剧", "国产剧", "剧集", "Season 01", "剧集 - S01E01.mkv") + if err := repos.DB.Create(&model.Media{ + Base: model.Base{ID: "media-1"}, + LibraryID: parent.ID, + Title: "剧集", + Path: mediaPath, + SeasonNum: 1, + EpisodeNum: 1, + }).Error; err != nil { + t.Fatal(err) + } + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + + got, err := svc.GetMedia(t.Context(), "media-1") + if err != nil { + t.Fatal(err) + } + if got.DisplayLibraryID != child.ID { + t.Fatalf("display library = %q, want mapped child library %q", got.DisplayLibraryID, child.ID) + } + if got.DisplayLibraryPath != filepath.Clean(filepath.Join(containerRoot, "电视剧", "国产剧")) { + t.Fatalf("display library path = %q, want mapped child path", got.DisplayLibraryPath) + } +} + func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) { db := newServiceTestDB(t, &model.Library{}, &model.Media{}) repos := repository.New(db) diff --git a/internal/service/filemanager_roots.go b/internal/service/filemanager_roots.go index 6364972..c694426 100644 --- a/internal/service/filemanager_roots.go +++ b/internal/service/filemanager_roots.go @@ -92,11 +92,11 @@ func (s *FileManagerService) allowedRoots() (map[string]string, error) { if label == "" { label = fmt.Sprintf("路径%d", i+1) } - add("library:"+l.Name+":"+label, root.Path) + add("library:"+l.Name+":"+label, resolveMappedDestinationPath(root.Path)) } continue } - add("library:"+l.Name, l.Path) + add("library:"+l.Name, resolveMappedDestinationPath(l.Path)) } } } diff --git a/internal/service/media_display_library.go b/internal/service/media_display_library.go index ba065d1..1e09800 100644 --- a/internal/service/media_display_library.go +++ b/internal/service/media_display_library.go @@ -17,7 +17,9 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model. return } byID := make(map[string]model.Library, len(libs)) - for _, lib := range libs { + for i := range libs { + libs[i] = normalizeLocalLibraryPathForDisplay(libs[i]) + lib := libs[i] byID[lib.ID] = lib } resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs) @@ -58,7 +60,8 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai displayLibraries: displayLibraries, } for _, lib := range libs { - resolver.byID[lib.ID] = lib + normalized := normalizeLocalLibraryPathForDisplay(lib) + resolver.byID[normalized.ID] = normalized } for _, lib := range displayLibraries { resolver.displayByID[lib.ID] = lib @@ -146,13 +149,16 @@ func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) ( } mediaPath := cleanPathForVolumeMapping(media.Path) + if isRelativeVolumeMarkerPath(media.Path) { + mediaPath = cleanPathForVolumeMapping(resolveMappedDestinationPath(media.Path)) + } var best model.Library bestLen := 0 for _, lib := range r.displayLibraries { if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled { continue } - libPath := cleanPathForVolumeMapping(lib.Path) + libPath := cleanPathForVolumeMapping(resolveMappedDestinationPath(lib.Path)) if libPath == "" || libPath == "." { continue } @@ -169,3 +175,17 @@ func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) ( } return model.Library{}, false } + +func normalizeLocalLibraryPathForDisplay(lib model.Library) model.Library { + if _, ok := ParseCloudLibraryMount(lib.Path); ok { + return lib + } + lib.Path = resolveMappedDestinationPath(lib.Path) + for i := range lib.Roots { + if _, ok := ParseCloudLibraryMount(lib.Roots[i].Path); ok { + continue + } + lib.Roots[i].Path = resolveMappedDestinationPath(lib.Roots[i].Path) + } + return lib +} diff --git a/internal/service/media_paths.go b/internal/service/media_paths.go index 5ab1dd1..de07078 100644 --- a/internal/service/media_paths.go +++ b/internal/service/media_paths.go @@ -70,7 +70,7 @@ func resolveMappedDestinationPath(path string) string { return "" } clean := filepath.Clean(path) - if _, err := os.Stat(clean); err == nil { + if _, err := os.Stat(clean); err == nil && !isRelativeVolumeMarkerPath(clean) { return clean } for _, candidate := range mappedPathCandidates(clean) { @@ -97,6 +97,11 @@ func mappedPathCandidates(input string) []string { candidates = append(candidates, candidate) } clean := filepath.Clean(input) + if isRelativeVolumeMarkerPath(clean) { + for _, candidate := range dockerVolumePathCandidates(clean) { + add(candidate) + } + } add(clean) for _, candidate := range dockerVolumePathCandidates(input) { add(candidate) @@ -141,6 +146,22 @@ func dockerVolumePathCandidates(path string) []string { {env: "MEDIASTATION_MEDIA_DIR", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")}, {env: "MEDIASTATION_DOWNLOAD_DIR", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")}, } { + container := cleanPathForVolumeMapping(mapping.container) + if container == "." || container == "" || strings.HasPrefix(container, ".") { + continue + } + relativeMarker := strings.TrimPrefix(normalized, "./") + if !strings.HasPrefix(relativeMarker, "/") { + containerBase := strings.TrimSpace(filepath.Base(container)) + if relativeMarker == containerBase { + addCandidate(container) + continue + } + if strings.HasPrefix(relativeMarker, containerBase+"/") { + addCandidate(container + strings.TrimPrefix(relativeMarker, containerBase)) + continue + } + } host := cleanPathForVolumeMapping(os.Getenv(mapping.env)) if host == "." || host == "" || strings.HasPrefix(host, ".") { continue @@ -152,10 +173,6 @@ func dockerVolumePathCandidates(path string) []string { if strings.HasPrefix(normalized, host+"/") { addCandidate(mapping.container + strings.TrimPrefix(normalized, host)) } - container := cleanPathForVolumeMapping(mapping.container) - if container == "." || container == "" || strings.HasPrefix(container, ".") { - continue - } if normalized == container { addCandidate(host) continue @@ -187,6 +204,20 @@ func dockerVolumePathCandidates(path string) []string { return candidates } +func isRelativeVolumeMarkerPath(path string) bool { + normalized := cleanPathForVolumeMapping(path) + normalized = strings.TrimPrefix(normalized, "./") + if normalized == "" || strings.HasPrefix(normalized, "/") || filepath.IsAbs(path) { + return false + } + for _, marker := range []string{"media", "downloads"} { + if normalized == marker || strings.HasPrefix(normalized, marker+"/") { + return true + } + } + return false +} + func cleanPathForVolumeMapping(path string) string { path = strings.TrimSpace(path) if path == "" { diff --git a/internal/service/media_test.go b/internal/service/media_test.go index 3eda67b..99887df 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -88,6 +88,24 @@ func TestResolveAccessibleLibraryPathKeepsAccessibleContainerPath(t *testing.T) } } +func TestResolveAccessibleLibraryPathMapsRelativeDockerMediaMarker(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(filepath.Join("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 TestInferLibraryKindFromCategoryPathOverridesMovieDefault(t *testing.T) { for _, tc := range []struct { name string @@ -142,6 +160,26 @@ func TestResolveMappedDestinationPathPrefersConfiguredContainerMapping(t *testin } } +func TestResolveMappedDestinationPathPrefersContainerForRelativeMediaMarker(t *testing.T) { + root := t.TempDir() + workDir := filepath.Join(root, "work") + containerMedia := filepath.Join(root, "container", "media") + if err := os.MkdirAll(filepath.Join(workDir, "media", "电视剧", "国产剧"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(containerMedia, "电视剧", "国产剧"), 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(workDir) + t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerMedia) + + got := resolveMappedDestinationPath(filepath.Join("media", "电视剧", "国产剧")) + want := filepath.Join(containerMedia, "电视剧", "国产剧") + if got != filepath.Clean(want) { + t.Fatalf("resolveMappedDestinationPath() = %q, want %q", got, filepath.Clean(want)) + } +} + func TestResolveAccessibleMappedPathMapsWindowsDownloadVariants(t *testing.T) { root := t.TempDir() containerDownloads := filepath.Join(root, "container", "downloads") diff --git a/internal/service/organizer.go b/internal/service/organizer.go index d3c6798..fd86b38 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -130,7 +130,7 @@ func (o *OrganizerService) OrganizeLibraryWithOptions(ctx context.Context, libra return nil, errors.New("organize source must be a local directory; cloud libraries should be managed from external storage scan/mount") } // 目的地目录:已位于该根下的文件视为已整理;受 dest_path 覆盖与设置影响。 - baseRoot := normalizeOrganizeDestinationRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath)) + baseRoot := normalizeMappedOrganizeDestinationRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath)) if _, ok := ParseCloudLibraryMount(baseRoot); ok { return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud") } diff --git a/internal/service/organizer_directory_libraries.go b/internal/service/organizer_directory_libraries.go index 7209cd2..a7a7ddb 100644 --- a/internal/service/organizer_directory_libraries.go +++ b/internal/service/organizer_directory_libraries.go @@ -30,7 +30,7 @@ func (o *OrganizerService) organizeLibraryForLayout(ctx context.Context, destRoo } return model.Library{}, false } - destRoot = filepath.Clean(strings.TrimSpace(destRoot)) + destRoot = normalizeMappedOrganizeDestinationRoot(destRoot) mediaType = normalizeOrganizeMediaType(mediaType) aliases := o.organizeCategoryAliases(mediaType, category) @@ -48,6 +48,7 @@ func (o *OrganizerService) organizeLibraryForLayout(ctx context.Context, destRoo // "手动整理"等暂存库不作为入库目标,避免把媒体留在暂存目录里。 continue } + lib.Path = resolveMappedDestinationPath(lib.Path) scopeScore, inScope := o.organizeLibraryTargetScopeScore(lib.Path, destRoot, mediaType, category) if !inScope { continue @@ -183,7 +184,7 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo if o == nil || o.repo == nil || o.repo.Library == nil { return model.Library{}, false } - root = filepath.Clean(strings.TrimSpace(root)) + root = resolveMappedDestinationPath(root) if root == "" || root == "." { return model.Library{}, false } @@ -206,7 +207,7 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo if _, ok := ParseCloudLibraryMount(lib.Path); ok { continue } - lib.Path = filepath.Clean(lib.Path) + lib.Path = resolveMappedDestinationPath(lib.Path) if strings.EqualFold(lib.Path, root) { return lib, true } diff --git a/internal/service/organizer_media.go b/internal/service/organizer_media.go index 9c9fc4d..c625597 100644 --- a/internal/service/organizer_media.go +++ b/internal/service/organizer_media.go @@ -43,7 +43,7 @@ func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, medi } requestedBaseRoot := o.resolveBaseRoot(ctx, lib, opts.DestPath) mediaType, mediaCategory := o.effectiveOrganizeOverrides(opts, requestedBaseRoot) - baseRoot := normalizeOrganizeDestinationRoot(requestedBaseRoot) + baseRoot := normalizeMappedOrganizeDestinationRoot(requestedBaseRoot) if _, ok := ParseCloudLibraryMount(baseRoot); ok { return organizeMediaRequest{}, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud") } diff --git a/internal/service/organizer_paths.go b/internal/service/organizer_paths.go index 8d09bd6..fec4d5b 100644 --- a/internal/service/organizer_paths.go +++ b/internal/service/organizer_paths.go @@ -235,6 +235,10 @@ func normalizeOrganizeDestinationRoot(root string) string { return cleaned } +func normalizeMappedOrganizeDestinationRoot(root string) string { + return normalizeOrganizeDestinationRoot(resolveMappedDestinationPath(root)) +} + func pathAlreadyEndsWith(path, suffix string) bool { base := strings.TrimSpace(filepath.Base(filepath.Clean(path))) return strings.EqualFold(base, suffix) diff --git a/internal/service/organizer_reclassify_scanned.go b/internal/service/organizer_reclassify_scanned.go index a4f1fbd..0cfa710 100644 --- a/internal/service/organizer_reclassify_scanned.go +++ b/internal/service/organizer_reclassify_scanned.go @@ -167,7 +167,7 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod return false, nil } - baseRoot := normalizeOrganizeDestinationRoot(requestedBaseRoot) + baseRoot := normalizeMappedOrganizeDestinationRoot(requestedBaseRoot) targetLibrary, matched := o.organizeLibraryForLayout(ctx, baseRoot, mediaType, category) if !matched || strings.TrimSpace(targetLibrary.ID) == "" || strings.TrimSpace(targetLibrary.Path) == "" { targetRoot := categoryRoot(o.organizeRoot(baseRoot, mediaType, category), category) diff --git a/internal/service/service.go b/internal/service/service.go index ea4fb44..5c020e3 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -88,6 +88,12 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont // Boot 启动后台工作进程(watcher, downloads poller, subscription scheduler)。 // 在 AutoMigrate 后调用一次。 func (c *Container) Boot() { + if err := c.NormalizeLocalLibraryPaths(c.stopCtx); err != nil { + c.Log.Warn("normalize local library paths failed", zap.Error(err)) + } + if err := c.NormalizeCloudLibraryTypes(c.stopCtx); err != nil { + c.Log.Warn("normalize cloud library types failed", zap.Error(err)) + } if err := c.Watcher.Start(c.stopCtx); err != nil { c.Log.Warn("watcher start failed", zap.Error(err)) } @@ -96,9 +102,6 @@ func (c *Container) Boot() { if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil { c.Log.Warn("api config seed failed", zap.Error(err)) } - if err := c.NormalizeCloudLibraryTypes(c.stopCtx); err != nil { - c.Log.Warn("normalize cloud library types failed", zap.Error(err)) - } go c.warmMediaSearchIndex(c.stopCtx) // 加载所有已配置的下载客户端 diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index e0845d7..461163e 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -186,12 +186,12 @@ func (b *serviceContainerBuilder) libraryRoots() []string { if !root.Enabled || strings.TrimSpace(root.Path) == "" { continue } - roots = append(roots, root.Path) + roots = append(roots, resolveMappedDestinationPath(root.Path)) } continue } if strings.TrimSpace(l.Path) != "" { - roots = append(roots, l.Path) + roots = append(roots, resolveMappedDestinationPath(l.Path)) } } return roots diff --git a/internal/service/service_library_normalize.go b/internal/service/service_library_normalize.go index 73d0aa0..7c44f9a 100644 --- a/internal/service/service_library_normalize.go +++ b/internal/service/service_library_normalize.go @@ -2,10 +2,82 @@ package service import ( "context" + "path/filepath" + "strings" "github.com/ShukeBta/MediaStationGo/internal/model" ) +func (c *Container) NormalizeLocalLibraryPaths(ctx context.Context) error { + if c == nil || c.Repo == nil || c.Repo.Library == nil || c.Repo.DB == nil { + return nil + } + libs, err := c.Repo.Library.List(ctx) + if err != nil { + return err + } + for _, lib := range libs { + if _, ok := ParseCloudLibraryMount(lib.Path); ok { + continue + } + if len(lib.Roots) == 0 { + normalized := normalizePersistedLocalLibraryPath(lib.Path) + if sameLibraryPath(normalized, lib.Path) { + continue + } + if err := c.Repo.DB.WithContext(ctx). + Model(&model.Library{}). + Where("id = ?", lib.ID). + Update("path", normalized).Error; err != nil { + return err + } + continue + } + primaryPath := "" + for i, root := range lib.Roots { + if _, ok := ParseCloudLibraryMount(root.Path); ok { + if i == 0 { + primaryPath = root.Path + } + continue + } + normalized := normalizePersistedLocalLibraryPath(root.Path) + if i == 0 { + primaryPath = normalized + } + if sameLibraryPath(normalized, root.Path) || strings.TrimSpace(root.ID) == "" { + continue + } + if err := c.Repo.DB.WithContext(ctx). + Model(&model.LibraryRoot{}). + Where("id = ?", root.ID). + Update("path", normalized).Error; err != nil { + return err + } + } + if strings.TrimSpace(primaryPath) != "" && !sameLibraryPath(primaryPath, lib.Path) { + if err := c.Repo.DB.WithContext(ctx). + Model(&model.Library{}). + Where("id = ?", lib.ID). + Update("path", primaryPath).Error; err != nil { + return err + } + } + } + return nil +} + +func normalizePersistedLocalLibraryPath(pathValue string) string { + pathValue = strings.TrimSpace(pathValue) + if pathValue == "" { + return "" + } + if !isRelativeVolumeMarkerPath(pathValue) { + return filepath.Clean(pathValue) + } + return resolveMappedDestinationPath(pathValue) +} + func (c *Container) NormalizeCloudLibraryTypes(ctx context.Context) error { if c == nil || c.Repo == nil || c.Repo.Library == nil || c.Repo.DB == nil { return nil diff --git a/internal/service/service_library_normalize_test.go b/internal/service/service_library_normalize_test.go new file mode 100644 index 0000000..55ea372 --- /dev/null +++ b/internal/service/service_library_normalize_test.go @@ -0,0 +1,54 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestNormalizeLocalLibraryPathsRewritesRelativeDockerMediaRoot(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) + + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Name: "国产剧", Path: filepath.Join("media", "电视剧", "国产剧"), Type: "tv", Enabled: true} + if err := repos.Library.CreateWithRoots(t.Context(), &lib, []model.LibraryRoot{{ + Name: "国产剧", + Path: filepath.Join("media", "电视剧", "国产剧"), + Enabled: true, + }}); err != nil { + t.Fatal(err) + } + + svc := &Container{Repo: repos} + if err := svc.NormalizeLocalLibraryPaths(t.Context()); err != nil { + t.Fatalf("NormalizeLocalLibraryPaths() error = %v", err) + } + + got, err := repos.Library.FindByID(t.Context(), lib.ID) + if err != nil { + t.Fatal(err) + } + want := filepath.Clean(containerLibrary) + if got.Path != want { + t.Fatalf("library path = %q, want %q", got.Path, want) + } + if len(got.Roots) != 1 || got.Roots[0].Path != want { + t.Fatalf("library roots = %#v, want path %q", got.Roots, want) + } +} + +func TestNormalizePersistedLocalLibraryPathKeepsEmptyPathEmpty(t *testing.T) { + if got := normalizePersistedLocalLibraryPath(""); got != "" { + t.Fatalf("normalizePersistedLocalLibraryPath(empty) = %q, want empty", got) + } +}