From 88ee6d6bb7dc1decfc89289d127e58c9cdb43aaf Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Sun, 7 Jun 2026 19:20:10 +0800 Subject: [PATCH] fix(downloads): prevent subscription readding deleted torrents --- internal/handler/download_client_handler.go | 21 ++++ internal/service/download_clients.go | 20 +++- internal/service/download_clients_test.go | 88 ++++++++++++++ internal/service/downloads.go | 46 +++++++- internal/service/downloads_test.go | 122 ++++++++++++++++++++ internal/service/local_availability.go | 2 +- internal/service/subscription_test.go | 78 +++++++++++++ 7 files changed, 372 insertions(+), 5 deletions(-) diff --git a/internal/handler/download_client_handler.go b/internal/handler/download_client_handler.go index 391bf08..c7b11b6 100644 --- a/internal/handler/download_client_handler.go +++ b/internal/handler/download_client_handler.go @@ -2,6 +2,7 @@ package handler import ( + "context" "encoding/json" "net/http" @@ -55,6 +56,7 @@ func (h *DownloadClientHandler) Create(c *gin.Context) { } ctx := c.Request.Context() + _ = h.svc.Repo.Setting.Set(ctx, "download_clients.managed", "true") // 加密密码 password := req.Password @@ -99,6 +101,7 @@ func (h *DownloadClientHandler) Create(c *gin.Context) { h.log.Warn("failed to hot-add download client", zap.Error(initErr)) } }() + _ = h.svc.Downloads.ReloadConfig(ctx) Success(c, client) } @@ -182,6 +185,7 @@ func (h *DownloadClientHandler) Update(c *gin.Context) { Error(c, http.StatusInternalServerError, ErrInternal, "更新失败") return } + clearLegacyQBitSettingsIfNoDefault(c.Request.Context(), h.svc) // 热更新适配器 go func() { @@ -189,6 +193,7 @@ func (h *DownloadClientHandler) Update(c *gin.Context) { h.log.Warn("failed to hot-update download client", zap.Error(updateErr)) } }() + _ = h.svc.Downloads.ReloadConfig(ctx) Success(c, client) } @@ -197,6 +202,7 @@ func (h *DownloadClientHandler) Update(c *gin.Context) { func (h *DownloadClientHandler) Delete(c *gin.Context) { id := c.Param("id") ctx := c.Request.Context() + _ = h.svc.Repo.Setting.Set(ctx, "download_clients.managed", "true") _, err := h.svc.Repo.DownloadClient.FindByID(ctx, id) if err != nil { @@ -211,10 +217,25 @@ func (h *DownloadClientHandler) Delete(c *gin.Context) { // 热移除 h.svc.DownloadMgr.RemoveClient(id) + clearLegacyQBitSettingsIfNoDefault(c.Request.Context(), h.svc) + _ = h.svc.Downloads.ReloadConfig(ctx) SuccessWithMessage(c, "已删除", nil) } +func clearLegacyQBitSettingsIfNoDefault(ctx context.Context, svc *service.Container) { + if svc == nil || svc.Repo == nil || svc.Repo.DownloadClient == nil || svc.Repo.Setting == nil { + return + } + defaultClient, err := svc.Repo.DownloadClient.FindDefault(ctx) + if err != nil || defaultClient != nil { + return + } + _ = svc.Repo.Setting.Set(ctx, "qbittorrent.url", "") + _ = svc.Repo.Setting.Set(ctx, "qbittorrent.username", "") + _ = svc.Repo.Setting.Set(ctx, "qbittorrent.password", "") +} + // Test 测试下载客户端连接。 func (h *DownloadClientHandler) Test(c *gin.Context) { id := c.Param("id") diff --git a/internal/service/download_clients.go b/internal/service/download_clients.go index e065e6f..1cd7736 100644 --- a/internal/service/download_clients.go +++ b/internal/service/download_clients.go @@ -118,13 +118,18 @@ func (s *DownloadClientService) Update(ctx context.Context, id string, in Downlo if err := s.repo.DownloadClient.Update(ctx, existing); err != nil { return nil, err } + s.clearLegacyQBitConnectionIfNoDefault(ctx) return s.repo.DownloadClient.FindByID(ctx, id) } // Delete removes one client. func (s *DownloadClientService) Delete(ctx context.Context, id string) error { s.markManaged(ctx) - return s.repo.DownloadClient.Delete(ctx, id) + if err := s.repo.DownloadClient.Delete(ctx, id); err != nil { + return err + } + s.clearLegacyQBitConnectionIfNoDefault(ctx) + return nil } // Test verifies that the client's WebUI is reachable. We use @@ -233,3 +238,16 @@ func (s *DownloadClientService) markManaged(ctx context.Context) { } _ = s.repo.Setting.Set(ctx, settingDownloadClientsManaged, "true") } + +func (s *DownloadClientService) clearLegacyQBitConnectionIfNoDefault(ctx context.Context) { + if s == nil || s.repo == nil || s.repo.DownloadClient == nil || s.repo.Setting == nil { + return + } + defaultClient, err := s.repo.DownloadClient.FindDefault(ctx) + if err != nil || defaultClient != nil { + return + } + _ = s.repo.Setting.Set(ctx, "qbittorrent.url", "") + _ = s.repo.Setting.Set(ctx, "qbittorrent.username", "") + _ = s.repo.Setting.Set(ctx, "qbittorrent.password", "") +} diff --git a/internal/service/download_clients_test.go b/internal/service/download_clients_test.go index 35214b1..6c720f0 100644 --- a/internal/service/download_clients_test.go +++ b/internal/service/download_clients_test.go @@ -80,3 +80,91 @@ func TestDownloadClientRejectsUnsupportedHostScheme(t *testing.T) { t.Fatal("expected unsupported scheme error") } } + +func TestDownloadClientDeleteClearsLegacyQBitConnectionWhenNoDefault(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.DownloadClient{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + for key, value := range map[string]string{ + "qbittorrent.url": "http://127.0.0.1:8080", + "qbittorrent.username": "admin", + "qbittorrent.password": "admin", + } { + if err := repos.Setting.Set(t.Context(), key, value); err != nil { + t.Fatal(err) + } + } + svc := NewDownloadClientService(zap.NewNop(), repos) + row, err := svc.Create(t.Context(), DownloadClientInput{ + Name: "qB", + Type: "qbittorrent", + Host: "http://127.0.0.1:8080", + IsDefault: true, + Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + + if err := svc.Delete(t.Context(), row.ID); err != nil { + t.Fatal(err) + } + + for _, key := range []string{"qbittorrent.url", "qbittorrent.username", "qbittorrent.password"} { + value, err := repos.Setting.Get(t.Context(), key) + if err != nil { + t.Fatal(err) + } + if value != "" { + t.Fatalf("%s = %q, want cleared", key, value) + } + } +} + +func TestDownloadClientUpdateClearsLegacyQBitConnectionWhenDefaultDisabled(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.DownloadClient{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + if err := repos.Setting.Set(t.Context(), "qbittorrent.url", "http://127.0.0.1:8080"); err != nil { + t.Fatal(err) + } + svc := NewDownloadClientService(zap.NewNop(), repos) + row, err := svc.Create(t.Context(), DownloadClientInput{ + Name: "qB", + Type: "qbittorrent", + Host: "http://127.0.0.1:8080", + IsDefault: true, + Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + + if _, err := svc.Update(t.Context(), row.ID, DownloadClientInput{ + Name: "qB", + Type: "qbittorrent", + Host: "http://127.0.0.1:8080", + IsDefault: false, + Enabled: false, + }); err != nil { + t.Fatal(err) + } + + value, err := repos.Setting.Get(t.Context(), "qbittorrent.url") + if err != nil { + t.Fatal(err) + } + if value != "" { + t.Fatalf("qbittorrent.url = %q, want cleared", value) + } +} diff --git a/internal/service/downloads.go b/internal/service/downloads.go index df6caa8..bc6ddec 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -357,7 +357,8 @@ func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title st if !downloadTaskBlocksReadd(rows[i].Status) { continue } - if downloadTaskIdentityKey(rows[i].Title) == key { + current := downloadTaskIdentityKey(rows[i].Title) + if current == key || strings.Contains(current, key) || strings.Contains(key, current) { return &rows[i], true } } @@ -366,7 +367,7 @@ func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title st func downloadTaskBlocksReadd(status string) bool { switch strings.ToLower(strings.TrimSpace(status)) { - case "failed", "error", "deleted", "removed", "canceled", "cancelled": + case "failed", "error": return false default: return true @@ -651,7 +652,46 @@ func firstNonEmpty(values ...string) string { // Delete removes a torrent (and optionally its files) from qBittorrent. func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles bool) error { - return d.qb.Delete(ctx, hash, withFiles) + hash = strings.TrimSpace(hash) + if hash == "" { + return errors.New("hash is required") + } + var torrentName string + if live, err := d.qb.List(ctx, ""); err == nil { + for _, torrent := range live { + if strings.EqualFold(torrent.Hash, hash) || len(live) == 1 { + torrentName = torrent.Name + break + } + } + } + if err := d.qb.Delete(ctx, hash, withFiles); err != nil { + return err + } + d.markDownloadTaskDeleted(ctx, torrentName) + delete(d.prevStates, hash) + return nil +} + +func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, torrentName string) { + if d == nil || d.repo == nil || d.repo.DB == nil || strings.TrimSpace(torrentName) == "" { + return + } + rows, err := d.repo.Download.List(ctx) + if err != nil { + return + } + taskByKey := tasksByIdentity(rows) + matched, ok := findMatchingTaskByIdentity(torrentName, taskByKey) + if !ok { + return + } + _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}). + Where("id = ?", matched.ID). + Updates(map[string]any{ + "status": "deleted", + "progress": matched.Progress, + }).Error } // RelocateTorrent moves a torrent's data to a new save directory while keeping diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go index 692fd98..96a915e 100644 --- a/internal/service/downloads_test.go +++ b/internal/service/downloads_test.go @@ -123,6 +123,128 @@ func TestAddDownloadWithMetaSkipsExistingTaskBeforeQBAdd(t *testing.T) { } } +func TestAddDownloadWithMetaSkipsUserDeletedTaskBeforeQBAdd(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": + _, _ = w.Write([]byte(`[]`)) + case "/api/v2/torrents/add": + atomic.AddInt32(&addCalls, 1) + _, _ = w.Write([]byte("Ok.")) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.DownloadTask{}, &model.Media{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + if err := repos.Setting.Set(t.Context(), "qbittorrent.url", qb.URL); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "qbittorrent.username", "admin"); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "qbittorrent.password", "admin"); err != nil { + t.Fatal(err) + } + existing := &model.DownloadTask{ + UserID: "u1", + Source: "qbittorrent", + URL: "https://pt.example/download?id=old&passkey=old", + Title: "User Deleted Show S01E01 1080p", + SavePath: "/downloads/tv", + Status: "deleted", + } + if err := repos.Download.Create(t.Context(), existing); err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "https://pt.example/download?id=new&passkey=new", "/downloads/tv", DownloadTaskMeta{ + Title: "User Deleted Show S01E01 1080p WEB-DL", + }) + if !errors.Is(err, ErrDownloadAlreadyExists) { + t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err) + } + if task == nil || task.ID != existing.ID { + t.Fatalf("task = %#v, want existing task %#v", task, existing) + } + if got := atomic.LoadInt32(&addCalls); got != 0 { + t.Fatalf("qb add calls = %d, want 0", got) + } +} + +func TestDeleteMarksMatchingDownloadTaskDeleted(t *testing.T) { + const hash = "abc123" + const title = "Delete Marker Show S01E01 1080p" + var deleteCalls 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": + _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Delete Marker Show S01E01 1080p","state":"downloading","progress":0.5}]`)) + case "/api/v2/torrents/delete": + atomic.AddInt32(&deleteCalls, 1) + _, _ = w.Write([]byte("Ok.")) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + configureTestDefaultQB(t, repos, qb.URL) + task := &model.DownloadTask{ + UserID: "u1", + Source: "qbittorrent", + URL: "https://pt.example/download?id=1", + Title: title, + SavePath: "/downloads/tv", + Status: "downloading", + Progress: 0.5, + } + if err := repos.Download.Create(t.Context(), task); err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + if err := svc.ReloadConfig(t.Context()); err != nil { + t.Fatal(err) + } + if err := svc.Delete(t.Context(), hash, false); err != nil { + t.Fatal(err) + } + if got := atomic.LoadInt32(&deleteCalls); got != 1 { + t.Fatalf("delete calls = %d, want 1", got) + } + + var updated model.DownloadTask + if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { + t.Fatal(err) + } + if updated.Status != "deleted" { + t.Fatalf("status = %q, want deleted", updated.Status) + } +} + func TestAddDownloadWithMetaSkipsExistingLocalMovieBeforeQBAdd(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { diff --git a/internal/service/local_availability.go b/internal/service/local_availability.go index f9df5b1..87ef52b 100644 --- a/internal/service/local_availability.go +++ b/internal/service/local_availability.go @@ -74,7 +74,7 @@ func LookupLocalAvailability(ctx context.Context, repo *repository.Container, ti like := "%" + query + "%" var rows []model.Media if err := repo.DB.WithContext(ctx). - Where("title LIKE ? OR original_name LIKE ?", like, like). + Where("title LIKE ? OR original_name LIKE ? OR path LIKE ?", like, like, like). Order("season_num asc, episode_num asc, created_at desc"). Limit(2000). Find(&rows).Error; err != nil { diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go index 1793bd5..a5a29e7 100644 --- a/internal/service/subscription_test.go +++ b/internal/service/subscription_test.go @@ -313,6 +313,84 @@ func TestSubscriptionPendingDownloadAvailabilityIncludesQueuedTasks(t *testing.T } } +func TestSubscriptionLocalAvailabilityMatchesMediaPath(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + if err := db.Create(&model.Media{ + Title: "Scraped English Title", + Path: "/media/电视剧/国产剧/凡人修仙传/Season 01/凡人修仙传 - S01E146.mkv", + SeasonNum: 1, + EpisodeNum: 146, + }).Error; err != nil { + t.Fatal(err) + } + sub := &model.Subscription{ + Name: "凡人修仙传 年番", + Filter: "凡人修仙传", + MediaType: "tv", + TotalEpisodes: 146, + } + + availability := SubscriptionLocalAvailability(t.Context(), repos, sub) + if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 146)]; !ok { + t.Fatalf("missing path-matched E146 key: %#v", availability.ExistingEpisodeKeys) + } + results := []SearchResult{ + {Title: "凡人修仙传 年番 - 146 1080p", DownloadURL: "https://pt/download/146", Seeders: 80}, + } + got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) + if len(got) != 0 { + t.Fatalf("selected %#v, want none because path-matched local episode exists", got) + } +} + +func TestSubscriptionPendingDownloadAvailabilityIncludesUserDeletedTasks(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.DownloadTask{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + if err := repos.Download.Create(t.Context(), &model.DownloadTask{ + Source: "qbittorrent", + URL: "magnet:?xt=urn:btih:3333333333333333333333333333333333333333", + Title: "间谍过家家 S01E02 1080p", + SavePath: "/downloads/tv", + Status: "deleted", + }); err != nil { + t.Fatal(err) + } + svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) + sub := &model.Subscription{ + Name: "间谍过家家 自动订阅", + Filter: "间谍过家家", + MediaType: "tv", + SavePath: "/downloads/tv", + TotalEpisodes: 3, + } + + availability := svc.pendingDownloadAvailability(t.Context(), sub) + if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; !ok { + t.Fatalf("missing user-deleted E02 key: %#v", availability.ExistingEpisodeKeys) + } + results := []SearchResult{ + {Title: "间谍过家家 S01E02 1080p WEB-DL", DownloadURL: "https://pt/download/2", Seeders: 80}, + {Title: "间谍过家家 S01E03 1080p WEB-DL", DownloadURL: "https://pt/download/3", Seeders: 70}, + } + got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) + if len(got) != 1 || got[0].Episode != 3 { + t.Fatalf("selected %#v, want only not-yet-deleted episode 3", got) + } +} + func TestSubscriptionPendingDownloadAvailabilityIncludesLiveQBTorrents(t *testing.T) { qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path {