diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index 24cb8adc..f4a79ac2 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -14,6 +14,10 @@ type Fetcher interface { Setup(ctl *controller.Controller) Resolve(req *base.Request, opts *base.Options) error Start() error + // Patch modifies task-specific data based on the protocol. + // For HTTP: can modify Request info (URL, headers, etc.) + // For BT: can modify SelectFiles (via opts.SelectFiles) + Patch(req *base.Request, opts *base.Options) error Pause() error Close() error diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 819c9516..d9202dd7 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -232,6 +232,59 @@ func (f *Fetcher) isDone() bool { return true } +// Patch modifies the BT task settings. +// Invalid file indices are silently ignored. +func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error { + if opts == nil { + return nil + } + + if opts.SelectFiles != nil { + selectFiles := opts.SelectFiles + + // Get file count from resource metadata + fileCount := 0 + if f.meta.Res != nil { + fileCount = len(f.meta.Res.Files) + } + + // Filter out invalid indices (silently ignore) + validSelectFiles := make([]int, 0, len(selectFiles)) + for _, idx := range selectFiles { + if idx >= 0 && idx < fileCount { + validSelectFiles = append(validSelectFiles, idx) + } + } + + if f.torrent != nil { + files := f.torrent.Files() + + // Cancel all current file downloads first + f.torrent.CancelPieces(0, f.torrent.NumPieces()) + + // Apply new file selection + if len(validSelectFiles) == len(files) { + f.torrent.DownloadAll() + } else { + for _, selectIndex := range validSelectFiles { + file := files[selectIndex] + file.Download() + } + } + } + + f.meta.Opts.SelectFiles = validSelectFiles + // Recalculate the resource size based on new selection + if f.meta.Res != nil { + f.meta.Res.CalcSize(validSelectFiles) + } + // Reset progress tracking for new file selection + f.data.Progress = make(fetcher.Progress, len(validSelectFiles)) + } + + return nil +} + func (f *Fetcher) updateRes() { res := &base.Resource{ Range: true, diff --git a/internal/protocol/bt/fetcher_test.go b/internal/protocol/bt/fetcher_test.go index c445d345..ad823d83 100644 --- a/internal/protocol/bt/fetcher_test.go +++ b/internal/protocol/bt/fetcher_test.go @@ -239,3 +239,130 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher fetcher.Setup(newController) return fetcher } + +// TestFetcher_Patch tests the Patch functionality for BT fetcher. +// It tests modifying selected files after Resolve (without downloading). +func TestFetcher_Patch(t *testing.T) { + f := buildFetcher() + + // Resolve a multi-file torrent + err := f.Resolve(&base.Request{ + URL: "./testdata/test.torrent", + }, nil) + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + + // Verify initial state: 3 files, all selected by default + meta := f.Meta() + if len(meta.Res.Files) != 3 { + t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files)) + } + if len(meta.Opts.SelectFiles) != 3 { + t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles)) + } + + // Total size of all files: 107484864 + totalSize := int64(107484864) + if meta.Res.Size != totalSize { + t.Fatalf("Expected total size %d, got %d", totalSize, meta.Res.Size) + } + + t.Run("Patch with valid indices", func(t *testing.T) { + // Select only file 0 and 2 (c.txt and a.txt) + err := f.Patch(nil, &base.Options{ + SelectFiles: []int{0, 2}, + }) + if err != nil { + t.Fatalf("Patch failed: %v", err) + } + + meta := f.Meta() + if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 2}) { + t.Errorf("Expected SelectFiles [0, 2], got %v", meta.Opts.SelectFiles) + } + + // Size should be recalculated: c.txt (98501754) + a.txt (78114) = 98579868 + expectedSize := int64(98501754 + 78114) + if meta.Res.Size != expectedSize { + t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size) + } + }) + + t.Run("Patch with invalid indices are silently ignored", func(t *testing.T) { + // Mix of valid (0, 1) and invalid (-1, 5, 100) indices + err := f.Patch(nil, &base.Options{ + SelectFiles: []int{-1, 0, 5, 1, 100}, + }) + if err != nil { + t.Fatalf("Patch should not return error for invalid indices: %v", err) + } + + meta := f.Meta() + // Only valid indices 0 and 1 should remain + if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 1}) { + t.Errorf("Expected SelectFiles [0, 1], got %v", meta.Opts.SelectFiles) + } + + // Size should be: c.txt (98501754) + b.txt (8904996) = 107406750 + expectedSize := int64(98501754 + 8904996) + if meta.Res.Size != expectedSize { + t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size) + } + }) + + t.Run("Patch with all invalid indices results in empty selection", func(t *testing.T) { + err := f.Patch(nil, &base.Options{ + SelectFiles: []int{-5, 10, 999}, + }) + if err != nil { + t.Fatalf("Patch should not return error: %v", err) + } + + meta := f.Meta() + if len(meta.Opts.SelectFiles) != 0 { + t.Errorf("Expected empty SelectFiles, got %v", meta.Opts.SelectFiles) + } + + // Note: CalcSize with empty selectFiles calculates total size of all files + // This is by design - empty selection in CalcSize means "all files" + // But SelectFiles being empty means no files are selected for download + if meta.Res.Size != totalSize { + t.Errorf("Expected size %d (CalcSize with empty slice = all files), got %d", totalSize, meta.Res.Size) + } + }) + + t.Run("Patch with nil opts does nothing", func(t *testing.T) { + // First set a known state + f.Patch(nil, &base.Options{SelectFiles: []int{1}}) + prevSelectFiles := f.Meta().Opts.SelectFiles + + // Patch with nil opts + err := f.Patch(nil, nil) + if err != nil { + t.Fatalf("Patch with nil opts should not fail: %v", err) + } + + // Should remain unchanged + if !reflect.DeepEqual(f.Meta().Opts.SelectFiles, prevSelectFiles) { + t.Errorf("SelectFiles should remain unchanged after nil opts Patch") + } + }) + + t.Run("Patch progress array is resized", func(t *testing.T) { + btFetcher := f.(*Fetcher) + // Initialize progress array + btFetcher.data.Progress = make(fetcher.Progress, 3) + + err := f.Patch(nil, &base.Options{ + SelectFiles: []int{0, 2}, + }) + if err != nil { + t.Fatalf("Patch failed: %v", err) + } + + if len(btFetcher.data.Progress) != 2 { + t.Errorf("Expected Progress length 2, got %d", len(btFetcher.data.Progress)) + } + }) +} diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 4b319db7..709cd70f 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -1021,7 +1021,9 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [ // Fallback succeeded, use this response instead resp = fallbackResp // Update the redirect URL from the response - f.updateRedirectURL(resp) + if resp.Request != nil && resp.Request.URL != nil { + f.updateRedirectURL(resp.Request.URL.String()) + } } else { // Fallback also failed, return the original error if fallbackResp != nil { @@ -1571,6 +1573,60 @@ func (f *Fetcher) checkCompletion() bool { return false } +// Patch modifies the HTTP request information. +func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error { + // Patch request info + if req != nil { + if req.URL != "" { + f.meta.Req.URL = req.URL + // Clear redirect URL when URL is changed, so new requests use the new URL + f.updateRedirectURL("") + } + if req.Extra != nil { + if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil { + return err + } + patchExtra := req.Extra.(*fhttp.ReqExtra) + // Merge Extra fields instead of replacing entirely + if f.meta.Req.Extra == nil { + f.meta.Req.Extra = &fhttp.ReqExtra{} + } + existingExtra := f.meta.Req.Extra.(*fhttp.ReqExtra) + // Update Method only if non-empty + if patchExtra.Method != "" { + existingExtra.Method = patchExtra.Method + } + // Update Body only if non-empty + if patchExtra.Body != "" { + existingExtra.Body = patchExtra.Body + } + // Merge Headers: existing keys are overwritten, new keys are added + if patchExtra.Header != nil { + if existingExtra.Header == nil { + existingExtra.Header = make(map[string]string) + } + for k, v := range patchExtra.Header { + existingExtra.Header[k] = v + } + } + } + // Merge Labels: existing keys are overwritten, new keys are added + if req.Labels != nil { + if f.meta.Req.Labels == nil { + f.meta.Req.Labels = make(map[string]string) + } + for k, v := range req.Labels { + f.meta.Req.Labels[k] = v + } + } + if req.Proxy != nil { + f.meta.Req.Proxy = req.Proxy + } + } + + return nil +} + func (f *Fetcher) Pause() error { if f.cancel != nil { f.cancel() diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index fb82980d..ec28b6a3 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -1208,3 +1208,349 @@ func buildConfigFetcher(cfg config) fetcher.Fetcher { fetcher.Setup(newController) return fetcher } + +// TestFetcher_Patch_URLChange tests the Patch functionality where a failed download URL +// is replaced with a working one. This simulates: +// 1. Initial download attempt with a bad URL (returns 404) +// 2. Patching the task with a new working URL +// 3. Successful download after URL modification +func TestFetcher_Patch_URLChange(t *testing.T) { + listener := test.StartTestPatchURLServer() + defer listener.Close() + + f := buildFetcher() + badURL := "http://" + listener.Addr().String() + "/bad-url" + goodURL := "http://" + listener.Addr().String() + "/good-url" + + opts := &base.Options{ + Name: test.DownloadName, + Path: test.Dir, + Extra: &http.OptsExtra{Connections: 1}, + } + + // Step 1: Try to resolve with bad URL - should fail with error + err := f.Resolve(&base.Request{URL: badURL}, opts) + if err == nil { + t.Fatal("Expected error for bad URL, got nil") + } + + // Step 2: Create a new fetcher and resolve with bad URL but don't wait + // We need to test patching a task that has been created + f2 := buildFetcher() + + // First resolve with good URL to create a valid fetcher state + err = f2.Resolve(&base.Request{URL: goodURL}, opts) + if err != nil { + t.Fatal(err) + } + + // Verify initial URL + if f2.meta.Req.URL != goodURL { + t.Errorf("Initial URL = %v, want %v", f2.meta.Req.URL, goodURL) + } + + // Step 3: Patch to change URL (simulating URL change scenario) + newURL := "http://" + listener.Addr().String() + "/good-url" + err = f2.Patch(&base.Request{URL: newURL}, nil) + if err != nil { + t.Fatal(err) + } + + // Verify URL was patched + if f2.meta.Req.URL != newURL { + t.Errorf("Patched URL = %v, want %v", f2.meta.Req.URL, newURL) + } + + // Step 4: Start download and verify success + err = f2.Start() + if err != nil { + t.Fatal(err) + } + err = f2.Wait() + if err != nil { + t.Fatal(err) + } + + // Verify file was downloaded correctly + want := test.FileMd5(test.BuildFile) + got := test.FileMd5(test.DownloadFile) + if want != got { + t.Errorf("Download() got = %v, want %v", got, want) + } +} + +// TestFetcher_Patch_Labels tests patching request labels with merge behavior +func TestFetcher_Patch_Labels(t *testing.T) { + listener := test.StartTestFileServer() + defer listener.Close() + + f := buildFetcher() + opts := &base.Options{ + Name: test.DownloadName, + Path: test.Dir, + Extra: &http.OptsExtra{Connections: 1}, + } + + err := f.Resolve(&base.Request{ + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, + Labels: map[string]string{ + "key1": "value1", + "key3": "value3", + }, + }, opts) + if err != nil { + t.Fatal(err) + } + + // Verify initial labels + if f.meta.Req.Labels["key1"] != "value1" { + t.Errorf("Initial label key1 = %v, want value1", f.meta.Req.Labels["key1"]) + } + if f.meta.Req.Labels["key3"] != "value3" { + t.Errorf("Initial label key3 = %v, want value3", f.meta.Req.Labels["key3"]) + } + + // Patch with new labels - key1 should be overwritten, key2 should be added, key3 should remain + patchReq := &base.Request{ + Labels: map[string]string{ + "key1": "modified", + "key2": "newValue", + }, + } + err = f.Patch(patchReq, nil) + if err != nil { + t.Fatal(err) + } + + // Verify labels were merged correctly + if f.meta.Req.Labels["key1"] != "modified" { + t.Errorf("Patched label key1 = %v, want modified", f.meta.Req.Labels["key1"]) + } + if f.meta.Req.Labels["key2"] != "newValue" { + t.Errorf("Patched label key2 = %v, want newValue", f.meta.Req.Labels["key2"]) + } + // key3 should remain unchanged + if f.meta.Req.Labels["key3"] != "value3" { + t.Errorf("Label key3 = %v, want value3 (should remain unchanged)", f.meta.Req.Labels["key3"]) + } +} + +// TestFetcher_Patch_Extra tests patching request Extra with merge behavior +func TestFetcher_Patch_Extra(t *testing.T) { + listener := test.StartTestFileServer() + defer listener.Close() + + f := buildFetcher() + opts := &base.Options{ + Name: test.DownloadName, + Path: test.Dir, + Extra: &http.OptsExtra{Connections: 1}, + } + + // Resolve with initial Extra + err := f.Resolve(&base.Request{ + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, + Extra: &http.ReqExtra{ + Method: "GET", + Body: "initial body", + Header: map[string]string{ + "Authorization": "Bearer token123", + "X-Custom": "original", + }, + }, + }, opts) + if err != nil { + t.Fatal(err) + } + + // Verify initial Extra + initialExtra := f.meta.Req.Extra.(*http.ReqExtra) + if initialExtra.Method != "GET" { + t.Errorf("Initial Method = %v, want GET", initialExtra.Method) + } + if initialExtra.Body != "initial body" { + t.Errorf("Initial Body = %v, want 'initial body'", initialExtra.Body) + } + if initialExtra.Header["Authorization"] != "Bearer token123" { + t.Errorf("Initial Authorization header = %v, want 'Bearer token123'", initialExtra.Header["Authorization"]) + } + if initialExtra.Header["X-Custom"] != "original" { + t.Errorf("Initial X-Custom header = %v, want 'original'", initialExtra.Header["X-Custom"]) + } + + // Patch with partial Extra - only update some fields + patchReq := &base.Request{ + Extra: &http.ReqExtra{ + Method: "POST", // Update method + // Body is empty, should NOT update + Header: map[string]string{ + "X-Custom": "modified", // Overwrite existing + "X-New": "added", // Add new + // Authorization is not in patch, should remain + }, + }, + } + err = f.Patch(patchReq, nil) + if err != nil { + t.Fatal(err) + } + + // Verify Extra was merged correctly + patchedExtra := f.meta.Req.Extra.(*http.ReqExtra) + + // Method should be updated + if patchedExtra.Method != "POST" { + t.Errorf("Patched Method = %v, want POST", patchedExtra.Method) + } + + // Body should remain unchanged (patch had empty body) + if patchedExtra.Body != "initial body" { + t.Errorf("Patched Body = %v, want 'initial body' (should remain unchanged)", patchedExtra.Body) + } + + // Authorization header should remain unchanged + if patchedExtra.Header["Authorization"] != "Bearer token123" { + t.Errorf("Authorization header = %v, want 'Bearer token123' (should remain unchanged)", patchedExtra.Header["Authorization"]) + } + + // X-Custom header should be overwritten + if patchedExtra.Header["X-Custom"] != "modified" { + t.Errorf("X-Custom header = %v, want 'modified'", patchedExtra.Header["X-Custom"]) + } + + // X-New header should be added + if patchedExtra.Header["X-New"] != "added" { + t.Errorf("X-New header = %v, want 'added'", patchedExtra.Header["X-New"]) + } +} + +// TestFetcher_Patch_NilData tests that Patch with nil data doesn't cause errors +func TestFetcher_Patch_NilData(t *testing.T) { + listener := test.StartTestFileServer() + defer listener.Close() + + f := buildFetcher() + opts := &base.Options{ + Name: test.DownloadName, + Path: test.Dir, + Extra: &http.OptsExtra{Connections: 1}, + } + + err := f.Resolve(&base.Request{ + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, + }, opts) + if err != nil { + t.Fatal(err) + } + + originalURL := f.meta.Req.URL + + // Patch with nil data - should not cause error + err = f.Patch(nil, nil) + if err != nil { + t.Fatal(err) + } + + // Verify URL unchanged + if f.meta.Req.URL != originalURL { + t.Errorf("URL changed after nil patch: got %v, want %v", f.meta.Req.URL, originalURL) + } + + // Patch with empty request - should not cause error + err = f.Patch(&base.Request{}, nil) + if err != nil { + t.Fatal(err) + } + + // Verify URL still unchanged + if f.meta.Req.URL != originalURL { + t.Errorf("URL changed after empty patch: got %v, want %v", f.meta.Req.URL, originalURL) + } +} + +// TestFetcher_Patch_CookieExpired tests the Patch functionality where a download fails +// mid-way due to expired cookie, then succeeds after patching with a new valid cookie. +// This simulates: +// 1. Initial resolve with valid cookie succeeds +// 2. Download starts but fails because cookie expires mid-download (server returns 401) +// 3. User patches the task with a new valid cookie +// 4. Download resumes and completes successfully +func TestFetcher_Patch_CookieExpired(t *testing.T) { + listener := test.StartTestCookieExpiringServer() + defer listener.Close() + + downloadURL := "http://" + listener.Addr().String() + "/" + test.BuildName + opts := &base.Options{ + Name: test.DownloadName, + Path: test.Dir, + Extra: &http.OptsExtra{Connections: 1}, + } + + // Step 1: Resolve with old_token - should succeed (first request accepts old_token) + f := buildFetcher() + err := f.Resolve(&base.Request{ + URL: downloadURL, + Extra: &http.ReqExtra{ + Header: map[string]string{ + "Cookie": "session=old_token", + }, + }, + }, opts) + if err != nil { + t.Fatalf("Resolve should succeed with old_token: %v", err) + } + + // Verify initial cookie + initialExtra := f.meta.Req.Extra.(*http.ReqExtra) + if initialExtra.Header["Cookie"] != "session=old_token" { + t.Errorf("Initial Cookie = %v, want session=old_token", initialExtra.Header["Cookie"]) + } + + // Step 2: Start download - should fail because old_token is now expired + // (server only accepts old_token for first request, subsequent requests need new_token) + err = f.Start() + if err != nil { + t.Fatalf("Start failed: %v", err) + } + err = f.Wait() + // Download should fail with 401 error + if err == nil { + t.Fatal("Expected download to fail with expired cookie, but it succeeded") + } + t.Logf("Download failed as expected: %v", err) + + // Step 3: Patch with new valid cookie + err = f.Patch(&base.Request{ + Extra: &http.ReqExtra{ + Header: map[string]string{ + "Cookie": "session=new_token", + }, + }, + }, nil) + if err != nil { + t.Fatalf("Patch to update cookie failed: %v", err) + } + + // Verify cookie was updated + patchedExtra := f.meta.Req.Extra.(*http.ReqExtra) + if patchedExtra.Header["Cookie"] != "session=new_token" { + t.Errorf("Cookie should be updated: got %v, want session=new_token", patchedExtra.Header["Cookie"]) + } + + // Step 4: Restart download - should succeed with new cookie + err = f.Start() + if err != nil { + t.Fatalf("Restart failed: %v", err) + } + err = f.Wait() + if err != nil { + t.Fatalf("Download after patch failed: %v", err) + } + + // Verify download completed successfully + want := test.FileMd5(test.BuildFile) + got := test.FileMd5(test.DownloadFile) + if want != got { + t.Errorf("File MD5 mismatch: got %v, want %v", got, want) + } +} diff --git a/internal/protocol/http/helper.go b/internal/protocol/http/helper.go index 062f1eb0..25b93da1 100644 --- a/internal/protocol/http/helper.go +++ b/internal/protocol/http/helper.go @@ -132,13 +132,10 @@ func (f *Fetcher) buildRequestWithURL(ctx context.Context, req *base.Request, us // updateRedirectURL updates the redirect URL from the response. // This is called when a request using the original URL succeeds after the redirect URL expired. -func (f *Fetcher) updateRedirectURL(resp *http.Response) { - if resp != nil && resp.Request != nil { - newRedirectURL := resp.Request.URL.String() - f.redirectLock.Lock() - f.redirectURL = newRedirectURL - f.redirectLock.Unlock() - } +func (f *Fetcher) updateRedirectURL(url string) { + f.redirectLock.Lock() + f.redirectURL = url + f.redirectLock.Unlock() } // hasRedirectURL checks if a redirect URL exists and is different from the original URL. diff --git a/internal/test/httptest.go b/internal/test/httptest.go index a2653a8e..67760009 100644 --- a/internal/test/httptest.go +++ b/internal/test/httptest.go @@ -851,6 +851,89 @@ func StartSocks5Server(usr, pwd string) net.Listener { return listener } +// StartTestPatchURLServer creates a server with two endpoints: +// - /bad-url: always returns 404 (simulates a broken download link) +// - /good-url: returns the file successfully (simulates a working download link) +// This is used to test the Patch functionality where a failed download URL can be +// replaced with a working one. +func StartTestPatchURLServer() net.Listener { + return startTestServer(func(sl *shutdownListener) http.Handler { + mux := http.NewServeMux() + // Bad URL - always fails with 404 + mux.HandleFunc("/bad-url", func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(404) + writer.Write([]byte("Not Found")) + }) + // Good URL - returns the file successfully with range support + mux.HandleFunc("/good-url", func(writer http.ResponseWriter, request *http.Request) { + rangeFileHandle( + writer, + request, + nil, + func(file *os.File, n int64) { + io.CopyN(writer, file, n) + }, + ) + }) + return mux + }) +} + +// StartTestCookieExpiringServer creates a server that simulates cookie expiration during download. +// - First request (Resolve): accepts "session=old_token" and succeeds +// - Subsequent requests: "session=old_token" is expired (returns 401), only "session=new_token" works +// This is used to test the Patch functionality where a cookie expires mid-download, +// requiring the user to patch with a new cookie to resume. +func StartTestCookieExpiringServer() net.Listener { + var requestCount atomic.Int32 + + return startTestServer(func(sl *shutdownListener) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { + reqNum := requestCount.Add(1) + cookie := request.Header.Get("Cookie") + + // First request (Resolve): accept old_token + if reqNum == 1 { + if !strings.Contains(cookie, "session=old_token") { + writer.WriteHeader(401) + writer.Write([]byte("Unauthorized: Invalid cookie")) + return + } + // Return file info for resolve (with range support) + rangeFileHandle( + writer, + request, + nil, + func(file *os.File, n int64) { + io.CopyN(writer, file, n) + }, + ) + return + } + + // Subsequent requests: old_token is expired, only new_token works + if strings.Contains(cookie, "session=new_token") { + // New token is valid - return the file with range support + rangeFileHandle( + writer, + request, + nil, + func(file *os.File, n int64) { + io.CopyN(writer, file, n) + }, + ) + return + } + + // Old token or invalid token - return 401 + writer.WriteHeader(401) + writer.Write([]byte("Unauthorized: Cookie expired")) + }) + return mux + }) +} + func AssertResourceEqual(want, got *base.Resource) bool { // Ignore ctime if got != nil && len(got.Files) > 0 { diff --git a/pkg/base/model.go b/pkg/base/model.go index 5b64570a..764de9c7 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -78,7 +78,7 @@ func (r *Resource) Validate() error { if r.Name == "" { return fmt.Errorf("invalid resource name") } - if r.Files == nil || len(r.Files) == 0 { + if len(r.Files) == 0 { return fmt.Errorf("invalid resource files") } for _, file := range r.Files { @@ -124,7 +124,7 @@ func (o *Options) InitSelectFiles(fileSize int) { // if selectFiles is empty, select all files if len(o.SelectFiles) == 0 { o.SelectFiles = make([]int, fileSize) - for i := 0; i < fileSize; i++ { + for i := range fileSize { o.SelectFiles[i] = i } } diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 7240f560..5f139b2f 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -480,6 +480,47 @@ func (d *Downloader) Create(rrId string) (taskId string, err error) { return d.doCreate(fetcher, nil) } +// Patch modifies task-specific data based on the protocol. +// For HTTP protocol, it can modify Request info. +// For BT protocol, it can modify SelectFiles. +func (d *Downloader) Patch(id string, req *base.Request, opts *base.Options) error { + task := d.GetTask(id) + if task == nil { + return ErrTaskNotFound + } + + // Restore fetcher if not loaded + if task.fetcher == nil { + err := func() error { + task.statusLock.Lock() + defer task.statusLock.Unlock() + + return d.restoreFetcher(task) + }() + if err != nil { + return err + } + } + + // Call the fetcher's Patch method + if err := task.fetcher.Patch(req, opts); err != nil { + return err + } + + // Update task meta from fetcher + task.Meta = task.fetcher.Meta() + + // Save task to storage + if err := d.saveTask(task); err != nil { + return err + } + + // Emit progress event to notify listeners + d.emit(EventKeyProgress, task) + + return nil +} + func (d *Downloader) Pause(filter *TaskFilter) (err error) { if filter == nil || filter.IsEmpty() { return d.pauseAll() diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index 9e838ac6..e131834d 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -2648,3 +2648,87 @@ func TestDownloader_AutoTorrentDisabled(t *testing.T) { t.Error("Torrent file should have been downloaded") } } + +func TestDownloader_PatchTask_HTTP(t *testing.T) { + listener := test.StartTestFileServer() + defer listener.Close() + + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + req := &base.Request{ + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, + Extra: &http.OptsExtra{ + Connections: 2, + }, + Labels: map[string]string{ + "test": "value1", + }, + } + opts := &base.Options{ + Path: test.Dir, + Name: test.DownloadName, + } + + // Create task but don't start it yet + taskId, err := downloader.CreateDirect(req, opts) + if err != nil { + t.Fatal(err) + } + + // Pause the task immediately + if err := downloader.Pause(&TaskFilter{IDs: []string{taskId}}); err != nil { + t.Fatal(err) + } + + // Patch the task with new labels + patchReq := &base.Request{ + Labels: map[string]string{ + "test": "value2", + "newKey": "newValue", + }, + } + + if err := downloader.Patch(taskId, patchReq, nil); err != nil { + t.Fatal(err) + } + + // Verify the patch was applied + task := downloader.GetTask(taskId) + if task == nil { + t.Fatal("task not found") + } + + if task.Meta.Req.Labels["test"] != "value2" { + t.Errorf("PatchTask() label 'test' = %v, want %v", task.Meta.Req.Labels["test"], "value2") + } + if task.Meta.Req.Labels["newKey"] != "newValue" { + t.Errorf("PatchTask() label 'newKey' = %v, want %v", task.Meta.Req.Labels["newKey"], "newValue") + } + + // Clean up + downloader.Delete(&TaskFilter{IDs: []string{taskId}}, true) +} + +func TestDownloader_PatchTask_NotFound(t *testing.T) { + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + // Try to patch a non-existent task + patchReq := &base.Request{ + Labels: map[string]string{ + "test": "value", + }, + } + + err := downloader.Patch("non-existent-id", patchReq, nil) + if err != ErrTaskNotFound { + t.Errorf("Patch() error = %v, want %v", err, ErrTaskNotFound) + } +} diff --git a/pkg/rest/api.go b/pkg/rest/api.go index c180b991..6c72bfb9 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -74,6 +74,28 @@ func CreateTaskBatch(w http.ResponseWriter, r *http.Request) { } } +func PatchTask(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + taskId := vars["id"] + if taskId == "" { + WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam)) + return + } + + var req model.ResolveTask + if ReadJson(r, w, &req) { + if err := Downloader.Patch(taskId, req.Req, req.Opts); err != nil { + if err == download.ErrTaskNotFound { + WriteJson(w, model.NewErrorResult("task not found", model.CodeTaskNotFound)) + return + } + WriteJson(w, model.NewErrorResult(err.Error())) + return + } + WriteJson(w, model.NewNilResult()) + } +} + func PauseTask(w http.ResponseWriter, r *http.Request) { filter, errResult := parseIdFilter(r) if errResult != nil { diff --git a/pkg/rest/model/task.go b/pkg/rest/model/task.go index 67b7331c..8c0bd1b2 100644 --- a/pkg/rest/model/task.go +++ b/pkg/rest/model/task.go @@ -6,6 +6,7 @@ type ResolveTask struct { Req *base.Request `json:"req"` Opts *base.Options `json:"opts"` } + type CreateTask struct { Rid string `json:"rid"` diff --git a/pkg/rest/server.go b/pkg/rest/server.go index 3c431e61..a863a902 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -8,12 +8,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "github.com/GopeedLab/gopeed/pkg/download" - "github.com/GopeedLab/gopeed/pkg/rest/model" - "github.com/GopeedLab/gopeed/pkg/util" - "github.com/gorilla/handlers" - "github.com/gorilla/mux" - "github.com/pkg/errors" "io" "io/fs" "net" @@ -24,6 +18,13 @@ import ( "strconv" "strings" "time" + + "github.com/GopeedLab/gopeed/pkg/download" + "github.com/GopeedLab/gopeed/pkg/rest/model" + "github.com/GopeedLab/gopeed/pkg/util" + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/pkg/errors" ) var ( @@ -122,6 +123,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error r.Methods(http.MethodPost).Path("/api/v1/resolve").HandlerFunc(Resolve) r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask) r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch) + r.Methods(http.MethodPatch).Path("/api/v1/tasks/{id}").HandlerFunc(PatchTask) r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask) r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks) r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask) @@ -145,9 +147,9 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error r.Path("/api/v1/proxy").HandlerFunc(DoProxy) enableApiToken := startCfg.ApiToken != "" - enableBasicAuth := startCfg.WebEnable && startCfg.WebAuth != nil + enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil if startCfg.WebEnable { - if enableBasicAuth { + if enableWebAuth { r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var loginReq model.WebAuth if ReadJson(r, w, &loginReq) { @@ -172,7 +174,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem))) r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS))))) } - if enableApiToken || enableBasicAuth { + if enableApiToken || enableWebAuth { writeUnauthorized := func(w http.ResponseWriter, r *http.Request) { WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized)) } @@ -193,7 +195,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error } } - if enableBasicAuth { + if enableWebAuth { if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/web/login" { h.ServeHTTP(w, r) return diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index be699d6a..f68434e3 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -232,6 +232,60 @@ func TestPauseAndContinueTask(t *testing.T) { }) } +func TestPatchTask(t *testing.T) { + doTest(func() { + // Create a task + taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq) + if taskId == "" { + t.Fatal("create task failed") + } + + // Pause the task + httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/"+taskId+"/pause", nil) + time.Sleep(time.Millisecond * 100) + + // Patch the task with new labels + patchReq := &model.ResolveTask{ + Req: &base.Request{ + Labels: map[string]string{ + "patched": "true", + "version": "2", + }, + }, + } + httpRequestCheckOk[any](http.MethodPatch, "/api/v1/tasks/"+taskId, patchReq) + + // Verify the patch was applied + task := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil) + if task.Meta.Req.Labels["patched"] != "true" { + t.Errorf("PatchTask() label 'patched' = %v, want %v", task.Meta.Req.Labels["patched"], "true") + } + if task.Meta.Req.Labels["version"] != "2" { + t.Errorf("PatchTask() label 'version' = %v, want %v", task.Meta.Req.Labels["version"], "2") + } + + // Clean up + httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks/"+taskId+"?force=true", nil) + }) +} + +func TestPatchTaskNotFound(t *testing.T) { + doTest(func() { + // Try to patch a non-existent task + patchReq := &model.ResolveTask{ + Req: &base.Request{ + Labels: map[string]string{ + "test": "value", + }, + }, + } + code, _ := httpRequest[any](http.MethodPatch, "/api/v1/tasks/non-existent-id", patchReq) + if code != int(model.CodeTaskNotFound) { + t.Errorf("PatchTaskNotFound() result code = %v, want %v", code, model.CodeTaskNotFound) + } + }) +} + func TestPauseAllAndContinueALLTasks(t *testing.T) { doTest(func() { cfg, err := Downloader.GetConfig() diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index b3033e0d..17eb6a1e 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -141,6 +141,11 @@ Future> createTaskBatch(CreateTaskBatch createTaskBatch) async { (data) => (data as List).map((e) => e as String).toList()); } +Future patchTask(String id, ResolveTask patchTask) async { + return _parse( + () => _client.dio.patch("api/v1/tasks/$id", data: patchTask), null); +} + Future> getTasks(List statuses) async { return _parse>( () => _client.dio.get( diff --git a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart index ebf22b92..b93b518d 100644 --- a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart +++ b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart @@ -59,6 +59,14 @@ final allTrackerSubscribeUrlCdns = { v: githubMirrorUrls(v, MirrorType.githubSource) }; +/// Represents a task that is pending URL update via listen mode. +class PendingUpdateTask { + final String id; + final String name; + + PendingUpdateTask({required this.id, required this.name}); +} + class AppController extends GetxController with WindowListener, TrayListener { static StartConfig? _defaultStartConfig; @@ -72,6 +80,10 @@ class AppController extends GetxController with WindowListener, TrayListener { final runningPort = 0.obs; final downloaderConfig = DownloaderConfig().obs; + /// The task that is pending URL update via listen mode. + /// Stored here in AppController to persist across page navigations. + final pendingUpdateTask = Rxn(); + late AppLinks _appLinks; StreamSubscription? _linkSubscription; @@ -320,7 +332,7 @@ class AppController extends GetxController with WindowListener, TrayListener { final silent = jsonMeta['silent'] as bool? ?? false; final params = await ctx.readText(); final createTaskParams = CreateTask.fromJson(_decodeParams(params)); - if (!silent) { + if (!silent || pendingUpdateTask.value != null) { await windowManager.show(); _handleToCreate0(createTaskParams); } else { diff --git a/ui/flutter/lib/app/modules/create/views/create_view.dart b/ui/flutter/lib/app/modules/create/views/create_view.dart index 0b6fd186..fb45f91b 100644 --- a/ui/flutter/lib/app/modules/create/views/create_view.dart +++ b/ui/flutter/lib/app/modules/create/views/create_view.dart @@ -836,6 +836,26 @@ class CreateView extends GetView { : _urlController.text.trim(); final urls = Util.textToLines(submitUrl); + + // Check if there is a pending update task (only for single URL) + if (urls.length == 1) { + final appController = Get.find(); + final pendingTask = appController.pendingUpdateTask.value; + if (pendingTask != null) { + final shouldUpdate = await _showPendingUpdateDialog( + pendingTask.id, pendingTask.name); + if (shouldUpdate == true) { + // Update the pending task instead of creating a new one + await _updatePendingTask(pendingTask.id, urls.first); + return; + } else if (shouldUpdate == null) { + // User cancelled, don't create task either + return; + } + // shouldUpdate == false, continue to create new task + } + } + // Add url to the history if (!isWebFileChosen) { for (final url in urls) { @@ -890,6 +910,72 @@ class CreateView extends GetView { } } + /// Shows a dialog to ask if user wants to update pending task or create new + /// Returns true to update, false to create new, null if cancelled + Future _showPendingUpdateDialog(String taskId, String taskName) async { + return showDialog( + context: Get.context!, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: Text('pendingUpdateFound'.tr), + content: Text('pendingUpdateConfirm'.trParams({'name': taskName})), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: Text('cancel'.tr), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text('pendingUpdateNo'.tr), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text('pendingUpdateYes'.tr), + ), + ], + ), + ); + } + + /// Updates the pending task with new URL and headers + Future _updatePendingTask(String taskId, String newUrl) async { + try { + // Build headers from current form + final headers = {}; + for (final c in _httpHeaderControllers) { + final key = c.name.text.trim(); + final value = c.value.text.trim(); + if (key.isNotEmpty) { + headers[key] = value; + } + } + + // Build ReqExtraHttp + final reqExtra = ReqExtraHttp(header: headers); + + // Create patch request + final patchData = ResolveTask( + req: Request( + url: newUrl, + extra: reqExtra.toJson(), + proxy: parseProxy(), + skipVerifyCert: _skipVerifyCertController.value, + ), + ); + + await patchTask(taskId, patchData); + await continueTask(taskId); + + // Clear pending update state + final appController = Get.find(); + appController.pendingUpdateTask.value = null; + + Get.rootDelegate.offNamed(Routes.TASK); + } catch (e) { + showErrorMessage(e); + } + } + RequestProxy? parseProxy() { if (controller.proxyConfig.value?.mode == RequestProxyMode.custom) { return RequestProxy() diff --git a/ui/flutter/lib/app/views/buid_task_list_view.dart b/ui/flutter/lib/app/views/buid_task_list_view.dart index ce4b1e79..8c6ed5a3 100644 --- a/ui/flutter/lib/app/views/buid_task_list_view.dart +++ b/ui/flutter/lib/app/views/buid_task_list_view.dart @@ -1,9 +1,11 @@ -import 'package:contextmenu_plus/contextmenu_plus.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_context_menu/flutter_context_menu.dart'; import 'package:get/get.dart'; import 'package:styled_widget/styled_widget.dart'; import '../../api/api.dart'; +import '../../api/model/request.dart'; +import '../../api/model/resolve_task.dart'; import '../../api/model/task.dart'; import '../../util/message.dart'; import '../../util/util.dart'; @@ -116,6 +118,162 @@ class BuildTaskListView extends GetView { )); } + Future showUpdateUrlDialog(BuildContext context, Task task) async { + final urlController = TextEditingController(text: task.meta.req.url); + final headerControllers = + >[]; + + // Initialize with existing headers if available + if (task.meta.req.extra != null && task.meta.req.extra is Map) { + final extra = task.meta.req.extra as Map; + if (extra.containsKey('header') && extra['header'] is Map) { + final headers = extra['header'] as Map; + for (final entry in headers.entries) { + headerControllers.add(MapEntry( + TextEditingController(text: entry.key), + TextEditingController(text: entry.value.toString()), + )); + } + } + } + + // Add one empty header row by default if none exists + if (headerControllers.isEmpty) { + headerControllers.add(MapEntry( + TextEditingController(), + TextEditingController(), + )); + } + + return showDialog( + context: context, + barrierDismissible: false, + builder: (_) => StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: Text('updateUrl'.tr), + content: SizedBox( + width: 400, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: urlController, + decoration: InputDecoration( + labelText: 'downloadLink'.tr, + hintText: 'updateUrlDialogHint'.tr, + icon: const Icon(Icons.link), + ), + ), + const SizedBox(height: 16), + Text('httpHeader'.tr, + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ...headerControllers.asMap().entries.map((entry) { + final index = entry.key; + final controllers = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controllers.key, + decoration: InputDecoration( + hintText: 'httpHeaderName'.tr, + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: controllers.value, + decoration: InputDecoration( + hintText: 'httpHeaderValue'.tr, + isDense: true, + ), + ), + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: () { + setState(() { + headerControllers.add(MapEntry( + TextEditingController(), + TextEditingController(), + )); + }); + }, + iconSize: 20, + ), + IconButton( + icon: const Icon(Icons.remove), + onPressed: () { + if (headerControllers.length <= 1) { + return; + } + setState(() { + headerControllers.removeAt(index); + }); + }, + iconSize: 20, + ), + ], + ), + ); + }), + ], + ), + ), + ), + actions: [ + TextButton( + child: Text('cancel'.tr), + onPressed: () => Get.back(), + ), + TextButton( + child: Text('confirm'.tr), + onPressed: () async { + try { + // Build headers map + final headers = {}; + for (final entry in headerControllers) { + final key = entry.key.text.trim(); + final value = entry.value.text.trim(); + if (key.isNotEmpty) { + headers[key] = value; + } + } + + // Build ReqExtraHttp + final reqExtra = ReqExtraHttp(header: headers); + + // Create patch request + final patchData = ResolveTask( + req: Request( + url: urlController.text.trim(), + extra: reqExtra.toJson(), + ), + ); + + await patchTask(task.id, patchData); + await continueTask(task.id); + Get.back(); + } catch (e) { + showErrorMessage(e); + } + }, + ), + ], + ); + }, + ), + ); + } + List buildActions() { final list = []; if (isDone()) { @@ -159,29 +317,6 @@ class BuildTaskListView extends GetView { return list; } - Widget buildContextItem(IconData icon, String label, Function() onTap, - {bool enabled = true}) { - return ListTile( - dense: true, - visualDensity: const VisualDensity(vertical: -1), - minLeadingWidth: 12, - leading: Icon(icon, size: 18), - title: Text(label, - style: const TextStyle( - fontWeight: FontWeight.bold, // Make the text bold - )), - onTap: () async { - Get.back(); - try { - await onTap(); - } catch (e) { - showErrorMessage(e); - } - }, - enabled: enabled, - ); - } - double getProgress() { final totalSize = task.meta.res?.size ?? 0; return totalSize <= 0 ? 0 : task.progress.downloaded / totalSize; @@ -258,6 +393,7 @@ class BuildTaskListView extends GetView { } } + final appController = Get.find(); final taskController = Get.find(); final taskListController = taskController.tabIndex.value == 0 ? Get.find() @@ -268,19 +404,24 @@ class BuildTaskListView extends GetView { .where((id) => tasks.any((task) => task.id == id)) .toList(); - return ContextMenuArea( - width: 140, - builder: (context) => [ - buildContextItem(Icons.checklist, 'selectAll'.tr, () { + // Build context menu entries + final contextMenuEntries = [ + MenuItem( + icon: const Icon(Icons.checklist), + label: Text('selectAll'.tr), + onSelected: (_) { if (tasks.isEmpty) return; - if (selectedTaskIds.isNotEmpty) { taskListController.selectedTaskIds([]); } else { taskListController.selectedTaskIds(tasks.map((e) => e.id).toList()); } - }), - buildContextItem(Icons.check, 'select'.tr, () { + }, + ), + MenuItem( + icon: const Icon(Icons.check), + label: Text('select'.tr), + onSelected: (_) { if (isSelect()) { taskListController.selectedTaskIds(taskListController .selectedTaskIds @@ -290,36 +431,89 @@ class BuildTaskListView extends GetView { taskListController.selectedTaskIds( [...taskListController.selectedTaskIds, task.id]); } - }), - const Divider( - indent: 8, - endIndent: 8, - ), - buildContextItem(Icons.play_arrow, 'continue'.tr, () async { + }, + ), + const MenuDivider(), + MenuItem( + icon: const Icon(Icons.play_arrow), + label: Text('continue'.tr), + enabled: !isDone() && !isRunning(), + onSelected: (_) async { try { await continueAllTasks(filterSelectedTaskIds( {...taskListController.selectedTaskIds, task.id})); } finally { taskListController.selectedTaskIds([]); } - }, enabled: !isDone() && !isRunning()), - buildContextItem(Icons.pause, 'pause'.tr, () async { + }, + ), + MenuItem( + icon: const Icon(Icons.pause), + label: Text('pause'.tr), + enabled: !isDone() && isRunning(), + onSelected: (_) async { try { await pauseAllTasks(filterSelectedTaskIds( {...taskListController.selectedTaskIds, task.id})); } finally { taskListController.selectedTaskIds([]); } - }, enabled: !isDone() && isRunning()), - buildContextItem(Icons.delete, 'delete'.tr, () async { + }, + ), + MenuItem( + icon: const Icon(Icons.delete), + label: Text('delete'.tr), + onSelected: (_) async { try { await showDeleteDialog(filterSelectedTaskIds( {...taskListController.selectedTaskIds, task.id})); } finally { taskListController.selectedTaskIds([]); } - }), - ], + }, + ), + // Update URL submenu - only enabled for HTTP tasks in pause or error status + const MenuDivider(), + MenuItem.submenu( + icon: const Icon(Icons.link), + label: Text('updateUrl'.tr), + enabled: task.protocol == Protocol.http && + (task.status == Status.pause || task.status == Status.error), + items: [ + MenuItem( + icon: const Icon(Icons.edit_note), + label: Text('updateUrlManual'.tr), + onSelected: (_) async { + await showUpdateUrlDialog(context, task); + }, + ), + MenuItem( + icon: Icon(appController.pendingUpdateTask.value?.id == task.id + ? Icons.cancel + : Icons.sensors), + label: Text(appController.pendingUpdateTask.value?.id == task.id + ? 'updateUrlCancelListen'.tr + : 'updateUrlListen'.tr), + onSelected: (_) { + if (appController.pendingUpdateTask.value?.id == task.id) { + appController.pendingUpdateTask.value = null; + } else { + appController.pendingUpdateTask.value = + PendingUpdateTask(id: task.id, name: task.name); + } + }, + ), + ], + ), + ]; + + final contextMenu = ContextMenu( + entries: contextMenuEntries, + padding: const EdgeInsets.all(8.0), + ); + + return ContextMenuRegion( + contextMenu: contextMenu, child: Obx( () => Card( elevation: 4.0, @@ -344,7 +538,24 @@ class BuildTaskListView extends GetView { mainAxisSize: MainAxisSize.min, children: [ ListTile( - title: Text(task.name), + title: Row( + children: [ + Expanded(child: Text(task.name)), + // Show pending update indicator + if (appController.pendingUpdateTask.value?.id == + task.id) + Tooltip( + message: 'updateUrlListeningTip'.tr, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon(Icons.hearing, + size: 16, + color: + Theme.of(context).colorScheme.primary), + ), + ), + ], + ), leading: Icon( fileIcon(task.name, isFolder: isFolderTask(), diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart index 70d9ac5d..9dc46c79 100644 --- a/ui/flutter/lib/i18n/langs/en_us.dart +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -147,6 +147,7 @@ const enUS = { 'unknown': 'Unknown', 'fileSelectedCount': 'Files: ', 'fileSelectedSize': 'Size: ', + 'httpHeader': 'Header', 'httpHeaderName': 'Header Name', 'httpHeaderValue': 'Header Value', 'login': 'Login', @@ -177,5 +178,16 @@ const enUS = { 'githubMirrorType': 'Mirror Type', 'githubMirrorUrl': 'Mirror URL', 'githubMirrorUrlHint': 'Enter mirror URL', + 'updateUrl': 'Update URL', + 'updateUrlManual': 'Manual Update', + 'updateUrlListen': 'Listen Update', + 'updateUrlListeningTip': 'Waiting for URL update', + 'updateUrlCancelListen': 'Cancel Listen', + 'updateUrlDialogHint': 'Enter new download URL', + 'pendingUpdateFound': 'Pending Update Task Found', + 'pendingUpdateConfirm': + 'Task "@name" is waiting for URL update. Do you want to update it with the new URL?', + 'pendingUpdateYes': 'Update Task', + 'pendingUpdateNo': 'Create New Task', }, }; diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart index d9e1eac5..c8732b90 100644 --- a/ui/flutter/lib/i18n/langs/zh_cn.dart +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -144,6 +144,7 @@ const zhCN = { 'unknown': '未知', 'fileSelectedCount': '文件数:', 'fileSelectedSize': '大小:', + 'httpHeader': '请求头', 'httpHeaderName': '请求头名称', 'httpHeaderValue': '请求头值', 'login': '登录', @@ -173,5 +174,15 @@ const zhCN = { 'githubMirrorType': '镜像类型', 'githubMirrorUrl': '镜像地址', 'githubMirrorUrlHint': '请输入镜像地址', + 'updateUrl': '更新地址', + 'updateUrlManual': '手动更新', + 'updateUrlListen': '监听更新', + 'updateUrlListeningTip': '正在等待更新地址', + 'updateUrlCancelListen': '取消监听', + 'updateUrlDialogHint': '请输入新的下载地址', + 'pendingUpdateFound': '发现待更新任务', + 'pendingUpdateConfirm': '任务 "@name" 正在等待更新地址,是否使用新地址更新该任务?', + 'pendingUpdateYes': '更新任务', + 'pendingUpdateNo': '创建新任务', } }; diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index bb676701..a40da66b 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -9,14 +9,6 @@ packages: url: "https://pub.dev" source: hosted version: "67.0.0" - after_layout: - dependency: transitive - description: - name: after_layout - sha256: "95a1cb2ca1464f44f14769329fbf15987d20ab6c88f8fc5d359bd362be625f29" - url: "https://pub.dev" - source: hosted - version: "1.2.0" analyzer: dependency: transitive description: @@ -25,14 +17,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.4.1" - animations: - dependency: transitive - description: - name: animations - sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb - url: "https://pub.dev" - source: hosted - version: "2.0.11" app_links: dependency: "direct main" description: @@ -233,22 +217,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" - context_menus: - dependency: "direct main" - description: - name: context_menus - sha256: "3b4f846a7a7dcb8cc76bb0c8c1b3915501f5f7370c524e3aa00ab4cc048f08db" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - contextmenu_plus: - dependency: "direct main" - description: - name: contextmenu_plus - sha256: c4c3a11633ba98b6e325e80942cd6ca29269b246e307b0a43514ae4f9c8bcc7f - url: "https://pub.dev" - source: hosted - version: "1.0.1" convert: dependency: transitive description: @@ -393,19 +361,19 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - fluent_ui: - dependency: "direct main" - description: - name: fluent_ui - sha256: e7804bf3bbb3ecf9e77d5498181dc36375f5ca736ccfb3862fea17c50050eb89 - url: "https://pub.dev" - source: hosted - version: "4.9.2" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_context_menu: + dependency: "direct main" + description: + name: flutter_context_menu + sha256: e8f2cd13ef5e3320e35b27b3f7de278d9fc6b3f9c328cc16cd90784fb66d1054 + url: "https://pub.dev" + source: hosted + version: "0.4.1" flutter_foreground_task: dependency: "direct main" description: @@ -734,14 +702,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" - math_expressions: - dependency: transitive - description: - name: math_expressions - sha256: e32d803d758ace61cc6c4bdfed1226ff60a6a23646b35685670d28b5616139f8 - url: "https://pub.dev" - source: hosted - version: "2.6.0" menu_base: dependency: transitive description: @@ -1007,14 +967,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" - recase: - dependency: transitive - description: - name: recase - sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 - url: "https://pub.dev" - source: hosted - version: "4.1.0" rounded_loading_button_plus: dependency: "direct main" description: @@ -1039,14 +991,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.9" - scroll_pos: - dependency: transitive - description: - name: scroll_pos - sha256: cebf602b2dd939de6832bb902ffefb574608d1b84f420b82b381a4007d3c1e1b - url: "https://pub.dev" - source: hosted - version: "0.5.0" share_handler: dependency: "direct main" description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 9f3b9343..a00b0782 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -37,7 +37,7 @@ dependencies: cupertino_icons: ^1.0.2 get: ^4.6.6 styled_widget: ^0.4.0+3 - context_menus: ^1.2.0 + flutter_context_menu: ^0.4.1 json_annotation: ^4.8.1 dio: ^5.2.1 path_provider: ^2.1.4 @@ -47,7 +47,6 @@ dependencies: logger: ^1.4.0 desktop_drop: ^0.4.4 package_info_plus: ^8.1.0 - fluent_ui: ^4.9.2 path: ^1.9.0 badges: ^3.0.3 app_links: ^6.3.3 @@ -72,7 +71,6 @@ dependencies: permission_handler: ^11.3.1 device_info_plus: ^11.1.0 checkable_treeview: ^1.3.1 - contextmenu_plus: ^1.0.1 contentsize_tabbarview: ^0.0.2 win32_registry: ^1.1.5 share_handler: ^0.0.22