From 6534347be60f6186756efcf481d72c4e1f29861c Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:25:42 +0800 Subject: [PATCH] fix subscription download dedup scope --- internal/service/download_add.go | 31 ++++++++++--- internal/service/download_add_test.go | 63 +++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/internal/service/download_add.go b/internal/service/download_add.go index d6eea74..89166e6 100644 --- a/internal/service/download_add.go +++ b/internal/service/download_add.go @@ -49,7 +49,7 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt if !req.meta.AllowExistingLibrary && d.localMediaAlreadyExists(ctx, req.title) { return nil, ErrMediaAlreadyInLibrary } - if existing, ok := d.findExistingDownloadTask(ctx, req.title, strings.TrimSpace(req.meta.SubscriptionID) != ""); ok { + if existing, ok := d.findExistingDownloadTask(ctx, req); ok { return existing, ErrDownloadAlreadyExists } _ = d.ReloadConfig(ctx) @@ -228,8 +228,8 @@ func localMediaRowSeasonEpisode(row model.Media) (int, int) { return rowSeason, rowEpisode } -func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title string, allowDeletedReadd bool) (*model.DownloadTask, bool) { - key := downloadTaskIdentityKey(title) +func (d *DownloadService) findExistingDownloadTask(ctx context.Context, req downloadAddRequest) (*model.DownloadTask, bool) { + key := downloadTaskIdentityKey(req.title) if key == "" || d == nil || d.repo == nil || d.repo.Download == nil { return nil, false } @@ -237,22 +237,43 @@ func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title st if err != nil { return nil, false } + subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) for i := range rows { - if allowDeletedReadd { + if subscriptionID != "" { if !downloadTaskBlocksReadd(rows[i].Status) { continue } + if !downloadTaskInSubscriptionScope(rows[i], req) { + continue + } } else if !downloadTaskBlocksDuplicate(rows[i].Status) { continue } current := downloadTaskIdentityKey(rows[i].Title) - if downloadTitleCoversRequest(rows[i].Title, title) || current == key { + if downloadTitleCoversRequest(rows[i].Title, req.title) || current == key { return &rows[i], true } } return nil, false } +func downloadTaskInSubscriptionScope(row model.DownloadTask, req downloadAddRequest) bool { + subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) + if subscriptionID == "" { + return true + } + rowSubscriptionID := strings.TrimSpace(row.SubscriptionID) + if rowSubscriptionID != "" { + return rowSubscriptionID == subscriptionID + } + rowSavePath := strings.TrimSpace(row.SavePath) + requestSavePath := strings.TrimSpace(req.savePath) + if rowSavePath == "" || requestSavePath == "" { + return false + } + return sameOrChildPath(rowSavePath, requestSavePath) || sameOrChildPath(requestSavePath, rowSavePath) +} + func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, title string) bool { query := downloadTaskIdentityKey(title) if query == "" { diff --git a/internal/service/download_add_test.go b/internal/service/download_add_test.go index f98dd79..e1f1ee2 100644 --- a/internal/service/download_add_test.go +++ b/internal/service/download_add_test.go @@ -429,6 +429,69 @@ func TestAddDownloadWithMetaDoesNotDedupRangeAgainstSingleEpisodeTask(t *testing } } +func TestAddDownloadWithMetaScopesSubscriptionDedupBySubscriptionOrSavePath(t *testing.T) { + var addCalls int32 + qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/auth/login": + _, _ = w.Write([]byte("Ok.")) + case "/api/v2/torrents/info": + http.Error(w, "temporary list unavailable", http.StatusInternalServerError) + case "/api/v2/torrents/add": + atomic.AddInt32(&addCalls, 1) + _, _ = w.Write([]byte("Ok.")) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) + repos := repository.New(db) + configureTestDefaultQB(t, repos, qb.URL) + for _, existing := range []model.DownloadTask{ + { + UserID: "u1", + SubscriptionID: "other-subscription", + Source: "qbittorrent", + URL: "https://pt.example/download?id=old-sub", + Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", + SavePath: "/downloads/other", + Status: "completed", + Progress: 1, + }, + { + UserID: "u1", + Source: "qbittorrent", + URL: "https://pt.example/download?id=old-manual", + Title: "Archives The Nanyang Mystery 2026 S01E09-S01E10 2160p WEB-DL", + SavePath: "/downloads/archive", + Status: "completed", + Progress: 1, + }, + } { + row := existing + if err := repos.Download.Create(t.Context(), &row); err != nil { + t.Fatal(err) + } + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:efefefefefefefefefefefefefefefefefefefef&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads/tv", DownloadTaskMeta{ + SubscriptionID: "current-subscription", + Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", + }) + if err != nil { + t.Fatalf("AddDownloadWithMeta returned %v, want queued because old task is outside current subscription scope", err) + } + if task == nil { + t.Fatal("task = nil, want queued task") + } + if got := atomic.LoadInt32(&addCalls); got != 1 { + t.Fatalf("qb add calls = %d, want 1", got) + } +} + func TestAddDownloadWithMetaAutoClassifiesSavePathAndQBitCategory(t *testing.T) { var addCalls int32 var gotSavePath string