mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
fix directory hardlink transfer
This commit is contained in:
@@ -407,6 +407,10 @@ MediaStationGo 支持 Telegram Bot 绑定、用户菜单、群组管理菜单和
|
|||||||
|
|
||||||
确认 qBittorrent 保存路径已经通过 `volumes` 挂载进 MediaStationGo 容器,并且 `MEDIASTATION_DOWNLOAD_DIR` 与 `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` 对应正确。
|
确认 qBittorrent 保存路径已经通过 `volumes` 挂载进 MediaStationGo 容器,并且 `MEDIASTATION_DOWNLOAD_DIR` 与 `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` 对应正确。
|
||||||
|
|
||||||
|
**硬链接目录在 Docker / NAS 上看不到内容?**
|
||||||
|
|
||||||
|
硬链接不能直接链接“目录”本身,只能链接目录里的文件。文件管理器执行目录硬链接时会递归创建目标目录结构,并为每个文件创建硬链接。硬链接还要求源文件和目标文件在容器内属于同一个文件系统/子卷;如果下载目录和媒体目录是两个独立 bind mount、不同硬盘、不同 btrfs 子卷或网盘挂载,系统会返回 `invalid cross-device link`,此时请选择“复制”或“软链接”。
|
||||||
|
|
||||||
**第三方播放器无法连接?**
|
**第三方播放器无法连接?**
|
||||||
|
|
||||||
确认播放器填写的是 `http://服务器IP:18080`,账号密码使用 MediaStationGo 用户账号。反代部署时需要正确设置外部访问地址和 HTTPS 头。
|
确认播放器填写的是 `http://服务器IP:18080`,账号密码使用 MediaStationGo 用户账号。反代部署时需要正确设置外部访问地址和 HTTPS 头。
|
||||||
|
|||||||
@@ -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 |
|
| Operations | Task queue, recycle bin, duplicate files, notifications, logs |
|
||||||
| AI | OpenAI-compatible API, AI search, recommendations, assistant |
|
| 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
|
## Development
|
||||||
|
|
||||||
Regular users should use Docker. Developers can run:
|
Regular users should use Docker. Developers can run:
|
||||||
|
|||||||
@@ -193,9 +193,6 @@ func (s *FileManagerService) Transfer(sourcePath, destDir string, mode TransferM
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if info.IsDir() {
|
|
||||||
return nil, errors.New("directory transfer is not supported yet")
|
|
||||||
}
|
|
||||||
dst := filepath.Join(dstDir, filepath.Base(src))
|
dst := filepath.Join(dstDir, filepath.Base(src))
|
||||||
if !s.withinAllowed(dst, roots) {
|
if !s.withinAllowed(dst, roots) {
|
||||||
return nil, ErrPathOutOfBounds
|
return nil, ErrPathOutOfBounds
|
||||||
@@ -206,7 +203,11 @@ func (s *FileManagerService) Transfer(sourcePath, destDir string, mode TransferM
|
|||||||
if mode == "" {
|
if mode == "" {
|
||||||
mode = TransferCopy
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &FileOperationResult{Path: dst}, nil
|
return &FileOperationResult{Path: dst}, nil
|
||||||
|
|||||||
@@ -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) {
|
func TestFileManagerRefusesRootMutation(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
svc := newFileManagerTestService(t, root)
|
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) {
|
func TestFileManagerIncludesConfiguredOrganizeRoots(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
sourceDir := filepath.Join(root, "downloads")
|
sourceDir := filepath.Join(root, "downloads")
|
||||||
|
|||||||
@@ -134,3 +134,52 @@ func moveFile(src, dst string) error {
|
|||||||
}
|
}
|
||||||
return os.Remove(src)
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user