Repository: AlistGo/alist (language: go)

Issue:
ftP中文乱码
### Please make sure of the following things

- [ ] I have read the [documentation](https://alist-doc.nn.ci).
- [ ] I'm sure there are no duplicate issues or discussions.
- [ ] I'm sure it's due to `alist` and not something else(such as `Dependencies` or `Operational`).

### Alist Version / Alist 版本

最新版

### Driver used / 使用的存储驱动

FTP

### Describe the bug / 问题描述

FTP文件名为中文时乱码，希望可以加入选字符集encoding的选项

### Reproduction / 复现链接

无            

### Logs / 日志

_No response_


Discussion / hints:
Thanks for opening your first issue here! Be sure to follow the issue template!

Hello @, this issue was closed due to inactive more than 60 days. You can reopen or recreate it if you think it should continue.
太对了，希望能加入编码方式（我甚至打算自己改了）

Fix patch:
diff --git a/drivers/ftp/driver.go b/drivers/ftp/driver.go
index 70fbabdcdcd..05b9e49a91d 100644
--- a/drivers/ftp/driver.go
+++ b/drivers/ftp/driver.go
@@ -39,7 +39,7 @@ func (d *FTP) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]m
 	if err := d.login(); err != nil {
 		return nil, err
 	}
-	entries, err := d.conn.List(dir.GetPath())
+	entries, err := d.conn.List(encode(dir.GetPath(), d.Encoding))
 	if err != nil {
 		return nil, err
 	}
@@ -49,7 +49,7 @@ func (d *FTP) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]m
 			continue
 		}
 		f := model.Object{
-			Name:     entry.Name,
+			Name:     decode(entry.Name, d.Encoding),
 			Size:     int64(entry.Size),
 			Modified: entry.Time,
 			IsFolder: entry.Type == ftp.EntryTypeFolder,
@@ -64,7 +64,7 @@ func (d *FTP) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*m
 		return nil, err
 	}
 
-	r := NewFileReader(d.conn, file.GetPath(), file.GetSize())
+	r := NewFileReader(d.conn, encode(file.GetPath(), d.Encoding), file.GetSize())
 	link := &model.Link{
 		MFile: r,
 	}
@@ -75,21 +75,27 @@ func (d *FTP) MakeDir(ctx context.Context, parentDir model.Obj, dirName string)
 	if err := d.login(); err != nil {
 		return err
 	}
-	return d.conn.MakeDir(stdpath.Join(parentDir.GetPath(), dirName))
+	return d.conn.MakeDir(encode(stdpath.Join(parentDir.GetPath(), dirName), d.Encoding))
 }
 
 func (d *FTP) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
 	if err := d.login(); err != nil {
 		return err
 	}
-	return d.conn.Rename(srcObj.GetPath(), stdpath.Join(dstDir.GetPath(), srcObj.GetName()))
+	return d.conn.Rename(
+		encode(srcObj.GetPath(), d.Encoding),
+		encode(stdpath.Join(dstDir.GetPath(), srcObj.GetName()), d.Encoding),
+	)
 }
 
 func (d *FTP) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
 	if err := d.login(); err != nil {
 		return err
 	}
-	return d.conn.Rename(srcObj.GetPath(), stdpath.Join(stdpath.Dir(srcObj.GetPath()), newName))
+	return d.conn.Rename(
+		encode(srcObj.GetPath(), d.Encoding),
+		encode(stdpath.Join(stdpath.Dir(srcObj.GetPath()), newName), d.Encoding),
+	)
 }
 
 func (d *FTP) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
@@ -100,10 +106,11 @@ func (d *FTP) Remove(ctx context.Context, obj model.Obj) error {
 	if err := d.login(); err != nil {
 		return err
 	}
+	path := encode(obj.GetPath(), d.Encoding)
 	if obj.IsDir() {
-		return d.conn.RemoveDirRecur(obj.GetPath())
+		return d.conn.RemoveDirRecur(path)
 	} else {
-		return d.conn.Delete(obj.GetPath())
+		return d.conn.Delete(path)
 	}
 }
 
@@ -112,7 +119,8 @@ func (d *FTP) Put(ctx context.Context, dstDir model.Obj, stream model.FileStream
 		return err
 	}
 	// TODO: support cancel
-	return d.conn.Stor(stdpath.Join(dstDir.GetPath(), stream.GetName()), stream)
+	path := stdpath.Join(dstDir.GetPath(), stream.GetName())
+	return d.conn.Stor(encode(path, d.Encoding), stream)
 }
 
 var _ driver.Driver = (*FTP)(nil)
diff --git a/drivers/ftp/meta.go b/drivers/ftp/meta.go
index 61d9d4a824e..5652c12e184 100644
--- a/drivers/ftp/meta.go
+++ b/drivers/ftp/meta.go
@@ -3,10 +3,28 @@ package ftp
 import (
 	"github.com/alist-org/alist/v3/internal/driver"
 	"github.com/alist-org/alist/v3/internal/op"
+	"github.com/axgle/mahonia"
 )
 
+func encode(str string, encoding string) string {
+	if encoding == "" {
+		return str
+	}
+	encoder := mahonia.NewEncoder(encoding)
+	return encoder.ConvertString(str)
+}
+
+func decode(str string, encoding string) string {
+	if encoding == "" {
+		return str
+	}
+	decoder := mahonia.NewDecoder(encoding)
+	return decoder.ConvertString(str)
+}
+
 type Addition struct {
 	Address  string `json:"address" required:"true"`
+	Encoding string `json:"encoding" required:"true"`
 	Username string `json:"username" required:"true"`
 	Password string `json:"password" required:"true"`
 	driver.RootPath
diff --git a/go.mod b/go.mod
index 9dd012c9667..a3f9756905f 100644
--- a/go.mod
+++ b/go.mod
@@ -79,6 +79,7 @@ require (
 	github.com/aead/ecdh v0.2.0 // indirect
 	github.com/andreburgaud/crypt2go v1.2.0 // indirect
 	github.com/andybalholm/brotli v1.0.4 // indirect
+	github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/benbjohnson/clock v1.3.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
diff --git a/go.sum b/go.sum
index c4f6832bcf1..acf3dffbb2a 100644
--- a/go.sum
+++ b/go.sum
@@ -36,6 +36,8 @@ github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevB
 github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.50.24 h1:3o2Pg7mOoVL0jv54vWtuafoZqAeEXLhm1tltWA2GcEw=
 github.com/aws/aws-sdk-go v1.50.24/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
+github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394 h1:OYA+5W64v3OgClL+IrOD63t4i/RW7RqrAVl9LTZ9UqQ=
+github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394/go.mod h1:Q8n74mJTIgjX4RBBcHnJ05h//6/k6foqmgE45jTQtxg=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=


Test patch: