mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-06 00:25:43 +00:00
feat(protocol/http, mcpserver, download): add checksum verification, MCP speed tool, pause webhook, and CLI flags (#1542)
This commit is contained in:
1 parent
fa622a4063
commit
6f20790b17
41 files changed
+1852
-25
No files matched your search
+7
-3
@@ -7,9 +7,11 @@ import (
|
||||
)
|
||||
|
||||
type args struct {
|
||||
url string
|
||||
connections *int
|
||||
dir *string
|
||||
url string
|
||||
connections *int
|
||||
dir *string
|
||||
checksum *string
|
||||
checksumAlgo *string
|
||||
}
|
||||
|
||||
func parse() *args {
|
||||
@@ -20,6 +22,8 @@ func parse() *args {
|
||||
var args args
|
||||
args.connections = flag.Int("C", 16, "Concurrent connections.")
|
||||
args.dir = flag.String("D", dir, "Store directory.")
|
||||
args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).")
|
||||
args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.")
|
||||
flag.Parse()
|
||||
t := flag.Args()
|
||||
if len(t) > 0 {
|
||||
|
||||
+17
-2
@@ -19,6 +19,18 @@ func main() {
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
var checksumOpt *http.ChecksumOption
|
||||
if args.checksum != nil && *args.checksum != "" {
|
||||
algo := "md5"
|
||||
if args.checksumAlgo != nil && *args.checksumAlgo != "" {
|
||||
algo = *args.checksumAlgo
|
||||
}
|
||||
checksumOpt = &http.ChecksumOption{
|
||||
Algorithm: algo,
|
||||
Expected: *args.checksum,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := download.Boot().
|
||||
URL(args.url).
|
||||
Listener(func(event *download.Event) {
|
||||
@@ -43,8 +55,11 @@ func main() {
|
||||
}
|
||||
}).
|
||||
Create(&base.Options{
|
||||
Path: *args.dir,
|
||||
Extra: http.OptsExtra{Connections: *args.connections},
|
||||
Path: *args.dir,
|
||||
Extra: http.OptsExtra{
|
||||
Connections: *args.connections,
|
||||
Checksum: checksumOpt,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -2,8 +2,13 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -280,6 +285,12 @@ type Fetcher struct {
|
||||
primaryReadyOnce sync.Once
|
||||
primaryReadyCh chan struct{}
|
||||
|
||||
// checksumFailed marks that the last completed download failed
|
||||
// checksum verification, so the next retry must force a full
|
||||
// re-download instead of skipping connections that already
|
||||
// reported Completed.
|
||||
checksumFailed atomic.Bool
|
||||
|
||||
// Start pending mechanism
|
||||
startPending atomic.Bool
|
||||
resolvedCh chan struct{} // Signal when resolve completes
|
||||
@@ -664,6 +675,22 @@ func (f *Fetcher) cleanupPrefetchFile() {
|
||||
f.resolveFallback.Store(false)
|
||||
}
|
||||
|
||||
// prepareFullRedownload resets all fetcher connection and prefetch state
|
||||
// after a checksum mismatch so the subsequent Start takes the fresh-start path.
|
||||
func (f *Fetcher) prepareFullRedownload() {
|
||||
f.connMu.Lock()
|
||||
if f.meta.Res.Range {
|
||||
f.connections = nil
|
||||
f.resolveConn = nil
|
||||
f.rangeReprobeEligible = false
|
||||
f.rangeValidatorPinned = false
|
||||
}
|
||||
f.connMu.Unlock()
|
||||
|
||||
f.cleanupPrefetchFile()
|
||||
f.resolveDataPos.Store(0)
|
||||
}
|
||||
|
||||
// Options may be replaced between Resolve and Start when creating a resolved task.
|
||||
func (f *Fetcher) initOptions() error {
|
||||
opts := f.meta.Opts
|
||||
@@ -681,6 +708,9 @@ func (f *Fetcher) initOptions() error {
|
||||
extra.Connections = 1
|
||||
}
|
||||
}
|
||||
if err := extra.Checksum.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -735,11 +765,26 @@ func (f *Fetcher) doStart() error {
|
||||
|
||||
// If retrying after error, reset connection states for retry
|
||||
if state == stateError {
|
||||
fullRedownload := f.checksumFailed.Swap(false)
|
||||
if fullRedownload {
|
||||
// Checksum mismatch: the file content itself is wrong even
|
||||
// though every connection reported Completed. Truncate the
|
||||
// corrupt file so stale bytes cannot linger if the retry is
|
||||
// interrupted before every connection re-writes its chunk.
|
||||
if err := os.Truncate(f.meta.SingleFilepath(), 0); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to reset file after checksum mismatch: %w", err)
|
||||
}
|
||||
f.prepareFullRedownload()
|
||||
}
|
||||
f.connMu.Lock()
|
||||
for _, conn := range f.connections {
|
||||
// Reset connections that can be retried
|
||||
if !conn.Completed && conn.State != connCompleted {
|
||||
if !f.hasSequentialPrefixLocked(conn) {
|
||||
// On a normal error, only reset connections that didn't
|
||||
// finish. On a checksum mismatch, reset EVERY connection —
|
||||
// "Completed" only means the download loop finished, not
|
||||
// that the bytes were verified correct.
|
||||
shouldReset := fullRedownload || (!conn.Completed && conn.State != connCompleted)
|
||||
if shouldReset {
|
||||
if fullRedownload || !f.hasSequentialPrefixLocked(conn) {
|
||||
f.resetConnectionForRestart(conn)
|
||||
}
|
||||
conn.State = connNotStarted
|
||||
@@ -970,8 +1015,14 @@ func (f *Fetcher) expandConnections() {
|
||||
}
|
||||
f.fileMu.Unlock()
|
||||
|
||||
f.setState(stateDone)
|
||||
f.doneCh <- nil
|
||||
var err error
|
||||
if chkErr := f.verifyChecksum(); chkErr != nil {
|
||||
err = chkErr
|
||||
f.setState(stateError)
|
||||
} else {
|
||||
f.setState(stateDone)
|
||||
}
|
||||
f.doneCh <- err
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2067,7 +2118,7 @@ func (f *Fetcher) resetConnectionForRestart(conn *connection) {
|
||||
// so pause/retry must restart instead of continuing from the old offset.
|
||||
if conn.Chunk == nil {
|
||||
conn.Chunk = newChunk(0, 0)
|
||||
} else {
|
||||
} else if !f.meta.Res.Range {
|
||||
conn.Chunk.Begin = 0
|
||||
conn.Chunk.setEnd(0)
|
||||
conn.Chunk.Downloaded = 0
|
||||
@@ -2238,10 +2289,15 @@ func (f *Fetcher) onDownloadComplete() {
|
||||
}
|
||||
f.fileMu.Unlock()
|
||||
|
||||
if finalErr != nil {
|
||||
f.setState(stateError)
|
||||
if finalErr == nil {
|
||||
if chkErr := f.verifyChecksum(); chkErr != nil {
|
||||
finalErr = chkErr
|
||||
f.setState(stateError)
|
||||
} else {
|
||||
f.setState(stateDone)
|
||||
}
|
||||
} else {
|
||||
f.setState(stateDone)
|
||||
f.setState(stateError)
|
||||
}
|
||||
|
||||
select {
|
||||
@@ -2250,6 +2306,51 @@ func (f *Fetcher) onDownloadComplete() {
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) verifyChecksum() error {
|
||||
if f.meta == nil || f.meta.Opts == nil || f.meta.Opts.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
extra, ok := f.meta.Opts.Extra.(*fhttp.OptsExtra)
|
||||
if !ok || extra.Checksum == nil {
|
||||
return nil
|
||||
}
|
||||
chk := extra.Checksum
|
||||
if chk.Algorithm == "" && chk.Expected == "" {
|
||||
return nil
|
||||
}
|
||||
var h hash.Hash
|
||||
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
|
||||
case "md5":
|
||||
h = md5.New()
|
||||
case "sha1", "sha-1":
|
||||
h = sha1.New()
|
||||
case "sha256", "sha-256":
|
||||
h = sha256.New()
|
||||
default:
|
||||
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
|
||||
}
|
||||
|
||||
filePath := f.meta.SingleFilepath()
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open file for checksum verification failed: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := make([]byte, 64*1024)
|
||||
if _, err := io.CopyBuffer(h, file, buf); err != nil {
|
||||
return fmt.Errorf("calculate checksum failed: %w", err)
|
||||
}
|
||||
|
||||
actual := hex.EncodeToString(h.Sum(nil))
|
||||
expected := strings.ToLower(strings.TrimSpace(chk.Expected))
|
||||
if !strings.EqualFold(actual, expected) {
|
||||
f.checksumFailed.Store(true)
|
||||
return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) checkCompletion() bool {
|
||||
// Check if all data has been downloaded
|
||||
f.connMu.Lock()
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
gohttp "net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
// serveRangeContent serves byte range requests or full responses from data.
|
||||
func serveRangeContent(w gohttp.ResponseWriter, r *gohttp.Request, data []byte) {
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
rangeHdr := r.Header.Get(base.HttpHeaderRange)
|
||||
total := int64(len(data))
|
||||
if rangeHdr == "" {
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", total))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse bytes=start-end or bytes=start-
|
||||
if !strings.HasPrefix(rangeHdr, "bytes=") {
|
||||
w.WriteHeader(gohttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
spec := strings.TrimPrefix(rangeHdr, "bytes=")
|
||||
parts := strings.Split(spec, "-")
|
||||
start, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || start < 0 || start >= total {
|
||||
w.WriteHeader(gohttp.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
end := total - 1
|
||||
if len(parts) > 1 && parts[1] != "" {
|
||||
if parsedEnd, err := strconv.ParseInt(parts[1], 10, 64); err == nil && parsedEnd >= start && parsedEnd < total {
|
||||
end = parsedEnd
|
||||
}
|
||||
}
|
||||
|
||||
contentLength := end - start + 1
|
||||
w.Header().Set(base.HttpHeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, end, total))
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", contentLength))
|
||||
w.WriteHeader(gohttp.StatusPartialContent)
|
||||
_, _ = w.Write(data[start : end+1])
|
||||
}
|
||||
|
||||
func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
|
||||
corruptContent := []byte("Corrupt Initial Download Data 33B")
|
||||
correctContent := []byte("Correct Retried Download Data 33B")
|
||||
|
||||
correctSha256Hasher := sha256.New()
|
||||
correctSha256Hasher.Write(correctContent)
|
||||
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
||||
|
||||
var phase atomic.Int32 // 0: serve corruptContent, 1: serve correctContent
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
var served []byte
|
||||
if phase.Load() == 0 {
|
||||
served = corruptContent
|
||||
} else {
|
||||
served = correctContent
|
||||
}
|
||||
serveRangeContent(w, r, served)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
fileName := "test_checksum_retry.txt"
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: downloadDir,
|
||||
Name: fileName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: correctSha256,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 1. Initial attempt -> will download corruptContent, failing checksum verification
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
err := f.Wait()
|
||||
if err == nil {
|
||||
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
|
||||
}
|
||||
if f.getState() != stateError {
|
||||
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
|
||||
}
|
||||
|
||||
// Flip phase to 1 after observing checksum mismatch
|
||||
phase.Store(1)
|
||||
|
||||
// 2. Retry download -> should trigger forced full reset, truncate file, and re-download correctContent
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error on retry: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error on retry (expected success): %v", err)
|
||||
}
|
||||
if f.getState() != stateDone {
|
||||
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
|
||||
}
|
||||
|
||||
// 3. Verify on-disk file content matches the re-downloaded bytes
|
||||
filePath := f.meta.SingleFilepath()
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read completed file: %v", err)
|
||||
}
|
||||
if string(fileBytes) != string(correctContent) {
|
||||
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
|
||||
}
|
||||
|
||||
// 4. Compute hash independently and verify
|
||||
actualHasher := sha256.New()
|
||||
actualHasher.Write(fileBytes)
|
||||
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
|
||||
if actualHash != correctSha256 {
|
||||
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_ChecksumMismatch_Retry_PartialPrefetch(t *testing.T) {
|
||||
corruptContent := []byte("Corrupt Initial Download Data 33B")
|
||||
correctContent := []byte("Correct Retried Download Data 33B")
|
||||
|
||||
correctSha256Hasher := sha256.New()
|
||||
correctSha256Hasher.Write(correctContent)
|
||||
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
||||
|
||||
var phase atomic.Int32 // 0: corrupt, 1: correct
|
||||
var isFirstRequest atomic.Bool
|
||||
isFirstRequest.Store(true)
|
||||
|
||||
releasePrefetch := make(chan struct{})
|
||||
var closeReleaseOnce sync.Once
|
||||
|
||||
var mu sync.Mutex
|
||||
var phase1Ranges []string
|
||||
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
curPhase := phase.Load()
|
||||
if curPhase == 1 {
|
||||
mu.Lock()
|
||||
phase1Ranges = append(phase1Ranges, r.Header.Get(base.HttpHeaderRange))
|
||||
mu.Unlock()
|
||||
serveRangeContent(w, r, correctContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 0: First request is Resolve GET without Range header.
|
||||
// Write exactly 7 bytes and flush, then block until release channel closes or context is done.
|
||||
if isFirstRequest.CompareAndSwap(true, false) {
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(corruptContent)))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(corruptContent[:7])
|
||||
if flusher, ok := w.(gohttp.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
select {
|
||||
case <-releasePrefetch:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests in Phase 0 (such as Range request from Start)
|
||||
serveRangeContent(w, r, corruptContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
defer closeReleaseOnce.Do(func() { close(releasePrefetch) })
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
fileName := "test_checksum_prefetch_retry.txt"
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: downloadDir,
|
||||
Name: fileName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: correctSha256,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 1. Resolve phase
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/prefetch_retry.txt"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
// Wait until prefetch has received exactly 7 bytes
|
||||
for i := 0; i < 200; i++ {
|
||||
if f.prefetchSize.Load() >= 7 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if f.prefetchSize.Load() < 7 {
|
||||
t.Fatalf("expected at least 7 prefetched bytes, got %d", f.prefetchSize.Load())
|
||||
}
|
||||
|
||||
// Release prefetch handler right before Start
|
||||
closeReleaseOnce.Do(func() { close(releasePrefetch) })
|
||||
|
||||
// 2. Start initial download attempt (will complete with corruptContent and fail checksum)
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
err := f.Wait()
|
||||
if err == nil {
|
||||
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
|
||||
}
|
||||
if f.getState() != stateError {
|
||||
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
|
||||
}
|
||||
|
||||
// 3. Flip to phase 1 (correctContent) and retry
|
||||
phase.Store(1)
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error on retry: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error on retry (expected success): %v", err)
|
||||
}
|
||||
if f.getState() != stateDone {
|
||||
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
|
||||
}
|
||||
|
||||
// Verify that in Phase 1 at least one Range request started at byte 0
|
||||
mu.Lock()
|
||||
rangesSeen := append([]string(nil), phase1Ranges...)
|
||||
mu.Unlock()
|
||||
|
||||
hasByteZero := false
|
||||
for _, r := range rangesSeen {
|
||||
if strings.HasPrefix(r, "bytes=0-") {
|
||||
hasByteZero = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasByteZero {
|
||||
t.Fatalf("expected at least one Phase 1 Range request starting at byte 0, saw: %v", rangesSeen)
|
||||
}
|
||||
|
||||
// Verify on-disk content matches correctContent exactly
|
||||
filePath := f.meta.SingleFilepath()
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read completed file: %v", err)
|
||||
}
|
||||
if string(fileBytes) != string(correctContent) {
|
||||
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
|
||||
}
|
||||
|
||||
// Compute hash independently and verify
|
||||
actualHasher := sha256.New()
|
||||
actualHasher.Write(fileBytes)
|
||||
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
|
||||
if actualHash != correctSha256 {
|
||||
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_ChecksumMismatch_Retry_NonRange(t *testing.T) {
|
||||
corruptContent := []byte("Corrupt Non-Range Initial Data 33B")
|
||||
correctContent := []byte("Correct Non-Range Retried Data 33B")
|
||||
|
||||
correctSha256Hasher := sha256.New()
|
||||
correctSha256Hasher.Write(correctContent)
|
||||
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
||||
|
||||
var phase atomic.Int32 // 0: corrupt, 1: correct
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
// Server does not support Range (always returns full content 200 OK)
|
||||
var served []byte
|
||||
if phase.Load() == 0 {
|
||||
served = corruptContent
|
||||
} else {
|
||||
served = correctContent
|
||||
}
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(served)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
fileName := "test_checksum_nonrange_retry.txt"
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: downloadDir,
|
||||
Name: fileName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: correctSha256,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 1. Resolve and start initial attempt
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/nonrange_retry.txt"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
err := f.Wait()
|
||||
if err == nil {
|
||||
t.Fatal("expected checksum mismatch error on initial non-range attempt, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
|
||||
}
|
||||
if f.getState() != stateError {
|
||||
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
|
||||
}
|
||||
|
||||
// 2. Switch phase and retry
|
||||
phase.Store(1)
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error on non-range retry: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error on non-range retry (expected success): %v", err)
|
||||
}
|
||||
if f.getState() != stateDone {
|
||||
t.Fatalf("expected stateDone after successful non-range retry, got %v", f.getState())
|
||||
}
|
||||
|
||||
// 3. Verify on-disk file content
|
||||
filePath := f.meta.SingleFilepath()
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read completed file: %v", err)
|
||||
}
|
||||
if string(fileBytes) != string(correctContent) {
|
||||
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
|
||||
}
|
||||
|
||||
// 4. Compute hash independently and verify
|
||||
actualHasher := sha256.New()
|
||||
actualHasher.Write(fileBytes)
|
||||
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
|
||||
if actualHash != correctSha256 {
|
||||
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
gohttp "net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
content := []byte("Hello, Gopeed Checksum Verification Test Data!")
|
||||
md5Hasher := md5.New()
|
||||
md5Hasher.Write(content)
|
||||
correctMd5 := hex.EncodeToString(md5Hasher.Sum(nil))
|
||||
|
||||
sha256Hasher := sha256.New()
|
||||
sha256Hasher.Write(content)
|
||||
correctSha256 := hex.EncodeToString(sha256Hasher.Sum(nil))
|
||||
|
||||
wrongHash := "0123456789abcdef0123456789abcdef"
|
||||
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(content)))
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(content)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Run("Valid MD5", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_valid_md5.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "md5",
|
||||
Expected: correctMd5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error (expected success): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Valid SHA256", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_valid_sha256.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: correctSha256,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error (expected success): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrong MD5 mismatch", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_wrong_md5.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "md5",
|
||||
Expected: wrongHash,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
err := f.Wait()
|
||||
if err == nil {
|
||||
t.Fatal("Wait expected checksum mismatch error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unsupported Algorithm", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_unsupported.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "invalid-algo",
|
||||
Expected: wrongHash,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts)
|
||||
if err == nil {
|
||||
t.Fatal("Resolve expected error for unsupported algorithm, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported checksum algorithm") {
|
||||
t.Fatalf("expected 'unsupported checksum algorithm' in error, got %v", err)
|
||||
}
|
||||
if f.checksumFailed.Load() {
|
||||
t.Fatal("checksumFailed flag should NOT be set on unsupported algorithm error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Case-Insensitive Hash Comparison", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_case_insensitive.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: strings.ToUpper(correctSha256),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error (expected success with upper case hash): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Empty Expected Checksum (Skipped)", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_empty_expected.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "",
|
||||
Expected: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error (expected skip and success): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Large File Multi-Chunk Streaming (>64KB)", func(t *testing.T) {
|
||||
largeData := make([]byte, 128*1024)
|
||||
for i := range largeData {
|
||||
largeData[i] = byte(i % 256)
|
||||
}
|
||||
largeSha256Hasher := sha256.New()
|
||||
largeSha256Hasher.Write(largeData)
|
||||
largeSha256 := hex.EncodeToString(largeSha256Hasher.Sum(nil))
|
||||
|
||||
largeServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(largeData)))
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(largeData)
|
||||
}))
|
||||
defer largeServer.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_large_file.bin",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: largeSha256,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: largeServer.URL + "/large.bin"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error (expected large file checksum success): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No Checksum (Opt-in backward compatibility)", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: test.DownloadName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid Checksum In Resolve Makes Zero Requests", func(t *testing.T) {
|
||||
var reqReceived atomic.Int32
|
||||
customServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
reqReceived.Add(1)
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write([]byte("some data"))
|
||||
}))
|
||||
defer customServer.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_invalid_checksum.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: "invalid-hex-chars-not-valid-hex-00000000000000000000000000000000",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := f.Resolve(&base.Request{URL: customServer.URL + "/test.txt"}, opts)
|
||||
if err == nil {
|
||||
t.Fatal("Resolve expected error for invalid checksum hash, got nil")
|
||||
}
|
||||
if reqReceived.Load() != 0 {
|
||||
t.Fatalf("expected 0 requests to server on invalid checksum in Resolve, got %d", reqReceived.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1912,6 +1912,7 @@ func (d *Downloader) doPause(task *Task) (err error) {
|
||||
}
|
||||
if handled {
|
||||
d.emit(EventKeyPause, task)
|
||||
d.triggerWebhooks(WebhookEventDownloadPause, task, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ type ResolveResult struct {
|
||||
Res *base.Resource `json:"res"`
|
||||
}
|
||||
|
||||
// Task represents a download unit.
|
||||
// Options flow path:
|
||||
// REST Request (model.CreateTask/ResolveTask) -> pkg/download Downloader -> Task.Meta.Opts (base.Options) -> Fetcher.Resolve/Start (e.g. internal/protocol/http/fetcher.go)
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
|
||||
@@ -21,6 +21,7 @@ type WebhookEvent string
|
||||
const (
|
||||
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
|
||||
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
|
||||
WebhookEventDownloadPause WebhookEvent = "DOWNLOAD_PAUSE"
|
||||
)
|
||||
|
||||
// WebhookData is the data sent to webhook URLs
|
||||
|
||||
@@ -814,6 +814,49 @@ func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TriggerOnPause(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
downloader.triggerWebhooks(WebhookEventDownloadPause, task, nil)
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
if data.Event != WebhookEventDownloadPause {
|
||||
t.Errorf("Expected event 'DOWNLOAD_PAUSE', got '%s'", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload.task to be present")
|
||||
} else if data.Payload.Task.ID != task.ID {
|
||||
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
|
||||
defaultDownloader.Setup()
|
||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||
|
||||
+67
-7
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
phttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
@@ -65,12 +66,13 @@ type btRequestExtra struct {
|
||||
}
|
||||
|
||||
type httpTaskExtra struct {
|
||||
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
|
||||
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
|
||||
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
|
||||
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
|
||||
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
|
||||
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
|
||||
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
|
||||
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
|
||||
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
|
||||
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
|
||||
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
|
||||
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
|
||||
Checksum *checksumOption `json:"checksum,omitempty" jsonschema:"Optional checksum verification settings"`
|
||||
}
|
||||
|
||||
// downloadRequest is the MCP-facing form of base.Request. The REST model uses
|
||||
@@ -129,6 +131,11 @@ func isBitTorrentRequest(rawURL string) bool {
|
||||
strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,")
|
||||
}
|
||||
|
||||
type checksumOption struct {
|
||||
Algorithm string `json:"algorithm" jsonschema:"Checksum algorithm: md5, sha1, or sha256"`
|
||||
Expected string `json:"expected" jsonschema:"Expected hex-encoded checksum hash"`
|
||||
}
|
||||
|
||||
// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently
|
||||
// defines protocol-specific task options, so Extra has one concrete schema.
|
||||
type downloadOptions struct {
|
||||
@@ -186,12 +193,31 @@ func (o *downloadOptions) baseOptions() *base.Options {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
var extra any
|
||||
if o.Extra != nil {
|
||||
var checksum *phttp.ChecksumOption
|
||||
if o.Extra.Checksum != nil {
|
||||
checksum = &phttp.ChecksumOption{
|
||||
Algorithm: o.Extra.Checksum.Algorithm,
|
||||
Expected: o.Extra.Checksum.Expected,
|
||||
}
|
||||
}
|
||||
extra = &phttp.OptsExtra{
|
||||
Connections: o.Extra.Connections,
|
||||
AutoTorrent: o.Extra.AutoTorrent,
|
||||
DeleteTorrentAfterDownload: o.Extra.DeleteTorrentAfterDownload,
|
||||
AutoExtract: o.Extra.AutoExtract,
|
||||
ArchivePassword: o.Extra.ArchivePassword,
|
||||
DeleteAfterExtract: o.Extra.DeleteAfterExtract,
|
||||
Checksum: checksum,
|
||||
}
|
||||
}
|
||||
return &base.Options{
|
||||
Name: o.Name,
|
||||
Path: o.Path,
|
||||
AsDefaultPath: o.AsDefaultPath,
|
||||
SelectFiles: o.SelectFiles,
|
||||
Extra: o.Extra,
|
||||
Extra: extra,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +296,13 @@ type getTaskStatsOutput struct {
|
||||
Stats any `json:"stats"`
|
||||
}
|
||||
|
||||
type getDownloadSpeedInput struct{}
|
||||
|
||||
type getDownloadSpeedOutput struct {
|
||||
TotalSpeed int64 `json:"totalSpeed"`
|
||||
ActiveTaskCount int `json:"activeTaskCount"`
|
||||
}
|
||||
|
||||
type taskActionOutput struct {
|
||||
ID string `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
@@ -416,6 +449,33 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
|
||||
return nil, &getTaskStatsOutput{Stats: stats}, nil
|
||||
})
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_download_speed",
|
||||
Title: "Get download speed",
|
||||
Description: "Returns the combined download speed across all currently active tasks and how many tasks are actively downloading. Use get_task_status instead if you need the speed, progress, or ETA of one specific task.",
|
||||
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
|
||||
}, func(_ context.Context, _ *mcp.CallToolRequest, _ *getDownloadSpeedInput) (*mcp.CallToolResult, *getDownloadSpeedOutput, error) {
|
||||
allTasks := downloader.GetTasks()
|
||||
var totalSpeed int64
|
||||
activeCount := 0
|
||||
|
||||
for _, task := range allTasks {
|
||||
status, err := downloader.RuntimeStatus(task.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if status.Status == base.DownloadStatusRunning {
|
||||
activeCount++
|
||||
totalSpeed += status.Speed
|
||||
}
|
||||
}
|
||||
|
||||
return nil, &getDownloadSpeedOutput{
|
||||
TotalSpeed: totalSpeed,
|
||||
ActiveTaskCount: activeCount,
|
||||
}, nil
|
||||
})
|
||||
|
||||
registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause)
|
||||
registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue)
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ func TestStreamableHTTPTools(t *testing.T) {
|
||||
"continue_task",
|
||||
"create_task",
|
||||
"delete_task",
|
||||
"get_download_speed",
|
||||
"get_task",
|
||||
"get_task_stats",
|
||||
"get_task_status",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package mcpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func TestGetDownloadSpeedTool(t *testing.T) {
|
||||
// Serve a test payload that takes a little bit of time or streams bytes
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "1048576")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
chunk := make([]byte, 8192)
|
||||
for i := 0; i < 128; i++ {
|
||||
_, _ = w.Write(chunk)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}))
|
||||
defer testServer.Close()
|
||||
|
||||
downloader := download.NewDownloader(&download.DownloaderConfig{
|
||||
Storage: download.NewMemStorage(),
|
||||
StorageDir: t.TempDir(),
|
||||
})
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer downloader.Close()
|
||||
|
||||
httpServer := httptest.NewServer(NewHandler(downloader))
|
||||
defer httpServer.Close()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "gopeed-test", Version: "1.0.0"}, nil)
|
||||
session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// 1. Zero tasks test (returns 0 totalSpeed and 0 activeTaskCount)
|
||||
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "get_download_speed",
|
||||
Arguments: map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("get_download_speed returned error: %+v", result.Content)
|
||||
}
|
||||
structured, ok := result.StructuredContent.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("structured content type = %T", result.StructuredContent)
|
||||
}
|
||||
if activeCount, ok := structured["activeTaskCount"].(float64); !ok || activeCount != 0 {
|
||||
t.Fatalf("activeTaskCount = %v, want 0", structured["activeTaskCount"])
|
||||
}
|
||||
if totalSpeed, ok := structured["totalSpeed"].(float64); !ok || totalSpeed != 0 {
|
||||
t.Fatalf("totalSpeed = %v, want 0", structured["totalSpeed"])
|
||||
}
|
||||
|
||||
// 2. Start a real download task
|
||||
taskID, err := downloader.CreateDirect(&base.Request{
|
||||
URL: testServer.URL,
|
||||
}, &base.Options{
|
||||
Path: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = downloader.Delete(&download.TaskFilter{IDs: []string{taskID}}, true)
|
||||
}()
|
||||
|
||||
// Query get_download_speed while downloading
|
||||
result, err = session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "get_download_speed",
|
||||
Arguments: map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("get_download_speed returned error: %+v", result.Content)
|
||||
}
|
||||
structured, ok = result.StructuredContent.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("structured content type = %T", result.StructuredContent)
|
||||
}
|
||||
if _, ok := structured["activeTaskCount"].(float64); !ok {
|
||||
t.Fatalf("activeTaskCount missing or wrong type: %v", structured["activeTaskCount"])
|
||||
}
|
||||
if _, ok := structured["totalSpeed"].(float64); !ok {
|
||||
t.Fatalf("totalSpeed missing or wrong type: %v", structured["totalSpeed"])
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,83 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ReqExtra struct {
|
||||
Method string `json:"method"`
|
||||
Header map[string]string `json:"header"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// ChecksumOption specifies an expected checksum to verify against
|
||||
// the completed download. Only supported for HTTP downloads.
|
||||
type ChecksumOption struct {
|
||||
Algorithm string `json:"algorithm"` // "md5" | "sha1" | "sha256"
|
||||
Expected string `json:"expected"` // hex-encoded expected hash
|
||||
}
|
||||
|
||||
// IsEmpty returns true when chk is nil, or when both Algorithm and Expected
|
||||
// are blank after trimming whitespace.
|
||||
func (chk *ChecksumOption) IsEmpty() bool {
|
||||
if chk == nil {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(chk.Algorithm) == "" && strings.TrimSpace(chk.Expected) == ""
|
||||
}
|
||||
|
||||
// NormalizedAlgorithm returns the canonical lower-cased algorithm ("md5", "sha1", "sha256")
|
||||
// and true if recognized, or ("", false) if unsupported.
|
||||
func (chk *ChecksumOption) NormalizedAlgorithm() (string, bool) {
|
||||
if chk == nil {
|
||||
return "", false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
|
||||
case "md5":
|
||||
return "md5", true
|
||||
case "sha1", "sha-1":
|
||||
return "sha1", true
|
||||
case "sha256", "sha-256":
|
||||
return "sha256", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks whether the checksum option is valid.
|
||||
// Empty options return nil (no verification).
|
||||
func (chk *ChecksumOption) Validate() error {
|
||||
if chk.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
algo, ok := chk.NormalizedAlgorithm()
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
|
||||
}
|
||||
expected := strings.TrimSpace(chk.Expected)
|
||||
if expected == "" {
|
||||
return fmt.Errorf("checksum expected hash is required when algorithm is set")
|
||||
}
|
||||
var expectedLen int
|
||||
switch algo {
|
||||
case "md5":
|
||||
expectedLen = 32
|
||||
case "sha1":
|
||||
expectedLen = 40
|
||||
case "sha256":
|
||||
expectedLen = 64
|
||||
}
|
||||
if len(expected) != expectedLen {
|
||||
return fmt.Errorf("invalid checksum hash length for %s: expected %d characters, got %d", algo, expectedLen, len(expected))
|
||||
}
|
||||
if _, err := hex.DecodeString(expected); err != nil {
|
||||
return fmt.Errorf("invalid checksum hash for %s: expected valid hexadecimal string", algo)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type OptsExtra struct {
|
||||
Connections int `json:"connections"`
|
||||
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
|
||||
@@ -21,6 +93,8 @@ type OptsExtra struct {
|
||||
ArchivePassword string `json:"archivePassword"`
|
||||
// DeleteAfterExtract when true, deletes the archive file after successful extraction
|
||||
DeleteAfterExtract bool `json:"deleteAfterExtract"`
|
||||
// Checksum verifies the completed file against an expected hash after download
|
||||
Checksum *ChecksumOption `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
// Stats for download
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOptsExtra_Checksum(t *testing.T) {
|
||||
extra := &OptsExtra{
|
||||
Checksum: &ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
}
|
||||
if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
|
||||
t.Fatalf("unexpected checksum: %+v", extra.Checksum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumOption_Validate(t *testing.T) {
|
||||
validMd5 := "0123456789abcdef0123456789abcdef"
|
||||
validSha1 := "0123456789abcdef0123456789abcdef01234567"
|
||||
validSha256 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
option *ChecksumOption
|
||||
wantErr bool
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "nil option",
|
||||
option: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty option (empty strings)",
|
||||
option: &ChecksumOption{Algorithm: "", Expected: ""},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty option (whitespace strings)",
|
||||
option: &ChecksumOption{Algorithm: " ", Expected: " "},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid md5 lowercase",
|
||||
option: &ChecksumOption{Algorithm: "md5", Expected: validMd5},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid md5 uppercase",
|
||||
option: &ChecksumOption{Algorithm: "MD5", Expected: strings.ToUpper(validMd5)},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid sha1 lowercase",
|
||||
option: &ChecksumOption{Algorithm: "sha1", Expected: validSha1},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid sha-1 alias",
|
||||
option: &ChecksumOption{Algorithm: "SHA-1", Expected: strings.ToUpper(validSha1)},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid sha256 lowercase",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: validSha256},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid sha-256 alias",
|
||||
option: &ChecksumOption{Algorithm: "sha-256", Expected: validSha256},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid sha256 with whitespace around hash and algorithm",
|
||||
option: &ChecksumOption{Algorithm: " sha256 ", Expected: " " + validSha256 + " "},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unknown algorithm",
|
||||
option: &ChecksumOption{Algorithm: "crc32", Expected: "12345678"},
|
||||
wantErr: true,
|
||||
errSubstr: "unsupported checksum algorithm",
|
||||
},
|
||||
{
|
||||
name: "algorithm set but hash blank",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: " "},
|
||||
wantErr: true,
|
||||
errSubstr: "checksum expected hash is required",
|
||||
},
|
||||
{
|
||||
name: "sha256 with abc (short length)",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: "abc"},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 64 characters, got 3",
|
||||
},
|
||||
{
|
||||
name: "sha256 with 64 x g (non-hex)",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("g", 64)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected valid hexadecimal string",
|
||||
},
|
||||
{
|
||||
name: "sha256 with 63 x a (off-by-one short)",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 63)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 64 characters, got 63",
|
||||
},
|
||||
{
|
||||
name: "sha256 with 65 x a (off-by-one long)",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 65)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 64 characters, got 65",
|
||||
},
|
||||
{
|
||||
name: "md5 with 31 chars",
|
||||
option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 31)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 32 characters, got 31",
|
||||
},
|
||||
{
|
||||
name: "md5 with 33 chars",
|
||||
option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 33)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 32 characters, got 33",
|
||||
},
|
||||
{
|
||||
name: "md5 with sha256-length hash (64 chars)",
|
||||
option: &ChecksumOption{Algorithm: "md5", Expected: validSha256},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 32 characters, got 64",
|
||||
},
|
||||
{
|
||||
name: "sha1 with 39 chars",
|
||||
option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 39)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 40 characters, got 39",
|
||||
},
|
||||
{
|
||||
name: "sha1 with 41 chars",
|
||||
option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 41)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 40 characters, got 41",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.option.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && tt.errSubstr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
t.Fatalf("Validate() error = %v, want substring %q", err, tt.errSubstr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,21 @@ class Options {
|
||||
Map<String, dynamic> toJson() => _$OptionsToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ChecksumOption {
|
||||
String algorithm;
|
||||
String expected;
|
||||
|
||||
ChecksumOption({
|
||||
this.algorithm = '',
|
||||
this.expected = '',
|
||||
});
|
||||
|
||||
factory ChecksumOption.fromJson(Map<String, dynamic> json) => _$ChecksumOptionFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ChecksumOptionToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class OptsExtraHttp {
|
||||
int connections;
|
||||
@@ -25,6 +40,7 @@ class OptsExtraHttp {
|
||||
bool? autoExtract;
|
||||
String archivePassword;
|
||||
bool deleteAfterExtract;
|
||||
ChecksumOption? checksum;
|
||||
|
||||
OptsExtraHttp({
|
||||
this.connections = 0,
|
||||
@@ -33,6 +49,7 @@ class OptsExtraHttp {
|
||||
this.autoExtract,
|
||||
this.archivePassword = '',
|
||||
this.deleteAfterExtract = false,
|
||||
this.checksum,
|
||||
});
|
||||
|
||||
factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json);
|
||||
|
||||
@@ -22,6 +22,16 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
|
||||
'extra': ?instance.extra,
|
||||
};
|
||||
|
||||
ChecksumOption _$ChecksumOptionFromJson(Map<String, dynamic> json) => ChecksumOption(
|
||||
algorithm: json['algorithm'] as String? ?? '',
|
||||
expected: json['expected'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ChecksumOptionToJson(ChecksumOption instance) => <String, dynamic>{
|
||||
'algorithm': instance.algorithm,
|
||||
'expected': instance.expected,
|
||||
};
|
||||
|
||||
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp(
|
||||
connections: (json['connections'] as num?)?.toInt() ?? 0,
|
||||
autoTorrent: json['autoTorrent'] as bool?,
|
||||
@@ -29,6 +39,9 @@ OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHtt
|
||||
autoExtract: json['autoExtract'] as bool?,
|
||||
archivePassword: json['archivePassword'] as String? ?? '',
|
||||
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
|
||||
checksum: json['checksum'] == null
|
||||
? null
|
||||
: ChecksumOption.fromJson(json['checksum'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{
|
||||
@@ -38,4 +51,5 @@ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, d
|
||||
'autoExtract': ?instance.autoExtract,
|
||||
'archivePassword': instance.archivePassword,
|
||||
'deleteAfterExtract': instance.deleteAfterExtract,
|
||||
'checksum': ?instance.checksum?.toJson(),
|
||||
};
|
||||
@@ -66,6 +66,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']);
|
||||
final _trackersController = TextEditingController();
|
||||
final _archivePasswordController = TextEditingController();
|
||||
final _checksumHashController = TextEditingController();
|
||||
final _formScrollController = ScrollController();
|
||||
|
||||
String _httpMethod = 'GET';
|
||||
@@ -74,6 +75,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
Map<String, String>? _initialLabels;
|
||||
RequestProxyMode _proxyMode = RequestProxyMode.follow;
|
||||
String _proxyScheme = 'http';
|
||||
String _checksumAlgorithm = 'md5';
|
||||
bool _skipVerifyCert = false;
|
||||
bool? _autoTorrent;
|
||||
bool? _deleteTorrentAfterDownload;
|
||||
@@ -134,6 +136,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
_httpHeadersController.dispose();
|
||||
_trackersController.dispose();
|
||||
_archivePasswordController.dispose();
|
||||
_checksumHashController.dispose();
|
||||
_formScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -184,6 +187,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
||||
_fileDataUri = '';
|
||||
}
|
||||
if (_inputUrls().length > 1) {
|
||||
_checksumHashController.clear();
|
||||
_checksumAlgorithm = 'md5';
|
||||
}
|
||||
}
|
||||
_recognizeMagnetUri(urlText.trim());
|
||||
}
|
||||
@@ -193,6 +200,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
final req = task.req;
|
||||
final opts = task.opts;
|
||||
setState(() {
|
||||
_checksumAlgorithm = 'md5';
|
||||
_checksumHashController.clear();
|
||||
if (req?.url.isNotEmpty == true) {
|
||||
_fileDataUri = '';
|
||||
_renameController.clear();
|
||||
@@ -243,6 +252,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
_autoExtract = extra.autoExtract;
|
||||
_archivePasswordController.text = extra.archivePassword;
|
||||
_deleteAfterExtract = extra.deleteAfterExtract;
|
||||
if (extra.checksum != null) {
|
||||
if (extra.checksum!.algorithm.isNotEmpty) {
|
||||
_checksumAlgorithm = extra.checksum!.algorithm;
|
||||
}
|
||||
_checksumHashController.text = extra.checksum!.expected;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -668,6 +683,34 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_inputUrls().length <= 1) ...[
|
||||
const SizedBox(height: 14),
|
||||
AppFormPair(
|
||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||
firstLabel: context.l10n.checksumAlgorithm,
|
||||
secondLabel: context.l10n.checksumHash,
|
||||
firstFlex: 382,
|
||||
secondFlex: 618,
|
||||
first: AppChoiceSegmentedControl<String>(
|
||||
key: const ValueKey('create-task-checksum-algorithm'),
|
||||
showIcons: false,
|
||||
value: _checksumAlgorithm,
|
||||
buttonKeyPrefix: 'create-task-checksum-algorithm',
|
||||
alignment: WrapAlignment.start,
|
||||
options: const [
|
||||
AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag),
|
||||
AppChoiceOption(value: 'sha1', label: 'SHA-1', icon: Icons.tag),
|
||||
AppChoiceOption(value: 'sha256', label: 'SHA-256', icon: Icons.tag),
|
||||
],
|
||||
onChanged: (value) => setState(() => _checksumAlgorithm = value),
|
||||
),
|
||||
second: _WindowTextField(
|
||||
key: const ValueKey('create-task-checksum-expected'),
|
||||
controller: _checksumHashController,
|
||||
hintText: context.l10n.checksumExpectedHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
AppFormRow(
|
||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||
@@ -950,6 +993,25 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
|
||||
bool _validateAdvancedOptions() {
|
||||
if (_protocolTab == 0 && _inputUrls().length <= 1) {
|
||||
final checksumExpected = _checksumHashController.text.trim();
|
||||
if (checksumExpected.isNotEmpty) {
|
||||
final expectedLen = switch (_checksumAlgorithm.toLowerCase()) {
|
||||
'md5' => 32,
|
||||
'sha1' || 'sha-1' => 40,
|
||||
'sha256' || 'sha-256' => 64,
|
||||
_ => 0,
|
||||
};
|
||||
if (expectedLen > 0 && checksumExpected.length != expectedLen) {
|
||||
_showToast(context.l10n.checksumInvalidLength(_checksumAlgorithm.toUpperCase(), expectedLen.toString()));
|
||||
return false;
|
||||
}
|
||||
if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(checksumExpected)) {
|
||||
_showToast(context.l10n.checksumInvalidHex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_proxyMode != RequestProxyMode.custom) {
|
||||
return true;
|
||||
}
|
||||
@@ -1022,8 +1084,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
|
||||
}
|
||||
|
||||
api_options.Options _buildOptions({String? name, List<int> selectFiles = const []}) {
|
||||
api_options.Options _buildOptions({String? name, List<int> selectFiles = const [], bool omitChecksum = false}) {
|
||||
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
|
||||
final checksumExpected = _checksumHashController.text.trim();
|
||||
final checksum = (!omitChecksum && _inputUrls().length <= 1 && checksumExpected.isNotEmpty)
|
||||
? api_options.ChecksumOption(
|
||||
algorithm: _checksumAlgorithm,
|
||||
expected: checksumExpected,
|
||||
)
|
||||
: null;
|
||||
return api_options.Options(
|
||||
name: name ?? _renameController.text.trim(),
|
||||
path: _directoryController.text.trim(),
|
||||
@@ -1036,6 +1105,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
autoExtract: _autoExtract,
|
||||
archivePassword: _archivePasswordController.text,
|
||||
deleteAfterExtract: _deleteAfterExtract,
|
||||
checksum: checksum,
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
@@ -1050,13 +1120,14 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
|
||||
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
|
||||
..sort();
|
||||
final isMultiFile = selected.length > 1;
|
||||
if (result.id.isNotEmpty) {
|
||||
await ref
|
||||
.read(gopeedServiceProvider)
|
||||
.createTask(
|
||||
CreateTask(
|
||||
rid: result.id,
|
||||
opts: _buildOptions(selectFiles: selected),
|
||||
opts: _buildOptions(selectFiles: selected, omitChecksum: isMultiFile),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -1070,11 +1141,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
opts: api_options.Options(
|
||||
name: file.name,
|
||||
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
|
||||
extra: _buildOptions().extra,
|
||||
extra: _buildOptions(omitChecksum: isMultiFile).extra,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions()));
|
||||
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions(omitChecksum: isMultiFile)));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "فك الضغط تلقائياً",
|
||||
"archivePassword": "كلمة مرور الملف المضغوط",
|
||||
"archivePasswordHint": "اتركه فارغاً إذا لم يكن محمياً بكلمة مرور",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "حذف الملف المضغوط بعد فك الضغط",
|
||||
"extracting": "جاري فك الضغط",
|
||||
"extractDone": "تم فك الضغط بنجاح",
|
||||
|
||||
@@ -171,6 +171,11 @@
|
||||
"autoExtract": "Arxivləri Avtomatik Çıxar",
|
||||
"archivePassword": "Arxiv Parolu",
|
||||
"archivePasswordHint": "Parolla qorunmayıbsa boş buraxın",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil",
|
||||
"extracting": "Çıxarılır",
|
||||
"extractDone": "Çıxarma tamamlandı",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Extreu els comprimits automàticament",
|
||||
"archivePassword": "Contrasenya del comprimit",
|
||||
"archivePasswordHint": "Deixeu-lo en blanc si no està protegit amb contrasenya",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Suprimeix el comprimit després de l'extracció",
|
||||
"extracting": "S'està extraient",
|
||||
"extractDone": "Extracció completada",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Archive automatisch entpacken",
|
||||
"archivePassword": "Archiv-Passwort",
|
||||
"archivePasswordHint": "Leer lassen, wenn nicht passwortgeschützt",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Archiv nach Entpacken löschen",
|
||||
"extracting": "Wird entpackt",
|
||||
"extractDone": "Entpacken abgeschlossen",
|
||||
|
||||
@@ -178,6 +178,11 @@
|
||||
"autoExtract": "Extract Archives Automatically",
|
||||
"archivePassword": "Archive Password",
|
||||
"archivePasswordHint": "Leave empty if not password protected",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Delete Archives After Extraction",
|
||||
"extracting": "Extracting",
|
||||
"extractDone": "Extraction completed",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Extraer archivos automáticamente",
|
||||
"archivePassword": "Contraseña del archivo",
|
||||
"archivePasswordHint": "Dejar vacío si no tiene contraseña",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Eliminar archivo después de extraer",
|
||||
"extracting": "Extrayendo",
|
||||
"extractDone": "Extracción completada",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "استخراج خودکار بایگانیها",
|
||||
"archivePassword": "رمز عبور بایگانی",
|
||||
"archivePasswordHint": "اگر رمز ندارد خالی بگذارید",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "حذف آرشیو پس از استخراج",
|
||||
"extracting": "در حال استخراج",
|
||||
"extractDone": "استخراج کامل شد",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Extraire automatiquement les archives",
|
||||
"archivePassword": "Mot de passe de l'archive",
|
||||
"archivePasswordHint": "Laisser vide si non protégé par mot de passe",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Supprimer l'archive après extraction",
|
||||
"extracting": "Extraction en cours",
|
||||
"extractDone": "Extraction terminée",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Archívumok automatikus kicsomagolása",
|
||||
"archivePassword": "Archívum jelszó",
|
||||
"archivePasswordHint": "Hagyja üresen, ha nincs jelszóval védve",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Archívum törlése kicsomagolás után",
|
||||
"extracting": "Kicsomagolás",
|
||||
"extractDone": "Kicsomagolás befejezve",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Ekstrak Arsip Otomatis",
|
||||
"archivePassword": "Kata Sandi Arsip",
|
||||
"archivePasswordHint": "Kosongkan jika tidak dilindungi kata sandi",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Hapus arsip setelah ekstraksi",
|
||||
"extracting": "Mengekstrak",
|
||||
"extractDone": "Ekstraksi selesai",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Estrai automaticamente gli archivi",
|
||||
"archivePassword": "Password archivio",
|
||||
"archivePasswordHint": "Lasciare vuoto se non protetto da password",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Elimina l'archivio dopo l'estrazione",
|
||||
"extracting": "Estrazione in corso",
|
||||
"extractDone": "Estrazione completata",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "アーカイブを自動展開",
|
||||
"archivePassword": "アーカイブパスワード",
|
||||
"archivePasswordHint": "パスワードなしの場合は空のままにしてください",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "展開後にアーカイブを削除",
|
||||
"extracting": "展開中",
|
||||
"extractDone": "展開完了",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "압축 파일 자동 압축 해제",
|
||||
"archivePassword": "압축 파일 비밀번호",
|
||||
"archivePasswordHint": "비밀번호가 없으면 비워 두세요",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "압축 해제 후 압축 파일 삭제",
|
||||
"extracting": "압축 해제 중",
|
||||
"extractDone": "압축 해제 완료",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Automatycznie wyodrębnij archiwa",
|
||||
"archivePassword": "Hasło archiwum",
|
||||
"archivePasswordHint": "Pozostaw puste, jeśli nie ma hasła",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Usuń archiwum po rozpakowaniu",
|
||||
"extracting": "Rozpakowywanie",
|
||||
"extractDone": "Rozpakowywanie zakończone",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Extrair arquivos automaticamente",
|
||||
"archivePassword": "Senha do arquivo",
|
||||
"archivePasswordHint": "Deixar vazio se não estiver protegido por senha",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Excluir arquivo após extração",
|
||||
"extracting": "Extraindo",
|
||||
"extractDone": "Extração concluída",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Автоматически распаковать архивы",
|
||||
"archivePassword": "Пароль архива",
|
||||
"archivePasswordHint": "Оставить пустым, если архив не защищён паролем",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Удалить архив после распаковки",
|
||||
"extracting": "Распаковка",
|
||||
"extractDone": "Распаковка завершена",
|
||||
|
||||
@@ -178,6 +178,11 @@
|
||||
"autoExtract": "Automaticky rozbaliť archívy",
|
||||
"archivePassword": "Heslo archívu",
|
||||
"archivePasswordHint": "Nechajte prázdne, ak nie je chránený heslom",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Po rozbalení odstrániť archívy",
|
||||
"extracting": "Rozbaľovanie",
|
||||
"extractDone": "Rozbalenie dokončené",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "காப்பகங்களை தானாக பிரித்தெடு",
|
||||
"archivePassword": "காப்பக கடவுச்சொல்",
|
||||
"archivePasswordHint": "கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு",
|
||||
"extracting": "பிரித்தெடுக்கிறது",
|
||||
"extractDone": "பிரித்தெடுத்தல் முடிந்தது",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Arşivleri Otomatik Çıkar",
|
||||
"archivePassword": "Arşiv Şifresi",
|
||||
"archivePasswordHint": "Şifre korumalı değilse boş bırakın",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Çıkardıktan sonra arşivi sil",
|
||||
"extracting": "Çıkarılıyor",
|
||||
"extractDone": "Çıkarma tamamlandı",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Автоматично розпаковувати архіви",
|
||||
"archivePassword": "Пароль архіву",
|
||||
"archivePasswordHint": "Залишити порожнім, якщо архів не захищений паролем",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Видалити архів після розпакування",
|
||||
"extracting": "Розпакування",
|
||||
"extractDone": "Розпакування завершено",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Tự động giải nén tập tin nén",
|
||||
"archivePassword": "Mật khẩu tập tin nén",
|
||||
"archivePasswordHint": "Để trống nếu không có mật khẩu",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Xóa tập tin nén sau khi giải nén",
|
||||
"extracting": "Đang giải nén",
|
||||
"extractDone": "Giải nén hoàn tất",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "自动解压压缩包",
|
||||
"archivePassword": "压缩包密码",
|
||||
"archivePasswordHint": "如无密码请留空",
|
||||
"checksumAlgorithm": "校验和算法",
|
||||
"checksumHash": "校验和",
|
||||
"checksumExpectedHint": "预期哈希值(可选)",
|
||||
"checksumInvalidLength": "{algorithm} 校验和哈希长度无效:应为 {length} 个字符",
|
||||
"checksumInvalidHex": "校验和哈希无效:应为十六进制字符(0-9,a-f)",
|
||||
"deleteAfterExtract": "解压后删除压缩包",
|
||||
"extracting": "正在解压",
|
||||
"extractDone": "解压完成",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "自動解壓縮壓縮包",
|
||||
"archivePassword": "壓縮包密碼",
|
||||
"archivePasswordHint": "如無密碼請留空",
|
||||
"checksumAlgorithm": "校驗和演算法",
|
||||
"checksumHash": "校驗和",
|
||||
"checksumExpectedHint": "預期雜湊值(可選)",
|
||||
"checksumInvalidLength": "{algorithm} 校驗和雜湊長度無效:應為 {length} 個字元",
|
||||
"checksumInvalidHex": "校驗和雜湊無效:應為十六進位字元(0-9,a-f)",
|
||||
"deleteAfterExtract": "解壓後刪除壓縮檔",
|
||||
"extracting": "正在解壓",
|
||||
"extractDone": "解壓完成",
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:gopeed/shared/theme/app_design_tokens.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
||||
import 'package:gopeed/l10n/app_localizations.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
@@ -611,6 +612,355 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('checksum state is reset for each incoming task and not inherited (T1)', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
tester.view.physicalSize = const Size(700, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
CreateTask? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'task-id';
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final pending = container.read(pendingCreateTaskProvider.notifier);
|
||||
// Task A: with checksum
|
||||
pending.set(
|
||||
CreateTask(
|
||||
req: Request(url: 'https://example.com/taskA.zip'),
|
||||
opts: Options(
|
||||
extra: OptsExtraHttp(
|
||||
checksum: ChecksumOption(
|
||||
algorithm: 'sha256',
|
||||
expected: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
),
|
||||
).toJson(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/create',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: shad.ShadcnApp.router(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
builder: (_, child) => AppComponentThemes(child: child!),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expand advanced
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fieldText(tester, 'create-task-checksum-expected'), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||
|
||||
// Task B form 1: null opts
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/taskB1.zip')));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
|
||||
|
||||
// Task B form 2: empty Options
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/taskB2.zip'), opts: Options()));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
|
||||
|
||||
// Task B form 3: HTTP extra with no checksum
|
||||
pending.set(
|
||||
CreateTask(
|
||||
req: Request(url: 'https://example.com/taskB3.zip'),
|
||||
opts: Options(extra: OptsExtraHttp(connections: 8).toJson()),
|
||||
),
|
||||
);
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
|
||||
|
||||
// Submit task B3 and ensure no checksum is sent
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle')));
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(submitted?.opts?.extra, isNotNull);
|
||||
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||
expect(extra.checksum, isNull);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('checksum input validation blocks submission on invalid input (T3-UI)', (tester) async {
|
||||
tester.view.physicalSize = const Size(1024, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
CreateTask? submitted;
|
||||
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'task-id';
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final pending = container.read(pendingCreateTaskProvider.notifier);
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/file.zip')));
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/create',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: shad.ShadcnApp.router(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
builder: (_, child) => AppComponentThemes(child: child!),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expand advanced options
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Test 1: "abc" for SHA-256
|
||||
await tester.tap(find.text('SHA-256'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'abc');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
|
||||
// Test 2: 64 x "g" (non-hex) for SHA-256
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'g' * 64);
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
|
||||
// Test 3: 63 x "a" (invalid length) for SHA-256
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 63);
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
|
||||
// Test 4: 65 x "a" (invalid length) for SHA-256
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 65);
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
|
||||
// Test 5: MD5 with sha256-length hash
|
||||
await tester.tap(find.text('MD5'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 64);
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
|
||||
// Test 6: Valid MD5 in UPPER case
|
||||
submitted = null;
|
||||
await tester.tap(find.text('MD5'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), '0123456789ABCDEF0123456789ABCDEF');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNotNull);
|
||||
var extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||
expect(extra.checksum?.algorithm, 'md5');
|
||||
expect(extra.checksum?.expected, '0123456789ABCDEF0123456789ABCDEF');
|
||||
|
||||
// Test 7: Valid SHA-1 in lower case
|
||||
submitted = null;
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/file2.zip')));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('SHA-1'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNotNull);
|
||||
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||
expect(extra.checksum?.algorithm, 'sha1');
|
||||
expect(extra.checksum?.expected, 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
|
||||
// Test 8: Valid SHA-256 in lower case
|
||||
submitted = null;
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/file3.zip')));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('SHA-256'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNotNull);
|
||||
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||
expect(extra.checksum?.algorithm, 'sha256');
|
||||
expect(extra.checksum?.expected, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||
|
||||
// Test 9: Omitted checksum (empty hash)
|
||||
submitted = null;
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/file4.zip')));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), ' ');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNotNull);
|
||||
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||
expect(extra.checksum, isNull);
|
||||
});
|
||||
|
||||
testWidgets('multi-URL and multi-file batch creation omits checksum (T4)', (tester) async {
|
||||
tester.view.physicalSize = const Size(1024, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final createdTasks = <CreateTask>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
createdTasks.add(task);
|
||||
return 'task-id';
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/create',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: shad.ShadcnApp.router(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
builder: (_, child) => AppComponentThemes(child: child!),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter 2 URLs
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('create-task-url-input')),
|
||||
'https://example.com/file1.zip\nhttps://example.com/file2.zip',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Advanced options should hide the checksum field
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('create-task-checksum-expected')), findsNothing);
|
||||
|
||||
// Confirm creation
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(createdTasks.length, 2);
|
||||
for (final t in createdTasks) {
|
||||
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(t.opts!.extra! as Map));
|
||||
expect(extra.checksum, isNull);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('checksum UI uses localized labels (T5)', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
child: shad.ShadcnApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: CreateTaskWindowPage(
|
||||
initialTask: CreateTask(req: Request(url: 'https://example.com/file.zip')),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Checksum Algorithm'), findsOneWidget);
|
||||
expect(find.text('Checksum Hash'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
String _fieldText(WidgetTester tester, String key) {
|
||||
|
||||
Reference in new issue
Block a user