mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
fix(downloads): prevent subscription readding deleted torrents
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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", "")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user