fix(downloads): prevent subscription readding deleted torrents

This commit is contained in:
ShukeBta
2026-06-07 19:20:10 +08:00
parent a5d061afa6
commit 88ee6d6bb7
7 changed files with 372 additions and 5 deletions
@@ -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")
+19 -1
View File
@@ -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", "")
}
+88
View File
@@ -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)
}
}
+43 -3
View File
@@ -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
+122
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+78
View File
@@ -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 {