Repository: GopeedLab/gopeed

Issue:
文件名乱码
<img width="542" height="354" alt="Image" src="https://github.com/user-attachments/assets/c2b375f5-3386-484f-b634-9fb7b8276cf9" />


Hints from issue comments:
@w19996: 下载123网盘的文件，文件名也不对

<img width="1052" height="81" alt="Image" src="https://github.com/user-attachments/assets/71bc9a67-d0e6-43f5-a82b-1b5793df293b" />

<img width="879" height="135" alt="Image" src="https://github.com/user-attachments/assets/ad861e83-2f6a-4797-846e-afdc57ae3d2f" />@monkeyWie: 可以提供一个下载链接吗@w19996: > 可以提供一个下载链接吗

乱码的下载链接是在一个论坛里，可能需要注册才能下载：
http://bbs.wuyou.net/forum.php?mod=viewthread&tid=435456

<img width="1174" height="318" alt="Image" src="https://github.com/user-attachments/assets/54898247-11e7-4987-9d45-7d344e9f370b" />

123网盘：
https://www.123912.com/s/BvnZVv-IArmH?pwd=XAFb# 提取码:XAFb

<img width="1438" height="945" alt="Image" src="https://github.com/user-attachments/assets/d0218362-2e42-41ef-86d5-c72931c6b992" />@monkeyWie: @w19996 感谢反馈，那个论坛我注册了也下载不了，积分不够，你可以给我抓个包看看吗，浏览器按F12启动开发者面板，然后网络那一栏看看`content-disposition`响应头，把内容复制过来，类似这样：

<img width="1322" height="540" alt="Image" src="https://github.com/user-attachments/assets/49bb8d71-d5e5-4ccc-89b7-1de69b372f88" />@w19996: > [@w19996](https://github.com/w19996) 感谢反馈，那个论坛我注册了也下载不了，积分不够，你可以给我抓个包看看吗，浏览器按F12启动开发者面板，然后网络那一栏看看`content-disposition`响应头，把内容复制过来，类似这样：
> 
> <img alt="Image" width="1322" height="540" src="https://private-user-images.githubusercontent.com/13160176/519632626-49bb8d71-d5e5-4ccc-89b7-1de69b372f88.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NjQyNDU4NjcsIm5iZiI6MTc2NDI0NTU2NywicGF0aCI6Ii8xMzE2MDE3Ni81MTk2MzI2MjYtNDliYjhkNzEtZDVlNS00Y2NjLTg5YjctMWRlNjliMzcyZjg4LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTExMjclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUxMTI3VDEyMTI0N1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWJmYWVmYjk5MWQ1ZDY1NWRmZWNmZTEwYzcxOTBjYmU2YmU1ZDQ2OTg2MTczZTFlMjljM2M3ZjE3NWQ4YzZmMjgmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.dn1aMWEAaGzn2XAzh1ZTKrCpPh-i9LYxYNElvZDLd48">

<img width="1018" height="534" alt="Image" src="https://github.com/user-attachments/assets/802eafa8-a331-4b8f-a053-8efab9b9565f" />
这种吗，随便找了个，那个链接的不会找@monkeyWie: 可以的，复制文本贴上来@w19996: > 可以的，复制文本贴上来

手机上用小黄鸟抓到了
attachment; filename="???ص?ַ.zip"
16进制：
61 74 74 61 63 68 6D 65 6E 74 3B 20 66 69 6C 65 6E 61 6D 65 3D 22 3F 3F 3F D8 B5 3F D6 B7 2E 7A 69 70 22@w19996: > 可以的，复制文本贴上来

inline; filename="Éî¶ÈÃÀ»¯.gif"

上面那个gif的@monkeyWie: > > 可以的，复制文本贴上来
> 
> 手机上用小黄鸟抓到了 attachment; filename="???ص?ַ.zip" 16进制： 61 74 74 61 63 68 6D 65 6E 74 3B 20 66 69 6C 65 6E 61 6D 65 3D 22 3F 3F 3F D8 B5 3F D6 B7 2E 7A 69 70 22

很关键，有这个可以复现了@monkeyWie: 帮忙测试下这个新包看看是否还乱码
[Gopeed-v1.8.3-windows-amd64.zip](https://github.com/user-attachments/files/23812430/Gopeed-v1.8.3-windows-amd64.zip)@w19996: > 帮忙测试下这个新包看看是否还乱码 [Gopeed-v1.8.3-windows-amd64.zip](https://github.com/user-attachments/files/23812430/Gopeed-v1.8.3-windows-amd64.zip)

不乱了@monkeyWie: ok，等新版本就行了

Solution patch:
diff --git a/go.mod b/go.mod
index a98d899..62dbc0b 100644
--- a/go.mod
+++ b/go.mod
@@ -132,7 +132,7 @@ require (
 	golang.org/x/mod v0.24.0 // indirect
 	golang.org/x/net v0.39.0 // indirect
 	golang.org/x/sys v0.32.0 // indirect
-	golang.org/x/text v0.24.0 // indirect
+	golang.org/x/text v0.24.0
 	golang.org/x/time v0.8.0 // indirect
 	golang.org/x/tools v0.32.0 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go
index b3e9035..3a408a9 100644
--- a/internal/protocol/http/fetcher.go
+++ b/internal/protocol/http/fetcher.go
@@ -18,6 +18,7 @@ import (
 	"strings"
 	"sync"
 	"time"
+	"unicode/utf8"
 
 	"github.com/GopeedLab/gopeed/internal/controller"
 	"github.com/GopeedLab/gopeed/internal/fetcher"
@@ -26,6 +27,7 @@ import (
 	"github.com/GopeedLab/gopeed/pkg/util"
 	"github.com/xiaoqidun/setft"
 	"golang.org/x/sync/errgroup"
+	"golang.org/x/text/encoding/simplifiedchinese"
 )
 
 const (
@@ -164,24 +166,7 @@ func (f *Fetcher) Resolve(req *base.Request) error {
 	}
 	contentDisposition := httpResp.Header.Get(base.HttpHeaderContentDisposition)
 	if contentDisposition != "" {
-		_, params, _ := mime.ParseMediaType(contentDisposition)
-		filename := params["filename"]
-		if filename != "" {
-			// Check if the filename is MIME encoded-word
-			if strings.HasPrefix(filename, "=?") {
-				decoder := new(mime.WordDecoder)
-				filename = strings.Replace(filename, "UTF8", "UTF-8", 1)
-				file.Name, _ = decoder.Decode(filename)
-			} else {
-				file.Name = util.TryUrlQueryUnescape(filename)
-			}
-		} else {
-			substr := "attachment; filename="
-			index := strings.Index(contentDisposition, substr)
-			if index != -1 {
-				file.Name = util.TryUrlQueryUnescape(contentDisposition[index+len(substr):])
-			}
-		}
+		file.Name = parseFilename(contentDisposition)
 	}
 	// get file filePath by URL
 	if file.Name == "" {
@@ -618,12 +603,158 @@ func (f *Fetcher) buildClient() *http.Client {
 	}
 }
 
-func decodeMangledString(mangled string) string {
-	rawBytes := make([]byte, 0, len(mangled)*3)
-	for _, r := range mangled {
-		rawBytes = append(rawBytes, byte(r))
+// parseFilename extracts filename from Content-Disposition header
+// It handles multiple encoding scenarios:
+// 1. RFC 5987/RFC 2231 format: filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip (preferred, checked first)
+// 2. MIME encoded-word: filename="=?UTF-8?B?5rWL6K+VLnppcA==?="
+// 3. URL-encoded: filename="%E6%B5%8B%E8%AF%95.zip"
+// 4. Plain ASCII filename
+//
+// The key fix is checking filename*= first before mime.ParseMediaType, because
+// some servers send Content-Disposition headers with invalid characters that cause
+// mime.ParseMediaType to fail, but the filename*= parameter is still valid.
+func parseFilename(contentDisposition string) string {
+	// First, try to find filename*= (RFC 5987 format, most reliable for non-ASCII)
+	if filename := parseFilenameExtended(contentDisposition); filename != "" {
+		return filename
+	}
+
+	// Try standard MIME parsing for regular filename= parameter
+	_, params, err := mime.ParseMediaType(contentDisposition)
+	if err == nil {
+		if filename := params["filename"]; filename != "" {
+			return decodeFilenameParam(filename)
+		}
+	}
+
+	// Fallback: manual parsing if mime.ParseMediaType fails
+	return parseFilenameFallback(contentDisposition)
+}
+
+// parseFilenameExtended parses RFC 5987/RFC 2231 extended parameter format
+// Format: filename*=charset'language'value (e.g., UTF-8''%E6%B5%8B%E8%AF%95.zip)
+func parseFilenameExtended(cd string) string {
+	// Look for filename*= (case-insensitive)
+	lower := strings.ToLower(cd)
+	idx := strings.Index(lower, "filename*=")
+	if idx == -1 {
+		return ""
+	}
+
+	// Extract the value after filename*=
+	value := cd[idx+len("filename*="):]
+
+	// Find the end of the value (next ; or end of string)
+	if endIdx := strings.Index(value, ";"); endIdx != -1 {
+		value = value[:endIdx]
+	}
+	value = strings.TrimSpace(value)
+
+	// Parse charset'language'encoded-value format
+	// Common format: UTF-8''%E6%B5%8B%E8%AF%95.zip
+	parts := strings.SplitN(value, "''", 2)
+	if len(parts) == 2 {
+		// parts[0] is charset (e.g., "UTF-8")
+		// parts[1] is percent-encoded value
+		decoded, err := url.QueryUnescape(parts[1])
+		if err == nil {
+			return decoded
+		}
+	}
+
+	// Try with single quote delimiter as well (some servers use this)
+	parts = strings.SplitN(value, "'", 3)
+	if len(parts) >= 3 {
+		decoded, err := url.QueryUnescape(parts[2])
+		if err == nil {
+			return decoded
+		}
+	}
+
+	return ""
+}
+
+// decodeFilenameParam decodes a filename parameter value
+// Handles MIME encoded-word, URL encoding, and GBK encoding fallback
+func decodeFilenameParam(filename string) string {
+	// Check if the filename is MIME encoded-word (e.g., =?UTF-8?B?...?=)
+	if strings.HasPrefix(filename, "=?") {
+		decoder := new(mime.WordDecoder)
+		// Some servers use "UTF8" instead of "UTF-8", create a normalized copy
+		normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1)
+		if decoded, err := decoder.Decode(normalizedFilename); err == nil {
+			return decoded
+		}
 	}
-	return string(rawBytes)
+
+	// Try URL decoding
+	decoded := util.TryUrlQueryUnescape(filename)
+
+	// Check if the result is valid UTF-8. If not, try GBK decoding.
+	// This handles the case where Chinese Windows servers send GBK-encoded filenames
+	// which appear as garbled characters (e.g., "下载地址.zip" -> "���ص�ַ.zip")
+	if !utf8.ValidString(decoded) {
+		if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" {
+			return gbkDecoded
+		}
+	}
+
+	return decoded
+}
+
+// gbkDecoder is a reusable GBK decoder for better performance
+var gbkDecoder = simplifiedchinese.GBK.NewDecoder()
+
+// tryDecodeGBK attempts to decode a string as GBK/GB2312/GB18030 encoding
+// Returns empty string if decoding fails or result is not valid UTF-8
+func tryDecodeGBK(s string) string {
+	// GBK uses 1-2 bytes per character. Single-byte chars are 0x00-0x7F (ASCII compatible).
+	// Double-byte chars have first byte 0x81-0xFE and second byte 0x40-0xFE.
+	// Skip if string is empty or all ASCII (valid UTF-8)
+	if len(s) == 0 {
+		return ""
+	}
+
+	// Create a fresh decoder since the transform state may be corrupted
+	decoder := simplifiedchinese.GBK.NewDecoder()
+	decoded, err := decoder.Bytes([]byte(s))
+	if err != nil {
+		return ""
+	}
+	result := string(decoded)
+	if utf8.ValidString(result) {
+		return result
+	}
+	return ""
+}
+
+// parseFilenameFallback manually parses filename= when mime.ParseMediaType fails
+func parseFilenameFallback(cd string) string {
+	// Look for filename= (case-insensitive)
+	lower := strings.ToLower(cd)
+	idx := strings.Index(lower, "filename=")
+	if idx == -1 {
+		return ""
+	}
+
+	// Skip "filename=" prefix
+	value := cd[idx+len("filename="):]
+
+	// Find the end of the value
+	if endIdx := strings.Index(value, ";"); endIdx != -1 {
+		value = value[:endIdx]
+	}
+	value = strings.TrimSpace(value)
+
+	// Remove quotes if present
+	if len(value) >= 2 {
+		if (value[0] == '"' && value[len(value)-1] == '"') ||
+			(value[0] == '\'' && value[len(value)-1] == '\'') {
+			value = value[1 : len(value)-1]
+		}
+	}
+
+	return decodeFilenameParam(value)
 }
 
 type fetcherData struct {



Test patch:
diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go
index 7ccc9ab..c709c46 100644
--- a/internal/protocol/http/fetcher_test.go
+++ b/internal/protocol/http/fetcher_test.go
@@ -42,7 +42,7 @@ func TestFetcher_Resolve(t *testing.T) {
 		Range: false,
 		Files: []*base.FileInfo{
 			{
-				Name: "测试.zip",
+				Name: test.TestChineseFileName,
 				Size: test.BuildSize,
 			},
 		},
@@ -52,7 +52,7 @@ func TestFetcher_Resolve(t *testing.T) {
 		Range: false,
 		Files: []*base.FileInfo{
 			{
-				Name: "测试.zip",
+				Name: test.TestChineseFileName,
 				Size: test.BuildSize,
 			},
 		},
@@ -62,7 +62,7 @@ func TestFetcher_Resolve(t *testing.T) {
 		Range: false,
 		Files: []*base.FileInfo{
 			{
-				Name: "测试.zip",
+				Name: test.TestChineseFileName,
 				Size: 0,
 			},
 		},
@@ -77,6 +77,42 @@ func TestFetcher_Resolve(t *testing.T) {
 			},
 		},
 	}, t)
+	// Test mixed encoding Content-Disposition where mime.ParseMediaType fails
+	// due to invalid characters, but filename*= contains the correct UTF-8 encoded name
+	testResolve(test.StartTestCustomServer, "mixed-encoding", &base.Resource{
+		Size:  test.BuildSize,
+		Range: false,
+		Files: []*base.FileInfo{
+			{
+				Name: test.TestChineseFileName,
+				Size: test.BuildSize,
+			},
+		},
+	}, t)
+	// Test filename*= only (RFC 5987 format)
+	testResolve(test.StartTestCustomServer, "filename-star", &base.Resource{
+		Size:  test.BuildSize,
+		Range: false,
+		Files: []*base.FileInfo{
+			{
+				Name: test.TestChineseFileName,
+				Size: test.BuildSize,
+			},
+		},
+	}, t)
+	// Test GBK-encoded filename (common on Chinese Windows servers)
+	// Before fix: "测试.zip" sent as GBK bytes -> parsed as "²âÊÔ.zip" (garbled)
+	// After fix: correctly decoded back to "测试.zip"
+	testResolve(test.StartTestCustomServer, "gbk-encoded", &base.Resource{
+		Size:  test.BuildSize,
+		Range: false,
+		Files: []*base.FileInfo{
+			{
+				Name: test.TestChineseFileName,
+				Size: test.BuildSize,
+			},
+		},
+	}, t)
 
 	fetcher := buildFetcher()
 	err := fetcher.Resolve(&base.Request{
diff --git a/internal/test/httptest.go b/internal/test/httptest.go
index 9910b5b..3646785 100644
--- a/internal/test/httptest.go
+++ b/internal/test/httptest.go
@@ -6,8 +6,6 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/GopeedLab/gopeed/pkg/base"
-	"github.com/armon/go-socks5"
 	"io"
 	"net"
 	"net/http"
@@ -17,6 +15,10 @@ import (
 	"strings"
 	"sync/atomic"
 	"time"
+
+	"github.com/GopeedLab/gopeed/pkg/base"
+	"github.com/armon/go-socks5"
+	"golang.org/x/text/encoding/simplifiedchinese"
 )
 
 const (
@@ -34,6 +36,10 @@ const (
 	DownloadRename     = "download (1).data"
 	DownloadFile       = Dir + DownloadName
 	DownloadRenameFile = Dir + DownloadRename
+
+	// TestChineseFileName is a common test filename with Chinese characters
+	// Used to test Content-Disposition parsing with various encodings
+	TestChineseFileName = "测试.zip"
 )
 
 func StartTestFileServer() net.Listener {
@@ -103,7 +109,57 @@ func StartTestCustomServer() net.Listener {
 			io.Copy(writer, file)
 		})
 		mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) {
-			writer.Header().Set("Content-Disposition", "attachment; filename=测试.zip")
+			writer.Header().Set("Content-Disposition", "attachment; filename="+TestChineseFileName)
+			writer.Header().Set("Content-Type", "application/octet-stream")
+			writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
+			file, err := os.Open(BuildFile)
+			if err != nil {
+				panic(err)
+			}
+			defer file.Close()
+			io.Copy(writer, file)
+		})
+		// Test endpoint for mixed encoding: filename= with garbled characters (including special chars)
+		// and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails
+		// due to invalid characters like <a> tags in the filename.
+		mux.HandleFunc("/mixed-encoding", func(writer http.ResponseWriter, request *http.Request) {
+			// This simulates a server that sends a garbled filename= with special chars that cause
+			// mime.ParseMediaType to fail, plus a proper filename*=UTF-8''...
+			// The filename*= should be preferred and correctly parsed.
+			writer.Header().Set("Content-Disposition", `attachment;filename="garbled<invalid>chars.zip";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
+			writer.Header().Set("Content-Type", "application/octet-stream")
+			writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
+			file, err := os.Open(BuildFile)
+			if err != nil {
+				panic(err)
+			}
+			defer file.Close()
+			io.Copy(writer, file)
+		})
+		// Test endpoint for filename*= only (RFC 5987 format)
+		mux.HandleFunc("/filename-star", func(writer http.ResponseWriter, request *http.Request) {
+			// URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip
+			writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
+			writer.Header().Set("Content-Type", "application/octet-stream")
+			writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
+			file, err := os.Open(BuildFile)
+			if err != nil {
+				panic(err)
+			}
+			defer file.Close()
+			io.Copy(writer, file)
+		})
+		// Test endpoint for GBK-encoded filename (common on Chinese Windows servers)
+		// This simulates the case where Chinese characters are sent as GBK bytes
+		// which appear as garbled characters when interpreted as UTF-8.
+		// For example, "测试" in GBK is [B2 E2 CA D4] which is invalid UTF-8.
+		// Our fix detects invalid UTF-8 and attempts GBK decoding.
+		mux.HandleFunc("/gbk-encoded", func(writer http.ResponseWriter, request *http.Request) {
+			// Encode TestChineseFileName as GBK
+			gbkEncoder := simplifiedchinese.GBK.NewEncoder()
+			gbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName))
+			// Send GBK bytes directly in filename (simulating broken server behavior)
+			writer.Header().Set("Content-Disposition", `attachment; filename="`+string(gbkBytes)+`"`)
 			writer.Header().Set("Content-Type", "application/octet-stream")
 			writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
 			file, err := os.Open(BuildFile)