mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
fix: map docker host library paths
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user