From 77b6e448e02d2a8789e601f0f0b679b34b24ccce Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:38:26 +0800 Subject: [PATCH] fix directory hardlink transfer --- README.md | 4 ++ README_EN.md | 6 +++ internal/service/filemanager.go | 9 ++-- internal/service/filemanager_test.go | 65 ++++++++++++++++++++++++++++ internal/service/transfer.go | 49 +++++++++++++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f84070a..b87fb82 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,10 @@ MediaStationGo 支持 Telegram Bot 绑定、用户菜单、群组管理菜单和 确认 qBittorrent 保存路径已经通过 `volumes` 挂载进 MediaStationGo 容器,并且 `MEDIASTATION_DOWNLOAD_DIR` 与 `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` 对应正确。 +**硬链接目录在 Docker / NAS 上看不到内容?** + +硬链接不能直接链接“目录”本身,只能链接目录里的文件。文件管理器执行目录硬链接时会递归创建目标目录结构,并为每个文件创建硬链接。硬链接还要求源文件和目标文件在容器内属于同一个文件系统/子卷;如果下载目录和媒体目录是两个独立 bind mount、不同硬盘、不同 btrfs 子卷或网盘挂载,系统会返回 `invalid cross-device link`,此时请选择“复制”或“软链接”。 + **第三方播放器无法连接?** 确认播放器填写的是 `http://服务器IP:18080`,账号密码使用 MediaStationGo 用户账号。反代部署时需要正确设置外部访问地址和 HTTPS 头。 diff --git a/README_EN.md b/README_EN.md index 75085e6..36bea25 100644 --- a/README_EN.md +++ b/README_EN.md @@ -510,6 +510,12 @@ Beginners should not. Editing `docker-compose.yml` directly is easier to underst | Operations | Task queue, recycle bin, duplicate files, notifications, logs | | AI | OpenAI-compatible API, AI search, recommendations, assistant | +Directory hardlinks are handled by recreating the directory tree and hardlinking +each contained file. Linux cannot hardlink a directory itself. Hardlinks still +require the source and target files to be on the same filesystem/subvolume from +inside the container; if media and downloads are separate bind mounts, disks, +btrfs subvolumes, or cloud mounts, use copy or symlink instead. + ## Development Regular users should use Docker. Developers can run: diff --git a/internal/service/filemanager.go b/internal/service/filemanager.go index f6fdbe4..d840036 100644 --- a/internal/service/filemanager.go +++ b/internal/service/filemanager.go @@ -193,9 +193,6 @@ func (s *FileManagerService) Transfer(sourcePath, destDir string, mode TransferM if err != nil { return nil, err } - if info.IsDir() { - return nil, errors.New("directory transfer is not supported yet") - } dst := filepath.Join(dstDir, filepath.Base(src)) if !s.withinAllowed(dst, roots) { return nil, ErrPathOutOfBounds @@ -206,7 +203,11 @@ func (s *FileManagerService) Transfer(sourcePath, destDir string, mode TransferM if mode == "" { mode = TransferCopy } - if err := transferFile(src, dst, mode); err != nil { + if info.IsDir() { + if err := transferDirectory(src, dst, mode); err != nil { + return nil, err + } + } else if err := transferFile(src, dst, mode); err != nil { return nil, err } return &FileOperationResult{Path: dst}, nil diff --git a/internal/service/filemanager_test.go b/internal/service/filemanager_test.go index 9e76ae2..f2c0e01 100644 --- a/internal/service/filemanager_test.go +++ b/internal/service/filemanager_test.go @@ -93,6 +93,58 @@ func TestFileManagerRecursiveListAndMutations(t *testing.T) { } } +func TestFileManagerTransferDirectoryHardlinksFiles(t *testing.T) { + root := t.TempDir() + if hardlinksUnsupported(t, root) { + t.Skip("hardlinks unsupported on this filesystem") + } + sourceDir := filepath.Join(root, "downloads", "Show") + seasonDir := filepath.Join(sourceDir, "Season 01") + targetRoot := filepath.Join(root, "media") + if err := os.MkdirAll(seasonDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(targetRoot, 0o755); err != nil { + t.Fatal(err) + } + sourceFile := filepath.Join(seasonDir, "Show.S01E01.mkv") + if err := os.WriteFile(sourceFile, []byte("episode"), 0o644); err != nil { + t.Fatal(err) + } + svc := newFileManagerTestService(t, root) + + res, err := svc.Transfer(sourceDir, targetRoot, TransferHardlink) + if err != nil { + t.Fatal(err) + } + targetFile := filepath.Join(res.Path, "Season 01", "Show.S01E01.mkv") + sourceInfo, err := os.Stat(sourceFile) + if err != nil { + t.Fatal(err) + } + targetInfo, err := os.Stat(targetFile) + if err != nil { + t.Fatalf("hardlinked directory file missing: %v", err) + } + if !os.SameFile(sourceInfo, targetInfo) { + t.Fatal("directory hardlink should hardlink contained files") + } + listing, err := svc.List(res.Path, 100, true) + if err != nil { + t.Fatal(err) + } + found := false + for _, entry := range listing.Entries { + if entry.Path == targetFile { + found = true + break + } + } + if !found { + t.Fatalf("target directory listing did not include %s", targetFile) + } +} + func TestFileManagerRefusesRootMutation(t *testing.T) { root := t.TempDir() svc := newFileManagerTestService(t, root) @@ -101,6 +153,19 @@ func TestFileManagerRefusesRootMutation(t *testing.T) { } } +func hardlinksUnsupported(t *testing.T, root string) bool { + t.Helper() + src := filepath.Join(root, "hardlink-probe-src") + dst := filepath.Join(root, "hardlink-probe-dst") + if err := os.WriteFile(src, []byte("probe"), 0o644); err != nil { + t.Fatal(err) + } + err := os.Link(src, dst) + _ = os.Remove(src) + _ = os.Remove(dst) + return err != nil +} + func TestFileManagerIncludesConfiguredOrganizeRoots(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "downloads") diff --git a/internal/service/transfer.go b/internal/service/transfer.go index 07e76a2..a89bbf4 100644 --- a/internal/service/transfer.go +++ b/internal/service/transfer.go @@ -134,3 +134,52 @@ func moveFile(src, dst string) error { } return os.Remove(src) } + +func transferDirectory(src, dst string, mode TransferMode) error { + if _, err := os.Stat(dst); err == nil { + return fmt.Errorf("destination already exists: %s", dst) + } + switch mode { + case TransferSymlink: + return transferFile(src, dst, mode) + case TransferMove: + if err := os.Rename(src, dst); err == nil { + return nil + } + if err := transferDirectoryTree(src, dst, TransferCopy); err != nil { + return err + } + return os.RemoveAll(src) + case TransferHardlink, TransferCopy: + return transferDirectoryTree(src, dst, mode) + default: + return transferDirectory(src, dst, TransferMove) + } +} + +func transferDirectoryTree(src, dst string, mode TransferMode) error { + if err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return os.MkdirAll(dst, 0o755) // #nosec G301 -- media folders must remain readable by local players. + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) // #nosec G301 -- media folders must remain readable by local players. + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { // #nosec G301 -- media folders must remain readable by local players. + return err + } + return transferFile(path, target, mode) + }); err != nil { + _ = os.RemoveAll(dst) + return err + } + return nil +}