diff --git a/internal/service/download_add_test.go b/internal/service/download_add_test.go index e1f1ee2..4045074 100644 --- a/internal/service/download_add_test.go +++ b/internal/service/download_add_test.go @@ -429,6 +429,64 @@ func TestAddDownloadWithMetaDoesNotDedupRangeAgainstSingleEpisodeTask(t *testing } } +func TestAddDownloadWithMetaDoesNotDedupRangeAgainstSeasonOnlyTask(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) + if err := repos.Download.Create(t.Context(), &model.DownloadTask{ + UserID: "u1", + SubscriptionID: "sub-nanyang", + Source: "qbittorrent", + URL: "https://pt.example/download?id=old-season", + Title: "Archives The Nanyang Mystery 2026 S01 2160p WEB-DL", + SavePath: "/downloads/tv", + Status: "completed", + Progress: 1, + }); 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:abababababababababababababababababababab&dn=Archives+The+Nanyang+Mystery+2026+S01E09-E10", "/downloads/tv", DownloadTaskMeta{ + SubscriptionID: "sub-nanyang", + Title: "Archives The Nanyang Mystery 2026 S01E09-E10 2160p WEB-DL", + }) + if err != nil { + t.Fatalf("AddDownloadWithMeta returned %v, want queued because season-only task does not prove E09-E10 exists", 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 TestDownloadTitleCoversRequestKeepsCompletePackDedup(t *testing.T) { + if !downloadTitleCoversRequest("Archives The Nanyang Mystery 2026 S01 Complete 2160p WEB-DL", "Archives The Nanyang Mystery 2026 S01E09-E10 2160p WEB-DL") { + t.Fatal("complete pack should cover requested episode range") + } + if downloadTitleCoversRequest("Archives The Nanyang Mystery 2026 S01 2160p WEB-DL", "Archives The Nanyang Mystery 2026 S01E09-E10 2160p WEB-DL") { + t.Fatal("season-only title must not cover requested episode range") + } +} + func TestAddDownloadWithMetaScopesSubscriptionDedupBySubscriptionOrSavePath(t *testing.T) { var addCalls int32 qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/download_identity.go b/internal/service/download_identity.go index 295b899..032d89a 100644 --- a/internal/service/download_identity.go +++ b/internal/service/download_identity.go @@ -10,6 +10,7 @@ import ( ) var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`) +var downloadPackTitleToken = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:complete|batch|pack|合集|全集|整季|全季)(?:[\s._-]|$)`) func localAvailabilityTitleCandidates(title string) []string { seen := map[string]struct{}{} @@ -75,6 +76,9 @@ type downloadMediaIdentity struct { func parseDownloadMediaIdentity(name string) downloadMediaIdentity { title, year := CleanQuery(name) + if isSeriesPackTitle(name) { + title = downloadPackTitleToken.ReplaceAllString(title, " ") + } titleKey := normalizeAvailabilityComparable(title) if titleKey == "" { titleKey = normalizeAvailabilityComparable(availabilityQuery(name, "")) @@ -101,7 +105,7 @@ func downloadTitleCoversRequest(existing, requested string) bool { if current.Year > 0 && want.Year > 0 && current.Year != want.Year { return false } - if current.Pack && len(current.Episodes) == 0 { + if downloadIdentityCoversWholeSeason(existing, current) { return true } if len(current.Episodes) == 0 || len(want.Episodes) == 0 { @@ -119,6 +123,13 @@ func downloadTitleCoversRequest(existing, requested string) bool { return true } +func downloadIdentityCoversWholeSeason(title string, identity downloadMediaIdentity) bool { + if !identity.Pack || len(identity.Episodes) > 0 { + return false + } + return seriesPackRE.MatchString(title) +} + func downloadMediaIdentityKey(name string) string { name = strings.ToLower(strings.TrimSpace(name)) if name == "" {