Skip to content

Commit f655f9f

Browse files
authored
Merge pull request #31 from vedhavyas/upstream/import-retry
make a failed import recoverable
2 parents 81739a8 + 4e19ad9 commit f655f9f

19 files changed

Lines changed: 1137 additions & 167 deletions

cmd/gamarr/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ func main() {
133133
}
134134
return result
135135
},
136+
RetryJob: mgr.RetryJob,
136137
QBReauth: func() bool {
137138
return cfg.HasQBittorrent() && qb.Login()
138139
},

internal/api/api.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,10 @@ func (s *Server) handleDownloads(w http.ResponseWriter, r *http.Request) {
672672
// clients are still returned below.
673673
var torrents []qbit.Torrent
674674
if s.cfg.HasQBittorrent() {
675-
torrents = s.mgr.QB().GetTorrents(s.cfg.QBCategory)
675+
var err error
676+
if torrents, err = s.mgr.QB().GetTorrents(s.cfg.QBCategory); err != nil {
677+
slog.Warn("could not read torrents from the download client", "error", err)
678+
}
676679
}
677680
jobs := s.mgr.Jobs()
678681

@@ -708,6 +711,7 @@ func (s *Server) handleDownloads(w http.ResponseWriter, r *http.Request) {
708711
platf, _ := matchedJob.Data["platform"].(string)
709712
errMsg, _ := matchedJob.Data["error"].(string)
710713
detail, _ := matchedJob.Data["detail"].(string)
714+
infoHash, _ := matchedJob.Data["info_hash"].(string)
711715

712716
downloads = append(downloads, models.DownloadEntry{
713717
Type: "job",
@@ -722,6 +726,7 @@ func (s *Server) handleDownloads(w http.ResponseWriter, r *http.Request) {
722726
Speed: speed,
723727
ETA: t.ETA,
724728
Hash: t.Hash,
729+
InfoHash: infoHash,
725730
})
726731
} else {
727732
status := t.State
@@ -750,6 +755,7 @@ func (s *Server) handleDownloads(w http.ResponseWriter, r *http.Request) {
750755
status, _ := item.Data["status"].(string)
751756
errMsg, _ := item.Data["error"].(string)
752757
detail, _ := item.Data["detail"].(string)
758+
infoHash, _ := item.Data["info_hash"].(string)
753759

754760
downloads = append(downloads, models.DownloadEntry{
755761
Type: "job",
@@ -759,6 +765,7 @@ func (s *Server) handleDownloads(w http.ResponseWriter, r *http.Request) {
759765
JobID: item.ID,
760766
Error: errMsg,
761767
Detail: detail,
768+
InfoHash: infoHash,
762769
})
763770
}
764771

internal/api/handlers_crud_test.go

Lines changed: 134 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import (
1111
"gamarr/internal/config"
1212
"gamarr/internal/db"
1313
"gamarr/internal/models"
14+
"os"
15+
"path/filepath"
16+
"time"
1417
)
1518

1619
// ── Wishlist CRUD ──────────────────────────────────────────────────────────────
@@ -272,39 +275,104 @@ func TestRetryJob(t *testing.T) {
272275
env := newTestEnv(t, nil)
273276
env.jobs.Set("job-err", map[string]interface{}{"status": "error", "title": "Broken"})
274277

275-
t.Run("failed job requeued", func(t *testing.T) {
278+
// A job with no torrent recorded has nothing to import again, and the row
279+
// stays where the UI still offers a button. It used to report success and
280+
// park at "queued", which nothing consumed and nothing could act on.
281+
t.Run("job with no torrent reports failure", func(t *testing.T) {
276282
rr := env.do("POST", "/api/downloads/job-err/retry", "")
277-
wantStatus(t, rr, 200)
278-
if m := decodeMap(t, rr); m["success"] != true {
279-
t.Errorf("retry failed: %v", m)
283+
// A refusal is a client error, and every non-browser consumer reads the
284+
// status before it reads the body.
285+
wantStatus(t, rr, 400)
286+
m := decodeMap(t, rr)
287+
if m["success"] != false {
288+
t.Errorf("retry reported success with no torrent to import: %v", m)
289+
}
290+
if msg, _ := m["error"].(string); !strings.Contains(msg, "no torrent recorded") {
291+
t.Errorf("error = %q, want it to say why", msg)
280292
}
281293
data, _ := env.jobs.Get("job-err")
282-
if data["status"] != "queued" {
283-
t.Errorf("status after retry = %v, want queued", data["status"])
294+
if data["status"] != "error" {
295+
t.Errorf("status after a refused retry = %v, want error", data["status"])
284296
}
285297
})
286298

287299
t.Run("missing job reports failure", func(t *testing.T) {
288300
rr := env.do("POST", "/api/downloads/missing/retry", "")
289-
wantStatus(t, rr, 200)
301+
wantStatus(t, rr, 400)
290302
if m := decodeMap(t, rr); m["success"] != false {
291303
t.Errorf("expected success=false for missing job, got %v", m)
292304
}
293305
})
294306
}
295307

296308
func TestBulkRetryAndCancel(t *testing.T) {
297-
env := newTestEnv(t, nil)
298-
env.jobs.Set("f1", map[string]interface{}{"status": "error", "title": "F1"})
309+
// One fixture records a torrent the client actually holds, so the bulk path
310+
// reaches the retry rather than short-circuiting on a missing hash. Without
311+
// it every request returns at the first check and the endpoint stays green
312+
// under a full revert.
313+
content := t.TempDir()
314+
if err := os.WriteFile(filepath.Join(content, "setup.exe"), []byte("installer"), 0644); err != nil {
315+
t.Fatalf("stage content: %v", err)
316+
}
317+
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
318+
switch r.URL.Path {
319+
case "/api/v2/auth/login":
320+
w.Write([]byte("Ok."))
321+
case "/api/v2/torrents/info":
322+
json.NewEncoder(w).Encode([]map[string]interface{}{{
323+
"name": "F1", "hash": "f1-hash", "progress": 1.0,
324+
"save_path": content, "content_path": content,
325+
}})
326+
default:
327+
w.WriteHeader(200)
328+
}
329+
}))
330+
defer qb.Close()
331+
332+
env := newTestEnv(t, func(c *config.Config) {
333+
c.QBURL = qb.URL
334+
// Without these the import destination is relative and resolves against
335+
// the package source directory.
336+
c.QBSavePath, c.GamesVaultPath, c.GamesRomsPath = t.TempDir(), t.TempDir(), t.TempDir()
337+
})
338+
env.jobs.Set("f1", map[string]interface{}{
339+
"status": "error", "title": "F1", "info_hash": "f1-hash", "is_pc": true,
340+
})
299341
env.jobs.Set("f2", map[string]interface{}{"status": "dead_letter", "title": "F2"})
300342
env.jobs.Set("a1", map[string]interface{}{"status": "downloading", "title": "A1"})
301343

302344
t.Run("retry all failed with empty body", func(t *testing.T) {
303345
rr := env.do("POST", "/api/admin/bulk/retry", "")
304346
wantStatus(t, rr, 200)
305347
m := decodeMap(t, rr)
306-
if m["requested"] != float64(2) || m["succeeded"] != float64(2) {
307-
t.Errorf("bulk retry = %v, want requested=2 succeeded=2", m)
348+
// f1 records a torrent the client holds, so its retry starts; f2 records
349+
// none, so it is refused with a reason.
350+
if m["requested"] != float64(2) || m["succeeded"] != float64(1) {
351+
t.Errorf("bulk retry = %v, want requested=2 succeeded=1", m)
352+
}
353+
results, _ := m["results"].([]interface{})
354+
if len(results) != 2 {
355+
t.Fatalf("results = %v, want one per requested job", m["results"])
356+
}
357+
// The retry runs in a goroutine, so wait for it rather than returning
358+
// while it is still writing.
359+
deadline := time.Now().Add(10 * time.Second)
360+
for time.Now().Before(deadline) {
361+
if job, ok := env.jobs.Get("f1"); ok {
362+
if s, _ := job["status"].(string); s == "completed" {
363+
break
364+
}
365+
}
366+
time.Sleep(5 * time.Millisecond)
367+
}
368+
if job, _ := env.jobs.Get("f1"); job["status"] != "completed" {
369+
t.Errorf("f1 status = %v, want completed", job["status"])
370+
}
371+
for _, r := range results {
372+
row, _ := r.(map[string]interface{})
373+
if msg, _ := row["message"].(string); msg == "" {
374+
t.Errorf("result %v carries no reason", row)
375+
}
308376
}
309377
})
310378

@@ -1020,3 +1088,58 @@ func TestAdminDashboard(t *testing.T) {
10201088
t.Error("dashboard missing system section")
10211089
}
10221090
}
1091+
1092+
// The Retry button is gated on the payload saying the job has a torrent to
1093+
// resolve. `hash` cannot answer that: it is a LIVE torrent matched to the row by
1094+
// fuzzy title, so a release whose name differs from the job title - which is any
1095+
// title carrying a colon, apostrophe or ampersand - loses the button while the
1096+
// backend would retry it fine.
1097+
func TestDownloadsReportTheJobsOwnInfoHash(t *testing.T) {
1098+
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1099+
switch r.URL.Path {
1100+
case "/api/v2/auth/login":
1101+
w.Write([]byte("Ok."))
1102+
case "/api/v2/torrents/info":
1103+
json.NewEncoder(w).Encode([]map[string]interface{}{
1104+
{"name": "Assassins.Creed.Valhalla.REPACK-KaOs", "hash": "ac-hash", "progress": 1.0},
1105+
{"name": "Plain Game", "hash": "pg-hash", "progress": 1.0},
1106+
})
1107+
default:
1108+
w.WriteHeader(200)
1109+
}
1110+
}))
1111+
defer qb.Close()
1112+
1113+
env := newTestEnv(t, func(c *config.Config) { c.QBURL = qb.URL })
1114+
// The first title does not fuzzy-match its torrent name, so it lands in the
1115+
// unmatched branch; the second does, so it lands in the matched one. Both
1116+
// have to carry the hash or one of the two branches silently drops it.
1117+
env.jobs.Set("ac", map[string]interface{}{
1118+
"status": "error", "title": "Assassin's Creed: Valhalla", "info_hash": "ac-hash",
1119+
})
1120+
env.jobs.Set("pg", map[string]interface{}{
1121+
"status": "error", "title": "Plain Game", "info_hash": "pg-hash",
1122+
})
1123+
1124+
rr := env.do("GET", "/api/downloads", "")
1125+
wantStatus(t, rr, 200)
1126+
entries, _ := decodeMap(t, rr)["downloads"].([]interface{})
1127+
1128+
rows := map[string]map[string]interface{}{}
1129+
for _, e := range entries {
1130+
m, _ := e.(map[string]interface{})
1131+
if id, _ := m["job_id"].(string); id != "" {
1132+
rows[id] = m
1133+
}
1134+
}
1135+
for jobID, want := range map[string]string{"ac": "ac-hash", "pg": "pg-hash"} {
1136+
row, ok := rows[jobID]
1137+
if !ok {
1138+
t.Errorf("no entry for job %s: %v", jobID, entries)
1139+
continue
1140+
}
1141+
if got, _ := row["info_hash"].(string); got != want {
1142+
t.Errorf("job %s info_hash = %q, want %q", jobID, got, want)
1143+
}
1144+
}
1145+
}

internal/api/handlers_extra.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,11 @@ func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) {
146146
func (s *Server) handleRetryJob(w http.ResponseWriter, r *http.Request) {
147147
jobID := chi.URLParam(r, "jobID")
148148
ok, msg := s.mgr.RetryJob(jobID)
149-
writeJSON(w, 200, map[string]interface{}{"success": ok, "message": msg})
149+
if !ok {
150+
writeError(w, 400, msg)
151+
return
152+
}
153+
writeJSON(w, 200, map[string]interface{}{"success": true, "message": msg})
150154
}
151155

152156
// ── Import preflight ───────────────────────────────────────────────────────────

internal/download/helpers_test.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,16 @@ func newTestJobs(t *testing.T) *db.JobStore {
6161
type qbitMock struct {
6262
srv *httptest.Server
6363

64-
mu sync.Mutex
65-
torrents []qbit.Torrent
66-
files []qbit.TorrentFile
67-
loginOK bool
68-
addOK bool
69-
addCalls int
70-
deleted []string
71-
deletes []deleteCall
72-
stopped []string
64+
mu sync.Mutex
65+
torrents []qbit.Torrent
66+
files []qbit.TorrentFile
67+
loginOK bool
68+
infoFails bool
69+
addOK bool
70+
addCalls int
71+
deleted []string
72+
deletes []deleteCall
73+
stopped []string
7374
}
7475

7576
// deleteCall records a /torrents/delete request, including whether the client
@@ -107,6 +108,11 @@ func newQbitMock(t *testing.T) *qbitMock {
107108
})
108109
mux.HandleFunc("/api/v2/torrents/info", func(w http.ResponseWriter, r *http.Request) {
109110
q.mu.Lock()
111+
if q.infoFails {
112+
q.mu.Unlock()
113+
w.WriteHeader(500)
114+
return
115+
}
110116
list := make([]qbit.Torrent, len(q.torrents))
111117
copy(list, q.torrents)
112118
q.mu.Unlock()
@@ -146,6 +152,14 @@ func (q *qbitMock) client() *qbit.Client {
146152
return qbit.New(q.srv.URL, "user", "pass")
147153
}
148154

155+
// failInfo makes every torrent listing fail, which is a different answer from
156+
// the client holding nothing.
157+
func (q *qbitMock) failInfo() {
158+
q.mu.Lock()
159+
q.infoFails = true
160+
q.mu.Unlock()
161+
}
162+
149163
func (q *qbitMock) setTorrents(ts []qbit.Torrent) {
150164
q.mu.Lock()
151165
q.torrents = ts

internal/download/main_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@ import (
55
"log/slog"
66
"os"
77
"testing"
8+
"time"
89
)
910

11+
// Captured before TestMain overrides them, so a test can assert what production
12+
// actually ships rather than what the suite substitutes.
13+
var prodImportAttempts, prodImportRetryDelay = importAttempts, importRetryDelay
14+
1015
func TestMain(m *testing.M) {
1116
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
17+
// The import retry delay is a production value measured in seconds. No test
18+
// should sit on it; one that exercises the retry path sets its own.
19+
importRetryDelay = time.Millisecond
1220
os.Exit(m.Run())
1321
}

0 commit comments

Comments
 (0)