fix: map docker host library paths

This commit is contained in:
ShukeBta
2026-05-29 02:44:52 +08:00
parent 5a7d90289b
commit 3031893156
6 changed files with 150 additions and 6 deletions
+10
View File
@@ -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
+2
View File
@@ -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
+8
View File
@@ -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}
+83 -5
View File
@@ -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)
+42
View File
@@ -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))
}
}
+5 -1
View File
@@ -92,10 +92,14 @@ function LibraryPanel() {
<input
required
className="input-base md:col-span-2"
placeholder="服务器路径,如 /media/movies"
placeholder="容器路径,如 /media/电视剧/国产剧"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
宿主机路径,系统会尝试按 compose 挂载自动转换。
</p>
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
<option value="movie">电影</option>
<option value="tv">电视剧</option>