fix docker media path normalization

This commit is contained in:
ShukeBta
2026-07-01 14:24:24 +08:00
parent 7cf0e468ff
commit 7ebeddfa38
14 changed files with 283 additions and 21 deletions
@@ -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)
+2 -2
View File
@@ -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))
}
}
}
+23 -3
View File
@@ -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
}
+36 -5
View File
@@ -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 == "" {
+38
View File
@@ -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")
+1 -1
View File
@@ -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")
}
@@ -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
}
+1 -1
View File
@@ -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")
}
+4
View File
@@ -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)
@@ -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)
+6 -3
View File
@@ -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)
// 加载所有已配置的下载客户端
+2 -2
View File
@@ -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
@@ -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
@@ -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)
}
}