Repository: OpenListTeam/OpenList

Issue:
[BUG] strm生成问题:如果视频后缀是MP4,strm生成后缀名有错
### 请确认以下事项

- [x] 我已确认阅读并同意 [AGPL-3.0 第15条](https://www.gnu.org/licenses/agpl-3.0.txt#:~:text=15.%20Disclaimer%20of%20Warranty.) 。
本程序不提供任何明示或暗示的担保，使用风险由您自行承担。

- [x] 我已确认阅读并同意 [AGPL-3.0 第16条](https://www.gnu.org/licenses/agpl-3.0.txt#:~:text=16.%20Limitation%20of%20Liability.) 。
无论何种情况，版权持有人或其他分发者均不对使用本程序所造成的任何损失承担责任。

- [x] 我确认我的描述清晰，语法礼貌，能帮助开发者快速定位问题，并符合社区规则。

- [x] 我已确认阅读了[OpenList文档](https://doc.oplist.org)。

- [x] 我已确认没有重复的问题或讨论。

- [x] 我已确认是`OpenList`的问题，而不是其他原因（例如 [网络](https://doc.oplist.org/faq/howto#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host-1) ，`依赖`或`操作`）。

- [x] 我认为此问题必须由`OpenList`处理，而非第三方。

- [x] 我已确认这个问题在最新版本中没有被修复。


### OpenList 版本（必填）

v4.1.7

### 使用的存储驱动（必填）

115+strm

### 问题描述（必填）

<img width="1529" height="478" alt="Image" src="https://github.com/user-attachments/assets/83757c13-bb63-400d-a481-f5e2c359df28" />

<img width="1505" height="472" alt="Image" src="https://github.com/user-attachments/assets/7fd28f56-7f15-454e-8c51-8272b5fc81ad" />

本该生成`.strm`文件 但是这生成了`.MP4strm`文件

### 日志（必填）

无

### 配置文件内容（必填）

无

### 复现链接（可选）

_No response_

Hints from issue comments:
@github-actions: 感谢您联系OpenList。我们会尽快回复您。
Thanks for contacting OpenList. We will reply to you as soon as possible.

@sevxn007: 等待 https://github.com/OpenListTeam/OpenList/pull/1630 合并

Solution patch:
diff --git a/drivers/123/meta.go b/drivers/123/meta.go
index e26212ea..5d37a15b 100644
--- a/drivers/123/meta.go
+++ b/drivers/123/meta.go
@@ -28,6 +28,7 @@ func init() {
 		return &Pan123{
 			Addition: Addition{
 				UploadThread: 3,
+				Platform: "web",
 			},
 		}
 	})
diff --git a/drivers/alias/util.go b/drivers/alias/util.go
index 80391fda..c37e1eff 100644
--- a/drivers/alias/util.go
+++ b/drivers/alias/util.go
@@ -5,7 +5,6 @@ import (
 	"errors"
 	stdpath "path"
 	"strings"
-	"sync"
 	"time"
 
 	"github.com/OpenListTeam/OpenList/v4/internal/driver"
@@ -17,9 +16,15 @@ import (
 	log "github.com/sirupsen/logrus"
 )
 
+type detailWithIndex struct {
+	idx int
+	val *model.StorageDetails
+}
+
 func (d *Alias) listRoot(ctx context.Context, withDetails, refresh bool) []model.Obj {
 	var objs []model.Obj
-	var wg sync.WaitGroup
+	detailsChan := make(chan detailWithIndex, len(d.pathMap))
+	workerCount := 0
 	for _, k := range d.rootOrder {
 		obj := model.Object{
 			Name:     k,
@@ -47,22 +52,26 @@ func (d *Alias) listRoot(ctx context.Context, withDetails, refresh bool) []model
 				DriverName:     remoteDriver.Config().Name,
 			},
 		}
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			c, cancel := context.WithTimeout(ctx, time.Second)
-			defer cancel()
-			details, e := op.GetStorageDetails(c, remoteDriver, refresh)
+		workerCount++
+		go func(dri driver.Driver, i int) {
+			details, e := op.GetStorageDetails(ctx, dri, refresh)
 			if e != nil {
 				if !errors.Is(e, errs.NotImplement) && !errors.Is(e, errs.StorageNotInit) {
-					log.Errorf("failed get %s storage details: %+v", remoteDriver.GetStorage().MountPath, e)
+					log.Errorf("failed get %s storage details: %+v", dri.GetStorage().MountPath, e)
 				}
-				return
 			}
-			objs[idx].(*model.ObjStorageDetails).StorageDetails = details
-		}()
+			detailsChan <- detailWithIndex{idx: i, val: details}
+		}(remoteDriver, idx)
+	}
+	for workerCount > 0 {
+		select {
+		case r := <-detailsChan:
+			objs[r.idx].(*model.ObjStorageDetails).StorageDetails = r.val
+			workerCount--
+		case <-time.After(time.Second):
+			workerCount = 0
+		}
 	}
-	wg.Wait()
 	return objs
 }
 
diff --git a/drivers/misskey/util.go b/drivers/misskey/util.go
index 5e7a0d8d..35ebe96c 100644
--- a/drivers/misskey/util.go
+++ b/drivers/misskey/util.go
@@ -57,18 +57,22 @@ func setBody(body interface{}) base.ReqCallback {
 }
 
 func handleFolderId(dir model.Obj) interface{} {
-	if dir.GetID() == "" {
-		return nil
+	if isRootFolder(dir) {
+		return nil // Root folder doesn't need folderId
 	}
 	return dir.GetID()
 }
 
+func isRootFolder(dir model.Obj) bool {
+	return dir.GetID() == ""
+}
+
 // API layer methods
 
 func (d *Misskey) getFiles(dir model.Obj) ([]model.Obj, error) {
 	var files []MFile
 	var body map[string]string
-	if dir.GetPath() != "/" {
+	if !isRootFolder(dir) {
 		body = map[string]string{"folderId": dir.GetID()}
 	} else {
 		body = map[string]string{}
@@ -85,7 +89,7 @@ func (d *Misskey) getFiles(dir model.Obj) ([]model.Obj, error) {
 func (d *Misskey) getFolders(dir model.Obj) ([]model.Obj, error) {
 	var folders []MFolder
 	var body map[string]string
-	if dir.GetPath() != "/" {
+	if !isRootFolder(dir) {
 		body = map[string]string{"folderId": dir.GetID()}
 	} else {
 		body = map[string]string{}
@@ -197,16 +201,24 @@ func (d *Misskey) put(ctx context.Context, dstDir model.Obj, stream model.FileSt
 		Reader:         stream,
 		UpdateProgress: up,
 	})
+
+	// Build form data, only add folderId if not root folder
+	formData := map[string]string{
+		"name":        stream.GetName(),
+		"comment":     "",
+		"isSensitive": "false",
+		"force":       "false",
+	}
+
+	folderId := handleFolderId(dstDir)
+	if folderId != nil {
+		formData["folderId"] = folderId.(string)
+	}
+
 	req := base.RestyClient.R().
 		SetContext(ctx).
 		SetFileReader("file", stream.GetName(), reader).
-		SetFormData(map[string]string{
-			"folderId":    handleFolderId(dstDir).(string),
-			"name":        stream.GetName(),
-			"comment":     "",
-			"isSensitive": "false",
-			"force":       "false",
-		}).
+		SetFormData(formData).
 		SetResult(&file).
 		SetAuthToken(d.AccessToken)
 
diff --git a/drivers/onedrive/meta.go b/drivers/onedrive/meta.go
index 18b461ea..4fd505b9 100644
--- a/drivers/onedrive/meta.go
+++ b/drivers/onedrive/meta.go
@@ -7,19 +7,19 @@ import (
 
 type Addition struct {
 	driver.RootPath
-	Region           string `json:"region" type:"select" required:"true" options:"global,cn,us,de" default:"global"`
-	IsSharepoint     bool   `json:"is_sharepoint"`
-	UseOnlineAPI     bool   `json:"use_online_api" default:"true"`
-	APIAddress       string `json:"api_url_address" default:"https://api.oplist.org/onedrive/renewapi"`
-	ClientID         string `json:"client_id"`
-	ClientSecret     string `json:"client_secret"`
-	RedirectUri      string `json:"redirect_uri" required:"true" default:"https://api.oplist.org/onedrive/callback"`
-	RefreshToken     string `json:"refresh_token" required:"true"`
-	SiteId           string `json:"site_id"`
-	ChunkSize        int64  `json:"chunk_size" type:"number" default:"5"`
-	CustomHost       string `json:"custom_host" help:"Custom host for onedrive download link"`
-	DisableDiskUsage bool   `json:"disable_disk_usage" default:"false"`
-	EnableDirectUpload bool `json:"enable_direct_upload" default:"false" help:"Enable direct upload from client to OneDrive"`
+	Region             string `json:"region" type:"select" required:"true" options:"global,cn,us,de" default:"global"`
+	IsSharepoint       bool   `json:"is_sharepoint"`
+	UseOnlineAPI       bool   `json:"use_online_api" default:"true"`
+	APIAddress         string `json:"api_url_address" default:"https://api.oplist.org/onedrive/renewapi"`
+	ClientID           string `json:"client_id"`
+	ClientSecret       string `json:"client_secret"`
+	RedirectUri        string `json:"redirect_uri" required:"true" default:"https://api.oplist.org/onedrive/callback"`
+	RefreshToken       string `json:"refresh_token" required:"true"`
+	SiteId             string `json:"site_id"`
+	ChunkSize          int64  `json:"chunk_size" type:"number" default:"5"`
+	CustomHost         string `json:"custom_host" help:"Custom host for onedrive download link"`
+	DisableDiskUsage   bool   `json:"disable_disk_usage" default:"false"`
+	EnableDirectUpload bool   `json:"enable_direct_upload" default:"false" help:"Enable direct upload from client to OneDrive"`
 }
 
 var config = driver.Config{
diff --git a/drivers/openlist/driver.go b/drivers/openlist/driver.go
index edf27c38..8cbbe6d4 100644
--- a/drivers/openlist/driver.go
+++ b/drivers/openlist/driver.go
@@ -117,7 +117,7 @@ func (d *OpenList) Link(ctx context.Context, file model.Obj, args model.LinkArgs
 	if d.PassUAToUpsteam {
 		userAgent := args.Header.Get("user-agent")
 		if userAgent != "" {
-			headers["User-Agent"] = base.UserAgent
+			headers["User-Agent"] = userAgent
 		}
 	}
 	// if PassIPToUpsteam is true, then pass the ip address to the upstream
diff --git a/drivers/quark_uc/driver.go b/drivers/quark_uc/driver.go
index f7ebfc60..68406ea9 100644
--- a/drivers/quark_uc/driver.go
+++ b/drivers/quark_uc/driver.go
@@ -217,11 +217,10 @@ func (d *QuarkOrUC) GetDetails(ctx context.Context) (*model.StorageDetails, erro
 	if err != nil {
 		return nil, err
 	}
+	used := memberInfo.Data.UseCapacity
+	total := memberInfo.Data.TotalCapacity
 	return &model.StorageDetails{
-		DiskUsage: model.DiskUsage{
-			TotalSpace: memberInfo.Data.TotalCapacity,
-			FreeSpace:  memberInfo.Data.TotalCapacity - memberInfo.Data.UseCapacity,
-		},
+		DiskUsage: driver.DiskUsageFromUsedAndTotal(used, total),
 	}, nil
 }
 
diff --git a/drivers/s3/driver.go b/drivers/s3/driver.go
index d19ead47..a9edef10 100644
--- a/drivers/s3/driver.go
+++ b/drivers/s3/driver.go
@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/OpenListTeam/OpenList/v4/internal/driver"
+	"github.com/OpenListTeam/OpenList/v4/internal/errs"
 	"github.com/OpenListTeam/OpenList/v4/internal/model"
 	"github.com/OpenListTeam/OpenList/v4/internal/stream"
 	"github.com/OpenListTeam/OpenList/v4/pkg/cron"
@@ -24,9 +25,10 @@ import (
 type S3 struct {
 	model.Storage
 	Addition
-	Session    *session.Session
-	client     *s3.S3
-	linkClient *s3.S3
+	Session            *session.Session
+	client             *s3.S3
+	linkClient         *s3.S3
+	directUploadClient *s3.S3
 
 	config driver.Config
 	cron   *cron.Cron
@@ -52,16 +54,18 @@ func (d *S3) Init(ctx context.Context) error {
 			if err != nil {
 				log.Errorln("Doge init session error:", err)
 			}
-			d.client = d.getClient(false)
-			d.linkClient = d.getClient(true)
+			d.client = d.getClient(ClientTypeNormal)
+			d.linkClient = d.getClient(ClientTypeLink)
+			d.directUploadClient = d.getClient(ClientTypeDirectUpload)
 		})
 	}
 	err := d.initSession()
 	if err != nil {
 		return err
 	}
-	d.client = d.getClient(false)
-	d.linkClient = d.getClient(true)
+	d.client = d.getClient(ClientTypeNormal)
+	d.linkClient = d.getClient(ClientTypeLink)
+	d.directUploadClient = d.getClient(ClientTypeDirectUpload)
 	return nil
 }
 
@@ -210,4 +214,33 @@ func (d *S3) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up
 	return err
 }
 
+func (d *S3) GetDirectUploadTools() []string {
+	if !d.EnableDirectUpload {
+		return nil
+	}
+	return []string{"HttpDirect"}
+}
+
+func (d *S3) GetDirectUploadInfo(ctx context.Context, _ string, dstDir model.Obj, fileName string, _ int64) (any, error) {
+	if !d.EnableDirectUpload {
+		return nil, errs.NotImplement
+	}
+	path := getKey(stdpath.Join(dstDir.GetPath(), fileName), false)
+	req, _ := d.directUploadClient.PutObjectRequest(&s3.PutObjectInput{
+		Bucket: &d.Bucket,
+		Key:    &path,
+	})
+	if req == nil {
+		return nil, fmt.Errorf("failed to create PutObject request")
+	}
+	link, err := req.Presign(time.Hour * time.Duration(d.SignURLExpire))
+	if err != nil {
+		return nil, err
+	}
+	return &model.HttpDirectUploadInfo{
+		UploadURL: link,
+		Method:    "PUT",
+	}, nil
+}
+
 var _ driver.Driver = (*S3)(nil)
diff --git a/drivers/s3/meta.go b/drivers/s3/meta.go
index 59c3bbb0..b2439c68 100644
--- a/drivers/s3/meta.go
+++ b/drivers/s3/meta.go
@@ -21,6 +21,8 @@ type Addition struct {
 	ListObjectVersion        string `json:"list_object_version" type:"select" options:"v1,v2" default:"v1"`
 	RemoveBucket             bool   `json:"remove_bucket" help:"Remove bucket name from path when using custom host."`
 	AddFilenameToDisposition bool   `json:"add_filename_to_disposition" help:"Add filename to Content-Disposition header."`
+	EnableDirectUpload       bool   `json:"enable_direct_upload" default:"false"`
+	DirectUploadHost         string `json:"direct_upload_host" required:"false"`
 }
 
 func init() {
diff --git a/drivers/s3/util.go b/drivers/s3/util.go
index b5897176..8b0fe4b2 100644
--- a/drivers/s3/util.go
+++ b/drivers/s3/util.go
@@ -41,9 +41,15 @@ func (d *S3) initSession() error {
 	return err
 }
 
-func (d *S3) getClient(link bool) *s3.S3 {
+const (
+	ClientTypeNormal = iota
+	ClientTypeLink
+	ClientTypeDirectUpload
+)
+
+func (d *S3) getClient(clientType int) *s3.S3 {
 	client := s3.New(d.Session)
-	if link && d.CustomHost != "" {
+	if clientType == ClientTypeLink && d.CustomHost != "" {
 		client.Handlers.Build.PushBack(func(r *request.Request) {
 			if r.HTTPRequest.Method != http.MethodGet {
 				return
@@ -58,6 +64,20 @@ func (d *S3) getClient(link bool) *s3.S3 {
 			}
 		})
 	}
+	if clientType == ClientTypeDirectUpload && d.DirectUploadHost != "" {
+		client.Handlers.Build.PushBack(func(r *request.Request) {
+			if r.HTTPRequest.Method != http.MethodPut {
+				return
+			}
+			split := strings.SplitN(d.DirectUploadHost, "://", 2)
+			if utils.SliceContains([]string{"http", "https"}, split[0]) {
+				r.HTTPRequest.URL.Scheme = split[0]
+				r.HTTPRequest.URL.Host = split[1]
+			} else {
+				r.HTTPRequest.URL.Host = d.DirectUploadHost
+			}
+		})
+	}
 	return client
 }
 
diff --git a/drivers/strm/driver.go b/drivers/strm/driver.go
index c1b1f042..04716542 100644
--- a/drivers/strm/driver.go
+++ b/drivers/strm/driver.go
@@ -96,7 +96,7 @@ func (d *Strm) Init(ctx context.Context) error {
 		}
 	}
 
-	if d.Version != 3 {
+	if d.Version != 4 {
 		types := strings.Split("mp4,mkv,flv,avi,wmv,ts,rmvb,webm,mp3,flac,aac,wav,ogg,m4a,wma,alac", ",")
 		for _, ext := range types {
 			if _, ok := d.supportSuffix[ext]; !ok {
@@ -109,12 +109,12 @@ func (d *Strm) Init(ctx context.Context) error {
 		types = strings.Split("ass,srt,vtt,sub,strm", ",")
 		for _, ext := range types {
 			if _, ok := d.downloadSuffix[ext]; !ok {
-				d.supportSuffix[ext] = struct{}{}
+				d.downloadSuffix[ext] = struct{}{}
 				downloadTypes = append(downloadTypes, ext)
 			}
 		}
 		d.DownloadFileTypes = strings.Join(downloadTypes, ",")
-		d.Version = 3
+		d.Version = 4
 	}
 	return nil
 }
diff --git a/drivers/strm/util.go b/drivers/strm/util.go
index 54e47a68..a8c090fd 100644
--- a/drivers/strm/util.go
+++ b/drivers/strm/util.go
@@ -3,7 +3,6 @@ package strm
 import (
 	"context"
 	"fmt"
-
 	stdpath "path"
 	"strings"
 
@@ -69,11 +68,12 @@ func (d *Strm) convert2strmObjs(ctx context.Context, reqPath string, objs []mode
 		if !obj.IsDir() {
 			path = stdpath.Join(reqPath, obj.GetName())
 			ext := strings.ToLower(utils.Ext(name))
+			sourceExt := utils.SourceExt(name)
 			if _, ok := d.downloadSuffix[ext]; ok {
 				size = obj.GetSize()
 			} else if _, ok := d.supportSuffix[ext]; ok {
 				id = "strm"
-				name = strings.TrimSuffix(name, ext) + "strm"
+				name = strings.TrimSuffix(name, sourceExt) + "strm"
 				size = int64(len(d.getLink(ctx, path)))
 			} else {
 				continue
diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go
index 58b6b568..6adf90e6 100644
--- a/internal/bootstrap/data/setting.go
+++ b/internal/bootstrap/data/setting.go
@@ -177,6 +177,9 @@ func InitialSettings() []model.SettingItem {
 		{Key: conf.ShareArchivePreview, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC},
 		{Key: conf.ShareForceProxy, Value: "true", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PRIVATE},
 		{Key: conf.ShareSummaryContent, Value: "@{{creator}} shared {{#each files}}{{#if @first}}\"{{filename this}}\"{{/if}}{{#if @last}}{{#unless (eq @index 0)}} and {{@index}} more files{{/unless}}{{/if}}{{/each}} from {{site_title}}: {{base_url}}/@s/{{id}}{{#if pwd}} , the share code is {{pwd}}{{/if}}{{#if expires}}, please access before {{dateLocaleString expires}}.{{/if}}", Type: conf.TypeText, Group: model.GLOBAL, Flag: model.PUBLIC},
+		{Key: conf.HandleHookAfterWriting, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PRIVATE},
+		{Key: conf.HandleHookRateLimit, Value: "0", Type: conf.TypeNumber, Group: model.GLOBAL, Flag: model.PRIVATE},
+		{Key: conf.IgnoreSystemFiles, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PRIVATE, Help: `When enabled, ignores common system files during upload (.DS_Store, desktop.ini, Thumbs.db, and files starting with ._)`},
 
 		// single settings
 		{Key: conf.Token, Value: token, Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE},
diff --git a/internal/conf/const.go b/internal/conf/const.go
index a0b1a157..80337da8 100644
--- a/internal/conf/const.go
+++ b/internal/conf/const.go
@@ -56,6 +56,9 @@ const (
 	ShareArchivePreview     = "share_archive_preview"
 	ShareForceProxy         = "share_force_proxy"
 	ShareSummaryContent     = "share_summary_content"
+	HandleHookAfterWriting  = "handle_hook_after_writing"
+	HandleHookRateLimit     = "handle_hook_rate_limit"
+	IgnoreSystemFiles       = "ignore_system_files"
 
 	// index
 	SearchIndex     = "search_index"
diff --git a/internal/errs/object.go b/internal/errs/object.go
index bddc78ab..ab78f314 100644
--- a/internal/errs/object.go
+++ b/internal/errs/object.go
@@ -11,6 +11,7 @@ var (
 	ObjectAlreadyExists = errors.New("object already exists")
 	NotFolder           = errors.New("not a folder")
 	NotFile             = errors.New("not a file")
+	IgnoredSystemFile   = errors.New("system file upload ignored")
 )
 
 func IsObjectNotFound(err error) bool {
diff --git a/internal/op/archive.go b/internal/op/archive.go
index 2cdcb2b2..05c6249d 100644
--- a/internal/op/archive.go
+++ b/internal/op/archive.go
@@ -11,6 +11,7 @@ import (
 
 	"github.com/OpenListTeam/OpenList/v4/internal/archive/tool"
 	"github.com/OpenListTeam/OpenList/v4/internal/cache"
+	"github.com/OpenListTeam/OpenList/v4/internal/conf"
 	"github.com/OpenListTeam/OpenList/v4/internal/driver"
 	"github.com/OpenListTeam/OpenList/v4/internal/errs"
 	"github.com/OpenListTeam/OpenList/v4/internal/model"
@@ -20,10 +21,13 @@ import (
 	gocache "github.com/OpenListTeam/go-cache"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
+	"golang.org/x/time/rate"
 )
 
-var archiveMetaCache = gocache.NewMemCache(gocache.WithShards[*model.ArchiveMetaProvider](64))
-var archiveMetaG singleflight.Group[*model.ArchiveMetaProvider]
+var (
+	archiveMetaCache = gocache.NewMemCache(gocache.WithShards[*model.ArchiveMetaProvider](64))
+	archiveMetaG     singleflight.Group[*model.ArchiveMetaProvider]
+)
 
 func GetArchiveMeta(ctx context.Context, storage driver.Driver, path string, args model.ArchiveMetaArgs) (*model.ArchiveMetaProvider, error) {
 	if storage.Config().CheckStatus && storage.GetStorage().Status != WORK {
@@ -196,8 +200,10 @@ func getArchiveMeta(ctx context.Context, storage driver.Driver, path string, arg
 	return obj, archiveMetaProvider, err
 }
 
-var archiveListCache = gocache.NewMemCache(gocache.WithShards[[]model.Obj](64))
-var archiveListG singleflight.Group[[]model.Obj]
+var (
+	archiveListCache = gocache.NewMemCache(gocache.WithShards[[]model.Obj](64))
+	archiveListG     singleflight.Group[[]model.Obj]
+)
 
 func ListArchive(ctx context.Context, storage driver.Driver, path string, args model.ArchiveListArgs) ([]model.Obj, error) {
 	if storage.Config().CheckStatus && storage.GetStorage().Status != WORK {
@@ -397,8 +403,10 @@ type objWithLink struct {
 	obj  model.Obj
 }
 
-var extractCache = cache.NewKeyedCache[*objWithLink](5 * time.Minute)
-var extractG = singleflight.Group[*objWithLink]{}
+var (
+	extractCache = cache.NewKeyedCache[*objWithLink](5 * time.Minute)
+	extractG     = singleflight.Group[*objWithLink]{}
+)
 
 func DriverExtract(ctx context.Context, storage driver.Driver, path string, args model.ArchiveInnerArgs) (*model.Link, model.Obj, error) {
 	if storage.Config().CheckStatus && storage.GetStorage().Status != WORK {
@@ -506,9 +514,9 @@ func ArchiveDecompress(ctx context.Context, storage driver.Driver, srcPath, dstD
 		return errors.WithMessage(err, "failed to get dst dir")
 	}
 
+	var newObjs []model.Obj
 	switch s := storage.(type) {
 	case driver.ArchiveDecompressResult:
-		var newObjs []model.Obj
 		newObjs, err = s.ArchiveDecompress(ctx, srcObj, dstDir, args)
 		if err == nil {
 			if len(newObjs) > 0 {
@@ -527,5 +535,31 @@ func ArchiveDecompress(ctx context.Context, storage driver.Driver, srcPath, dstD
 	default:
 		return errs.NotImplement
 	}
+	if !utils.IsBool(lazyCache...) && err == nil && needHandleObjsUpdateHook() {
+		onlyList := false
+		targetPath := dstDirPath
+		if newObjs != nil && len(newObjs) == 1 && newObjs[0].IsDir() {
+			targetPath = stdpath.Join(dstDirPath, newObjs[0].GetName())
+		} else if newObjs != nil && len(newObjs) == 1 && !newObjs[0].IsDir() {
+			onlyList = true
+		} else if args.PutIntoNewDir {
+			targetPath = stdpath.Join(dstDirPath, strings.TrimSuffix(srcObj.GetName(), stdpath.Ext(srcObj.GetName())))
+		} else if innerBase := stdpath.Base(args.InnerPath); innerBase != "." && innerBase != "/" {
+			targetPath = stdpath.Join(dstDirPath, innerBase)
+			dstObj, e := GetUnwrap(ctx, storage, targetPath)
+			onlyList = e != nil || !dstObj.IsDir()
+		}
+		if onlyList {
+			go List(context.Background(), storage, dstDirPath, model.ListArgs{Refresh: true})
+		} else {
+			var limiter *rate.Limiter
+			if l, _ := GetSettingItemByKey(conf.HandleHookRateLimit); l != nil {
+				if f, e := strconv.ParseFloat(l.Value, 64); e == nil && f > .0 {
+					limiter = rate.NewLimiter(rate.Limit(f), 1)
+				}
+			}
+			go RecursivelyListStorage(context.Background(), storage, targetPath, limiter, nil)
+		}
+	}
 	return errors.WithStack(err)
 }
diff --git a/internal/op/fs.go b/internal/op/fs.go
index 98df7039..70b6b702 100644
--- a/internal/op/fs.go
+++ b/internal/op/fs.go
@@ -2,10 +2,11 @@ package op
 
 import (
 	"context"
-	stderrors "errors"
 	stdpath "path"
+	"strconv"
 	"time"
 
+	"github.com/OpenListTeam/OpenList/v4/internal/conf"
 	"github.com/OpenListTeam/OpenList/v4/internal/driver"
 	"github.com/OpenListTeam/OpenList/v4/internal/errs"
 	"github.com/OpenListTeam/OpenList/v4/internal/model"
@@ -14,6 +15,7 @@ import (
 	"github.com/OpenListTeam/OpenList/v4/pkg/utils"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
+	"golang.org/x/time/rate"
 )
 
 var listG singleflight.Group[[]model.Obj]
@@ -173,10 +175,10 @@ func Link(ctx context.Context, storage driver.Driver, path string, args model.Li
 		mode = storage.(driver.LinkCacheModeResolver).ResolveLinkCacheMode(path)
 	}
 	typeKey := args.Type
-	if mode&driver.LinkCacheIP == 1 {
+	if mode&driver.LinkCacheIP == driver.LinkCacheIP {
 		typeKey += "/" + args.IP
 	}
-	if mode&driver.LinkCacheUA == 1 {
+	if mode&driver.LinkCacheUA == driver.LinkCacheUA {
 		typeKey += "/" + args.Header.Get("User-Agent")
 	}
 	key := Key(storage, path)
@@ -310,7 +312,7 @@ func Move(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string
 	srcDirPath := stdpath.Dir(srcPath)
 	dstDirPath = utils.FixAndCleanPath(dstDirPath)
 	if dstDirPath == srcDirPath {
-		return stderrors.New("move in place")
+		return errors.New("move in place")
 	}
 	srcRawObj, err := Get(ctx, storage, srcPath)
 	if err != nil {
@@ -343,8 +345,24 @@ func Move(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string
 			}
 		}
 	default:
-		return errs.NotImplement
+		err = errs.NotImplement
+	}
+
+	if !utils.IsBool(lazyCache...) && err == nil && needHandleObjsUpdateHook() {
+		if !srcObj.IsDir() {
+			go List(context.Background(), storage, dstDirPath, model.ListArgs{Refresh: true})
+		} else {
+			targetPath := stdpath.Join(dstDirPath, srcObj.GetName())
+			var limiter *rate.Limiter
+			if l, _ := GetSettingItemByKey(conf.HandleHookRateLimit); l != nil {
+				if f, e := strconv.ParseFloat(l.Value, 64); e == nil && f > .0 {
+					limiter = rate.NewLimiter(rate.Limit(f), 1)
+				}
+			}
+			go RecursivelyListStorage(context.Background(), storage, targetPath, limiter, nil)
+		}
 	}
+
 	return errors.WithStack(err)
 }
 
@@ -397,7 +415,7 @@ func Copy(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string
 	srcPath = utils.FixAndCleanPath(srcPath)
 	dstDirPath = utils.FixAndCleanPath(dstDirPath)
 	if dstDirPath == stdpath.Dir(srcPath) {
-		return stderrors.New("copy in place")
+		return errors.New("copy in place")
 	}
 	srcRawObj, err := Get(ctx, storage, srcPath)
 	if err != nil {
@@ -428,8 +446,24 @@ func Copy(ctx context.Context, storage driver.Driver, srcPath, dstDirPath string
 			}
 		}
 	default:
-		return errs.NotImplement
+		err = errs.NotImplement
+	}
+
+	if !utils.IsBool(lazyCache...) && err == nil && needHandleObjsUpdateHook() {
+		if !srcObj.IsDir() {
+			go List(context.Background(), storage, dstDirPath, model.ListArgs{Refresh: true})
+		} else {
+			targetPath := stdpath.Join(dstDirPath, srcObj.GetName())
+			var limiter *rate.Limiter
+			if l, _ := GetSettingItemByKey(conf.HandleHookRateLimit); l != nil {
+				if f, e := strconv.ParseFloat(l.Value, 64); e == nil && f > .0 {
+					limiter = rate.NewLimiter(rate.Limit(f), 1)
+				}
+			}
+			go RecursivelyListStorage(context.Background(), storage, targetPath, limiter, nil)
+		}
 	}
+
 	return errors.WithStack(err)
 }
 
@@ -557,6 +591,9 @@ func Put(ctx context.Context, storage driver.Driver, dstDirPath string, file mod
 			err = Remove(ctx, storage, tempPath)
 		}
 	}
+	if !utils.IsBool(lazyCache...) && err == nil && needHandleObjsUpdateHook() {
+		go List(context.Background(), storage, dstDirPath, model.ListArgs{Refresh: true})
+	}
 	return errors.WithStack(err)
 }
 
@@ -601,6 +638,9 @@ func PutURL(ctx context.Context, storage driver.Driver, dstDirPath, dstName, url
 	default:
 		return errors.WithStack(errs.NotImplement)
 	}
+	if !utils.IsBool(lazyCache...) && err == nil && needHandleObjsUpdateHook() {
+		go List(context.Background(), storage, dstDirPath, model.ListArgs{Refresh: true})
+	}
 	log.Debugf("put url [%s](%s) done", dstName, url)
 	return errors.WithStack(err)
 }
@@ -644,3 +684,8 @@ func GetDirectUploadInfo(ctx context.Context, tool string, storage driver.Driver
 	}
 	return info, nil
 }
+
+func needHandleObjsUpdateHook() bool {
+	needHandle, _ := GetSettingItemByKey(conf.HandleHookAfterWriting)
+	return needHandle != nil && (needHandle.Value == "true" || needHandle.Value == "1")
+}
diff --git a/internal/op/recursive_list.go b/internal/op/recursive_list.go
new file mode 100644
index 00000000..de8a27b2
--- /dev/null
+++ b/internal/op/recursive_list.go
@@ -0,0 +1,125 @@
+package op
+
+import (
+	"context"
+	stdpath "path"
+	"sync"
+	"sync/atomic"
+
+	"github.com/OpenListTeam/OpenList/v4/internal/driver"
+	"github.com/OpenListTeam/OpenList/v4/internal/errs"
+	"github.com/OpenListTeam/OpenList/v4/internal/model"
+	"github.com/OpenListTeam/OpenList/v4/pkg/utils"
+	"github.com/pkg/errors"
+	log "github.com/sirupsen/logrus"
+	"golang.org/x/time/rate"
+)
+
+var (
+	ManualScanCancel = atomic.Pointer[context.CancelFunc]{}
+	ScannedCount     = atomic.Uint64{}
+)
+
+func ManualScanRunning() bool {
+	return ManualScanCancel.Load() != nil
+}
+
+func BeginManualScan(rawPath string, limit float64) error {
+	rawPath = utils.FixAndCleanPath(rawPath)
+	ctx, cancel := context.WithCancel(context.Background())
+	if !ManualScanCancel.CompareAndSwap(nil, &cancel) {
+		cancel()
+		return errors.New("manual scan is running, please try later")
+	}
+	ScannedCount.Store(0)
+	go func() {
+		defer func() { (*ManualScanCancel.Swap(nil))() }()
+		err := RecursivelyList(ctx, rawPath, rate.Limit(limit), &ScannedCount)
+		if err != nil {
+			log.Errorf("failed recursively list: %v", err)
+		}
+	}()
+	return nil
+}
+
+func StopManualScan() {
+	c := ManualScanCancel.Load()
+	if c != nil {
+		(*c)()
+	}
+}
+
+func RecursivelyList(ctx context.Context, rawPath string, limit rate.Limit, counter *atomic.Uint64) error {
+	storage, actualPath, err := GetStorageAndActualPath(rawPath)
+	if err != nil && !errors.Is(err, errs.StorageNotFound) {
+		return err
+	} else if err == nil {
+		var limiter *rate.Limiter
+		if limit > .0 {
+			limiter = rate.NewLimiter(limit, 1)
+		}
+		RecursivelyListStorage(ctx, storage, actualPath, limiter, counter)
+	} else {
+		var wg sync.WaitGroup
+		recursivelyListVirtual(ctx, rawPath, limit, counter, &wg)
+		wg.Wait()
+	}
+	return nil
+}
+
+func recursivelyListVirtual(ctx context.Context, rawPath string, limit rate.Limit, counter *atomic.Uint64, wg *sync.WaitGroup) {
+	objs := GetStorageVirtualFilesByPath(rawPath)
+	if counter != nil {
+		counter.Add(uint64(len(objs)))
+	}
+	for _, obj := range objs {
+		if utils.IsCanceled(ctx) {
+			return
+		}
+		nextPath := stdpath.Join(rawPath, obj.GetName())
+		storage, actualPath, err := GetStorageAndActualPath(nextPath)
+		if err != nil && !errors.Is(err, errs.StorageNotFound) {
+			log.Errorf("error recursively list: failed get storage [%s]: %v", nextPath, err)
+		} else if err == nil {
+			var limiter *rate.Limiter
+			if limit > .0 {
+				limiter = rate.NewLimiter(limit, 1)
+			}
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				RecursivelyListStorage(ctx, storage, actualPath, limiter, counter)
+			}()
+		} else {
+			recursivelyListVirtual(ctx, nextPath, limit, counter, wg)
+		}
+	}
+}
+
+func RecursivelyListStorage(ctx context.Context, storage driver.Driver, actualPath string, limiter *rate.Limiter, counter *atomic.Uint64) {
+	objs, err := List(ctx, storage, actualPath, model.ListArgs{Refresh: true})
+	if err != nil {
+		if !errors.Is(err, context.Canceled) {
+			log.Errorf("error recursively list: failed list (%s)[%s]: %v", storage.GetStorage().MountPath, actualPath, err)
+		}
+		return
+	}
+	if counter != nil {
+		counter.Add(uint64(len(objs)))
+	}
+	for _, obj := range objs {
+		if utils.IsCanceled(ctx) {
+			return
+		}
+		if !obj.IsDir() {
+			continue
+		}
+		if limiter != nil {
+			if err = limiter.Wait(ctx); err != nil {
+				return
+			}
+		}
+		nextPath := stdpath.Join(actualPath, obj.GetName())
+		RecursivelyListStorage(ctx, storage, nextPath, limiter, counter)
+	}
+}
diff --git a/internal/op/storage.go b/internal/op/storage.go
index 2bcd4ed7..5ccdf781 100644
--- a/internal/op/storage.go
+++ b/internal/op/storage.go
@@ -358,16 +358,21 @@ func GetStorageVirtualFilesWithDetailsByPath(ctx context.Context, prefix string,
 				DriverName:     d.Config().Name,
 			},
 		}
-		timeoutCtx, cancel := context.WithTimeout(ctx, time.Second)
-		defer cancel()
-		details, err := GetStorageDetails(timeoutCtx, d, refresh)
-		if err != nil {
-			if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.StorageNotInit) {
-				log.Errorf("failed get %s storage details: %+v", d.GetStorage().MountPath, err)
+		resultChan := make(chan *model.StorageDetails, 1)
+		go func(dri driver.Driver) {
+			details, err := GetStorageDetails(ctx, dri, refresh)
+			if err != nil {
+				if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.StorageNotInit) {
+					log.Errorf("failed get %s storage details: %+v", dri.GetStorage().MountPath, err)
+				}
 			}
-			return ret
+			resultChan <- details
+		}(d)
+		select {
+		case r := <-resultChan:
+			ret.StorageDetails = r
+		case <-time.After(time.Second):
 		}
-		ret.StorageDetails = details
 		return ret
 	})
 }
diff --git a/internal/setting/setting.go b/internal/setting/setting.go
index 7ebd5e92..32968ee7 100644
--- a/internal/setting/setting.go
+++ b/internal/setting/setting.go
@@ -28,3 +28,11 @@ func GetInt(key string, defaultVal int) int {
 func GetBool(key string) bool {
 	return GetStr(key) == "true" || GetStr(key) == "1"
 }
+
+func GetFloat(key string, defaultVal float64) float64 {
+	f, err := strconv.ParseFloat(GetStr(key), 64)
+	if err != nil {
+		return defaultVal
+	}
+	return f
+}
diff --git a/internal/task_group/transfer.go b/internal/task_group/transfer.go
index 8c75b77d..b046f0b2 100644
--- a/internal/task_group/transfer.go
+++ b/internal/task_group/transfer.go
@@ -5,11 +5,14 @@ import (
 	"fmt"
 	"path"
 
+	"github.com/OpenListTeam/OpenList/v4/internal/conf"
 	"github.com/OpenListTeam/OpenList/v4/internal/driver"
 	"github.com/OpenListTeam/OpenList/v4/internal/model"
 	"github.com/OpenListTeam/OpenList/v4/internal/op"
+	"github.com/OpenListTeam/OpenList/v4/internal/setting"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
+	"golang.org/x/time/rate"
 )
 
 type SrcPathToRemove string
@@ -27,12 +30,31 @@ func RefreshAndRemove(dstPath string, payloads ...any) {
 	if dstNeedRefresh {
 		op.Cache.DeleteDirectory(dstStorage, dstActualPath)
 	}
+	dstNeedHandleHook := setting.GetBool(conf.HandleHookAfterWriting)
+	dstHandleHookLimit := setting.GetFloat(conf.HandleHookRateLimit, .0)
+	var listLimiter *rate.Limiter
+	if dstNeedRefresh && dstNeedHandleHook && dstHandleHookLimit > .0 {
+		listLimiter = rate.NewLimiter(rate.Limit(dstHandleHookLimit), 1)
+	}
 	var ctx context.Context
 	for _, payload := range payloads {
 		switch p := payload.(type) {
 		case DstPathToRefresh:
 			if dstNeedRefresh {
-				op.Cache.DeleteDirectory(dstStorage, string(p))
+				if dstNeedHandleHook {
+					if ctx == nil {
+						ctx = context.Background()
+					}
+					if listLimiter != nil {
+						_ = listLimiter.Wait(ctx)
+					}
+					_, e := op.List(ctx, dstStorage, string(p), model.ListArgs{Refresh: true})
+					if e != nil {
+						log.Errorf("failed handle objs update hook: %v", e)
+					}
+				} else {
+					op.Cache.DeleteDirectory(dstStorage, string(p))
+				}
 			}
 		case SrcPathToRemove:
 			if ctx == nil {
diff --git a/pkg/utils/file.go b/pkg/utils/file.go
index 1f819edf..dcfd51a2 100644
--- a/pkg/utils/file.go
+++ b/pkg/utils/file.go
@@ -185,3 +185,20 @@ const (
 	GB
 	TB
 )
+
+// IsSystemFile checks if a filename is a common system file that should be ignored
+// Returns true for files like .DS_Store, desktop.ini, Thumbs.db, and Apple Double files (._*)
+func IsSystemFile(filename string) bool {
+	// Common system files
+	switch filename {
+	case ".DS_Store", "desktop.ini", "Thumbs.db":
+		return true
+	}
+	
+	// Apple Double files (._*)
+	if strings.HasPrefix(filename, "._") {
+		return true
+	}
+	
+	return false
+}
diff --git a/pkg/utils/path.go b/pkg/utils/path.go
index 2a104ff5..ec75501e 100644
--- a/pkg/utils/path.go
+++ b/pkg/utils/path.go
@@ -44,11 +44,15 @@ func IsSubPath(path string, subPath string) bool {
 }
 
 func Ext(path string) string {
+	return strings.ToLower(SourceExt(path))
+}
+
+func SourceExt(path string) string {
 	ext := stdpath.Ext(path)
 	if len(ext) > 0 && ext[0] == '.' {
 		ext = ext[1:]
 	}
-	return strings.ToLower(ext)
+	return ext
 }
 
 func EncodePath(path string, all ...bool) string {
diff --git a/server/ftp/fsup.go b/server/ftp/fsup.go
index 34fb36c6..c549a194 100644
--- a/server/ftp/fsup.go
+++ b/server/ftp/fsup.go
@@ -15,7 +15,9 @@ import (
 	"github.com/OpenListTeam/OpenList/v4/internal/fs"
 	"github.com/OpenListTeam/OpenList/v4/internal/model"
 	"github.com/OpenListTeam/OpenList/v4/internal/op"
+	"github.com/OpenListTeam/OpenList/v4/internal/setting"
 	"github.com/OpenListTeam/OpenList/v4/internal/stream"
+	"github.com/OpenListTeam/OpenList/v4/pkg/utils"
 	"github.com/OpenListTeam/OpenList/v4/server/common"
 	ftpserver "github.com/fclairamb/ftpserverlib"
 	"github.com/pkg/errors"
@@ -49,6 +51,11 @@ func OpenUpload(ctx context.Context, path string, trunc bool) (*FileUploadProxy,
 	if err != nil {
 		return nil, err
 	}
+	// Check if system file should be ignored
+	_, name := stdpath.Split(path)
+	if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(name) {
+		return nil, errs.IgnoredSystemFile
+	}
 	tmpFile, err := os.CreateTemp(conf.Conf.TempDir, "file-*")
 	if err != nil {
 		return nil, err
@@ -150,6 +157,11 @@ func OpenUploadWithLength(ctx context.Context, path string, trunc bool, length i
 	if err != nil {
 		return nil, err
 	}
+	// Check if system file should be ignored
+	_, name := stdpath.Split(path)
+	if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(name) {
+		return nil, errs.IgnoredSystemFile
+	}
 	if trunc {
 		_ = fs.Remove(ctx, path)
 	}
diff --git a/server/handles/fsup.go b/server/handles/fsup.go
index 71d9dbae..2da65a16 100644
--- a/server/handles/fsup.go
+++ b/server/handles/fsup.go
@@ -8,8 +8,10 @@ import (
 	"time"
 
 	"github.com/OpenListTeam/OpenList/v4/internal/conf"
+	"github.com/OpenListTeam/OpenList/v4/internal/errs"
 	"github.com/OpenListTeam/OpenList/v4/internal/fs"
 	"github.com/OpenListTeam/OpenList/v4/internal/model"
+	"github.com/OpenListTeam/OpenList/v4/internal/setting"
 	"github.com/OpenListTeam/OpenList/v4/internal/stream"
 	"github.com/OpenListTeam/OpenList/v4/internal/task"
 	"github.com/OpenListTeam/OpenList/v4/pkg/utils"
@@ -28,6 +30,14 @@ func getLastModified(c *gin.Context) time.Time {
 	return lastModified
 }
 
+// shouldIgnoreSystemFile checks if the filename should be ignored based on settings
+func shouldIgnoreSystemFile(filename string) bool {
+	if setting.GetBool(conf.IgnoreSystemFiles) {
+		return utils.IsSystemFile(filename)
+	}
+	return false
+}
+
 func FsStream(c *gin.Context) {
 	defer func() {
 		if n, _ := io.ReadFull(c.Request.Body, []byte{0}); n == 1 {
@@ -56,6 +66,11 @@ func FsStream(c *gin.Context) {
 		}
 	}
 	dir, name := stdpath.Split(path)
+	// Check if system file should be ignored
+	if shouldIgnoreSystemFile(name) {
+		common.ErrorStrResp(c, errs.IgnoredSystemFile.Error(), 403)
+		return
+	}
 	// 如果请求头 Content-Length 和 X-File-Size 都没有，则 size=-1，表示未知大小的流式上传
 	size := c.Request.ContentLength
 	if size < 0 {
@@ -160,6 +175,11 @@ func FsForm(c *gin.Context) {
 	}
 	defer f.Close()
 	dir, name := stdpath.Split(path)
+	// Check if system file should be ignored
+	if shouldIgnoreSystemFile(name) {
+		common.ErrorStrResp(c, errs.IgnoredSystemFile.Error(), 403)
+		return
+	}
 	h := make(map[*utils.HashType]string)
 	if md5 := c.GetHeader("X-File-Md5"); md5 != "" {
 		h[utils.MD5] = md5
diff --git a/server/handles/scan.go b/server/handles/scan.go
new file mode 100644
index 00000000..fc5e80f6
--- /dev/null
+++ b/server/handles/scan.go
@@ -0,0 +1,47 @@
+package handles
+
+import (
+	"github.com/OpenListTeam/OpenList/v4/internal/op"
+	"github.com/OpenListTeam/OpenList/v4/server/common"
+	"github.com/gin-gonic/gin"
+)
+
+type ManualScanReq struct {
+	Path  string  `json:"path"`
+	Limit float64 `json:"limit"`
+}
+
+func StartManualScan(c *gin.Context) {
+	var req ManualScanReq
+	if err := c.ShouldBind(&req); err != nil {
+		common.ErrorResp(c, err, 400)
+		return
+	}
+	if err := op.BeginManualScan(req.Path, req.Limit); err != nil {
+		common.ErrorResp(c, err, 400)
+		return
+	}
+	common.SuccessResp(c)
+}
+
+func StopManualScan(c *gin.Context) {
+	if !op.ManualScanRunning() {
+		common.ErrorStrResp(c, "manual scan is not running", 400)
+		return
+	}
+	op.StopManualScan()
+	common.SuccessResp(c)
+}
+
+type ManualScanResp struct {
+	ObjCount uint64 `json:"obj_count"`
+	IsDone   bool   `json:"is_done"`
+}
+
+func GetManualScanProgress(c *gin.Context) {
+	ret := ManualScanResp{
+		ObjCount: op.ScannedCount.Load(),
+		IsDone:   !op.ManualScanRunning(),
+	}
+	common.SuccessResp(c, ret)
+}
diff --git a/server/handles/storage.go b/server/handles/storage.go
index f648fc36..687c9355 100644
--- a/server/handles/storage.go
+++ b/server/handles/storage.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"errors"
 	"strconv"
-	"sync"
 	"time"
 
 	"github.com/OpenListTeam/OpenList/v4/internal/conf"
@@ -24,9 +23,15 @@ type StorageResp struct {
 	MountDetails *model.StorageDetails `json:"mount_details,omitempty"`
 }
 
-func makeStorageResp(c *gin.Context, storages []model.Storage) []*StorageResp {
+type detailWithIndex struct {
+	idx int
+	val *model.StorageDetails
+}
+
+func makeStorageResp(ctx *gin.Context, storages []model.Storage) []*StorageResp {
 	ret := make([]*StorageResp, len(storages))
-	var wg sync.WaitGroup
+	detailsChan := make(chan detailWithIndex, len(storages))
+	workerCount := 0
 	for i, s := range storages {
 		ret[i] = &StorageResp{
 			Storage:      s,
@@ -43,22 +48,26 @@ func makeStorageResp(c *gin.Context, storages []model.Storage) []*StorageResp {
 		if !ok {
 			continue
 		}
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			ctx, cancel := context.WithTimeout(c, time.Second*3)
-			defer cancel()
-			details, err := op.GetStorageDetails(ctx, d)
-			if err != nil {
-				if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.StorageNotInit) {
-					log.Errorf("failed get %s details: %+v", s.MountPath, err)
+		workerCount++
+		go func(dri driver.Driver, idx int) {
+			details, e := op.GetStorageDetails(ctx, dri)
+			if e != nil {
+				if !errors.Is(e, errs.NotImplement) && !errors.Is(e, errs.StorageNotInit) {
+					log.Errorf("failed get %s details: %+v", dri.GetStorage().MountPath, e)
 				}
-				return
 			}
-			ret[i].MountDetails = details
-		}()
+			detailsChan <- detailWithIndex{idx: idx, val: details}
+		}(d, i)
+	}
+	for workerCount > 0 {
+		select {
+		case r := <-detailsChan:
+			ret[r.idx].MountDetails = r.val
+			workerCount--
+		case <-time.After(time.Second * 3):
+			workerCount = 0
+		}
 	}
-	wg.Wait()
 	return ret
 }
 
diff --git a/server/middlewares/search.go b/server/middlewares/search.go
index 7807a210..dc89a08a 100644
--- a/server/middlewares/search.go
+++ b/server/middlewares/search.go
@@ -11,7 +11,7 @@ import (
 func SearchIndex(c *gin.Context) {
 	mode := setting.GetStr(conf.SearchIndex)
 	if mode == "none" {
-		common.ErrorResp(c, errs.SearchNotAvailable, 500)
+		common.ErrorResp(c, errs.SearchNotAvailable, 404)
 		c.Abort()
 	} else {
 		c.Next()
diff --git a/server/router.go b/server/router.go
index fbf3a918..32e933de 100644
--- a/server/router.go
+++ b/server/router.go
@@ -179,6 +179,11 @@ func admin(g *gin.RouterGroup) {
 	index.POST("/stop", middlewares.SearchIndex, handles.StopIndex)
 	index.POST("/clear", middlewares.SearchIndex, handles.ClearIndex)
 	index.GET("/progress", middlewares.SearchIndex, handles.GetProgress)
+
+	scan := g.Group("/scan")
+	scan.POST("/start", handles.StartManualScan)
+	scan.POST("/stop", handles.StopManualScan)
+	scan.GET("/progress", handles.GetManualScanProgress)
 }
 
 func fsAndShare(g *gin.RouterGroup) {
diff --git a/server/s3/backend.go b/server/s3/backend.go
index ac398699..c0f7aedd 100644
--- a/server/s3/backend.go
+++ b/server/s3/backend.go
@@ -19,6 +19,7 @@ import (
 	"github.com/OpenListTeam/OpenList/v4/internal/fs"
 	"github.com/OpenListTeam/OpenList/v4/internal/model"
 	"github.com/OpenListTeam/OpenList/v4/internal/op"
+	"github.com/OpenListTeam/OpenList/v4/internal/setting"
 	"github.com/OpenListTeam/OpenList/v4/internal/stream"
 	"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
 	"github.com/OpenListTeam/OpenList/v4/pkg/utils"
@@ -286,6 +287,10 @@ func (b *s3Backend) PutObject(
 		Modified: ti,
 		Ctime:    time.Now(),
 	}
+	// Check if system file should be ignored
+	if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(obj.Name) {
+		return result, errs.IgnoredSystemFile
+	}
 	stream := &stream.FileStream{
 		Obj:      &obj,
 		Reader:   input,
diff --git a/server/webdav/webdav.go b/server/webdav/webdav.go
index 4612665b..504c5fc1 100644
--- a/server/webdav/webdav.go
+++ b/server/webdav/webdav.go
@@ -20,6 +20,7 @@ import (
 
 	"github.com/OpenListTeam/OpenList/v4/internal/conf"
 	"github.com/OpenListTeam/OpenList/v4/internal/net"
+	"github.com/OpenListTeam/OpenList/v4/internal/setting"
 	"github.com/OpenListTeam/OpenList/v4/internal/stream"
 
 	"github.com/OpenListTeam/OpenList/v4/internal/errs"
@@ -358,6 +359,10 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int,
 		Modified: h.getModTime(r),
 		Ctime:    h.getCreateTime(r),
 	}
+	// Check if system file should be ignored
+	if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(obj.Name) {
+		return http.StatusForbidden, errs.IgnoredSystemFile
+	}
 	fsStream := &stream.FileStream{
 		Obj:      &obj,
 		Reader:   r.Body,



Test patch:
diff --git a/pkg/utils/file_test.go b/pkg/utils/file_test.go
new file mode 100644
index 00000000..d7a7262f
--- /dev/null
+++ b/pkg/utils/file_test.go
@@ -0,0 +1,42 @@
+package utils
+
+import (
+	"testing"
+)
+
+func TestIsSystemFile(t *testing.T) {
+	testCases := []struct {
+		filename string
+		expected bool
+	}{
+		// System files that should be filtered
+		{".DS_Store", true},
+		{"desktop.ini", true},
+		{"Thumbs.db", true},
+		{"._test.txt", true},
+		{"._", true},
+		{"._somefile", true},
+		{"._folder_name", true},
+		
+		// Regular files that should not be filtered
+		{"test.txt", false},
+		{"file.pdf", false},
+		{"document.docx", false},
+		{".gitignore", false},
+		{".env", false},
+		{"_underscore.txt", false},
+		{"normal_file.txt", false},
+		{"", false},
+		{".hidden", false},
+		{"..special", false},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.filename, func(t *testing.T) {
+			result := IsSystemFile(tc.filename)
+			if result != tc.expected {
+				t.Errorf("IsSystemFile(%q) = %v, want %v", tc.filename, result, tc.expected)
+			}
+		})
+	}
+}