From 2ed8a6ed9bb973dd643339877e53ca8106e4be81 Mon Sep 17 00:00:00 2001 From: okatu-loli Date: Fri, 12 Jun 2026 16:27:05 +0800 Subject: [PATCH] feat(fs): support skipping existing files in copy tasks (#9555) Add skip_existing option to POST /fs/copy. When enabled, destination files with the same name and size are skipped instead of failing the request, so an interrupted cross-storage copy can be resumed without re-transferring completed files. Files with mismatched sizes (e.g. truncated by a previous interruption) are re-copied. --- internal/conf/const.go | 3 ++- internal/fs/copy.go | 23 +++++++++++++++++++++++ server/handles/fsmanage.go | 13 +++++++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/internal/conf/const.go b/internal/conf/const.go index 79480a4589e..f12d11ca89e 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -170,5 +170,6 @@ const ( // ContextKey is the type of context keys. const ( - NoTaskKey = "no_task" + NoTaskKey = "no_task" + SkipExistingKey = "skip_existing" ) diff --git a/internal/fs/copy.go b/internal/fs/copy.go index 155e3cf7a87..7933949fb20 100644 --- a/internal/fs/copy.go +++ b/internal/fs/copy.go @@ -28,6 +28,7 @@ type CopyTask struct { dstStorage driver.Driver `json:"-"` SrcStorageMp string `json:"src_storage_mp"` DstStorageMp string `json:"dst_storage_mp"` + SkipExisting bool `json:"skip_existing"` } func (t *CopyTask) GetName() string { @@ -69,8 +70,18 @@ func _copy(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool if err != nil { return nil, errors.WithMessage(err, "failed get dst storage") } + skipExisting := ctx.Value(conf.SkipExistingKey) != nil // copy if in the same storage, just call driver.Copy if srcStorage.GetStorage() == dstStorage.GetStorage() { + if skipExisting { + if srcObj, err := op.Get(ctx, srcStorage, srcObjActualPath); err == nil && !srcObj.IsDir() { + dstFilePath := stdpath.Join(dstDirActualPath, srcObj.GetName()) + if dstFile, err := op.Get(ctx, dstStorage, dstFilePath); err == nil && + !dstFile.IsDir() && dstFile.GetSize() == srcObj.GetSize() { + return nil, nil + } + } + } err = op.Copy(ctx, srcStorage, srcObjActualPath, dstDirActualPath, lazyCache...) if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) { return nil, err @@ -113,6 +124,7 @@ func _copy(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool DstDirPath: dstDirActualPath, SrcStorageMp: srcStorage.GetStorage().MountPath, DstStorageMp: dstStorage.GetStorage().MountPath, + SkipExisting: skipExisting, } CopyTaskManager.Add(t) return t, nil @@ -146,6 +158,7 @@ func copyBetween2Storages(t *CopyTask, srcStorage, dstStorage driver.Driver, src DstDirPath: dstObjPath, SrcStorageMp: srcStorage.GetStorage().MountPath, DstStorageMp: dstStorage.GetStorage().MountPath, + SkipExisting: t.SkipExisting, }) } t.Status = "src object is dir, added all copy tasks of objs" @@ -160,6 +173,16 @@ func copyFileBetween2Storages(tsk *CopyTask, srcStorage, dstStorage driver.Drive return errors.WithMessagef(err, "failed get src [%s] file", srcFilePath) } tsk.SetTotalBytes(srcFile.GetSize()) + if tsk.SkipExisting { + dstFilePath := stdpath.Join(dstDirPath, srcFile.GetName()) + // a failed probe falls through to a normal copy, worst case is a redundant transfer + if dstFile, err := op.Get(tsk.Ctx(), dstStorage, dstFilePath); err == nil && + !dstFile.IsDir() && dstFile.GetSize() == srcFile.GetSize() { + tsk.Status = "skipped: destination file already exists" + tsk.SetProgress(100) + return nil + } + } link, _, err := op.Link(tsk.Ctx(), srcStorage, srcFilePath, model.LinkArgs{ Header: http.Header{}, }) diff --git a/server/handles/fsmanage.go b/server/handles/fsmanage.go index 31976edd9ad..98c68353b9d 100644 --- a/server/handles/fsmanage.go +++ b/server/handles/fsmanage.go @@ -1,12 +1,14 @@ package handles import ( + "context" "fmt" "io" stdpath "path" "github.com/alist-org/alist/v3/internal/task" + "github.com/alist-org/alist/v3/internal/conf" "github.com/alist-org/alist/v3/internal/errs" "github.com/alist-org/alist/v3/internal/fs" "github.com/alist-org/alist/v3/internal/model" @@ -66,6 +68,9 @@ type MoveCopyReq struct { DstDir string `json:"dst_dir"` Names []string `json:"names"` Overwrite bool `json:"overwrite"` + // SkipExisting only takes effect on copy: existing destination files + // with the same size are skipped instead of failing the request + SkipExisting bool `json:"skip_existing"` } func FsMove(c *gin.Context) { @@ -169,7 +174,7 @@ func FsCopy(c *gin.Context) { common.ErrorResp(c, errs.PermissionDenied, 403) return } - if !req.Overwrite { + if !req.Overwrite && !req.SkipExisting { for _, name := range req.Names { dstPath, err := utils.JoinUnderBase(dstDir, name) if err != nil { @@ -182,6 +187,10 @@ func FsCopy(c *gin.Context) { } } } + var ctx context.Context = c + if req.SkipExisting { + ctx = context.WithValue(ctx, conf.SkipExistingKey, struct{}{}) + } var addedTasks []task.TaskExtensionInfo for i, name := range req.Names { srcPath, err := utils.JoinUnderBase(srcDir, name) @@ -194,7 +203,7 @@ func FsCopy(c *gin.Context) { common.ErrorResp(c, err, 400) return } - t, err := fs.Copy(c, srcPath, dstDir, len(req.Names) > i+1) + t, err := fs.Copy(ctx, srcPath, dstDir, len(req.Names) > i+1) if t != nil { addedTasks = append(addedTasks, t) }