diff --git a/README.md b/README.md index 13d2ce4..f57c6a0 100644 --- a/README.md +++ b/README.md @@ -366,6 +366,14 @@ services: MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db MEDIASTATION_CACHE_CACHE_DIR: /cache + # 宿主机到容器的路径映射提示。用于用户误填宿主机路径时自动转换为容器路径: + # /vol1/1000/Docker/moviepilot-v2/media/电视剧 -> /media/电视剧 + # /vol1/1000/qBittorrent/downloads/国产剧 -> /downloads/国产剧 + MEDIASTATION_MEDIA_DIR: ${MEDIASTATION_MEDIA_DIR:-./media} + MEDIASTATION_MEDIA_CONTAINER_DIR: /media + MEDIASTATION_DOWNLOAD_DIR: ${MEDIASTATION_DOWNLOAD_DIR:-./downloads} + MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads + # 日志级别:debug / info / warn / error。 MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info} @@ -488,6 +496,8 @@ Docker Compose 左侧是宿主机真实目录,右侧是容器内目录。宿 容器内只需要使用 `/media` 和 `/downloads`。如果你看到 `/vol1/1000/Docker/MediaStationGo/vol1/...`,说明 `.env` 或 compose 里把路径写成了 `./vol1/...`,需要去掉前面的点,改为 `/vol1/...`。 +> 如果你在添加媒体库时填写了 `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧` 并提示不可访问,原因是应用在容器内运行,默认只能看到 `/media/电视剧/国产剧`。新版 compose 会把宿主机路径作为映射提示传入容器,尽量自动纠正;但最推荐、最稳定的填写方式仍然是容器路径 `/media/...`。 + 如果 compose 中这样挂载: ```yaml diff --git a/README_EN.md b/README_EN.md index 8bfa7bd..6ceabb6 100644 --- a/README_EN.md +++ b/README_EN.md @@ -346,6 +346,8 @@ The left side of a Docker Compose volume is the real host directory, and the rig Inside the app, use only `/media` and `/downloads`. If you see `/vol1/1000/Docker/MediaStationGo/vol1/...`, your `.env` or compose file probably uses `./vol1/...`; remove the dot and use `/vol1/...`. +> If adding `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧` as a library reports inaccessible, the app is running inside the container and normally sees `/media/电视剧/国产剧`. The updated compose passes host-path hints so the backend can auto-convert common mistakes, but `/media/...` remains the recommended and most stable input. + If your compose mounts are: ```yaml diff --git a/docker-compose.yml b/docker-compose.yml index 6d8ffa2..b999b27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,14 @@ services: MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db MEDIASTATION_CACHE_CACHE_DIR: /cache + # 宿主机到容器的路径映射提示。用于用户误填宿主机路径时自动转换为容器路径: + # /vol1/1000/Docker/moviepilot-v2/media/电视剧 -> /media/电视剧 + # /vol1/1000/qBittorrent/downloads/国产剧 -> /downloads/国产剧 + MEDIASTATION_MEDIA_DIR: ${MEDIASTATION_MEDIA_DIR:-./media} + MEDIASTATION_MEDIA_CONTAINER_DIR: /media + MEDIASTATION_DOWNLOAD_DIR: ${MEDIASTATION_DOWNLOAD_DIR:-./downloads} + MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads + # 日志级别:debug / info / warn / error。 MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info} diff --git a/internal/service/media.go b/internal/service/media.go index 32a450c..0c56ee8 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "go.uber.org/zap" @@ -32,12 +33,9 @@ func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind strin if name == "" || path == "" { return nil, errors.New("name and path required") } - abs, err := filepath.Abs(path) + abs, err := resolveAccessibleLibraryPath(path) if err != nil { - return nil, fmt.Errorf("invalid path: %w", err) - } - if info, err := os.Stat(abs); err != nil || !info.IsDir() { - return nil, fmt.Errorf("path is not an accessible directory: %s", abs) + return nil, err } if kind == "" { kind = "movie" @@ -49,6 +47,86 @@ func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind strin return lib, nil } +func resolveAccessibleLibraryPath(path string) (string, error) { + abs, err := filepath.Abs(strings.TrimSpace(path)) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + if isAccessibleDir(abs) { + return abs, nil + } + for _, candidate := range dockerVolumePathCandidates(abs) { + if isAccessibleDir(candidate) { + return filepath.Clean(candidate), nil + } + } + return "", fmt.Errorf("path is not an accessible directory: %s", abs) +} + +func isAccessibleDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func dockerVolumePathCandidates(path string) []string { + normalized := filepath.ToSlash(filepath.Clean(path)) + var candidates []string + addCandidate := func(candidate string) { + candidate = filepath.Clean(filepath.FromSlash(candidate)) + for _, existing := range candidates { + if sameLibraryPath(existing, candidate) { + return + } + } + candidates = append(candidates, candidate) + } + + for _, mapping := range []struct { + env string + container string + }{ + {env: "MEDIASTATION_MEDIA_DIR", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")}, + {env: "MEDIASTATION_DOWNLOAD_DIR", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")}, + } { + host := filepath.ToSlash(filepath.Clean(os.Getenv(mapping.env))) + if host == "." || host == "" || strings.HasPrefix(host, ".") { + continue + } + if normalized == host { + addCandidate(mapping.container) + continue + } + if strings.HasPrefix(normalized, host+"/") { + addCandidate(mapping.container + strings.TrimPrefix(normalized, host)) + } + } + + for _, marker := range []struct { + part string + container string + }{ + {part: "/media/", container: "/media/"}, + {part: "/downloads/", container: "/downloads/"}, + } { + if idx := strings.Index(normalized, marker.part); idx >= 0 { + addCandidate(marker.container + strings.TrimPrefix(normalized[idx+len(marker.part):], "/")) + } + } + + return candidates +} + +func sameLibraryPath(a, b string) bool { + return filepath.Clean(a) == filepath.Clean(b) +} + +func envOrDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + // ListLibraries returns every library configured on the server. func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) { return s.repo.Library.List(ctx) diff --git a/internal/service/media_test.go b/internal/service/media_test.go new file mode 100644 index 0000000..0c6d0b8 --- /dev/null +++ b/internal/service/media_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveAccessibleLibraryPathMapsConfiguredHostMediaDir(t *testing.T) { + root := t.TempDir() + hostRoot := filepath.Join(root, "nas", "moviepilot-v2", "media") + 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_DIR", hostRoot) + t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerRoot) + + got, err := resolveAccessibleLibraryPath(filepath.Join(hostRoot, "电视剧", "国产剧")) + 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 TestResolveAccessibleLibraryPathKeepsAccessibleContainerPath(t *testing.T) { + containerLibrary := filepath.Join(t.TempDir(), "media", "电影") + if err := os.MkdirAll(containerLibrary, 0o755); err != nil { + t.Fatal(err) + } + + got, err := resolveAccessibleLibraryPath(containerLibrary) + if err != nil { + t.Fatalf("resolveAccessibleLibraryPath() error = %v", err) + } + if got != filepath.Clean(containerLibrary) { + t.Fatalf("resolveAccessibleLibraryPath() = %q, want %q", got, filepath.Clean(containerLibrary)) + } +} diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index f272ab7..b7f8f07 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -92,10 +92,14 @@ function LibraryPanel() { setPath(e.target.value)} /> +

+ Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS + 宿主机路径,系统会尝试按 compose 挂载自动转换。 +